Substitui a tabela de linhas com dropdown de produto por duas colunas, no padrão do legado OrçaFácil: "Adicionar Produtos" à esquerda (busca, ordenação, lista com preço da faixa do período de contrato, clique adiciona ao carrinho) e "Resumo da Oferta" à direita, sticky (itens com stepper, breakdown de implantação, implantação geral, condição de pagamento, condição especial e observações — todos migrados pra dentro do painel —, totais ao vivo com Total do Contrato em destaque). Fórmula do preview espelha exatamente pricing.ts::computeQuotePricing — nada muda no backend, é só a experiência de montar a oferta antes de enviar; o servidor continua sendo a única fonte de verdade nos totais persistidos. pnpm turbo run typecheck build: 28/28. Suíte @eden/api: 113/113 (inalterada). Testado ao vivo ponta a ponta, incluindo o caso de aprovação por desconto. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
833 lines
131 KiB
Markdown
833 lines
131 KiB
Markdown
# EDEN — Progresso
|
||
|
||
## Fase 0 — Descoberta e Arquitetura: CONCLUÍDA
|
||
|
||
Data: 2026-09-03.
|
||
|
||
### Entregáveis
|
||
- `docs/project-inventory.md`, `docs/glossary.md`, `docs/architecture.md`, `docs/assumptions.md`, `docs/security/threat-model.md`, `docs/implementation-plan.md`, `docs/progress.md` (este arquivo).
|
||
- 12 ADRs em `docs/adr/` (0001–0012), cobrindo: modular monolith, topologia de containers (Postgres isolado + eden-core/parceiros/assinante em containers/portas distintas — decisão explícita do operador), auth/sessões, modelo de permissões, criptografia/segredos, contrato first-class, stock ledger, separação billing/finance/fiscal, outbox transacional, adapter SaperX, adapter Focus NFe, três apps com identidade compartilhada.
|
||
- 14 subagentes em `.claude/agents/`: eden-architect, eden-database, eden-security, eden-commercial, eden-finance, eden-fiscal, eden-inventory, eden-telecom, eden-support, eden-hr-timeclock, eden-frontend, eden-api-integrations, eden-qa, eden-code-reviewer.
|
||
- 7 skills em `.claude/skills/`: eden-domain, eden-security, eden-finance, eden-fiscal, eden-inventory, eden-telecom, eden-timeclock — cada uma com `SKILL.md` + `references/`.
|
||
- Hooks em `.claude/settings.json` + `.claude/hooks/*`: `PreToolUse` (guarda de comandos Bash destrutivos/externos e de acesso a arquivo fora do projeto/`.ssh`/`/etc`/segredo), `PostToolUse` (lint/typecheck incremental, fail-open enquanto pnpm não existir), `Stop` (checagem advisória de segredo/TODO crítico no diff, silenciosa até existir repositório git). Testados via pipe com payloads sintéticos — todos os cenários (allow/ask/deny/fail-open) se comportaram como esperado.
|
||
|
||
### Revisão cruzada (self-review contra os critérios dos agentes eden-architect / eden-security / eden-database, criados nesta mesma fase)
|
||
|
||
**eden-architect**: bounded contexts (`docs/architecture.md` §2) têm direção de dependência consistente (nenhum ciclo); HR/Timeclock corretamente isolado; candidatos a extração futura (Billing, Fiscal) identificados sem extração prematura. Nenhuma feature de negócio foi implementada antes deste gate — consistente com o Master Prompt §18.
|
||
|
||
**eden-security**: threat model cobre as superfícies do legado (rotas públicas, auth, autorização, integrações, upload, PDF, auditoria, infraestrutura) mais os invariantes de teste obrigatórios (§15.3). Hooks `PreToolUse` já impõem, a nível de ferramenta, várias regras de §2.1/§2.2 (fora do projeto, `.ssh`/`/etc`, impressão de `.env`, SSH/SCP externo, remoção ampla de volume/prune). Pendência real registrada (não bloqueante): política de retenção/eliminação LGPD ainda não definida pela Handix (`docs/assumptions.md` #1).
|
||
|
||
**eden-database**: princípios de modelagem (§11) já refletidos nas ADRs de contrato/estoque/billing (nunca saldo editável, nunca CASCADE indiscriminado, JSONB só para snapshot/metadata). Nenhum schema físico foi criado ainda nesta fase — corretamente adiado para a Fase 1, quando o Postgres 18 realmente subir.
|
||
|
||
**Nota**: esta revisão foi feita pela própria sessão orquestradora aplicando os critérios definidos nos arquivos de agente recém-criados. Os subagentes dedicados (`eden-architect`, `eden-security`, `eden-database`) ficam disponíveis para revisões reais em sessões futuras assim que o Claude Code os carregar como subagent types.
|
||
|
||
### Pendências não bloqueantes (ver `docs/assumptions.md`)
|
||
1. Política de retenção/eliminação de dados pessoais (LGPD) — Handix ainda não definiu.
|
||
2. Docker/Node/pnpm precisam ser provisionados no ambiente antes da Fase 1.
|
||
3. Portas dos containers definidas via `.env` (não fixas) — ver ADR-0002.
|
||
4. Git ainda não iniciado no projeto — planejado para o início da Fase 1.
|
||
5. Inventário fino do `tema_do_Eden.zip` (extração de componentes) adiado para o início de `eden-frontend`.
|
||
|
||
## Gate de saída da Fase 0: VERDE
|
||
|
||
Todos os itens do checklist da seção 22 do Master Prompt (“Primeira Execução”) estão completos.
|
||
|
||
---
|
||
|
||
## Fase 1 — Plataforma: EM ANDAMENTO
|
||
|
||
Data de início: 2026-09-03.
|
||
|
||
### Ambiente provisionado
|
||
- Docker CE 29.7.2 + Compose v5.5.0 (repositório oficial Docker, não o `docker.io` do Debian) instalados via apt.
|
||
- Node.js 22.23.2 LTS (NodeSource) + pnpm 11.25.0 (via corepack).
|
||
- Git 2.47.3. Repositório inicializado (`main`), remoto `origin` = `https://git.falehandix.com.br/Matheus/eden.git` (nenhum push feito ainda).
|
||
|
||
### Monorepo
|
||
- pnpm workspaces + Turborepo. 5 apps (`api`, `worker`, `core-web`, `reseller-web`, `subscriber-web`) + 9 packages, todos com `package.json`/`tsconfig.json` válidos.
|
||
- `pnpm install`, `pnpm turbo run typecheck` e `pnpm turbo run build` passam 100% (15/15 e 14/14 tarefas, respectivamente) em todo o monorepo.
|
||
- `apps/api`: NestJS mínimo com `/health/live` e `/health/ready` (este último checando Postgres de verdade via `@eden/database`).
|
||
- `apps/worker`: placeholder que confirma conectividade com o banco; sem BullMQ/Redis ainda (conforme ADR-0002 — só entra quando houver job real).
|
||
- `apps/core-web`, `apps/reseller-web`, `apps/subscriber-web`: Vite + React + TS + Tailwind, cada um com favicon oficial do EDEN.
|
||
- `packages/database`: migrations via `node-pg-migrate` (formato `.cjs`), client de query (`pg` Pool).
|
||
|
||
### Banco de dados
|
||
- Migration baseline aplicada com sucesso contra Postgres 18.6 real (container `eden-postgres`): `roles`, `role_permissions`, `applications`, `users`, `user_applications`, `sessions`, `audit_log` — cobrindo Identity & Access (ADR-0003/ADR-0004) e um audit log append-only com hash-chain (testado: `UPDATE`/`DELETE` são efetivamente bloqueados pelo trigger, só liberados via `SET LOCAL eden.allow_audit_mutation`).
|
||
- Seeds: 4 papéis do sistema (pesos idênticos ao legado) + as 3 aplicações (`core`/`reseller`/`subscriber`).
|
||
- Organization (empresas/entidades legais), Commercial e demais bounded contexts **ainda não têm migration** — ficam para quando cada fase começar, por design (ver ADR-0001).
|
||
|
||
### Docker Compose — topologia ADR-0002 validada de ponta a ponta
|
||
`docker compose --env-file .env up -d` sobe os 6 serviços com sucesso:
|
||
|
||
| Serviço | Status observado |
|
||
|---|---|
|
||
| `eden-postgres` | healthy — porta dev 55432, nunca 5432 direto |
|
||
| `eden-api` | healthy — único serviço com `DATABASE_URL`; `/health/ready` confirma Postgres via rede interna |
|
||
| `eden-worker` | up — conecta ao Postgres, sem porta exposta |
|
||
| `eden-core` (:3001) | healthy — HTTP 200 |
|
||
| `eden-parceiros` (:3002) | healthy — HTTP 200 |
|
||
| `eden-assinante` (:3003) | healthy — HTTP 200 |
|
||
|
||
Bugs de bootstrap corrigidos nesta sessão (documentados aqui para não se repetirem):
|
||
1. Imagem oficial `postgres:18-alpine` mudou o layout do volume — precisa montar `/var/lib/postgresql` (não `.../data`).
|
||
2. `apps/api/package.json` tinha `"type": "module"` incompatível com o `tsconfig` (saída CommonJS) — Node falhava com `exports is not defined`.
|
||
3. `node-pg-migrate --envPath` não carregava o `.env` corretamente nesta versão — trocado por `bash -c 'source .env; ...'` nos scripts `migrate:up`/`migrate:down`.
|
||
4. Healthcheck dos 3 frontends usava `wget http://localhost/...`, que resolve para `::1` (IPv6) dentro do container — nginx só escuta IPv4; trocado para `127.0.0.1`.
|
||
|
||
### Auth, permissões, Companies e OpenAPI — implementados e testados ao vivo
|
||
|
||
- **Auth** (`apps/api/src/auth/`): Argon2id (parâmetros versionados, ADR-0005), JWT de acesso de 15 min, refresh token opaco (256 bits) com rotação e **detecção de replay** (reuso de token já rotacionado revoga toda a cadeia de sessões do usuário). Endpoints `POST /api/v1/auth/{login,refresh,logout}` e `GET /api/v1/auth/me`.
|
||
- **Permissões** (`apps/api/src/auth/permissions.ts`, `permission.guard.ts`): resource+action+scope (ADR-0004), `super_admin` com bypass hardcoded (nunca consulta `role_permissions`, espelhando a trava também existente no banco). Guard resolve permissões do banco a cada request (nunca cacheado no JWT) — mudança de papel tem efeito imediato, mesma filosofia do legado.
|
||
- **Companies** (`apps/api/src/companies/` + migration `1788437478189_companies`): CRUD com leitura aberta a qualquer autenticado e escrita restrita a `companies:manage`; validação de CNPJ (dígito verificador, `packages/domain-shared`) testada com CNPJ válido/inválido conhecidos.
|
||
- **Seed automático**: `SeedService` cria o primeiro `super_admin` a partir de `EDEN_SUPERADMIN_EMAIL`/`PASSWORD` só quando `users` está vazia — testado (log `super_admin inicial criado`).
|
||
- **OpenAPI/Swagger**: `/api/docs` (UI) e `/api/docs-json`, cobrindo todos os endpoints atuais.
|
||
- **Validação/segurança de request**: `ValidationPipe` global com `whitelist`+`forbidNonWhitelisted` (proteção contra mass-assignment) — testado (`id`/`isActive` extras num POST de company são rejeitados com 400).
|
||
|
||
Testes manuais realizados contra a stack real (não só typecheck): login com senha errada → 401 genérico; refresh rotaciona e o token antigo some; reuso do token antigo → 401 + toda a cadeia revogada; logout revoga e é idempotente; audit log com a sequência completa de eventos (`auth.login`, `auth.refresh`, `auth.refresh_replay_detected`, `company.created`).
|
||
|
||
### CRUD de Usuários/Papéis, Products, Resellers, adapters S3/SMTP — implementados via 4 subagentes especializados em paralelo (worktrees isolados) + trabalho direto
|
||
|
||
Estratégia: 4 subagentes (`general-purpose`, atuando com a persona `eden-commercial`/`eden-api-integrations` definida em `.claude/agents/`, já que os subagent types customizados ainda não carregaram nesta sessão) rodaram em paralelo, cada um em `git worktree` isolado, enquanto o CRUD de Usuários/Papéis foi feito diretamente (por tocar peso hierárquico e segurança). Cada agente foi instruído a não editar `app.module.ts` nem `packages/database/src/index.ts` — a integração final (merge das branches, wiring, resolução de conflitos, rebuild, teste ao vivo) foi feita manualmente.
|
||
|
||
- **Users/Roles** (`apps/api/src/users/`, `apps/api/src/roles/`): convite com senha temporária, troca de senha self-service, reset administrativo, grant/revoke de aplicação — tudo com checagem de peso hierárquico (`assertCanAssignRole`/`assertCanActOnUser`, eden.md §1.6). CRUD de papéis e `role_permissions` exclusivo `super_admin` (`SuperAdminGuard`), deliberadamente não generalizado para o modelo de permissão (raiz do próprio sistema de autorização).
|
||
- **Products** (`apps/api/src/products/` + migration `products`): catálogo com as 5 faixas de preço fixas, `product_code` sequencial, flags de rastreamento (estoque/serial/patrimônio/MAC). Leitura aberta, escrita via `products:edit`.
|
||
- **Resellers** (`apps/api/src/resellers/` + migration `resellers`): CRUD operacional + vínculo usuário↔revenda (`users.reseller_id`). Leitura aberta, escrita via `resellers:edit` — desvio deliberado do legado (que travava em `requireAdmin`), documentado em comentário.
|
||
- **Adapters S3/SMTP** (`packages/integrations/`): portados fielmente do legado — S3 falha alto (erro claro se não configurado), SMTP falha silencioso (nunca quebra um fluxo de negócio por causa de e-mail).
|
||
|
||
- **CRM** (`apps/api/src/leads/`, `apps/api/src/opportunities/` + migration `crm`): leads e oportunidades com conversão transacional (`convertLeadToOpportunity`, `FOR UPDATE` + `withTransaction`). Diferente de Companies/Products, leitura também exige permissão (`leads:view`/`opportunities:view`) por conter PII de prospect.
|
||
|
||
**4 bugs reais encontrados em teste manual e corrigidos antes do commit final:**
|
||
1. `assertCanAssignRole`/`assertCanActOnUser` pulavam a validação de existência do papel quando o ator era `super_admin` (o bypass de peso pulava a checagem inteira) — convidar usuário com `role_key` inexistente estourava a constraint FK do banco como 500 não tratado em vez de 400 claro.
|
||
2. `PATCH /v1/resellers/:id/assign-user` aceitava um `resellerId` diferente no corpo, que na prática era o valor usado — o `:id` da URL virava só uma checagem de existência, não o alvo real da atribuição. Corrigido para o `:id` da URL ser a única fonte de verdade; desatribuição virou endpoint próprio (`POST /v1/resellers/unassign-user`).
|
||
3. Os controllers de Leads/Opportunities liam `req.user?.id` em vez de usar `@CurrentUser()`/`request.edenUser` (o padrão real do `JwtAuthGuard`) — todo evento de auditoria de lead/oportunidade estaria gravando `actorId: null` sempre. O agente que os escreveu não tinha o módulo de auth disponível no worktree dele para testar isso de verdade (só stubou e reverteu antes de commitar).
|
||
4. As chamadas `appendAuditEvent()` de leads/opportunities omitiam o campo obrigatório `actorType` (6 ocorrências).
|
||
|
||
Também precisou renomear o timestamp da migration de CRM, que o agente gerou com valor anterior ao da migration de `resellers` (já aplicada por outro agente/merge) — `node-pg-migrate` recusa migration fora de ordem.
|
||
|
||
Todos os módulos testados ao vivo contra a stack real (não só typecheck): convite de usuário, papel inexistente (400 após o fix), CRUD de produto com preços NUMERIC corretos, CRUD de revenda, atribuição/desatribuição de usuário à revenda, lead→oportunidade transacional.
|
||
|
||
### Suíte de testes de integração (agente eden-qa)
|
||
|
||
Vitest + supertest + `@nestjs/testing` (bootstrap real do Nest, sem mock) contra o Postgres real — 5 arquivos, 15 testes, todos verdes: `auth.spec.ts` (anti-enumeração, rotação de refresh, **detecção de replay fim-a-fim**, logout idempotente), `role-weight.spec.ts` (guarda a regressão do bug #1), `companies.spec.ts` (CNPJ), `mass-assignment.spec.ts` (whitelist), `reseller-assignment.spec.ts` (guarda a regressão do bug #2). Achado técnico do agente: o `esbuild` (transform padrão do Vitest) não implementa `emitDecoratorMetadata`, quebrando toda injeção de dependência do Nest silenciosamente sob teste — corrigido trocando para SWC (`unplugin-swc`), abordagem padrão documentada pelo próprio NestJS. Rodar com `pnpm --filter @eden/api test`.
|
||
|
||
### Resumo do lote de agentes paralelos
|
||
|
||
6 subagentes no total (4 + 1 QA + Users/Roles feito diretamente), rodando em `git worktree` isolados, revisados e mesclados manualmente um a um (nunca merge automático sem verificação). **4 bugs reais** encontrados só porque cada módulo foi testado contra a stack viva antes do commit — nenhum deles teria aparecido só com typecheck/build, que todos os agentes já reportavam verde. Padrão validado: delegar implementação para agentes especializados funciona bem para módulos CRUD bem especificados, mas a integração final (merge, wiring, teste ao vivo, revisão) precisa ficar com quem tem visão do sistema inteiro.
|
||
|
||
### Pendências da Fase 1 (não iniciadas)
|
||
- ~~MFA/TOTP~~ — implementado na leva seguinte (ver Fase 4+ abaixo).
|
||
- Extração do Design System a partir do tema DreamsERP (`packages/ui` ainda é placeholder).
|
||
- Wiring dos adapters S3/SMTP em algum módulo consumidor real (hoje só existem como biblioteca em `packages/integrations`, nada os chama ainda).
|
||
- Nota de dado de teste: a empresa "Handix Telecom LTDA" e a revenda "Handix (revenda própria)" criadas durante os testes usam dados de exemplo, não os dados reais da Handix — substituir/remover antes de qualquer ambiente que não seja dev local.
|
||
|
||
---
|
||
|
||
## Fase 2/3 — Ofertas (Quotes), Cadastro de Cliente (Customer 360) e esqueleto de Contratos
|
||
|
||
Data: 2026-09-03.
|
||
|
||
### Ofertas / Quotes (`apps/api/src/quotes/` + migration `quotes`) — implementado diretamente
|
||
|
||
Núcleo comercial mais sensível a erro de arredondamento/regra de negócio (eden.md §7/§8), por isso feito sem delegar a subagente:
|
||
- `pricing.ts`: funções puras porteadas literalmente do legado — `monthlyTotal = Σitem.total`; `totalImpl = implFromProducts + implGeral`; `hasSpecialPrice/isDiscount/isMarkup` por comparação com `proposedMonthlyTotal`; `contractTotal = effectiveMonthly * (contractPeriod||1) + totalImpl`; `needsApproval = isDiscount || hasReducedFidelity`; fidelidade só tem efeito legal com `approval_status === 'approved'`. Faixas de condição de pagamento (`À vista` ≤1500; `+1+1` ≤3000; `+1+2` acima) replicadas do legado.
|
||
- Preço sempre derivado server-side do produto vivo no banco (nunca confia em preço vindo do cliente).
|
||
- Fluxo completo: criar oferta → decidir aprovação (`PATCH :id/approval`) → fechar negócio vinculando a um cadastro de cliente (`POST :id/close`, bloqueado por `client_registration_id IS NULL` — não fecha duas vezes).
|
||
|
||
### Customer 360 / Cadastro de Cliente (`apps/api/src/client-registrations/` + migration `client-registrations`) e Contratos (`apps/api/src/contracts/` + migration `contracts`) — subagentes + salvamento manual
|
||
|
||
Lançados 2 subagentes em paralelo (worktrees isolados) para Cadastro de Cliente e esqueleto de Contratos. Ambos falharam duas vezes: primeiro com rate limit de sessão (retomado após o usuário habilitar cobrança de excedente), depois com **limite de gasto mensal da conta** — um bloqueio de nível de conta que não se resolve tentando de novo. Em vez de insistir, os dois worktrees foram inspecionados (`git status`, diff arquivo a arquivo); o trabalho não commitado encontrado em ambos era substancial e de boa qualidade — foi copiado diretamente para `main` (sem passar por branch/merge, já que nada tinha sido commitado) e o que faltava foi completado manualmente:
|
||
- **Cadastro de Cliente**: repositórios e camada de API salvos do agente praticamente inalterados; só faltava o `client-registrations.module.ts`, escrito manualmente. MVP com escopo explícito (eden.md §3): só fluxo autenticado, sem token de autoatendimento público, sem upload de anexo, sem integração CNPJ/CEP — tudo documentado em comentário no código, não descartado silenciosamente.
|
||
- **Contratos**: repositório salvo do agente (com um import não usado removido); toda a camada de API (DTOs, service, controller, module) escrita manualmente, pois o agente não tinha chegado lá. Máquina de estados (ADR-0006) `draft→{pending_signature,cancelled}`, `pending_signature→{active,cancelled}`, `active→{suspended,cancelled,terminated,expired}`, `suspended→{active,cancelled,terminated}` — imposta via `SELECT...FOR UPDATE` + tabela de transições, com `signed_at`/`activated_at` carimbados na transição correta e histórico completo em `contract_status_history`. Escopo: 4 das 11 tabelas do ADR-0006 (contracts, contract_items, contract_parties, contract_status_history); as outras 7 explicitamente adiadas.
|
||
|
||
Wiring final feito manualmente: barrel (`packages/database/src/index.ts`) e `app.module.ts` atualizados; 3 migrations aplicadas ao Postgres real; `pnpm turbo run typecheck build` (28/28 tasks) e `pnpm --filter @eden/api test` (15/15 testes) verdes; container `eden-api` reconstruído e todo o fluxo testado ao vivo via curl contra a stack real:
|
||
- Oferta sem preço especial (sem aprovação) e oferta com desconto + fidelidade reduzida (`approval_status: pending`) — valores conferidos manualmente contra a fórmula.
|
||
- Aprovação da oferta, fechamento vinculando a um cadastro de cliente criado via `POST /client-registrations/direct`, e guarda de fechamento duplo (409 na segunda tentativa).
|
||
- Contrato criado a partir da oferta fechada, transições de status válidas e inválidas (draft→active direto rejeitado com 409; draft→pending_signature→active aceito, `signed_at`/`activated_at` carimbados corretamente), adição de item/parte, FK de produto inexistente rejeitada com 400.
|
||
- 401 sem token; trilha de auditoria (`audit_log`) conferida no Postgres com a sequência completa de eventos do lote (`quote.created`, `quote.approval_approved`, `quote.closed`, `client_registration.created`, `contract.created`, `contract.status_changed` ×2, `contract.party_added`, `client_registration.updated`).
|
||
|
||
**2 bugs reais encontrados e corrigidos antes do commit (nenhum via subagente — achados na própria implementação/revisão):**
|
||
1. Primeiro rascunho do `updateQuote` (escrito por mim) usava `COALESCE` para os campos financeiros (`fidelity_period`, `proposed_monthly_total`, `approved_by_id`) — como `undefined` do JS também vira `NULL` no SQL, qualquer update parcial que omitisse esses campos os apagaria silenciosamente. Corrigido tornando esses campos **obrigatórios** em `UpdateQuoteInput` (não mais opcionais), forçando a camada de serviço a sempre fornecer um valor deliberado.
|
||
2. `packages/database/src/repositories/types.ts` — a interface `User` estava sem `reseller_id`/`can_approve_discount`, mesmo essas colunas já existindo no banco desde as migrations de Resellers/Quotes (ninguém tinha atualizado o tipo compartilhado). Descoberto ao ligar o uso desses campos em Quotes; corrigido no `types.ts` e propagado para `EdenAuthenticatedUser` em `jwt-auth.guard.ts`.
|
||
|
||
### Pendências deste lote
|
||
- ~~FKs cruzadas entre as 3 tabelas~~ — fechadas na leva seguinte (ver abaixo).
|
||
- Fluxo de autoatendimento público (token) de Cadastro de Cliente, upload de anexo, integração CNPJ/CEP — todos fora do escopo desta MVP.
|
||
- 7 das 11 tabelas do ADR-0006 de Contratos (aditivos, faturas, documentos/e-signature, etc.) ainda não criadas.
|
||
|
||
---
|
||
|
||
## Fase 4/9 — MFA/TOTP, Estoque (Inventory), Suporte (Service Desk) e fundação de Ponto Eletrônico (HR/Timeclock)
|
||
|
||
Data: 2026-09-03.
|
||
|
||
Pedido do usuário: "pode seguir na criacao do app ao menos mais 20 etapas use subagentes". Estratégia: fechei a pendência de MFA/TOTP da Fase 1 diretamente (autenticação é sensível demais para delegar) e lancei 3 subagentes em paralelo (`eden-inventory`, `eden-support`, `eden-hr-timeclock`) em worktrees isolados, seguindo o mesmo padrão validado nas levas anteriores. Além das FKs cruzadas de Quotes/Contracts/ClientRegistrations, deixadas pendentes na leva anterior.
|
||
|
||
### FKs cruzadas (pendência da leva anterior) — fechada
|
||
|
||
Migration `1788450538883_cross-reference-fks.cjs`: `quotes.client_registration_id → client_registrations(id)`, `client_registrations.quote_id → quotes(id)`, `contracts.quote_id → quotes(id)`, `contracts.client_registration_id → client_registrations(id)`, todas `ON DELETE RESTRICT` (nenhum desses agregados tem rota DELETE hoje; perder o vínculo silenciosamente numa exclusão não relacionada seria pior que bloquear a exclusão). Verificado zero órfãos antes de aplicar.
|
||
|
||
### MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `field-encryption.service.ts`) — implementado diretamente
|
||
|
||
- `FieldEncryptionService` (`apps/api/src/crypto/`): criptografia reversível genérica AES-256-GCM versionada (ADR-0005), formato `v1:iv:tag:ciphertext` base64, chave de `EDEN_FIELD_ENCRYPTION_KEY` (hasheada para 32 bytes via SHA-256). Primeira implementação real desse padrão no projeto — vai ser reaproveitada por outros segredos reversíveis (ex.: credencial de equipamento Control iD, feito na mesma leva).
|
||
- Fluxo de login em duas etapas quando `mfa_enabled=true`: `POST /auth/login` retorna `{mfaRequired: true, mfaChallenge}` (JWT de propósito único, 2 min, claim `purpose` distintivo) em vez de tokens; `POST /auth/mfa/challenge` troca o desafio + código TOTP pelos tokens reais. `POST /auth/mfa/enroll` (gera secret + `otpauthUrl`, não habilita ainda) → `POST /auth/mfa/confirm` (primeiro código correto habilita) → `POST /auth/mfa/disable` (exige código válido, não só sessão autenticada).
|
||
- Biblioteca `otplib` v13 (API funcional `generateSecret`/`generateURI`/`verify`, não mais o antigo `authenticator` singleton de v12).
|
||
|
||
**Bug de segurança real encontrado e corrigido em teste ao vivo (não hipotético — explorado de verdade contra a stack rodando):** o token de desafio MFA (`mfaChallenge`, emitido logo após a senha, antes do código TOTP) era aceito pelo `JwtAuthGuard` como um access token comum — `verifyAccessToken()` só validava assinatura/expiração, nunca checava a claim `purpose`. Resultado: um atacante que só tivesse a senha (sem o segundo fator) conseguia acesso total à API por 2 minutos usando o token de desafio como Bearer normal — MFA completamente contornável. Corrigido em `token.service.ts::verifyAccessToken` rejeitando qualquer token com claim `purpose` presente. Teste de regressão adicionado em `test/mfa.spec.ts` (`GET /auth/me` com o challenge token como Bearer → 401). Explorado e depois fechado ao vivo via curl contra o container real antes de seguir adiante.
|
||
|
||
### Estoque / Inventory (`apps/api/src/inventory/` + migration `inventory`) — subagente `eden-inventory`
|
||
|
||
5 tabelas (de 15 possíveis no Master Prompt §6.8, mesmo approach de MVP-coerente do módulo Contracts): `warehouses`, `stock_movements` (ledger append-only, saldo sempre derivado via SUM, nunca coluna editável — reforçado por trigger que bloqueia UPDATE/DELETE sem exceção), `serialized_assets`, `asset_assignments`, `stock_reservations`. Invariante "mesmo serial/patrimônio/MAC nunca duas vezes ativo" (Master Prompt §15.3) reforçada por **3 índices únicos parciais no banco**, não só validação de aplicação. Fluxo completo reserva→instalação(comodato)→devolução implementado com `SELECT...FOR UPDATE` + transação, erros tipados mapeados para 404/409.
|
||
|
||
Subagente entregou trabalho de excelente qualidade na primeira tentativa — nenhum bug encontrado na revisão nem no teste ao vivo.
|
||
|
||
### Suporte / Service Desk (`apps/api/src/support/` + migration `support`) — subagente `eden-support`
|
||
|
||
4 tabelas: `support_tickets` (protocolo humano `TCK-2026-000001`, mesmo padrão de `quote_number`/`contract_number`), `ticket_comments` (com `is_internal`, nunca exposto fora do escopo `all`), `ticket_status_history`, `sla_timers` (1:1). Máquina de estados `new→triage→in_progress⇄{waiting_customer,waiting_third_party}→resolved→closed` (+`cancelled`), mesmo padrão de `CONTRACT_STATUS_TRANSITIONS`. Motor de SLA implementado como funções puras testáveis (`apps/api/src/support/sla.ts`) — pausa/despausa atomicamente em SQL na transição de status, cálculo de violação subtrai corretamente o tempo pausado (incluindo a pausa corrente). `root_cause`/`resolution_notes` só persistidos na transição para `closed`, não `resolved` (decisão documentada, confirmada em teste ao vivo).
|
||
|
||
Também de excelente qualidade — nenhum bug encontrado.
|
||
|
||
### Fundação de Ponto Eletrônico / HR-Timeclock (`apps/api/src/hr-timeclock/` + migration `hr-timeclock`) — subagente `eden-hr-timeclock`
|
||
|
||
Escopo desta leva deliberadamente limitado a fundação (modelo de dados + Control iD device + parser/import de AFD com CRC-16/KERMIT) — apuração, fechamento de período, ajustes, banco de horas e relatórios (eden.md §5-10) explicitamente adiados. 4 tabelas: `timeclock_devices` (senha do equipamento cifrada via `FieldEncryptionService`, nunca retornada em nenhum GET — só `hasCredential:boolean`), `timeclock_employees` (cadastro separado de `users`, decisão documentada: o Control iD identifica por CPF, e `users` não tem CPF), `afd_import_batches`, `afd_records`. `afd_records` é **imutável sem exceção, nem para super_admin** — trigger que bloqueia UPDATE/DELETE sem nenhuma válvula de escape (diferente de `audit_log`, que tem bypass controlado via `SET LOCAL`). Idempotência em duas camadas: hash SHA-256 do arquivo inteiro (retorna `alreadyImported:true` em reimportação) e `UNIQUE(device_id, nsr)` por registro. NSR ausente/não-numérico nunca vira 0 — fica `null`, preservando o dado bruto mesmo inválido.
|
||
|
||
Parser CRC-16/KERMIT (poly 0x1021 refletido/0x8408, init 0x0000, sem XOR final) verificado contra o vetor de teste conhecido ("123456789" → 0x2189) — confirmado tanto no script ad-hoc do agente quanto na suíte permanente `test/afd-parser.spec.ts` (11 testes).
|
||
|
||
**1 bug real encontrado e corrigido antes da integração:** a migration `PENDING_hr-timeclock.cjs` tinha uma crase (`` ` ``) solta dentro de um comentário SQL (`-- separate from \`users\``) — como a migration inteira é um template literal JS (`` pgm.sql(`...`) ``), essa crase fechava o literal prematuramente e quebrava o resto do SQL em código JS inválido, causando `SyntaxError: missing ) after argument list` ao tentar rodar `pnpm db:migrate`. Corrigido trocando a crase por aspas simples no comentário. Encontrado e corrigido antes de qualquer teste ao vivo, na primeira tentativa de aplicar a migration.
|
||
|
||
### Padrão de salvamento seletivo ao integrar (novo, deste lote)
|
||
|
||
Diferente da leva anterior (onde os 2 agentes falharam por limite de gasto e o trabalho foi resgatado de worktrees não commitados), desta vez os 3 agentes **completaram normalmente**, mas todos os 3 worktrees foram criados a partir do commit único de bootstrap (antes de Auth/Companies/Products/etc. existirem nesse checkout isolado) — sem acesso ao código real de `apps/api/src/auth/`, `packages/database/src/repositories/{audit,users,roles,types}.ts` etc. Cada agente relatou isso com transparência e, para conseguir cumprir a etapa de verificação (`pnpm typecheck` limpo), recriou stubs/placeholders type-compatíveis desses arquivos dentro do próprio worktree — e dois deles (`eden-support`, `eden-hr-timeclock`) deixaram esses placeholders (`audit.ts`, `users.ts`, `roles.ts`, `types.ts`, config, guards de auth) como arquivos genuínos no diff final, não revertidos, por acreditarem (corretamente, do ponto de vista deles, isolados no worktree) que essa infraestrutura ainda não existia.
|
||
|
||
Na integração, copiei seletivamente **só os arquivos de domínio real** de cada agente (migration, repositório específico do módulo, camada de API do módulo) e **descartei os placeholders duplicados de Auth/Config/Crypto/audit/users/roles**, mantendo as versões reais já existentes em `main`. Wiring do barrel (`packages/database/src/index.ts`) e `app.module.ts` feito manualmente para os 3 módulos, como nas levas anteriores.
|
||
|
||
### Verificação
|
||
|
||
`pnpm turbo run typecheck build` verde em cada integração incremental; suíte de testes cresceu de 15 para **30 testes, todos verdes** (`auth.spec.ts`, `mfa.spec.ts` novo, `afd-parser.spec.ts` novo com 11 testes, `role-weight.spec.ts`, `reseller-assignment.spec.ts`, `companies.spec.ts`, `mass-assignment.spec.ts`). Container `eden-api` reconstruído e todo o lote testado ao vivo via curl contra a stack real:
|
||
- MFA: enroll→confirm com código TOTP real gerado via `otplib`→login exige desafio→código errado rejeitado→código certo completa login→disable exige código válido; **exploração do bug de segurança repetida após o fix, confirmando 401**.
|
||
- Inventory: armazém→entrada de ativo→serial duplicado rejeitado (409, não 500)→reserva→instalação (comodato)→instalação duplicada rejeitada (409)→devolução→ledger conferido linha a linha (`purchase_in`, `reservation`, `comodato_out`, `return_in`).
|
||
- Support: ticket criado com SLA (+4h/+24h)→transições válidas→comentário público marca `first_response_met`→`waiting_customer` pausa o timer→retorno a `in_progress` acumula o tempo pausado→`resolved`→`closed` (root_cause só persiste aqui)→transição inválida rejeitada (409).
|
||
- HR-Timeclock: device criado com senha cifrada (confirmado no Postgres, formato `v1:iv:tag:ciphertext`, nunca no GET)→import de arquivo AFD válido (2 linhas, CRC correto)→reimport do mesmo arquivo retorna `alreadyImported:true` sem duplicar→`UPDATE` direto em `afd_records` via SQL bloqueado pelo trigger mesmo fora da API.
|
||
- Trilha de auditoria (`audit_log`) conferida no Postgres com a sequência completa de todos os eventos do lote.
|
||
|
||
### Pendências deste lote
|
||
- Inventory: `warehouse_locations`, `stock_items` (cache de saldo), `stock_lots`, `inventory_counts`, `transfers`/`receipts`/`issues`/`returns` formais, `rma`, `asset_maintenance` — deferidos, tipos de movimento já modelados no vocabulário do ledger para não exigir nova migration.
|
||
- Support: anexos reais (S3), CSAT, OS dedicada, filas com roteamento automático, calendário de atendimento configurável, SLA por categoria — hoje é uma política única fixa (4h/24h).
|
||
- HR-Timeclock: apuração, fechamento de período, ajustes manuais, banco de horas, relatórios, sync ao vivo com o Control iD, upload multipart real (hoje aceita o conteúdo como string JSON no body).
|
||
- MFA: sem rate-limiting/bloqueio de tentativas no `mfa/challenge` (mesma lacuna que já existe no login por senha hoje — nenhum endpoint de auth tem rate limit ainda, ficou registrado como débito técnico geral, não específico do MFA).
|
||
|
||
---
|
||
|
||
## Fase 5/6 + Design System — Billing, Fiscal e primeira tela real (login + shell autenticado)
|
||
|
||
Data: 2026-09-03.
|
||
|
||
Continuação do mesmo pedido do usuário ("pelo menos mais 20 etapas, use subagentes"). 3 subagentes em paralelo (`eden-finance`, `eden-fiscal`, `eden-frontend`), mesmo padrão de worktree isolado + integração seletiva manual.
|
||
|
||
### Billing / Faturamento recorrente (`apps/api/src/billing/` + migration `billing`) — subagente `eden-finance`
|
||
|
||
MVP de 4 tabelas (ADR-0008 lista 9; billing_accounts/billing_cycles/charge_components/usage_charges/invoice_adjustments/billing_run_logs deferidos, documentado): `billing_subscriptions` (derivada de contrato, `monthly_value` é snapshot — nunca recalculado automaticamente), `billing_runs`, `invoices`, `invoice_items`. Fluxo `subscription→billing_run→invoice` completo e idempotente: rodar o mesmo run duas vezes em `draft` substitui as invoices (não duplica) — reforçado por índice único parcial `WHERE status='draft'` (no máximo um run em rascunho por período); depois de `consolidate`, run e invoices ficam imutáveis pela aplicação (409 em qualquer tentativa de gerar/alterar). ADR-0008 respeitado à risca: Billing só calcula o que é devido, nunca toca em AR/AP/boleto/cobrança (Finance) nem em documento fiscal (Fiscal) — bounded contexts continuam sendo desenvolvidos separadamente.
|
||
|
||
### Fiscal (`apps/api/src/fiscal/` + migration `fiscal`) — subagente `eden-fiscal`
|
||
|
||
eden.md módulo 5 tem ~19 tabelas de catálogo + importadores automáticos (Siscomex/IBGE) + importador manual de arquivo — MVP portou 5 tabelas fielmente ao shape do legado: `fiscal_ncm`/`fiscal_cfop` (catálogo simples), `fiscal_municipality` (shape distinto, sem `valid_from`/`valid_until`), `fiscal_operation_nature` (seed fixo com as 11 naturezas do legado), `product_fiscal_profiles` (liga produto à classificação fiscal). Invariante do eden.md §2.5 implementada de verdade: **um produto tem no máximo um profile ativo por `billing_component`** — índice único parcial + camada de aplicação que inativa o profile anterior antes de criar o novo (nunca update destrutivo, histórico preservado). Todo o importador automático/manual e as ~14 outras tabelas de catálogo ficam deferidos (documentado com a seção exata do eden.md para cada item).
|
||
|
||
### Design System + login real (`packages/ui`, `apps/core-web`) — subagente `eden-frontend`
|
||
|
||
Primeira leva de frontend do projeto. Achado que mudou o escopo para melhor: o `tema_do_Eden.zip` (comprado) não é só Angular — tem uma versão pronta em **React 18 + Vite + Tailwind** dentro (`react.zip`, 42.5MB), a mesma stack que o EDEN já usa. Em vez de portar de Angular, o agente extraiu os tokens visuais reais (cores, tipografia, sombras, border-radius, larguras de layout) desse React/Tailwind — confirmando inclusive uma divergência da nossa própria documentação: `docs/project-inventory.md` citava fonte Nunito/Poppins, a fonte real do tema é **Geist**.
|
||
|
||
- `packages/ui` deixou de ser placeholder: preset Tailwind exportável (`@eden/ui/tailwind-preset`) com os tokens reais extraídos, mais componentes React reais (não stub) — `Button`, `Input`, `Card`, `Badge`, `Alert`, `PageHeader`, `AppShell` (sidebar+topbar+conteúdo).
|
||
- `apps/core-web` (EDEN Core — prioridade, os outros 2 apps ficam para leva futura): trocou a tela estática "Bootstrap da Fase 1" por um fluxo real com `react-router-dom` — página de login chamando de verdade `POST /api/v1/auth/login` (com suporte ao fluxo `mfaRequired`/`POST /mfa/challenge` da leva de MFA), sessão em `localStorage`, rota protegida, e um shell autenticado (`AppShell` real, nome do usuário via `GET /api/v1/auth/me`, logout).
|
||
|
||
**Achado real fora do escopo do agente, corrigido por mim em seguida**: a API não tinha CORS configurado (`app.enableCors` ausente em `main.ts`) — um browser real rodando `core-web` num domínio/porta diferente da API teria toda chamada de login bloqueada no preflight, mesmo com tudo implementado corretamente nos dois lados. Corrigido: novo `EDEN_CORS_ORIGINS` (env, lista de origens separadas por vírgula, nunca wildcard — a API emite bearer token) em `packages/config/src/env-schema.ts`, `app.enableCors({origin, credentials:true})` em `main.ts`, propagado em `compose.yaml`/`.env`/`.env.example` já com os 3 domínios públicos reais (`dev-edencore`/`dev-parceirohandix`/`dev-portalassinate.falehandix.com.br`) mais as portas de dev locais. Testado ao vivo: preflight de `https://dev-edencore.falehandix.com.br` aceito com os headers corretos; origem não autorizada (`evil.example.com`) corretamente sem `Access-Control-Allow-Origin` na resposta; login completo simulando o `Origin` real de browser, 200 com o header de CORS certo.
|
||
|
||
**Pendência de infra identificada, não resolvida (decisão do operador, não técnica)**: os 3 domínios públicos hoje só apontam para os containers de frontend (3001/3002/3003) — não há um domínio público equivalente para a API. `VITE_API_BASE_URL` do build de produção dos 3 apps ainda aponta para `localhost:8080` (só funciona em dev local); falta decidir e implementar como o browser em produção alcança a API (domínio próprio para a API? proxy reverso roteando um path tipo `/api` para o container `eden-api` no mesmo domínio do frontend?) antes do login funcionar nos domínios públicos de verdade.
|
||
|
||
### Verificação
|
||
|
||
`pnpm turbo run typecheck build` (28/28) e `pnpm --filter @eden/api test` (30/30, sem regressão) verdes após cada integração. Containers `eden-api`/`eden-core` reconstruídos; testado ao vivo via curl:
|
||
- Fiscal: naturezas de operação semeadas (11), NCM criado, **invariante de profile único ativo por componente confirmada** (segundo profile RECURRING criado → primeiro automaticamente `active:false`).
|
||
- Billing: subscription criada → billing run gerado (invoice de R$499,90, vencimento no `billing_day`) → regeneração do mesmo período reaproveita o mesmo run (idempotente, sem duplicar invoice) → consolidação → segunda consolidação rejeitada (409) → regeneração de período já consolidado rejeitada (409).
|
||
- CORS: preflight aceito do domínio público real, rejeitado de origem não cadastrada, login completo com `Origin` de browser real.
|
||
- `core-web`: build de produção servido pelo container real não contém mais o texto "Bootstrap da Fase 1" — contém o formulário de login real (`E-mail`/`Senha`/`Entrar`).
|
||
- Trilha de auditoria conferida no Postgres com a sequência completa do lote.
|
||
|
||
### Pendências deste lote
|
||
- Billing: `billing_accounts`, `billing_cycles` como tabela própria, consumo/telefonia (`charge_components`/`usage_charges`), `invoice_adjustments`, pró-rata — tudo Finance/AR-AP (títulos, boletos, dunning, conciliação) fica para quando esse bounded context for iniciado, por decisão da ADR-0008.
|
||
- Fiscal: as ~14 tabelas de catálogo restantes, `fiscal_catalog_sync`, importador automático (Siscomex/IBGE) e manual (CSV/XLSX/JSON com proteção de inativação em massa), `fiscal_rules` (schema pronto mas sem consumo mesmo no legado).
|
||
- Frontend: `reseller-web`/`subscriber-web` ainda no bootstrap estático; telas de negócio (Quotes/Contracts/Inventory/etc.) no `core-web`; rotação automática de refresh token; fluxo de ENROLL de MFA na UI (só login com MFA já habilitado está implementado).
|
||
|
||
### Decisão do operador sobre topologia de API pública + 3 bugs de infra encontrados e corrigidos na sequência
|
||
|
||
Usuário decidiu: a API **nunca** tem domínio público próprio, só IP/nome interno — o browser sempre chama a API pelo mesmo domínio do frontend (caminho relativo), e o proxy externo (gerenciado pelo operador, fora deste repo) roteia esse caminho internamente para `eden-api` (por nome de serviço Docker, `/etc/hosts` ou IP interno — detalhe de infra do operador, não deste código).
|
||
|
||
Implementado: `infra/docker/Dockerfile.web` agora aceita `ARG VITE_API_BASE_URL` (default `/api/v1`, Vite bake em build-time); `compose.yaml` passa esse build arg fixo (`/api/v1`) para os 3 serviços web; `EDEN_CORS_ORIGINS` simplificado para só as portas de dev local (o fluxo de produção é same-origin via proxy, não precisa de CORS).
|
||
|
||
**3 bugs reais de infraestrutura encontrados e corrigidos nessa verificação (nenhum era o objetivo original, todos achados testando o resultado de ponta a ponta, não só lendo o código):**
|
||
1. **`turbo.json` não declarava `VITE_API_BASE_URL` como input da task `build`** — o Turborepo cacheava o build do Vite sem saber que essa env var afeta o output, então reaproveitava um bundle antigo (com a URL absoluta de dev) mesmo depois de mudar a env var. Corrigido adicionando `"env": ["VITE_*"]` à task `build` em `turbo.json`.
|
||
2. **`compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`** para o build arg do Docker — parecia razoável, mas colidia com a variável de mesmo nome no `.env` (usada para `pnpm dev` local, valor absoluto `http://localhost:8080/api/v1`) que o `docker compose` carrega automaticamente; o "default" nunca era usado porque a variável já estava setada. Corrigido fixando o valor diretamente (`/api/v1`, sem interpolação de env) nos 3 serviços web, já que essa é uma decisão arquitetural fixa, não uma configuração por ambiente.
|
||
3. **Não existia `.dockerignore`** no projeto — todo `COPY . .` do `Dockerfile.web`/`Dockerfile.node` estava copiando `node_modules/` (315MB), `.git/` (5.8MB) e `.claude/worktrees/` (345MB, os worktrees de subagentes desta mesma sessão) para dentro do contexto de build a cada vez. Além do desperdício óbvio, isso é parte de por que o bug nº1 passou despercebido no primeiro teste: caches `.turbo/` de dentro de cada pacote também eram copiados para dentro da imagem. Corrigido criando `.dockerignore` (exclui `node_modules`, `.git`, `dist`, `.turbo`, `.claude/worktrees`, `.tmp`, `.env*`, o zip do tema).
|
||
|
||
Cada correção foi testada isoladamente e a última confirmada abrindo o JS de produção servido pelo container real (`docker compose build --no-cache`) e conferindo que o literal `"/api/v1"` (não mais `"http://localhost:8080/api/v1"`) está no bundle.
|
||
|
||
---
|
||
|
||
## Fase 8 (parcial) — Portais Parceiros e Assinante replicam o shell autenticado do Core
|
||
|
||
Data: 2026-09-03. Subagente `eden-frontend` replicou o mesmo padrão de login/sessão/shell do `core-web` (leva anterior) para `apps/reseller-web` e `apps/subscriber-web` — mesma estrutura de arquivos, mesmo cliente HTTP, mesmo `AuthContext`, só trocando `applicationKey` (`reseller`/`subscriber`), título, e a chave de `localStorage` da sessão (`eden.reseller.session`/`eden.subscriber.session`, isolamento explícito mesmo já sendo origens/portas diferentes).
|
||
|
||
**Bug de integração (não do agente) encontrado e corrigido**: ao copiar `src/lib/` e `src/routes/` do worktree para `main`, o `cp -r` criou diretórios aninhados (`src/lib/lib/`, `src/routes/routes/`) porque os diretórios de destino já existiam vazios em `main` — mesmo padrão de erro já visto na integração do HR-Timeclock nesta sessão. Corrigido achatando a estrutura antes do typecheck.
|
||
|
||
Verificado: `pnpm turbo run typecheck build` (28/28) após a correção; containers `eden-parceiros`/`eden-assinante` reconstruídos — títulos corretos (`EDEN Parceiros`/`EDEN Assinante`), texto antigo "Bootstrap da Fase 1" ausente do bundle de produção, formulário de login real presente; login de ponta a ponta testado contra a API real com `applicationKey: "reseller"` e `"subscriber"` — ambos aceitos e retornam tokens válidos.
|
||
|
||
### Pendências
|
||
Idênticas às do `core-web` (rotação de refresh token, enroll de MFA na UI, telas de negócio) — nenhuma tela além do shell autenticado existe em nenhum dos 3 apps ainda.
|
||
|
||
---
|
||
|
||
## Fase 3 (parcial) — Fundação de Templates de Documento (sem Assinatura Eletrônica ainda)
|
||
|
||
Data: 2026-09-03. Subagente construiu a metade "templates + geração" do eden.md módulo 4 (§1.1-1.3) — a metade de Assinatura Eletrônica (§1.4-1.9: envelopes, signatários, OTP, cadeia de auditoria hash-chain, 9 tabelas satélite) foi deliberadamente deixada de fora, por ser criptograficamente/legalmente sensível e grande demais para dividir espaço com esta leva — fica para uma leva própria e dedicada.
|
||
|
||
`document_templates` + `document_template_versions` (ciclo `draft→publish`, invariante de no máximo 1 rascunho por template via índice único parcial, versão publicada é imutável) + `document_generations` (idempotente por `(template_version_id, origem)`, CHECK garantindo exatamente uma origem). Dependência circular (`document_templates.current_version_id` ↔ `document_template_versions`) resolvida com `ALTER TABLE` depois que as duas tabelas existem. Motor de merge de variáveis (`{{chave}}` → valor do contexto, nunca lança exceção, reporta variáveis ausentes) implementado como função pura testável — o parser Tiptap real e a renderização de PDF via Playwright/Chromium ficam para quando o editor visual e essa infra existirem (simplificação documentada: `editor_content.plainText` como stand-in).
|
||
|
||
Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api test` (30/30, sem regressão). Container `eden-api` reconstruído; testado ao vivo via curl: template criado → draft criado → segundo draft rejeitado (409, invariante confirmada) → draft editado com placeholders → publicado (`current_version_id` atualizado) → documento gerado com merge de variáveis funcionando (`{{empresa}}`/`{{cliente}}`/`{{valor}}` substituídos corretamente) → regeneração com os mesmos parâmetros reaproveita o registro existente (idempotência confirmada, mesmo `id`). Trilha de auditoria conferida no Postgres.
|
||
|
||
### Pendências
|
||
Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`.
|
||
|
||
---
|
||
|
||
## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas)
|
||
|
||
Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos.
|
||
|
||
**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto.
|
||
|
||
### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`)
|
||
|
||
CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table<T>` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro).
|
||
|
||
### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`)
|
||
|
||
Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`.
|
||
|
||
**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar.
|
||
|
||
### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`)
|
||
|
||
Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro).
|
||
|
||
Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo.
|
||
|
||
### Pendências
|
||
Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio).
|
||
|
||
---
|
||
|
||
## Proxy same-origin dentro do próprio nginx dos 3 frontends
|
||
|
||
Data: 2026-09-03. Usuário pediu para "configurar o proxy". Como a API não tem domínio público próprio (decisão do operador) e o proxy externo que termina SSL nos 3 domínios é gerenciado fora deste repositório (sem acesso daqui, e o guard de segurança do projeto bloqueia acesso a caminhos fora do projeto/`/etc`), a solução implementada foi dentro do que o repo já controla: `infra/docker/nginx-spa.conf` (usado pelos 3 containers de frontend) ganhou um `location /api/` que faz `proxy_pass` para `eden-api:8080` — os 2 containers já estão na mesma rede Docker (`eden_net`), então o roteamento é interno, sem tocar em nada externo. `resolver 127.0.0.11` (DNS embutido do Docker) usado com `proxy_pass` via variável para evitar o problema clássico de nginx cachear o IP do upstream para sempre.
|
||
|
||
Com isso, o browser batendo em `https://dev-edencore.falehandix.com.br/api/v1/...` (uma vez que o proxy externo do operador já roteia o domínio inteiro para a porta 3001, como já estava configurado) chega ao nginx do container, que repassa para a API interna — fluxo 100% same-origin do ponto de vista do browser, sem CORS.
|
||
|
||
Testado ao vivo: `POST /api/v1/auth/login` com credenciais reais funcionando através da porta 3001 (`eden-core`), 3002 (`eden-parceiros`) e 3003 (`eden-assinante`) — os 3 containers reconstruídos e confirmados.
|
||
|
||
---
|
||
|
||
## Cadastro de Cliente e Contratos no EDEN Core
|
||
|
||
Data: 2026-09-03. Continuação direta da leva anterior (sem parar para autorização, a pedido do usuário). 2 subagentes `eden-frontend` em paralelo, mesmo padrão de referência do tema DreamsERP (`customers.tsx`/`contacts.tsx` para Cadastro de Cliente; `salesOrders*.tsx` para Contratos — a mais próxima estruturalmente, já que "pedido de venda" no tema tem exatamente o mesmo shape de itens+partes+status+histórico de um Contrato).
|
||
|
||
### Cadastro de Cliente (`apps/core-web/src/routes/client-registrations/`)
|
||
|
||
Lista, criação (fluxo direto: tipo PF/PJ + portabilidade), e uma tela de detalhe rica com edição por seção (Status, Endereço, Contatos principal/financeiro/técnico, Dados PF ou PJ condicionalmente — CPF/CNPJ sempre somente-leitura, nunca editável por este endpoint —, Portabilidade quando aplicável) mais uma sub-seção de Sócios (só PJ: lista + adicionar + editar os 3 campos administrativos).
|
||
|
||
**Fechou a pendência da leva anterior**: `QuoteDetailPage` não pede mais para colar um UUID à mão para fechar negócio — agora é um `<select>` real populado por `GET /v1/client-registrations` (código + nome), com atalho para criar um cadastro novo em outra aba quando a lista está vazia.
|
||
|
||
### Contratos (`apps/core-web/src/routes/contracts/`)
|
||
|
||
Lista, criação (revenda/empresa opcionais, período, fidelidade reduzida opcional, itens produto+quantidade, partes contratada/contratante) e detalhe rico: resumo financeiro, aviso explícito quando `fidelity_period` está preenchido mas `fidelity_approved=false` ("sem efeito legal ainda"), tabela de itens, lista de partes, histórico de status, e botões de transição — **só os alvos válidos a partir do status atual** (mesma tabela de transições do backend replicada no frontend só para guiar a UI; o servidor segue sendo a única fonte de verdade, confirmado com 409 num teste de transição inválida). `QuoteDetailPage` ganhou um link "Criar contrato a partir desta oferta" quando `deal_status === "fechado"`, pré-preenchendo `quoteId`/`clientRegistrationId` via query string.
|
||
|
||
### Integração
|
||
|
||
Ambos os agentes fizeram fast-forward do próprio branch para `main` antes de começar (mesmo padrão da leva anterior), então os diffs vieram limpos direto sobre o commit mais recente. Único ponto de reconciliação manual: os dois (mais o de Cadastro de Cliente) tocaram `QuoteDetailPage.tsx`/`App.tsx`/`AuthenticatedLayout.tsx` — mesclados um de cada vez, sempre confirmando `typecheck`/`build` limpo entre cada mesclagem antes de aceitar a próxima.
|
||
|
||
Verificado: `pnpm turbo run typecheck build` (28/28); containers `eden-api`/`eden-core` reconstruídos; todo o fluxo testado ao vivo **através do proxy same-origin da porta 3001** (não direto na 8080, simulando exatamente o caminho real do browser): login, criação de cadastro de cliente, criação de contrato, e transição de status inválida corretamente rejeitada com 409.
|
||
|
||
### Pendências
|
||
`ContractFormPage` não tem edição pós-criação (só criação + transição de status); nenhuma tela ainda para Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents; `reseller-web`/`subscriber-web` seguem só com o shell de login.
|
||
|
||
---
|
||
|
||
## Estoque e Faturamento no EDEN Core
|
||
|
||
Data: 2026-09-03. Continuação direta, sem parar para autorização (a pedido do usuário). 2 subagentes `eden-frontend` em paralelo, referência do tema DreamsERP (`inventory/warehouse.tsx`/`inventoryList.tsx`/`manageStock.tsx` para Estoque; `sales/recurringInvoices*.tsx` para Faturamento — fatura recorrente no tema mapeia quase 1:1 para assinatura+fatura de Billing).
|
||
|
||
### Estoque (`apps/core-web/src/routes/inventory/`)
|
||
|
||
Armazéns (CRUD), Ativos (lista com ações contextuais por status — só mostra Reservar/Instalar/Devolver quando fazem sentido para o status atual, mesma disciplina de `ContractDetailPage`), formulário de entrada de ativo, detalhe com histórico de movimentos, ledger de movimentos (só leitura) e reservas genéricas.
|
||
|
||
**Achado real do próprio agente, documentado no código**: `installAsset`/`returnAsset` gravam o `stock_movements` com `reference_type: 'contract'`, não `'serialized_asset'` — então filtrar o ledger por `referenceId=<assetId>` nunca traz o histórico de instalação/devolução daquele ativo, só `purchase_in`/`reservation`. O agente contornou buscando também os movimentos do `current_contract_id` do ativo quando existe, e documentou que isso é uma lacuna real do backend (fora do escopo desta tarefa de frontend, registrado como pendência).
|
||
|
||
### Faturamento (`apps/core-web/src/routes/billing/`)
|
||
|
||
Assinaturas (criar a partir de um contrato, valor mensal pré-preenchido do contrato mas editável com aviso), Fechamentos (criar por ano/mês/dia de cobrança, aviso de idempotência), detalhe do fechamento com tabela de faturas + total e botão consolidar com confirmação em duas etapas, lista de faturas (só leitura, filtro por status/período/revenda).
|
||
|
||
### Integração
|
||
|
||
Um dos dois agentes restaurou a base manualmente (sem `git merge`) — comparação byte a byte (`diff -rq`) contra `main` confirmou zero desvio na restauração antes de aceitar qualquer coisa como "novo". `App.tsx`/`AuthenticatedLayout.tsx` reconciliados manualmente mais uma vez, confirmando `typecheck` limpo a cada mesclagem.
|
||
|
||
Verificado: `pnpm turbo run typecheck build` (28/28); containers `eden-api`/`eden-core` reconstruídos; testado ao vivo através do proxy same-origin (porta 3001): criação de armazém confirmada, trilha de auditoria conferida no Postgres. Os próprios agentes já haviam confirmado ao vivo, contra a API real, antes de entregar: invariante de serial duplicado (409), ciclo reservar→instalar→devolver, e o fluxo completo de billing (gerar→regenerar idempotente→consolidar→409 em nova tentativa).
|
||
|
||
### Pendências
|
||
Nenhuma tela ainda para Support/HR-Timeclock/Fiscal/Documentos; `reseller-web`/`subscriber-web` seguem só com o shell de login; a lacuna de `reference_type` no ledger de movimentos de instalação/devolução (achado acima) precisa de correção no backend numa leva futura.
|
||
|
||
---
|
||
|
||
## Suporte e Fiscal no EDEN Core
|
||
|
||
Data: 2026-09-04. Continuação direta, sem parar para autorização. 2 subagentes `eden-frontend` em paralelo, referência do tema DreamsERP (`support/tickets.tsx`/`ticketDetails.tsx`/`slaManagement.tsx` para Suporte; `inventory/categories.tsx`/`units.tsx`/`brands.tsx` para os catálogos fiscais — padrão "catálogo simples" quase idêntico).
|
||
|
||
### Suporte (`apps/core-web/src/routes/support/`)
|
||
|
||
Lista de tickets, criação, e detalhe rico: painel de SLA (prazos de primeira resposta/resolução, indicador cumprido/violado/pendente, aviso "SLA pausado" quando aplicável), thread de comentários públicos/internos com formulário de resposta, histórico de status (timeline), transições limitadas aos alvos válidos com campos condicionais de causa raiz/resolução ao fechar. Tradeoff documentado pelo próprio agente: a listagem não traz o `slaTimer` (só o detalhe traz), então o indicador de SLA vencido na lista busca o detalhe em background só para tickets não-terminais.
|
||
|
||
### Fiscal (`apps/core-web/src/routes/fiscal/`)
|
||
|
||
NCM/CFOP/Municípios (CRUD simples com formulário inline) e Naturezas de Operação (só leitura, as 11 seedadas). Nova seção "Fiscal" dentro de `ProductFormPage` (só ao editar um produto existente): histórico de perfis fiscais do produto com aviso claro de que criar um novo perfil do mesmo componente substitui automaticamente o anterior — confirmado ao vivo pelo próprio agente antes de entregar.
|
||
|
||
### Integração
|
||
|
||
Os dois agentes restauraram a base manualmente (sem `git merge`) — `diff -rq` contra `main` confirmou zero desvio de restauração nos dois casos antes de aceitar qualquer arquivo como novo. Reconciliação manual de `App.tsx`/`AuthenticatedLayout.tsx`/`ProductFormPage.tsx`/`format.ts` (este último ganhou `formatDateTimeBR`, puramente aditivo).
|
||
|
||
Verificado: `pnpm turbo run typecheck build` (28/28); containers `eden-api`/`eden-core` reconstruídos; testado ao vivo através do proxy same-origin (porta 3001): criação de ticket e listagem de naturezas de operação; trilha de auditoria conferida no Postgres.
|
||
|
||
### Pendências
|
||
Nenhuma tela ainda para HR-Timeclock/Documentos; `reseller-web`/`subscriber-web` seguem só com o shell de login. Com esta leva, o EDEN Core tem UI real para praticamente todo o backend construído até agora, exceto HR-Timeclock e Documentos.
|
||
|
||
---
|
||
|
||
## Documentos e Ponto Eletrônico no EDEN Core — cobertura completa do backend construído até agora
|
||
|
||
Data: 2026-09-04. Última leva desta sequência, sem parar para autorização. 2 subagentes `eden-frontend` em paralelo, referência do tema (`documents/documentRepository.tsx`/`documentWorkflows.tsx` para Documentos; `system/integrationsSettings.tsx` para dispositivos de ponto, `hrm/attendance.tsx` para registros AFD).
|
||
|
||
### Documentos (`apps/core-web/src/routes/documents/`)
|
||
|
||
Templates com ciclo draft→publish (edição por textarea de texto com placeholders `{{variavel}}`, já que o editor visual Tiptap não existe nesta leva de backend), preview do HTML publicado, geração de documento com extração automática das variáveis do template via regex para gerar um campo de formulário por variável, mostrando `missingVariables` quando aplicável.
|
||
|
||
**Achado real do próprio agente, corrigindo meu brief**: a regra de validação de `key` do template aceita minúsculas+dígitos+underscore, não só minúsculas+underscore como eu tinha descrito — corrigido no frontend antes de entregar, confirmado contra a mensagem de erro real do backend.
|
||
|
||
### Ponto Eletrônico — fundação (`apps/core-web/src/routes/hr-timeclock/`)
|
||
|
||
Dispositivos Control iD (CRUD, senha nunca retornada — só `hasCredential`, campo de senha vazio na edição significa "manter atual"), import de arquivo AFD (decodificação ISO-8859-1 explícita, não UTF-8, documentada no código — detalhe real e não óbvio), lista só-leitura de registros AFD (sem nenhuma ação de edição, imutabilidade legal respeitada na UI também).
|
||
|
||
**2 discrepâncias reais encontradas pelo próprio agente entre meu brief e o comportamento real da API, corrigidas antes de entregar**: reimportar o mesmo arquivo retorna **409** com corpo estruturado (`{message, alreadyImported, importId, status}`), não 200 como eu tinha descrito; `GET /hr-timeclock/afd-records` devolve um envelope paginado (`{records, total, limit, offset}`), não um array simples.
|
||
|
||
### Integração
|
||
|
||
Ambos os agentes reconciliados manualmente em `App.tsx`/`AuthenticatedLayout.tsx` (Documents entrou primeiro, HR-Timeclock por cima). Verificado: `pnpm turbo run typecheck build` (28/28); containers `eden-api`/`eden-core` reconstruídos; testado ao vivo através do proxy same-origin (porta 3001): criação de dispositivo confirmando ausência de senha na resposta, criação de template; trilha de auditoria conferida no Postgres.
|
||
|
||
### Estado do EDEN Core ao final desta sequência
|
||
|
||
UI real para: Empresas, Produtos (+ perfil fiscal), Revendas, CRM (Leads/Oportunidades), Ofertas, Cadastro de Cliente, Contratos, Estoque, Faturamento, Suporte, Fiscal, Documentos (fundação), Ponto Eletrônico (fundação). Cobertura completa de todo o backend construído nesta sessão, exceto Assinatura Eletrônica (deliberadamente não construída — módulo próprio, ainda não existe no backend) e apuração/fechamento de ponto (idem). `reseller-web`/`subscriber-web` seguem só com o shell de login — nenhuma tela de negócio própria ainda.
|
||
|
||
---
|
||
|
||
## E-mail transacional: convite de usuário + recuperação de senha
|
||
|
||
Data: 2026-09-04. Usuário perguntou onde configurar as credenciais de e-mail (`noreply@handix.com.br`) — confirmado que é via `.env` (`SMTP_HOST/PORT/USER/PASS/FROM`, já existiam desde a Fase 1, sem consumidor real). Pedido em seguida: ligar o envio de verdade e construir recuperação de senha na tela de login.
|
||
|
||
### Backend (`apps/api/src/auth/`, `apps/api/src/users/`) — implementado diretamente (fluxo de autenticação, não delegado)
|
||
|
||
- `@eden/integrations` adicionado como dependência real de `apps/api` (só existia como biblioteca solta até agora).
|
||
- `UsersService.invite()` agora envia e-mail de verdade com a senha temporária (mantendo o retorno na resposta da API também — dupla entrega, eden.md §4.8) via o adapter SMTP existente (`packages/integrations/src/smtp/mailer.ts`, já era fail-soft: nunca quebra o fluxo de convite se o SMTP falhar ou não estiver configurado).
|
||
- Novo fluxo de recuperação de senha: `POST /v1/auth/forgot-password` (`{email}` → sempre a mesma resposta genérica, cadastrado ou não — mesma disciplina anti-enumeração do login) e `POST /v1/auth/reset-password` (`{token, newPassword}`). Token de 256 bits, só o hash SHA-256 é persistido (`users.reset_token_hash`/`reset_token_expires_at`, colunas que já existiam desde a migration baseline da Fase 1 mas nunca tinham sido usadas), validade de 1h, uso único (limpo após consumido), e **revoga todas as sessões ativas do usuário** ao completar a redefinição (senha mudou → toda sessão anterior morre).
|
||
|
||
### Frontend (`apps/core-web`)
|
||
|
||
Link "Esqueci minha senha" no `LoginPage`, nova `ForgotPasswordPage` (mensagem genérica de sucesso, nunca confirma se o e-mail existe) e `ResetPasswordPage` (token vem da query string do link do e-mail, confirmação de senha, redireciona pro login com banner de sucesso ao concluir).
|
||
|
||
### Verificação
|
||
|
||
Teste de integração novo (`test/password-reset.spec.ts`, 3 testes) — usa `vi.mock` para interceptar `sendMail` e extrair o token real do corpo do e-mail (a única forma de testar o caminho feliz de ponta a ponta, já que o token nunca é devolvido na resposta da API, só por e-mail): confirma resposta genérica idêntica para e-mail cadastrado/não-cadastrado, token bogus rejeitado, e o ciclo completo — sessão anterior revogada, senha antiga para de funcionar, nova senha funciona, token não pode ser reaproveitado. Suíte completa: 33/33 verdes (30→33). `pnpm turbo run typecheck build` (28/28). Testado ao vivo através do proxy same-origin: anti-enumeração confirmada com curl real, token gravado no Postgres, token bogus rejeitado (400), telas novas confirmadas no bundle de produção.
|
||
|
||
### Pendências
|
||
SMTP ainda não configurado no `.env` real (aguardando credenciais do usuário) — o fluxo todo já funciona corretamente sem SMTP configurado (fail-soft), só não envia e-mail de verdade ainda. Sem rate-limiting no `forgot-password` (mesma lacuna geral já registrada para outros endpoints de auth).
|
||
|
||
---
|
||
|
||
## Renderização real de PDF (Chromium) — geração de Documentos ligada de ponta a ponta
|
||
|
||
Data: 2026-09-04. Usuário pediu para clonar o legado OrçaFácil (referência read-only em `.tmp/orcafacil-reference/`, fora do git — caminho pedido originalmente (`/opt/orcafacil`) foi bloqueado pelo guard-hook de segurança do projeto por ficar fora de `/opt/eden`; substituído por um local equivalente dentro do projeto em vez de contornar o guard) para estudar como o legado implementa edição de contrato, assinatura eletrônica e o PDF de oferta compatível com ANATEL. Achado principal (via subagente `fork`, sem poluir o contexto principal com código bruto): o legado usa Chromium real (Playwright) server-side só para os templates ricos de `document_templates`; o PDF de oferta ANATEL e o certificado de assinatura usam um pipeline client-side separado (`html2canvas`+`jsPDF`) — não recomendado para portar. Direção confirmada pelo usuário: unificar tudo em Chromium server-side, começando pela geração de Documentos (o "PDF do backoffice") que ainda não produzia PDF real nenhum.
|
||
|
||
### `packages/integrations/src/pdf/renderer.ts` (novo)
|
||
|
||
Port do `chromiumPdfRenderer.js` do legado, mesma disciplina de segurança/confiabilidade:
|
||
- SSRF impossível por construção: conteúdo sempre via `page.setContent()` (nunca `page.goto()`, não existe URL para o chamador controlar) + `page.route('**/*', route => route.abort())` como reforço — todo asset (imagem, fonte) precisa já vir como `data:` URI, não há fallback de rede.
|
||
- Browser único reaparoveitado entre chamadas, com relançamento automático se cair (`isConnected()` checado a cada uso).
|
||
- Semáforo de concorrência (`PDF_MAX_CONCURRENCY`, default 3) e timeout duro (`PDF_RENDER_TIMEOUT_SECONDS`, default 15s) — novas variáveis em `packages/config/src/env-schema.ts`.
|
||
- `--no-sandbox` no lançamento do Chromium (necessário rodando como root no container, sem `USER` no Dockerfile) — risco residual mitigado pelo bloqueio de rede acima (conteúdo é sempre nosso, nunca de terceiro).
|
||
- Gotcha documentado no código (igual ao legado): `@page { margin: 0 }` no CSS do próprio documento sobrescreve silenciosamente a opção `margin` passada para `page.pdf()`.
|
||
|
||
### Infraestrutura
|
||
|
||
- `infra/docker/Dockerfile.node`: novo passo condicional (`if [ "$APP_NAME" = "api" ]`) que roda `playwright install --with-deps chromium` só na imagem da API — o worker não renderiza PDF, fica sem o peso extra.
|
||
- `compose.yaml`: novo serviço `eden-minio` (imagem oficial `minio/minio` — a variante Bitnami parou de publicar `latest` gratuita, tentativa inicial falhou no pull) + `eden-minio-init` (one-shot `minio/mc` que cria o bucket via `mc mb --ignore-existing`, já que a imagem oficial não tem criação automática de bucket por env var). Mesmo racional do Redis já documentado no compose: container próprio de storage de objeto só entra agora porque a geração de PDF é o primeiro consumidor real (S3 existia como adapter desde a Fase 1, nunca tinha um caller). Credenciais MinIO geradas com `openssl rand` e adicionadas ao `.env` local.
|
||
|
||
### `apps/api/src/documents/` — geração agora produz PDF real
|
||
|
||
`DocumentsService.createGeneration()`: mescla variáveis no `html_content` da versão publicada (antes mesclava só o texto puro de `editor_content.plainText`, sem gerar PDF nenhum — `pdf_s3_key`/`pdf_sha256`/`pdf_size_bytes` sempre nulos), renderiza via `renderPdf`, calcula SHA-256, sobe pro S3 (`documents/generations/<uuid>.pdf`) e persiste os três campos. Checagem de idempotência (`findFinalGeneration`, exportado do repositório) roda ANTES de renderizar — uma geração deduplicada nunca aciona Chromium/S3 à toa. Novo endpoint `GET /v1/documents/generations/:id/pdf` (`StreamableFile`, `Content-Type: application/pdf`) para baixar o PDF gerado.
|
||
|
||
### Verificação
|
||
|
||
Teste de integração novo (`test/documents-pdf.spec.ts`, 3 testes, `@eden/integrations` mockado como no `password-reset.spec.ts` — evita depender de um Chromium/MinIO real rodando na suíte): confirma que a geração chama o renderer com a variável já resolvida, que o hash/tamanho persistidos batem com o buffer enviado ao S3, que uma geração duplicada não re-renderiza, e que o download stream a chave certa. Suíte completa: 36/36 verdes (33→36). `pnpm turbo run typecheck build --force` (28/28, forçado por precaução após editar `env-schema.ts`/pacotes compartilhados). Testado ao vivo através do container `eden-api` real (não só via proxy): Chromium instalado de fato na imagem (build ~4min, download de ~300MB), bucket criado no MinIO, geração de PDF de ponta a ponta via curl — SHA-256 do arquivo baixado bate byte a byte com o `pdf_sha256` gravado no Postgres.
|
||
|
||
### Pendências
|
||
PDF de Oferta/Orçamento compatível com ANATEL (Resolução 765/2023) ainda não construído — próximo item natural desta sequência, usando o mesmo `renderPdf`. Módulo de Assinatura Eletrônica completo (envelopes/signers/OTP/hash-chain) ainda não iniciado — item maior, sensível o suficiente para merecer leva própria dedicada, não apressada. Regras de negócio do legado em torno de edição/trava de Orçamento-como-Contrato (lock ao vincular `client_registration_id`, override só por `super_admin`) ainda não avaliadas contra o modelo de Contrato do EDEN (ADR-0006).
|
||
|
||
---
|
||
|
||
## Anexo de conformidade ANATEL/RGC no PDF de Oferta (Quotes)
|
||
|
||
Data: 2026-09-04. Continuação direta da leva anterior — usuário confirmou que a parte de conteúdo regulatório precisava ser exata, não aproximada de memória. Dois subagentes `fork` extraíram o conteúdo verbatim do legado antes de qualquer código ser escrito: (1) `BackofficeAnatelPDFTemplate.jsx` (texto legal, fórmulas computadas, ordem dos campos), (2) o schema real de `service_type`/`metered`/`tariff_rates`/`has_ldi` em `products` (baseline-schema.js).
|
||
|
||
### Decisão de escopo confirmada com o usuário
|
||
|
||
O anexo do legado depende de `service_type`/`metered`/`tariff_rates`/`has_ldi` por item — campos que a migration `1788438907074_products.cjs` **deliberadamente** excluiu ("legacy telecom-billing specifics que belong to a later phase"). Perguntei ao usuário como proceder (gerar conteúdo regulatório sobre uma base incompleta era um risco real de compliance) — escolheu **expandir o schema primeiro**, com fidelidade total ao legado incluindo a seção de tarifação STFC.
|
||
|
||
### Schema (`packages/database/migrations/1788525270647_products-service-type-tariff.cjs`)
|
||
|
||
`products` ganha `service_type` (CHECK nos 4 valores do legado: STFC/Serviço PABX/SVA (locação)/Serviço software), `metered`, `minutes_allowance`, `tariff_rates` (jsonb, formato `{TIPO: {normal, reduced}}`), `has_ldi`. `quotes` ganha `sales_rep_name` (impresso no bloco de assinatura). Novo pacote compartilhado `packages/domain-shared/src/telecom/service-types.ts` (catálogo de tipos, legendas de tarifação verbatim do legado, `formatCPF`/`formatCNPJ` adicionados a `br-documents.ts`) — consumido por `@eden/database`, `apps/api` e `apps/core-web`.
|
||
|
||
**Snapshot, não referência viva**: `QuotesService.buildItems()` copia `service_type`/`metered`/`minutesAllowance`/`tariffRates`/`hasLdi` do produto para o item no momento da criação da oferta (mesmo comportamento do legado, confirmado pelo subagente) — editar o produto depois nunca muda retroativamente a disclosure regulatória de uma oferta já emitida.
|
||
|
||
### `apps/api/src/quotes/anatel-offer-template.ts` (novo)
|
||
|
||
Gera o HTML completo do anexo — todos os campos do legado portados com fidelidade: Prazo de Comercialização (10 dias corridos a partir de `quote.updated_at`, rótulo exato do art. 3º XI, confirmado que NÃO é "Prazo de Oferta"), Prazo de Vigência/Permanência, Multa por Rescisão Antecipada (usa `computeSavings().beneficioTotalFidelidade` — `pricing.ts` já tinha essa fórmula pronta desde a leva de Quotes, nunca usada até agora), Critério/Data-base de Reajuste, tabela de itens agrupada por `service_type`, seção de Tarifação (LC/LDN/VC1/VC2/VC3/LDI, só renderiza para itens `metered`, legenda verbatim), condição especial, resumo financeiro, assinaturas. Placeholder `[A DEFINIR]` para CPF/CNPJ do cliente quando a oferta ainda não tem `client_registration_id` vinculado (nunca inventa um documento).
|
||
|
||
**Simplificação deliberada e documentada**: sem o split automático software/não-software do legado (`splitQuoteByServiceType.js`) — esta leva sempre gera um único PDF cobrindo todos os itens da oferta. Renderizado sempre ao vivo (nunca cacheado como os Documents), porque o conteúdo regulatório deriva de `updated_at` — um PDF salvo ficaria defasado assim que a oferta fosse editada de novo.
|
||
|
||
Novo endpoint `GET /v1/quotes/:id/anatel-pdf` — 400 se a oferta não tiver nenhum item STFC (o documento inteiro existe para essa disclosure, art. 21 §3º do RGC).
|
||
|
||
### Frontend
|
||
|
||
`ProductFormPage`: novo card "Regulatório (ANATEL/RGC)" — tipo de serviço, franquia de minutos, tarifado por minuto, LDI, tabela de tarifas (só aparece se "tarifado por minuto" marcado). `QuoteFormPage`: campo "Responsável comercial". `QuoteDetailPage`: botão "Baixar PDF ANATEL" (só aparece se a oferta tiver item STFC, mesma regra do backend) + campo "Responsável comercial" no resumo.
|
||
|
||
### Verificação
|
||
|
||
Teste de integração novo (`test/anatel-pdf.spec.ts`, 3 testes, `renderPdf` mockado como nos outros specs de PDF — o que está sob teste é o conteúdo regulatório computado, não o Chromium em si): confirma snapshot correto dos campos do produto no item, todos os campos regulatórios computados corretos no HTML (datas, fórmula da multa, tabela de tarifação, texto VoIP, campos fixos), e 400 para oferta sem item STFC. Suíte completa: 39/39 verdes (36→39). `pnpm turbo run typecheck build --force` (28/28).
|
||
|
||
---
|
||
|
||
## Trava de edição de Contrato — lacuna real entre ADR-0006 e a implementação
|
||
|
||
Data: 2026-09-04. Usuário pediu para avaliar as regras de edição/trava de Orçamento-como-Contrato do legado contra o modelo de Contrato do EDEN (ADR-0006) antes de seguir para Assinatura Eletrônica.
|
||
|
||
### Achado
|
||
|
||
`docs/adr/0006-contract-first-class.md` promete explicitamente: "fluxo de fechamento de oferta → geração de contrato preserva a trava equivalente [...] transição de estado do contrato que também impede edição desconforme, exceto por papel com permissão de correção auditada equivalente ao super_admin do legado". Essa trava **nunca foi implementada**: `ContractsService.addItem`/`addParty` não tinham nenhuma checagem de status ou papel — qualquer ator com a permissão genérica `contracts:edit` podia adicionar item/parte a um contrato em **qualquer** status, incluindo `cancelled`/`terminated`/`expired`, ou um `active` já assinado. A infraestrutura para a regra correta já existia (`resolveEffectiveFidelity()`, a máquina de estados `CONTRACT_STATUS_TRANSITIONS`) — só a trava de edição em si nunca foi conectada.
|
||
|
||
### Correção (`apps/api/src/contracts/contracts.service.ts`)
|
||
|
||
Novo `assertEditable()`, mesmo formato do `assertNotLocked` de Quotes: `status === 'draft'` é o único estado livre para adicionar item/parte; qualquer outro status bloqueia (409) para quem não é `super_admin`; `super_admin` pode seguir editando, mas o evento de auditoria fica marcado (`contract.item_added_override`/`contract.party_added_override`, com `overrode: true` no metadata) em vez de ser indistinguível de uma edição normal — a "correção auditada" que o ADR pedia.
|
||
|
||
Sem mudança de frontend: `ContractDetailPage` nunca chamou `addContractItem`/`addContractParty` (a função existe no client mas não é usada em nenhuma tela) — a lacuna era puramente de backend.
|
||
|
||
### Verificação
|
||
|
||
Teste de integração novo (`test/contracts-edit-lock.spec.ts`, 3 testes, papel throwaway com `contracts:edit`+`view` mas sem `super_admin` — mesmo padrão do `role-weight.spec.ts`): confirma que adicionar item funciona em `draft`, que um ator não-super_admin é bloqueado (409) assim que o contrato sai de `draft`, e que `super_admin` continua conseguindo (override). Suíte completa: 42/42 verdes (39→42). `pnpm turbo run typecheck build --force` (28/28).
|
||
|
||
### Pendências
|
||
`contract_versions` (snapshot-on-signature, invariante nº4 do ADR-0006) segue deliberadamente adiado — precisa do desenho do fluxo de assinatura para não virar uma tabela vazia. `contract_amendments` (forma correta de alterar um contrato já assinado, em vez do override de super_admin) também segue adiado — depende de `contract_versions` primeiro.
|
||
|
||
---
|
||
|
||
## Módulo de Assinatura Eletrônica — completo (envelopes, signatários, OTP, hash-chain, finalização, verificação pública)
|
||
|
||
Data: 2026-09-04. Usuário pediu para seguir com a "questão da assinatura" — última peça do estudo do OrçaFácil. Dado o tamanho e a sensibilidade jurídica/criptográfica do módulo, os algoritmos de segurança (hash-chain, OTP, máquina de estados) foram re-verificados contra o código-fonte real do legado por um subagente `fork` dedicado, cruzado com `eden.md` módulo 4 (que já documentava o módulo com grande precisão, escrito numa leva anterior) — nenhuma linha de crypto foi escrita de memória.
|
||
|
||
### Schema (`packages/database/migrations/1788536022387_signatures.cjs`)
|
||
|
||
5 tabelas: `signature_envelopes` (19 estados, `company_id` obrigatório — diferente de `contracts.company_id`, que é opcional — porque `createEnvelope` recusa criar sem representante legal cadastrado), `signature_signers` (snapshot, nunca referência viva), `signature_documents` (cópia congelada + SHA-256), `signature_otp_challenges`, `signature_audit_events` (hash-chain, append-only via a mesma GUC `eden.allow_audit_mutation` que já protege o `audit_log` genérico desde a Fase 1 — reaproveitada, não duplicada). `packages/database/src/hash-chain.ts` novo, compartilhado entre `audit_log` e `signature_audit_events` (a canonicalização recursiva já existia em `audit.ts`, só foi extraída).
|
||
|
||
**Três adaptações deliberadas ao EDEN, documentadas no cabeçalho da migration** (tudo o mais — os 19 estados, a fórmula do hash-chain, o algoritmo de OTP — é fidelidade exata ao legado):
|
||
1. `signature_documents.source_generation_id` referencia `document_generations` (o pipeline de PDF já construído nesta sessão) em vez do `source_attachment_id` do legado — EDEN não tem um módulo de upload de anexo genérico ainda.
|
||
2. `reseller_id` em vez de `reseller_registration_id` — EDEN modela revenda como uma tabela só.
|
||
3. Só existe a versão 2 (correta) da canonicalização do hash — a v1 do legado existe lá só para tolerar hashes já gravados antes de uma correção de bug real; EDEN nunca teve essa v1.
|
||
|
||
### Certificado de conclusão — server-side em vez de client-side
|
||
|
||
O legado desenha o certificado no navegador do backoffice (React → html2canvas → jsPDF) e recebe o PDF pronto via upload multipart em `finalizeEnvelope`. O EDEN já tem Chromium real server-side (construído para Documents/ANATEL) — `apps/api/src/signatures/certificate-template.ts` gera o HTML (conteúdo/texto legal portado verbatim, incluindo o aviso de que **não é certificação ICP-Brasil**) e renderiza no próprio servidor, eliminando o upload multipart inteiramente. Decisão deliberadamente limitada a essa troca técnica: os **dois passos autenticados** de finalização (`seal-checkpoint` depois `finalize`) foram mantidos exatamente como no legado — não virou automático mesmo sendo tecnicamente possível, porque esse checkpoint humano antes de uma finalização irreversível é comportamento de negócio, não um detalhe do desenho client-side do certificado.
|
||
|
||
### Fluxo (`apps/api/src/signatures/`)
|
||
|
||
`EnvelopeService` (criação — congela documentos com cópia S3 + SHA-256 recalculado, nunca confia cegamente no hash já gravado —, envio de convite, cancelamento, exclusão física só antes de SIGNED, verificação da cadeia), `PublicSignatureService` (fluxo do signatário só por token — nunca por `envelopeId`/`signerId` do corpo da requisição —, cada ação exige o estado anterior correto: confirmar leitura de cada documento → confirmar dados → aceitar termos → solicitar/validar OTP → assinar), `EnvelopeFinalizeService` (os dois passos), `PublicVerifyService` (só para `COMPLETED`, recomputa `envelope_hash` ao vivo e compara, nomes mascarados). Nota de design importante preservada do legado: o **envelope** salta direto de `SENT` para `SIGNED` quando o último signatário termina — o progresso fino (`VIEWED`..`OTP_VERIFIED`) vive inteiramente em `signature_signers.status`, nunca é espelhado passo a passo no envelope.
|
||
|
||
### Verificação
|
||
|
||
Suíte de testes nova e grande: `signature-hash-chain.spec.ts` (15 testes, puros, sem banco — canonicalização, determinismo do hash, máquina de estados, OTP, token) e `signature-envelope-flow.spec.ts` (13 testes, ciclo de vida completo via HTTP real: criação com signatário `contratada` automático, recusa sem representante legal, convite, os dois signatários passando pelo fluxo público inteiro incluindo código OTP errado/bloqueio/uso único, `SENT`→`SIGNED` no último signatário, cadeia de auditoria válida, `seal-checkpoint`, `finalize` com merge real via pdf-lib — confirmado contando páginas do PDF final —, `public-verify` com integridade válida e nomes mascarados, e proteção contra exclusão pós-assinatura). `renderPdf`/`uploadObject`/`getObjectStream`/`sendMail` mockados (S3 em memória); `mergePdfs`/`getPdfPageCount` NÃO mockados — o merge de PDF de verdade roda contra a saída (pequena, mas válida) do `renderPdf` mockado. Suíte completa: 70/70 verdes (42→70). `pnpm turbo run typecheck build --force` (28/28).
|
||
|
||
### Pendências
|
||
Rate limiting nas rotas públicas (por IP e por token, eden.md §6.8/§10.3) — lacuna conhecida, mesmo padrão de outros endpoints públicos deste projeto que também não têm ainda (ex. forgot-password). UI de frontend para todo o módulo (criação/envio/acompanhamento de envelope no backoffice, tela pública de assinatura do signatário, página pública de verificação) — não construída nesta leva, só o backend. `DECLINED` (recusa de assinatura) é um estado reservado no schema mas sem rota que o produza — mesma lacuna do legado, nunca foi implementado lá também.
|
||
|
||
---
|
||
|
||
## Rate limiting em todas as rotas públicas — lacuna fechada (eden-security)
|
||
|
||
Data: 2026-09-17. Essa lacuna estava registrada em pelo menos três lugares diferentes (`docs/security/threat-model.md` §2.1/§2.2, e as pendências de MFA/forgot-password/assinatura acima): nenhuma rota sem sessão tinha qualquer limite de requisições. Escopo: `POST /v1/auth/{login,mfa/challenge,forgot-password,reset-password,refresh,logout}` (`logout` não estava na lista original do pedido, mas também não tem `JwtAuthGuard` — incluído por completude), `GET /v1/public-signature/:token`, `GET /v1/public-signature/:token/documents/:documentId/pdf`, `POST /v1/public-signature/:token/{documents/:documentId/confirm-read,confirm-data,consent,otp/request,otp/verify,sign}` e `GET /v1/public-verify/:verificationId`.
|
||
|
||
### Decisão: guard/decorator próprio, não `@nestjs/throttler`
|
||
|
||
Sem Redis no ambiente (`compose.yaml`: "eden-redis (quando houver job real)" — ainda não existe) e API roda como instância única (ADR-0002), então em memória é a escolha certa nos dois casos — `@nestjs/throttler` teria servido igual de bem nesse ponto. A razão real de ter escrito um guard próprio (`apps/api/src/common/rate-limit/`) foi precisar de **chaves compostas por rota**: `public-signature/:token/*` precisa de um balde por IP (pega uma fonte varrendo/testando muitos tokens diferentes) *e* um balde por token (pega um link vazado sendo atacado por qualquer número de IPs — é exatamente o caso que `@nestjs/throttler` não cobre bem sem sobrepor múltiplos throttlers nomeados com trackers customizados por controller, o que na prática vira quase tanto código próprio quanto escrever o guard direto). `RateLimiterService` é um Map com janela fixa (não sliding window/token bucket — trade-off deliberado de simplicidade, documentado no próprio arquivo), `RateLimitGuard` lê metadata de `@RateLimit(...rules)` e aplica todas; sem decorator no handler, o guard é um no-op (por isso pôde ser plugado a nível de controller sem afetar rotas autenticadas do mesmo controller, ex. `mfa/enroll`).
|
||
|
||
### Limites
|
||
|
||
Todos em `apps/api/src/common/rate-limit/rate-limit-rules.ts`, cada um com a razão numa linha de comentário — resumo: `login`/`mfa/challenge`/`forgot-password`/`reset-password` a 10/min por IP (alvo de adivinhação de credencial — argon2/token de 256 bits já encarece o palpite, isso limita a *taxa*), `refresh`/`logout` a 30/min por IP (token de 256 bits, não é alvo de força bruta — limite mais alto porque sessão ativa com múltiplas abas atualiza sozinha com frequência), `public-signature/*` com um balde de IP compartilhado do controller inteiro (60/min) somado a um balde por token específico da ação (20/5min nas ações comuns, 5/15min em `otp/request` porque dispara e-mail de verdade, 15/5min em `otp/verify` — complementar ao lockout por tentativas erradas que já existe em `public-signature.service.ts::verifyOtp`, persistido no banco, esse sim já sobrevive a troca de IP), `public-verify` a 60/min por IP e 30/min por `verificationId`.
|
||
|
||
### O que NÃO foi resolvido (residual, registrado em `docs/security/threat-model.md`)
|
||
|
||
Um atacante trocando de IP lentamente (abaixo do limite por IP, em baixo volume, por muito tempo) não é pego por isto — não existe lockout de conta a nível de aplicação para login hoje, só o rate limit por IP. E `app.set('trust proxy', ...)` nunca foi configurado nesta API; se algum dia um reverse proxy/LB externo ficar na frente do `eden-api` em produção, `req.ip` vai reportar o IP do proxy para todo mundo e essa mitigação (e todo log de IP do projeto) fica cega — não configurei isso adivinhando a topologia futura, porque confiar em `X-Forwarded-For` sem saber exatamente quantos hops confiáveis existem abre uma forma de o próprio cliente spoofar seu IP.
|
||
|
||
### Verificação
|
||
`apps/api/test/rate-limit.spec.ts`: metade pura (sem banco/HTTP, no estilo de `signature-hash-chain.spec.ts`) provando que uma chave estoura exatamente no limite configurado, que chaves diferentes (duas IPs, ou dois tokens de assinatura) nunca competem pelo mesmo balde, e que a janela reseta (`vi.useFakeTimers`); metade HTTP real (app real, Postgres real) provando estouro de verdade em `POST /v1/auth/login` (11ª tentativa em 1 minuto vira 429 com `Retry-After`, não mais 401) e que esgotar o balde do login não afeta o balde de `forgot-password` na mesma requisição/IP. Suíte completa: 77/77 verdes (70→77). `pnpm turbo run typecheck build` 28/28. Testado ao vivo contra o container real `eden-api` (rebuild + restart): 10× 401 seguidos de 429 com corpo `{"statusCode":429,"message":"Muitas requisições. Tente novamente mais tarde.","retryAfterSeconds":60}` e header `Retry-After: 60`, log estruturado `WARN [RateLimitGuard] rate limit exceeded: rule=auth-login ...` no `docker logs eden-api`, e confirmado que `forgot-password` seguiu respondendo 200 no mesmo IP enquanto `login` estava bloqueado.
|
||
|
||
---
|
||
|
||
## UI de Assinatura Eletrônica (backoffice + páginas públicas) — segunda pendência do módulo fechada
|
||
|
||
Data: 2026-09-17. Continuação direta, sem parar para autorização — retomando as duas pendências deixadas pela leva anterior (rate limiting, ver entrada acima, e a UI completa do módulo). Antes de delegar o frontend, um gap real de backend foi encontrado e corrigido diretamente (não delegado, por ser rota nova/sensível): `PublicSignatureService.getPublicState` só devolvia metadados dos documentos (título, versão, contagem de páginas) — não existia NENHUMA forma de o signatário efetivamente ver o conteúdo do PDF antes de "confirmar leitura", nem do backoffice baixar o documento congelado ou o PDF final consolidado. Endpoints novos, mesmo padrão de streaming já usado em `documents.controller.ts` (`StreamableFile`, nunca URL assinada — eden.md linha ~4442):
|
||
- `GET /v1/signature-envelopes/:id/documents/:documentId/pdf` e `GET /v1/signature-envelopes/:id/final-pdf` (autenticados, escopo por revenda como o resto do módulo).
|
||
- `GET /v1/public-signature/:token/documents/:documentId/pdf` (público, autorização inteiramente pelo token — mesma checagem de posse do documento que `confirmDocumentRead` já usava).
|
||
|
||
Cobertos em `test/signature-envelope-flow.spec.ts` (72/72 antes da leva de rate limiting subir para 77/77). `pnpm turbo run typecheck build` 28/28.
|
||
|
||
### Frontend (agente `eden-frontend`, uma leva só — public+autenticado não foram paralelizados desta vez, para evitar a reconciliação manual de `App.tsx` que as levas anteriores precisaram fazer a cada par de agentes)
|
||
|
||
**Backoffice** (`apps/core-web/src/routes/signatures/`): lista de envelopes (enriquecida por chamada individual `GET /:id`, já que a listagem não traz signatários — mesmo idioma já usado em `TicketsListPage` para o SLA), criação (origem cliente XOR revenda, seleção de `document_generations` já `final`, form-array de signatários contratante — contratada nunca é pedida no formulário), detalhe com documentos/signatários/timeline de auditoria e todas as ações de transição de estado (enviar convite, cancelar, excluir, selar checkpoint, finalizar, baixar PDF final, verificar cadeia), gates de UI espelhando `ENVELOPE_TRANSITIONS` do repositório.
|
||
|
||
**Público** (`apps/core-web/src/routes/`, fora do `AuthenticatedLayout`, mesmo padrão de `/login`/`/forgot-password`): `PublicSignaturePage.tsx` em `/assinar/:token` (path literal que o backend já usa no e-mail de convite) — wizard guiado inteiramente por `signer.status`, sempre re-buscando o estado a cada ação para permitir retomar de onde parou se o signatário fechar o navegador no meio; `PublicVerifyPage.tsx` em `/verificar/:verificationId` (idem, path literal do e-mail de conclusão).
|
||
|
||
**Achado de infraestrutura do próprio agente**: nenhuma tela existente no projeto tinha um padrão pronto de "esconder ação por permissão" (grep não encontrou nenhum uso de `permissions` em rotas) — o agente adicionou `permissions` ao `AuthContext` (buscado de `/auth/me`) e um `hasPermission` mínimo, usado só para UX (esconder/desabilitar botão), nunca como controle de acesso — a decisão real continua inteiramente no backend.
|
||
|
||
**Divergências reais encontradas contra o brief, corrigidas antes de entregar**: `GET /signature-envelopes` não inclui signatários/documentos (resolvido enriquecendo cada linha); `verify-audit` pode retornar `failedAt`/`reason` além de `{valid, eventsChecked}` quando a cadeia está corrompida (UI trata); o contador de tentativas do OTP não é exposto pela API (UI usa mensagem genérica); `sendInvite` não valida o status do envelope no backend (UI esconde o botão em estados terminais por UX, sem depender disso para segurança).
|
||
|
||
### Verificação
|
||
|
||
`pnpm turbo run typecheck build` 28/28. Teste ao vivo de ponta a ponta contra `eden-api`/`eden-postgres`/`eden-minio` reais (o agente subiu um mail-catcher SMTP efêmero na rede docker para extrair tokens/OTP reais dos e-mails, revertido ao final — `.env` original confirmado intacto via `docker inspect`): empresa+representante legal → revenda → template→geração final → criar envelope → enviar convite → os dois signatários completando o fluxo público inteiro (ver PDF do documento, confirmar leitura, dados, consentimento, OTP errado então certo, assinar) → `SENT`→`SIGNED` automático no último signatário → seal-checkpoint → finalize (Chromium + merge de PDF reais) → `COMPLETED` → download do PDF final autenticado confirmado como PDF válido → `verify-audit` válido (38 eventos) → `public-verify` sem auth retornando o formato esperado com `integrityValid: true`. Depois containers `eden-api`/`eden-core` reconstruídos com o estado final combinado (endpoints de streaming + rate limiting + UI) e testados ao vivo: `/signatures`, `/signatures/new`, `/assinar/:token`, `/verificar/:verificationId` respondendo 200 via proxy same-origin (porta 3001), e o 11º login errado seguido confirmando 429 (rate limiting da leva anterior intacto após o rebuild combinado).
|
||
|
||
### Pendências
|
||
`DECLINED` (recusa de assinatura) segue sem rota que o produza — reservado no schema, mesma lacuna do legado. `contract_versions`/`contract_amendments` (ADR-0006, adiados desde a leva de trava de edição de contrato) agora estão desbloqueados — o fluxo de assinatura já existe — mas ainda não iniciados. `reseller-web`/`subscriber-web` seguem só com o shell de login — nenhuma tela própria, incluindo nada do módulo de assinatura (fora de escopo desta leva).
|
||
|
||
---
|
||
|
||
## Catálogos de Produto (Tipo/Grupo/Subgrupo/Marca/Unidade) — submenu, telas e dropdowns no cadastro de item
|
||
|
||
Data: 2026-09-17. Usuário pediu: "o menu produtos precisa ter sub menus, aonde cadastraremos os grupos subgrupos marcas e tipo e unidades e dentro do cadastro do item ai essas informações sera apenas dropdown dos cadastros feitos anteriormente." O próprio cabeçalho da migration original de `products` (Fase 2) já previa isso como trabalho futuro ("`product_type` is free text for now... tightening it to a CHECK/lookup table is future work"). Confirmado antes de começar: a tabela `products` só tinha 37 linhas, todas de teste/QA — backfill trivial, sem dado histórico real em risco.
|
||
|
||
### Backend (agente `eden-commercial`)
|
||
|
||
Migration `packages/database/migrations/1789694035000_product-catalogs.cjs`: 5 tabelas novas no formato "catálogo simples" (mesmo shape de `fiscal_ncm`/`fiscal_cfop`, sem os campos de sincronização externa — são catálogos 100% manuais) — `product_types`, `product_groups`, `product_subgroups` (FK `group_id → product_groups`, `ON DELETE RESTRICT`), `brands`, `units` (seedada com `'Unidade'`). `products` ganha `product_type_id`/`product_group_id`/`product_subgroup_id`/`brand_id`/`unit_id`, backfill a partir das colunas TEXT antigas (`product_type`/`unit` sempre presentes viram `NOT NULL`), colunas antigas dropadas. `down` reversível de verdade, testado (`migrate` → `migrate:down` → `migrate` de novo).
|
||
|
||
Endpoints novos (mesmo controller de `products`, padrão de agrupamento do `FiscalController`): `GET/POST /v1/products/{types,groups,brands,units}`, `PATCH .../:id`, `GET/POST /v1/products/subgroups` (`groupId` filtra a listagem, obrigatório no create), `PATCH /v1/products/subgroups/:id`. Leitura aberta a qualquer autenticado, escrita exige `products:edit` — mesma regra já documentada no `ProductsController`. Sem rota de exclusão física em nenhum dos 5 (mesmo padrão dos catálogos fiscais) — `active=false` é o "apagar". `GET /v1/products`/`GET /v1/products/:id` agora fazem JOIN e devolvem `*_name` resolvido ao lado de cada `*_id`. `CreateProductDto`/`UpdateProductDto` trocaram os campos de texto livre por `productTypeId`/`productGroupId`/`productSubgroupId`/`brandId`/`unitId` (UUID), com validação de existência e de consistência subgrupo↔grupo.
|
||
|
||
**Achado real corrigido diretamente por mim (não delegado)**: o agente de frontend, ao integrar, encontrou e reportou com precisão um buraco de integridade de dados que o `eden-commercial` tinha deixado passar — `PATCH /v1/products/:id` usa `COALESCE` para os campos opcionais, então não existia forma de expressar "limpar este campo" distinta de "não mexer nele" (ambos colapsavam no mesmo parâmetro `NULL` do SQL). Consequência real: editar um produto trocando o Grupo sem tocar no Subgrupo deixava o `product_subgroup_id` antigo órfão, agora inconsistente com o novo grupo, sem nenhuma validação pegando isso (a checagem de consistência só rodava quando `productSubgroupId` vinha no mesmo PATCH). Corrigido em `apps/api/src/products/products.service.ts`/`packages/database/src/repositories/products.ts`: `productSubgroupId: null` agora é uma instrução explícita de "remover" (nunca tenta buscar um subgrupo com id `null`), a validação de update passou a checar o estado **efetivo resultante** (valor do DTO onde informado, senão o valor atual do produto) — então trocar de grupo sem resolver o subgrupo agora é bloqueado com 400 claro em vez de corromper o vínculo silenciosamente — e uma função de repositório dedicada (`clearProductSubgroup`) contorna a limitação do `COALESCE` para esse campo especificamente. Frontend (`ProductFormPage.tsx`) ajustado para mandar `null` explícito (não `undefined`) quando o campo é limpo, e o aviso que o agente tinha colocado como paliativo foi removido — o problema está resolvido de verdade, não só sinalizado. Não estendi o mesmo tratamento a `productGroupId`/`brandId` (limitação de "não dá para limpar de volta pra vazio via PATCH" pré-existente e mais ampla no código, sem risco de invariante quebrada — fora de escopo).
|
||
|
||
3 testes novos em `apps/api/test/product-catalogs.spec.ts` cobrindo exatamente esse ciclo (bloqueio, limpeza explícita, troca de grupo bem-sucedida depois). Suíte completa: 95/95 verdes (92→95, incluindo os 15 do agente + os 3 meus).
|
||
|
||
### Frontend (agente `eden-frontend`)
|
||
|
||
`packages/ui/src/components/AppShell.tsx`: `AppShellNavItem` ganhou `children?` — item com filhos vira accordion (chevron, auto-expande quando a rota atual é de um filho), sem dependência nova. Verificado sem regressão nos 3 apps (`core-web`/`reseller-web`/`subscriber-web` usam o mesmo componente).
|
||
|
||
Menu "Produtos" ganhou submenu: Itens/Tipos/Grupos/Marcas/Unidades (Subgrupo deliberadamente sem entrada própria — sempre gerenciado a partir da tela de um Grupo específico, via painel "Ver subgrupos", já que subgrupo não existe fora do contexto de um grupo). 4 telas novas em `apps/core-web/src/routes/products/catalogs/` reaproveitando o padrão já usado em `fiscal/NcmListPage.tsx` (lista + formulário inline de criação + toggle ativo/inativo). `ProductFormPage.tsx`: Tipo/Unidade/Grupo/Subgrupo/Marca viraram dropdowns (Subgrupo desabilitado até escolher um Grupo, refaz fetch por `groupId` a cada troca, limpa a seleção se deixar de pertencer ao grupo novo — e agora manda `null` explícito nesse caso, fechando o ciclo com a correção de backend acima).
|
||
|
||
### Verificação
|
||
|
||
`pnpm turbo run typecheck build` 28/28 (verificado de forma independente por mim antes e depois da correção do bug de COALESCE). Suíte de testes 95/95. Testado ao vivo via `curl` contra `eden-api`/`eden-core` reais, através do proxy same-origin: criação de tipo/grupo/subgrupo/marca/unidade, criação de produto referenciando os 5, o 400 exato de "grupo incompatível" ao trocar de grupo sem resolver o subgrupo, `productSubgroupId: null` limpando de verdade (confirmado por re-GET), e a troca de grupo funcionando depois de limpo. Rotas `/products/types`, `/products/groups`, `/products/brands`, `/products/units` respondendo 200 pelo proxy (porta 3001). Linhas de teste (`qa-test-*`/`smoke-test-*`) desativadas nos 5 catálogos e no produto de teste ao final — estado ativo ficou limpo (`service`/`telecom` em Tipos, `Unidade` em Unidades, Grupos/Subgrupos/Marcas vazios, prontos para o usuário cadastrar os seus).
|
||
|
||
### Pendências
|
||
Suítes de teste futuras vão continuar acrescentando linhas `qa-test-*` permanentes nesses 5 catálogos (sem rota de exclusão física, mesmo padrão aceito para os catálogos fiscais) — precisa de desativação manual periódica até (se algum dia for justificado) existir uma rota de exclusão física ou um processo de limpeza dedicado. "Não dá para limpar Grupo/Marca de volta para vazio via PATCH" continua uma limitação pré-existente e mais ampla do padrão `COALESCE` usado em `updateProduct` — não corrigida (sem risco de invariante quebrada, ao contrário do caso do subgrupo).
|
||
|
||
---
|
||
|
||
## Submenu de Subgrupos dentro de Grupos — segundo nível de navegação
|
||
|
||
Data: 2026-09-18. Usuário pediu, sobre a leva anterior: "dentro da opção grupos tem que abrir um sub menu de sub grupos para que fique na mesma lógica" — ou seja, um segundo nível de submenu (Produtos > Grupos > Subgrupos), não só o painel "Ver subgrupos" já existente dentro da tela de Grupos. Implementado diretamente (mudança pequena e bem entendida, sem delegar).
|
||
|
||
`packages/ui/src/components/AppShell.tsx`: `NavGroup` passou a ser recursivo — um filho que também tem `children` agora renderiza como outro `NavGroup` aninhado (antes só 1 nível era suportado, documentado explicitamente como limitação). `withActive()` em `AuthenticatedLayout.tsx` já era recursivo (nenhuma mudança necessária ali) — só o item "Grupos" ganhou seu próprio `children`: `[Grupos (lista), Subgrupos]`.
|
||
|
||
Nova tela `apps/core-web/src/routes/products/catalogs/SubgroupsListPage.tsx` (`/products/subgroups`) — todos os subgrupos, de todos os grupos, com coluna "Grupo" e formulário de criação que pede o Grupo explicitamente (dropdown), diferente do `GroupSubgroupsPanel` já existente (que fica dentro da tela de um Grupo específico e por isso nunca precisa perguntar "qual grupo" — o contexto já implica). As duas telas coexistem deliberadamente: uma é a visão geral alcançável pelo menu, a outra é o atalho contextual ao editar um grupo específico.
|
||
|
||
Pequeno complemento de backend necessário para a coluna "Grupo" da tela nova: `listProductSubgroups` (`packages/database/src/repositories/product-catalogs.ts`) passou a fazer JOIN com `product_groups` e devolver `group_name` — antes só existia `group_id` cru, suficiente para o painel escopado (que já sabe o nome do grupo pelo contexto) mas não para a listagem geral.
|
||
|
||
### Verificação
|
||
`pnpm turbo run typecheck build` 28/28 (os 3 apps que usam `@eden/ui` — `core-web`/`reseller-web`/`subscriber-web` — confirmados sem regressão). Teste novo em `apps/api/test/product-catalogs.spec.ts` cobrindo `group_name` resolvido tanto na listagem filtrada por `groupId` quanto na geral. Suíte completa: 95/95 verdes. Testado ao vivo via `curl` contra `eden-api`/`eden-core` reconstruídos: rota `/products/subgroups` responde 200, criação de subgrupo segue funcionando, listagem geral resolve `group_name` corretamente. Interação de clique no accordion aninhado (abrir "Grupos" → ver "Subgrupos" → navegar) não foi possível verificar via browser automatizado (nenhuma ferramenta desse tipo disponível neste ambiente) — verificação limitada a build/tipos/API real, mesma limitação já registrada pelo agente de frontend na leva anterior.
|
||
|
||
---
|
||
|
||
## Rótulo "Nome do X" nos formulários de catálogo de Produto + verificação real em navegador
|
||
|
||
Data: 2026-09-18. Usuário pediu duas coisas na sequência: (1) verificar no navegador se a leva anterior (submenu de Subgrupos) está funcionando de verdade, e (2) trocar o rótulo genérico "Nome" por "Nome do subgrupo" no formulário de criação — e, logo em seguida, "usar a mesma lógica para o Grupo", interpretado (e confirmado fazendo) como aplicar o mesmo padrão aos 5 catálogos: "Nome do tipo"/"Nome do grupo"/"Nome do subgrupo"/"Nome da marca"/"Nome da unidade" (sem dois-pontos no final — nenhum outro rótulo do app usa, mantido consistente).
|
||
|
||
### Verificação real em navegador (não só API)
|
||
|
||
Nenhuma ferramenta de browser automation (`claude-in-chrome`) estava disponível nesta sessão. Em vez de reportar como não verificável (mesma limitação que os agentes de frontend já haviam registrado nas levas anteriores), usei o Playwright que já existe no monorepo (`packages/integrations` depende dele para renderização de PDF; o Chromium já estava em cache local de builds anteriores) para abrir o Chromium de verdade, logar como super_admin e navegar pelas telas reais. Isso capturou um bug real que a verificação só por API/build nunca pegaria: o menu aninhado Produtos → Grupos → Subgrupos realmente expande e navega corretamente (confirmado via dump do texto do DOM da sidebar), mas o rótulo do campo de criação continuava mostrando "Nome" em vez de "Nome do grupo"/"Nome do tipo"/etc — o prop `nameLabel` tinha sido adicionado à assinatura de `CatalogCreateForm.tsx` mas esquecido de usar de fato no JSX (`<Input label="Nome" .../>` continuava hardcoded). Corrigido, reconstruído, reverificado com o mesmo script Playwright — os 5 rótulos batem exatamente com o esperado.
|
||
|
||
### Mudança
|
||
|
||
`CatalogCreateForm.tsx`/`SimpleCatalogCard.tsx` ganharam o prop `nameLabel` (default `"Nome"`, sem quebrar nada que não o passe); os 5 pontos de uso (`ProductTypesListPage`, `ProductGroupsListPage`, `GroupSubgroupsPanel`, `BrandsListPage`, `UnitsListPage`) e o formulário manual de `SubgroupsListPage.tsx` passaram o rótulo específico de cada catálogo.
|
||
|
||
### Verificação
|
||
`pnpm turbo run typecheck build` 28/28. Suíte 95/95 (uma corrida isolada apresentou uma falha pontual num `afterAll` de outro spec depois de várias execuções seguidas da suíte hoje contra o mesmo Postgres compartilhado — não reproduziu nas execuções seguintes, tratado como ambiental, não investigado a fundo). Verificação em navegador real via Playwright headless: dump de DOM confirmando os 5 rótulos corretos em `/products/{types,groups,subgroups,brands,units}`, screenshots confirmando visualmente o submenu aninhado "Grupos" expandido mostrando "Grupos"/"Subgrupos", a tela de Subgrupos com dropdown "Grupo" + campo "Nome do subgrupo" + coluna "Grupo" na tabela, e o painel "Ver subgrupos" dentro de um grupo específico também com "Nome do subgrupo". Linhas de teste acumuladas nos 5 catálogos ao longo do dia (múltiplas rodadas de suíte + smoke tests) desativadas ao final — só sobraram duas linhas de subgrupo não reconhecidas como teste automatizado ("teste"/"qqweqw", possivelmente exploração manual do próprio usuário) deliberadamente preservadas, não mexidas.
|
||
|
||
---
|
||
|
||
## Menu "Cadastro" — agrupando Empresas/Produtos/Revendas/Clientes
|
||
|
||
Data: 2026-09-18. Usuário pediu para agrupar os cadastros de dados mestres num menu próprio: "vamos criar um menu cadastro, e dentro do menu cadastro vai estar o cadastro do produto com seus submenus, cadastro de revenda cadastro de clientes e cadastro da empresa". Também sinalizou que o próximo passo será ajustar a tela de Cadastro de Cliente (`client-registrations`) — ainda sem especificar o quê, fica para a próxima leva.
|
||
|
||
`AuthenticatedLayout.tsx`: novo item de nível 1 "Cadastro" (logo após "Painel") reunindo "Empresas", "Produtos" (com toda a árvore de submenus já existente — Itens/Tipos/Grupos{Grupos,Subgrupos}/Marcas/Unidades, agora um nível mais fundo), "Revendas" e "Clientes" — os 4 saíram do nível 1 do menu. `withActive()` já era recursivo (nenhuma mudança de lógica necessária); só a estrutura de dados (`NAV_ITEM_DEFS`) mudou.
|
||
|
||
Isso exercita profundidade 3 no `AppShell` (Cadastro > Produtos > Grupos > Subgrupos) pela primeira vez — o comentário do componente dizia explicitamente "depth beyond 2 levels is untested". Verificado ao vivo via Playwright que funciona corretamente (auto-expande só o caminho ativo em cada nível, sem abrir irmãos à toa) — comentário atualizado para refletir que a recursão é validada, não só "estruturalmente não impedida".
|
||
|
||
### Verificação
|
||
`pnpm turbo run typecheck build` 28/28. Suíte 95/95. Verificado ao vivo com Playwright headless (login real, dump de DOM da sidebar em `/companies`, `/products/groups`, `/products/subgroups`, `/resellers`, `/client-registrations`, e no painel): "Cadastro" some fechado quando nenhum filho está ativo, abre exatamente o caminho certo nos outros casos, screenshot conferindo visualmente a indentação dos 3 níveis.
|
||
|
||
### Pendências
|
||
Ajustes na tela de Cadastro de Cliente — usuário sinalizou que é o próximo passo, ainda sem especificar quais ajustes.
|
||
|
||
---
|
||
|
||
## Busca automática de CNPJ (dados da empresa, sócios, inscrição estadual) no Cadastro de Cliente
|
||
|
||
Data: 2026-09-18. O ajuste pedido na leva anterior era este: usuário apontou `/home/handix/cadastro.md` (fora do repo), documentando como o legado OrçaFácil consulta CNPJ pra pré-preencher razão social/endereço/inscrição estadual e a lista de sócios (pra usar depois na confecção do contrato/assinatura), via duas APIs públicas gratuitas (cnpj.ws principal — única com inscrição estadual —, BrasilAPI como fallback).
|
||
|
||
**Achado real antes de implementar**: `pj_cnpj` (e `pf_cpf`/`pj_rep_cpf`) **não tinham nenhuma forma de ser gravados** — `UpdateClientRegistrationDto` os exclui deliberadamente, com um comentário no próprio código: "Changing the document itself would need a dedicated re-verification flow — never a side effect of a generic admin PATCH." Era exatamente essa lacuna, já prevista mas nunca fechada, que esta feature resolve.
|
||
|
||
### Backend (agente `eden-commercial`)
|
||
|
||
`packages/integrations/src/cnpj-lookup/lookup.ts` (novo adapter, `fetch` nativo do Node 22, sem dependência nova): `lookupCnpj(cnpj)` — cnpj.ws primário, BrasilAPI fallback só em erro de rede/5xx (404 explícito do primário = "não existe", não cai pro fallback), timeout de 10s. Endpoint novo `POST /v1/client-registrations/:id/cnpj-lookup` — uma ação só, dedicada (sem endpoint de "preview" separado, mais fiel ao "ao perder o foco já preenche" do legado): o servidor SEMPRE refaz a própria consulta autoritativa a partir do CNPJ recebido (nunca confia num payload do cliente dizendo o que "já encontrou"), grava `pj_cnpj` + todos os campos PJ + endereço via uma função de repositório nova e dedicada (`applyCnpjLookup`, o único lugar que pode tocar `pj_cnpj` — `COALESCE` em tudo mais pra nunca apagar dado já preenchido com um campo vazio da API), cria sócios novos com dedup por `document` (rodar de novo não duplica), devolve `{registration, partners}`. 404 se o CNPJ não for encontrado em nenhuma fonte (nenhum efeito colateral). 400 se o cadastro for PF.
|
||
|
||
**Decisão registrada**: sócios vindos do lookup usam `source: 'cnpj_lookup'`, não o `'brasilapi'` hardcoded do legado — já que cnpj.ws é a fonte primária real nesta porta, o valor do legado seria uma atribuição enganosa.
|
||
|
||
Verificado: `pnpm turbo run typecheck build` 28/28, suíte 100/100 (95→100, 5 testes novos com `lookupCnpj` mockado, sem dependência de rede). Testado ao vivo contra internet real: Petrobras (`33000167000101`, 10 sócios) e Banco do Brasil (`00000000000191`, 96 sócios) — dados corretos, inscrição estadual resolvida corretamente (ativa por UF, fallback pra primeira do array se nenhuma ativa); reexecução no mesmo cadastro não duplicou sócios; CNPJ inexistente devolveu 404 sem alterar nada. Achado ao vivo (não no plano original): cnpj.ws também devolve 429 (rate limit da própria API pública) além dos 5xx documentados — tratado igual, cai pro fallback normalmente.
|
||
|
||
### Frontend (agente `eden-frontend`)
|
||
|
||
`ClientRegistrationDetailPage.tsx`: campo CNPJ (dentro de "Dados PJ") agora tem dois estados — sem `pj_cnpj` ainda: `Input` editável + botão "Buscar CNPJ" (desabilitado até 14 dígitos); já com `pj_cnpj`: valor formatado somente-leitura + botão "Buscar novamente" (permite atualizar situação cadastral/endereço/IE que podem mudar com o tempo, sem redigitar o CNPJ). Sucesso mostra quantos sócios ficaram no cadastro; 404 mostra a mensagem exata da API num `Alert` de aviso; a lista de Sócios (`PartnersSection.tsx`, componente independente) ganhou um prop `refreshKey` pra recarregar depois de um lookup bem-sucedido, sem precisar de F5.
|
||
|
||
### Verificação
|
||
`pnpm turbo run typecheck build` 28/28. Suíte 100/100. Testado ao vivo em navegador real via Playwright: cadastro PJ novo → CNPJ da Petrobras → título da página, endereço, todos os campos PJ e os 10 sócios preenchidos automaticamente na tela, confirmado por screenshot, sem reload manual. Cadastro de teste desativado ao final.
|
||
|
||
### Pendências
|
||
Lookup de CPF pra pessoa física — fora de escopo, não foi pedido. Refresh automático da situação cadastral/endereço (ex. um job periódico) não existe — "Buscar novamente" é manual, sob demanda.
|
||
|
||
---
|
||
|
||
## Reordenação do formulário de Cadastro de Cliente
|
||
|
||
Data: 2026-09-18. Usuário pediu: "primeiro vem os dados PJ depois vem os sócios depois endereço e depois contatos". Implementado diretamente (mudança de layout, sem lógica nova). Ordem final: Status (não mencionado, mantido primeiro) → Dados PF/PJ → Sócios (só PJ) → Endereço → Contatos → Portabilidade/botão de salvar (não mencionados, mantidos no final).
|
||
|
||
`ClientRegistrationDetailPage.tsx`: o `<form>` único virou dois — `PartnersSection` (Sócios) já renderiza seu próprio `<form>` interno (form aninhado é HTML inválido), então não dá pra intercalar sem quebrar essa regra. Os dois `<form>` chamam o mesmo `handleSubmit`, que sempre lê o estado React completo (`form`), nunca o DOM — testado ao vivo que salvar a partir do botão (agora depois de Contatos, no segundo `<form>`) grava corretamente campos da seção Status (primeiro `<form>`, antes de Sócios) junto com campos de Endereço (segundo `<form>`), confirmando que a divisão é só visual.
|
||
|
||
### Verificação
|
||
`pnpm turbo run typecheck build` 28/28. Suíte 100/100 (inalterada — mudança é só de frontend). Testado ao vivo em navegador real via Playwright: ordem das seções confirmada por leitura do DOM (`Status, Dados PJ, Sócios, Endereço, Contatos`) e por screenshot; ciclo completo com CNPJ real (Petrobras) mostrando os 10 sócios na nova posição; salvamento testado preenchendo um campo de cada `<form>` e confirmando após reload que os dois persistiram. Cadastro de teste desativado ao final.
|
||
|
||
---
|
||
|
||
## Access token: 15min → 8h
|
||
|
||
Data: 2026-09-18. Usuário pediu diretamente: "ajuste o tempo de acesso do token preciso que seja no mínimo 8 horas". Implementado diretamente (mudança pequena, uma constante).
|
||
|
||
`ACCESS_TOKEN_TTL_SECONDS` em `apps/api/src/auth/token.service.ts`: `15 * 60` → `8 * 60 * 60`. Achado ao investigar antes de mudar: o TTL curto original era uma decisão deliberada e documentada (ADR-0003, motivada pelo Master Prompt §5.3 — reduzir a janela de uso de um token vazado), mas o frontend nunca implementou refresh silencioso (`AuthContext.tsx`/`session.ts` documentam isso explicitamente: "No refresh-token rotation in this leva") — na prática, 15min derrubava o usuário no meio do trabalho, sem nenhum mecanismo automático de renovação. 8h é um paliativo real pro problema real, não só "afrouxar por afrouxar": documentado como tal num adendo na própria ADR-0003, com a mitigação que já existe (revogação de sessão continua granular, não depende do TTL do JWT) e o trabalho futuro real que resolveria isso sem abrir mão de segurança (refresh silencioso no frontend).
|
||
|
||
### Verificação
|
||
`pnpm turbo run typecheck build` 28/28. Suíte completa 106/106 (após a leva de Revenda abaixo, rodada em conjunto). Testado ao vivo: login real devolve `expiresIn: 28800` (exatamente 8h).
|
||
|
||
---
|
||
|
||
## Cadastro de Revenda — mesma expansão do Cadastro de Cliente (CNPJ, campos, reordenação)
|
||
|
||
Data: 2026-09-18. Usuário pediu pra aplicar em Revenda a mesma coisa feita em Cliente: "fazer a busca automática de cnpj e expandir campos de cadastro e reordenar os campos". A tabela `resellers` até agora era minimalista (`name`/`document`/`contact_email`/`active`, sem endereço, sem sócios, sem os campos PJ que `client_registrations` já tinha).
|
||
|
||
### Backend (agente `eden-commercial`)
|
||
|
||
Migration `packages/database/migrations/1789700000000_resellers-expansion.cjs`: `resellers` ganha `trade_name`, `state_registration`, `municipal_registration`, `opening_date`, `legal_nature`, `cnpj_situation`, `main_activity`, `address_*`, `contact_principal/financial/technical_*` — mesmo shape de `client_registrations`, sem o prefixo `pj_` (não existe ambiguidade PF/PJ aqui — decisão deliberada: revenda não ganhou um `person_type`, é sempre tratada como PJ). Tabela nova `reseller_partners`, mesmo shape de `client_registration_partners`.
|
||
|
||
Endpoints novos em `resellers.controller.ts`: `GET/POST /v1/resellers/:id/partners`, `PATCH /v1/resellers/:id/partners/:partnerId`, e `POST /v1/resellers/:id/cnpj-lookup` — **reaproveitando o adapter `lookupCnpj` já existente** (`@eden/integrations`, construído pra Cliente), mesma mecânica exata (servidor sempre reconsulta a fonte autoritativa, `COALESCE` em tudo que a API pode não trazer, dedup de sócios por `document`, `source: 'cnpj_lookup'`).
|
||
|
||
**Decisão deliberada, diferente de Cliente**: `document` continua livremente editável via `PATCH` genérico — ao contrário de `pj_cnpj` em `client_registrations` (que é bloqueado de propósito, só a ação dedicada de lookup grava). Aqui a busca de CNPJ é só uma conveniência adicional, não uma trava nova.
|
||
|
||
### Verificação
|
||
`pnpm db:migrate`/`down`/`migrate` de novo — limpo. `pnpm turbo run typecheck build` 28/28. Suíte 106/106 (100→106, 6 testes novos). Testado ao vivo contra CNPJ real (Petrobras): nome/nome fantasia/inscrição estadual/endereço/10 sócios corretos, reexecução sem duplicar, `PATCH` manual em `document` confirmado ainda funcionando depois do lookup.
|
||
|
||
### Pendências
|
||
UI ainda não construída — só o backend nesta leva (frontend é o próximo passo natural, mesmo padrão de Cliente: Dados PJ → Sócios → Endereço → Contatos, já direto na ordem certa desta vez, sem precisar de uma segunda leva de reordenação).
|
||
|
||
---
|
||
|
||
## UI de Cadastro de Revenda — CNPJ, sócios, endereço, contatos
|
||
|
||
Data: 2026-09-18. Frontend da leva anterior (agente `eden-frontend`), construído direto na ordem pedida: Dados da revenda (com busca de CNPJ) → Sócios → Endereço → Contatos → Status.
|
||
|
||
`ResellerFormPage.tsx` reescrita: ao criar uma revenda nova (sem `id` ainda), continua só com os campos mínimos (Nome/Documento/E-mail/Ativo) — CNPJ-lookup e Sócios precisam de um `id` existente. Diferença de UX deliberada em relação a Cliente: depois de criar, o usuário é redirecionado direto pra `/resellers/:id/edit` (antes voltava pra lista) — senão não teria como usar as novidades sem um clique extra. `PartnersSection.tsx` foi generalizado (`apps/core-web/src/components/PartnersSection.tsx`, funções de API via prop) em vez de duplicado — a versão de Cliente ficou como estava, pronta pra migrar depois se fizer sentido, sem necessidade agora.
|
||
|
||
**Bug real encontrado e corrigido na verificação ao vivo (por mim, não pelo agente)**: o rótulo do botão ("Buscar CNPJ" vs. "Buscar novamente") estava amarrado à contagem de dígitos do campo `document` sendo DIGITADO (`form.document`, ainda não salvo) em vez de ao valor já persistido no servidor — resultado: digitar um CNPJ válido já trocava o botão para "Buscar novamente" antes de qualquer busca real ter acontecido, uma mensagem enganosa (funcionalmente a busca ainda funcionava, mas o texto mentia sobre o estado). Corrigido com um novo estado `savedDocument` (preenchido só a partir do que o servidor confirma — ao carregar, ao salvar manualmente, ao concluir uma busca), o rótulo agora reflete a persistência real, não a digitação.
|
||
|
||
### Verificação
|
||
`pnpm turbo run typecheck build` 28/28 (antes e depois da correção). Suíte 106/106 (inalterada, mudança de frontend). Testado ao vivo em navegador real via Playwright, incluindo o cenário exato do bug (digitar 14 dígitos antes de qualquer busca — botão permanece "Buscar CNPJ" corretamente agora) e o ciclo completo com CNPJ real (Petrobras, nome fantasia e sócio confirmados). Revendas de teste desativadas ao final.
|
||
|
||
---
|
||
|
||
## Representante legal da Revenda — preparação pra contrato com revenda
|
||
|
||
Data: 2026-09-18. Usuário: "o cadastro da revenda tem que ser completo pq depois vamos usar esses dados para fazer o contrato com a revenda tb por dentro do erp". Implementado diretamente (mudança pequena e bem entendida, mesmo padrão já estabelecido).
|
||
|
||
Investigando antes de mudar: o módulo de Assinatura Eletrônica já aceita `resellerId` como origem de um envelope (`CreateEnvelopeDto`, alternativa a `clientRegistrationId`), mas os `contratante.signers` de um envelope hoje vêm sempre digitados manualmente no corpo da requisição — não existe ainda nenhuma derivação automática a partir de `client_registration_partners`/`reseller_partners` (achado que vale registrar para quando essa integração for construída de verdade, não resolvido aqui — fora do pedido desta leva). O que faltava de concreto e imediato: `resellers`, ao contrário de `client_registrations`, não tinha um bloco de **representante legal** dedicado (`pj_rep_*` em client_registrations) — só a lista geral de sócios. Um contrato precisa de um signatário nomeado e autorizado, não só uma lista de sócios.
|
||
|
||
Migration `packages/database/migrations/1789700100000_resellers-legal-representative.cjs`: `resellers` ganha `rep_full_name`, `rep_cpf`, `rep_role`, `rep_email`, `rep_mobile_phone`, `rep_authorization_ack` — mesmo shape de `client_registrations.pj_rep_*`. **Divergência deliberada**: lá `pj_rep_cpf` é estruturalmente bloqueado do PATCH genérico (só seria setável pelo fluxo de submissão pública original do legado, que o EDEN ainda não construiu — hoje é uma lacuna conhecida, o campo nunca é preenchido de fato). Revenda não tem esse fluxo de submissão pública (é sempre cadastro de backoffice), então não existe "momento de submissão original" pra travar atrás — `rep_cpf` aqui é um campo comum, livremente editável via `PATCH`, sem repetir uma trava que só faria sentido lá.
|
||
|
||
Backend (`CreateResellerDto`, `resellers.service.ts`, repositório) e frontend (`ResellerFormPage.tsx`, novo card "Representante legal" entre "Dados da revenda" e "Sócios") seguindo o mesmo padrão já usado no resto do módulo.
|
||
|
||
### Verificação
|
||
`pnpm db:migrate`/`down`/`migrate` de novo — limpo. `pnpm turbo run typecheck build` 28/28. Suíte 107/107 (106→107, 1 teste novo cobrindo set+update dos campos). Testado ao vivo em navegador real: ordem confirmada (`Dados da revenda → Representante legal → Sócios → Endereço → Contatos → Status`), Nome/CPF do representante preenchidos e confirmados persistentes após reload da página. Revenda de teste desativada ao final.
|
||
|
||
### Pendências
|
||
A derivação automática de signatários de contrato a partir de `client_registration_partners`/`reseller_partners`/representante legal (pra realmente "usar na confecção do contrato") ainda não existe — hoje `CreateEnvelopeDto.signers` continua sendo preenchido manualmente independente da origem. Esse é o trabalho real que fecha o ciclo que o usuário descreveu; os dados já ficam prontos e completos esperando por ele.
|
||
|
||
---
|
||
|
||
## Derivação automática de sócios/representante na criação do envelope de assinatura
|
||
|
||
Data: 2026-09-18. Fecha exatamente a pendência registrada acima. Usuário: "vamos puxar sócios/representante automaticamente na criação do envelope de assinatura". Implementado diretamente (módulo de assinatura é sensível o suficiente pra não delegar, mesmo padrão já seguido desde a leva original do módulo).
|
||
|
||
`CreateEnvelopeDto.signers` passou de obrigatório pra opcional. `EnvelopeService.create()`: se `signers` vier vazio/omitido, deriva automaticamente a partir da origem do envelope (`clientRegistrationId` XOR `resellerId`, já validado):
|
||
- **Cliente PF**: o próprio titular (`pf_full_name`/`pf_cpf`/`pf_email`/`pf_mobile_phone`) — não tem sócios nem representante, é a única pessoa possível.
|
||
- **Cliente PJ**: o representante legal (`pj_rep_*`) se `pj_rep_authorization_ack = true`, mais todo sócio de `client_registration_partners` marcado `will_sign = true`.
|
||
- **Revenda**: mesma lógica, `rep_*`/`reseller_partners`.
|
||
- **Dedup por CPF**: se o representante também aparecer como sócio com o mesmo CPF (possível em revenda, onde `rep_cpf` é editável — não em cliente, onde `pj_rep_cpf` nunca é setável, lacuna pré-existente já registrada antes), conta uma vez só.
|
||
- **Nunca falha silenciosamente**: sócio marcado `will_sign` sem `signature_email` cadastrado → 400 nomeando o sócio, pedindo pra completar o cadastro; nada disponível pra derivar (sem representante autorizado, sem sócio marcado) → 400 claro. `signers` explícito no corpo da requisição continua funcionando exatamente como antes — override manual completo, ignora a derivação inteiramente.
|
||
|
||
### Verificação
|
||
`pnpm turbo run typecheck build` 28/28. Suíte 113/113 (107→113, `test/signature-envelope-auto-signers.spec.ts` novo — 6 testes cobrindo PJ com rep+sócio, PF com titular, dedup por CPF em revenda, os dois casos de 400, e o override manual). Testado ao vivo contra a API real: criar revenda com representante autorizado + criar envelope sem informar `signers` → o `contratante` do envelope saiu preenchido automaticamente com os dados do representante, confirmado via `curl`.
|
||
|
||
### Achado adicional, resolvido no caminho (não pedido, mas real)
|
||
Usuário reportou "testei o cadastro de revenda e continua só com nome/documento/e-mail" — investigado ao vivo: a tela de edição de uma revenda existente já mostrava todas as seções corretamente (confirmado abrindo a revenda real "Handix" pela lista). O que ele via era a tela de **criação** (`/resellers/new`), que por design mesmo só mostra os 3 campos mínimos até a revenda ser salva (CNPJ/sócios/representante precisam de um ID existente) — mas sem nenhuma explicação disso na tela, parecendo um cadastro incompleto. Adicionado um aviso claro no formulário de criação: "Salve com o nome pra continuar o cadastro — busca de CNPJ, representante legal, sócios, endereço e contatos ficam disponíveis logo em seguida, na tela de edição desta revenda." Confirmado ao vivo via screenshot.
|
||
|
||
---
|
||
|
||
## Nova oferta — catálogo navegável + resumo ao vivo, no padrão do legado OrçaFácil
|
||
|
||
Data: 2026-09-18. Usuário mandou uma captura de tela de como o legado montava essa tela (produtos navegáveis à esquerda, resumo se montando ao lado conforme adicionava) e um link de uma página de exemplo do tema DreamsERP como "só uma ideia" — na prática essa página do tema acabou sendo só inspiração de acabamento solta (é uma tabela de fatura genérica, não tem o padrão de catálogo+carrinho do legado). Pedido: "pode fazer mais legal".
|
||
|
||
`QuoteFormPage.tsx` (agente `eden-frontend`): a seção de itens, que era uma tabela chata de linhas com dropdown de produto (sem preço visível, sem busca, sem total em tempo real), virou duas colunas — **"Adicionar Produtos"** à esquerda (busca por código/nome, ordenar por Código/Nome, lista rolável com badge de código, preço da faixa do período de contrato selecionado, tag "plano Nm", clique adiciona ao carrinho) e **"Resumo da Oferta"** à direita, sticky (cartão por item com stepper de quantidade e remover, breakdown de implantação por item, os campos que já existiam — implantação geral, condição de pagamento, condição especial/valor mensal final, observações — todos migrados pra dentro deste painel, e totais calculados ao vivo com "Total do Contrato" em destaque). Fórmula do preview espelha `apps/api/src/quotes/pricing.ts::computeQuotePricing` exatamente — mesma disciplina já documentada no próprio arquivo desde que foi criado: todo total mostrado aqui é ilustrativo, o servidor recalcula tudo ao salvar, nada muda no backend.
|
||
|
||
**Achado real do próprio agente**: produtos sem preço cadastrado pra faixa do período de contrato selecionado (existem de verdade nos dados seedados) precisavam de tratamento — aparecem na lista como "indisponível p/ este período", não clicáveis, em vez de quebrar ou mostrar "R$ null/mês".
|
||
|
||
**Limpeza feita por mim depois**: a lista de produtos navegável tornou a poluição de dados de teste (`qa-test-*`, acumulada a sessão inteira nas suítes de `product-catalogs`) muito mais visível do que era como dropdown — 173 de 177 produtos ativos eram lixo de teste. Desativados (mesma disciplina de higiene já seguida o resto da sessão), junto com uma nova rodada de limpeza nos catálogos de Tipo/Grupo/Subgrupo/Marca/Unidade (mais rodadas de suíte desde a última limpeza tinham acumulado mais linhas). Sobrou 1 produto real ativo ("Link Dedicado 100Mbps").
|
||
|
||
### Verificação
|
||
`pnpm turbo run typecheck build` 28/28. Suíte 113/113 (inalterada — mudança é só de frontend). Testado ao vivo pelo agente: busca/ordenação filtrando corretamente, período de contrato trocando o preço mostrado e o badge de plano, stepper incrementando/decrementando (desabilita em 1), totais batendo à mão com a fórmula pra vários produtos/quantidades, duas ofertas completas criadas de ponta a ponta com o total final batendo exatamente com o que o servidor calculou (uma delas disparando aprovação por desconto, confirmado o banner "aguarda aprovação"). Confirmado por mim depois, com screenshot, com o catálogo já limpo.
|
||
|
||
### Achado não resolvido (fora de escopo, registrado pelo agente)
|
||
Na tela de sucesso pós-criação, o campo "Implantação total" mostra só `implementation_fee` (implantação dos produtos), sem somar `impl_geral` — quando esse campo é preenchido, o rótulo sub-representa o valor que efetivamente compõe o "Total do contrato". Não é um bug introduzido por esta leva, é pré-existente; não corrigido por estar fora do escopo pedido (só a tela de montagem da oferta).
|