diff --git a/.agents/skills/DeskcommCRM/SKILL.md b/.agents/skills/DeskcommCRM/SKILL.md index 6670a6236..c87218e4a 100644 --- a/.agents/skills/DeskcommCRM/SKILL.md +++ b/.agents/skills/DeskcommCRM/SKILL.md @@ -1,79 +1,43 @@ -```markdown -# DeskcommCRM Development Patterns +# DeskcommCRM — doutrina de código -> Auto-generated skill from repository analysis +> A fonte da verdade é o `CLAUDE.md` da raiz, lido do `origin/main` e não de um resumo. Este +> arquivo existe para te fazer abri-lo na hora certa e para carregar as três regras que mais +> custam caro quando esquecidas. -## Overview -This skill teaches the core development patterns and conventions used in the DeskcommCRM TypeScript codebase. It covers file organization, code style, commit message standards, and testing patterns, providing practical examples and command suggestions to streamline your workflow. +## 1. Leia antes de escrever -## Coding Conventions +| arquivo | quando | +|---|---| +| `CLAUDE.md` | **sempre**, antes de qualquer código — contém a Definition of Done, que muda | +| `VISION.md` | antes de decidir escopo, ou de dizer não a uma feature | +| `docs/doctrine/` | ao mexer em canal, agente, ou peça que se conecte a outra | +| `ARCHITECTURE.md` | para a visão de uma página | -### File Naming -- Use **snake_case** for all file names. - - Example: - ``` - user_profile.ts - customer_data_manager.ts - ``` +**Não confie em resumo de doutrina — nem neste arquivo.** Abra o `CLAUDE.md`. -### Import Style -- Use **relative imports** for referencing modules. - - Example: - ```typescript - import { getUser } from './user_utils'; - import { Customer } from '../models/customer'; - ``` +## 2. As três que mais custam -### Export Style -- Use **named exports** for all modules. - - Example: - ```typescript - // In user_utils.ts - export function getUser(id: string) { ... } - export const USER_ROLE = 'admin'; - ``` +**Multi-tenancy.** Toda tabela tenant-aware leva `organization_id uuid not null` e RLS com policy +`tenant_isolation__all` via `fn_user_org_ids()`. Service role bypassa RLS — handler que o +usa filtra `organization_id` **manualmente**, resolvido de fonte confiável (cookie, JWT, segredo de +webhook, token de path), **nunca do body**. No backend é sempre `getUser()`, nunca `getSession()`. -### Commit Messages -- Follow **conventional commits** with the `fix` prefix for bug fixes. - - Example: - ``` - fix: correct customer email validation logic - ``` +**Schema sai em tripla.** Arquivo em `supabase/migrations/`, apêndice **idempotente** no +`supabase/baseline.sql`, e linha no `MANIFEST.md`. O kit self-host aplica **só o baseline** — o que +não chega lá não chega em quem instalou numa VPS, que é o cliente que paga. -## Workflows +**Nenhuma feature nomeia um provider.** Provider vive em `lib/channels/`. `pnpm lint:channels` é +catraca com lista de dívida. -### Bug Fix Workflow -**Trigger:** When you need to fix a bug in the codebase -**Command:** `/fix-bug` +## 3. Convenção de arquivo — o oposto do que a versão anterior ensinava -1. Identify the bug and create a new branch. -2. Make code changes following the coding conventions. -3. Write or update relevant tests (`*.test.*` files). -4. Commit your changes using the `fix:` prefix and a concise description. - - Example: `fix: resolve crash on empty customer list` -5. Push your branch and open a pull request. +A versão anterior deste arquivo era gerada automaticamente por análise de repositório e ensinava +`snake_case` para nome de arquivo e imports relativos. **O repo usa o oposto**: `kebab-case` para +nome de arquivo (`user-profile.ts`, não `user_profile.ts`) e o alias `@/` para import +(`import { getUser } from "@/lib/auth/get-user"`, não `./user_utils`). Nenhum comando de fluxo +(`/fix-bug`, `/add-module`) existe neste repo — não invente um. -### Adding a New Module -**Trigger:** When you need to add a new feature or module -**Command:** `/add-module` +## 4. Antes de dizer "pronto" -1. Create new files using snake_case naming. -2. Use relative imports to connect new and existing modules. -3. Export functions and constants using named exports. -4. Write corresponding tests in `*.test.*` files. -5. Commit with an appropriate message (e.g., `feat: add customer notes module`). - -## Testing Patterns - -- Test files follow the `*.test.*` naming pattern. - - Example: `user_utils.test.ts` -- The testing framework is not explicitly specified; check existing test files for structure. -- Place tests alongside or near the modules they test. -- Ensure all new features and bug fixes are covered by tests. - -## Commands -| Command | Purpose | -|--------------|-----------------------------------------| -| /fix-bug | Start the bug fix workflow | -| /add-module | Start the new module addition workflow | -``` +Verde de teste não é prova de comportamento. Sabote a linha que você corrigiu e confirme que a +suíte fica **vermelha**. Declare o que **não** mediu. diff --git a/.ai/AI_BOOTSTRAP.md b/.ai/AI_BOOTSTRAP.md new file mode 100644 index 000000000..9df11ee4a --- /dev/null +++ b/.ai/AI_BOOTSTRAP.md @@ -0,0 +1,71 @@ +# AI_BOOTSTRAP — leia isto primeiro + +Porta de entrada de qualquer agente de código neste repositório (Claude Code, Codex, Cursor, +Copilot, Amp). Não é doutrina: é o roteador. **Dois minutos aqui evitam o dano típico.** + +## 1. Ordem de leitura + +| # | Arquivo | Quando | +| --- | --------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| 1 | **este** | Sempre, antes de qualquer coisa | +| 2 | [`CLAUDE.md`](../CLAUDE.md) | Antes de escrever a primeira linha de código. É a doutrina, e vence qualquer outro documento | +| 3 | [`AGENTS.md`](../AGENTS.md) | Se você não é o Claude Code: mesmo contrato, forma portável | +| 4 | [`docs/index.md`](../docs/index.md) | Quando precisar de detalhe de um assunto — índice dos docs | +| 5 | [`docs/current-state.md`](../docs/current-state.md) | Antes de estimar, prometer ou dizer que algo existe | + +## 2. A regra que governa todas as outras + +**O repositório mede; a prosa descreve.** Código, `package.json`, workflows e `gh api` são a fonte +do estado — documento é sempre uma foto que pode ter envelhecido. Onde os dois discordarem, o +documento está errado: corrija-o no mesmo PR. + +Por isso a documentação de autoridade deste repo evita número volátil e prefere comando. Quando +você for escrever uma afirmação de estado, escreva o comando que a prova. + +## 3. O que este produto é (e por que isso muda seu trabalho) + +CRM de vendas open source com agentes de IA e WhatsApp, **distribuído como código e instalado numa +VPS pelo próprio cliente**. Quem instala é o usuário final. Consequências diretas: + +- Mudança que funciona na sua máquina e quebra no clone fresco é **bug de produto**. +- Schema só chega ao cliente se entrar em `supabase/baseline.sql` — migration solta não chega. +- O nome do produto é revendido: **nunca escreva "Deskcomm" em código que alcança o usuário**. +- A tela é o produto. `curl` diagnostica; ele não prova experiência de usuário. + +## 4. As dez regras que evitam dano + +1. **Toda tabela tenant-aware tem `organization_id` + RLS.** Service role bypassa RLS — quem usa o + admin client filtra `organization_id` à mão, de fonte confiável, **nunca do body**. +2. **`getUser()` no backend, nunca `getSession()`.** +3. **Zod em todo input externo** (body, webhook, env). +4. **`ok()` / `fail()` de `lib/api/wrappers.ts`** — nunca monte `Response` na mão, nunca deixe + `throw` cru na borda. +5. **Mudança de schema = migration versionada + apêndice idempotente no baseline + linha no + MANIFEST.** Os três juntos, ou o self-hoster não recebe. +6. **Trigger Postgres nunca faz HTTP** — emite linha em `event_log`, o worker dispara o efeito. +7. **API key nunca em query string**; bearer no banco só como hash SHA256. +8. **`console.log` é proibido** em código merged — use `lib/logger.ts`. +9. **Tela nova precisa de porta** em `lib/navigation/registry.ts`: existir e ser alcançável são + coisas diferentes. +10. **Nunca invente regra de negócio, SLA ou número.** Se não está escrito, diga que não está e + pergunte. + +## 5. Antes de começar + +```bash +git fetch origin && git merge origin/main # branch atrasada é a causa nº 1 de retrabalho +pnpm install +``` + +Nunca use `reset --hard` ou force push para "atualizar" uma branch, e nunca toque em worktree sujo +que não é seu. + +## 6. Antes de dizer "pronto" + +```bash +pnpm gov:verify # typecheck + lint + lint:channels + test:unit +``` + +Verde aqui **não** é prova completa: `gov:verify` não roda `test:db` (schema/RLS), `test:e2e` (UI) +nem `test:shell` (kit de instalação). Rode o que sua mudança exige e cumpra a Definition of Done de +[`CLAUDE.md`](../CLAUDE.md) — ela é a régua de conclusão, item por item. diff --git a/.claude/ecc-tools.json b/.claude/ecc-tools.json deleted file mode 100644 index e529dbb8a..000000000 --- a/.claude/ecc-tools.json +++ /dev/null @@ -1,227 +0,0 @@ -{ - "version": "1.3", - "schemaVersion": "1.0", - "generatedBy": "ecc-tools", - "generatedAt": "2026-07-06T20:13:26.688Z", - "repo": "https://github.com/melgarafael/DeskcommCRM", - "referenceSetReadiness": { - "score": 0, - "present": 0, - "total": 7, - "items": [ - { - "id": "deep-analyzer-corpus", - "label": "Deep analyzer corpus", - "status": "missing", - "evidence": [], - "recommendation": "Add analyzer fixture, golden, benchmark, or reference-set files that can catch analyzer regressions." - }, - { - "id": "rag-evaluator", - "label": "RAG/evaluator comparison", - "status": "missing", - "evidence": [], - "recommendation": "Add retrieval or evaluator reference-set comparison fixtures with expected ranking behavior." - }, - { - "id": "pr-salvage", - "label": "PR salvage/review corpus", - "status": "missing", - "evidence": [], - "recommendation": "Add stale-PR, review-thread, reopen-flow, or salvage reference cases for queue cleanup automation." - }, - { - "id": "discussion-triage", - "label": "Discussion triage corpus", - "status": "missing", - "evidence": [], - "recommendation": "Add public discussion triage fixtures, golden cases, or reference sets for informational, answered, and no-response classifications." - }, - { - "id": "harness-compatibility", - "label": "Harness compatibility", - "status": "missing", - "evidence": [], - "recommendation": "Add cross-harness, adapter-compliance, or harness-audit evidence for Claude, Codex, OpenCode, Zed, dmux, and agent surfaces." - }, - { - "id": "security-evidence", - "label": "Security evidence", - "status": "missing", - "evidence": [], - "recommendation": "Attach security evidence such as SBOMs, SARIF, audit reports, or AgentShield evidence packs." - }, - { - "id": "ci-failure-mode", - "label": "CI failure-mode evidence", - "status": "missing", - "evidence": [], - "recommendation": "Add captured CI failure logs, dry-run fixtures, or troubleshooting docs for common workflow failure modes." - } - ] - }, - "profiles": { - "requested": "core", - "recommended": "core", - "effective": "core", - "requestedAlias": "core", - "recommendedAlias": "core", - "effectiveAlias": "core" - }, - "requestedProfile": "core", - "profile": "core", - "recommendedProfile": "core", - "effectiveProfile": "core", - "tier": "free", - "requestedComponents": [ - "repo-baseline" - ], - "selectedComponents": [ - "repo-baseline" - ], - "requestedAddComponents": [], - "requestedRemoveComponents": [], - "blockedRemovalComponents": [], - "tierFilteredComponents": [], - "requestedRootPackages": [ - "runtime-core" - ], - "selectedRootPackages": [ - "runtime-core" - ], - "requestedPackages": [ - "runtime-core" - ], - "requestedAddPackages": [], - "requestedRemovePackages": [], - "selectedPackages": [ - "runtime-core" - ], - "packages": [ - "runtime-core" - ], - "blockedRemovalPackages": [], - "tierFilteredRootPackages": [], - "tierFilteredPackages": [], - "conflictingPackages": [], - "dependencyGraph": { - "runtime-core": [] - }, - "resolutionOrder": [ - "runtime-core" - ], - "requestedModules": [ - "runtime-core" - ], - "selectedModules": [ - "runtime-core" - ], - "modules": [ - "runtime-core" - ], - "managedFiles": [ - ".claude/skills/DeskcommCRM/SKILL.md", - ".agents/skills/DeskcommCRM/SKILL.md", - ".agents/skills/DeskcommCRM/agents/openai.yaml", - ".claude/identity.json", - ".codex/config.toml", - ".codex/AGENTS.md", - ".codex/agents/explorer.toml", - ".codex/agents/reviewer.toml", - ".codex/agents/docs-researcher.toml", - ".claude/homunculus/instincts/inherited/DeskcommCRM-instincts.yaml" - ], - "packageFiles": { - "runtime-core": [ - ".claude/skills/DeskcommCRM/SKILL.md", - ".agents/skills/DeskcommCRM/SKILL.md", - ".agents/skills/DeskcommCRM/agents/openai.yaml", - ".claude/identity.json", - ".codex/config.toml", - ".codex/AGENTS.md", - ".codex/agents/explorer.toml", - ".codex/agents/reviewer.toml", - ".codex/agents/docs-researcher.toml", - ".claude/homunculus/instincts/inherited/DeskcommCRM-instincts.yaml" - ] - }, - "moduleFiles": { - "runtime-core": [ - ".claude/skills/DeskcommCRM/SKILL.md", - ".agents/skills/DeskcommCRM/SKILL.md", - ".agents/skills/DeskcommCRM/agents/openai.yaml", - ".claude/identity.json", - ".codex/config.toml", - ".codex/AGENTS.md", - ".codex/agents/explorer.toml", - ".codex/agents/reviewer.toml", - ".codex/agents/docs-researcher.toml", - ".claude/homunculus/instincts/inherited/DeskcommCRM-instincts.yaml" - ] - }, - "files": [ - { - "moduleId": "runtime-core", - "path": ".claude/skills/DeskcommCRM/SKILL.md", - "description": "Repository-specific Claude Code skill generated from git history." - }, - { - "moduleId": "runtime-core", - "path": ".agents/skills/DeskcommCRM/SKILL.md", - "description": "Codex-facing copy of the generated repository skill." - }, - { - "moduleId": "runtime-core", - "path": ".agents/skills/DeskcommCRM/agents/openai.yaml", - "description": "Codex skill metadata so the repo skill appears cleanly in the skill interface." - }, - { - "moduleId": "runtime-core", - "path": ".claude/identity.json", - "description": "Suggested identity.json baseline derived from repository conventions." - }, - { - "moduleId": "runtime-core", - "path": ".codex/config.toml", - "description": "Repo-local Codex MCP and multi-agent baseline aligned with ECC defaults." - }, - { - "moduleId": "runtime-core", - "path": ".codex/AGENTS.md", - "description": "Codex usage guide that points at the generated repo skill and workflow bundle." - }, - { - "moduleId": "runtime-core", - "path": ".codex/agents/explorer.toml", - "description": "Read-only explorer role config for Codex multi-agent work." - }, - { - "moduleId": "runtime-core", - "path": ".codex/agents/reviewer.toml", - "description": "Read-only reviewer role config focused on correctness and security." - }, - { - "moduleId": "runtime-core", - "path": ".codex/agents/docs-researcher.toml", - "description": "Read-only docs researcher role config for API verification." - }, - { - "moduleId": "runtime-core", - "path": ".claude/homunculus/instincts/inherited/DeskcommCRM-instincts.yaml", - "description": "Continuous-learning instincts derived from repository patterns." - } - ], - "workflows": [], - "adapters": { - "claudeCode": { - "skillPath": ".claude/skills/DeskcommCRM/SKILL.md", - "identityPath": ".claude/identity.json", - "commandPaths": [] - }, - "codex": { - "configPath": ".codex/config.toml", - "agentsGuidePath": ".codex/AGENTS.md", - "skillPath": ".agents/skills/DeskcommCRM/SKILL.md" - } - } -} \ No newline at end of file diff --git a/.claude/homunculus/instincts/inherited/DeskcommCRM-instincts.yaml b/.claude/homunculus/instincts/inherited/DeskcommCRM-instincts.yaml deleted file mode 100644 index 7a011a4f0..000000000 --- a/.claude/homunculus/instincts/inherited/DeskcommCRM-instincts.yaml +++ /dev/null @@ -1,300 +0,0 @@ -# Instincts generated from https://github.com/melgarafael/DeskcommCRM -# Generated: 2026-07-06T20:13:47.311Z -# Version: 2.0 -# NOTE: This file supplements (does not replace) any existing curated instincts. -# High-confidence manually curated instincts should be preserved alongside these. - ---- -id: DeskcommCRM-commit-conventional -trigger: "when writing a commit message" -confidence: 0.85 -domain: git -source: repo-analysis -source_repo: https://github.com/melgarafael/DeskcommCRM ---- - -# DeskcommCRM Commit Conventional - -## Action - -Use conventional commit format with prefixes: fix - -## Evidence - -- 1 commits analyzed -- Detected conventional commit pattern -- Examples: fix(supabase): compara channel_sessions.status contra WORKING (maiúsculo) - ---- -id: DeskcommCRM-commit-length -trigger: "when writing a commit message" -confidence: 0.6 -domain: git -source: repo-analysis -source_repo: https://github.com/melgarafael/DeskcommCRM ---- - -# DeskcommCRM Commit Length - -## Action - -Write moderate-length commit messages (~73 characters) - -## Evidence - -- Average commit message length: 73 chars -- Based on 1 commits - ---- -id: DeskcommCRM-naming-files -trigger: "when creating a new file" -confidence: 0.8 -domain: code-style -source: repo-analysis -source_repo: https://github.com/melgarafael/DeskcommCRM ---- - -# DeskcommCRM Naming Files - -## Action - -Use snake_case naming convention - -## Evidence - -- Analyzed file naming patterns in repository -- Dominant pattern: snake_case - ---- -id: DeskcommCRM-import-relative -trigger: "when importing modules" -confidence: 0.75 -domain: code-style -source: repo-analysis -source_repo: https://github.com/melgarafael/DeskcommCRM ---- - -# DeskcommCRM Import Relative - -## Action - -Use relative imports for project files - -## Evidence - -- Import analysis shows relative import pattern -- Example: import { x } from '../lib/x' - ---- -id: DeskcommCRM-export-style -trigger: "when exporting from a module" -confidence: 0.7 -domain: code-style -source: repo-analysis -source_repo: https://github.com/melgarafael/DeskcommCRM ---- - -# DeskcommCRM Export Style - -## Action - -Prefer named exports - -## Evidence - -- Export pattern analysis -- Dominant style: named - ---- -id: DeskcommCRM-test-separate -trigger: "when writing tests" -confidence: 0.8 -domain: testing -source: repo-analysis -source_repo: https://github.com/melgarafael/DeskcommCRM ---- - -# DeskcommCRM Test Separate - -## Action - -Place tests in the tests/ or __tests__/ directory, mirroring src structure - -## Evidence - -- Separate test directory pattern detected -- Tests live in dedicated test folders - ---- -id: deskcommcrm-instinct-file-naming -trigger: "When creating a new file" -confidence: 0.9 -domain: code-style -source: repo-analysis -source_repo: melgarafael/DeskcommCRM ---- - -# Deskcommcrm Instinct File Naming - -## Action - -Name the file using snake_case - -## Evidence - -- Pattern in namingConventions.files: snake_case - ---- -id: deskcommcrm-instinct-function-naming -trigger: "When defining a new function" -confidence: 0.9 -domain: code-style -source: repo-analysis -source_repo: melgarafael/DeskcommCRM ---- - -# Deskcommcrm Instinct Function Naming - -## Action - -Name the function using camelCase - -## Evidence - -- Pattern in namingConventions.functions: camelCase - ---- -id: deskcommcrm-instinct-class-naming -trigger: "When defining a new class" -confidence: 0.9 -domain: code-style -source: repo-analysis -source_repo: melgarafael/DeskcommCRM ---- - -# Deskcommcrm Instinct Class Naming - -## Action - -Name the class using PascalCase - -## Evidence - -- Pattern in namingConventions.classes: PascalCase - ---- -id: deskcommcrm-instinct-constant-naming -trigger: "When defining a new constant" -confidence: 0.9 -domain: code-style -source: repo-analysis -source_repo: melgarafael/DeskcommCRM ---- - -# Deskcommcrm Instinct Constant Naming - -## Action - -Name the constant using SCREAMING_SNAKE_CASE - -## Evidence - -- Pattern in namingConventions.constants: SCREAMING_SNAKE_CASE - ---- -id: deskcommcrm-instinct-import-style -trigger: "When importing modules" -confidence: 0.9 -domain: code-style -source: repo-analysis -source_repo: melgarafael/DeskcommCRM ---- - -# Deskcommcrm Instinct Import Style - -## Action - -Use relative import paths - -## Evidence - -- Pattern in importStyle: relative - ---- -id: deskcommcrm-instinct-export-style -trigger: "When exporting modules or functions" -confidence: 0.9 -domain: code-style -source: repo-analysis -source_repo: melgarafael/DeskcommCRM ---- - -# Deskcommcrm Instinct Export Style - -## Action - -Use named exports - -## Evidence - -- Pattern in exportStyle: named - ---- -id: deskcommcrm-instinct-git-commit-prefix -trigger: "When writing a commit message for a bug fix" -confidence: 0.9 -domain: git -source: repo-analysis -source_repo: melgarafael/DeskcommCRM ---- - -# Deskcommcrm Instinct Git Commit Prefix - -## Action - -Prefix the commit message with 'fix' following conventional commit format - -## Evidence - -- Pattern in commits.prefixes: fix -- Seen in commit: fix(supabase): compara channel_sessions.status contra WORKING (maiúsculo) - ---- -id: deskcommcrm-instinct-git-commit-format -trigger: "When writing a commit message" -confidence: 0.9 -domain: git -source: repo-analysis -source_repo: melgarafael/DeskcommCRM ---- - -# Deskcommcrm Instinct Git Commit Format - -## Action - -Use the conventional commit format: (): - -## Evidence - -- Pattern in commits.type: conventional -- Seen in commit: fix(supabase): compara channel_sessions.status contra WORKING (maiúsculo) - ---- -id: deskcommcrm-instinct-test-location -trigger: "When adding or updating tests" -confidence: 0.8 -domain: testing -source: repo-analysis -source_repo: melgarafael/DeskcommCRM ---- - -# Deskcommcrm Instinct Test Location - -## Action - -Place test files in a separate directory from source code - -## Evidence - -- Pattern in architecture.testLocation: separate - diff --git a/.env.example b/.env.example index 2ac6f03fc..069d5ceec 100644 --- a/.env.example +++ b/.env.example @@ -236,6 +236,12 @@ JOB_QUEUE_RETENTION_DAYS=90 # 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 um evento TERMINAL (done/dead) do bus interno +# (event_log) é apagado. pending/processing NUNCA é tocado — inclusive +# event_type sem consumer registrado, que por isso nunca chegam a done/dead +# (achado documentado na migration 0172). Piso de 7 dias, mesma justificativa +# da JOB_QUEUE_RETENTION_DAYS acima. +EVENT_LOG_RETENTION_DAYS=90 # Chave LLM de plataforma (fallback quando a org não tem credencial BYOK # cadastrada em /app/ai/credentials). Opcional com BYOK. diff --git a/.env.hostgator.example b/.env.hostgator.example index 4facf0c21..c4f72bdbe 100644 --- a/.env.hostgator.example +++ b/.env.hostgator.example @@ -28,16 +28,16 @@ # Os valores abaixo são o PISO seguro para quem preenche à mão (stable = a # última release). O install.sh sobrescreve com o NÚMERO da versão, que é o que # uma instalação de verdade usa — ver a regra de ouro na doutrina. -APP_IMAGE=ghcr.io/melgarafael/deskcommcrm:stable # troque pelo seu fork se publicar o seu +APP_IMAGE=ghcr.io/maugarciasa/deskcommcrm:stable APP_PULL_POLICY=always # O worker (agente de IA 24/7) e o scheduler (crons) acompanham a versão do app. # Até 2026-08-13 eles não tinham imagem: eram compilados na sua VPS no install e # nenhum update.sh jamais os reconstruía — o agente ficava congelado no código # do dia da instalação. Deixe-os na mesma versão do APP_IMAGE acima. -WORKER_IMAGE=ghcr.io/melgarafael/deskcomm-worker:stable +WORKER_IMAGE=ghcr.io/maugarciasa/deskcomm-worker:stable WORKER_PULL_POLICY=always -SCHEDULER_IMAGE=ghcr.io/melgarafael/deskcomm-scheduler:stable +SCHEDULER_IMAGE=ghcr.io/maugarciasa/deskcomm-scheduler:stable SCHEDULER_PULL_POLICY=always # ----------------------------------------------------------------------------- @@ -199,6 +199,14 @@ SENTRY_DSN= OWNER_EMAIL=voce@seudominio.com.br OWNER_PASSWORD= # senha forte do primeiro admin +# ----------------------------------------------------------------------------- +# 13) Backup automático (banco + sessões do WhatsApp) +# ----------------------------------------------------------------------------- +# O install.sh (e o update.sh) já agenda sozinho um cron diário de +# `hostgator-setup-kit/backup.sh` no HOST — não precisa mexer aqui. O único +# knob é a HORA (0-23); o minuto é sempre :00. Vazio = 03h (fora do expediente). +BACKUP_CRON_HOUR= + # ----------------------------------------------------------------------------- # AGENT ENGINE (fusão Vendaval) — worker 24/7 do agente SDR (serviço `worker`) # ----------------------------------------------------------------------------- diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..0d49e876c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: codeql + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Segunda 06h UTC — pega vulnerabilidade nova em código que não mudou. + - cron: "0 6 * * 1" + +# contents: read no topo pelo mesmo motivo do ci.yml: os dois jobs herdariam o +# default do repositório se não fosse fixado aqui. security-events: write só +# entra por job (analyze), que é o único passo que precisa escrever o +# resultado do scan — menor escopo que precisa, mesma regra do publish-image.yml. +permissions: + contents: read + +jobs: + analyze: + name: analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: ["javascript-typescript"] + steps: + - uses: actions/checkout@v7 + + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/AGENTS.md b/AGENTS.md index 2e70bbc9c..3b36a8f3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,53 +1,59 @@ # AGENTS.md — DeskcommCRM -> Contrato para **qualquer** agente de código (Codex, Cursor, Copilot, Amp, Claude Code). -> Este arquivo é o núcleo portável. A **doutrina completa e não-negociável vive em -> [`CLAUDE.md`](CLAUDE.md)** — leia-o antes de tocar em código. Aqui está o mínimo -> para não causar dano. +> Contrato portável para **qualquer** agente de código (Codex, Cursor, Copilot, Amp, Claude Code). +> A doutrina completa e não-negociável vive em [`CLAUDE.md`](CLAUDE.md) — leia-o antes de tocar em +> código. Aqui está o mínimo para não causar dano. +> +> Precedência: repositório (código, `package.json`, workflows) > `CLAUDE.md` > este arquivo > +> [`docs/index.md`](docs/index.md). Toda afirmação daqui vem com o comando que a mede — **rode o +> comando, não confie no texto**. --- ## Objetivo do projeto -Sistema operacional de vendas open source com agentes de IA nativos, multi-nicho, -WhatsApp como canal primário (via WAHA). Multi-tenant com RLS desde o dia 1, LGPD -nativa. Monetização = self-host em VPS, não assinatura. Posicionamento: [`VISION.md`](VISION.md). +Sistema operacional de vendas open source com agentes de IA nativos, multi-nicho, WhatsApp como +canal primário (via WAHA). Multi-tenant com RLS desde o dia 1, LGPD nativa. Monetização = self-host +em VPS, não assinatura. Posicionamento: [`VISION.md`](VISION.md). -**Consequência que muda como você trabalha:** o produto é distribuído como código. -Quem instala numa VPS **é** o usuário. Uma mudança que funciona na máquina do dev e -quebra no clone fresco é um bug de produto, não um detalhe de ambiente. +**Consequência que muda como você trabalha:** o produto é distribuído como código. Quem instala numa +VPS **é** o usuário. Uma mudança que funciona na máquina do dev e quebra no clone fresco é um bug de +produto, não um detalhe de ambiente. ## Stack (CONFIRMADO em `package.json`) -Next.js 16 (App Router) · React 19 · TypeScript 6 estrito · Tailwind 3 · -shadcn/ui · Supabase (Postgres + Auth + Realtime + Storage) · Upstash Redis · -Vercel AI Gateway (`@ai-sdk/anthropic|openai|google`) · WAHA Plus (engine NOWEB) · -Zod 4 · Vitest 4 · Playwright 1 · Sentry 10. +Next.js 16 (App Router) · React 19 · TypeScript 6 estrito · Tailwind 3 · shadcn/ui · +Supabase (Postgres + Auth + Realtime + Storage) · Upstash Redis · Vercel AI Gateway +(`@ai-sdk/anthropic|openai|google`) · WAHA Plus (engine NOWEB) · Zod 4 · Vitest 4 · Playwright 1 · +Sentry 10. Só a **major**, de propósito: é onde o idioma muda, e é o que -`tests/unit/agents-md-versoes.test.ts` verifica contra o `package.json`. Declarar a minor -aqui fazia todo bump do Dependabot reprovar o `verify` (5 dos 8 pacotes) e não cobria nada -que a major já não cobrisse — issue #235. Para a versão exata, `package.json` é a fonte. +`tests/unit/agents-md-versoes.test.ts` cobra contra o `package.json`. Declarar a minor aqui fazia +todo bump do Dependabot reprovar o `verify` sem cobrir nada que a major já não cobrisse (issue +#235). Para a versão exata, a fonte é o `package.json`. -Runtime: **Node ≥22** (`.nvmrc` = 22; os quatro workflows fixam `node-version: 22` — -`ci` ×2, `perf`, `e2e`). Gerenciador: **pnpm 9.15.9** (`packageManager`). -Versão do produto: **1.0.0** (`CHANGELOG.md`, SemVer — mudança que afeta quem roda VPS entra lá). +- **Runtime:** Node ≥22 — `.nvmrc` e o `node-version` dos workflows. +- **Gerenciador:** pnpm, versão fixada no campo `packageManager` do `package.json`. +- **Versão do produto:** topo do `CHANGELOG.md` (`grep -m2 -E '^## \[' CHANGELOG.md`), SemVer, com + tag git correspondente. Mudança que afeta quem roda VPS entra lá. O campo `version` do + `package.json` **não** é a versão do produto e não é lido em runtime. ## Estrutura que importa -| Path | O quê | -|---|---| -| `app/api/v1/` | 166 route handlers REST (versionado por path) — 169 contando `app/api/**` | -| `app/api/internal/`, `app/api/mcp/`, `app/api/v1/cron/` | superfícies não-cookie (secret/bearer próprio) | -| `app/app/` | UI autenticada do tenant · `app/admin/` UI de plataforma | -| `app/actions/` | Server Actions (auth, onboarding, team, settings) | -| `lib/agent-engine/`, `lib/ai/` | runtime do agente, guardrails, RAG, dispatcher | -| `lib/api/wrappers.ts` | `ok()` / `fail()` — **use sempre**, não monte Response na mão | -| `lib/auth/require-role.ts` | `requireRole()` — guard canônico de RBAC | -| `lib/supabase/{browser,server,admin}.ts` | clients canônicos | -| `workers/` | workers de `event_log` + crons | -| `supabase/migrations/` | schema versionado · `supabase/baseline.sql` = o que o self-host aplica | -| `proxy.ts` | middleware do Next 16 (auth de borda, `X-Request-Id`) | +| Path | O quê | +| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `app/api/v1/` | Route handlers REST, versionados por path | +| `app/api/internal/`, `app/api/mcp/`, `app/api/v1/cron/` | Superfícies não-cookie (secret / bearer próprio) | +| `app/app/`, `app/admin/` | UI autenticada do tenant · UI de plataforma | +| `app/actions/` | Server Actions (auth, onboarding, team, settings) | +| `lib/agent-engine/`, `lib/ai/` | Runtime do agente, guardrails, RAG, dispatcher | +| `lib/api/wrappers.ts` | `ok()` / `fail()` — **use sempre**, não monte `Response` na mão | +| `lib/auth/require-role.ts` | `requireRole()` — guard canônico de RBAC | +| `lib/supabase/browser.ts`, `lib/supabase/server.ts`, `lib/supabase/admin.ts` | Clients canônicos | +| `lib/navigation/registry.ts` | Registro de telas — é o que dá porta a uma tela nova | +| `workers/` | Workers de `event_log` + crons | +| `supabase/migrations/` | Schema versionado · `supabase/baseline.sql` = o que o self-host aplica | +| `proxy.ts` | Middleware do Next 16 (auth de borda, `X-Request-Id`) | ## Comandos (CONFIRMADO em `package.json`) @@ -56,182 +62,201 @@ pnpm install # deps (frozen-lockfile no CI) pnpm dev # dev server pnpm build # next build pnpm lint # eslint +pnpm lint:channels # nenhuma feature nomeia um provider de canal pnpm typecheck # tsc --noEmit (estrito) pnpm test:unit # vitest — EXCLUI tests/invariants e tests/e2e pnpm test:db # invariantes de banco + gate do baseline (PRECISA de Docker) pnpm test:e2e # Playwright (PRECISA de app rodando + banco semeado) -pnpm gov:verify # typecheck + lint + test:unit ← verificação única atual +pnpm test:shell # kit self-host (update.sh, scheduler, validadores do install.sh) +pnpm gov:verify # atalho local = typecheck + lint + lint:channels + test:unit ``` -⚠️ **`pnpm gov:verify` NÃO cobre tudo.** Ele omite `test:db` e `test:e2e`. Se sua -mudança toca schema, RLS ou UI, `gov:verify` verde **não** é prova — rode `pnpm test:db` -(exige Docker) e/ou `pnpm test:e2e` você mesmo. Ver [`docs/harness-audit.md`](docs/harness-audit.md). +⚠️ **`pnpm gov:verify` NÃO cobre tudo.** Ele omite `test:db`, `test:e2e` e `test:shell`. Se sua +mudança toca schema, RLS, UI ou o kit, `gov:verify` verde **não** é prova — rode as suítes que +faltam você mesmo. Ver [`docs/harness-audit.md`](docs/harness-audit.md). -**O que o CI cobre.** `.github/workflows/ci.yml`: `verify` = typecheck + lint + test:unit; -`invariants` = `pnpm test:db` (isolamento RLS + invariantes de governança contra Postgres -efêmero pg17). `.github/workflows/perf.yml`: `build-and-size` = `pnpm build`. -`.github/workflows/e2e.yml` roda **45 das 46 specs** Playwright contra um Supabase local de -verdade com o `baseline.sql` aplicado — o mesmo banco que o self-hoster tem. **É check -obrigatório desde 2026-08-08.** A **única** de fora é `vps-fresh-onboarding` (WAHA + Redis + -Resend + Nuvemshop; é 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`). +## O que o CI cobre -`.github/workflows/publish-image.yml`: `imagens-ok` = as três imagens Docker constroem. **Obrigatório -desde 2026-08-13.** +| Check | Workflow | O que roda | +| ---------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `verify` | `.github/workflows/ci.yml` | `typecheck` + `lint` + `lint:channels` + `test:unit` + `test:shell` | +| `invariants` | `.github/workflows/ci.yml` | `pnpm test:db` — Postgres efêmero pg17, `baseline.sql` em install **e** update, isolamento RLS + governança | +| `build-and-size` | `.github/workflows/perf.yml` | `pnpm build` | +| `e2e` | `.github/workflows/e2e.yml` | Playwright contra Supabase local com o `baseline.sql` aplicado — o mesmo banco que o self-hoster tem | +| `imagens-ok` | `.github/workflows/publish-image.yml` | As três imagens Docker constroem | -**Os cinco são checks obrigatórios** na branch protection da `main` — medido em 2026-08-14 @ `741c4ec8`: +**Os cinco são checks obrigatórios na branch protection da `main`.** Meça em vez de acreditar: -```console -$ gh api repos/melgarafael/DeskcommCRM/branches/main/protection --jq '.required_status_checks.contexts|join(", ")' -verify, build-and-size, invariants, e2e, imagens-ok +```bash +gh api repos/melgarafael/DeskcommCRM/branches/main/protection --jq '.required_status_checks.contexts|join(", ")' ``` -> Este bloco estava errado em quatro pontos até 2026-08-14 (dizia "três checks", "28 das 32 -> specs", "e2e não é obrigatório ainda" e listava como excluídas três specs que já rodavam). -> A pior era a do `e2e`: quem lesse mediria um PR contra a régua errada. **Reconte antes de -> citar** — `ls tests/e2e/*.spec.ts | wc -l` e o comando acima. +O `e2e` roda todas as specs de `tests/e2e/`, menos as declaradas em `FORA_DO_CI` — hoje só +`vps-fresh-onboarding` (precisa de WAHA + Redis + Resend + Nuvemshop). **Ou seja: `e2e` verde não +prova a jornada de instalação fresca**, que é a P0 da doutrina de QA Visual e o produto que se +vende; se você mexeu nela, a prova é sua. `tests/unit/e2e-cobertura-completa.test.ts` reprova spec +que sumiu de todas as listas: + +```bash +ls tests/e2e/*.spec.ts | wc -l # specs no disco +grep -A20 'FORA_DO_CI: >-' .github/workflows/e2e.yml # o que o CI declara não cobrir +``` ## Padrões de código (observados no repo, não inventados) -- **Route handler:** valida input com Zod → guard (`requireRole` / `requirePlatformAdmin` / - secret) → query com `organization_id` explícito → `audit()` se mutação → `ok()` / `fail()`. +- **Route handler:** valida input com Zod → guard (`requireRole` / `requirePlatformAdmin` / secret) + → query com `organization_id` explícito → `audit()` se mutação → `ok()` / `fail()`. - Erro: `fail(code, message, status)` com código de `lib/api/errors.ts`. Nunca `throw` cru na borda. - JSON **snake_case** na API. Dinheiro em `_cents` + `currency`. Datas ISO-8601 UTC. - Log: `lib/logger.ts` (estruturado). **`console.log` é proibido** em código merged. -- Testes ao lado do código (`lib/foo/bar.test.ts`) ou em `tests/{unit,api,invariants,e2e}/`. +- Testes ao lado do código (`lib/foo/bar.test.ts`) ou em `tests/unit`, `tests/api`, + `tests/invariants`, `tests/e2e`. - Comentários em PT-BR são a norma neste repo — mantenha o idioma do arquivo que editar. ### Marca própria (white-label) — o produto é revendido, e o nome não é seu -- **Nunca escreva "Deskcomm"/"DeskcommCRM" em código que alcança o usuário.** `tests/unit/branding.test.ts` varre `app|components|lib|workers|hooks` e reprova; a allowlist **só encolhe**. -- A marca resolve do **banco** (`platform_branding` para a instalação, `organizations.settings.branding` para a organização). `APP_NAME`/`APP_LOGO_URL`/`APP_ACCENT_HEX` no `.env` são **semente e piso de rollback**, não a fonte. -- Precisa da marca **fora do DOM** (e-mail, remetente, ícone, `issuer` do MFA)? Use `marcaDaSaida()` de `lib/branding/saida.ts` — um hex e uma frente legível, tema claro. Nunca entregue `MarcaResolvida` a um template de e-mail. -- Resolvedor de marca **nunca lança**: ele roda em `app/layout.tsx`, e um throw ali é 500 em todas as telas. -- **O PDF de LGPD não leva marca** — ele nomeia o controlador (`organizations.legal_name`) e o DPO. Isso é decisão, não omissão; há gate no mapa de arquitetura. -- Contexto de venda em `docs/white-label.md`; mapa em `docs/architecture/marca-propria.architecture.json`. +- **Nunca escreva "Deskcomm"/"DeskcommCRM" em código que alcança o usuário.** + `tests/unit/branding.test.ts` varre `app|components|lib|workers|hooks` e reprova; a allowlist + **só encolhe**. +- A marca resolve do **banco** (`platform_branding` para a instalação, + `organizations.settings.branding` para a organização). `APP_NAME` / `APP_LOGO_URL` / + `APP_ACCENT_HEX` no `.env` são **semente e piso de rollback**, não a fonte. +- Precisa da marca **fora do DOM** (e-mail, remetente, ícone, `issuer` do MFA)? Use `marcaDaSaida()` + de `lib/branding/saida.ts` — um hex e uma frente legível, tema claro. Nunca entregue + `MarcaResolvida` a um template de e-mail. +- Resolvedor de marca **nunca lança**: ele roda em `app/layout.tsx`, e um throw ali é 500 em todas + as telas. +- **O PDF de LGPD não leva marca** — ele nomeia o controlador (`organizations.legal_name`) e o DPO. + É decisão, não omissão; há gate no mapa de arquitetura. +- Contexto de venda em [`docs/white-label.md`](docs/white-label.md); mapa em + `docs/architecture/marca-propria.architecture.json`. ## Diretórios e arquivos SENSÍVEIS -- **`supabase/baseline.sql`** — é o que o `install.sh`/`update.sh` do self-host aplicam. - Toda mudança de schema tem que aparecer aqui **como apêndice idempotente**, senão - não chega em quem instalou. Ver doutrina de Migrations em `CLAUDE.md`. -- **`supabase/migrations/*.sql` já aplicadas** — nunca edite. Corrija com migration nova. -- **`lib/supabase/admin.ts`** — service role **bypassa RLS**. 89 rotas o usam; toda - query precisa filtrar `organization_id` manualmente, resolvido de fonte confiável - (cookie/JWT/webhook secret/path token), **nunca do body**. -- **`lib/auth/public-paths.ts`** — adicionar path aqui remove a checagem de auth de borda. - Só com guard próprio dentro da rota. +- **`supabase/baseline.sql`** — é o que o `install.sh`/`update.sh` do self-host aplicam. Toda + mudança de schema tem que aparecer aqui **como apêndice idempotente**, senão não chega em quem + instalou. Ver a doutrina de Migrations em [`CLAUDE.md`](CLAUDE.md). +- **`supabase/migrations/`, arquivos já aplicados** — nunca edite. Corrija com migration nova. +- **`lib/supabase/admin.ts`** — service role **bypassa RLS**. Toda query precisa filtrar + `organization_id` manualmente, resolvido de fonte confiável (cookie/JWT/webhook secret/path + token), **nunca do body**. Quem já o usa: + `grep -rl 'supabase/admin\|createAdminClient' app/api --include='route.ts'`. +- **`lib/auth/public-paths.ts`** — adicionar path aqui remove a checagem de auth de borda. Só com + guard próprio dentro da rota. - **`.env*`** — não abra, não copie valor, não logue. Só `.env.example` é template. -- **`docker-compose.traefik.yml`** — numa VPS que já tem proxy reverso próprio - (Hostinger, Coolify, Dokploy…), é o único lugar que dá ao contêiner `app` as labels - de roteamento. Todo `up -d` leva os **dois** arquivos de compose: +- **`docker-compose.traefik.yml`** — numa VPS que já tem proxy reverso próprio (Hostinger, Coolify, + Dokploy…), é o único lugar que dá ao contêiner `app` as labels de roteamento. Todo `up -d` leva os + **dois** arquivos de compose: `docker compose -f docker-compose.prod.yml -f docker-compose.traefik.yml --env-file .env up -d app`. - Esquecer o segundo `-f` recria o contêiner sem labels: o proxy deixa de enxergá-lo e o - domínio inteiro responde `404`, com o contêiner `healthy` — o healthcheck é um probe TCP - interno e não sabe nada de roteamento. Runbook: `docs/runbooks/deploy.md`. + Esquecer o segundo `-f` recria o contêiner sem labels: o proxy deixa de enxergá-lo e o domínio + inteiro responde `404`, com o contêiner `healthy` — o healthcheck é um probe TCP interno e não + sabe nada de roteamento. Runbook: [`docs/runbooks/deploy.md`](docs/runbooks/deploy.md). ## Arquivos GERADOS — não editar à mão -- `lib/database.types.ts` (6.1k linhas — gerado do schema Supabase) +- `lib/database.types.ts` (gerado do schema Supabase) - `graphify-out/` (grafo de conhecimento; regenerado por `/graphify .`) - `pnpm-lock.yaml`, `tsconfig.tsbuildinfo`, `next-env.d.ts`, `.next/` ## Como validar uma alteração -1. `pnpm typecheck` e `pnpm lint` zerados. +1. `pnpm typecheck`, `pnpm lint` e `pnpm lint:channels` zerados. 2. `pnpm test:unit` verde. -3. Tocou schema/RLS/tabela tenant-aware → `pnpm test:db` (sobe Postgres efêmero via Docker, - aplica `baseline.sql` em modo install **e** update, roda os invariantes). -4. Tocou UI ou fluxo de usuário → `pnpm test:e2e` com evidência visual. **`curl` não conta** - como prova de UX (doutrina de QA Visual em `CLAUDE.md`). -5. Mudou schema → migration versionada em `supabase/migrations/` **+** apêndice idempotente - em `supabase/baseline.sql` **+** linha em `supabase/migrations/MANIFEST.md`. Os três juntos. -6. Criou função em `public` → `revoke execute on function ... from public, anon;` e depois - `grant` só a quem precisa. São **duas** origens de `EXECUTE` e revogar uma só deixa a - função exposta como RPC alcançável pela anon key. Detalhe em `CLAUDE.md`, item 9 da - doutrina de Migrations. - -## Testes existentes (CONFIRMADO) - -Medido em 2026-08-14 @ `741c4ec8`, com o comando ao lado de cada número: - -- **257** arquivos de teste unitário em `tests/unit/` (`git ls-files 'tests/unit/*.test.ts' 'tests/unit/*.test.tsx' | wc -l`). O repo tem **491** arquivos `*.test.ts(x)` no total (`git ls-files '*.test.ts' '*.test.tsx' | wc -l`) — a diferença vive junto ao código, fora de `tests/`, e também roda em `test:unit`. -- **102** arquivos de invariante de banco em `tests/invariants/` (`git ls-files 'tests/invariants/*.test.ts' | wc -l`) — RLS/isolamento cross-tenant, - RBAC, governança (G1–G6). Excluídos do `test:unit` de propósito; rodam via `pnpm test:db` - **e no job `invariants` do CI**. -- **46** specs Playwright em `tests/e2e/` (`ls tests/e2e/*.spec.ts | wc -l`). **45 rodam no CI** (via `e2e.yml`, - **obrigatório**). A única de fora é `vps-fresh-onboarding`, por dependência de serviço externo - (WAHA/Redis/Resend/Nuvemshop). Ver issue #63. - -## Limitações conhecidas (estado em 2026-07-29, contra `origin/main` @ 789dfa6) - -- **1 das 46 specs E2E segue fora do CI** (`vps-fresh-onboarding`), e o `e2e` **é** check - obrigatório desde 2026-08-08. Ou seja: um PR que quebre o `e2e` não entra — mas a jornada de - instalação fresca, que é o produto que se vende, continua sem gate. Se você mexeu nela, a - prova é sua. *(Corrigido em 2026-08-14; a redação anterior — "4 das 32, não-obrigatório" — - mudava a régua de qualquer triagem que a lesse.)* -- Rate limit HTTP: `lib/auth/rate-limit.ts` cobre **login, signup, recuperação de senha e - aceite de convite** (contando por IP **e** por identificador hasheado); `checkRateLimit` cobre - o webhook de captação e o dispatcher de IA. **Crons e MCP seguem sem.** Meça antes de agir: +3. Tocou schema/RLS/tabela tenant-aware → `pnpm test:db` (sobe Postgres efêmero via Docker, aplica + `baseline.sql` em modo install **e** update, roda os invariantes). +4. Tocou UI ou fluxo de usuário → `pnpm test:e2e` com evidência visual. **`curl` não conta** como + prova de UX (doutrina de QA Visual em [`CLAUDE.md`](CLAUDE.md)). +5. Mudou schema → migration versionada em `supabase/migrations/` **+** apêndice idempotente em + `supabase/baseline.sql` **+** linha em `supabase/migrations/MANIFEST.md`. Os três juntos. +6. Criou função em `public` → `revoke execute on function ... from public, anon;` e depois `grant` + só a quem precisa. São **duas** origens de `EXECUTE`, e revogar uma só deixa a função exposta + como RPC alcançável pela anon key. Detalhe em [`CLAUDE.md`](CLAUDE.md), item 9 da doutrina de + Migrations. +7. Tocou `Dockerfile*`, `docker-compose*.yml` ou `hostgator-setup-kit/` → `pnpm test:shell`. + +## Testes existentes — meça, não cite de cabeça + +Contagem de arquivo de teste envelhece a cada PR. Os comandos: + +```bash +git ls-files 'tests/unit/*.test.ts' 'tests/unit/*.test.tsx' | wc -l # suíte unitária central +git ls-files '*.test.ts' '*.test.tsx' | wc -l # total (inclui testes ao lado do código) +git ls-files 'tests/invariants/*.test.ts' | wc -l # invariantes de banco (RLS, RBAC, governança) +ls tests/e2e/*.spec.ts | wc -l # specs Playwright +``` + +Os invariantes de `tests/invariants/` são excluídos do `test:unit` de propósito — precisam de um +Postgres real e rodam via `pnpm test:db`, no job `invariants` do CI. + +## Limitações conhecidas + +Nenhuma tem número fixo aqui: cada uma vem com a medição. + +- **A jornada de instalação fresca não tem gate.** `vps-fresh-onboarding` está em `FORA_DO_CI` por + depender de serviço externo (WAHA/Redis/Resend/Nuvemshop) — issue #63. +- **Rate limit HTTP é parcial.** `lib/auth/rate-limit.ts` cobre login, signup, recuperação de senha + e aceite de convite (contando por IP **e** por identificador hasheado); `checkRateLimit` cobre o + webhook de captação e o dispatcher de IA. **Crons e MCP seguem sem.** Meça antes de agir: `grep -rln 'authRateLimited\|checkRateLimit(' app lib --include='*.ts' --include='*.tsx'`. - Esta linha dizia "existe em 2 pontos; login e signup estão sem" — era o estado anterior à - issue #64, e o `docs/threat-model.md` ainda carrega a versão velha, com nota de reauditoria. -- Fallback do rate limit é **em memória** — sem Upstash configurado o limite é por processo. -- `Idempotency-Key` implementado em **1** rota, apesar de o contrato prometer nos POSTs de criação. -- **`.env.example` está completo** — medido em 2026-08-14: das 45 chaves de `lib/env.ts`, a - única ausente é `NODE_ENV`, que não é configuração do operador. Esta linha dizia que faltavam - 6, "incluindo 3 secrets"; os três (`IMPERSONATE_COOKIE_SECRET`, `INTERNAL_CRON_SECRET`, - `LGPD_SIGNING_KEY`) estão lá. Se você adicionar env var, adicione nos dois lugares (item 9 do - DoD) — a regra continua valendo, o que caiu foi a dívida. -- `lib/auth/invite-token.ts` cai em `"dev-fallback"` como secret HMAC se nenhum secret existir - (inalcançável em produção, porque `INTERNAL_SECRET` é obrigatório e derruba o boot). -- **89 dos 169 handlers de `app/api/**` usam service role** — sem gate automático para o filtro de - `organization_id`. Escrevendo handler novo, o filtro é responsabilidade sua. -- Detalhes e prioridade: [`docs/harness-audit.md`](docs/harness-audit.md), +- **O fallback do rate limit é em memória** — sem Upstash configurado, o limite é por processo. +- **`Idempotency-Key` tem adoção parcial**, apesar de o contrato prometer nos POSTs de criação: + `grep -rln 'Idempotency-Key' app/api --include='*.ts'`. +- **`lib/auth/invite-token.ts` cai em `"dev-fallback"`** como secret HMAC se nenhum secret existir + (inalcançável em produção: `INTERNAL_SECRET` é obrigatório e derruba o boot). +- **Handler com service role não tem gate automático** para o filtro de `organization_id`. + Escrevendo handler novo, o filtro é responsabilidade sua. +- Prioridade e detalhe: [`docs/harness-audit.md`](docs/harness-audit.md), [`docs/current-state.md`](docs/current-state.md) e [`docs/threat-model.md`](docs/threat-model.md). ## Regras de segurança - Sempre `getUser()` no backend. **Nunca `getSession()`** (confia no cookie sem revalidar). -- API key/token **nunca** em query string — só header. Plaintext do bearer é mostrado - **uma vez**; no banco só hash SHA256. +- API key/token **nunca** em query string — só header. O plaintext do bearer é mostrado **uma vez**; + no banco, só hash SHA256. - HMAC de webhook com `crypto.timingSafeEqual`. Fail-closed quando o secret falta. -- Nunca logue segredo, token, CPF, telefone ou e-mail. Sentry tem `beforeSend` que - higieniza — não confie nele como única camada. -- Não commite screenshot/dump com dado real de cliente. +- Nunca logue segredo, token, CPF, telefone ou e-mail. O Sentry tem `beforeSend` que higieniza — não + confie nele como única camada. +- Não commite screenshot ou dump com dado real de cliente. ## Packaging — se você tocou `Dockerfile*`, `docker-compose*.yml` ou `hostgator-setup-kit/` Lei completa em [`docs/doctrine/packaging.md`](docs/doctrine/packaging.md). O não-negociável: - **Nenhum serviço de `docker-compose.prod.yml` constrói na máquina do cliente.** Todo serviço - declara `image:` de uma imagem publicada; `build:` só existe **ao lado**, como escape. - Serviço `build:`-only é pulado por `docker compose pull` e imune a `up -d` sem `--build` — - ele não é só caro de instalar, ele **nunca é atualizado**. + declara `image:` de uma imagem publicada; `build:` só existe **ao lado**, como escape. Serviço + `build:`-only é pulado por `docker compose pull` e imune a `up -d` sem `--build` — ele não é só + caro de instalar, ele **nunca é atualizado**. - **Publicação é ato do CI**, nunca da sua máquina: build ARM local não roda na VPS amd64. - **Instalação de cliente aponta para número de versão**, nunca para tag móvel. Aqui `latest` significa **topo da `main`**, não última release — quem quer a última release usa `stable`. - **Dependência upstream é referenciada com tag fixa, nunca republicada** (WAHA é licenciado). - **Bump de versão não pode exigir que o operador da VPS edite arquivo à mão.** -`pnpm test:shell` é o único gate que exercita o kit. Rode-o. +`pnpm test:shell` é a única suíte que exercita o kit — ela roda dentro do check `verify`, e você +deve rodá-la localmente antes de abrir o PR. ## Critério de conclusão -Vale a **Definition of Done em [`CLAUDE.md`](CLAUDE.md)** — conte lá em vez de confiar num número aqui (`sed -n '/^## Definition of Done/,/^Um staff engineer/p' CLAUDE.md | grep -cE '^[0-9]+\. '`; esta linha já disse 15 e o DoD tem 16). A régua tem que DELIMITAR a seção: a primeira versão desta linha oferecia `grep -c '^[0-9]\+\. \*\*' CLAUDE.md`, que devolve **25** — casa toda linha numerada em negrito do arquivo (anti-patterns, packaging, higiene de branches, migrations) e perde os itens 1–10 do próprio DoD, que não são negrito. Trocar o número pelo comando só ajuda se o comando responder à pergunta. Não declare pronto -sem: typecheck/lint zerados, testes relevantes verdes, RLS testada se tocou tabela -tenant-aware, migration + baseline + MANIFEST se mudou schema, prova visual se mudou UI, e a -regra de packaging acima se mudou o artefato que o self-hoster instala. +Vale a **Definition of Done de [`CLAUDE.md`](CLAUDE.md)** — conte lá em vez de confiar num número +aqui: + +```bash +sed -n '/^## Definition of Done/,/^Um staff engineer/p' CLAUDE.md | grep -cE '^[0-9]+\. ' +``` + +A régua tem que DELIMITAR a seção: um `grep` no arquivo inteiro casa toda linha numerada de +anti-patterns, packaging e migrations, e perde os itens do próprio DoD. Não declare pronto sem: +typecheck/lint zerados, testes relevantes verdes, RLS testada se tocou tabela tenant-aware, +migration + baseline + MANIFEST se mudou schema, prova visual se mudou UI, e a regra de packaging +acima se mudou o artefato que o self-hoster instala. ## Regra final — não invente -Este repositório tem PRDs, specs, regras de negócio e doutrina escritos -(`docs/prd/`, `docs/specs/`, `docs/business-rules/`, `docs/doctrine/`). -**Nunca invente regra de negócio, número, SLA ou comportamento de produto.** -Se a regra não está escrita, diga que não está e pergunte — não preencha a lacuna com -suposição plausível. Ao documentar, marque o que é `CONFIRMADO` (provado por código) e o -que é `INFERIDO`. +Este repositório tem PRDs, specs, regras de negócio e doutrina escritos (`docs/prd/`, `docs/specs/`, +`docs/business-rules/`, `docs/doctrine/`). **Nunca invente regra de negócio, número, SLA ou +comportamento de produto.** Se a regra não está escrita, diga que não está e pergunte — não preencha +a lacuna com suposição plausível. Ao documentar, marque o que é `CONFIRMADO` (provado por código) e +o que é `INFERIDO`. diff --git a/CHANGELOG.md b/CHANGELOG.md index fb38ea77b..bbc42180a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Se você roda o DeskcommCRM numa VPS, **leia a seção da versão para a qual es ## [Não lançado] +## [1.3.1] — 2026-08-23 + ### Adicionado - **O agente de atualização passa a fixar sozinho a versão que ficou solta**, em até 5 @@ -57,6 +59,19 @@ Se você roda o DeskcommCRM numa VPS, **leia a seção da versão para a qual es - **No painel de administração, o alerta de orçamento parou de gritar "crítico" sobre um número que não é o do mês.** Ele continua avisando, com o rótulo dizendo que o valor é acumulado, e leva direto para a tela de saúde do cliente, que mostra o número real. +- **A tela de editar o agente de IA salvava sem publicar nada.** O botão "Salvar" gravava só + o rascunho; o atendimento continuava usando a versão publicada antiga (ou o prompt genérico + do primeiro instalador), mesmo depois de várias edições — sem erro nenhum na tela. Agora + existe um botão "Publicar": o rascunho só entra em uso depois que você aperta ele. +- **Reconectar o WhatsApp podia travar o atendimento por horas, sem nenhum aviso na + conversa.** Toda reconexão (celular caiu, precisou escanear o QR de novo) tratava o número + como se fosse novo e passava a segurar toda mensagem, esperando alguém achar e resolver um + aviso escondido na Central de avisos. Agora o sistema reconhece quando é o MESMO número já + aprovado antes e libera sozinho. +- **Quando o limite diário de envio (anti-banimento) travava uma resposta, a conversa + morria ali** — o cliente não recebia nada, nem depois de o limite abrir de novo, a menos + que mandasse outra mensagem por conta própria. Agora o sistema reagenda a resposta sozinho + para o próximo horário permitido. ## [1.3.0] — 2026-08-13 @@ -396,7 +411,9 @@ Primeira versão marcada do DeskcommCRM. O projeto vinha sendo desenvolvido publ - **Node 22 é obrigatório para desenvolvimento.** A suíte de invariantes instancia o cliente do Supabase, que exige o `WebSocket` global — nativo apenas a partir do Node 22. Isso não afeta quem apenas hospeda: a VPS roda a imagem pronta. -[Não lançado]: https://github.com/melgarafael/DeskcommCRM/compare/v1.2.1...HEAD +[Não lançado]: https://github.com/maugarciasa/DeskcommCRM/compare/v1.3.1...HEAD +[1.3.1]: https://github.com/maugarciasa/DeskcommCRM/compare/v1.3.0...v1.3.1 +[1.3.0]: https://github.com/maugarciasa/DeskcommCRM/compare/v1.2.1...v1.3.0 [1.2.1]: https://github.com/melgarafael/DeskcommCRM/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.0.0...v1.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 103f058c0..b5dbe7ab3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,427 +1,567 @@ # CLAUDE.md — DeskcommCRM -> Instruções pra futuras sessões Claude trabalhando neste repo. Leitura obrigatória antes de qualquer task de código. +> **Doutrina de código deste repositório.** Autoridade final sobre convenção, schema e +> anti-pattern. Leitura obrigatória antes de qualquer task de código. -**Este arquivo é a doutrina — a autoridade final sobre convenção e anti-pattern.** Complementos, na ordem em que ajudam: +## Precedência — qual documento vence -- [`AGENTS.md`](AGENTS.md) — mesmo contrato em forma portável (para Codex/Cursor/Copilot e afins). É derivado deste arquivo, não o substitui. **Ao mudar doutrina aqui, verifique se `AGENTS.md` desatualizou.** -- [`docs/index.md`](docs/index.md) — índice dos 149 docs, com regra de precedência quando dois docs discordam. Use antes de sair varrendo `docs/`. -- [`docs/current-state.md`](docs/current-state.md) — o que está pronto, incompleto e quebrado. **Leia antes de estimar ou prometer qualquer coisa.** -- [`docs/harness-audit.md`](docs/harness-audit.md) — onde a verificação tem buraco. Importante: `pnpm gov:verify` **não** cobre `test:db` nem `test:e2e` — verde ali não é prova para mudança de schema ou de UI. +| # | Documento | Papel | +| --- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| 1 | [`.ai/AI_BOOTSTRAP.md`](.ai/AI_BOOTSTRAP.md) | Porta de entrada: o que ler, nesta ordem, e as regras que evitam dano | +| 2 | **`CLAUDE.md`** (este) | Doutrina. Vence qualquer outro documento em conflito de regra | +| 3 | [`AGENTS.md`](AGENTS.md) | O mesmo contrato em forma portável (Codex, Cursor, Copilot, Amp). É derivado deste arquivo — ao mudar doutrina aqui, atualize-o | +| 4 | [`docs/index.md`](docs/index.md) | Índice dos documentos, com a regra de precedência interna de `docs/` | + +Acima dos quatro está **o repositório**: código, `package.json`, workflows e `gh api` medem o +estado; a prosa apenas o descreve. Onde os dois discordarem, a prosa está errada — corrija-a. + +**Por isso este arquivo evita número volátil.** Contagem de rota, de teste e de spec envelhece +entre um PR e o próximo, e uma triagem que a use como régua mede contra o número errado. Onde a +afirmação puder virar comando, ela vira comando. + +Complementos por assunto: + +- [`docs/current-state.md`](docs/current-state.md) — o que está pronto, incompleto e quebrado. Leia antes de estimar ou prometer. +- [`docs/harness-audit.md`](docs/harness-audit.md) — onde a verificação tem buraco. - [`docs/threat-model.md`](docs/threat-model.md) — superfície de ataque real do self-host. +- [`VISION.md`](VISION.md) — posicionamento, nicho e monetização. --- -## Visão (1 parágrafo) +## Visão -DeskcommCRM é um sistema operacional de vendas open source com agentes de IA nativos — multi-nicho (e-commerce, clínicas, imobiliárias, infoprodutos, serviços), com WhatsApp como canal primário (via WAHA). Agentes com RAG por tenant atendem, qualificam e movem o funil junto com humanos; CRM inteiro exposto via MCP. Monetização = self-host em VPS (parceria HostGator), não assinatura. Arquitetura multi-tenant com RLS desde o dia 1; LGPD nativa. Posicionamento completo: `VISION.md`. +DeskcommCRM é um sistema operacional de vendas open source com agentes de IA nativos — +multi-nicho (e-commerce, clínicas, imobiliárias, infoprodutos, serviços), com WhatsApp como canal +primário (via WAHA). Agentes com RAG por tenant atendem, qualificam e movem o funil junto com +humanos; o CRM inteiro é exposto via MCP. Monetização = self-host em VPS (parceria HostGator), +não assinatura. Multi-tenant com RLS desde o dia 1; LGPD nativa. + +**A consequência que muda como você trabalha:** o produto é distribuído como código, e quem +instala numa VPS **é** o usuário. Mudança que funciona na sua máquina e quebra no clone fresco é +bug de produto, não detalhe de ambiente. --- ## Stack canônica -- **Frontend:** Next.js 16 App Router (Turbopack) + React 19 + TypeScript 6 estrito + Tailwind + shadcn/ui (style: `new-york`, neutral) -- **Backend:** Next.js Route Handlers (mesmo repo); workers via `event_log` table + cron +- **Frontend:** Next.js 16 App Router · React 19 · TypeScript 6 estrito · Tailwind 3 · shadcn/ui (style `new-york`, neutral) +- **Backend:** Route Handlers no mesmo repo; workers via tabela `event_log` + cron - **DB:** Supabase (Postgres). RLS em toda tabela tenant-aware. Extensions: `uuid-ossp`, `pgcrypto`, `vector` -- **Auth:** Supabase Auth via `@supabase/ssr`. Cookie SameSite=Strict, HttpOnly, Secure -- **Realtime:** Supabase Realtime (postgres_changes + broadcast) -- **Storage:** Supabase Storage (bucket `whatsapp-media` privado, URLs assinadas) +- **Auth:** Supabase Auth via `@supabase/ssr`. Cookie `SameSite=Strict`, `HttpOnly`, `Secure` +- **Realtime:** Supabase Realtime (`postgres_changes` + broadcast) · **Storage:** bucket `whatsapp-media` privado, URLs assinadas - **WhatsApp:** WAHA Plus, engine NOWEB -- **Filas/eventos:** `event_log` table + workers (não usar Inngest/Trigger no MVP) +- **Filas/eventos:** tabela `event_log` + workers (sem Inngest/Trigger no MVP) - **Rate limit:** Upstash Redis sliding window -- **AI:** Vercel AI Gateway (Anthropic primário; OpenAI backup pra embeddings); strings tipo `"anthropic/claude-sonnet-4-6"` -- **Validação:** Zod em todo input externo (request body, webhook payload, env) -- **Observability:** Sentry com `beforeSend` sanitizado +- **AI:** Vercel AI Gateway (Anthropic primário; OpenAI backup para embeddings); modelos como `"anthropic/claude-sonnet-4-6"` +- **Validação:** Zod 4 em todo input externo (body, webhook, env) +- **Testes:** Vitest 4 · Playwright 1 · **Observability:** Sentry 10 com `beforeSend` sanitizado +- **Runtime:** Node ≥22 (`.nvmrc`) · **Gerenciador:** pnpm (campo `packageManager` do `package.json`) + +Só a **major** é declarada, aqui e no `AGENTS.md`: é onde o idioma da biblioteca muda, e é o que +`tests/unit/agents-md-versoes.test.ts` cobra contra o `package.json`. Para a versão exata, a fonte +é o `package.json`. **Versão do produto** é a do topo do `CHANGELOG.md` +(`grep -m2 -E '^## \[' CHANGELOG.md`), com tag git correspondente — o campo `version` do +`package.json` não é fonte de nada e não é lido em runtime. + +--- + +## Como rodar local + +O passo a passo completo (extensões do Postgres, `.env.local`, WAHA) está no +[`README.md`](README.md) e em [`docs/SETUP.md`](docs/SETUP.md). O essencial: + +```bash +nvm use # Node 22 +npm install -g pnpm && pnpm install +cp .env.example .env.local # guia em docs/SETUP.md +docker compose up -d # WAHA local (opcional em dev sem WhatsApp) +pnpm dev # http://localhost:3000 +``` + +**Schema local: aplique `supabase/baseline.sql`, NUNCA a cadeia de `supabase/migrations/`.** +Migrations antigas são stubs `SELECT 1;` — a cadeia não sobe do zero, `supabase db push` "passa" e +deixa o banco vazio. O baseline é o mesmo artefato que o `install.sh` aplica na VPS. --- ## Convenções críticas (NÃO NEGOCIÁVEIS) ### Multi-tenancy + - `organization_id uuid not null references organizations(id) on delete cascade` em **toda** tabela tenant-aware -- RLS policy `tenant_isolation__all` aplicada via helper `fn_user_org_ids()` -- Service role bypassa RLS — handlers que usam admin client **DEVEM** filtrar `organization_id` manualmente, resolvido de fonte confiável (cookie/JWT/webhook secret/path token), **NUNCA do body** +- RLS policy `tenant_isolation__all`, aplicada via helper `fn_user_org_ids()` +- Service role **bypassa RLS**: handler que usa o admin client **DEVE** filtrar `organization_id` + manualmente, resolvido de fonte confiável (cookie/JWT/webhook secret/path token) e **NUNCA do body** - Toda query que cruza tabelas tenant-aware filtra `organization_id` explicitamente -- Teste de isolamento (cria 2 tenants, verifica não-vazamento) é obrigatório no CI antes de merge +- Teste de isolamento (2 tenants, prova de não-vazamento) é obrigatório antes do merge — roda no check `invariants` + +Quais handlers usam o admin client hoje: +`grep -rl 'supabase/admin\|createAdminClient' app/api --include='route.ts'` ### Idempotência & event sourcing leve -- Mensagens WhatsApp e eventos externos: `unique (organization_id, external_id)` + captura `code === '23505'` no INSERT -- POSTs de criação na API aceitam header `Idempotency-Key: ` (TTL 24h via Upstash) -- **Trigger Postgres NUNCA faz HTTP.** Trigger emite linha em `event_log`; worker (cron / Realtime listener) consome e dispara side effect + +- Mensagem de WhatsApp e evento externo: `unique (organization_id, external_id)` + captura de `code === '23505'` no INSERT +- POST de criação aceita `Idempotency-Key: ` (TTL 24h via Upstash). **A adoção é parcial** — + o contrato promete em todo POST de criação e a implementação não chegou lá. Meça antes de + afirmar cobertura: `grep -rln 'Idempotency-Key' app/api --include='*.ts'` +- **Trigger Postgres NUNCA faz HTTP.** Trigger emite linha em `event_log`; worker (cron ou listener + de Realtime) consome e dispara o side effect ### API REST `/api/v1/` + - Versionamento por path. JSON snake_case. UUID v4. ISO-8601 UTC. Dinheiro em `_cents` + `currency` ISO-4217 -- Wrapper sucesso: `{ data, meta?: { cursor, has_more, total } }` -- Wrapper erro: `{ error: { code, message, details? } }` — usar helpers `ok()` / `fail()` de `lib/api/wrappers.ts` +- Sucesso: `{ data, meta?: { cursor, has_more, total } }` · Erro: `{ error: { code, message, details? } }` +- Use sempre `ok()` / `fail()` de `lib/api/wrappers.ts` com código de `lib/api/errors.ts`. Nunca + monte `Response` na mão nem deixe `throw` cru na borda - Paginação: cursor opaco base64+HMAC por default -- Auth dual: cookie session (frontend) OU `Authorization: Bearer tok_...` (server-to-server) -- **API key NUNCA em query string** (vaza em logs Vercel/CF). Sempre header -- Plaintext de bearer token mostrado **uma vez** na criação; depois apenas hash SHA256 no DB -- Rate limit headers: `X-RateLimit-*` + `Retry-After` em 429 -- `X-Request-Id` em toda response (correlaciona com audit log) +- Auth dual: cookie de sessão (frontend) **ou** `Authorization: Bearer tok_...` (server-to-server) +- **API key NUNCA em query string** (vaza em log de proxy/CDN) — sempre header +- Plaintext do bearer é mostrado **uma vez**, na criação; no banco só hash SHA256 +- `X-RateLimit-*` + `Retry-After` no 429 · `X-Request-Id` em toda response (correlaciona com o audit log) ### Auth & RBAC -- Sempre `getUser()` (valida JWT no backend). NUNCA `getSession()` (confia no cookie local) -- 4 roles dentro do tenant: `viewer` (1) < `agent` (2) < `manager` (3) < `admin` (4) -- Super-admin de plataforma é uma role transversal — `is_platform_admin` (decisão final na Spec 01) -- MFA TOTP é **opcional e ligado por quem administra** — não é mais forçado por papel. Quem exige são duas políticas independentes que SOMAM: `platform_admins.mfa_required` (para o super-admin) e `organizations.settings.security.mfa_required` (para o `admin` do tenant). O padrão de ambas é **não exigir**, e o `bootstrap-owner.ts` grava `false` explícito. Regra pura em `lib/auth/politica-mfa.ts` - - **Por que mudou:** o gate era `isPlatformAdmin || role === "admin"`, sem opção, e o `install.sh` cria o dono como platform admin — então TODA instalação self-host recebia um bloqueador de tela cheia logo depois do onboarding, um passo que o wizard nunca anunciou. Decisão do dono do produto; segurança que expulsa o usuário na primeira tela não protege ninguém - - **⚠️ CADASTRAR e PROVAR são perguntas diferentes.** A política decide o cadastro. Já `mfaEmDivida()` — o 403 `mfa_required` das rotas — NÃO consulta a política: quem TEM fator prova na sessão, sempre. Ligá-lo à política faria quem ativa a verificação por vontade própria ter o fator ignorado - - Ligar/desligar vive em **Configurações › Segurança**; desligar o próprio fator exige sessão `aal2` (senão uma sessão roubada desliga a proteção com um clique) -- Permissão por pipeline (`user_pipeline_access`) **NÃO** entra no MVP + +- Sempre `getUser()` (valida o JWT no backend). **NUNCA `getSession()`** (confia no cookie local) +- 4 papéis dentro do tenant: `viewer` (1) < `agent` (2) < `manager` (3) < `admin` (4). Guard + canônico: `requireRole()` em `lib/auth/require-role.ts` +- Super-admin de plataforma é papel transversal — `is_platform_admin` +- Permissão por pipeline (`user_pipeline_access`) **não** entra no MVP +- **MFA TOTP é opcional e ligado por quem administra.** Quem exige são duas políticas + independentes que somam: `platform_admins.mfa_required` (super-admin) e + `organizations.settings.security.mfa_required` (admin do tenant). O default de ambas é **não + exigir**, e `scripts/bootstrap-owner.ts` grava `false` explícito. A regra pura vive em + `lib/auth/politica-mfa.ts` + - **Por quê:** o gate era `isPlatformAdmin || role === "admin"`, sem opção, e o `install.sh` cria + o dono como platform admin — então toda instalação self-host recebia um bloqueador de tela + cheia logo depois do onboarding, um passo que o wizard nunca anunciou. Segurança que expulsa o + usuário na primeira tela não protege ninguém + - **CADASTRAR e PROVAR são perguntas diferentes.** A política decide o cadastro. O 403 + `mfa_required` das rotas (`mfaEmDivida()`) **não** consulta a política: quem TEM fator prova na + sessão, sempre. Ligá-lo à política faria quem ativou a verificação por vontade própria ter o + fator ignorado + - O liga/desliga vive em **Configurações › Segurança**; desligar o próprio fator exige sessão + `aal2` — senão uma sessão roubada desliga a proteção com um clique ### Audit log + - Toda mutação POST/PATCH/DELETE bem-sucedida → 1 entrada em `api_audit_log` (fire-and-forget, p99 ≤500ms) -- **Rodada de cron que não fez nada NÃO é mutação e não audita** — e a que fez, audita. `routing-worker` (1×/min) e `attendant-heartbeat` (1×/5min) auditavam incondicionalmente: ~51.840 linhas/mês numa instalação que não atende ninguém, e numa VPS real **95% do audit log** era batida de cron vazia (`docs/testing/user-journey-map.md`, achado 17). A guarda certa é *auditar quando houve efeito*, nunca *parar de auditar* — as duas direções são medidas por `tests/unit/cron-audita-so-quando-ha-efeito.test.ts`, que varre o AST de **toda** rota de `app/api/v1/cron/` -- Audit é append-only, e isso é do SCHEMA e não da prosa: nenhum papel tem GRANT de UPDATE/DELETE em `api_audit_log` — **nem `service_role`**. Para conferir na fonte em vez de acreditar nesta linha: +- Falha de escrita no audit gera alerta no Sentry; **não** bloqueia a mutação principal +- **Rodada de cron que não fez nada NÃO é mutação e não audita** — e a que fez, audita. Auditar + incondicionalmente enchia o log com batida vazia (numa VPS real, a maior parte do audit log). A + guarda certa é _auditar quando houve efeito_, nunca _parar de auditar_; as duas direções são + medidas por `tests/unit/cron-audita-so-quando-ha-efeito.test.ts`, que varre o AST de **toda** + rota de `app/api/v1/cron/` +- **Append-only é do SCHEMA, não da prosa:** nenhum papel tem GRANT de UPDATE/DELETE em + `api_audit_log` — nem `service_role`. Confira na fonte: ```bash psql "$SUPABASE_DB_URL" -c "select grantee, privilege_type from information_schema.role_table_grants where table_name='api_audit_log' and privilege_type in ('DELETE','UPDATE','TRUNCATE');" ``` - **`TRUNCATE` entra na consulta de propósito, e o resultado não é vazio.** Ele - está concedido a `anon`, `authenticated` e `service_role` — resíduo de o dump - enumerar os privilégios desta tabela (as demais recebem `GRANT ALL`, e quem as - protege é a RLS). Uma sonda que pergunte só por `DELETE`/`UPDATE` devolve zero - linhas e deixa quem leu concluindo que a tabela não pode ser esvaziada, quando - o privilégio que a esvazia INTEIRA está lá. Não é alcançável pela REST (o - PostgREST não emite `TRUNCATE`), então não é buraco de superfície — mas a - frase "append-only é do schema" só é inteira com esta ressalva escrita. -- **Retenção default de 5 anos, configurável, e agora EXECUTADA.** O expurgo é `public.fn_expurgar_auditoria_vencida` (`security definer`, **piso de 90 dias dentro do corpo**, revogada de anon/authenticated), chamada em lotes pelo cron `app/api/v1/cron/data-retention` (diário). O knob é `AUDIT_LOG_RETENTION_DAYS`. **Não há camada cold/S3** — o "hot 90 dias, cold (S3) o resto" que este arquivo afirmava por meses nunca existiu em código (auditoria de 2026-08-14: zero ocorrência de arquivamento), e um self-host não tem para onde arquivar: o Storage do cliente é a MESMA cota de 1 GB, já dividida com `whatsapp-media`. Para ver o que está em vigor: `grep -n "RETENCAO_AUDITORIA_DIAS" lib/retencao/politica.ts` -- Por que uma `security definer` de expurgo não é porta de adulteração (o argumento inteiro está no cabeçalho da migration 0167): ela **não tem seletor de linha** — nenhum parâmetro de org, ator, ação ou id, e o único predicado é `created_at < now() - N dias`; o piso mora **no corpo**, não em quem chama; não é alcançável pela REST; não amplia o raio de quem já tem a service key; e **registra a própria erosão** (`retention.sweep_run`, com a contagem, numa linha nova demais para a chamada seguinte alcançar) -- Falha de write em audit gera alerta Sentry, não bloqueia mutação principal + **`TRUNCATE` entra na consulta de propósito, e o resultado não vem vazio:** ele está concedido a + `anon`, `authenticated` e `service_role`, resíduo de o dump enumerar os privilégios desta tabela. + Não é alcançável pela REST (o PostgREST não emite `TRUNCATE`), então não é buraco de superfície — + mas uma sonda que pergunte só por `DELETE`/`UPDATE` devolve zero linhas e deixa quem leu + concluindo que a tabela não pode ser esvaziada, quando o privilégio que a esvazia INTEIRA está lá + +- **Retenção default de 5 anos, configurável e executada.** O expurgo é + `public.fn_expurgar_auditoria_vencida` (`security definer`, **piso de 90 dias dentro do corpo**, + revogada de anon/authenticated), chamada em lotes pelo cron `app/api/v1/cron/data-retention` + (diário). O knob é `AUDIT_LOG_RETENTION_DAYS`; a regra em vigor: + `grep -n "RETENCAO_AUDITORIA_DIAS" lib/retencao/politica.ts` +- **Não há camada cold/S3.** Um self-host não tem para onde arquivar: o Storage do cliente é a + MESMA cota, já dividida com `whatsapp-media` +- Por que uma `security definer` de expurgo não é porta de adulteração: ela **não tem seletor de + linha** — nenhum parâmetro de org, ator, ação ou id, e o único predicado é + `created_at < now() - N dias`; o piso mora **no corpo**, não em quem chama; não é alcançável pela + REST; não amplia o raio de quem já tem a service key; e **registra a própria erosão** + (`retention.sweep_run`, com a contagem, numa linha nova demais para a chamada seguinte alcançar). + Argumento completo no cabeçalho da migration 0167 ### LGPD -- Anonimização preferida sobre delete. Nome do contato vira `Cliente Anonimizado #N` + +- Anonimização é preferida sobre delete. Nome do contato vira `Cliente Anonimizado #N` - Cascade de redact: contact + conversations + messages (mídia removida do storage) + activities (preserva timestamps) - Reversão de anonimização: 403 `lgpd_anonymization_irreversible` -- SLA: data_request entregue D+7; redact executado D+15 -- 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) -- 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` -- Anti-banimento: throttle 1 msg/1.2s + jitter ≤800ms. Campanha 1 msg/5s. Warm-up 7-14d. Spinning de copy. Janela 7h-22h (domingo LIBERADO por default desde 2026-08-20; a janela é knob por canal) -- STOP detection: a regra mora em `lib/opt-out/deteccao.ts` e é a MESMA nos dois lados — - a ingestão (que grava `is_blocked=true`) e o runtime do agente. **Não é mais a palavra - solta:** só bloqueia palavra ISOLADA (mensagem inteira = a palavra) ou verbo de cessação - com OBJETO DE COMUNICAÇÃO ("parar de me mandar", "sair da lista"). Enquanto eram duas - regras, a ingestão bloqueava paciente que perguntou "tem como parar a dor?" — medido em - clínica, 12 falsos positivos num corpus de 32 frases de nicho. - Para ver o vocabulário em vigor sem confiar nesta linha: - `grep -n 'PALAVRAS_DE_OPT_OUT' -A20 lib/opt-out/deteccao.ts`, e as frases de controle em - `tests/unit/opt-out-deteccao.test.ts`. **Espanhol ainda NÃO é coberto** (`baja`, `salir`, - `no quiero recibir`) — ver PR #275. -- Mídia: subir pro Supabase Storage primeiro, passar URL ao WAHA (não inline base64) -- Multi-device: assinar `message.any` (não só `message`); tratar `fromMe=true` sem duplicar -- Grupos: SKIP CRM binding se `chatId.endsWith('@g.us')`. Sender é `p.author`, não `p.from` -- Cron `recover-stuck-messages` (`app/api/v1/cron/recover-stuck-messages/route.ts`, agendado no `scheduler` do `docker-compose.prod.yml`): marca `status='sending'` há >5min como `failed` **e abre aviso na Central** (`agent_inbox_items` kind `message_send_stuck`). Não toca em `queued`: esse estado tem dono (o agent-engine reagenda por `SEND_QUEUED_RETRY_MS`), e falhá-lo perderia mensagem que ia sair. Não reenvia — envio em dobro é pior que não-envio +- SLA: `data_request` entregue em D+7; redact executado em D+15 +- Audit obrigatório: `lgpd.data_request_received`, `lgpd.export_generated`, `lgpd.redact_executed`, `lgpd.consent_changed` + +### WhatsApp / WAHA + +- WAHA **Plus** obrigatório (o Core não suporta multi-tenant, não tem retry nem S3). Engine NOWEB + default; WEBJS só se precisar de sticker animado / botão +- Auth: o env do WAHA recebe **hash SHA512 hex** da api key; o cliente envia o plaintext em `X-Api-Key` +- Webhook: HMAC SHA512 com `crypto.timingSafeEqual`, fail-closed quando o secret falta +- Anti-banimento: throttle 1 msg/1,2s + jitter ≤800ms; campanha 1 msg/5s; warm-up 7–14 dias; + spinning de copy; janela 7h–22h (domingo liberado por default; a janela é knob por canal) +- **Opt-out (STOP):** a regra mora em `lib/opt-out/deteccao.ts` e é a MESMA nos dois lados — a + ingestão (que grava `is_blocked=true`) e o runtime do agente. **Não é palavra solta:** bloqueia + palavra ISOLADA (a mensagem inteira é a palavra) ou verbo de cessação com OBJETO DE COMUNICAÇÃO + ("parar de me mandar", "sair da lista"). Enquanto eram duas regras, a ingestão bloqueava paciente + que perguntou "tem como parar a dor?". Vocabulário em vigor: + `grep -n 'PALAVRAS_DE_OPT_OUT' -A20 lib/opt-out/deteccao.ts`; frases de controle em + `tests/unit/opt-out-deteccao.test.ts`. **Espanhol ainda não é coberto** (`baja`, `salir`, `no quiero recibir`) +- Mídia: sobe para o Supabase Storage primeiro e passa a URL ao WAHA (nunca base64 inline) +- Multi-device: assine `message.any` (não só `message`); trate `fromMe=true` sem duplicar +- Grupos: pule o binding com o CRM se `chatId.endsWith('@g.us')`. O remetente é `p.author`, não `p.from` +- O cron `app/api/v1/cron/recover-stuck-messages/route.ts` marca `status='sending'` há >5min como + `failed` **e abre aviso na Central** (`agent_inbox_items`, kind `message_send_stuck`). Não toca em + `queued` — esse estado tem dono (o agent-engine reagenda por `SEND_QUEUED_RETRY_MS`) e falhá-lo + perderia mensagem que ia sair. Não reenvia: envio em dobro é pior que não-envio ### Marca própria (white-label) -- **Uma imagem Docker serve todas as marcas.** Nada de `NEXT_PUBLIC_*` para marca, nada de `public/favicon.ico`, nada de imagem por revendedor — a imagem é pré-buildada e o `update.sh` regrava `APP_IMAGE` incondicionalmente -- **O banco está ACIMA do `.env`.** `platform_branding` (instalação) e `organizations.settings.branding` (organização) são a fonte; `APP_NAME`/`APP_LOGO_URL`/`APP_ACCENT_HEX` são **semente e piso de rollback** (o `agent.sh` reverte a imagem, nunca o banco) -- **Resolvedor NUNCA lança.** `lib/branding/instalacao.ts` e `lib/branding/saida.ts` degradam para o padrão do produto e seguem: `branding()` roda em `app/layout.tsx`, e um throw ali é 500 em todas as telas -- **Saída sem DOM usa `marcaDaSaida()`** (`lib/branding/saida.ts`) — e-mail, remetente, ícone, `issuer` do MFA. Um hex e uma frente legível, tema **claro** sempre. Nunca passe `MarcaResolvida` a template de e-mail -- **O PDF de LGPD NUNCA leva marca.** Ele nomeia o **controlador** (`organizations.legal_name`) e o DPO resolvido. Nomear ali o revendedor — que é operador — inverteria papéis num documento que responde a direito legal. Vigiado em `tests/unit/mapas-de-arquitetura.test.ts` -- Vazamento de marca no código é vigiado por `tests/unit/branding.test.ts` (varre `app|components|lib|workers|hooks`), com allowlist que **só encolhe**. Contexto de venda em [`docs/white-label.md`](docs/white-label.md); mapa em `docs/architecture/marca-propria.architecture.json` + +O produto é revendido, e o nome não é seu. + +- **Nunca escreva "Deskcomm"/"DeskcommCRM" em código que alcança o usuário.** + `tests/unit/branding.test.ts` varre `app|components|lib|workers|hooks` e reprova; a allowlist **só encolhe** +- **Uma imagem Docker serve todas as marcas.** Nada de `NEXT_PUBLIC_*` para marca, nada de + `public/favicon.ico`, nada de imagem por revendedor +- **O banco está ACIMA do `.env`:** `platform_branding` (instalação) e + `organizations.settings.branding` (organização) são a fonte; `APP_NAME` / `APP_LOGO_URL` / + `APP_ACCENT_HEX` são **semente e piso de rollback** (o `agent.sh` reverte a imagem, nunca o banco) +- **O resolvedor NUNCA lança:** `lib/branding/instalacao.ts` e `lib/branding/saida.ts` degradam para + o padrão do produto e seguem. `branding()` roda em `app/layout.tsx`, e um throw ali é 500 em todas as telas +- **Saída sem DOM usa `marcaDaSaida()`** (`lib/branding/saida.ts`) — e-mail, remetente, ícone, + `issuer` do MFA: um hex e uma frente legível, tema claro sempre. Nunca passe `MarcaResolvida` a + template de e-mail +- **O PDF de LGPD NUNCA leva marca.** Ele nomeia o **controlador** (`organizations.legal_name`) e o + DPO resolvido. Nomear ali o revendedor — que é operador — inverteria papéis num documento que + responde a direito legal. Vigiado em `tests/unit/mapas-de-arquitetura.test.ts` +- Contexto de venda em [`docs/white-label.md`](docs/white-label.md); mapa em `docs/architecture/marca-propria.architecture.json` ### Doutrina DIRC (antes de adicionar campo) -- **D**uplicar — vive aqui mesmo? -- **I**ntegrar — vem de outra tabela via FK? -- **R**eferenciar — só ponteiro? -- **C**alcular — pode ser computado on-demand? + +**D**uplicar — vive aqui mesmo? · **I**ntegrar — vem de outra tabela via FK? · +**R**eferenciar — só ponteiro? · **C**alcular — dá para computar on-demand? ### Modelagem -- 5 tabelas core CRM: `crm_pipelines`, `crm_stages`, `crm_leads`, `crm_lead_activities` (polimórfica timeline), `crm_lead_links` (polimórficos vínculos) + +- 5 tabelas core de CRM: `crm_pipelines`, `crm_stages`, `crm_leads`, `crm_lead_activities` + (timeline polimórfica), `crm_lead_links` (vínculos polimórficos) - `position_in_stage numeric` (fractional indexing via `midpoint()`) — **NUNCA `int`** -- `external_id` nullable (mensagem outbound `sending` ainda não tem ID WAHA) -- `type` é `text` + `check constraint`, **não enum** (enum é difícil de estender) - - **Exceção deliberada — colunas de vocabulário ABERTO:** onde um clone pode ter linhas com valor - legado (ex.: `crm_lead_activities.type`), o CHECK **não** entra: a constraint faria o `update.sh` - do clone quebrar, e a doutrina de migrations proíbe. Nesses casos o vocabulário vive só no +- `external_id` nullable (mensagem outbound em `sending` ainda não tem ID do WAHA) +- `type` é `text` + check constraint, **não enum** (enum é difícil de estender) + - **Exceção deliberada — coluna de vocabulário ABERTO:** onde um clone pode ter linha com valor + legado (ex.: `crm_lead_activities.type`), o CHECK **não** entra: a constraint quebraria o + `update.sh` do clone, e a doutrina de migrations proíbe. Nesses casos o vocabulário vive só no TypeScript, o emissor usa **constante compartilhada, nunca string literal**, e a coluna fica **fora** do invariante `tests/invariants/vocabulario-banco-x-typescript.test.ts` — que cobre - apenas colunas que JÁ têm CHECK. Ver o cabeçalho desse arquivo antes de "completar" o schema. -- `tags text[]` + GIN index; promove pra coluna gerada apenas quando vira hot path + apenas colunas que JÁ têm CHECK. Leia o cabeçalho desse arquivo antes de "completar" o schema +- `tags text[]` + índice GIN; promove para coluna gerada só quando virar hot path - `custom_fields jsonb` com schema declarativo em `pipeline.settings.fields`; Zod construído dinamicamente -- `vocabulary jsonb` em pipeline permite renomear lead/deal/won/lost (e-commerce: lead=Cliente, deal=Pedido, won=Pago, lost=Cancelado) +- `vocabulary jsonb` no pipeline permite renomear lead/deal/won/lost (e-commerce: lead=Cliente, + deal=Pedido, won=Pago, lost=Cancelado) --- ## Anti-patterns proibidos -1. String que deveria ser FK (ex: `owner_email text` em vez de `owner_user_id uuid`) +1. String que deveria ser FK (`owner_email text` em vez de `owner_user_id uuid`) 2. Duplicação sem source of truth declarado 3. Evento sem consumer (emite e ninguém escuta) 4. FK ausente que vira inferência por nome 5. Campo sincronizado por cron quando devia ser realtime/trigger -6. `jsonb` lock-in (UI lê path direto sem schema central) -7. Cascade fantasma (deletar contact cascade em messages perde histórico) -8. Polimórfico sem padronização (`target_kind` cada lugar grava diferente) -9. **Trigger Postgres faz HTTP** (letal — espera rede dentro da transação) -10. Service role usado em request handler sem filtrar `organization_id` manualmente +6. `jsonb` lock-in (UI lê path direto, sem schema central) +7. Cascade fantasma (deletar contact em cascade nas messages perde histórico) +8. Polimórfico sem padronização (`target_kind` gravado diferente em cada lugar) +9. **Trigger Postgres fazendo HTTP** (letal — espera rede dentro da transação) +10. Service role em request handler sem filtrar `organization_id` manualmente 11. `getSession()` no backend 12. API key em query string -13. Bearer plaintext armazenado no DB (deve ser hash SHA256) -14. `console.log` deixado em código merged (use logger estruturado ou Sentry breadcrumb) +13. Bearer plaintext armazenado no banco (deve ser hash SHA256) +14. `console.log` em código merged (use `lib/logger.ts` ou breadcrumb do Sentry) --- ## Paths importantes -| Path | Conteúdo | -|---|---| -| `docs/prd/00-prd-master.md` | Visão geral, escopo MVP, KPIs | -| `docs/prd/01-prd-platform-base.md` | Auth, tenancy, RBAC, LGPD framework | -| `docs/prd/02-...06-` | Customer 360, WhatsApp, Pipeline, IA-RAG, Nuvemshop | -| `docs/specs/` | Specs técnicas detalhadas (schema SQL, payloads exatos) | -| `docs/business-rules/` | Regras de negócio fora do código | -| `docs/research/reference-synthesis.md` | Arquitetura herdada do curso WAHA | -| `tasks/todo.md` | Workflow de construção atual | -| `lib/api/wrappers.ts` | `ok()`, `fail()`, tipos `ApiSuccess` / `ApiError` | -| `lib/api/errors.ts` | Códigos de erro canônicos | -| `lib/env.ts` | Validação Zod das env vars (lança no startup se faltar crítica) | -| `lib/supabase/{browser,server,admin}.ts` | Clients canônicos | -| `app/api/v1/health/route.ts` | Health check (Supabase + Redis + WAHA) | -| `supabase/migrations/` | Schema versionado | -| `docs/runbooks/deploy.md` | **Deploy em produção — leia ANTES de mexer na VPS** | +| Path | Conteúdo | +| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `app/api/v1/` | Route handlers REST (versionado por path) | +| `app/api/internal/`, `app/api/mcp/`, `app/api/v1/cron/` | Superfícies não-cookie (secret / bearer próprio) | +| `app/app/`, `app/admin/` | UI autenticada do tenant · UI de plataforma | +| `app/actions/` | Server Actions (auth, onboarding, team, settings) | +| `lib/agent-engine/`, `lib/ai/` | Runtime do agente, guardrails, RAG, dispatcher | +| `lib/api/wrappers.ts` | `ok()` / `fail()` e os tipos `ApiSuccess` / `ApiError` | +| `lib/api/errors.ts` | Códigos de erro canônicos | +| `lib/auth/require-role.ts` | `requireRole()` — guard canônico de RBAC | +| `lib/env.ts` | Validação Zod das env vars (lança no startup se faltar crítica) | +| `lib/supabase/browser.ts`, `lib/supabase/server.ts`, `lib/supabase/admin.ts` | Clients canônicos | +| `lib/navigation/registry.ts` | Registro de telas — é o que dá porta a uma tela nova | +| `workers/` | Workers de `event_log` + crons | +| `proxy.ts` | Middleware do Next 16 (auth de borda, `X-Request-Id`) | +| `supabase/migrations/` | Schema versionado · `supabase/baseline.sql` = o que o self-host aplica | +| `docs/prd/00-prd-master.md` | Visão geral, escopo do MVP, KPIs | +| `docs/specs/` | Specs técnicas (schema SQL, payloads exatos) | +| `docs/business-rules/` | Regras de negócio fora do código | +| `docs/runbooks/deploy.md` | **Deploy em produção — leia ANTES de mexer na VPS** | + +### Arquivos e diretórios SENSÍVEIS + +- **`supabase/baseline.sql`** — é o que o `install.sh`/`update.sh` aplicam. Mudança de schema que + não aparece aqui **não chega a quem instalou** +- **`supabase/migrations/`, arquivos já aplicados** — nunca edite; corrija com migration nova +- **`lib/supabase/admin.ts`** — service role bypassa RLS; o filtro de `organization_id` é sua responsabilidade +- **`lib/auth/public-paths.ts`** — adicionar path aqui remove a checagem de auth de borda. Só com + guard próprio dentro da rota +- **`.env*`** — não abra, não copie valor, não logue. Só `.env.example` é template +- **`docker-compose.traefik.yml`** — o único lugar que dá ao contêiner `app` as labels de roteamento (ver Deploy) + +### Arquivos GERADOS — não editar à mão + +`lib/database.types.ts` (gerado do schema Supabase) · `graphify-out/` · `pnpm-lock.yaml` · +`tsconfig.tsbuildinfo` · `next-env.d.ts` · `.next/` --- -## Deploy em produção (NÃO NEGOCIÁVEL) - -**Numa VPS que já tem proxy reverso próprio (Hostinger, Coolify, Dokploy…), todo -`up -d` leva os DOIS arquivos de compose:** +## Testes e gates ```bash -docker compose -f docker-compose.prod.yml -f docker-compose.traefik.yml --env-file .env up -d app +pnpm typecheck # tsc --noEmit (estrito) +pnpm lint # eslint +pnpm lint:channels # invariante 1 de docs/doctrine/restricao-de-canal.md: nenhuma feature nomeia um provider +pnpm test:unit # Vitest — EXCLUI tests/invariants/** e tests/e2e/** +pnpm test:db # Postgres efêmero + baseline em install E update + invariantes (PRECISA de Docker) +pnpm test:e2e # Playwright (PRECISA de app rodando + banco semeado) +pnpm test:shell # kit self-host (update.sh, entrypoint do scheduler, validadores do install.sh) +pnpm gov:verify # atalho local = typecheck + lint + lint:channels + test:unit ``` -Omitir `-f docker-compose.traefik.yml` recria o contêiner sem as labels de -roteamento; o Traefik da hospedagem deixa de enxergá-lo e **o domínio inteiro -responde `404 page not found`** — com o contêiner `healthy`, porque o -healthcheck é um probe TCP interno e não sabe nada de roteamento. +**Os invariantes não estão no `test:unit`.** `vitest.config.ts` exclui `tests/invariants/**` de +propósito: a suíte precisa de um Postgres real e roda via `vitest.db.config.ts`, orquestrada por +`scripts/test-db.sh`. Rodar só `pnpm test:unit` e concluir "está tudo verde" é falso verde — o +isolamento de RLS não foi exercitado. -Depois de qualquer deploy, confirme que o domínio responde **307** (redireciona -pro login) e não 404. Verificações e o caso de build local em -`docs/runbooks/deploy.md`. +⚠️ **`pnpm gov:verify` não cobre tudo:** omite `test:db`, `test:e2e` e `test:shell`. Se sua mudança +toca schema, RLS, UI ou o kit, verde ali **não é prova**. Ver [`docs/harness-audit.md`](docs/harness-audit.md). -O caminho normal **não constrói nada na VPS**: commit → push → PR → merge na -`main` → o CI publica no GHCR → a VPS puxa. Imagem construída na VPS é exceção -de emergência e é dívida: existe só naquele disco e qualquer `up -d` sem -`APP_PULL_POLICY=never` a substitui em silêncio. +### O que o CI cobre -Essa frase já foi meia-verdade: valia para o `app` e era falsa para o produto, -porque o serviço `worker` não tinha `image:` — era construído na VPS de todo -cliente e nunca reconstruído por nenhum `update.sh`. Hoje os três serviços -nossos (`app`, `worker`, `scheduler`) são imagens publicadas, e um teste -reprova o retorno do padrão. Ver a doutrina abaixo. +| Check | Workflow | O que roda | +| ---------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `verify` | `.github/workflows/ci.yml` | `typecheck` + `lint` + `lint:channels` + `test:unit` + `test:shell` | +| `invariants` | `.github/workflows/ci.yml` | `pnpm test:db` — `pgvector/pgvector:pg17`, `baseline.sql` em install **e** update, isolamento RLS + governança | +| `build-and-size` | `.github/workflows/perf.yml` | `pnpm build` em Node 22 | +| `e2e` | `.github/workflows/e2e.yml` | Playwright contra Supabase local com o `baseline.sql` aplicado | +| `imagens-ok` | `.github/workflows/publish-image.yml` | As três imagens Docker constroem | ---- - -## Packaging e distribuição — DOUTRINA (NÃO NEGOCIÁVEL) - -Lei completa em [`docs/doctrine/packaging.md`](docs/doctrine/packaging.md); -decisões estruturais e o que foi recusado em -[`docs/adr/0001-packaging-e-distribuicao.md`](docs/adr/0001-packaging-e-distribuicao.md). -O não-negociável, em quatro linhas: - -1. **Nenhum serviço de `docker-compose.prod.yml` constrói na máquina do - cliente.** Todo serviço declara `image:` de uma imagem publicada; `build:` - só existe **ao lado**, como escape. Serviço `build:`-only é invisível para - `docker compose pull` e imune a `up -d` sem `--build` — ele não é só caro de - instalar, ele **nunca é atualizado**. -2. **Publicação é ato do CI.** Nunca da sua máquina: build ARM local não roda - na VPS amd64 do cliente, e a falha só aparece no `up -d` dele. O job - `imagens-ok` reprova quando qualquer uma das três imagens não constrói, e - **é status check obrigatório desde 2026-08-13** — a branch protection tem - `verify, build-and-size, invariants, e2e, imagens-ok`. (Este parágrafo dizia - "ainda não é obrigatório" até 2026-08-14; a ativação era o passo final do - merge da doutrina e aconteceu.) Confira na fonte antes de confiar nesta linha. -3. **Instalação de cliente aponta para número de versão, nunca para tag móvel.** - `latest` aqui significa **topo da `main`**, não última release — quem quer a - última release usa `stable`. `pull_policy` acompanha a mutabilidade da tag: - imutável → `missing`, móvel → `always`. -4. **Dependência upstream é referenciada com tag fixa, nunca republicada.** - Vale para WAHA (licenciado — republicar é passivo jurídico), Redis, Caddy e - `serverless-redis-http`. - -Bump de versão **não pode** exigir que o operador da VPS edite `.env`, compose -ou qualquer arquivo à mão. Se exigir, não entra: vira issue com plano de -migração e vai para uma major. - ---- - -## Como rodar local +**Os cinco são checks obrigatórios na branch protection da `main`.** Confira na fonte em vez de +confiar nesta tabela: ```bash -nvm use # node 22 -npm install -cp .env.example .env.local # preencher -docker compose up -d # WAHA local -npm run dev # http://localhost:3000 +gh api repos/melgarafael/DeskcommCRM/branches/main/protection --jq '.required_status_checks.contexts|join(", ")' ``` -Ver `README.md` pra detalhes de setup. - ---- - -## Testes +**O `e2e` roda todas as specs de `tests/e2e/`, menos as declaradas em `FORA_DO_CI`** — hoje só +`vps-fresh-onboarding`, que precisa de WAHA + Redis + Resend + Nuvemshop. Ou seja: **`e2e` verde não +prova a jornada de instalação fresca**, que é a P0 da doutrina de QA Visual e o produto que se +vende. Quem está dentro e quem está fora é enumerável, e +`tests/unit/e2e-cobertura-completa.test.ts` reprova spec que sumiu de todas as listas: ```bash -pnpm typecheck # tsc --noEmit (estrito) -pnpm lint # eslint next/core-web-vitals -pnpm test:unit # Vitest (NÃO inclui tests/invariants/** — ver abaixo) -pnpm test:db # Postgres efêmero + baseline install/update + 364 invariantes -pnpm test:e2e # Playwright (requer dev server) +ls tests/e2e/*.spec.ts | wc -l # specs no disco +grep -A20 'FORA_DO_CI: >-' .github/workflows/e2e.yml # o que o CI declara não cobrir ``` -**Os invariantes não estão no `test:unit`.** `vitest.config.ts` exclui `tests/invariants/**` de propósito: essa suíte precisa de um Postgres real e roda via `vitest.db.config.ts`, orquestrada por `scripts/test-db.sh`. Rodar só `pnpm test:unit` e concluir "está tudo verde" é um falso verde — o isolamento RLS não foi exercitado. +Ao mexer em schema, RLS, RBAC, atribuição, escopo, roteamento, follow-up, webhooks ou automações: +rode `pnpm test:db` **localmente** antes de abrir PR. É o único caminho que exercita o +`baseline.sql` que o self-hoster realmente aplica. -Checks **obrigatórios** na branch protection da `main` (verificado na configuração, não só no papel): - -- **`verify`** (`ci.yml`) — typecheck + lint + test:unit. -- **`invariants`** (`ci.yml`) — `pnpm test:db`: sobe `pgvector/pgvector:pg17`, aplica `supabase/baseline.sql` em modo install (`ON_ERROR_STOP=1`) e update (idempotência), e roda os testes de invariante, incluindo o de isolamento RLS entre 2 organizações. -- **`build-and-size`** (`perf.yml`) — `pnpm build` em Node 22. -- **`e2e`** (`e2e.yml`) — sobe Supabase local, aplica o `baseline.sql` e roda **48 das 49 specs** Playwright (medido em 2026-08-14 @ `587a494d`; **reconte antes de citar** — este número já apodreceu **quatro** vezes). A **única** de fora é `vps-fresh-onboarding` (precisa de WAHA + Redis + Resend + Nuvemshop) — e ela é a **P0** da doutrina de QA Visual, ou seja, `e2e` verde **não** prova a jornada de instalação fresca, que é o produto que se vende. +--- - **A receita antiga de recontagem estava errada** e é provavelmente uma das causas do apodrecimento. `grep -oE '[a-z0-9-]+\.spec\.ts' .github/workflows/e2e.yml | sort -u | wc -l` devolve **49**, não 48 — mas *não* pelo motivo que este parágrafo afirmava até 2026-08-14. Ele dizia "conta menções em COMENTÁRIOS do workflow", e isso é falso: medido, o conjunto de specs citadas fora de variável é **vazio**. O excedente é a `FORA_DO_CI`, que é uma **variável YAML** como as outras — o grep não distingue a variável que o CI *invoca* da que ele só *declara*. Medir o arquivo inteiro mede quem é citado, não quem é invocado. O que roda são as `SPECS_PARTE_*`: +## QA Visual com recursos reais — DOUTRINA + +**O DeskcommCRM é distribuído open-source: a experiência de quem instala numa VPS É o produto.** +Toda feature nova (ou fix de comportamento visível) DEVE ser provada como um usuário leigo a usaria +de verdade — pelo frontend, num ambiente que imita a instalação fresca — antes de "pronto". + +O que conta como recurso real: + +- **Prova pela tela**, dirigindo o browser (Playwright), com conta de teste real. `curl` e chamada + de API **não** provam UX — validam o backend. Use curl só como diagnóstico +- **Banco fresco estilo VPS:** Postgres limpo com `supabase/baseline.sql` (não a cadeia de + migrations) + `scripts/bootstrap-owner.ts` — o que o `install.sh` faz +- **Dependências como na VPS:** WAHA local, Redis local (`redis` + `serverless-redis-http`), cron + drenado por endpoint. E **teste com os envs opcionais AUSENTES** (ex.: sem `RESEND_API_KEY`): é o + estado real de um primeiro deploy, e é onde moram os piores bugs de primeira impressão +- **Efeito colateral externo provado com receiver real** (webhook outbound, envio). Mock não + estressa o egress real — anti-SSRF, projeção de payload, https em prod +- **Medida de front-end por ferramenta, nunca a olho:** `getBoundingClientRect` / `getComputedStyle` + no Playwright + +**Prioridade: primeira impressão acima de tudo.** Onboarding e as primeiras ações (criar conta, +conectar canal, primeiro lead, primeiro convite) são a primeira impressão — bug ali é abandono. + +Registro obrigatório, senão o progresso é invisível: mapa de jornadas em +[`docs/testing/user-journey-map.md`](docs/testing/user-journey-map.md) (casos, prioridade `[P0]`, +achados), spec em `tests/e2e/` que dirige o **frontend**, evidência visual em +`.superpowers/evidence/`. Bug achado executando → conserta na causa raiz, com migration versionada +se tocar schema, commit próprio e re-teste verde como prova. + +**Receita de ambiente fresco (não-óbvia):** banco = `baseline.sql` num Supabase local **pg17** +(`config.toml major_version = 17`; o baseline usa `GRANT MAINTAIN`, privilégio pg17+); +`next build` + `next start` (produção — `next dev` compila lento demais e o Turbopack quebra +`cookies()`); **worktree com `node_modules` real, nunca symlink** (o Turbopack rejeita symlink "out +of filesystem root") e **fora de `/tmp`** (é limpo no meio da sessão — commite cada marco). - ```bash - ls tests/e2e/*.spec.ts | wc -l # 49 em disco - python3 - <<'PY' # 48 que o CI invoca - import re - y = open(".github/workflows/e2e.yml", encoding="utf-8").read() - print(len({s for _, c in re.findall(r'(SPECS_PARTE_\d+):\s*>-\n((?:[ ]{8,}.*\n)+)', y) - for s in re.findall(r'[a-z0-9-]+\.spec\.ts', c)})) - PY - ``` - - **E a recontagem já não é o conserto.** A quarta vez era a condição que o PR #242 pôs para parar de recontar — ela aconteceu. O conserto devido é `tests/unit/e2e-cobertura-completa.test.ts` passar a cobrar também o texto daqui, como já cobra as três listas do workflow (medido em 2026-08-14: ele não cobra — `grep -c 'CLAUDE.md' tests/unit/e2e-cobertura-completa.test.ts` devolve 0). Prosa que nenhum gate lê é prosa que diverge — e uma triagem que a use como régua mede contra o número errado, que é o modo de falha nº 1 do procedimento. -- **`imagens-ok`** (`publish-image.yml`) — reprova quando qualquer uma das três imagens Docker não constrói. **É obrigatório desde 2026-08-13**; este arquivo dizia o contrário em outro parágrafo (ver a doutrina de packaging acima, já corrigida). +--- -Todos os **cinco** são **obrigatórios** — medido em 2026-08-14 na branch protection: +## Migrations & banco — DOUTRINA + +**Este projeto é open-source: toda mudança de schema DEVE sair como migration versionada.** Quem +clonou uma versão antiga precisa conseguir atualizar aplicando as migrations em ordem. **Nunca** +aplique `ALTER`/`CREATE` solto no banco sem o arquivo correspondente. + +1. **Arquivo versionado** em `supabase/migrations/`, no padrão `__.sql`. + `NNNN` é o próximo sequencial (`ls supabase/migrations/ | tail -3`) +2. **Idempotente sempre que possível:** `add column if not exists`, `create ... if not exists`, + `create or replace function`. Reaplicar não pode quebrar nem duplicar efeito +3. **Portável em `psql` puro** (clones podem não usar o CLI/MCP do Supabase): sem + `create temporary table ... on commit drop` fora de transação explícita; sem `BEGIN`/`COMMIT` + explícito (o runner já envolve em transação). Prefira CTEs, subqueries de janela e colunas-mapa +4. **Data migration genérica:** se corrige ou deduplica dados, escreva pensando em QUALQUER banco de + clone (não hardcode IDs do seu tenant). Repointe FKs conferindo o catálogo (`information_schema`) + para não perder histórico +5. **Registre no MANIFEST:** uma linha em `supabase/migrations/MANIFEST.md` (tabela "Applied") com + versão, nome e o QUÊ/PORQUÊ +6. **Reflita no `supabase/baseline.sql` — OBRIGATÓRIO.** O baseline é um dump `--schema-only` mais + um **apêndice idempotente** no fim do arquivo (blocos rotulados + `-- ---- (migration NNNN) ----`). O kit aplica **só o baseline**: no `install.sh` (banco + novo, `ON_ERROR_STOP=1`) e no `update.sh` (re-aplica em banco existente, **sem** a flag). Toda + mudança pós-snapshot entra no apêndice, idempotente e auto-curativa. Migration que só existe em + `supabase/migrations/` **não chega ao self-hoster** +7. **Aplique e prove:** capture o estado ANTES/DEPOIS e prove invariantes (ex.: contagem que não + pode mudar). Se mexeu em contrato, regenere `lib/database.types.ts`. Valide o baseline num + Postgres descartável (`pgvector/pgvector:pg17`) em modo install **e** update — ambos têm que passar +8. **Backfill antes da constraint:** constraint nova falha se os dados atuais a violam. Deduplique ou + corrija ANTES de criar — na migration **e** no apêndice do baseline +9. **Função nova em `public` nasce EXPOSTA — revogue as DUAS origens:** -```console -$ gh api repos/melgarafael/DeskcommCRM/branches/main/protection --jq '.required_status_checks.contexts|join(", ")' -verify, build-and-size, invariants, e2e, imagens-ok -``` + ```sql + revoke execute on function public.fn_x(...) from public, anon; + grant execute on function public.fn_x(...) to ; + ``` -Duas correções que este bloco já pagou: o `e2e` entrou para a lista depois de o arquivo ser escrito, e -a versão anterior dizia que ele "ainda não é obrigatório"; depois o `imagens-ok` entrou e o arquivo -seguiu dizendo "quatro". Uma triagem que leia qualquer uma dessas versões mede contra a régua errada — -que é o modo de falha nº 1 do procedimento de triagem. **Reconfira na fonte antes de confiar em -qualquer lista aqui**, com o comando acima. + São origens distintas de `EXECUTE`, e tratar só uma deixa a função exposta com o gate verde: + **(A)** o grant direto a `anon` do `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon` + do baseline, que vale para toda função criada depois dele — isto é, para todo apêndice novo — e + que `revoke from public` **não** remove; **(B)** o grant a `PUBLIC` que o Postgres dá a qualquer + função ao criá-la, que `revoke from anon` **não** remove. Sem os dois, o PostgREST expõe a função + como RPC alcançável pela anon key, que vai para o browser. Vigiado por + `tests/invariants/hardening-definer-varredura.test.ts` -Ao mexer em schema, RLS, RBAC, atribuição, escopo, roteamento, follow-up, webhooks ou automações: rode `pnpm test:db` **localmente** antes de abrir PR. É o único caminho que exercita o `baseline.sql` que o self-hoster realmente aplica. +**Resumo:** arquivo em `supabase/migrations/` **+** apêndice idempotente no `supabase/baseline.sql` +**+** linha no MANIFEST. Os três andam juntos. Nunca edite migration já aplicada — corrija com +forward-fix. --- -## QA Visual com Recursos Reais — DOUTRINA (produto self-host) +## Deploy em produção — NÃO NEGOCIÁVEL -**O DeskcommCRM é distribuído open-source: a experiência de quem instala numa VPS É o produto.** Toda feature nova (ou fix de comportamento visível) DEVE ser provada como um **usuário leigo a usaria de verdade** — pelo frontend, num ambiente que imita a instalação fresca — antes de "pronto". Não é opcional; é critério de aceite de toda sessão que toca UI ou fluxo de usuário. +**Numa VPS que já tem proxy reverso próprio (Hostinger, Coolify, Dokploy…), todo `up -d` leva os +DOIS arquivos de compose:** -**O que "recurso real" significa (e o que NÃO conta):** -- **Conta.** Prova pela tela, dirigindo o browser (Playwright), logando com conta de teste real. `curl`/chamada de API **não** provam UX — validam o backend, mas não o que o usuário vê, clica e entende. Use curl só como diagnóstico. -- **Banco fresco estilo VPS.** Postgres limpo aplicado do `supabase/baseline.sql` (não das `migrations/` — a cadeia fresh não sobe) + `scripts/bootstrap-owner.ts` (o que o `install.sh` faz). O ambiente do teste = o que o clone recém-instalado tem: sem os seus dados, sem os seus envs opcionais. -- **Dependências como na VPS.** WAHA local, Redis local (`redis` + `serverless-redis-http`), cron drain via endpoint. E **teste com os envs opcionais AUSENTES** (ex.: sem `RESEND_API_KEY`) — é o estado real de um primeiro deploy, e é onde moram os piores bugs de primeira impressão. -- **Efeito colateral externo provado com receiver real.** Webhook outbound, envio — suba um receiver HTTP de verdade e prove o que chegou (ou que foi barrado). Mock não estressa o egress real (anti-SSRF, projeção de payload, https em prod). - -**Prioridade: primeira impressão acima de tudo.** Onboarding e as primeiras ações (criar conta, conectar canal, primeiro lead, primeiro convite) são a primeira impressão do usuário — bug ali é abandono. Teste esses caminhos primeiro e com o maior rigor. +```bash +docker compose -f docker-compose.prod.yml -f docker-compose.traefik.yml --env-file .env up -d app +``` -**Registro obrigatório (senão o progresso é invisível):** -- Mapa de jornadas vivo em `docs/testing/user-journey-map.md` — casos por jornada, prioridade (`[P0]` primeira impressão), e achados. Atualize quando adicionar cobertura ou achar bug. -- Specs em `tests/e2e/*.spec.ts` que dirigem o **frontend** (não só API). Evidência visual (screenshot/trace) em `.superpowers/evidence/`. -- Bug achado executando → **conserta na causa raiz**, com migration versionada se tocar schema (ver doutrina abaixo), commit próprio, e re-teste verde como prova. +Omitir `-f docker-compose.traefik.yml` recria o contêiner sem as labels de roteamento; o Traefik da +hospedagem deixa de enxergá-lo e **o domínio inteiro responde `404 page not found`** — com o +contêiner `healthy`, porque o healthcheck é um probe TCP interno e não sabe nada de roteamento. -**Medidas de front-end por ferramenta, nunca a olho** (`getBoundingClientRect`/`getComputedStyle` no Playwright). Ver `feedback_protocolo_execucao_visivel` na memória. +Depois de qualquer deploy, confirme que o domínio responde **307** (redireciona para o login) e não 404. Verificações e o caso de build local em [`docs/runbooks/deploy.md`](docs/runbooks/deploy.md). -**Receita de ambiente fresco (não-óbvia):** banco = `baseline.sql` num Supabase local **pg17** (`config.toml major_version = 17`; o baseline usa `GRANT MAINTAIN`, privilégio pg17+); `next build` + `next start` (produção — `next dev` compila lento demais e o Turbopack quebra `cookies()`); **worktree com `node_modules` real, nunca symlink** (Turbopack rejeita symlink "out of filesystem root") e **fora de `/tmp`** (é limpo no meio da sessão — commite cada marco). Detalhes em [[project_invite_e2e_and_bugs]]. +O caminho normal **não constrói nada na VPS**: commit → push → PR → merge na `main` → o CI publica +no GHCR → a VPS puxa. Imagem construída na VPS é exceção de emergência e é dívida: existe só naquele +disco, e qualquer `up -d` sem `APP_PULL_POLICY=never` a substitui em silêncio. --- -## Higiene de branches — DOUTRINA (NÃO NEGOCIÁVEL) +## Packaging e distribuição — DOUTRINA -**`main` é produção e é a fonte da verdade. Toda branch começa e se mantém atualizada com a `main`.** Trabalho iniciado numa branch atrasada gera conflito e retrabalho — é a causa número um de "cagada" em ambiente multi-sessão. Regra: +Lei completa em [`docs/doctrine/packaging.md`](docs/doctrine/packaging.md); decisões estruturais e o +que foi recusado em [`docs/adr/0001-packaging-e-distribuicao.md`](docs/adr/0001-packaging-e-distribuicao.md). +O não-negociável, em quatro linhas: -1. **ANTES de começar QUALQUER trabalho numa branch, atualize-a com a `main`:** `git fetch origin && git merge origin/main` (traz produção pra dentro). Se a branch ainda não tem commits próprios, é fast-forward puro (`git merge --ff-only origin/main`). Não codar antes disso. -2. **NUNCA `reset --hard`/force pra "atualizar"** — apaga trabalho. Só dois caminhos: **fast-forward** (branch sem commits próprios) ou **merge da `main` pra dentro** (preserva os dois lados). `main` nunca é reescrita. -3. **NUNCA toque numa branch/worktree com working tree sujo que não é seu.** Antes de atualizar qualquer branch, cheque `git status` e `git worktree list` — se está suja e é de outra sessão, **deixe quieto** e avise. Merge só entra em árvore limpa. -4. **Quando uma feature entra na `main`, todas as outras branches ficam atrasadas na hora.** Quem for retomar qualquer uma delas aplica a regra 1 primeiro. Ao fim de uma feature, considere propagar a `main` para as branches vivas limpas (FF as sem trabalho próprio; merge nas divergentes limpas; pular as sujas/conflitantes e reportar). -5. **Conflito ao atualizar = pare e resolva com cabeça** (ou escale), nunca escolha um lado no automático numa branch que não é sua. Preservar trabalho > branch "verde rápido". +1. **Nenhum serviço de `docker-compose.prod.yml` constrói na máquina do cliente.** Todo serviço + declara `image:` de uma imagem publicada; `build:` só existe **ao lado**, como escape. Serviço + `build:`-only é invisível para `docker compose pull` e imune a `up -d` sem `--build` — ele não é + só caro de instalar, ele **nunca é atualizado**. Os três serviços nossos (`app`, `worker`, + `scheduler`) são imagens publicadas, e um teste reprova o retorno do padrão +2. **Publicação é ato do CI**, nunca da sua máquina: build ARM local não roda na VPS amd64 do + cliente, e a falha só aparece no `up -d` dele +3. **Instalação de cliente aponta para número de versão, nunca para tag móvel.** Aqui `latest` + significa **topo da `main`**, não última release — quem quer a última release usa `stable`. + `pull_policy` acompanha a mutabilidade da tag: imutável → `missing`, móvel → `always` +4. **Dependência upstream é referenciada com tag fixa, nunca republicada** — WAHA (licenciado; + republicar é passivo jurídico), Redis, Caddy e `serverless-redis-http` + +Bump de versão **não pode** exigir que o operador da VPS edite `.env`, compose ou qualquer arquivo à +mão. Se exigir, não entra: vira issue com plano de migração e vai para uma major. --- -## Migrations & Banco — DOUTRINA (projeto open-source) - -**Este projeto é open-source. Toda mudança de schema DEVE sair como migration versionada** — quem clonou uma versão antiga do banco precisa conseguir atualizar aplicando as migrations em ordem. **Nunca** aplique `ALTER`/`CREATE` solto no banco sem o arquivo correspondente. Isto é critério de aceite de TODA sessão, não opcional. +## Higiene de branches — DOUTRINA -Processo padrão (siga sempre): +**`main` é produção e é a fonte da verdade. Toda branch começa e se mantém atualizada com ela.** +Trabalho iniciado numa branch atrasada gera conflito e retrabalho — é a causa número um de estrago +em ambiente multi-sessão. -1. **Arquivo versionado** em `supabase/migrations/` com o padrão do repo: `__.sql` (ex.: `20260706210000_0027_whatsapp_conversation_unification.sql`). `NNNN` é o próximo número sequencial (veja o último em `ls supabase/migrations/`). -2. **Idempotente sempre que possível**: `add column if not exists`, `create ... if not exists`, `create or replace function`. Uma migration deve poder ser re-aplicada sem quebrar nem duplicar efeito. -3. **Portável em `psql` puro** (clones podem não usar o MCP/CLI Supabase): **sem** `create temporary table ... on commit drop` fora de transação explícita; **sem** `BEGIN`/`COMMIT` explícito (o runner já envolve em transação, como as demais migrations). Prefira CTEs, subqueries de janela e colunas-mapa (ex.: `is_merged_into`) a temp tables. -4. **Data migrations genéricas**: se a migration corrige/deduplica dados, escreva pensando em QUALQUER banco de clone (não hardcode IDs do seu tenant). Repointe FKs conferindo o catálogo (`information_schema` FK map) para não perder histórico. -5. **Registre no MANIFEST**: adicione uma linha em `supabase/migrations/MANIFEST.md` (tabela "Applied") descrevendo versão, nome e o QUÊ/PORQUÊ. -6. **Reflita no `supabase/baseline.sql` (OBRIGATÓRIO — é o que o kit self-host aplica).** O baseline é um dump `--schema-only` + um **apêndice idempotente** no fim do arquivo (blocos rotulados `-- ---- (migration NNNN) ----`). O kit HostGator aplica **só o baseline.sql**, tanto no `install.sh` (banco novo, `ON_ERROR_STOP=1`) quanto no `update.sh` (re-aplica em banco existente, **sem** `ON_ERROR_STOP`). Então toda mudança de schema pós-snapshot DEVE ser acrescentada ao apêndice, **idempotente e auto-curativa**: `add column if not exists`, `create ... if not exists`, `create or replace function`, e — se a mudança adiciona constraint — **deduplicar/corrigir os dados ANTES** de criar a constraint (senão o `update.sh` de um clone bugado quebra). Sem isto, clones não recebem a mudança (ou quebram ao atualizar). Migração adicionada só em `migrations/` mas não no baseline **não chega aos self-hosters**. -7. **Aplique e prove**: aplique via `mcp__plugin_supabase_supabase__apply_migration` (ou `supabase db push`), capture o estado ANTES/DEPOIS e prove invariantes (ex.: contagem de linhas que não pode mudar). Se mexeu em contrato, regenere `lib/database.types.ts`. Para mudanças de schema no kit, valide o baseline num Postgres descartável (`pgvector/pgvector:pg17` + extensões) aplicando `install` (fresh, `ON_ERROR_STOP=1`) e `update` (re-aplicar, sem a flag) — ambos têm que passar. -8. **Backfill de dados quebrados existentes**: constraint nova falha se os dados atuais a violam — a migration (e o apêndice do baseline) deve deduplicar/corrigir ANTES de criar a constraint. -9. **Função nova em `public` nasce EXPOSTA — revogue as DUAS origens.** Toda `create function` no schema `public` termina com: +1. **ANTES de começar qualquer trabalho, atualize a branch:** + `git fetch origin && git merge origin/main`. Se a branch ainda não tem commits próprios, é + fast-forward puro (`git merge --ff-only origin/main`) +2. **NUNCA `reset --hard` ou force para "atualizar"** — apaga trabalho. Só dois caminhos: + fast-forward, ou merge da `main` para dentro. A `main` nunca é reescrita +3. **NUNCA toque em branch/worktree com working tree sujo que não é seu.** Cheque `git status` e + `git worktree list` antes; se está suja e é de outra sessão, deixe quieto e avise +4. **Quando uma feature entra na `main`, todas as outras branches ficam atrasadas na hora.** Ao fim + de uma feature, considere propagar a `main` para as branches vivas e limpas +5. **Conflito ao atualizar = pare e resolva com cabeça** (ou escale). Nunca escolha um lado no + automático numa branch que não é sua. Preservar trabalho > branch verde rápido - ```sql - revoke execute on function public.fn_x(...) from public, anon; - grant execute on function public.fn_x(...) to ; - ``` +--- - São duas origens distintas de `EXECUTE`, e tratar só uma deixa a função exposta com o gate verde: **(A)** o grant direto a `anon` do `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon` do baseline, que vale para toda função criada depois dele — isto é, para todo apêndice novo — e que `revoke from public` **não** remove; **(B)** o grant a `PUBLIC` que o Postgres dá a qualquer função ao criá-la, que `revoke from anon` **não** remove. Sem os dois, o PostgREST expõe a função como RPC alcançável pela anon key, que vai para o browser. Vigiado por `tests/invariants/hardening-definer-varredura.test.ts`, que varre todas as `security definer` de `public` (issue #128 — a versão anterior checava uma lista fixa de 6, e 8 de 25 estavam expostas). +## Regra final — não invente -**Resumo do fluxo de uma mudança de schema:** arquivo em `migrations/` (fonte da verdade p/ Supabase CLI) **+** apêndice idempotente no `baseline.sql` (p/ o kit self-host) **+** linha no MANIFEST. Os dois artefatos de schema andam juntos. Nunca edite migrations já aplicadas — corrija com uma "forward-fix" nova (e mais um apêndice no baseline). +Este repositório tem PRDs, specs, regras de negócio e doutrina escritos (`docs/prd/`, `docs/specs/`, +`docs/business-rules/`, `docs/doctrine/`). **Nunca invente regra de negócio, número, SLA ou +comportamento de produto.** Se a regra não está escrita, diga que não está e pergunte — não preencha +a lacuna com suposição plausível. Ao documentar, marque o que é `CONFIRMADO` (provado por código) e +o que é `INFERIDO`. --- -## Skills relevantes a usar (Claude Code) +## Skills relevantes (Claude Code) - `superpowers:brainstorming` — antes de implementar feature não-trivial -- `superpowers:writing-plans` — pra task com mais de 1 etapa de DB/API +- `superpowers:writing-plans` — task com mais de uma etapa de DB/API - `superpowers:test-driven-development` — feature crítica (LGPD, RLS, anti-banimento) -- `superpowers:systematic-debugging` — bugs reportados +- `superpowers:systematic-debugging` — bug reportado - `superpowers:verification-before-completion` — antes de declarar "pronto" -- `tomik-db-doctrine` — referência cruzada de doutrina de schema -- `supabase:supabase` — qualquer task com Supabase -- `vercel:nextjs` — App Router, Server Components, edge runtime -- `vercel:ai-gateway` — config de fallback de provider -- `frontend-design` — UI distinta (não cair em shadcn-default genérico) +- `supabase:supabase` · `vercel:nextjs` · `vercel:ai-gateway` · `frontend-design` · `tomik-db-doctrine` --- ## Definition of Done -Antes de declarar uma task pronta: - -1. `npm run typecheck` passa zerado -2. `npm run lint` zerado -3. Testes unit/e2e relevantes existem e passam -4. RLS testada se feature toca tabela tenant-aware -5. Audit log emitido se há mutação relevante -6. Rate limit aplicado se rota é pública -7. Zod valida todo input externo -8. Sem `console.log` esquecido -9. Env vars novas adicionadas em `.env.example` + `lib/env.ts` -10. Doc atualizada se mudou contrato (PRD/spec) -11. **Mudança de schema saiu como migration versionada + linha no MANIFEST** (ver Doutrina de Migrations) — clones conseguem atualizar -12. **Se tocou UI/fluxo de usuário: provado pela tela como um leigo faria**, em ambiente fresco estilo VPS, com evidência visual (ver Doutrina de QA Visual com Recursos Reais) — curl não conta -13. **Living System Checklist respondido** (lei em `docs/doctrine/sistema-vivo.md`; racional no manual `docs/doctrine/sistema-vivo/`) — a feature não é ilha: tem entrada + saída, emite atividade/log, aparece na tela, tem porta na navegação, tem mecanismo anti-morte, **declara seu laço de retorno** (invariante 7 — o que muda no sistema quando ela erra), e o mapa vivo (`docs/architecture/`) reflete peça nova com ≥2 arestas. Resposta que não **nomeia o artefato concreto** (consumidor real, tela real, log real) não conta -14. **Tela nova tem porta** — declarada em `lib/navigation/registry.ts` com seu grupo, ou na allowlist de `tests/unit/navegacao-completude.test.ts` **com justificativa escrita**. Ter tela e ser alcançável são coisas diferentes: o CI reprova tela que existe mas em que só se chega digitando a URL -15. **Se tocou Dockerfile, compose ou setup kit: a mudança chega a quem já instalou** (lei em `docs/doctrine/packaging.md`) — nenhum serviço de produção ficou `build:`-only; variável nova tem default que não quebra `.env` antigo; a atualização não pede edição manual de arquivo; e, se mudou o que a imagem contém, o `update.sh` alcança essa peça. Rode `pnpm test:shell` — é o único gate que exercita o kit -16. **Se o PR muda comportamento, procure a afirmação de estado sobre esse comportamento.** Só - sobre o que você mudou, e só nos documentos de autoridade — não saia caçando pelo repo. A - documentação afirma como o mundo *está*, e uma auditoria de 2026-08-14 achou **227 - afirmações desatualizadas em 393 medidas** - ([`docs/audits/2026-08-14-afirmacoes-de-estado.md`](docs/audits/2026-08-14-afirmacoes-de-estado.md)). - Onde a afirmação puder virar **comando**, troque em vez de corrigir: um número corrigido - envelhece de novo; um `rode isto para saber` não envelhece nunca +1. `pnpm typecheck` zerado +2. `pnpm lint` zerado +3. `pnpm lint:channels` zerado (nenhuma feature nomeia um provider de canal) +4. Testes unit/e2e relevantes existem e passam +5. RLS testada se a feature toca tabela tenant-aware +6. Audit log emitido se há mutação relevante +7. Rate limit aplicado se a rota é pública +8. Zod valida todo input externo +9. Sem `console.log` esquecido +10. Env var nova adicionada em `.env.example` **e** `lib/env.ts` +11. Doc atualizada se mudou contrato (PRD/spec) +12. **Mudança de schema saiu como migration versionada + apêndice no baseline + linha no MANIFEST** — clones conseguem atualizar +13. **Se tocou UI ou fluxo de usuário: provado pela tela como um leigo faria**, em ambiente fresco estilo VPS, com evidência visual — curl não conta +14. **Living System Checklist respondido** (lei em [`docs/doctrine/sistema-vivo.md`](docs/doctrine/sistema-vivo.md)) — a feature não é ilha: tem entrada e saída, emite atividade/log, aparece na tela, tem porta na navegação, tem mecanismo anti-morte, **declara seu laço de retorno** (o que muda no sistema quando ela erra), e o mapa vivo (`docs/architecture/`) reflete a peça nova com ≥2 arestas. Resposta que não **nomeia o artefato concreto** (consumidor real, tela real, log real) não conta +15. **Tela nova tem porta** — declarada em `lib/navigation/registry.ts` com seu grupo, ou na allowlist de `tests/unit/navegacao-completude.test.ts` **com justificativa escrita**. Ter tela e ser alcançável são coisas diferentes +16. **Se tocou Dockerfile, compose ou o kit: a mudança chega a quem já instalou** — nenhum serviço de produção ficou `build:`-only; variável nova tem default que não quebra `.env` antigo; a atualização não pede edição manual de arquivo. Rode `pnpm test:shell` +17. **Se o PR muda comportamento, procure a afirmação de estado sobre esse comportamento** — só sobre o que você mudou, e só nos documentos de autoridade. Onde a afirmação puder virar **comando**, troque em vez de corrigir: um número corrigido envelhece de novo; um `rode isto para saber` não envelhece nunca Um staff engineer aprovaria? Se não, itera. diff --git a/Dockerfile.worker b/Dockerfile.worker index e605fa135..07056076e 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -18,7 +18,14 @@ RUN corepack enable && corepack prepare pnpm@9.15.9 --activate COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile -COPY . . +# non-root: o worker roda com SUPABASE_SERVICE_ROLE_KEY, chaves WAHA e a AES +# de credenciais de IA (todo o .env) — sem isto um RCE numa dependência da +# cadeia tsx/npm ganha root dentro do contêiner em vez de um usuário sem +# privilégio. `node:22-alpine` já traz o usuário `node` (uid 1000) pronto. +# `--chown` no COPY (em vez de `chown -R` depois) evita reescrever os ~200MB +# de node_modules numa camada extra. +COPY --chown=node:node . . +USER node # Healthz do worker (bind 0.0.0.0 dentro do container). EXPOSE 8787 diff --git a/app/api/v1/admin/lgpd/requests/[id]/route.ts b/app/api/v1/admin/lgpd/requests/[id]/route.ts index 89b4592fe..62bc14281 100644 --- a/app/api/v1/admin/lgpd/requests/[id]/route.ts +++ b/app/api/v1/admin/lgpd/requests/[id]/route.ts @@ -54,11 +54,15 @@ export async function GET( .eq("id", request.organization_id) .maybeSingle(); - // Fetch audit trail for this request (cross-tenant) + // Fetch audit trail for this request (cross-tenant). `id` já foi confirmado + // como UUID existente pelo .eq() acima (comparação literal, não sujeita a + // injeção de filtro), mas escapamos aqui também por segurança-em-camadas — + // esta chamada usa .or(), cujo DSL é sensível a `,`/`(`/`)`. + const safeId = id.replace(/[,()]/g, ""); const { data: auditRows, error: auditErr } = await admin .from("api_audit_log") .select("id, action, actor_user_id, resource_type, resource_id, metadata, created_at") - .or(`resource_id.eq.${id},metadata->>request_id.eq.${id}`) + .or(`resource_id.eq.${safeId},metadata->>request_id.eq.${safeId}`) .order("created_at", { ascending: true }) .limit(50); diff --git a/app/api/v1/admin/tenants/route.ts b/app/api/v1/admin/tenants/route.ts index 29b2286c2..776abf3a2 100644 --- a/app/api/v1/admin/tenants/route.ts +++ b/app/api/v1/admin/tenants/route.ts @@ -108,8 +108,9 @@ export async function GET(req: NextRequest) { } if (q) { + const safeQ = q.replace(/[%_]/g, (m) => `\\${m}`).replace(/[,()]/g, " "); query = query.or( - `display_name.ilike.%${q}%,slug::text.ilike.%${q}%,cnpj.ilike.%${q}%`, + `display_name.ilike.%${safeQ}%,slug::text.ilike.%${safeQ}%,cnpj.ilike.%${safeQ}%`, ); } diff --git a/app/api/v1/ai/agents/[id]/publish-rag-bot/route.ts b/app/api/v1/ai/agents/[id]/publish-rag-bot/route.ts new file mode 100644 index 000000000..0c7a09eb9 --- /dev/null +++ b/app/api/v1/ai/agents/[id]/publish-rag-bot/route.ts @@ -0,0 +1,100 @@ +/** + * POST /api/v1/ai/agents/:id/publish-rag-bot + * + * Publica o rascunho de um agente `rag_bot` — o editor legado + * (`components/ai/AgentEditor.tsx`) só salva em `ai_agents`, mas o runtime lê + * `system_prompt`/`provider`/`model` da versão publicada. Sem esta rota, editar + * pela tela não tem efeito nenhum (medido: fica respondendo com o prompt do + * bootstrap inicial, em silêncio). Ver `supabase/migrations/*_0168_*.sql`. + * + * Atomic flip via fn_publish_rag_bot_version: + * - versão publicada atual → 'superseded' + * - versão nova (system_prompt/provider/model do rascunho, resto copiado da + * atual) → 'published' + * - ai_agents.published_version_id → a nova + */ +import { randomUUID } from "node:crypto"; +import { type NextRequest } from "next/server"; + +import { ok, fail } from "@/lib/api/wrappers"; +import { audit } from "@/lib/audit"; +import { requireRole } from "@/lib/auth/require-role"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { publishRagBotVersion, RAG_BOT_PUBLISH_ERROR_CODES } from "@/lib/ai/agents/publish-rag-bot"; + +export const dynamic = "force-dynamic"; + +const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +type Ctx = { params: Promise<{ id: string }> }; + +export async function POST(_req: NextRequest, ctx: Ctx): Promise { + const requestId = randomUUID(); + const { id } = await ctx.params; + if (!UUID_RX.test(id)) { + return fail("invalid_request", "id inválido.", 400, { requestId }); + } + + const authz = await requireRole("admin", { requestId, resource: "ai_agents" }); + if (!authz.ok) return authz.response; + const { user: authUser, org: activeOrg } = authz; + + const admin = createAdminClient(); + + const result = await publishRagBotVersion(admin, { + orgId: activeOrg.orgId, + agentId: id, + createdBy: authUser.id, + }); + + if (!result.ok) { + if (RAG_BOT_PUBLISH_ERROR_CODES.has(result.code)) { + const status = + result.code === "agent_not_found" || result.code === "version_not_found" + ? 404 + : 422; + return fail(result.code, "Validação de publish falhou.", status, { requestId }); + } + return fail("internal_error", "Erro ao publicar.", 500, { requestId }); + } + + void admin + .rpc("emit_event" as never, { + p_event_type: "ai_agent.published", + p_entity_kind: "ai_agent", + p_entity_id: result.agent_id, + p_payload: { + agent_id: result.agent_id, + version_id: result.version_id, + previous_version_id: result.previous_version_id, + published_at: result.published_at, + }, + p_organization_id: activeOrg.orgId, + } as never) + .then(({ error }) => { + if (error) console.error("[ai_agents/publish-rag-bot] emit_event error", error.message); + }); + + void audit({ + action: "ai_agent.published", + actorUserId: authUser.id, + organizationId: activeOrg.orgId, + resourceType: "ai_agent", + resourceId: id, + requestId, + metadata: { + version_id: result.version_id, + previous_version_id: result.previous_version_id, + }, + }); + + return ok( + { + agent_id: result.agent_id, + version_id: result.version_id, + previous_version_id: result.previous_version_id, + published_at: result.published_at, + }, + { requestId }, + ); +} diff --git a/app/api/v1/channels/partner/templates/media/route.ts b/app/api/v1/channels/partner/templates/media/route.ts index 4aaaaaf17..4c0c47c23 100644 --- a/app/api/v1/channels/partner/templates/media/route.ts +++ b/app/api/v1/channels/partner/templates/media/route.ts @@ -26,7 +26,7 @@ import { randomUUID } from "node:crypto"; import type { NextRequest } from "next/server"; import { fail, ok } from "@/lib/api/wrappers"; -import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server"; +import { requireRole } from "@/lib/auth/require-role"; import { logger } from "@/lib/logger"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -48,10 +48,11 @@ const TAMANHO_MAX = 5 * 1024 * 1024; export async function POST(req: NextRequest): Promise { const requestId = randomUUID(); - const user = await loadAuthUser(); - if (!user) return fail("unauthenticated", "Faça login.", 401, { requestId }); - const org = await resolveActiveOrg(user); - if (!org) return fail("forbidden", "Sem organização ativa.", 403, { requestId }); + // Sobe pro storage o cabeçalho de uma definição aprovada pela Meta — mesmo + // piso de "channels/templates" e "channels/partner/templates" (admin). + const authz = await requireRole("admin", { requestId, resource: "channel_partner_templates_media" }); + if (!authz.ok) return authz.response; + const org = authz.org; const form = await req.formData().catch(() => null); const file = form?.get("file"); diff --git a/app/api/v1/channels/partner/templates/route.ts b/app/api/v1/channels/partner/templates/route.ts index 836607271..7949c64e0 100644 --- a/app/api/v1/channels/partner/templates/route.ts +++ b/app/api/v1/channels/partner/templates/route.ts @@ -29,6 +29,7 @@ import type { NextRequest } from "next/server"; import { fail, ok } from "@/lib/api/wrappers"; import { audit } from "@/lib/audit"; import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server"; +import { requireRole } from "@/lib/auth/require-role"; import { CHANNEL_SESSION_REF_COLUMNS, DEFAULT_CHANNEL_PROVIDER, @@ -138,6 +139,12 @@ export async function GET(): Promise { */ export async function POST(req: NextRequest): Promise { const requestId = randomUUID(); + // Cria/sincroniza definição aprovada pela Meta e pode derrubar a sessão de + // WhatsApp indiretamente (uploads e mensagens dependem da mesma conexão) — + // mesmo piso de "channels/templates" e "channels/official" (admin). + const authz = await requireRole("admin", { requestId, resource: "channel_partner_templates" }); + if (!authz.ok) return authz.response; + const r = await contexto(requestId); if (!r.ok) return r.res; diff --git a/app/api/v1/cron/data-retention/route.ts b/app/api/v1/cron/data-retention/route.ts index 5f1dd7567..c05987396 100644 --- a/app/api/v1/cron/data-retention/route.ts +++ b/app/api/v1/cron/data-retention/route.ts @@ -1,23 +1,28 @@ /** * GET/POST /api/v1/cron/data-retention — issue #261. * - * As duas tabelas que crescem sozinhas numa instalação parada — `job_queue` e - * `api_audit_log` — não tinham poda nenhuma. Medido no HEAD anterior: + * As tabelas que crescem sozinhas numa instalação parada — `job_queue` e + * `api_audit_log` (issue #261) e, desde a migration 0172, `event_log` — não + * tinham poda nenhuma. Medido no HEAD anterior à 0167: * * $ grep -rn "from job_queue" lib workers app supabase scripts | grep -i delete * (zero linhas) * * E a retenção de 5 anos do audit existia só no `COMMENT ON TABLE` e em seis - * documentos. O plano free do Supabase limita **500 MB de banco**: estas duas - * estouram antes de qualquer tabela de negócio, e o bloat ainda cobra CPU (715 - * buffers varridos no `count(*)` do claim com zero linhas vivas, issue #260). + * documentos. O plano free do Supabase limita **500 MB de banco**: estas + * tabelas estouram antes de qualquer tabela de negócio, e o bloat ainda cobra + * CPU (715 buffers varridos no `count(*)` do claim com zero linhas vivas, + * issue #260). `event_log` some do resto do produto de propósito nesta + * migration: ela só some `done`/`dead` — os `event_type` sem consumer nunca + * chegam lá e continuam pending (achado documentado na 0172). * * O que ele faz, e o que deliberadamente NÃO faz: * - * - chama `fn_podar_fila_de_jobs` e `fn_expurgar_auditoria_vencida` EM LOTES. - * Um DELETE grande num banco de cliente trava a tabela e o tempo do lock - * cresce com o backlog; lotes de `TAMANHO_DO_LOTE` fecham a transação a cada - * rodada e o backlog drena ao longo de vários dias, sem janela de manutenção; + * - chama `fn_podar_fila_de_jobs`, `fn_expurgar_auditoria_vencida` e + * `fn_podar_event_log` EM LOTES. Um DELETE grande num banco de cliente + * trava a tabela e o tempo do lock cresce com o backlog; lotes de + * `TAMANHO_DO_LOTE` fecham a transação a cada rodada e o backlog drena ao + * longo de vários dias, sem janela de manutenção; * - **não decide o que é podável.** As duas regras (quais status são terminais, * o que ainda tem dono, o piso da retenção) moram DENTRO das funções do * banco, porque lá elas valem para qualquer chamador — inclusive um `psql` @@ -57,6 +62,8 @@ import { logger } from "@/lib/logger"; import { RETENCAO_AUDITORIA_DIAS_PADRAO, RETENCAO_AUDITORIA_DIAS_PISO, + RETENCAO_EVENT_LOG_DIAS_PADRAO, + RETENCAO_EVENT_LOG_DIAS_PISO, RETENCAO_FILA_DIAS_PADRAO, RETENCAO_FILA_DIAS_PISO, interpretarRetencao, @@ -83,13 +90,17 @@ export const MAX_LOTES = 20; export interface ResultadoDaRetencao { jobs_apagados: number; auditoria_apagada: number; + event_log_apagado: number; lotes_fila: number; lotes_auditoria: number; + lotes_event_log: number; /** O último lote veio cheio e o teto foi atingido: sobrou trabalho para amanhã. */ fila_tem_resto: boolean; auditoria_tem_resto: boolean; + event_log_tem_resto: boolean; retencao_fila_dias: number; retencao_auditoria_dias: number; + retencao_event_log_dias: number; /** Avisos de configuração — nunca ausentes em silêncio quando existem. */ avisos: string[]; } @@ -97,14 +108,14 @@ export interface ResultadoDaRetencao { /** Só a superfície que este cron usa — o teste injeta uma implementação. */ export interface PodaDb { rpc( - nome: "fn_podar_fila_de_jobs" | "fn_expurgar_auditoria_vencida", + nome: "fn_podar_fila_de_jobs" | "fn_expurgar_auditoria_vencida" | "fn_podar_event_log", args: { p_retencao_dias: number; p_limite: number }, ): Promise<{ data: number | null; error: { message: string } | null }>; } async function drenar( db: PodaDb, - nome: "fn_podar_fila_de_jobs" | "fn_expurgar_auditoria_vencida", + nome: "fn_podar_fila_de_jobs" | "fn_expurgar_auditoria_vencida" | "fn_podar_event_log", dias: number, ): Promise<{ apagadas: number; lotes: number; temResto: boolean }> { let apagadas = 0; @@ -132,7 +143,11 @@ async function drenar( */ export async function podarHistorico( db: PodaDb, - ambiente: { JOB_QUEUE_RETENTION_DAYS?: string; AUDIT_LOG_RETENTION_DAYS?: string }, + ambiente: { + JOB_QUEUE_RETENTION_DAYS?: string; + AUDIT_LOG_RETENTION_DAYS?: string; + EVENT_LOG_RETENTION_DAYS?: string; + }, ): Promise { const fila = interpretarRetencao(ambiente.JOB_QUEUE_RETENTION_DAYS, { chave: "JOB_QUEUE_RETENTION_DAYS", @@ -144,20 +159,30 @@ export async function podarHistorico( padrao: RETENCAO_AUDITORIA_DIAS_PADRAO, piso: RETENCAO_AUDITORIA_DIAS_PISO, }); + const eventLog = interpretarRetencao(ambiente.EVENT_LOG_RETENTION_DAYS, { + chave: "EVENT_LOG_RETENTION_DAYS", + padrao: RETENCAO_EVENT_LOG_DIAS_PADRAO, + piso: RETENCAO_EVENT_LOG_DIAS_PISO, + }); const jobs = await drenar(db, "fn_podar_fila_de_jobs", fila.dias); const linhas = await drenar(db, "fn_expurgar_auditoria_vencida", auditoria.dias); + const eventos = await drenar(db, "fn_podar_event_log", eventLog.dias); return { jobs_apagados: jobs.apagadas, auditoria_apagada: linhas.apagadas, + event_log_apagado: eventos.apagadas, lotes_fila: jobs.lotes, lotes_auditoria: linhas.lotes, + lotes_event_log: eventos.lotes, fila_tem_resto: jobs.temResto, auditoria_tem_resto: linhas.temResto, + event_log_tem_resto: eventos.temResto, retencao_fila_dias: fila.dias, retencao_auditoria_dias: auditoria.dias, - avisos: [fila.aviso, auditoria.aviso].filter((a): a is string => a !== null), + retencao_event_log_dias: eventLog.dias, + avisos: [fila.aviso, auditoria.aviso, eventLog.aviso].filter((a): a is string => a !== null), }; } @@ -167,7 +192,11 @@ export async function podarHistorico( * não fez nada" sozinho é satisfeito por um cron que nunca audita. */ export function houveEfeito(resultado: ResultadoDaRetencao): boolean { - return resultado.jobs_apagados > 0 || resultado.auditoria_apagada > 0; + return ( + resultado.jobs_apagados > 0 || + resultado.auditoria_apagada > 0 || + resultado.event_log_apagado > 0 + ); } async function handle(req: NextRequest): Promise { @@ -183,8 +212,8 @@ async function handle(req: NextRequest): Promise { let resultado: ResultadoDaRetencao; try { const admin = createAdminClient(); - // As duas funções são novas e não estão em `lib/database.types.ts` (gerado a - // partir de um projeto Supabase vivo) — mesmo tratamento que + // As três funções são novas e não estão em `lib/database.types.ts` (gerado + // a partir de um projeto Supabase vivo) — mesmo tratamento que // `recover-stuck-messages` dá a `emit_event`. const db: PodaDb = { async rpc(nome, args) { @@ -195,6 +224,7 @@ async function handle(req: NextRequest): Promise { resultado = await podarHistorico(db, { JOB_QUEUE_RETENTION_DAYS: env.JOB_QUEUE_RETENTION_DAYS, AUDIT_LOG_RETENTION_DAYS: env.AUDIT_LOG_RETENTION_DAYS, + EVENT_LOG_RETENTION_DAYS: env.EVENT_LOG_RETENTION_DAYS, }); } catch (err) { const detail = err instanceof Error ? err.message : String(err); diff --git a/app/api/v1/leads/bulk/route.ts b/app/api/v1/leads/bulk/route.ts index 29f7af935..cf0a2ab5a 100644 --- a/app/api/v1/leads/bulk/route.ts +++ b/app/api/v1/leads/bulk/route.ts @@ -249,34 +249,48 @@ export async function POST(req: NextRequest): Promise { const add = input.params.add ?? []; const remove = new Set(input.params.remove ?? []); // Compute next tags per row from already-fetched `scoped`. - for (const row of visible) { + const rows = visible.map((row) => { const current = (row.tags ?? []) as string[]; const next = Array.from(new Set([...current.filter((t) => !remove.has(t)), ...add])); - const { error } = await supabase - .from("crm_leads") - .update({ tags: next, updated_at: nowIso }) - .eq("id", row.id); - if (error) return fail("internal_error", error.message, 500, { requestId }); - updatedCount += 1; - - // Per-lead lead.tag_added (only-when-added), same contract as - // updateLeadHandler, so the automation engine fires for bulk tags too. const addedTags = add.filter((t) => !current.includes(t)); - if (addedTags.length) { - await supabase - .rpc("emit_event", { - p_event_type: "lead.tag_added", - p_entity_kind: "crm_lead", - p_entity_id: row.id, - p_payload: { added_tags: addedTags, tags: next }, - p_metadata: { request_id: requestId, actor_user_id: user.id }, - p_organization_id: organizationId, - }) - .then(({ error: emitError }) => { - if (emitError) console.error("[lead.bulk_tagged] emit_event failed", emitError.message); - }); - } - } + return { row, next, addedTags }; + }); + + // Wave 3 (CORE 2): parallelize like the `move` case above — a + // sequential await-per-row here turned into an N+1 that made bulk tag + // take seconds where bulk move/assign/delete are near-instant. + const results = await Promise.all( + rows.map(({ row, next }) => + supabase + .from("crm_leads") + .update({ tags: next, updated_at: nowIso }) + .eq("id", row.id), + ), + ); + const firstError = results.find((r) => r.error)?.error; + if (firstError) return fail("internal_error", firstError.message, 500, { requestId }); + updatedCount = rows.length; + + // Per-lead lead.tag_added (only-when-added), same contract as + // updateLeadHandler, so the automation engine fires for bulk tags too. + await Promise.all( + rows + .filter(({ addedTags }) => addedTags.length) + .map(({ row, next, addedTags }) => + supabase + .rpc("emit_event", { + p_event_type: "lead.tag_added", + p_entity_kind: "crm_lead", + p_entity_id: row.id, + p_payload: { added_tags: addedTags, tags: next }, + p_metadata: { request_id: requestId, actor_user_id: user.id }, + p_organization_id: organizationId, + }) + .then(({ error: emitError }) => { + if (emitError) console.error("[lead.bulk_tagged] emit_event failed", emitError.message); + }), + ), + ); break; } case "delete": { diff --git a/app/api/v1/onboarding/whatsapp/session/route.ts b/app/api/v1/onboarding/whatsapp/session/route.ts index b5991fdfe..9a97603a0 100644 --- a/app/api/v1/onboarding/whatsapp/session/route.ts +++ b/app/api/v1/onboarding/whatsapp/session/route.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { NextResponse } from "next/server"; import { ok, fail } from "@/lib/api/wrappers"; import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server"; +import { requireRole } from "@/lib/auth/require-role"; import { ARCHIVED_AT, queryTolerantToMissingArchived } from "@/lib/channels/archived"; import { CHANNEL_PROVIDER_WAHA } from "@/lib/channels/capabilities"; import { @@ -132,10 +133,13 @@ export async function GET() { export async function POST(req: Request) { const requestId = randomUUID(); - const user = await loadAuthUser(); - if (!user) return fail("unauthenticated", "Sessão expirada", 401); - const activeOrg = await resolveActiveOrg(user); - if (!activeOrg) return fail("tenant_not_found", "Sem organização ativa", 404); + // Inicia/reinicia a sessão oficial de WhatsApp do tenant — `?restart=1` + // derruba a sessão antes de reabrir. Mesmo piso de "channels/official" + // (admin): reconectar o canal não é operação de agente. + const authz = await requireRole("admin", { requestId, resource: "onboarding_whatsapp_session" }); + if (!authz.ok) return authz.response; + const user = authz.user; + const activeOrg = authz.org; const waha = getWahaClient(); if (!waha) return fail("waha_not_configured", "Suba o Docker (docker compose up -d waha) e tente novamente.", 503); const sessionName = defaultSessionName(activeOrg.orgId); diff --git a/app/app/ai/agents/[id]/_actions.ts b/app/app/ai/agents/[id]/_actions.ts index 723a7795e..026bfea68 100644 --- a/app/app/ai/agents/[id]/_actions.ts +++ b/app/app/ai/agents/[id]/_actions.ts @@ -252,19 +252,20 @@ export async function publishAgentAction( } void admin - .from("event_log") - .insert({ - organization_id: activeOrg.orgId, - event_type: "ai_agent.published", - payload: { + .rpc("emit_event" as never, { + p_event_type: "ai_agent.published", + p_entity_kind: "ai_agent", + p_entity_id: result.agent_id, + p_payload: { agent_id: result.agent_id, version_id: result.version_id, previous_version_id: result.previous_version_id, published_at: result.published_at, }, - }) + p_organization_id: activeOrg.orgId, + } as never) .then(({ error }) => { - if (error) console.error("[saveAgentDraftAction/publish] event_log error", error.message); + if (error) console.error("[saveAgentDraftAction/publish] emit_event error", error.message); }); void audit({ @@ -448,19 +449,20 @@ export async function revertToVersionAction( } void admin - .from("event_log") - .insert({ - organization_id: activeOrg.orgId, - event_type: "ai_agent.published", - payload: { + .rpc("emit_event" as never, { + p_event_type: "ai_agent.published", + p_entity_kind: "ai_agent", + p_entity_id: result.agent_id, + p_payload: { agent_id: result.agent_id, version_id: result.version_id, previous_version_id: result.previous_version_id, published_at: result.published_at, }, - }) + p_organization_id: activeOrg.orgId, + } as never) .then(({ error }) => { - if (error) console.error("[revertToVersionAction/event_log] error", error.message); + if (error) console.error("[revertToVersionAction/emit_event] error", error.message); }); void audit({ diff --git a/app/app/settings/tenant/_form.tsx b/app/app/settings/tenant/_form.tsx index a24f9328b..b9a06176f 100644 --- a/app/app/settings/tenant/_form.tsx +++ b/app/app/settings/tenant/_form.tsx @@ -159,7 +159,7 @@ export function TenantForm({ initial }: Props) { placeholder="ex: Sem orçamento, Concorrente" />

- Adicionados ao set padrão. Cada pipeline pode ter seus próprios motivos. + Adicionados ao set padrão. Cada funil pode ter seus próprios motivos.

diff --git a/app/app/webhooks/_components/SourceDetail.tsx b/app/app/webhooks/_components/SourceDetail.tsx index 77a522b3b..557f2a97b 100644 --- a/app/app/webhooks/_components/SourceDetail.tsx +++ b/app/app/webhooks/_components/SourceDetail.tsx @@ -203,7 +203,7 @@ export function SourceDetail({ source, open, onOpenChange }: Props) { {testOk ? (

- Ver no Kanban + Ver no funil

) : null} diff --git a/components/ai/AgentEditor.tsx b/components/ai/AgentEditor.tsx index f971b9a23..dd0f2435e 100644 --- a/components/ai/AgentEditor.tsx +++ b/components/ai/AgentEditor.tsx @@ -17,7 +17,12 @@ import { import { Textarea } from "@/components/ui/textarea"; import { GuardrailsEditor } from "@/components/ai/GuardrailsEditor"; import { SystemPromptEditor } from "@/components/ai/SystemPromptEditor"; -import { useAgent, useUpdateAgent, type AgentRow } from "@/hooks/ai/useAgent"; +import { + useAgent, + useUpdateAgent, + usePublishRagBotAgent, + type AgentRow, +} from "@/hooks/ai/useAgent"; import { AGENT_CONFIG_DEFAULTS, AGENT_MODELS, @@ -91,6 +96,7 @@ function diffPatch(initial: FormState, current: FormState): AgentPatch { export function AgentEditor({ agentId, initialData, readOnly = false }: Props) { const query = useAgent(agentId, { initialData }); const update = useUpdateAgent(agentId); + const publish = usePublishRagBotAgent(agentId); const agent = query.data; @@ -164,7 +170,13 @@ export function AgentEditor({ agentId, initialData, readOnly = false }: Props) { setFormState(baselineState); } - const disabled = readOnly || update.isPending; + async function handlePublish() { + await publish.mutateAsync().catch(() => { + // toast já mostrado em onError do hook + }); + } + + const disabled = readOnly || update.isPending || publish.isPending; return (
@@ -183,8 +195,17 @@ export function AgentEditor({ agentId, initialData, readOnly = false }: Props) { + + +
+

+ "Salvar" grava o rascunho. O agente só responde com o prompt/modelo salvos + depois de "Publicar". +

diff --git a/components/inbox/ConversationList.tsx b/components/inbox/ConversationList.tsx index 183de9770..e48e1e34c 100644 --- a/components/inbox/ConversationList.tsx +++ b/components/inbox/ConversationList.tsx @@ -1,5 +1,6 @@ "use client"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { useChannelSessions } from "@/hooks/channels/useChannelSessions"; @@ -12,6 +13,12 @@ import { type ConversationWithContact, } from "@/hooks/inbox/useConversationsRealtime"; +/** Estimativa antes da primeira medição real (`measureElement` corrige depois). + * Linha sem fila/tags mede ~76px; com badge de fila ou tags sobe uns 20px — + * 84 fica no meio, então a primeira pintura sub-mede menos do que super-mede. */ +const ESTIMATED_ROW_HEIGHT = 84; +const LOADER_ROW_KEY = "__load-more__"; + interface Props { filters: ConversationsFilters; orgId: string | null; @@ -60,6 +67,37 @@ export function ConversationList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [items]); + // Uma linha "virtual" extra pro botão "Carregar mais" quando existe próxima + // página — assim ele rola junto com a lista em vez de furar o container + // virtualizado (que precisa ser o único filho medido/posicionado do scroll). + const hasLoaderRow = !!q.hasNextPage; + const rowCount = items.length + (hasLoaderRow ? 1 : 0); + + const parentRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => parentRef.current, + estimateSize: () => ESTIMATED_ROW_HEIGHT, + overscan: 8, + // Chave por identidade da conversa (não índice): com Realtime, uma + // conversa que recebe mensagem nova pula para o topo da lista — sem a + // key certa, o cache de medição do virtualizer atribuiria a ALTURA MEDIDA + // de uma linha à conversa que ocupa o índice agora, não à que a mediu. + getItemKey: (index) => items[index]?.id ?? LOADER_ROW_KEY, + }); + + // Rola até a conversa selecionada quando ela existe na lista mas está fora + // da janela renderizada (ex.: navegação por j/k além do overscan). `auto` + // só move o scroll se o item já não estiver visível — clique numa linha já + // visível não causa salto. + useEffect(() => { + if (!selectedId) return; + const idx = items.findIndex((c) => c.id === selectedId); + if (idx === -1) return; + rowVirtualizer.scrollToIndex(idx, { align: "auto" }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + if (q.isLoading) { return (
@@ -96,29 +134,54 @@ export function ConversationList({ return (
-
- {items.map((c, i) => ( - - ))} - {q.hasNextPage && ( -
- -
- )} +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const isLoaderRow = virtualRow.index >= items.length; + const conversation = items[virtualRow.index]; + return ( +
+ {isLoaderRow || !conversation ? ( +
+ +
+ ) : ( + + )} +
+ ); + })} +
); diff --git a/components/kanban/KanbanBoard.tsx b/components/kanban/KanbanBoard.tsx index 612003d2f..735aff71f 100644 --- a/components/kanban/KanbanBoard.tsx +++ b/components/kanban/KanbanBoard.tsx @@ -225,7 +225,7 @@ export function KanbanBoard({ if (data.stages.length === 0) { return ( - Nenhum lead nesta pipeline ainda. + Nenhum lead neste funil ainda. ); } diff --git a/components/kanban/NewLeadDialog.tsx b/components/kanban/NewLeadDialog.tsx index 61ea063a5..074e3402e 100644 --- a/components/kanban/NewLeadDialog.tsx +++ b/components/kanban/NewLeadDialog.tsx @@ -133,7 +133,7 @@ export function NewLeadDialog({ open, onOpenChange, pipelineId, stages, contactI Novo Lead - Crie um lead manualmente neste pipeline. + Crie um lead manualmente neste funil.
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f6f0820c9..8ab5de55e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,7 +32,7 @@ services: # default é quem NÃO tem APP_IMAGE no .env — avaliação, ou um .env que # perdeu a chave. Nenhum desses casos quer código não lançado; quem quiser o # topo da main pede `APP_IMAGE=…:latest` explicitamente. - image: ${APP_IMAGE:-ghcr.io/melgarafael/deskcommcrm:stable} + image: ${APP_IMAGE:-ghcr.io/maugarciasa/deskcommcrm:stable} pull_policy: ${APP_PULL_POLICY:-always} restart: unless-stopped env_file: .env @@ -89,7 +89,7 @@ services: # default): o app pinado numa release e o runtime do agente de # IA rodando código não lançado, sobre o banco da release. `:stable` é a # última release publicada — pareia com o app em vez de correr na frente. - image: ${WORKER_IMAGE:-ghcr.io/melgarafael/deskcomm-worker:stable} + image: ${WORKER_IMAGE:-ghcr.io/maugarciasa/deskcomm-worker:stable} pull_policy: ${WORKER_PULL_POLICY:-always} build: context: . @@ -209,7 +209,7 @@ services: # serviço `restart: unless-stopped`, isso significa que o cron do cliente só # voltava se a VPS tivesse internet e o mirror do Alpine estivesse de pé, # justamente no momento em que a máquina está se recuperando de algo. - image: ${SCHEDULER_IMAGE:-ghcr.io/melgarafael/deskcomm-scheduler:stable} + image: ${SCHEDULER_IMAGE:-ghcr.io/maugarciasa/deskcomm-scheduler:stable} pull_policy: ${SCHEDULER_PULL_POLICY:-always} build: context: . diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3afa96788..a3e895a2b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -26,7 +26,7 @@ ser fonte sem ninguém decidir isso. | `followup-dossie.architecture.json` | dossiê do follow-up e intervenção humana — 20 peças, 30 arestas; as **duas metades** da corrida contra o motor (o tick reclamado e o turno em voo) e quatro não-ligações declaradas | | `indice-de-atrito.architecture.json` | índice de atrito — 24 peças, 31 arestas; a régua do atrito, o rádio que a lê e as demandas que entram nela | | `marca-propria.architecture.json` | marca própria (white-label) — 37 peças, 54 arestas, 6 faixas; a pilha org → instalação → `.env` → padrão, as saídas SEM DOM (`marcaDaSaida`) e a **não-ligação declarada** do PDF de LGPD, que imprime o CONTROLADOR e nunca a marca de quem revende | -| `retencao-de-historico.architecture.json` | poda do histórico (issue #261) — 16 peças, 18 arestas, 6 faixas; o que sai (`done`/`failed`/`dead` velho), o que tem dono e **não** sai (`pending`/`running`, e `dead` com aviso ainda aberto), e por que o expurgo do audit é uma `security definer` sem seletor de linha em vez de uma porta | +| `retencao-de-historico.architecture.json` | poda do histórico (issue #261 + migration 0172) — 18 peças, 20 arestas, 6 faixas; o que sai (`done`/`failed`/`dead` velho em `job_queue`/`api_audit_log`/`event_log`), o que tem dono e **não** sai (`pending`/`running`/`processing`, e `dead` com aviso ainda aberto), e por que o expurgo do audit é uma `security definer` sem seletor de linha em vez de uma porta | > **Esta tabela já apodreceu uma vez:** ela listava 8 mapas quando o disco tinha 9 — faltava > `indice-de-atrito`. Nenhum teste lê este README (o gate lê os `.json`), então mapa novo que diff --git a/docs/architecture/retencao-de-historico.architecture.json b/docs/architecture/retencao-de-historico.architecture.json index 70fc3fd28..2827f73a2 100644 --- a/docs/architecture/retencao-de-historico.architecture.json +++ b/docs/architecture/retencao-de-historico.architecture.json @@ -2,8 +2,8 @@ "schema_version": 1, "diagram_type": "architecture", "meta": { - "title": "A poda do histórico — as duas tabelas que cresciam sozinhas", - "subtitle": "issue #261: o que sai, o que tem dono e não sai, e por que o expurgo da auditoria não é uma porta", + "title": "A poda do histórico — as três tabelas que cresciam sozinhas", + "subtitle": "issue #261 (job_queue/api_audit_log) + migration 0172 (event_log): o que sai, o que tem dono e não sai, e por que o expurgo da auditoria não é uma porta", "output": "retencao-de-historico.html", "quality_profile": "standard" }, @@ -17,7 +17,7 @@ ], "mainPath": ["scheduler", "rota", "podar", "fnpoda", "fila", "trilha"], "nodes": [ - { "id": "envs", "lane": "humano", "col": 1, "type": "frontend", "label": "JOB_QUEUE_RETENTION_DAYS / AUDIT_LOG_RETENTION_DAYS", "sublabel": "opcionais; os defaults valem sem editar .env (doutrina de packaging)" }, + { "id": "envs", "lane": "humano", "col": 1, "type": "frontend", "label": "JOB_QUEUE_RETENTION_DAYS / AUDIT_LOG_RETENTION_DAYS / EVENT_LOG_RETENTION_DAYS", "sublabel": "opcionais; os defaults valem sem editar .env (doutrina de packaging)" }, { "id": "runbook", "lane": "humano", "col": 2, "type": "frontend", "label": "docs/runbooks/custo-e-cota-do-supabase.md §4", "sublabel": "comandos: medir tamanho, ver a última poda, separar 'nada vencido' de 'cron parado'" }, { "id": "painel", "lane": "humano", "col": 3, "type": "frontend", "label": "Painel de auditoria (/admin/audit)", "sublabel": "retention.sweep_run entra no filtro por vir de AUDIT_ACTIONS" }, @@ -35,6 +35,8 @@ { "id": "auditlog", "lane": "banco", "col": 4, "type": "database", "label": "api_audit_log", "sublabel": "append-only no SCHEMA: ninguém tem GRANT de DELETE, nem service_role" }, { "id": "cascata", "lane": "banco", "col": 5, "type": "database", "label": "send_ledger / before_send_traces", "sublabel": "on delete cascade — parte do conserto, e os consumidores falham FECHADO" }, { "id": "avisos", "lane": "banco", "col": 6, "type": "database", "label": "agent_inbox_items (job_dead, status='open')", "sublabel": "o aviso aberto É o dono: o job que ele aponta não é podado" }, + { "id": "fnpodaevento", "lane": "banco", "col": 7, "type": "database", "label": "fn_podar_event_log (definer)", "sublabel": "migration 0172 · só done/dead · piso 7d no corpo · pending/processing nunca saem" }, + { "id": "eventlog", "lane": "banco", "col": 8, "type": "database", "label": "event_log", "sublabel": "8 event_type órfãos (sem handler) ficam pending para sempre — fora do alcance desta poda" }, { "id": "trilha", "lane": "volta", "col": 2, "type": "service", "label": "audit('retention.sweep_run')", "sublabel": "contagem + retenção em vigor; linha nova demais para o próprio expurgo alcançar" }, { "id": "log", "lane": "volta", "col": 1, "type": "service", "label": "logger.warn / logger.error", "sublabel": "knob ajustado e falha de RPC saem alto — nunca a frase tranquilizadora" } @@ -46,6 +48,8 @@ { "id": "e4", "from": "rota", "to": "podar", "label": "invoca a regra" }, { "id": "e5", "from": "podar", "to": "fnpoda", "label": "rpc em lote" }, { "id": "e6", "from": "podar", "to": "fnexpurgo", "label": "rpc em lote" }, + { "id": "e19", "from": "podar", "to": "fnpodaevento", "label": "rpc em lote" }, + { "id": "e20", "from": "fnpodaevento", "to": "eventlog", "label": "delete de done/dead vencidos" }, { "id": "e7", "from": "fnpoda", "to": "fila", "label": "delete dos terminais velhos" }, { "id": "e8", "from": "avisos", "to": "fnpoda", "label": "aviso aberto ⇒ não pode podar" }, { "id": "e9", "from": "fila", "to": "cascata", "label": "FK on delete cascade" }, diff --git a/docs/design/onda-7-alarme-de-orcamento.md b/docs/design/onda-7-alarme-de-orcamento.md index a3b8fdfed..fca3e61a1 100644 --- a/docs/design/onda-7-alarme-de-orcamento.md +++ b/docs/design/onda-7-alarme-de-orcamento.md @@ -7,7 +7,9 @@ > **Alvo em movimento, declarado:** durante a redação a branch avançou para > `a875faa7`. `git diff --name-only 8c7a8dc6..a875faa7` devolve **um** arquivo, > `HANDOFF-marca-propria.md` — nenhum arquivo medido nesta página mudou, e as -> citações de `HANDOFF-marca-propria.md:1038-1043` foram reconferidas no HEAD +> citações de `HANDOFF-marca-propria.md:1038-1043` (hoje arquivado em +> [`docs/handoffs/HANDOFF-marca-propria.md`](../handoffs/HANDOFF-marca-propria.md), mesmas linhas) +> foram reconferidas no HEAD > novo (seguem nessas linhas). A medição vale para os dois SHAs. Também apareceu > `M tests/invariants/marca-logo.test.ts` no working tree, de outra sessão; não > foi tocado aqui. @@ -228,7 +230,7 @@ tem fallback silencioso (`:98-102`). ## (b) O risco: o que se confirma e o que se desmente -O risco herdado (`HANDOFF-marca-propria.md:1038-1043`) diz: +O risco herdado (agora [`docs/handoffs/HANDOFF-marca-propria.md:1038-1043`](../handoffs/HANDOFF-marca-propria.md)) diz: > *"Numa instalação em que alguém preencheu `monthly_limit_cents` há meses — com > o contador travado em 0 — o primeiro tick estrangula a IA da organização, e o diff --git a/HANDOFF-conversa-vira-lead.md b/docs/handoffs/HANDOFF-conversa-vira-lead.md similarity index 100% rename from HANDOFF-conversa-vira-lead.md rename to docs/handoffs/HANDOFF-conversa-vira-lead.md diff --git a/HANDOFF-fv-w1-fila.md b/docs/handoffs/HANDOFF-fv-w1-fila.md similarity index 100% rename from HANDOFF-fv-w1-fila.md rename to docs/handoffs/HANDOFF-fv-w1-fila.md diff --git a/HANDOFF-ia-360.md b/docs/handoffs/HANDOFF-ia-360.md similarity index 100% rename from HANDOFF-ia-360.md rename to docs/handoffs/HANDOFF-ia-360.md diff --git a/HANDOFF-marca-propria.md b/docs/handoffs/HANDOFF-marca-propria.md similarity index 100% rename from HANDOFF-marca-propria.md rename to docs/handoffs/HANDOFF-marca-propria.md diff --git a/HANDOFF-sistema-vivo-consertos.md b/docs/handoffs/HANDOFF-sistema-vivo-consertos.md similarity index 100% rename from HANDOFF-sistema-vivo-consertos.md rename to docs/handoffs/HANDOFF-sistema-vivo-consertos.md diff --git a/HANDOFF-tres-papeis.md b/docs/handoffs/HANDOFF-tres-papeis.md similarity index 100% rename from HANDOFF-tres-papeis.md rename to docs/handoffs/HANDOFF-tres-papeis.md diff --git a/docs/index.md b/docs/index.md index f336c2226..26be818ee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -143,7 +143,7 @@ Documentação de *processo*. Alta rotatividade; trate como estado, não como co **encerrado** é arquivado em [`handoffs/`](handoffs/). Use isso para saber o que está em voo. - **Raiz (em voo):** `HANDOFF.md` (follow-up), `HANDOFF-harness-evolution.md`, `HANDOFF-operacao-visivel.md` -- [`handoffs/`](handoffs/) — arquivados: casos humanos, inbox multimodal, CRM vivo, LGPD, wave1-devvivo, contrato wave5, briefing CRM vivo +- [`handoffs/`](handoffs/) — arquivados: casos humanos, inbox multimodal, CRM vivo, LGPD, wave1-devvivo, contrato wave5, briefing CRM vivo, conversa vira lead (spec 17, PR #194), fv-w1-fila (follow-up vivo, PR #223), IA 360 (PR #145), marca própria/whitelabel (PR #248/#252), consertos do Sistema Vivo pós-#181 (PR #218/#222), três papéis do agente (spec 16, PR #181) - [`stories/`](stories/) — épicos e stories (`epics/MASTER.md` = plano por epic/wave) - [`superpowers/`](superpowers/) — `plans/` e `specs/` datados por onda, mais `handoffs/` - [`growth/`](growth/) — material de crescimento · [`brand/`](brand/) — marca · [`white-label.md`](white-label.md) — instalação com marca própria, também em [en](white-label.en.md) e [es](white-label.es.md) (traduções seladas pelo hash do original; ver `scripts/selar-traducao.ts`) diff --git a/docs/testing/user-journey-map.md b/docs/testing/user-journey-map.md index 6305385d2..6a15c4c4a 100644 --- a/docs/testing/user-journey-map.md +++ b/docs/testing/user-journey-map.md @@ -225,7 +225,7 @@ Evidência: `.superpowers/evidence/ia-360-w3/`. | J8.8 | O agente retoma **sabendo** o que a pessoa fez | a abertura do turno (`ritualBlocks`) cita a decisão dela, sem apagar o acumulado anterior | PASS | | J8.9 | Status da conversa escalada em português | o cabeçalho mostrava `pending` cru | FAIL → PASS | -Bugs desta jornada estão detalhados em `HANDOFF-ia-360.md` (BUG-01 a BUG-05). +Bugs desta jornada estão detalhados em [`docs/handoffs/HANDOFF-ia-360.md`](../handoffs/HANDOFF-ia-360.md) (BUG-01 a BUG-05, arquivado — epico IA 360 mergeado no PR #145). --- diff --git a/hooks/ai/useAgent.ts b/hooks/ai/useAgent.ts index 5e05079da..330614295 100644 --- a/hooks/ai/useAgent.ts +++ b/hooks/ai/useAgent.ts @@ -101,3 +101,38 @@ export function useUpdateAgent(id: string) { }, }); } + +interface PublishRagBotResponse { + data: { + agent_id: string; + version_id: string; + previous_version_id: string | null; + published_at: string; + }; +} + +/** + * Publica o rascunho de um agente `rag_bot` (POST /publish-rag-bot). Sem isso + * o "Salvar" do `AgentEditor.tsx` grava o rascunho mas o runtime continua + * respondendo com a última versão publicada — ver comentário da rota. + */ +export function usePublishRagBotAgent(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationKey: ["ai", "agents", id, "publish-rag-bot"], + mutationFn: async () => { + const res = await apiClient.post( + `/api/v1/ai/agents/${id}/publish-rag-bot`, + {}, + ); + return res.data; + }, + onError: (err) => { + showApiError(err); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: agentQueryKey(id) }); + toast.success("Publicado"); + }, + }); +} diff --git a/hostgator-setup-kit/CLAUDE.md b/hostgator-setup-kit/CLAUDE.md index 75143f3ac..3f2996633 100644 --- a/hostgator-setup-kit/CLAUDE.md +++ b/hostgator-setup-kit/CLAUDE.md @@ -184,9 +184,12 @@ Se mesmo assim aparecerem, aqui está o diagnóstico pronto: - **Numa instalação que ainda não tem o agente da tela**, rodar `bash update.sh` **duas vezes** liga o botão: a primeira execução ainda é a do script antigo (que baixa o novo, mas não conhece o agente); a segunda instala o cron do agente. -- **Backup** (importante! o Supabase grátis não faz sozinho): `bash backup.sh`, - e sugira agendar um backup diário no cron. O `update.sh` já roda um backup sozinho - antes de cada atualização. +- **Backup** (importante! o Supabase grátis não faz sozinho): já é automático — o + `install.sh` (e o `update.sh`) agenda sozinho um cron diário de `bash backup.sh` + no servidor (03h por padrão; ajustável por `BACKUP_CRON_HOUR` no `.env`). Não + precisa orientar a pessoa a agendar nada. `bash backup.sh` continua existindo + para rodar um backup avulso na hora. O `update.sh` também roda um backup sozinho + antes de cada atualização, além do cron diário. ## O que você NÃO faz diff --git a/hostgator-setup-kit/_common.sh b/hostgator-setup-kit/_common.sh index 5d11896af..1bb7648f6 100755 --- a/hostgator-setup-kit/_common.sh +++ b/hostgator-setup-kit/_common.sh @@ -348,7 +348,7 @@ psql_run() { docker run --rm -i postgres:17-alpine psql "$(url_do_schema)" -v ON # O namespace é constante e literal de propósito: ele está gravado no .env de # toda instalação viva, e derivá-lo de variável faria o kit antigo (que já está # no disco do cliente) e o novo montarem strings diferentes. -IMG_NS="ghcr.io/melgarafael" +IMG_NS="ghcr.io/maugarciasa" IMG_APP="${IMG_NS}/deskcommcrm" IMG_WORKER="${IMG_NS}/deskcomm-worker" IMG_SCHEDULER="${IMG_NS}/deskcomm-scheduler" @@ -365,7 +365,7 @@ IMG_SCHEDULER="${IMG_NS}/deskcomm-scheduler" # alguém porque não deu para resolver um número de versão seria trocar um # problema de previsibilidade por um de disponibilidade. ultima_versao_publicada() { - local url="${1:-https://github.com/melgarafael/DeskcommCRM.git}" ref + local url="${1:-https://github.com/maugarciasa/DeskcommCRM.git}" ref command -v git >/dev/null 2>&1 || return 0 # `grep -v -- -` descarta PRERELEASE (v1.11.0-rc1, v1.1.1-jmpo.1 — esta última # existe de verdade neste repo). O `--sort=-v:refname` do git põe o prerelease @@ -387,13 +387,13 @@ ultima_versao_publicada() { ghcr_status() { local img="$1" tag="$2" tok tok="$(curl -fsS --max-time 6 \ - "https://ghcr.io/token?scope=repository:melgarafael/${img}:pull&service=ghcr.io" 2>/dev/null \ + "https://ghcr.io/token?scope=repository:maugarciasa/${img}:pull&service=ghcr.io" 2>/dev/null \ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" || true if [ -z "$tok" ]; then printf '000'; return 0; fi curl -s -o /dev/null --max-time 6 -w '%{http_code}' \ -H "Authorization: Bearer $tok" \ -H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json' \ - "https://ghcr.io/v2/melgarafael/${img}/manifests/${tag}" 2>/dev/null || printf '000' + "https://ghcr.io/v2/maugarciasa/${img}/manifests/${tag}" 2>/dev/null || printf '000' } # As TRÊS imagens existem e são públicas nesta referência? @@ -584,7 +584,7 @@ owner_id_by_email() { # primeira (o filtro remove tudo que casa com o marcador, e as duas linhas # casavam). Medido na VPS: depois de instalar, sobrava só o agente e o CRM ficava # SEM o drain de eventos — a automação inteira parada, em silêncio. -cron_tag() { printf '# deskcomm:%s:%s' "${PROJECT_DIR:-$PWD}" "${1:?papel da linha (drain|agent)}"; } +cron_tag() { printf '# deskcomm:%s:%s' "${PROJECT_DIR:-$PWD}" "${1:?papel da linha (drain|agent|backup)}"; } # Puro (testável sem tocar no crontab real): lê o crontab atual em stdin e # imprime o novo. Tira as linhas DESTA instalação — pelo marcador, e também @@ -657,6 +657,45 @@ setup_update_agent_cron() { c_grn "✓ atualização pela tela ativa (agente a cada 5 minutos)" } +# Ativa (idempotente) o cron do backup diário (banco + sessões do WhatsApp). +# +# Por que HOST, e não o container `scheduler`: o `backup.sh` faz `docker run +# postgres:17-alpine pg_dump` e `docker run -v waha-data:/data alpine tar`, e o +# `scheduler` é DELIBERADAMENTE sem docker.sock ("Cron sem docker.sock: crond +# interno batendo curl na rede interna" — ver docker-compose.prod.yml). Dar +# socket a ele só para isto seria abrir root-no-host a um container cujo +# desenho inteiro é não ter esse alcance. O crontab do HOST é o mecanismo que +# este kit já usa para automação que também exige Docker: mesma função +# (setup_update_agent_cron, acima) roda `agent.sh`, que também chama `docker`. +# +# Sem isto o backup existia só como script MANUAL (cabeçalho do backup.sh +# ensinava "crontab -e" à mão) — numa instalação self-host real, onde quem +# instala não é a mesma pessoa que sabe editar crontab, isso é "nunca ter +# backup rodando". É achado de auditoria: Supabase free não faz backup +# sozinho, e depender do dono lembrar de agendar é depender do pior caso. +# +# Chamada por install.sh e update.sh, junto das outras automações — re-rodar +# não duplica a linha do crontab (mesmo cron_merge por marcador das outras). +# Horário configurável por BACKUP_CRON_HOUR (0-23; default 3 = 03h, fora do +# expediente); minuto fixo em :00 — não é knob, é o que o próprio cabeçalho do +# backup.sh já ensinava como receita manual. +setup_backup_cron() { + command -v crontab >/dev/null 2>&1 || { c_ylw "⚠ 'crontab' não encontrado — o backup diário não foi agendado. Rode 'bash hostgator-setup-kit/backup.sh' manualmente, ou instale o pacote 'cron' e rode de novo."; return 0; } + + local hora="${BACKUP_CRON_HOUR:-3}" + case "$hora" in ''|*[!0-9]*) hora=3;; esac + [ "$hora" -le 23 ] || hora=3 + + # Mesmo `cd` explícito de setup_update_agent_cron, e pelo mesmo motivo: o + # backup.sh chama enter_project(), que acha o projeto pelo CWD, e no cron o + # CWD é o home de quem é dono do crontab (não a pasta do projeto). + local legado="cd ${PROJECT_DIR} && bash hostgator-setup-kit/backup.sh" + local marcador; marcador="$(cron_tag backup)" + local cron_line="0 ${hora} * * * ${legado} >/dev/null 2>&1 ${marcador}" + ( crontab -l 2>/dev/null | cron_merge "$marcador" "$legado" "$cron_line" ) | crontab - + c_grn "✓ backup diário agendado (todo dia às $(printf '%02d' "$hora"):00)" +} + # Garante a chave de cifra dos segredos (webhooks/Nuvemshop) e a semeia no # banco (private.app_secrets, migration 0041). Idempotente: reusa a chave do # .env se existir (trocá-la invalidaria dados já cifrados); gera se ausente e diff --git a/hostgator-setup-kit/backup.sh b/hostgator-setup-kit/backup.sh index 532e1901b..ff7cb99d7 100755 --- a/hostgator-setup-kit/backup.sh +++ b/hostgator-setup-kit/backup.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash # Backup: dump do banco (Supabase) + snapshot das sessões do WhatsApp. -# Supabase free NÃO tem backup automático — rode isto num cron diário. +# Supabase free NÃO tem backup automático — mas o install.sh/update.sh já +# agendam isto sozinhos num cron diário do HOST (setup_backup_cron, em +# _common.sh; horário em BACKUP_CRON_HOUR no .env, default 03h). Não precisa +# agendar à mão. Para rodar manual, ou com outro horário próprio: # # crontab -e → 0 3 * * * cd /caminho/deskcommcrm && bash hostgator-setup-kit/backup.sh source "$(dirname "$0")/_common.sh" diff --git a/hostgator-setup-kit/comecar.sh b/hostgator-setup-kit/comecar.sh index 13bee40a5..3bd565323 100755 --- a/hostgator-setup-kit/comecar.sh +++ b/hostgator-setup-kit/comecar.sh @@ -9,11 +9,11 @@ # # Uso: # bash comecar.sh -# curl -fsSL https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/hostgator-setup-kit/comecar.sh | bash +# curl -fsSL https://raw.githubusercontent.com/maugarciasa/DeskcommCRM/main/hostgator-setup-kit/comecar.sh | bash # set -euo pipefail -REPO_URL="${REPO_URL:-https://github.com/melgarafael/DeskcommCRM.git}" +REPO_URL="${REPO_URL:-https://github.com/maugarciasa/DeskcommCRM.git}" # Link de parceria com a HostGator. Mesma URL e mesmo rótulo do README: uma # promessa só, num lugar só — duas redações da mesma oferta viram duas ofertas. VPS_URL="https://www.hostgator.com.br/52708-141-3-52.html" diff --git a/hostgator-setup-kit/diagnostico.sh b/hostgator-setup-kit/diagnostico.sh index 3bd5e1ba7..869d16212 100755 --- a/hostgator-setup-kit/diagnostico.sh +++ b/hostgator-setup-kit/diagnostico.sh @@ -19,7 +19,7 @@ # depender dele seria diagnosticar o passado com a ferramenta do passado. E # precisa poder ser baixado avulso, sem clonar nada: # -# curl -fsSL https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/hostgator-setup-kit/diagnostico.sh | bash +# curl -fsSL https://raw.githubusercontent.com/maugarciasa/DeskcommCRM/main/hostgator-setup-kit/diagnostico.sh | bash # # ── O que ele pode assumir que existe ──────────────────────────────────────── # Medido numa VPS real: bash 5.1, docker, docker compose, curl, sed/awk/grep. diff --git a/hostgator-setup-kit/install.sh b/hostgator-setup-kit/install.sh index 3cc5ff0eb..56afec492 100755 --- a/hostgator-setup-kit/install.sh +++ b/hostgator-setup-kit/install.sh @@ -15,7 +15,7 @@ set -euo pipefail # de qualquer 'cd' (step 2 pode entrar num repo clonado à parte). KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -REPO_URL="${REPO_URL:-https://github.com/melgarafael/DeskcommCRM.git}" +REPO_URL="${REPO_URL:-https://github.com/maugarciasa/DeskcommCRM.git}" # Uma constante, dois usos (o fim feliz e o fim travado) — e o comecar.sh tem a # gêmea. Link repetido à mão vira link divergente na primeira troca. COMUNIDADE_URL="https://lp-comunidade.automatiklabs.com.br" @@ -1471,6 +1471,10 @@ esac envq NODE_ENV "production" envq NUVEMSHOP_ENABLED "false" envq INTERNAL_AGENT_RUN_STUB "false" + # Hora do backup diário (0-23; default 03h). setup_backup_cron() em + # _common.sh já cai em 3 quando ausente/inválida — grava aqui só para + # persistir a escolha real e não sumir com ela no próximo update.sh. + envq BACKUP_CRON_HOUR "${BACKUP_CRON_HOUR:-3}" envq OWNER_EMAIL "$OWNER_EMAIL" envq OWNER_PASSWORD "$OWNER_PASSWORD" # As variáveis que você acrescentou à mão, de volta — já no formato em que @@ -1711,11 +1715,12 @@ else [ -n "$health_body" ] && c_dim " última resposta: $(printf '%s' "$health_body" | head -c 200 || true)" fi -# ── 11. Automações (cron do drain de eventos) ─────────────────────────────── +# ── 11. Automações (cron do drain de eventos + backup diário) ─────────────── step "Ativando as automações" ensure_encryption_key .env setup_event_log_drain_cron setup_update_agent_cron +setup_backup_cron # ── Final ─────────────────────────────────────────────────────────────────── # O app não confirmou que está de pé: dizer "Instalação concluída!" aqui seria diff --git a/hostgator-setup-kit/test-validators.sh b/hostgator-setup-kit/test-validators.sh index 867ebddae..4dd050e15 100755 --- a/hostgator-setup-kit/test-validators.sh +++ b/hostgator-setup-kit/test-validators.sh @@ -1470,7 +1470,7 @@ STUB tag_app="${img_app##*:}" for par in "WORKER_IMAGE:deskcomm-worker" "SCHEDULER_IMAGE:deskcomm-scheduler"; do chave="${par%%:*}"; repo="${par##*:}" - if [ "$(valor_no_env "$VPS_PROJ/.env" "$chave")" != "ghcr.io/melgarafael/${repo}:${tag_app}" ]; then + if [ "$(valor_no_env "$VPS_PROJ/.env" "$chave")" != "ghcr.io/maugarciasa/${repo}:${tag_app}" ]; then printf ' ✗ %s não acompanha a versão do app (%s): %s\n' "$chave" "$tag_app" \ "$(grep -E "^${chave}=" "$VPS_PROJ/.env" || echo '(ausente)')" printf ' app numa versão e worker em outra é a matriz que ninguém testou.\n'; exit 1 @@ -1629,7 +1629,7 @@ STUB for par in "APP_IMAGE:deskcommcrm" "WORKER_IMAGE:deskcomm-worker" "SCHEDULER_IMAGE:deskcomm-scheduler"; do chave="${par%%:*}"; repo="${par##*:}" - if [ "$(valor_no_env "$VPS_PROJ/.env" "$chave")" != "ghcr.io/melgarafael/${repo}:1.10.0" ]; then + if [ "$(valor_no_env "$VPS_PROJ/.env" "$chave")" != "ghcr.io/maugarciasa/${repo}:1.10.0" ]; then printf ' ✗ %s não foi pinado na versão resolvida (1.10.0): %s\n' "$chave" \ "$(grep -E "^${chave}=" "$VPS_PROJ/.env" || echo '(ausente)')" printf ' instalação de cliente NUNCA nasce em tag móvel — docs/doctrine/packaging.md, invariante 3.\n' diff --git a/hostgator-setup-kit/update.sh b/hostgator-setup-kit/update.sh index 79e586f9b..58c7c7a8c 100755 --- a/hostgator-setup-kit/update.sh +++ b/hostgator-setup-kit/update.sh @@ -51,7 +51,7 @@ CURRENT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)" # (Veio da `main`; a versão por tag cai exatamente na mesma armadilha, porque a # comparação de tags também fica satisfeita com a imagem velha no lugar.) image_desatualizada() { - local img="${APP_IMAGE:-ghcr.io/melgarafael/deskcommcrm:latest}" local_d remote_d + local img="${APP_IMAGE:-ghcr.io/maugarciasa/deskcommcrm:latest}" local_d remote_d local_d="$(docker image inspect "$img" --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' 2>/dev/null | sed 's/.*@//')" [ -z "$local_d" ] && return 0 # nem baixada ainda → atualizar remote_d="$(docker buildx imagetools inspect "$img" 2>/dev/null | awk '/^Digest:/{print $2; exit}')" @@ -278,7 +278,8 @@ else exit 1 fi -# ── 7. Automações (cron do drain de eventos; o da tela já subiu no bloco 0) ── +# ── 7. Automações (cron do drain de eventos + backup; o da tela já subiu no bloco 0) ── step "Conferindo as automações" ensure_encryption_key .env setup_event_log_drain_cron +setup_backup_cron diff --git a/lib/agent-engine/agent/inbound-turn.ts b/lib/agent-engine/agent/inbound-turn.ts index c4dc9d6b3..700a8c512 100644 --- a/lib/agent-engine/agent/inbound-turn.ts +++ b/lib/agent-engine/agent/inbound-turn.ts @@ -78,7 +78,7 @@ import { DECLARACAO_INSTRUCTION, declaracaoDoTurnoSchema, promessasEmAberto, typ import { projetarContexto, projetarRetornoDeTool, turnoProjeta, type ContextoProjetado } from './projecao'; import { capacidadesEntreguesAoOperador, catalogoEntregueAoOperador } from './entrega-de-capacidade'; import { composeSystemPrompt, loadOrgMemory, renderOrgMemory } from './org-memory'; -import { matchesHandoffKeyword } from './agent-config'; +import { matchesHandoffKeyword, type PublishedAgentConfig } from './agent-config'; import { resolveTurnAgent } from './resolve-turn-agent'; import { hasOpenCaseForContact, @@ -89,7 +89,7 @@ import { provideCaseUpdateInputSchema, } from './human-cases'; import { buildMcpTurnTools } from '../edge/crm/mcp-tools'; -import { cancelPendingCronsForLead } from '../cron/scheduler'; +import { cancelPendingCronsForLead, scheduleCronJob } from '../cron/scheduler'; import { latestInboundSignal, loadSkills, @@ -97,6 +97,7 @@ import { recordSkillMissCandidates, renderMatchedSkillBodies, renderSkillIndex, + type LoadedSkill, } from './skills'; import { readSkillReference, skillHasReferences } from './skill-references'; import { READ_ONLY_TOOLS, wrapToolsWithBreaker, type ToolBreakerThresholds } from './tool-breaker'; @@ -931,6 +932,64 @@ export async function avisarCapacidadesAusentes( } } +/** + * Veto do gate `pacing` não pode virar silêncio para o lead (mesmo argumento de + * `comHandoffSeOrcamentoAcabar`, aplicado a um gate diferente). + * + * `runBeforeSend` veta em `outside_window`/`warmup_cap`/`daily_cap` com + * `nextAllowedAt` já calculado (`decidePacing`, `pacing/engine.ts`) — mas até aqui + * o `send_message` do turno só ENSINAVA o modelo (retornava o erro pro loop de + * tools) e o run fechava com `messages_sent: 0`, sem reagendar nada. Fora do + * caminho determinístico de re-entrada (`runDeterministicReentry`, que só cobre + * `outside_window`), NADA reagendava — medido em produção (instalação MKT, + * 2026-08-22): `warmup_cap` bateu com o cliente NO MEIO da conversa, o turno + * terminou "concluído" e a próxima tentativa só aconteceria se o cliente + * mandasse OUTRA mensagem. + * + * A saída é reusar `followup_turn` como o "volte e tente de novo" — é a MESMA + * peça que a re-entrada determinística já usa para `outside_window` + * (`rescheduleReentry`, followup-turn.ts), só que agora coberta para os TRÊS + * códigos de veto do pacing (todos carregam `nextAllowedAt`; nenhum outro gate + * carrega). `followup_turn` resolve conversa/sessão pela ROW do lead (nunca do + * payload), então reagendar só precisa do `leadId` e do instante. + * + * Idempotente por job de origem (mesmo padrão de `rescheduleReentry`): dois sends + * vetados no MESMO turno (o modelo tenta de novo após o erro de ensino) não + * duplicam o reagendamento — cabe UM followup_turn por job que sofreu o veto. + * + * Best-effort: falhar aqui não pode derrubar o erro de ensino que o modelo já + * está recebendo — o pior caso vira "sem reagendamento" (o defeito de antes), + * nunca uma exceção que descarta o turno inteiro por causa do agendamento. + */ +export async function reagendarTurnoPorVetoDePacing( + pool: pg.Pool, + log: Logger, + input: { tenantId: string; leadId: string; jobId: string; at: Date }, +): Promise { + try { + const { rowCount } = await pool.query( + `select 1 from cron_jobs + where organization_id = $1 and contact_id = $2 and payload->>'reschedule_of' = $3`, + [input.tenantId, input.leadId, input.jobId], + ); + if (rowCount !== null && rowCount > 0) return; // já reagendado para este job + await scheduleCronJob(pool, input.tenantId, { + leadId: input.leadId, + spec: { kind: 'at', at: input.at }, + jobKind: 'followup_turn', + payload: { reschedule_of: input.jobId }, + staggerWindowMs: 0, + }); + log.info('turno reagendado após veto do pacing — lead não fica sem próximo passo', { + next_run_at: input.at.toISOString(), + }); + } catch (err) { + log.warn('reagendamento pós-veto de pacing falhou — turno segue sem retry automático', { + error: (err instanceof Error ? err.message : String(err)).slice(0, 120), + }); + } +} + /** * O NÚCLEO DO TURNO, SEMPRE SOB A ESCOLTA DO ORÇAMENTO. * @@ -976,43 +1035,22 @@ export async function runAgentTurn( ); } -async function executarTurnoDoAgente( +/** + * Fase 3: resolve o agente publicado desta sessão (stickiness do router + sinal de + * roteamento do inbound) e grava a decisão em `ai_router_decisions` (fire-and-forget — + * falha de telemetria nunca derruba o turno). Extraída de `executarTurnoDoAgente` só + * por tamanho: o único valor que sobrevive depois deste passo é o `PublishedAgentConfig` + * devolvido (ou `null`, sem agente publicado para esta sessão). + */ +async function resolverAgenteDoTurno( + pool: pg.Pool, deps: InboundTurnDeps, job: JobRow, - pool: pg.Pool, - ctx: { workerId: string }, + tenantId: string, + leadId: string, input: AgentTurnInput, -): Promise { - const tenantId = job.organization_id; - const leadId = job.contact_id; - if (leadId === null) { - throw new Error('job de turno sem contact_id — o CHECK da fila deveria impedir'); - } - const contextKnobs = { historyLimit: deps.knobs.historyLimit, maxTokens: deps.knobs.maxContextTokens }; - // Contexto do RUN em toda linha de log do turno (F2-16): job_id É o run id. - const runLog = withFields(deps.log, { job_id: job.id, tenant_id: tenantId, lead_id: leadId }); - - // AS DUAS CAMADAS QUE CUSTAM DINHEIRO, resolvidas UMA vez por turno. - // - // Os knobs (`deps.knobs.jailbreak`, `deps.knobs.promiseSemantic`) nascem no boot - // do worker e valem para a instalação inteira; a linha em `org_guardrail_layers` - // é a preferência de QUEM PAGA a consulta. Sem linha, `camadaLigada` devolve o - // padrão do ambiente — aplicar a migration não muda o comportamento de quem já - // decidiu no `.env`. - // - // Lido aqui, e não em cada ponto de uso: os dois consumidores ficam a ~900 - // linhas de distância um do outro, e duas queries para a mesma pergunta viram, - // com o tempo, duas respostas. - const camadas = await lerCamadasDaOrg(pool, tenantId); - - // F4-06 (acceptance 2): lead em handoff humano → NO-OP no INÍCIO do turno, antes de - // qualquer chamada de modelo/CRM. O bot silenciou (bot_silenced_until='infinity', cache - // do force_human do CRM) e só o humano/CRM libera — o agente nunca reassume (regra dura 2). - if (await isLeadInHandoff(pool, tenantId, leadId)) { - runLog.info('turno pulado — lead em handoff humano (bot silenciado)', { kind: job.kind }); - return; - } - + runLog: Logger, +): Promise { // Fase 3: stickiness do router — qual agente já atende esta conversa. Leituras // tolerantes a falha (ex.: clone self-host ainda sem a migration 0085 aplicada) — // um erro aqui degrada pro fluxo sem router, nunca derruba o turno (review T5). @@ -1101,6 +1139,89 @@ async function executarTurnoDoAgente( runLog.warn('decisão do router não gravada', { error: (err instanceof Error ? err.message : String(err)).slice(0, 120) }); } } + return agentConfig; +} + +/** + * Playbook (por ponteiro) + skills residentes + memória da org, compostos no prefixo + * estável do system prompt (F2-17). `skills` sobrevive ao retorno: o matching por + * sinal (`matchSkills`) roda mais abaixo, sobre a mesma lista carregada aqui. + */ +async function montarPromptDoSistema( + pool: pg.Pool, + tenantId: string, + agentConfig: PublishedAgentConfig | null, +): Promise<{ system: string; skills: LoadedSkill[] }> { + // Ritual de abertura: playbook por ponteiro + checkpoint + contexto curado. + // Com agente publicado, o system_prompt DELE é a camada tenant (platform de + // compliance continua à frente, sempre). + const playbook = await loadPlaybook( + pool, + tenantId, + agentConfig !== null ? { agentLayer: agentConfig.systemPrompt } : undefined, + ); + // Skills situacionais (F3-09): índice (name+description) SEMPRE residente — vai junto do + // system do playbook, no prefixo estável org-wide (disclosure progressivo; cacheável F2-17). + // O CORPO só carrega no match, no sufixo por-lead (mais abaixo). loadSkills resolve os + // ponteiros a cada run: trocar/rollback de skill = mover o ponteiro, sem restart. + const skills = await loadSkills(pool, tenantId); + const skillIndex = renderSkillIndex(skills); + // Fase 1 (harness): memória geral da org — prefixo estável, resolvida a cada + // turno como o playbook (publicar ⇒ próximo turno vale). composeSystemPrompt já + // encaixa playbook + memória + índice de skills no prefixo cacheável. + const orgMemory = await loadOrgMemory(pool, tenantId); + const systemWithMemory = composeSystemPrompt({ + playbookPrompt: playbook.prompt, + orgMemoryBlock: renderOrgMemory(orgMemory), + skillIndex, + }); + // Spec 15 §5.2: bloco das tools de caso SEMPRE residente (não invalida o prefixo + // cacheável — mesmo espírito do índice de skills) quando a tela habilita. + const system = + agentConfig !== null && agentConfig.casesEnabled + ? `${systemWithMemory}\n\n${CASES_SYSTEM_BLOCK}` + : systemWithMemory; + return { system, skills }; +} + +async function executarTurnoDoAgente( + deps: InboundTurnDeps, + job: JobRow, + pool: pg.Pool, + ctx: { workerId: string }, + input: AgentTurnInput, +): Promise { + const tenantId = job.organization_id; + const leadId = job.contact_id; + if (leadId === null) { + throw new Error('job de turno sem contact_id — o CHECK da fila deveria impedir'); + } + const contextKnobs = { historyLimit: deps.knobs.historyLimit, maxTokens: deps.knobs.maxContextTokens }; + // Contexto do RUN em toda linha de log do turno (F2-16): job_id É o run id. + const runLog = withFields(deps.log, { job_id: job.id, tenant_id: tenantId, lead_id: leadId }); + + // AS DUAS CAMADAS QUE CUSTAM DINHEIRO, resolvidas UMA vez por turno. + // + // Os knobs (`deps.knobs.jailbreak`, `deps.knobs.promiseSemantic`) nascem no boot + // do worker e valem para a instalação inteira; a linha em `org_guardrail_layers` + // é a preferência de QUEM PAGA a consulta. Sem linha, `camadaLigada` devolve o + // padrão do ambiente — aplicar a migration não muda o comportamento de quem já + // decidiu no `.env`. + // + // Lido aqui, e não em cada ponto de uso: os dois consumidores ficam a ~900 + // linhas de distância um do outro, e duas queries para a mesma pergunta viram, + // com o tempo, duas respostas. + const camadas = await lerCamadasDaOrg(pool, tenantId); + + // F4-06 (acceptance 2): lead em handoff humano → NO-OP no INÍCIO do turno, antes de + // qualquer chamada de modelo/CRM. O bot silenciou (bot_silenced_until='infinity', cache + // do force_human do CRM) e só o humano/CRM libera — o agente nunca reassume (regra dura 2). + if (await isLeadInHandoff(pool, tenantId, leadId)) { + runLog.info('turno pulado — lead em handoff humano (bot silenciado)', { kind: job.kind }); + return; + } + + const agentConfig = await resolverAgenteDoTurno(pool, deps, job, tenantId, leadId, input, runLog); // Knobs por-turno: a versão publicada vence o env; sem ela, env (main.ts). const maxSteps = agentConfig?.maxSteps ?? deps.knobs.maxSteps; // Fallback de modelo das chamadas AUXILIARES (classificadores/compaction/promessa): @@ -1136,35 +1257,7 @@ async function executarTurnoDoAgente( ? { historyLimit: agentConfig.historyMessageWindow, maxTokens: agentConfig.historyTokenWindow } : contextKnobs; - // Ritual de abertura: playbook por ponteiro + checkpoint + contexto curado. - // Com agente publicado, o system_prompt DELE é a camada tenant (platform de - // compliance continua à frente, sempre). - const playbook = await loadPlaybook( - pool, - tenantId, - agentConfig !== null ? { agentLayer: agentConfig.systemPrompt } : undefined, - ); - // Skills situacionais (F3-09): índice (name+description) SEMPRE residente — vai junto do - // system do playbook, no prefixo estável org-wide (disclosure progressivo; cacheável F2-17). - // O CORPO só carrega no match, no sufixo por-lead (mais abaixo). loadSkills resolve os - // ponteiros a cada run: trocar/rollback de skill = mover o ponteiro, sem restart. - const skills = await loadSkills(pool, tenantId); - const skillIndex = renderSkillIndex(skills); - // Fase 1 (harness): memória geral da org — prefixo estável, resolvida a cada - // turno como o playbook (publicar ⇒ próximo turno vale). composeSystemPrompt já - // encaixa playbook + memória + índice de skills no prefixo cacheável. - const orgMemory = await loadOrgMemory(pool, tenantId); - const systemWithMemory = composeSystemPrompt({ - playbookPrompt: playbook.prompt, - orgMemoryBlock: renderOrgMemory(orgMemory), - skillIndex, - }); - // Spec 15 §5.2: bloco das tools de caso SEMPRE residente (não invalida o prefixo - // cacheável — mesmo espírito do índice de skills) quando a tela habilita. - const system = - agentConfig !== null && agentConfig.casesEnabled - ? `${systemWithMemory}\n\n${CASES_SYSTEM_BLOCK}` - : systemWithMemory; + const { system, skills } = await montarPromptDoSistema(pool, tenantId, agentConfig); const previous = await latestCheckpoint(pool, tenantId, leadId); const leadState = await getLeadState(pool, tenantId, leadId); const openingContext = await getLeadContext( @@ -1222,60 +1315,12 @@ async function executarTurnoDoAgente( return; // bot silencia: sem modelo, sem envio neste turno } - // F3-07: compaction + flush pré-compaction. Quando o histórico cresce além do limiar, - // o FLUSH grava as notas duráveis (lead_notes) e a compaction resume a conversa com o - // modelo BARATO; o resumo compactado entra no lugar do rolling summary e o transcript - // integral é trocado por uma cauda recente sob orçamento (regra de cache 15). O rolling - // summary DURÁVEL segue vindo do checkpoint de fechamento; aqui ele só alimenta o prompt. - let effectivePrevious = previous; - let effectiveContext = openingContext.context; - if (deps.knobs.compaction !== undefined) { - const compacted = await maybeCompact( - pool, - deps.llmCfg, - { tenantId, leadId, jobId: job.id }, - { - context: openingContext.context, - previousSummary: previous?.rolling_summary ?? '', - // A compactação é o QUARTO call site da mesma regra, e o #151 só cobriu - // três: ela também pedia o modelo do agente ao provider default da org. - // Mesmo 404, mesma morte de turno — só que num caminho que roda quando a - // conversa já é longa, ou seja, mais tarde e com menos gente olhando. - knobs: { ...deps.knobs.compaction, ...argsAux(deps.knobs.compaction.model) }, - notesIndexMaxTokens: deps.knobs.notesIndexMaxTokens, - }, - { registry: deps.registry, log: runLog }, - ); - if (compacted !== null) { - // Só o rolling_summary é sobrescrito (o resumo compactado carrega compromissos/ - // objeções/estágio/dados pessoais planificados). O `previous` sintético do 1º - // turno com histórico importado é local — nunca persistido; o fechamento grava o - // checkpoint real. - const base: LeadCheckpointRow = - previous ?? - { - id: '', - seq: '0', - organization_id: tenantId, - contact_id: leadId, - job_id: null, - created_at: new Date(), - commitments: [], - objections: [], - next_action: null, - rolling_summary: '', - // Este `previous` é sintetizado a partir de histórico IMPORTADO — não - // houve turno nosso, logo ninguém declarou nada. `null` é o valor - // honesto; um objeto vazio afirmaria uma avaliação que não aconteceu. - declaracao: null, - }; - effectivePrevious = { ...base, rolling_summary: renderCompactedSummary(compacted) }; - effectiveContext = { - ...openingContext.context, - messages: trimTranscriptToBudget(openingContext.context.messages, deps.knobs.compaction.transcriptMaxTokens), - }; - } - } + const { effectivePrevious, effectiveContext } = await compactarSeNecessario( + pool, + deps, + { tenantId, leadId, jobId: job.id }, + { previous, openingContext, argsAux, runLog }, + ); // Índice da memória durável do lead (F3-05) — headlines dentro do orçamento fixo, // injetado no SUFIXO da abertura (não invalida o prefixo cacheável F2-17). Montado @@ -1746,6 +1791,18 @@ async function executarTurnoDoAgente( }); } if (chain.status === 'vetoed') { + // pacing (outside_window/warmup_cap/daily_cap) é o ÚNICO gate que calcula + // nextAllowedAt — veto dele não pode virar silêncio: reagenda um + // followup_turn pra tentar de novo na próxima janela válida (ver o + // cabeçalho de reagendarTurnoPorVetoDePacing). + if (chain.gate === 'pacing' && chain.nextAllowedAt !== undefined) { + await reagendarTurnoPorVetoDePacing(pool, runLog, { + tenantId, + leadId, + jobId: job.id, + at: chain.nextAllowedAt, + }); + } // Erro de ENSINO pt-br (mesmo shape de get_lead_context/breaker): o // modelo o vê no turno seguinte. NÃO é exceção — não derruba o run. return { ok: false, error: { code: chain.code, message: chain.message } }; @@ -2346,20 +2403,7 @@ async function executarTurnoDoAgente( { registry: deps.registry, log: runLog }, ); - // F4-04: correlação dos dois sinais do MESMO turno — jailbreak ALTO + tentativa de - // promessa fora de tabela (F4-01). Ambos estão determinados aqui (o jailbreak rodou na - // abertura; as tentativas de envio já passaram pelo loop). Dispara escalação humana em - // inbox_items (dedup por episódio). Advisório: o classifier sozinho nunca escala — o gate - // determinístico é que confirma a promessa indevida. Feito antes do runError/veto para - // não se perder num turno que falha o envio depois. - if (jailbreakLevel === JAILBREAK_ESCALATION_LEVEL && outOfTablePromiseAttempted) { - const created = await escalateJailbreakPromise(pool, { tenantId, leadId, level: jailbreakLevel }); - if (created > 0) { - runLog.warn('jailbreak: escalação humana criada (flag alta + promessa fora de tabela no turno)', { - jailbreak_level: jailbreakLevel, - }); - } - } + await escalarSeJailbreakComPromessaForaDeTabela(pool, { tenantId, leadId, jailbreakLevel, outOfTablePromiseAttempted }, runLog); if (runError !== null) { throw runError; // job falha → retry da fila; o ledger segura duplicata de envio @@ -2370,57 +2414,14 @@ async function executarTurnoDoAgente( throw new Error('envio marcado como failed pelo CRM — run re-tentado pela fila'); } - // F3-10: poda os tool results antigos da fita do run ANTES de reenviá-los no fechamento - // (é onde a fita inteira é re-serializada num prompt) — o conteúdo durável já foi para - // lead_notes pelo flush (F3-07), então o stub não perde nada recuperável. Opera SÓ no - // sufixo por-lead, nunca no prefixo estável (regra de cache 15). - const responseMessages = - deps.knobs.prune !== undefined - ? pruneToolResults(turn.result.response.messages, deps.knobs.prune) - : turn.result.response.messages; + const { content, checkpointAnterior, closingCallId } = await fecharTurnoEGravarCheckpoint( + pool, + deps, + { tenantId, leadId, jobId: job.id }, + { agentConfig, system, openingTextOnly, turn, runLog }, + ); - // Fechamento imposto pelo runtime: 2ª chamada, mesma conversa, só o checkpoint. - // - // Também sob o handoff (o do turno inteiro, em `runAgentTurn`): o teto pode - // ser cruzado ENTRE as duas chamadas — a primeira é que gasta o grosso do - // turno. Aqui o lead já recebeu resposta, mas a conversa ficaria sem - // checkpoint e sem dono, e o próximo inbound cairia no mesmo bloqueio, agora - // sem nada tendo mudado no meio. - const closing = await runModelCall( - pool, - deps.llmCfg, - { - tenantId, - leadId, - jobId: job.id, - purpose: 'checkpoint', - ...(agentConfig !== null - ? { - model: agentConfig.model, - llmOverride: { provider: agentConfig.provider, credentialId: agentConfig.credentialId }, - } - : {}), - system, - messages: [ - // prune: o checkpoint reusa a abertura só como texto — a mídia nativa (cara) já - // fez seu trabalho na 1ª chamada e não precisa ir de novo. - ...openingTextOnly, - ...responseMessages, - { role: 'user', content: CHECKPOINT_INSTRUCTION }, - ], - }, - { registry: deps.registry, log: runLog }, - ); - const content = parseCheckpointText(closing.result.text); - - // Wave 3 (2.4): o checkpoint anterior é lido ANTES de gravar o novo — a - // timeline recebe o DIFF, nunca o snapshot. Emitir a cada turno encheria a - // tela com "a IA pensou" e enterraria a única linha que muda o que alguém - // faria a seguir. - const checkpointAnterior = await latestCheckpoint(pool, tenantId, leadId); - await insertCheckpoint(pool, { tenantId, leadId, jobId: job.id, content }); - - // ── O TURNO DO OPERADOR (spec 16 §3.2) ───────────────────────────────────── + // ── O TURNO DO OPERADOR (spec 16 §3.2) ───────────────────────────────────── // // Enfileirado AQUI, pelo RUNTIME, logo depois de o checkpoint existir — nunca // por decisão do modelo. Um Conversador que "chama" o Operador devolveria o @@ -2434,6 +2435,239 @@ async function executarTurnoDoAgente( // Fire-and-forget: falha ao enfileirar NÃO derruba um turno que já respondeu // ao cliente. O `sourceEventId` é o job do Conversador, então o retry da fila // não gera um segundo Operador para o mesmo turno. + await enfileirarTurnoDoOperador(pool, { tenantId, leadId, job, input, agentConfig, content, runLog }); + + await registrarAtividadeDeCheckpoint(pool, { + tenantId, + leadId, + job, + agentConfig, + checkpointAnterior, + content, + closingCallId, + runLog, + }); + + // ── A NOTA DO NEGÓCIO ────────────────────────────────────────────────────── + // + // O turno acabou de mexer em TUDO que a fórmula lê: compromissos e objeções + // (o checkpoint acima) e a qualificação BANT (`lead_state`, escrita pelo + // update_lead_state do modelo). Recalcular aqui é recalcular no instante em + // que os sinais mudaram — não há evento melhor. + // + // ⚠️ POR QUE ISTO EXISTE: `recalculaScoreDoLead` estava escrita, testada e + // com constraint no banco exigindo o `reason` — e SEM UM ÚNICO CHAMADOR no + // repositório inteiro. Nenhuma nota jamais foi calculada. O modo de falha era + // mudo: o card simplesmente não mostrava número, e "não tem nota ainda" é + // indistinguível de "ninguém nunca calcula". + // + // Fora do `if (mudanca.emit)` DE PROPÓSITO: o BANT muda em turnos que não + // mexem no checkpoint, e esses turnos também mudam a nota. Amarrar o cálculo + // à emissão da atividade faria a nota envelhecer em silêncio — o mesmo + // defeito, um andar acima. + // + // Falha aqui não derruba o turno: nota é derivado, e o próximo turno + // recalcula. O que não pode é o cliente ficar sem resposta por causa dela. + await recalcularScoreDoNegocio(pool, { tenantId, leadId, runLog }); + + await registrarDivergenciaDeEstagio(deps, { + tenantId, + leadId, + job, + skillSignal, + stageSuggestion, + confirmedStage, + runLog, + }); + + const blocked = outcomes.find((o) => o.kind === 'blocked'); + if (blocked !== undefined) { + // veto permanente (regra dura nº 2): cancela o job e cacheia o opt-out — + // depois do checkpoint (o artefato do turno fica registrado mesmo em veto). + await applySendOutcome( + pool, + blocked, + { jobId: job.id, workerId: ctx.workerId, tenantId, leadId }, + { queuedRetryDelayMs: deps.knobs.queuedRetryDelayMs }, + ); + throw new JobSettledError( + 'turno encerrado com veto do sink (is_blocked) — job cancelado em definitivo, checkpoint gravado', + ); + } + + await mcpCleanup?.(); + + runLog.info('turno do agente concluído', { + kind: job.kind, + messages_sent: outcomes.length, + model: turn.model, + }); +} + +/** + * F3-11: divergência classificador×modelo. O classificador sugeriu um estágio; se + * o modelo confirmou (via update_lead_state — a máquina F2-10) um estágio + * DIFERENTE, o desacordo vira candidato ao golden set (fs em runtime — reuso do + * dir da F3-09). Sem sugestão, sem confirmação, ou concordância ⇒ nenhum arquivo. + */ +async function registrarDivergenciaDeEstagio( + deps: InboundTurnDeps, + args: { + tenantId: string; + leadId: string; + job: JobRow; + skillSignal: string; + stageSuggestion: LeadStage | null; + confirmedStage: LeadStage | null; + runLog: Logger; + }, +): Promise { + const { tenantId, leadId, job, skillSignal, stageSuggestion, confirmedStage, runLog } = args; + if ( + deps.knobs.goldenCandidatesDir !== undefined && + stageSuggestion !== null && + confirmedStage !== null && + stageSuggestion !== confirmedStage + ) { + await recordStageDivergenceCandidate( + deps.knobs.goldenCandidatesDir, + { + tenantId, + leadId, + jobId: job.id, + signal: skillSignal, + divergence: { suggested: stageSuggestion, confirmed: confirmedStage }, + }, + runLog, + ); + } +} + +/** + * A NOTA DO NEGÓCIO: o turno acabou de mexer em tudo que a fórmula lê + * (compromissos/objeções via checkpoint, qualificação BANT via `lead_state`) — + * recalcular aqui é recalcular no instante em que os sinais mudaram. Falha não + * derruba o turno: nota é derivado, e o próximo turno recalcula. + */ +async function recalcularScoreDoNegocio( + pool: pg.Pool, + args: { tenantId: string; leadId: string; runLog: Logger }, +): Promise { + const { tenantId, leadId, runLog } = args; + try { + const alvo = await resolveActiveLeadForContact( + ( + await pool.query( + `select l.id, l.organization_id, l.pipeline_id, l.status, + l.last_activity_at, l.created_at + from crm_leads l + where l.organization_id = $1 and l.contact_id = $2`, + [tenantId, leadId], + ) + ).rows, + ); + if (alvo.routed) { + const r = await recalculaScoreDoLead(pool, tenantId, alvo.leadId); + runLog.info('score do negócio recalculado', { + lead_id: alvo.leadId, + gravou: r.gravou, + ...(r.motivo !== undefined ? { motivo: r.motivo } : {}), + }); + } + } catch (err) { + runLog.error('falha ao recalcular score (segue)', { + error: err instanceof Error ? err.name : 'unknown', + }); + } +} + +/** + * Wave 3 (2.4): diff do checkpoint (compromissos/objeções/next_action) contra o + * anterior — a timeline recebe o DIFF, nunca o snapshot. Emitir a cada turno + * encheria a tela com "a IA pensou" e enterraria a única linha que muda o que + * alguém faria a seguir. A timeline não pode derrubar o turno: falha só loga. + */ +async function registrarAtividadeDeCheckpoint( + pool: pg.Pool, + args: { + tenantId: string; + leadId: string; + job: JobRow; + agentConfig: PublishedAgentConfig | null; + checkpointAnterior: LeadCheckpointRow | null; + content: CheckpointContent; + closingCallId: string | null; + runLog: Logger; + }, +): Promise { + const { tenantId, leadId, job, agentConfig, checkpointAnterior, content, closingCallId, runLog } = args; + const mudanca = diffCheckpoint( + checkpointAnterior + ? { + commitments: (checkpointAnterior.commitments ?? []) as string[], + objections: (checkpointAnterior.objections ?? []) as string[], + next_action: checkpointAnterior.next_action ?? null, + rolling_summary: checkpointAnterior.rolling_summary ?? null, + } + : null, + content, + ); + + if (mudanca.emit) { + try { + const r = await emitAgentActivityForContact({ + pool, + organizationId: tenantId, + contactId: leadId, + type: "ai_turn", + sourceModule: "agent", + sourceId: job.id, + // O lastro é a chamada de modelo que PRODUZIU este checkpoint + // (llm_calls.id). Sem ele a linha entraria como 'system' e perderia a + // autoria justamente no evento mais "de IA" que existe. + ...(closingCallId ? { evidence: { llm_call_ids: [closingCallId] } } : {}), + ...(agentConfig?.agentId ? { agentId: agentConfig.agentId } : {}), + reason: mudanca.reason, + payload: { + added_commitments: mudanca.addedCommitments, + added_objections: mudanca.addedObjections, + next_action_changed: mudanca.nextActionChanged, + }, + }); + if (!r.routed) { + runLog.info('checkpoint sem negócio para pendurar: registrado no event_log', { + reason: r.reason, + }); + } + } catch (err) { + // A timeline do turno não pode derrubar o turno. + runLog.error('falha ao registrar atividade de checkpoint (segue)', { + error: err instanceof Error ? err.name : 'unknown', + }); + } + } +} + +/** + * O TURNO DO OPERADOR (spec 16 §3.2) — enfileirado AQUI, pelo RUNTIME, logo depois + * de o checkpoint existir, nunca por decisão do modelo. Fire-and-forget: falha ao + * enfileirar não derruba um turno que já respondeu ao cliente, mas quando havia + * PROMESSA em aberto vira aviso na Central (só quando há promessa — sem ela o + * Operador teria decidido "nada a fazer", e item sem ação é ruído). + */ +async function enfileirarTurnoDoOperador( + pool: pg.Pool, + args: { + tenantId: string; + leadId: string; + job: JobRow; + input: AgentTurnInput; + agentConfig: PublishedAgentConfig | null; + content: CheckpointContent; + runLog: Logger; + }, +): Promise { + const { tenantId, leadId, job, input, agentConfig, content, runLog } = args; const disparo = decidirSeEnfileiraOperador({ temAgentePublicado: agentConfig !== null, papelLigado: agentConfig?.operatorEnabled ?? false, @@ -2497,144 +2731,187 @@ async function executarTurnoDoAgente( } } } +} - const mudanca = diffCheckpoint( - checkpointAnterior - ? { - commitments: (checkpointAnterior.commitments ?? []) as string[], - objections: (checkpointAnterior.objections ?? []) as string[], - next_action: checkpointAnterior.next_action ?? null, - rolling_summary: checkpointAnterior.rolling_summary ?? null, - } - : null, - content, - ); +/** + * F3-10 (prune) + fechamento imposto pelo runtime (2ª chamada de modelo, só o + * checkpoint) + a persistência dele. Devolve `content` (o que o modelo declarou) + * e `checkpointAnterior` (lido ANTES de gravar o novo) — os dois alimentam o + * enfileiramento do Operador e o diff da timeline, mais abaixo. + */ +async function fecharTurnoEGravarCheckpoint( + pool: pg.Pool, + deps: InboundTurnDeps, + ids: { tenantId: string; leadId: string; jobId: string }, + args: { + agentConfig: PublishedAgentConfig | null; + system: string; + openingTextOnly: ModelMessage[]; + turn: Awaited>; + runLog: Logger; + }, +): Promise<{ content: CheckpointContent; checkpointAnterior: LeadCheckpointRow | null; closingCallId: string | null }> { + const { tenantId, leadId, jobId } = ids; + const { agentConfig, system, openingTextOnly, turn, runLog } = args; - if (mudanca.emit) { - try { - const r = await emitAgentActivityForContact({ - pool, - organizationId: tenantId, - contactId: leadId, - type: "ai_turn", - sourceModule: "agent", - sourceId: job.id, - // O lastro é a chamada de modelo que PRODUZIU este checkpoint - // (llm_calls.id). Sem ele a linha entraria como 'system' e perderia a - // autoria justamente no evento mais "de IA" que existe. - ...(closing.callId ? { evidence: { llm_call_ids: [closing.callId] } } : {}), - ...(agentConfig?.agentId ? { agentId: agentConfig.agentId } : {}), - reason: mudanca.reason, - payload: { - added_commitments: mudanca.addedCommitments, - added_objections: mudanca.addedObjections, - next_action_changed: mudanca.nextActionChanged, - }, - }); - if (!r.routed) { - runLog.info('checkpoint sem negócio para pendurar: registrado no event_log', { - reason: r.reason, - }); - } - } catch (err) { - // A timeline do turno não pode derrubar o turno. - runLog.error('falha ao registrar atividade de checkpoint (segue)', { - error: err instanceof Error ? err.name : 'unknown', - }); - } - } + // F3-10: poda os tool results antigos da fita do run ANTES de reenviá-los no fechamento + // (é onde a fita inteira é re-serializada num prompt) — o conteúdo durável já foi para + // lead_notes pelo flush (F3-07), então o stub não perde nada recuperável. Opera SÓ no + // sufixo por-lead, nunca no prefixo estável (regra de cache 15). + const responseMessages = + deps.knobs.prune !== undefined + ? pruneToolResults(turn.result.response.messages, deps.knobs.prune) + : turn.result.response.messages; - // ── A NOTA DO NEGÓCIO ────────────────────────────────────────────────────── - // - // O turno acabou de mexer em TUDO que a fórmula lê: compromissos e objeções - // (o checkpoint acima) e a qualificação BANT (`lead_state`, escrita pelo - // update_lead_state do modelo). Recalcular aqui é recalcular no instante em - // que os sinais mudaram — não há evento melhor. - // - // ⚠️ POR QUE ISTO EXISTE: `recalculaScoreDoLead` estava escrita, testada e - // com constraint no banco exigindo o `reason` — e SEM UM ÚNICO CHAMADOR no - // repositório inteiro. Nenhuma nota jamais foi calculada. O modo de falha era - // mudo: o card simplesmente não mostrava número, e "não tem nota ainda" é - // indistinguível de "ninguém nunca calcula". - // - // Fora do `if (mudanca.emit)` DE PROPÓSITO: o BANT muda em turnos que não - // mexem no checkpoint, e esses turnos também mudam a nota. Amarrar o cálculo - // à emissão da atividade faria a nota envelhecer em silêncio — o mesmo - // defeito, um andar acima. + // Fechamento imposto pelo runtime: 2ª chamada, mesma conversa, só o checkpoint. // - // Falha aqui não derruba o turno: nota é derivado, e o próximo turno - // recalcula. O que não pode é o cliente ficar sem resposta por causa dela. - try { - const alvo = await resolveActiveLeadForContact( - ( - await pool.query( - `select l.id, l.organization_id, l.pipeline_id, l.status, - l.last_activity_at, l.created_at - from crm_leads l - where l.organization_id = $1 and l.contact_id = $2`, - [tenantId, leadId], - ) - ).rows, - ); - if (alvo.routed) { - const r = await recalculaScoreDoLead(pool, tenantId, alvo.leadId); - runLog.info('score do negócio recalculado', { - lead_id: alvo.leadId, - gravou: r.gravou, - ...(r.motivo !== undefined ? { motivo: r.motivo } : {}), + // Também sob o handoff (o do turno inteiro, em `runAgentTurn`): o teto pode + // ser cruzado ENTRE as duas chamadas — a primeira é que gasta o grosso do + // turno. Aqui o lead já recebeu resposta, mas a conversa ficaria sem + // checkpoint e sem dono, e o próximo inbound cairia no mesmo bloqueio, agora + // sem nada tendo mudado no meio. + const closing = await runModelCall( + pool, + deps.llmCfg, + { + tenantId, + leadId, + jobId, + purpose: 'checkpoint', + ...(agentConfig !== null + ? { + model: agentConfig.model, + llmOverride: { provider: agentConfig.provider, credentialId: agentConfig.credentialId }, + } + : {}), + system, + messages: [ + // prune: o checkpoint reusa a abertura só como texto — a mídia nativa (cara) já + // fez seu trabalho na 1ª chamada e não precisa ir de novo. + ...openingTextOnly, + ...responseMessages, + { role: 'user', content: CHECKPOINT_INSTRUCTION }, + ], + }, + { registry: deps.registry, log: runLog }, + ); + const content = parseCheckpointText(closing.result.text); + + // Wave 3 (2.4): o checkpoint anterior é lido ANTES de gravar o novo — a + // timeline recebe o DIFF, nunca o snapshot. Emitir a cada turno encheria a + // tela com "a IA pensou" e enterraria a única linha que muda o que alguém + // faria a seguir. + const checkpointAnterior = await latestCheckpoint(pool, tenantId, leadId); + await insertCheckpoint(pool, { tenantId, leadId, jobId, content }); + + return { content, checkpointAnterior, closingCallId: closing.callId }; +} + +/** + * F4-04: correlação dos dois sinais do MESMO turno — jailbreak ALTO + tentativa de + * promessa fora de tabela (F4-01). Ambos já estão determinados quando este passo + * roda (o jailbreak rodou na abertura; as tentativas de envio já passaram pelo + * loop). Dispara escalação humana em inbox_items (dedup por episódio). Advisório: + * o classifier sozinho nunca escala — o gate determinístico é que confirma a + * promessa indevida. Chamada ANTES do runError/veto para não se perder num turno + * que falha o envio depois. + */ +async function escalarSeJailbreakComPromessaForaDeTabela( + pool: pg.Pool, + args: { tenantId: string; leadId: string; jailbreakLevel: JailbreakLevel; outOfTablePromiseAttempted: boolean }, + runLog: Logger, +): Promise { + const { tenantId, leadId, jailbreakLevel, outOfTablePromiseAttempted } = args; + if (jailbreakLevel === JAILBREAK_ESCALATION_LEVEL && outOfTablePromiseAttempted) { + const created = await escalateJailbreakPromise(pool, { tenantId, leadId, level: jailbreakLevel }); + if (created > 0) { + runLog.warn('jailbreak: escalação humana criada (flag alta + promessa fora de tabela no turno)', { + jailbreak_level: jailbreakLevel, }); } - } catch (err) { - runLog.error('falha ao recalcular score (segue)', { - error: err instanceof Error ? err.name : 'unknown', - }); } +} - // F3-11: divergência classificador×modelo. O classificador sugeriu um estágio; se o - // modelo confirmou (via update_lead_state — a máquina F2-10) um estágio DIFERENTE, o - // desacordo vira candidato ao golden set (fs em runtime — reuso do dir da F3-09). Sem - // sugestão, sem confirmação, ou concordância ⇒ nenhum arquivo (zero divergência). - if ( - deps.knobs.goldenCandidatesDir !== undefined && - stageSuggestion !== null && - confirmedStage !== null && - stageSuggestion !== confirmedStage - ) { - await recordStageDivergenceCandidate( - deps.knobs.goldenCandidatesDir, +/** + * F3-07: compaction + flush pré-compaction. Sem `deps.knobs.compaction` (ou sem + * resumo compactado devolvido), `effectivePrevious`/`effectiveContext` saem iguais + * aos de entrada — o resto do turno nunca precisa saber se compactou. + * + * Definida DEPOIS de `executarTurnoDoAgente` de propósito: a chamada de modelo + * (`maybeCompact`) precisa continuar aparecendo, no texto, DENTRO do núcleo + * escoltado por `comHandoffSeOrcamentoAcabar` — é isso que + * `tests/unit/handoff-por-orcamento.test.ts` mede (a escolta cobre até as + * chamadas indiretas, que rodam primeiro). `function` é hoisted, então a ordem + * textual não muda quem chama quem. + */ +async function compactarSeNecessario( + pool: pg.Pool, + deps: InboundTurnDeps, + ids: { tenantId: string; leadId: string; jobId: string }, + args: { + previous: LeadCheckpointRow | null; + openingContext: { context: LeadContext }; + argsAux: (configuredModel: string | undefined) => AuxModelArgs; + runLog: Logger; + }, +): Promise<{ effectivePrevious: LeadCheckpointRow | null; effectiveContext: LeadContext }> { + const { tenantId, leadId, jobId } = ids; + const { previous, openingContext, argsAux, runLog } = args; + // F3-07: compaction + flush pré-compaction. Quando o histórico cresce além do limiar, + // o FLUSH grava as notas duráveis (lead_notes) e a compaction resume a conversa com o + // modelo BARATO; o resumo compactado entra no lugar do rolling summary e o transcript + // integral é trocado por uma cauda recente sob orçamento (regra de cache 15). O rolling + // summary DURÁVEL segue vindo do checkpoint de fechamento; aqui ele só alimenta o prompt. + let effectivePrevious = previous; + let effectiveContext = openingContext.context; + if (deps.knobs.compaction !== undefined) { + const compacted = await maybeCompact( + pool, + deps.llmCfg, + { tenantId, leadId, jobId }, { - tenantId, - leadId, - jobId: job.id, - signal: skillSignal, - divergence: { suggested: stageSuggestion, confirmed: confirmedStage }, + context: openingContext.context, + previousSummary: previous?.rolling_summary ?? '', + // A compactação é o QUARTO call site da mesma regra, e o #151 só cobriu + // três: ela também pedia o modelo do agente ao provider default da org. + // Mesmo 404, mesma morte de turno — só que num caminho que roda quando a + // conversa já é longa, ou seja, mais tarde e com menos gente olhando. + knobs: { ...deps.knobs.compaction, ...argsAux(deps.knobs.compaction.model) }, + notesIndexMaxTokens: deps.knobs.notesIndexMaxTokens, }, - runLog, - ); - } - - const blocked = outcomes.find((o) => o.kind === 'blocked'); - if (blocked !== undefined) { - // veto permanente (regra dura nº 2): cancela o job e cacheia o opt-out — - // depois do checkpoint (o artefato do turno fica registrado mesmo em veto). - await applySendOutcome( - pool, - blocked, - { jobId: job.id, workerId: ctx.workerId, tenantId, leadId }, - { queuedRetryDelayMs: deps.knobs.queuedRetryDelayMs }, - ); - throw new JobSettledError( - 'turno encerrado com veto do sink (is_blocked) — job cancelado em definitivo, checkpoint gravado', + { registry: deps.registry, log: runLog }, ); + if (compacted !== null) { + // Só o rolling_summary é sobrescrito (o resumo compactado carrega compromissos/ + // objeções/estágio/dados pessoais planificados). O `previous` sintético do 1º + // turno com histórico importado é local — nunca persistido; o fechamento grava o + // checkpoint real. + const base: LeadCheckpointRow = + previous ?? + { + id: '', + seq: '0', + organization_id: tenantId, + contact_id: leadId, + job_id: null, + created_at: new Date(), + commitments: [], + objections: [], + next_action: null, + rolling_summary: '', + // Este `previous` é sintetizado a partir de histórico IMPORTADO — não + // houve turno nosso, logo ninguém declarou nada. `null` é o valor + // honesto; um objeto vazio afirmaria uma avaliação que não aconteceu. + declaracao: null, + }; + effectivePrevious = { ...base, rolling_summary: renderCompactedSummary(compacted) }; + effectiveContext = { + ...openingContext.context, + messages: trimTranscriptToBudget(openingContext.context.messages, deps.knobs.compaction.transcriptMaxTokens), + }; + } } - - await mcpCleanup?.(); - - runLog.info('turno do agente concluído', { - kind: job.kind, - messages_sent: outcomes.length, - model: turn.model, - }); + return { effectivePrevious, effectiveContext }; } /** diff --git a/lib/agent-engine/agent/reagendar-por-veto-de-pacing.test.ts b/lib/agent-engine/agent/reagendar-por-veto-de-pacing.test.ts new file mode 100644 index 000000000..0db47011e --- /dev/null +++ b/lib/agent-engine/agent/reagendar-por-veto-de-pacing.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import type pg from 'pg'; + +import { reagendarTurnoPorVetoDePacing } from './inbound-turn'; +import type { Logger } from '../obs/logger'; + +/** + * Bug medido em produção (instalação MKT, 2026-08-22): o `send_message` do turno + * só ENSINAVA o modelo quando o gate `pacing` vetava (warmup_cap/daily_cap/ + * outside_window) — o run fechava "concluído" com 0 mensagens enviadas e NADA + * reagendava. O cliente ficava sem resposta até mandar outra mensagem por conta + * própria. `reagendarTurnoPorVetoDePacing` fecha esse buraco reusando o + * `followup_turn` como "volte e tente de novo". + */ +function logFalso(): Logger { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as Logger; +} + +function poolFalso(opts: { jaReagendado: boolean; falharNoInsert?: boolean }) { + const chamadas: Array<{ sql: string; params: unknown[] }> = []; + const query = vi.fn(async (sql: string, params: unknown[] = []) => { + chamadas.push({ sql, params }); + const s = sql.replace(/\s+/g, ' ').trim(); + if (s.startsWith('select 1 from cron_jobs')) { + return { rows: opts.jaReagendado ? [{ '?column?': 1 }] : [], rowCount: opts.jaReagendado ? 1 : 0 }; + } + if (s.startsWith('insert into cron_jobs')) { + if (opts.falharNoInsert) throw new Error('conexão caiu'); + return { rows: [{ id: 'cron-novo' }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + return { query, chamadas } as unknown as pg.Pool & { chamadas: typeof chamadas }; +} + +const INPUT = { + tenantId: 'org-1', + leadId: 'lead-1', + jobId: 'job-vetado-1', + at: new Date('2026-08-23T10:00:00.000Z'), +}; + +describe('reagendarTurnoPorVetoDePacing', () => { + it('sem reagendamento prévio: cria followup_turn no instante do veto (nextAllowedAt)', async () => { + const pool = poolFalso({ jaReagendado: false }); + const log = logFalso(); + + await reagendarTurnoPorVetoDePacing(pool, log, INPUT); + + const insert = pool.chamadas.find((c) => c.sql.includes('insert into cron_jobs')); + expect(insert).toBeDefined(); + // (organization_id, contact_id, kind, interval_ms, cron_expr, tz, job_kind, payload, next_run_at, max_attempts) + expect(insert?.params[0]).toBe('org-1'); // organization_id + expect(insert?.params[1]).toBe('lead-1'); // contact_id + expect(insert?.params[2]).toBe('at'); // kind do cron spec + expect(insert?.params[6]).toBe('followup_turn'); // job_kind + expect(insert?.params[7]).toEqual({ reschedule_of: 'job-vetado-1' }); // payload + expect(insert?.params[8]).toEqual(INPUT.at); // next_run_at (staggerWindowMs 0 = sem deslocamento) + expect(log.info).toHaveBeenCalled(); + }); + + it('já existe followup_turn reagendado para este job — não duplica (idempotência)', async () => { + const pool = poolFalso({ jaReagendado: true }); + const log = logFalso(); + + await reagendarTurnoPorVetoDePacing(pool, log, INPUT); + + expect(pool.chamadas.some((c) => c.sql.includes('insert into cron_jobs'))).toBe(false); + }); + + it('falha no banco não lança — best-effort, turno segue com o erro de ensino que já tinha', async () => { + const pool = poolFalso({ jaReagendado: false, falharNoInsert: true }); + const log = logFalso(); + + await expect(reagendarTurnoPorVetoDePacing(pool, log, INPUT)).resolves.toBeUndefined(); + expect(log.warn).toHaveBeenCalled(); + }); +}); diff --git a/lib/agent-engine/health/circuit.test.ts b/lib/agent-engine/health/circuit.test.ts new file mode 100644 index 000000000..2e65d25d6 --- /dev/null +++ b/lib/agent-engine/health/circuit.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; +import type pg from 'pg'; + +import { evaluateSession } from './circuit'; +import { HEALTH_DEFAULTS } from './defaults'; + +const RATES_VAZIAS = { totalSends: 0, blockedSends: 0, sentLeads: 0, respondedLeads: 0 }; + +/** + * Client falso de UMA transação (begin/select for update/commit) — mesmo padrão de + * `drain.test.ts` (mock por substring de SQL), adaptado para `harness.connect()` porque + * `evaluateSession` roda sob `for update` explícito. + */ +function clientFalso(opts: { + healthRow: { + health_hold_active: boolean; + health_released_at: Date | null; + cooldown_elapsed: boolean; + has_open_item: boolean; + }; + phoneNumber: string | null; + outraSessaoJaLiberada: boolean; +}) { + const chamadas: Array<{ sql: string; params: unknown[] }> = []; + const query = vi.fn(async (sql: string, params: unknown[] = []) => { + chamadas.push({ sql, params }); + const s = sql.replace(/\s+/g, ' ').trim(); + if (s === 'begin' || s === 'commit' || s === 'rollback') return { rows: [] }; + if (s.includes('for update')) return { rows: [opts.healthRow] }; + if (s.includes('select phone_number from channel_sessions')) { + return { rows: [{ phone_number: opts.phoneNumber }] }; + } + if (s.includes('ja_liberado')) { + return { rows: [{ ja_liberado: opts.outraSessaoJaLiberada }] }; + } + if (s.includes('update channel_session_health')) return { rows: [], rowCount: 1 }; + if (s.includes('insert into agent_inbox_items')) return { rows: [], rowCount: 1 }; + return { rows: [] }; + }); + return { query, release: vi.fn(), chamadas }; +} + +function harnessFalso(client: ReturnType): pg.Pool { + return { connect: vi.fn(async () => client) } as unknown as pg.Pool; +} + +describe('evaluateSession — go-live entre sessões do mesmo número', () => { + it( + 'sessão nova (health_released_at null) do MESMO phone_number de uma sessão já ' + + 'liberada não entra em hold — libera de cara, sem exigir resolução manual de novo', + async () => { + const client = clientFalso({ + healthRow: { + health_hold_active: false, + health_released_at: null, + cooldown_elapsed: false, + has_open_item: false, + }, + phoneNumber: '553398590909', + outraSessaoJaLiberada: true, + }); + + const delta = await evaluateSession( + harnessFalso(client), + 'org-1', + 'sessao-nova', + RATES_VAZIAS, + HEALTH_DEFAULTS, + ); + + expect(delta).toEqual({ held: 0, released: 1, alerts: 0 }); + const inseriuInboxItem = client.chamadas.some((c) => c.sql.includes('insert into agent_inbox_items')); + expect(inseriuInboxItem).toBe(false); + const liberou = client.chamadas.some( + (c) => c.sql.includes('update channel_session_health') && c.sql.includes('health_released_at = now()'), + ); + expect(liberou).toBe(true); + }, + ); + + it('número DE VERDADE novo (nenhuma outra sessão liberada) continua nascendo em hold go_live', async () => { + const client = clientFalso({ + healthRow: { + health_hold_active: false, + health_released_at: null, + cooldown_elapsed: false, + has_open_item: false, + }, + phoneNumber: '553398590909', + outraSessaoJaLiberada: false, + }); + + const delta = await evaluateSession( + harnessFalso(client), + 'org-1', + 'sessao-nova', + RATES_VAZIAS, + HEALTH_DEFAULTS, + ); + + expect(delta.held).toBe(1); + expect(delta.released).toBe(0); + const engajouGoLive = client.chamadas.some( + (c) => c.sql.includes('health_hold_reason = $3') && c.params[2] === 'go_live', + ); + expect(engajouGoLive).toBe(true); + }); + + it('phone_number ainda desconhecido (QR não pareado) não casa com nada — hold normal', async () => { + const client = clientFalso({ + healthRow: { + health_hold_active: false, + health_released_at: null, + cooldown_elapsed: false, + has_open_item: false, + }, + phoneNumber: null, + outraSessaoJaLiberada: true, // não deve nem ser consultado de verdade, mas garante que não vaza + }); + + const delta = await evaluateSession( + harnessFalso(client), + 'org-1', + 'sessao-nova', + RATES_VAZIAS, + HEALTH_DEFAULTS, + ); + + expect(delta.held).toBe(1); + expect(delta.released).toBe(0); + }); +}); diff --git a/lib/agent-engine/health/circuit.ts b/lib/agent-engine/health/circuit.ts index 8475dbb0b..3ecdbd8ef 100644 --- a/lib/agent-engine/health/circuit.ts +++ b/lib/agent-engine/health/circuit.ts @@ -216,12 +216,66 @@ interface HealthRow { has_open_item: boolean; } +/** + * O número já passou por go-live sob OUTRA sessão? (F2-26 forward-fix, bug + * medido em produção 2026-08-22, instalação MKT.) + * + * `channel_session_health` nasce por `channel_sessions.id` — uma linha por + * SESSÃO, não por NÚMERO. O caminho de recuperação sancionado para uma sessão + * morta é arquivar + conectar de novo (`channel-sessions/[id]/reconnect` + * recusa sessão arquivada e manda o operador reconectar criando uma nova — + * ver o cabeçalho daquela rota), e "conectar de novo" sempre gera um + * `channel_sessions.id` novo. Esse id novo nasce com `health_released_at + * is null` — o fail-safe "número novo" — mesmo quando o NÚMERO por trás + * (mesmo `phone_number`) já viveu dias no ar e já passou pelo go-live manual + * antes. Resultado medido: uma queda de WAHA banal (container reiniciou, + * sessão caiu STOPPED) obrigava o operador a "liberar" o mesmo número de + * novo, e enquanto ninguém achava esse aviso na Central o outbound ficava + * retido — mensagem de cliente sem resposta por horas. + * + * O fail-safe "número novo nasce em hold" continua valendo para número + * DE VERDADE novo (nenhuma sessão anterior com este `phone_number` nesta org + * jamais foi liberada). Ele só deixa de disparar quando existe EVIDÊNCIA + * concreta de que o número já foi aprovado: outra linha de + * `channel_session_health`, de OUTRA sessão do mesmo `phone_number` nesta + * org, com `health_released_at` preenchido — arquivada ou não (arquivar não + * apaga o histórico de aquecimento do número). + * + * `phone_number` pode ainda ser `null` na sessão nova (QR não pareado) — sem + * telefone conhecido não há como comparar, e o fail-safe original prevalece. + */ +async function foiLiberadoPorOutraSessaoDoMesmoNumero( + client: pg.PoolClient, + tenantId: string, + channelSessionId: string, +): Promise { + const { rows: phoneRows } = await client.query<{ phone_number: string | null }>( + `select phone_number from channel_sessions where organization_id = $1 and id = $2`, + [tenantId, channelSessionId], + ); + const phone = phoneRows[0]?.phone_number; + if (!phone) return false; + const { rows } = await client.query<{ ja_liberado: boolean }>( + `select exists ( + select 1 + from channel_session_health h + join channel_sessions s on s.id = h.channel_session_id + where s.organization_id = $1 + and s.phone_number = $2 + and s.id <> $3 + and h.health_released_at is not null + ) as ja_liberado`, + [tenantId, phone, channelSessionId], + ); + return rows[0]?.ja_liberado ?? false; +} + /** * Decide e aplica a transição de UMA sessão sob lock (FOR UPDATE) — dois ticks * concorrentes serializam na row do espelho, então nunca duplicam item nem hold. * Devolve o delta {held, released, alerts} desta sessão. */ -async function evaluateSession( +export async function evaluateSession( harness: pg.Pool, tenantId: string, channelSessionId: string, @@ -308,9 +362,22 @@ async function evaluateSession( if (row.health_released_at === null) { // Número novo (fail-safe): nasce em hold com razão go_live; libera SÓ por ato - // explícito (humano resolve o item = liberação inicial de go-live). + // explícito (humano resolve o item = liberação inicial de go-live) — EXCETO + // quando este mesmo phone_number já foi liberado sob outra sessão (reconexão + // que trocou o id): aí o go-live já foi feito, e repeti-lo só derruba o + // outbound de um número que já está aprovado (ver o cabeçalho da função acima). if (!row.health_hold_active) { - await engageHold('go_live'); + if (await foiLiberadoPorOutraSessaoDoMesmoNumero(client, tenantId, channelSessionId)) { + await client.query( + `update channel_session_health + set health_released_at = now(), health_hold_active = false, health_hold_reason = null, updated_at = now() + where organization_id = $1 and channel_session_id = $2`, + [tenantId, channelSessionId], + ); + delta.released = 1; + } else { + await engageHold('go_live'); + } } else if (!row.has_open_item) { await client.query( `update channel_session_health diff --git a/lib/ai/agents/publish-rag-bot.test.ts b/lib/ai/agents/publish-rag-bot.test.ts new file mode 100644 index 000000000..42cbb1337 --- /dev/null +++ b/lib/ai/agents/publish-rag-bot.test.ts @@ -0,0 +1,88 @@ +/** + * O editor legado de `rag_bot` só grava o rascunho — sem publish, o runtime + * (que lê `ai_agents.published_version_id` → `ai_agent_versions`) nunca via a + * mudança. Este teste cobre só o mapeamento de erro do wrapper: a função SQL + * em si é exercitada em `tests/invariants` (test:db), não aqui. + */ +import { describe, it, expect, vi } from "vitest"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +import { publishRagBotVersion } from "./publish-rag-bot"; + +function adminComRpc(resposta: { data: unknown; error: { message: string } | null }) { + return { rpc: vi.fn().mockResolvedValue(resposta) } as unknown as SupabaseClient; +} + +describe("publishRagBotVersion", () => { + it("mapeia sucesso pra RagBotPublishOk", async () => { + const admin = adminComRpc({ + data: [ + { + agent_id: "agent-1", + version_id: "version-2", + previous_version_id: "version-1", + published_at: "2026-08-22T20:00:00Z", + }, + ], + error: null, + }); + + const result = await publishRagBotVersion(admin, { + orgId: "org-1", + agentId: "agent-1", + createdBy: "user-1", + }); + + expect(result).toEqual({ + ok: true, + agent_id: "agent-1", + version_id: "version-2", + previous_version_id: "version-1", + published_at: "2026-08-22T20:00:00Z", + }); + }); + + it("mapeia erro conhecido (P0001) pro código estável, sem virar internal_error", async () => { + const admin = adminComRpc({ data: null, error: { message: "agent_kind_invalid" } }); + + const result = await publishRagBotVersion(admin, { + orgId: "org-1", + agentId: "agent-mcp", + createdBy: null, + }); + + expect(result).toEqual({ + ok: false, + code: "agent_kind_invalid", + message: "agent_kind_invalid", + }); + }); + + it("mapeia erro NÃO catalogado pra internal_error, sem vazar a mensagem crua como código", async () => { + const admin = adminComRpc({ + data: null, + error: { message: "relation ai_agent_versions does not exist" }, + }); + + const result = await publishRagBotVersion(admin, { + orgId: "org-1", + agentId: "agent-1", + createdBy: null, + }); + + expect(result.ok).toBe(false); + expect((result as { code: string }).code).toBe("internal_error"); + }); + + it("trata resposta sem linha (RPC ok mas array vazio) como internal_error, não como sucesso vazio", async () => { + const admin = adminComRpc({ data: [], error: null }); + + const result = await publishRagBotVersion(admin, { + orgId: "org-1", + agentId: "agent-1", + createdBy: null, + }); + + expect(result).toEqual({ ok: false, code: "internal_error", message: "no_row_returned" }); + }); +}); diff --git a/lib/ai/agents/publish-rag-bot.ts b/lib/ai/agents/publish-rag-bot.ts new file mode 100644 index 000000000..3825dde39 --- /dev/null +++ b/lib/ai/agents/publish-rag-bot.ts @@ -0,0 +1,98 @@ +/** + * Publish wrapper around fn_publish_rag_bot_version (migration 0168). + * + * O editor legado de `rag_bot` (`components/ai/AgentEditor.tsx`, "caminho + * pré-EPIC-13") só grava o rascunho em `ai_agents` — o runtime lê + * `system_prompt`/`provider`/`model` da versão publicada + * (`ai_agents.published_version_id`), nunca do rascunho. Sem este wrapper (e a + * rota que o chama), editar um `rag_bot` pela tela não tinha efeito nenhum. + * + * Não reusa `publishAgentVersion`/`fn_publish_ai_agent_version`: aquela função + * exige `credential_id` não-nulo na versão e canal `WORKING` — nenhum dos dois + * é como `rag_bot` opera. + */ +import type { SupabaseClient } from "@supabase/supabase-js"; + +export const RAG_BOT_PUBLISH_ERROR_CODES = new Set([ + "agent_not_found", + "agent_kind_invalid", + "agent_archived", + "no_existing_version", + "version_not_found", + "org_not_found", + "model_not_found", +]); + +export type RagBotPublishErrorCode = + | "agent_not_found" + | "agent_kind_invalid" + | "agent_archived" + | "no_existing_version" + | "version_not_found" + | "org_not_found" + | "model_not_found"; + +export interface RagBotPublishOk { + ok: true; + agent_id: string; + version_id: string; + previous_version_id: string | null; + published_at: string; +} + +export interface RagBotPublishFail { + ok: false; + code: RagBotPublishErrorCode | "internal_error"; + message: string; +} + +export type RagBotPublishResult = RagBotPublishOk | RagBotPublishFail; + +interface PublishRow { + agent_id: string; + version_id: string; + previous_version_id: string | null; + published_at: string; +} + +export async function publishRagBotVersion( + admin: SupabaseClient, + params: { orgId: string; agentId: string; createdBy: string | null }, +): Promise { + const { data, error } = await admin.rpc("fn_publish_rag_bot_version", { + p_org_id: params.orgId, + p_agent_id: params.agentId, + p_created_by: params.createdBy, + }); + + if (error) { + // Postgres P0001 com a razão como mensagem — mesmo contrato de + // fn_publish_ai_agent_version. + const raw = (error.message ?? "").trim(); + if (RAG_BOT_PUBLISH_ERROR_CODES.has(raw)) { + return { ok: false, code: raw as RagBotPublishErrorCode, message: raw }; + } + // Erro não mapeado (constraint, FK, etc) — sem isto, o 500 chega ao + // cliente sem nenhuma pista da causa nos logs do container. + console.error("[publishRagBotVersion] fn_publish_rag_bot_version failed", { + code: error.code, + message: error.message, + details: error.details, + hint: error.hint, + }); + return { ok: false, code: "internal_error", message: raw || "publish_failed" }; + } + + const row = Array.isArray(data) ? (data[0] as PublishRow | undefined) : (data as PublishRow | null); + if (!row) { + console.error("[publishRagBotVersion] fn_publish_rag_bot_version returned no row"); + return { ok: false, code: "internal_error", message: "no_row_returned" }; + } + return { + ok: true, + agent_id: row.agent_id, + version_id: row.version_id, + previous_version_id: row.previous_version_id, + published_at: row.published_at, + }; +} diff --git a/lib/env.ts b/lib/env.ts index f370df0f9..48062143f 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -254,6 +254,7 @@ const schema = z.object({ */ JOB_QUEUE_RETENTION_DAYS: z.string().optional().default(""), AUDIT_LOG_RETENTION_DAYS: z.string().optional().default(""), + EVENT_LOG_RETENTION_DAYS: z.string().optional().default(""), // LGPD export (S-08.04) LGPD_SIGNING_KEY: z.string().optional().default(""), diff --git a/lib/retencao/politica.ts b/lib/retencao/politica.ts index 664beb89b..4d80f6a8f 100644 --- a/lib/retencao/politica.ts +++ b/lib/retencao/politica.ts @@ -31,6 +31,22 @@ export const RETENCAO_FILA_DIAS_PISO = 7; export const RETENCAO_AUDITORIA_DIAS_PADRAO = 1825; /** Piso da auditoria: o knob nunca vira apagador de rastro recente. */ export const RETENCAO_AUDITORIA_DIAS_PISO = 90; +/** + * `event_log` é o mesmo tipo de dado que `job_queue` — bus interno operacional, + * não trilha legal/LGPD (isso é `api_audit_log`) — por isso herda os MESMOS + * números, não os da auditoria. Knob próprio (não o de `job_queue`) porque as + * duas tabelas crescem em ritmos diferentes: um clone com muito tráfego de + * WhatsApp gera `event_log` (um evento por mensagem, `message.*`) muito mais + * rápido que `job_queue` (um job por turno de conversa). + */ +export const RETENCAO_EVENT_LOG_DIAS_PADRAO = 90; +/** + * Piso de 7 dias — mesmo horizonte da `job_queue` (migration 0167): o único + * consumidor de `event_log` sem janela própria é `health/circuit.ts`, que já é + * janelado (`windowMs`, default 6h — muito abaixo de 7 dias) e lê exatamente os + * eventos `ai_agent.dispatch_requested` que este expurgo também alcança. + */ +export const RETENCAO_EVENT_LOG_DIAS_PISO = 7; export interface RetencaoInterpretada { /** Dias a pedir ao banco. Nunca abaixo do piso, nunca `NaN`. */ diff --git a/package.json b/package.json index 4d6f5d5f3..ad69c234f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "deskcomm-crm", + "//version": "Metadado inerte, e de propósito. A versão do PRODUTO é o topo do CHANGELOG.md (SemVer) com tag git correspondente; a imagem publicada recebe APP_VERSION do CI (tag → número, fora de tag → SHA curto). Nada em runtime lê este campo — até a 1.2.1 o /api/v1/health lia `npm_package_version`, que é undefined sob `CMD [\"node\",\"server.js\"]`, e TODA instalação respondia 0.1.0. Bumpar aqui não lança nada e reintroduz a ambiguidade.", "version": "0.1.0", "private": true, "description": "DeskcommCRM — sistema operacional de vendas open source com agentes de IA nativos e WhatsApp (WAHA). Self-hosted, multi-tenant, LGPD by-design.", @@ -8,26 +9,23 @@ }, "scripts": { "dev": "next dev", - "dev:webpack": "next dev", "build": "next build", - "build:webpack": "next build", "start": "next start", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "lint": "eslint .", + "lint:channels": "tsx scripts/lint-channels.ts", "format": "prettier --write \"**/*.{ts,tsx,md,json,css}\"", "format:check": "prettier --check \"**/*.{ts,tsx,md,json,css}\"", - "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", - "db:migrate": "echo 'TODO: wire supabase db push or pg-migrate' && exit 0", - "db:reset": "supabase db reset", - "test:e2e": "playwright test", - "e2e:build": "bash scripts/e2e-build.sh", - "e2e:env": "bash scripts/gerar-env-e2e.sh", - "test:journeys": "playwright test -c tests/journeys/playwright.config.ts", + "gov:verify": "pnpm typecheck && pnpm lint && pnpm lint:channels && pnpm test:unit", "test:unit": "vitest run", "test:db": "bash scripts/test-db.sh", + "test:invariants": "pnpm test:db", + "test:e2e": "playwright test", + "test:journeys": "playwright test -c tests/journeys/playwright.config.ts", "test:shell": "bash tests/shell/update-guard.test.sh && bash tests/shell/scheduler-entrypoint.test.sh && bash hostgator-setup-kit/test-validators.sh", - "test:invariants": "bash scripts/test-db.sh", - "lint:channels": "tsx scripts/lint-channels.ts", - "gov:verify": "pnpm typecheck && pnpm lint && pnpm lint:channels && pnpm test:unit", + "e2e:build": "bash scripts/e2e-build.sh", + "e2e:env": "bash scripts/gerar-env-e2e.sh", + "db:reset": "supabase db reset", "worker": "tsx --env-file=.env --env-file=.env.local workers/agent-worker/main.ts", "flywheel:judge": "tsx --env-file=.env --env-file=.env.local scripts/flywheel-judge-live.ts" }, diff --git a/proxy.ts b/proxy.ts index 9ff53072e..481d2a03d 100644 --- a/proxy.ts +++ b/proxy.ts @@ -22,6 +22,88 @@ export async function proxy(request: NextRequest) { response.headers.set("x-pathname", pathname); request.headers.set("x-pathname", pathname); + // --------------------------------------------------------------------- + // HSTS + CSP. Set here (not in next.config.ts `headers()`) on purpose: CSP + // below needs the REAL runtime Supabase URL, and self-host does NOT burn + // NEXT_PUBLIC_* into the build — the same Docker image serves every + // install, and each reads its own `.env` at request time (see + // app/public-env-script.tsx). A static header in next.config.ts would bake + // in whatever placeholder was present at CI build time, not the customer's + // actual project. The other, non-runtime-dependent security headers + // (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, + // Permissions-Policy) stay in next.config.ts — no reason to duplicate that + // logic here. + // + // HSTS only over HTTPS: forcing it on http://localhost breaks local dev. + // Behind Traefik (self-host default, see CLAUDE.md "Deploy em produção") + // the app itself is reached over plain HTTP inside the docker network — + // `x-forwarded-proto` is the only reliable signal of the ORIGINAL scheme. + const forwardedProto = request.headers.get("x-forwarded-proto"); + const isHttps = forwardedProto === "https" || request.nextUrl.protocol === "https:"; + if (isHttps) { + response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + } + + const supabaseUrl = new URL(env.NEXT_PUBLIC_SUPABASE_URL); + const supabaseWsOrigin = `${supabaseUrl.protocol === "https:" ? "wss:" : "ws:"}//${supabaseUrl.host}`; + const isDev = process.env.NODE_ENV !== "production"; + + const cspValue = [ + "default-src 'self'", + // No nonce plumbing exists today: 4 inline