Implementação MVP

This commit is contained in:
camilafagundes committed 2026-10-03 00:00:45 -03:00
commit 807b3d72cd
54 files changed
+5241

No files matched your search

@@ -0,0 +1,63 @@
---
description: Regras obrigatórias para qualquer alteração no Gerador de Propostas EloPro
alwaysApply: true
---
# EloPro — Gerador de Propostas — Regras do agente
Antes de modificar código, leia `docs/gerador-propostas/SOURCE_OF_TRUTH.md` se existir.
## Não negociável
1. Não invente regra de negócio para preencher lacunas P-001..P-012.
2. Não confie no frontend para autorização. Revalide workspace, role, ownership, entitlement, estado e limites no servidor.
3. Toda tabela de domínio deve ser tenant-safe por `workspace_id` direto ou derivável por FK segura + RLS.
4. Nunca aceitar `workspace_id` do cliente como fonte de autoridade. Resolva-o da sessão/contexto server-side.
5. Usuário `agent` opera apenas próprias propostas; Gestor/Admin podem operar escopo do workspace conforme matriz.
6. Usuário Final nunca acessa Modelos, inclusive via rota direta/Server Action.
7. Versões formais de proposta e versões ativadas de modelo são imutáveis no fluxo normal.
8. Falha de geração não pode criar número comercial, versão formal ou PDF formal parcial.
9. Geração deve ser idempotente contra duplo clique, retry e reload.
10. Lock funcional é de 10 minutos; não implementar force takeover no MVP.
11. O fluxo 4.2 é uma experiência única com estados Preencher → Validar → Gerar.
12. Abrir 4.2 não cria rascunho vazio. O rascunho nasce no primeiro persist aplicável.
13. Apenas Modelo Ativo pode iniciar nova proposta.
14. Limite de modelos = 10 por workspace, contando draft/active/archived, mas não versões.
15. Marcadores são `[campo]`; não inferir campos com IA/heurstica.
16. IA não pode substituir validação determinística de marcadores/regras.
17. PDF/email/download devem trabalhar com a versão formal explicitamente exibida.
18. Sentry/logs nunca devem receber documento integral, template integral ou PII desnecessária.
19. Não introduza ORM, auth provider, query library, PDF engine, e-mail provider ou arquitetura paralela sem ADR/aprovação quando a stack já tem decisão.
20. Não usar Lucide como “biblioteca oficial da marca”; é fallback quando não houver ícone EloPro.
## Engenharia
- Next.js 16 App Router + React 19.
- Preferir Server Components para leitura.
- Client Components somente quando interação/estado local exigir.
- Server Actions para comandos autenticados da UI; Route Handlers para jobs/callbacks/integrações.
- TanStack Query somente onde cache/refetch client-side trouxer valor real; não duplicar o cache do App Router.
- TypeScript strict e tipos explícitos em fronteiras de domínio.
- Vitest para regras de domínio e helpers críticos.
- Migrations PostgreSQL versionadas; constraints e RLS fazem parte da feature, não são pós-trabalho.
- Redis é efêmero; PostgreSQL é fonte de verdade de estados formais.
## UX/UI
- EloPro V2.1: Cloud White/Midnight Ink/Azul+Aqua; Manrope; superfícies claras predominantes.
- Não criar dark page dominante, neon, cyberpunk, glassmorphism, hologramas, robôs/cérebros futuristas.
- Erro/status nunca somente por cor.
- Reutilizar componentes EloPro existentes; lacunas do DS viram componentes locais do domínio, não globais automaticamente.
- Mobile deve ser adaptado, não encolhido.
- Todas as ações destrutivas precisam de confirmação contextual e foco acessível.
## Saída obrigatória ao terminar uma tarefa
Responda com:
1. resumo objetivo;
2. arquivos criados/alterados;
3. migrations/policies/constraints afetadas;
4. testes adicionados e resultados;
5. comandos de validação executados;
6. riscos/pendências;
7. qualquer decisão que dependa de humano/ADR.
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
*.tsbuildinfo
+146
View File
@@ -0,0 +1,146 @@
# Arquitetura — Gerador de Propostas
Estado: prompt 01. Baseline oficial: repositório standalone, sem código EloPro legado. Decisão em `adr/ADR-000-standalone-application.md`.
Não há feature de produto nesta fase: nem tela, nem migration, nem upload, nem PDF real, nem e-mail real.
## 1. Contexto
O Gerador de Propostas é um produto EloPro independente. O usuário autenticado opera propostas e, conforme o papel, modelos, dentro de um workspace. CRO, Contratos e Apresentação não são pré-requisito do fluxo.
```text
Pessoa de comercial / Admin / Gestor
|
v
Gerador de Propostas (este repositório)
|
+--> PostgreSQL (Supabase) estados formais, RLS
+--> Redis lock, claim de geração, job efêmero
+--> Storage privado arquivo de modelo e PDF formal
+--> Sentry erro, com scrub
+--> Provider de e-mail port; provider não escolhido (P-011)
+--> Motor de ingestão port; motor não escolhido (P-010)
+--> CRO port opcional; fonte de cliente aberta (P-002)
```
Auth, workspace, papéis e RLS nascem neste repositório. A UI usa os tokens EloPro V2.1 definidos na source of truth, em componentes locais, a partir do prompt 04.
## 2. Contêineres
| Contêiner | Papel nesta fase | Fonte de verdade |
| --- | --- | --- |
| Next.js 16 App Router | Ainda não criado. Entra no prompt 04. | — |
| PostgreSQL via Supabase | Ainda sem migration. Estados formais: modelo, proposta, versão, auditoria, e-mail. | Sim, quando existir |
| Redis (`ioredis`) | Ainda sem cliente. Lock de edição, claim de geração, estado efêmero de job. | Não |
| Storage | Ainda sem bucket. | O ponteiro formal fica no PostgreSQL |
| Sentry | Ainda sem SDK. A política de scrub já existe. | Não |
| Claude | Ainda sem SDK. Não lê marcadores. | Não |
PostgreSQL é a fonte dos estados formais. Redis pode perder dados. Se o processo cair depois do commit e antes de apagar o claim, o retry lê o PostgreSQL e devolve a mesma versão.
## 3. Camadas
```text
src/ui experiência. Vazia até o prompt 04.
src/application casos de uso. Hoje: coordenação da geração e o contrato de autorização.
src/domain regras e tipos. Não importa aplicação, ports, infra, UI nem integrações.
src/ports interfaces que a aplicação exige da infra.
src/infrastructure adapters. Hoje só duplos em memória, para teste.
src/integrations/elopro contratos com outros produtos. Sem banco nem sessão compartilhada.
src/observability scrub antes de log/Sentry.
```
Regra de dependência, também no ESLint:
- `domain` não importa as outras camadas.
- `ports` importa só `domain`.
- `application` não importa `infrastructure` nem `ui`. Teste pode importar duplo.
- `infrastructure` não importa `application` nem `ui`.
- `integrations` não importa `application`, `infrastructure` nem `ui`.
`app/` (rotas) fica para o prompt 04 e deve chamar aplicação, não o banco direto a partir de componente cliente.
## 4. Componentes de domínio
| Módulo | Responsabilidade | Fora dele |
| --- | --- | --- |
| Models | Modelos, limite 10, versão de modelo, ativação | Não inicia proposta |
| Proposals | Rascunho, status `Rascunho`/`Gerada`, versão formal | Não envia e-mail |
| Generation | Commit do PDF, número e versão na mesma transação | Não escolhe o escopo de `PROP` |
| Delivery | E-mail do `pdfRef` exibido | Não muda status |
| Audit | Eventos mínimos | Retenção é P-007 |
| Branding | Logo obrigatório, cores opcionais | Não é o design system da UI |
| CRO | Pré-preenchimento quando houver contexto | O standalone funciona com `no-context` |
Papéis previstos no tipo `WorkspaceRole`: `owner`, `admin`, `gestor`, `agent`. O `WorkspaceAuthorizer` não tem implementação. Não existe adapter que responda `allowed` por omissão. O prompt 03 implementa a matriz, no servidor, com `workspace_id` da sessão.
Lock de edição: `EDIT_LOCK_TTL_MS` = 10 minutos. A interface `DistributedLockService` não tem tomada forçada.
## 5. Geração: idempotência e transação
Entrada: workspace, proposta, rascunho, ator, chave de idempotência, hash do conteúdo, versão do modelo e, se já existir, o número comercial. A chave efetiva no store e no ledger inclui workspace + proposta + chave. Outro workspace não reaproveita o commit.
Ordem:
1. Se o PostgreSQL já tem commit da mesma chave e da mesma impressão digital (rascunho, versão do modelo, hash), devolver essa versão com `replayed: true`. Não renderiza de novo e não aloca número.
2. Se a mesma chave já tem outra impressão digital, responder `conflict`. Não cria versão.
3. Claim efêmero (Redis). Mesma impressão em andamento: `in-progress`. Outra impressão em andamento: `conflict`.
4. Renderizar para staging. Falha aqui não abre transação, não aloca número e não grava versão. O claim é liberado para o retry.
5. Commit único no store formal:
- promove o staging para `pdfRef`;
- se a proposta ainda não tem número e esta é a primeira versão, reserva o número dentro dessa mesma unidade;
- insere a versão imutável apontando para esse `pdfRef`;
- confirma a reserva.
6. Se qualquer passo do commit falha, a reserva sofre rollback, o `pdfRef` promovido é desfeito e o staging é descartado. `commercialNumberAssigned` e `formalVersionCreated` ficam falsos. A falha não é gravada como resultado da chave: o retry pode tentar de novo.
7. A segunda versão só segue se `existingCommercialNumber` já veio do commit anterior. O coordenador não pede outro número. Quem aloca é só a primeira versão, e só dentro do commit.
O duplo de teste emite `TEST-######` para deixar óbvio que não é a sequência de produção. O escopo real espera o ADR-001.
Não há método de update ou delete de versão formal. O objeto commitado é congelado no duplo. A garantia de produção será a ausência de `UPDATE`/`DELETE` na policy e o fluxo, no prompt 02.
O TTL do claim não é SLO. P-012 segue aberto. Quem ligar o coordinator no futuro passa `attemptTtlMs`.
## 6. Ports
| Port | Situação |
| --- | --- |
| `GenerationCoordinator` | Implementado em `src/application/generation/run-generation.ts` |
| `FormalGenerationStore` | Contrato. Duplo em memória nos testes |
| `CommercialNumberAllocator` | Contrato transacional (`reserve` / `commit` / `rollback`). Sem adapter de produção |
| `ProposalPdfRenderer` | Contrato. Adapter previsto: `@react-pdf/renderer` sobre representação normalizada. Não instalado |
| `StagingArtifactStore` | Promote só fica visível se o commit concluir |
| `GenerationAttemptLedger` | Contrato do estado efêmero |
| `DistributedLockService` | Contrato do lock de edição. Sem force takeover |
| `TemplateIngestionService` | Sempre bloqueado: P-006 e depois P-010 |
| `ProposalEmailProvider` | Sempre bloqueado: P-001 ou P-011 |
| `CroClientPrefillPort` | `no-context` ou `pending-source` |
| `WorkspaceAuthorizer` | Só a interface |
## 7. Sentry e correlação
`CorrelationContext` carrega `requestId`, `jobId`, `workspaceId` e `actorId`. O `jobId` nasce quando houver job de geração. O `requestId` nasce na borda HTTP se o chamador não enviar um.
Propagação: a borda cria o contexto, a aplicação repassa aos ports, o job carrega o mesmo `requestId` e preenche `jobId`. Tags previstas no Sentry: esses ids mais `proposalId` ou `modelId` quando a operação for sobre um registro.
`scrubForTelemetry` roda antes de `captureException` / breadcrumb. Remove, em qualquer profundidade, documento, template, snapshot, análise, e-mail, assunto, corpo, telefone, CPF, CNPJ, logo e nome de arquivo. Não envia o PDF nem o modelo. O SDK do Sentry entra junto com a primeira borda que possa falhar (prompt 14, ou antes se o prompt 04 já expuser erro de runtime). Até lá a função é a política, não um cliente.
## 8. Stack instalada agora
TypeScript 5, ESLint 9 e Vitest. O `tsconfig` usa `moduleResolution: Bundler` e strict, alinhado ao App Router que virá.
Ainda não instalados, de propósito: Next.js, React, Tailwind, Supabase, `ioredis`, Sentry, `@anthropic-ai/sdk`, `@react-pdf/renderer`. Não há uso para eles nesta fase.
## 9. Pendências que continuam Proposed
- ADR-001 / P-009 — escopo da sequência
- ADR-002 / P-010 — motor de ingestão
- ADR-003 / P-011 — provider de e-mail
- ADR-004 / P-006 — tamanho, antivírus, sandbox, extração parcial
P-001, P-002, P-003, P-004, P-005, P-007, P-008 e P-012 também seguem abertos e não têm ADR de decisão.
## 10. O que o prompt 02 pode fazer sem novas decisões
Pode criar o schema do workspace, papéis e das tabelas de domínio da source of truth, com RLS por `workspace_id`, desde que não feche os ADRs acima.
Não criar sequence, unique do número comercial, check de provider, limite de arquivo, job de antivírus nem enum que escolha o motor de ingestão. Detalhe em cada ADR.
+41
View File
@@ -0,0 +1,41 @@
# Ordem recomendada de execução
## Pacote A — Fundação
1. `prompts/00_repo_discovery_and_guardrails.md`
2. `prompts/01_architecture_and_adrs.md`
3. `prompts/02_database_schema_rls_migrations.md`
4. `prompts/03_auth_workspace_permissions.md`
5. `prompts/04_design_system_and_app_shell.md`
**Gate A:** typecheck/lint/test/build verdes; cross-tenant básico validado; ADRs críticos registrados.
## Pacote B — Modelos de Proposta
6. `prompts/05_models_domain_list_capacity.md`
7. `prompts/06_model_upload_analysis_pipeline.md`
8. `prompts/07_model_field_review_activation_versioning.md`
9. `prompts/08_model_lock_autosave_archive_delete.md`
**Gate B:** fluxo de Modelos P0/P1 funcional, incluindo 10/10, lock, reprocessamento, ativação e V2+.
## Pacote C — Propostas
10. `prompts/09_proposals_home_and_selection.md`
11. `prompts/10_proposal_editor_dynamic_fields_preview.md`
12. `prompts/11_proposal_autosave_lock_save_exit_model_switch.md`
13. `prompts/12_validate_generate_async_idempotency.md`
14. `prompts/13_versioning_history_download.md`
**Gate C:** 4.1 → 4.2 → 4.3 completo; falhas não criam versões parciais; retry/duplo clique é idempotente.
## Pacote D — Comunicação, observabilidade e integração
15. `prompts/14_email_audit_sentry.md`
16. `prompts/15_cro_prefill_integration.md`
**Gate D:** auditoria ponta a ponta; e-mail usa o PDF exibido; CRO não é dependência do fluxo standalone.
## Pacote E — Qualidade e release
17. `prompts/16_testing_security_accessibility.md`
18. `prompts/17_performance_resilience_hardening.md`
19. `prompts/18_release_ci_cd_runbook.md`
20. `prompts/19_final_gap_audit.md`
**Gate final:** Definition of Done do MVP atendida, pendências remanescentes explicitamente registradas e nenhum comportamento não aprovado inventado.
@@ -0,0 +1,23 @@
# Checklist humano entre os pacotes
## Antes do Pacote B
- ADR-001: escopo da sequência PROP decidido?
- ADR-002: engine real de ingestão PDF/PPTX/DOCX decidida?
- ADR-004: política de tamanho/segurança de arquivo decidida ao menos para staging?
## Antes do Pacote D
- ADR-003: provider de e-mail aprovado?
- P-001: comportamento quando workspace não tem Reply-To definido?
## Antes de produção
- P-006: tamanho máximo, antivírus/sandbox/zip-bomb/timeout definidos.
- P-007: retenção de auditoria definida.
- P-008: lifecycle de entitlements definido.
- P-009: sequência PROP aprovada.
- P-010: engine de ingestão validada com arquivos reais.
- P-011: provider e credenciais de e-mail formalizados.
- P-012: pelo menos thresholds operacionais/alertas de produção definidos, mesmo que SLO final venha depois.
- Cross-tenant e storage tests aprovados.
- Geração idempotente comprovada sob concorrência.
- PDF histórico baixado/enviado corresponde exatamente à versão exibida.
- QA P0/P1 sem blocker aberto.
@@ -0,0 +1,53 @@
# Mapa de implementação — Gerador de Propostas
Estado: prompt 01 concluído no papel. ADR-000 aceito: standalone neste repositório. P-006, P-009, P-010 e P-011 seguem Proposed.
## Layout
```text
app/ # prompt 04; ainda não existe
src/domain/{models,proposals,generation,delivery,audit,branding,cro,shared}
src/application/generation # coordinator da geração
src/application/access # interface; implementação no prompt 03
src/ports/
src/infrastructure/memory # somente duplos de teste
src/integrations/elopro # CRO e futuros contratos, sem código compartilhado
src/ui # prompt 04
src/observability
supabase/migrations/ # prompt 02; ainda não existe
docs/gerador-propostas/adr/
```
PostgreSQL será a fonte de verdade dos estados formais. Redis ficará com lock, claim de geração e job efêmero. Nenhum dos dois clientes está instalado.
## Mapa por prompt
| Prompt | Feature | Onde deve morar | Padrão existente |
| --- | --- | --- | --- |
| 01 | Arquitetura, ADRs P-006/P-009/P-010/P-011, ports | `docs/gerador-propostas/ARCHITECTURE.md`, `docs/gerador-propostas/adr/`, `src/ports/` | Feito. ADRs 001–004 em Proposed. Sem sequence, sem motor, sem provider. |
| 02 | Schema, constraints, RLS | `supabase/migrations/` | Nenhum. Toda tabela de domínio tenant-safe por `workspace_id` ou FK + RLS. |
| 03 | Auth, workspace, papéis | `src/application/access/` + guards nas actions | Interface `WorkspaceAuthorizer` sem adapter permissivo. `workspace_id` resolvido no servidor. |
| 04 | DS V2.1 e app shell | `app/`, estilos Tailwind v4, componentes locais do domínio | Nenhum token no repo. Lacunas de DS (Search, Table, Stepper, Empty, Pagination, Skeleton, Upload, Avatar) ficam locais, não globais. |
| 05 | Listagem de modelos e limite 10 | `src/domain/models/` + rota de Modelos | Contar draft/active/archived; versões não ocupam vaga. |
| 06 | Upload e análise `[campo]` | porta `TemplateIngestionService`; UI de upload local | P-010 aberto. Sem inferir campo por IA/heurística. |
| 07 | Revisão de campos, ativação, V1/V2+ | domínio de modelos | Versão ativada imutável no fluxo normal. Só modelo Ativo inicia proposta. |
| 08 | Lock, autosave, arquivar, excluir modelo | Redis lock + PostgreSQL estado | TTL 10 min, sem force takeover. |
| 09 | Home 4.1 e seleção | `app/` propostas | Abrir fluxo não cria rascunho vazio. |
| 10 | Editor 4.2, campos, preview | uma página, estados Preencher → Validar → Gerar | Tipos Texto e Número/moeda; default Texto; obrigatório = Não. |
| 11 | Autosave, lock, salvar, sair, trocar modelo | drafts + lock | Rascunho nasce no primeiro persist. Lock só libera após sucesso de salvar e sair. |
| 12 | Validar e gerar, idempotência | `GenerationCoordinator` + job | Falha não cria número, versão formal nem PDF parcial. |
| 13 | Versões, histórico, download | `proposal_versions` + storage privado | PDF servido é o da versão exibida. Histórico não é editável. |
| 14 | E-mail, auditoria, Sentry | porta de e-mail + `proposal_audit_events` + Sentry | P-011 e P-001 abertos. Sem documento integral nem PII desnecessária em log/Sentry. |
| 15 | Pré-preenchimento CRO | `src/domain/cro/` | Opcional. Standalone funciona sem CRO. P-002 aberto (fonte de Cliente). |
| 16 | Testes, segurança, a11y | Vitest + testes de RLS/cross-tenant | Ainda sem runner. |
| 17 | Performance e resiliência | sem módulo novo obrigatório | P-012 aberto; não inventar SLO. |
| 18 | CI/CD e runbook | pipeline no remoto Gitea (não há `.github/`) | Sem pipeline hoje. |
| 19 | Auditoria de lacunas | `docs/gerador-propostas/` | Confrontar com `SOURCE_OF_TRUTH.md` §7 e §8. |
## Fora do MVP (não mapear como entrega)
USD/i18n, editor rico, modelo do zero, duplicar modelo, assinatura, WhatsApp/CRM, status Enviada/Aceita/Rejeitada, rollback de checkpoint, app nativo, Contrato habilitado (P-005), Apresentação (P-004).
## Gates
Ordem e gates humanos: `EXECUTION_ORDER.md` e `HUMAN_REVIEW_CHECKLIST.md`. O próximo passo permitido é o prompt 01, depois de revisão desta discovery.
+107
View File
@@ -0,0 +1,107 @@
# Discovery do repositório — Gerador de Propostas EloPro
Data: 2026-10-02
Fase: prompt 00 (fundação; nenhuma feature de produto)
Raiz: `c:\Users\Usuário\Documents\gerador-proposta`
## Conclusão
Este repositório é greenfield. Não há aplicação EloPro pré-existente para reutilizar. A stack descrita no pacote de prompts e na especificação v1.0 ainda não está materializada aqui. O remoto aponta para Gitea (`origin` → `https://gitea.fatorxtech.com/cferraz/gerador-proposta.git`) e o branch `main` ainda não tem commits.
Decisão de 2026-10-02, registrada no ADR-000: o vazio deste checkout é o baseline oficial. Auth, workspace, papéis, RLS e design system serão criados aqui. Não há outro repositório a importar. A compatibilidade com o restante do EloPro é de produto e de contrato, não de código compartilhado.
## 1. Inventário da árvore
| Área pedida | Achado |
| --- | --- |
| `app/`, `src/`, componentes, `lib` | Ausentes |
| Server Actions, Route Handlers | Ausentes |
| Supabase (`supabase/`, clients, migrations) | Ausentes |
| Testes (`vitest`, `*.test.ts`) | Ausentes |
| CI (`.github/`, pipelines) | Ausente |
| Sentry (`sentry.*.config.*`, `@sentry/*`) | Ausente |
| `package.json`, lockfile, `tsconfig`, Tailwind, ESLint | Ausentes |
| `.cursor/rules/` | Criado nesta fase: `elopro-gerador-propostas.mdc` |
| `docs/gerador-propostas/` | Criado nesta fase |
Arquivos versionáveis antes desta fase: nenhum (working tree só com `.git`).
## 2. Workspace ativo e papéis
Não há código que resolva workspace ativo nem que represente `owner` / `admin` / `gestor` / `agent`.
Contrato documental (ainda não implementado), em `SOURCE_OF_TRUTH.md`:
- Tenant técnico = `workspace_id`, com RLS. CNPJ não é chave de tenant.
- Papéis: Superadmin (`profiles.is_superadmin`), Administrador (`owner`/`admin`), Gestor (`gestor`), Usuário (`agent`).
- `workspace_id` não pode vir do cliente como autoridade.
Não existe tabela `profiles`, sessão, helper de impersonação nem superadmin neste repo.
## 3. RLS, policies e autorização
Não há migrations, policies, helpers de autorização nem fluxo de impersonação. Nada a reutilizar. A regra do agente exige revalidação server-side e RLS; isso começa no prompt 02, não aqui.
## 4. Design system e Tailwind v4
Não há tokens, componentes EloPro, `tailwind` nem consumo de CSS. A especificação visual (EloPro V2.1) está só no texto: Cloud White `#FBFBFE`, Midnight Ink `#071B45`, Primary Blue `#1F6FFF`, Blue 600 `#225EDB`, Bright Blue `#2F8FFF`, Aqua Signal `#00C2A8`, Manrope, raios e motion descritos em `SOURCE_OF_TRUTH.md` §6. Ícones EloPro não estão no repositório; Lucide fica como fallback futuro, não como biblioteca da marca.
## 5. Redis / ioredis
Ausente. Não há cliente, URL nem uso de lock.
## 6. Storage privado e signed URLs
Ausente. Não há bucket, adapter nem padrão de signed URL.
## 7. Server Actions, Route Handlers, validação e erros
Ausentes. Não há convenção local de validação (Zod ou outra) nem de erro de domínio.
## 8. Sentry
Ausente nos três runtimes (client, server, edge). Não há DSN nem scrubbing configurado.
## 9. CI e comandos canônicos
Não há pipeline. Não há scripts `typecheck`, `lint`, `test` ou `build`.
### Baseline registrado (2026-10-02)
| Comando | Resultado |
| --- | --- |
| `typecheck` | Não existe. Sem `package.json` / `tsc`. |
| `lint` | Não existe. Sem ESLint. |
| `test` | Não existe. Sem Vitest. |
| `build` | Não existe. Sem Next.js. |
| Instalação | Não executada. Não há dependências declaradas e o prompt 00 proíbe introduzir dependências sem necessidade. |
Falhas preexistentes de toolchain: não se aplicam. O baseline é “repositório sem toolchain”. Regressão futura será qualquer falha introduzida depois que esses comandos passarem a existir.
## Divergências entre stack esperada e o repo
Stack obrigatória do pacote (ainda não instalada): Next.js 16 App Router, React 19, TypeScript 5, Tailwind CSS v4, Supabase Auth + PostgreSQL (`@supabase/supabase-js`, `@supabase/ssr`), Redis (`ioredis`), TanStack Query v5 só onde houver cache client-side real, `@react-pdf/renderer`, Sentry, `@anthropic-ai/sdk` só para IA aprovada, Lucide só como fallback, `@dnd-kit` só se drag-and-drop for necessário, Vitest, ESLint 9, `postgres` + `tsx` para migrations.
Premissa do prompt 00 (“integrar ao ecossistema EloPro existente e reutilizar helpers”) **não se confirma neste checkout**. O próximo prompt precisa tratar o app como novo, com boundaries explícitos, em vez de procurar pastas que não existem.
Entradas externas usadas nesta análise (não estão no Git):
- `Especificacao_Final_Gerador_de_Propostas_EloPro_v1.0.docx` (02/10/2026)
- `Prompt_Pack_Cursor_Gerador_Propostas_EloPro_v1.0.zip`
- `elopro-gerador-propostas.mdc`
## Riscos antes do prompt 01
1. **Plataforma EloPro ausente.** Resolvido pelo ADR-000: o produto segue standalone neste repositório. Entitlements (P-008) continuam sem lifecycle definido.
2. **Pendências P-001..P-012 permanecem abertas.** Não foram preenchidas. P-009, P-010, P-011 e P-006 são o alvo dos ADRs do prompt 01 e devem ficar `Proposed`.
3. **Sem baseline executável.** Gates A–E do `EXECUTION_ORDER.md` não podem ficar verdes até existir toolchain. O prompt 01 deve criar o mínimo de projeto só se o escopo dele exigir ports testáveis; o prompt 00 não cria app.
4. **Sem commits.** `main` não tem histórico. Convenção de commit ainda não existe no repo.
5. **Isolamento multi-tenant ainda é só documento.** Qualquer feature antes de RLS/policies (prompt 02) e auth server-side (prompt 03) não pode ser considerada segura.
6. **Spec longa fora do Git.** A fonte normativa operacional copiada é `SOURCE_OF_TRUTH.md`. O docx completo continua em Downloads; se ele mudar, este discovery não acompanha sozinho.
## O que esta fase não fez
- Nenhuma feature de negócio (modelos, propostas, PDF, e-mail, CRO).
- Nenhuma dependência nova.
- Nenhuma migration.
- Nenhuma escolha silenciosa sobre P-001..P-012.
+125
View File
@@ -0,0 +1,125 @@
# Source of Truth — Gerador de Propostas EloPro — MVP
## 1. Decisões consolidadas
- Produto standalone dentro do ecossistema EloPro.
- Integração com CRO é opcional e serve para pré-preenchimento quando houver contexto explícito de cliente.
- Tenant técnico = `workspace_id`; isolamento por RLS. CNPJ é dado comercial, não chave de tenant.
- Papéis funcionais: Superadmin, Administrador, Gestor e Usuário. Mapeamento atual EloPro: `profiles.is_superadmin`, `owner/admin`, `gestor`, `agent`.
- Modelos são importados por Admin/Gestor a partir de PDF, PPTX ou DOCX.
- Marcadores variáveis: `[campo]`. O sistema não infere trechos variáveis.
- Limite: 10 modelos por workspace; Rascunho, Ativo e Arquivado contam. Versões internas não ocupam nova vaga.
- Status de proposta: apenas `Rascunho` e `Gerada`.
- Estados de UI da criação: `Preencher → Validar → Gerar`; são estados da mesma experiência, não status persistidos independentes.
- Primeira geração bem-sucedida cria `PROP-######` e `V1`.
- Versões formais são imutáveis. Edição posterior cria/retoma o rascunho da próxima versão.
- Download e envio por e-mail usam exatamente o PDF da versão exibida.
- Lock: um editor por recurso, TTL funcional de 10 minutos, sem force takeover no MVP.
- Autosave obrigatório.
- Contrato fica visível/desabilitado nesta fase. Apresentação permanece pendente.
## 2. Regras de negócio
- RN-001 Standalone — fluxo principal funciona sem Calculadora, Contratos, Planilhas ou outros produtos.
- RN-002 Brasil/BRL — pt-BR, BRL; sem seletor obrigatório de moeda.
- RN-003 Segurança server-side — permissão, entitlement, limite e estado também no backend/RLS.
- RN-004 Autosave — autosave não substitui Salvar/Salvar e sair.
- RN-005 Limite de modelos — máximo 10 por workspace; draft/active/archived contam.
- RN-006 Modelo ativo — somente modelo Ativo inicia proposta.
- RN-007 Marcadores — somente `[campo]` explícito origina campo.
- RN-008 Tipos de campo — Texto e Número/moeda; default Texto, obrigatório=Não.
- RN-009 Estrutura do modelo — marcador não é criado/excluído/renomeado na tela de validação; correção estrutural exige reenvio.
- RN-010 Versão de modelo — primeira ativação V1; alterações V2+ preservam histórico.
- RN-011 Criação de rascunho — abrir 4.2 não cria rascunho; nasce no primeiro persist válido.
- RN-012 Status — apenas Rascunho/Gerada; edição em andamento é indicador.
- RN-013 Fluxo 4.2 — Preencher/Validar/Gerar são estados da mesma página.
- RN-014 Salvar — com pendências salva e fica em Preencher; sem pendências cria checkpoint e vai para Validar.
- RN-015 Salvar e sair — persiste/checkpoint; libera lock somente após sucesso.
- RN-016 Lock — um editor; 10 min; sem tomada forçada; observador vê último persistido.
- RN-017 Geração formal — só existe sucesso se PDF formal existir; falha não cria número/versão formal.
- RN-018 Numeração — primeira geração cria PROP-###### e V1; escopo da sequência ainda precisa de decisão técnica formal.
- RN-019 Imutabilidade — versão formal gerada é imutável.
- RN-020 Nova versão — somente com alteração de conteúdo + nova geração bem-sucedida.
- RN-021 Histórico — versões antigas podem ser vistas/baixadas/enviadas, nunca editadas/excluídas/usadas como base.
- RN-022 E-mail — usa exatamente PDF exibido; não muda status Gerada.
- RN-023 Auditoria — autoria e eventos críticos rastreáveis.
- RN-024 Logo — logo da empresa obrigatório para identidade da proposta; cores opcionais.
- RN-025 Erros — erros são estados das telas existentes, sem criar novas páginas numeradas.
## 3. Requisitos funcionais
RF-001 autenticar/resolver workspace; RF-002 listar propostas por escopo; RF-003 listar modelos ativos; RF-004 confirmar seleção sem criar draft; RF-005 abrir 4.2 sem draft vazio; RF-006 prefill CRO quando aplicável; RF-007 campos dinâmicos; RF-008 validação durante edição; RF-009 autosave; RF-010 draft no primeiro persist; RF-011 Salvar; RF-012 Salvar e sair; RF-013 trocar modelo; RF-014 lock; RF-015 validar antes de gerar; RF-016 gerar PDF; RF-017 reconciliar geração assíncrona; RF-018 número comercial; RF-019 versionar; RF-020 detectar ausência de alteração; RF-021 visualizar vigente/histórica; RF-022 baixar PDF; RF-023 enviar e-mail; RF-024 registrar envio; RF-025 excluir draft elegível; RF-026 listar modelos; RF-027 limite 10/10; RF-028 upload PDF/PPTX/DOCX; RF-029 analisar marcadores; RF-030 draft de modelo após upload+análise técnica; RF-031 reprocessar correção no mesmo draft; RF-032 configurar campos; RF-033 autosave/lock de modelo; RF-034 ativar V1; RF-035 versionar modelo V2+; RF-036 arquivar; RF-037 excluir modelo nunca usado (Admin); RF-038 auditoria; RF-039 RLS; RF-040 Sentry.
## 4. Requisitos não funcionais
- Segurança: RLS + autorização server-side; fail-closed.
- Privacidade: nenhum documento/template cross-tenant; Sentry/logs sem documento integral/PII desnecessária.
- Confiabilidade: geração/ativação idempotentes; sem efeitos parciais.
- Concorrência: lock TTL 10 min + renovação por atividade; sem force takeover.
- Recuperação: autosave e retomada respeitando lock.
- Performance: carregamento progressivo; paginação/virtualização quando necessário; metas numéricas ainda abertas.
- Acessibilidade: alvo WCAG 2.1 AA, teclado, foco, labels, contraste, status não apenas por cor.
- Responsividade: web adaptada; mobile não é mera redução proporcional.
- Observabilidade: Sentry client/server/edge + correlação request/job.
- Auditabilidade: eventos mínimos imutáveis; retenção ainda aberta.
- Manutenibilidade: TS strict recomendado, lint, testes, separação por domínio.
- Integridade documental: versão formal referencia versão exata do modelo e artefato.
- Segurança de arquivos: MIME/extensão/conteúdo; tamanho/antivírus/sandbox ainda abertos.
## 5. Modelo de dados proposto
- `proposal_models`: id, workspace_id, name, status, current_version, created_by, archived_at.
- `proposal_model_versions`: id, model_id, version_no, source_file_ref, normalized_template, analysis_json, activated_at, activated_by.
- `proposal_model_fields`: id, model_version_id, field_key, display_name, type, required, order.
- `proposals`: id, workspace_id, created_by, owner_user_id, status, commercial_number, current_version_no, model_id, client_ref?, created_at.
- `proposal_drafts`: id, proposal_id, based_on_version_no?, model_version_id, data_json, content_hash, lock_owner?, updated_at.
- `proposal_versions`: id, proposal_id, version_no, model_version_id, content_snapshot, content_hash, pdf_ref, generated_by, generated_at.
- `proposal_email_events`: id, proposal_version_id, actor_id, to_email, subject, result, provider_message_id?, created_at.
- `proposal_audit_events`: id, workspace_id, proposal_id?, model_id?, actor_id, event_type, metadata, created_at.
- locks preferencialmente no Redis.
- `workspace_branding`: workspace_id, logo_ref, brand colors opcionais, contact email.
## 6. Design System EloPro V2.1
- Base predominante: Cloud White `#FBFBFE`, 65–75% superfícies claras.
- Texto/âncora: Midnight Ink `#071B45`.
- Primary Blue `#1F6FFF`; botão primário deve usar token de contraste do DS, Blue 600 `#225EDB` quando aplicável.
- Bright Blue `#2F8FFF`; Aqua Signal `#00C2A8`; Soft Blue `#DCEBFF`; Warning `#F39A2D`; Error `#E63946`.
- Manrope para UI. Source Serif 4 apenas institucional/editorial.
- Spacing 4px base; radius input 10, button 18, card 14, panel 18, modal 24.
- Motion 120/200/320ms, easing `cubic-bezier(.2,.8,.2,1)`.
- Proibido: neon, cyberpunk, holograma, glassmorphism, dark page dominante, grid decorativo, robôs/cérebros futuristas.
- Estados sempre por cor + texto + ícone/semântica; nunca cor isolada.
- Componentes globais: Button, IconButton, Badge, Tag, Card, Input, Select, Checkbox, Radio, Switch, DateField/DateRangeField, Tabs, CapsuleTabs, ContextGroup, SectionPill, Toast, Tooltip, Dialog.
- Preservar quando fizer sentido: ContextLayers, JourneyTrail, OpenFramePanel, SemanticMarker.
- `JourneyTrail` não é Stepper genérico.
- Lacunas de DS: Search, Table, Stepper, Empty State, Pagination, Skeleton, File Upload, Avatar. Compor localmente usando tokens/primitivos e não promover como componente global sem decisão.
- Iconografia EloPro é prioridade; Lucide apenas fallback técnico.
## 7. Pendências que NÃO podem ser inventadas
- P-001 fallback de Reply-To sem e-mail comercial.
- P-002 fonte definitiva de Cliente no uso independente.
- P-003 Tipo no MVP e opções definitivas.
- P-004 Apresentação.
- P-005 Gerador de Contratos.
- P-006 tamanho máximo/antivírus/sandbox/extração parcial de arquivo.
- P-007 retenção da auditoria.
- P-008 fonte/lifecycle de entitlements.
- P-009 escopo definitivo da sequência PROP (recomendação técnica atual: workspace, mas não assumir sem ADR/aprovação).
- P-010 motor de ingestão/conversão PDF/PPTX/DOCX.
- P-011 provider de e-mail (EloPro documenta Resend, mas formalizar uso no módulo).
- P-012 metas quantitativas de performance/SLO/document size/generation time.
## 8. Critérios críticos de aceite
- Nenhum P0/P1 bloqueador aberto.
- Workspace A nunca acessa B por UI, URL manipulada, API/Action direta ou storage.
- Falha de geração não consome PROP nem versão.
- Duplo clique/retry não cria duas versões.
- Versão formal é imutável.
- Lock funcional expira em 10 min e impede edição concorrente.
- Usuário Final não acessa Modelos nem por rota direta.
- 10/10 bloqueia upload antes do processamento.
- PDF baixado/enviado é o mesmo da versão exibida.
- UI segue EloPro V2.1 sem inventar regra funcional.
@@ -0,0 +1,23 @@
# ADR-000 — Aplicação standalone neste repositório
- Status: Accepted
- Data: 2026-10-02
- Decisor: produto / engenharia, nesta sessão
## Contexto
O discovery da fase 00 encontrou um repositório vazio. A premissa inicial do pacote era reutilizar auth, workspace, RLS e design system de outro checkout EloPro.
## Decisão
`gerador-proposta` é um produto standalone novo. O repositório vazio é o baseline oficial.
Auth, workspace, usuários, papéis e RLS serão implementados aqui, nas fases 02 e 03. O design system nasce aqui no prompt 04, com os tokens EloPro V2.1, pronto para convergir depois sem acoplar código agora.
A integração com outros componentes EloPro ocorre por ports. Este módulo não assume banco, sessão, componentes ou código compartilhado.
## Consequências
- Não há etapa de importar um repositório legado antes do prompt 02.
- Contratos externos ficam em `src/integrations/`. O CRO standalone devolve `no-context` ou `pending-source` (P-002), e a geração não depende dele.
- Stack alvo: Next.js 16, React 19, TypeScript 5, Tailwind CSS v4, Supabase, Redis, Sentry, Claude e Vitest. Nesta fase só entram TypeScript, ESLint e Vitest, porque ainda não há app, banco, fila nem chamada de modelo.
@@ -0,0 +1,49 @@
# ADR-001 — Escopo da sequência PROP
- Status: Proposed
- Data: 2026-10-02
- Pendência: P-009
- Decisão: nenhuma
## Contexto
A primeira geração bem-sucedida cria um número comercial no formato `PROP-######` e a versão `V1`. Falha de geração não pode consumir número nem versão. O que permanece aberto é o escopo do contador: o que faz duas propostas receberem números diferentes, e onde a unicidade vale.
A source of truth registra uma recomendação técnica de escopo por workspace. Esta recomendação não foi aprovada. Este ADR não a converte em regra.
## Alternativas
1. Sequência por `workspace_id`. É a recomendação técnica citada na especificação. Continua não adotada.
2. Sequência global da plataforma, única entre workspaces.
3. Sequência por documento comercial (CNPJ ou equivalente). CNPJ não é chave de tenant; esta alternativa entra só para ficar visível.
4. Sequência por usuário criador.
## Critérios para sair de Proposed
- Unicidade definida: quem pode ver o mesmo número e quem não pode.
- Comportamento em falha: a reserva volta ao contador. Pular número em rollback não atende RN-017.
- Concorrência: duas gerações simultâneas no mesmo escopo não recebem o mesmo número.
- Efeito em workspace novo, arquivado e reativado.
## Impacto no código desta fase
`CommercialNumberAllocator` só pode ser chamado de dentro do commit formal, depois do artefato válido, e só quando a proposta ainda não tem número. O duplo em memória usa o prefixo `TEST-` de propósito. Ele não é a sequência de produção e não escolhe a alternativa 1, 2, 3 ou 4.
O formato `PROP-######` já está na source of truth. O regex em `src/domain/generation/types.ts` descreve esse formato. Ele não aloca números.
## Impacto no prompt 02
A migration pode criar `proposals.commercial_number` como texto anulável.
Ainda não criar:
- `CREATE SEQUENCE` ou equivalente
- `UNIQUE (commercial_number)` global
- `UNIQUE (workspace_id, commercial_number)`
- trigger que preencha o número na inserção da proposta
Qualquer um desses objetos escolheria o escopo antes da aprovação deste ADR.
## O que aprovar este ADR não muda
Versão formal continua imutável. Retry com a mesma chave não cria outra versão. A segunda versão reutiliza o número já commitado; não pede outro.
@@ -0,0 +1,40 @@
# ADR-002 — Motor de ingestão de PDF, PPTX e DOCX
- Status: Proposed
- Data: 2026-10-02
- Pendência: P-010
- Decisão: nenhuma
## Contexto
Admin ou Gestor importa um modelo PDF, PPTX ou DOCX. O campo dinâmico nasce só de um marcador `[campo]` escrito no arquivo. O sistema não infere trecho variável. IA não substitui essa validação.
O motor que lê o arquivo e devolve texto ou estrutura ainda não foi escolhido. Sem essa escolha, não há parser real nesta fase.
## Alternativas
1. Bibliotecas locais por formato, com uma etapa posterior e determinística que só aceita `[campo]`.
2. Conversão por processo externo (por exemplo LibreOffice) para um formato intermediário, seguida da mesma detecção determinística.
3. Serviço externo de extração de documento, seguido da mesma detecção determinística.
4. Modelo de linguagem lendo o arquivo para achar campos. Esta alternativa conflita com a regra de marcador explícito e com a proibição de a IA substituir a validação. Está listada para não reaparecer como atalho. Não é candidata aceitável para descobrir campos.
As alternativas 1, 2 e 3 continuam abertas. Nenhuma foi escolhida. Claude permanece na stack para capacidades de IA que venham a ser aprovadas; não é o motor de marcadores.
## Critérios para sair de Proposed
- Fidelidade dos marcadores em PDF, PPTX e DOCX reais, inclusive repetidos e partidos por formatação.
- O que acontece com arquivo sem marcador, arquivo protegido e extração incompleta. O último ponto também depende de P-006.
- Isolamento do processo que abre o arquivo, tempo máximo e falha sem criar rascunho de modelo. O limite numérico continua em P-006 e P-012.
- O workspace só lê o próprio arquivo.
## Impacto no código desta fase
`TemplateIngestionService` devolve `blocked / P-006` enquanto a política de arquivo não existir, e `blocked / P-010` mesmo que um teste marque a política como aprovada. Não há adapter que leia bytes.
## Impacto no prompt 02
Pode existir coluna ou objeto para `source_file_ref`, `normalized_template` e `analysis_json`. O conteúdo desses JSON não fica preso a um motor. Não há worker de conversão nesta fase nem na migration.
## O que este ADR não decide
Tamanho máximo, antivírus, sandbox e extração parcial continuam no ADR-004.
@@ -0,0 +1,41 @@
# ADR-003 — Provider de e-mail
- Status: Proposed
- Data: 2026-10-02
- Pendência: P-011
- Relacionada: P-001
- Decisão: nenhuma
## Contexto
Download e e-mail usam o PDF da versão formal exibida. O envio não muda o status `Gerada`. A documentação de ecossistema cita Resend em outros contextos. Isso não formaliza o provider deste módulo.
P-001 também segue aberto: o que fazer quando o workspace não tem e-mail comercial para Reply-To.
## Alternativas
1. Resend. Citado fora deste módulo. Não adotado aqui.
2. Outro provider HTTP transacional.
3. SMTP operado por este produto.
4. Não enviar até haver provider e regra de Reply-To. É o comportamento do port hoje, não uma escolha de produto para o MVP.
## Critérios para sair de Proposed
- Provider nomeado, conta e ambiente definidos.
- O anexo é o `pdfRef` da versão exibida, não uma nova renderização.
- Falha de envio não altera status, número nem versão.
- Reply-To: ou o e-mail comercial do workspace, ou a regra explícita de P-001. O provider não inventa remetente.
- Evento em `proposal_email_events` com resultado e id do provider quando houver.
- Corpo, assunto e destinatário não vão para o Sentry.
## Impacto no código desta fase
`ProposalEmailProvider` devolve `blocked / P-001` sem Reply-To resolvido e `blocked / P-011` quando o Reply-To existe mas não há provider. Não há cliente HTTP.
## Impacto no prompt 02
A tabela `proposal_email_events` pode ser criada com `provider_message_id` anulável e `result` textual. Não gravar um check constraint com o nome de um provider.
## O que aprovar o provider não decide
P-001 continua uma pendência separada. Aprovar Resend, ou outro nome, não define o fallback de Reply-To.
@@ -0,0 +1,39 @@
# ADR-004 — Limites e segurança de arquivo
- Status: Proposed
- Data: 2026-10-02
- Pendência: P-006
- Decisão: nenhuma
## Contexto
O upload do modelo aceita PDF, PPTX e DOCX. A especificação deixa abertos tamanho máximo, antivírus, sandbox e o que fazer com extração parcial. P-012 também deixa abertas as metas numéricas de tempo e tamanho. Este ADR não preenche nenhum desses números.
## Alternativas
1. Allowlist de MIME e extensão, com teto de tamanho, sem antivírus no MVP.
2. Antivírus síncrono antes de persistir o arquivo.
3. Abertura do arquivo só em sandbox, com timeout.
4. Rejeitar qualquer extração parcial. A alternativa simétrica, aceitar documento parcial, também fica registrada e não foi escolhida.
Nenhuma combinação foi aprovada. Não há valor padrão de megabytes, timeout ou lista de assinaturas.
## Critérios para sair de Proposed
- Tamanho máximo por arquivo e por workspace, ou a decisão explícita de não ter teto.
- Varredura: obrigatória, adiada ou dispensada no MVP, com o risco aceito.
- Onde o arquivo é aberto e o que acontece se o conversor não terminar.
- Extração parcial: rejeita o upload ou segue com o que foi lido. Isso altera o rascunho de modelo e precisa estar explícito.
- Falha de política não cria rascunho de modelo nem dispara o motor do ADR-002.
## Impacto no código desta fase
`FileSecurityPolicy` só tem `unapproved` ou `approved` com uma referência de decisão. Não há campos `maxBytes` nem `timeoutMs`. A ingestão bloqueia em P-006 enquanto a política estiver `unapproved`.
## Impacto no prompt 02
Pode existir referência de storage privado para o arquivo de origem. Não criar check de tamanho, coluna de status de antivírus com fornecedor escolhido, nem job de varredura. Signed URL e bucket entram quando o fluxo de upload existir, com isolamento por workspace.
## Relação com os outros ADRs
P-010 escolhe como ler o arquivo. P-006 escolhe se ele pode ser aceito. Os dois precisam estar Approved antes do upload real do prompt 06. O checklist humano pede essa revisão antes do Pacote B.
+90
View File
@@ -0,0 +1,90 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
const layerImports = {
application: ["**/application/**"],
infrastructure: ["**/infrastructure/**"],
ui: ["**/ui/**"],
integrations: ["**/integrations/**"],
ports: ["**/ports/**"],
};
export default tseslint.config(
{
ignores: ["dist/**", "node_modules/**", "docs/**", "coverage/**"],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/domain/**/*.ts"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
...layerImports.application,
...layerImports.infrastructure,
...layerImports.ui,
...layerImports.integrations,
...layerImports.ports,
],
},
],
},
},
{
files: ["src/ports/**/*.ts"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
...layerImports.application,
...layerImports.infrastructure,
...layerImports.ui,
...layerImports.integrations,
],
},
],
},
},
{
files: ["src/application/**/*.ts"],
ignores: ["src/**/*.test.ts"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [...layerImports.infrastructure, ...layerImports.ui],
},
],
},
},
{
files: ["src/infrastructure/**/*.ts"],
ignores: ["src/**/*.test.ts"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [...layerImports.application, ...layerImports.ui],
},
],
},
},
{
files: ["src/integrations/**/*.ts"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
...layerImports.application,
...layerImports.infrastructure,
...layerImports.ui,
],
},
],
},
},
);
+3032
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "gerador-proposta",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Gerador de Propostas EloPro. Fase 01: arquitetura e contratos. Sem features de produto.",
"engines": {
"node": ">=20"
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"build": "tsc --project tsconfig.build.json"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.5.2",
"eslint": "^9.36.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.0",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,23 @@
/**
* Autorização de workspace neste repositório. A implementação chega no prompt 03.
* Não há adapter que aprove por omissão: sem implementação, a ação não é autorizada.
* `workspaceId` é resolvido no servidor. O cliente não é fonte de autoridade.
*/
export type WorkspaceRole = "owner" | "admin" | "gestor" | "agent";
export type WorkspaceAction =
| "models.read"
| "models.write"
| "proposals.read"
| "proposals.write"
| "proposals.generate";
export type AccessDecision = "allowed" | "denied";
export interface WorkspaceAuthorizer {
authorize(input: {
readonly actorId: string;
readonly workspaceId: string;
readonly action: WorkspaceAction;
}): Promise<AccessDecision>;
}
@@ -0,0 +1,300 @@
import { describe, expect, it } from "vitest";
import { createCorrelation } from "../../domain/shared/correlation";
import type { GenerationRequest } from "../../domain/generation/types";
import type { GenerationCoordinator } from "../../ports/generation-coordinator";
import type { ProposalPdfRenderer } from "../../ports/proposal-pdf-renderer";
import { createGenerationCoordinator } from "./run-generation";
import {
createInMemoryArtifacts,
createInMemoryFormalGenerationStore,
createInMemoryGenerationLedger,
type CommitFailurePoint,
} from "../../infrastructure/memory/in-memory-generation";
import { createTestNumberAllocator } from "../../infrastructure/memory/test-commercial-number-allocator";
function request(overrides: Partial<GenerationRequest> = {}): GenerationRequest {
return {
workspaceId: "workspace-a",
proposalId: "proposal-1",
draftId: "draft-1",
actorId: "user-1",
idempotencyKey: "click-1",
contentHash: "hash-1",
modelVersionId: "model-version-1",
existingCommercialNumber: null,
correlation: createCorrelation({
workspaceId: "workspace-a",
actorId: "user-1",
jobId: "job-1",
requestId: "request-1",
}),
...overrides,
};
}
function harness() {
const numbers = createTestNumberAllocator();
const artifacts = createInMemoryArtifacts();
const ledger = createInMemoryGenerationLedger({ now: () => Date.now() });
let failure: CommitFailurePoint | null = null;
const store = createInMemoryFormalGenerationStore({
takeFailure: () => {
const point = failure;
failure = null;
return point;
},
demote: artifacts.demote,
});
const coordinator = createGenerationCoordinator({
store,
ledger,
renderer: artifacts.renderer,
staging: artifacts.staging,
numbers,
attemptTtlMs: 60_000,
});
return {
numbers,
artifacts,
ledger,
store,
coordinator,
failNextCommit(point: CommitFailurePoint) {
failure = point;
},
};
}
describe("geração formal", () => {
it("grava número e versão só depois do artefato promovido", async () => {
const { coordinator, numbers, artifacts } = harness();
const outcome = await coordinator.generate(request());
expect(outcome.status).toBe("committed");
if (outcome.status !== "committed") {
return;
}
expect(outcome.replayed).toBe(false);
expect(outcome.versionNo).toBe(1);
expect(outcome.commercialNumber.startsWith("TEST-")).toBe(true);
expect(outcome.pdfRef.startsWith("formal:")).toBe(true);
expect(numbers.committedCount()).toBe(1);
expect(artifacts.formal()).toEqual([outcome.pdfRef]);
expect(artifacts.pending()).toEqual([]);
expect(Object.isFrozen(outcome)).toBe(true);
});
it("repete a mesma versão no retry com a mesma chave", async () => {
const { coordinator, numbers, artifacts } = harness();
const first = await coordinator.generate(request());
const second = await coordinator.generate(request());
expect(first.status).toBe("committed");
expect(second.status).toBe("committed");
if (first.status !== "committed" || second.status !== "committed") {
return;
}
expect(second.replayed).toBe(true);
expect(second.proposalVersionId).toBe(first.proposalVersionId);
expect(second.commercialNumber).toBe(first.commercialNumber);
expect(numbers.reserveCalls()).toBe(1);
expect(artifacts.renderCount()).toBe(1);
expect(artifacts.formal()).toHaveLength(1);
});
it("não cria segunda versão quando a mesma chave chega com outro conteúdo", async () => {
const { coordinator, artifacts } = harness();
await coordinator.generate(request());
const conflict = await coordinator.generate(request({ contentHash: "hash-2" }));
expect(conflict).toMatchObject({
status: "conflict",
commercialNumberAssigned: false,
formalVersionCreated: false,
});
expect(artifacts.formal()).toHaveLength(1);
expect(artifacts.renderCount()).toBe(1);
});
it("não consome número nem versão quando a renderização falha", async () => {
const { coordinator, numbers, artifacts } = harness();
artifacts.failNextRender();
const outcome = await coordinator.generate(request());
expect(outcome).toEqual({
status: "failed",
stage: "render",
commercialNumberAssigned: false,
formalVersionCreated: false,
});
expect(numbers.reserveCalls()).toBe(0);
expect(numbers.committedCount()).toBe(0);
expect(artifacts.formal()).toEqual([]);
expect(artifacts.pending()).toEqual([]);
});
it("desfaz número e artefato quando o commit falha depois da reserva", async () => {
const { coordinator, numbers, artifacts, failNextCommit } = harness();
failNextCommit("after-reserve");
const failed = await coordinator.generate(request());
const retried = await coordinator.generate(request());
expect(failed).toMatchObject({ status: "failed", stage: "commit" });
expect(numbers.committedCount()).toBe(1);
expect(retried.status).toBe("committed");
if (retried.status !== "committed") {
return;
}
expect(retried.commercialNumber).toBe("TEST-000001");
expect(artifacts.formal()).toEqual([retried.pdfRef]);
});
it("não deixa PDF formal visível quando a promoção não chega a commitar", async () => {
const { coordinator, numbers, artifacts, failNextCommit } = harness();
failNextCommit("after-promote");
const outcome = await coordinator.generate(request());
expect(outcome).toMatchObject({
status: "failed",
stage: "commit",
formalVersionCreated: false,
commercialNumberAssigned: false,
});
expect(numbers.committedCount()).toBe(0);
expect(artifacts.formal()).toEqual([]);
expect(artifacts.pending()).toEqual([]);
});
it("reutiliza o número já existente na versão seguinte", async () => {
const { coordinator, numbers, artifacts } = harness();
const first = await coordinator.generate(request());
if (first.status !== "committed") {
throw new Error("primeira geração deveria commitar");
}
const second = await coordinator.generate(
request({
idempotencyKey: "click-2",
contentHash: "hash-2",
existingCommercialNumber: first.commercialNumber,
}),
);
expect(second).toMatchObject({
status: "committed",
replayed: false,
versionNo: 2,
commercialNumber: first.commercialNumber,
});
expect(numbers.reserveCalls()).toBe(1);
expect(artifacts.formal()).toHaveLength(2);
});
it("não abre outra versão se a proposta ainda não tem número comercial", async () => {
const { coordinator, numbers, artifacts } = harness();
await coordinator.generate(request());
const outcome = await coordinator.generate(
request({
idempotencyKey: "click-2",
contentHash: "hash-2",
existingCommercialNumber: null,
}),
);
expect(outcome).toMatchObject({ status: "failed", stage: "commit" });
expect(numbers.reserveCalls()).toBe(1);
expect(artifacts.formal()).toHaveLength(1);
});
it("não reaproveita geração formal de outro workspace", async () => {
const { coordinator, artifacts } = harness();
const first = await coordinator.generate(request());
const otherWorkspace = await coordinator.generate(
request({
workspaceId: "workspace-b",
correlation: createCorrelation({
workspaceId: "workspace-b",
actorId: "user-1",
requestId: "request-2",
}),
}),
);
expect(first.status).toBe("committed");
expect(otherWorkspace.status).toBe("committed");
if (first.status !== "committed" || otherWorkspace.status !== "committed") {
return;
}
expect(otherWorkspace.proposalVersionId).not.toBe(first.proposalVersionId);
expect(otherWorkspace.pdfRef).not.toBe(first.pdfRef);
expect(artifacts.formal()).toHaveLength(2);
});
it("um segundo clique enquanto a geração está em andamento não cria outra versão", async () => {
const numbers = createTestNumberAllocator();
const artifacts = createInMemoryArtifacts();
const ledger = createInMemoryGenerationLedger({ now: () => Date.now() });
const store = createInMemoryFormalGenerationStore({
takeFailure: () => null,
demote: artifacts.demote,
});
const gate: { current: GenerationCoordinator | null } = { current: null };
let nestedStatus: string | null = null;
const renderer: ProposalPdfRenderer = {
async render(input) {
if (nestedStatus === null && gate.current) {
const nested = await gate.current.generate(request());
nestedStatus = nested.status;
}
return artifacts.renderer.render(input);
},
};
const coordinator = createGenerationCoordinator({
store,
ledger,
renderer,
staging: artifacts.staging,
numbers,
attemptTtlMs: 60_000,
});
gate.current = coordinator;
const outcome = await coordinator.generate(request());
expect(nestedStatus).toBe("in-progress");
expect(outcome.status).toBe("committed");
expect(numbers.committedCount()).toBe(1);
expect(artifacts.formal()).toHaveLength(1);
expect(artifacts.renderCount()).toBe(1);
});
});
describe("claim efêmero da geração", () => {
it("expira e aceita uma nova tentativa", async () => {
let now = 0;
const ledger = createInMemoryGenerationLedger({ now: () => now });
const identity = {
workspaceId: "workspace-a",
proposalId: "proposal-1",
idempotencyKey: "click-1",
};
await expect(
ledger.claim({ ...identity, fingerprint: "a", ttlMs: 10 }),
).resolves.toBe("claimed");
await expect(
ledger.claim({ ...identity, fingerprint: "b", ttlMs: 10 }),
).resolves.toBe("conflict");
now = 10;
await expect(
ledger.claim({ ...identity, fingerprint: "b", ttlMs: 10 }),
).resolves.toBe("claimed");
});
});
@@ -0,0 +1,108 @@
import {
failedGeneration,
generationFingerprint,
} from "../../domain/generation/types";
import type { GenerationOutcome, GenerationRequest } from "../../domain/generation/types";
import type { CommercialNumberAllocator } from "../../ports/commercial-number-allocator";
import type { FormalGenerationStore } from "../../ports/formal-generation-store";
import type { GenerationAttemptLedger } from "../../ports/generation-attempt-ledger";
import type { GenerationCoordinator } from "../../ports/generation-coordinator";
import type { ProposalPdfRenderer } from "../../ports/proposal-pdf-renderer";
import type { StagingArtifactStore } from "../../ports/staging-artifact";
export type GenerationCoordinatorDependencies = {
readonly store: FormalGenerationStore;
readonly ledger: GenerationAttemptLedger;
readonly renderer: ProposalPdfRenderer;
readonly staging: StagingArtifactStore;
readonly numbers: CommercialNumberAllocator;
/**
* TTL do claim efêmero no Redis. Não é meta de desempenho.
* P-012 continua aberto; quem liga o coordinator informa o valor operacional.
*/
readonly attemptTtlMs: number;
};
const blocked = {
commercialNumberAssigned: false,
formalVersionCreated: false,
} as const;
/**
* Orquestra a geração sem persistir número comercial nem versão formal
* antes de existir artefato válido dentro do commit final.
* Falha de render ou de commit não grava resultado idempotente, para o retry poder repetir.
*/
export function createGenerationCoordinator(
deps: GenerationCoordinatorDependencies,
): GenerationCoordinator {
return {
async generate(request: GenerationRequest): Promise<GenerationOutcome> {
const fingerprint = generationFingerprint(request);
const identity = {
workspaceId: request.workspaceId,
proposalId: request.proposalId,
idempotencyKey: request.idempotencyKey,
};
const existing = await deps.store.findByIdempotency(identity);
if (existing) {
if (existing.fingerprint !== fingerprint) {
return { status: "conflict", ...blocked };
}
return Object.freeze({ ...existing, replayed: true });
}
const claim = await deps.ledger.claim({
...identity,
fingerprint,
ttlMs: deps.attemptTtlMs,
});
if (claim === "conflict") {
return { status: "conflict", ...blocked };
}
if (claim === "same-in-progress") {
return { status: "in-progress", ...blocked };
}
let stagingRef: string | null = null;
let committed = false;
try {
let rendered: Awaited<ReturnType<ProposalPdfRenderer["render"]>>;
try {
rendered = await deps.renderer.render({
workspaceId: request.workspaceId,
proposalId: request.proposalId,
modelVersionId: request.modelVersionId,
contentHash: request.contentHash,
correlation: request.correlation,
});
} catch {
return failedGeneration("render");
}
if (rendered.status === "failed") {
return failedGeneration("render");
}
stagingRef = rendered.stagingRef;
if (rendered.contentHash !== request.contentHash) {
return failedGeneration("render");
}
const result = await deps.store.commit({
request,
stagingRef,
numbers: deps.numbers,
staging: deps.staging,
});
committed = true;
return Object.freeze({ ...result, replayed: false });
} catch {
return failedGeneration("commit");
} finally {
if (stagingRef !== null && !committed) {
await deps.staging.discard(stagingRef);
}
await deps.ledger.release(identity);
}
},
};
}
+8
View File
@@ -0,0 +1,8 @@
/** Eventos mínimos. Retenção é P-007 e não é decidida aqui. */
export const AUDIT_MODULE = "domain.audit" as const;
export type AuditEventName =
| "proposal.checkpoint"
| "proposal.generated"
| "proposal.email"
| "model.activated";
+2
View File
@@ -0,0 +1,2 @@
/** Logo obrigatório para identidade da proposta. Cores são opcionais. */
export const BRANDING_MODULE = "domain.branding" as const;
+5
View File
@@ -0,0 +1,5 @@
/**
* Pré-preenchimento opcional. O fluxo standalone não depende deste módulo.
* A fonte de Cliente no uso independente é P-002.
*/
export const CRO_MODULE = "domain.cro" as const;
+9
View File
@@ -0,0 +1,9 @@
/**
* P-001 permanece aberto: sem e-mail comercial não há Reply-To decidido.
* P-011 permanece aberto: não há provider escolhido.
*/
export type ReplyToPolicy =
| { readonly status: "commercial"; readonly address: string }
| { readonly status: "unresolved"; readonly pending: "P-001" };
export const DELIVERY_MODULE = "domain.delivery" as const;
+13
View File
@@ -0,0 +1,13 @@
export {
COMMERCIAL_NUMBER_PATTERN,
failedGeneration,
generationFingerprint,
} from "./types";
export type {
CommittedGeneration,
GenerationFingerprint,
GenerationOutcome,
GenerationRequest,
} from "./types";
export const GENERATION_MODULE = "domain.generation" as const;
+62
View File
@@ -0,0 +1,62 @@
import type { CorrelationContext } from "../shared/correlation";
/** Formato visual já decidido na source of truth. O escopo do contador é P-009. */
export const COMMERCIAL_NUMBER_PATTERN = /^PROP-\d{6}$/;
export type GenerationRequest = {
readonly workspaceId: string;
readonly proposalId: string;
readonly draftId: string;
readonly actorId: string;
readonly idempotencyKey: string;
readonly contentHash: string;
readonly modelVersionId: string;
readonly existingCommercialNumber: string | null;
readonly correlation: CorrelationContext;
};
export type GenerationFingerprint = string;
export function generationFingerprint(request: GenerationRequest): GenerationFingerprint {
return `${request.draftId}\u0000${request.modelVersionId}\u0000${request.contentHash}`;
}
export type CommittedGeneration = {
readonly status: "committed";
readonly proposalVersionId: string;
readonly versionNo: number;
readonly commercialNumber: string;
readonly pdfRef: string;
readonly contentHash: string;
readonly draftId: string;
readonly modelVersionId: string;
readonly fingerprint: GenerationFingerprint;
};
export type GenerationOutcome =
| (CommittedGeneration & { readonly replayed: boolean })
| {
readonly status: "failed";
readonly stage: "render" | "commit";
readonly commercialNumberAssigned: false;
readonly formalVersionCreated: false;
}
| {
readonly status: "conflict";
readonly commercialNumberAssigned: false;
readonly formalVersionCreated: false;
}
| {
readonly status: "in-progress";
readonly commercialNumberAssigned: false;
readonly formalVersionCreated: false;
};
export function failedGeneration(stage: "render" | "commit"): GenerationOutcome {
return {
status: "failed",
stage,
commercialNumberAssigned: false,
formalVersionCreated: false,
};
}
+2
View File
@@ -0,0 +1,2 @@
/** Ciclo de modelo, limite de 10 e versões entram nos prompts 05–08. */
export const MODELS_MODULE = "domain.models" as const;
+2
View File
@@ -0,0 +1,2 @@
/** Propostas, rascunho e versões formais entram nos prompts 09–13. */
export const PROPOSALS_MODULE = "domain.proposals" as const;
+24
View File
@@ -0,0 +1,24 @@
/**
* Identificadores de correlação permitidos em Sentry e logs.
* Não carregam documento, template, snapshot nem PII.
*/
export type CorrelationContext = {
readonly requestId: string;
readonly jobId: string | null;
readonly workspaceId: string;
readonly actorId: string;
};
export function createCorrelation(input: {
readonly workspaceId: string;
readonly actorId: string;
readonly jobId?: string | null;
readonly requestId?: string;
}): CorrelationContext {
return {
requestId: input.requestId ?? crypto.randomUUID(),
jobId: input.jobId ?? null,
workspaceId: input.workspaceId,
actorId: input.actorId,
};
}
+4
View File
@@ -0,0 +1,4 @@
/** RN-016. TTL funcional do lock de edição. O MVP não tem tomada forçada. */
export const EDIT_LOCK_TTL_MS = 10 * 60 * 1000;
export const EDIT_LOCK_ALLOWS_FORCE_TAKEOVER = false as const;
+12
View File
@@ -0,0 +1,12 @@
/**
* P-006 / ADR-004 permanecem Proposed.
* Não há limite de tamanho, antivírus, sandbox nem regra de extração parcial neste tipo.
*/
export type FileSecurityPolicy =
| { readonly status: "unapproved"; readonly pending: "P-006" }
| { readonly status: "approved"; readonly decisionRef: string };
export const UNAPPROVED_FILE_SECURITY_POLICY: FileSecurityPolicy = {
status: "unapproved",
pending: "P-006",
};
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { createCorrelation } from "../../domain/shared/correlation";
import { UNAPPROVED_FILE_SECURITY_POLICY } from "../../domain/shared/file-security-policy";
import { createBlockedTemplateIngestion } from "./blocked-template-ingestion";
import { createUnconfiguredEmailProvider } from "./unconfigured-email-provider";
const correlation = createCorrelation({
workspaceId: "workspace-a",
actorId: "user-1",
requestId: "request-1",
});
describe("adaptações ainda sem decisão", () => {
it("não analisa arquivo enquanto P-006 e P-010 seguem abertos", async () => {
const ingestion = createBlockedTemplateIngestion();
await expect(
ingestion.ingest({
workspaceId: "workspace-a",
sourceFileRef: "file-1",
mediaType: "application/pdf",
policy: UNAPPROVED_FILE_SECURITY_POLICY,
correlation,
}),
).resolves.toEqual({ status: "blocked", pending: "P-006" });
await expect(
ingestion.ingest({
workspaceId: "workspace-a",
sourceFileRef: "file-1",
mediaType: "application/pdf",
policy: { status: "approved", decisionRef: "ADR-004" },
correlation,
}),
).resolves.toEqual({ status: "blocked", pending: "P-010" });
});
it("não envia e-mail enquanto P-001 ou P-011 seguem abertos", async () => {
const email = createUnconfiguredEmailProvider();
const command = {
workspaceId: "workspace-a",
proposalVersionId: "version-1",
pdfRef: "formal:version-1",
toAddress: "cliente@example.com",
subject: "Proposta",
body: "Segue a proposta.",
correlation,
};
await expect(
email.send({
...command,
replyTo: { status: "unresolved", pending: "P-001" },
}),
).resolves.toEqual({ status: "blocked", pending: "P-001" });
await expect(
email.send({
...command,
replyTo: { status: "commercial", address: "comercial@example.com" },
}),
).resolves.toEqual({ status: "blocked", pending: "P-011" });
});
});
@@ -0,0 +1,13 @@
import type { TemplateIngestionService } from "../../ports/template-ingestion";
/** Não extrai arquivo. Política e motor seguem P-006 e P-010. */
export function createBlockedTemplateIngestion(): TemplateIngestionService {
return {
async ingest(input) {
if (input.policy.status === "unapproved") {
return { status: "blocked", pending: "P-006" };
}
return { status: "blocked", pending: "P-010" };
},
};
}
@@ -0,0 +1,181 @@
import { generationFingerprint } from "../../domain/generation/types";
import type { CommittedGeneration } from "../../domain/generation/types";
import type { FormalGenerationIdentity, FormalGenerationStore } from "../../ports/formal-generation-store";
import type { GenerationAttemptLedger, GenerationClaim } from "../../ports/generation-attempt-ledger";
import type { NumberReservation } from "../../ports/commercial-number-allocator";
import type { ProposalPdfRenderer } from "../../ports/proposal-pdf-renderer";
import type { StagingArtifactStore } from "../../ports/staging-artifact";
export type CommitFailurePoint = "after-reserve" | "after-promote";
type ClaimRow = {
readonly fingerprint: string;
readonly expiresAt: number;
};
function identityKey(identity: FormalGenerationIdentity): string {
return `${identity.workspaceId}\u0000${identity.proposalId}\u0000${identity.idempotencyKey}`;
}
export function createInMemoryGenerationLedger(clock: {
readonly now: () => number;
}): GenerationAttemptLedger {
const rows = new Map<string, ClaimRow>();
return {
async claim(input): Promise<GenerationClaim> {
const key = identityKey(input);
const now = clock.now();
const current = rows.get(key);
if (current && current.expiresAt > now) {
return current.fingerprint === input.fingerprint ? "same-in-progress" : "conflict";
}
rows.set(key, {
fingerprint: input.fingerprint,
expiresAt: now + input.ttlMs,
});
return "claimed";
},
async release(identity): Promise<void> {
rows.delete(identityKey(identity));
},
};
}
export function createInMemoryArtifacts(): {
readonly renderer: ProposalPdfRenderer;
readonly staging: StagingArtifactStore;
readonly demote: (pdfRef: string) => void;
readonly formal: () => readonly string[];
readonly pending: () => readonly string[];
readonly renderCount: () => number;
readonly failNextRender: () => void;
} {
const pending = new Set<string>();
const formal = new Set<string>();
let renders = 0;
let failRender = false;
const renderer: ProposalPdfRenderer = {
async render(input) {
renders += 1;
if (failRender) {
failRender = false;
return { status: "failed", code: "render-failed" };
}
const stagingRef = `staging:${input.proposalId}:${input.contentHash}:${renders}`;
pending.add(stagingRef);
return {
status: "rendered",
stagingRef,
contentHash: input.contentHash,
};
},
};
const staging: StagingArtifactStore = {
async promote(stagingRef) {
if (!pending.has(stagingRef)) {
throw new Error("missing-staging");
}
pending.delete(stagingRef);
const pdfRef = `formal:${stagingRef}`;
formal.add(pdfRef);
return { pdfRef };
},
async discard(stagingRef) {
pending.delete(stagingRef);
},
};
return {
renderer,
staging,
demote(pdfRef) {
formal.delete(pdfRef);
},
formal: () => [...formal],
pending: () => [...pending],
renderCount: () => renders,
failNextRender() {
failRender = true;
},
};
}
export function createInMemoryFormalGenerationStore(hooks: {
readonly takeFailure: () => CommitFailurePoint | null;
readonly demote: (pdfRef: string) => void;
}): FormalGenerationStore {
const byKey = new Map<string, CommittedGeneration>();
const byProposal = new Map<string, CommittedGeneration[]>();
return {
async findByIdempotency(identity) {
return byKey.get(identityKey(identity)) ?? null;
},
async commit(input) {
const fingerprint = generationFingerprint(input.request);
const key = identityKey(input.request);
const existing = byKey.get(key);
if (existing) {
if (existing.fingerprint !== fingerprint) {
throw new Error("idempotency-conflict");
}
return existing;
}
const proposalKey = `${input.request.workspaceId}\u0000${input.request.proposalId}`;
const current = byProposal.get(proposalKey) ?? [];
const versionNo = current.length + 1;
const failurePoint = hooks.takeFailure();
let reservation: NumberReservation | null = null;
let pdfRef: string | null = null;
try {
let commercialNumber = input.request.existingCommercialNumber;
if (commercialNumber === null) {
if (versionNo !== 1) {
throw new Error("missing-commercial-number");
}
reservation = await input.numbers.reserve({
workspaceId: input.request.workspaceId,
proposalId: input.request.proposalId,
});
commercialNumber = reservation.commercialNumber;
}
if (failurePoint === "after-reserve") {
throw new Error("commit-failed");
}
const promoted = await input.staging.promote(input.stagingRef);
pdfRef = promoted.pdfRef;
if (failurePoint === "after-promote") {
throw new Error("commit-failed");
}
const committed: CommittedGeneration = Object.freeze({
status: "committed",
proposalVersionId: crypto.randomUUID(),
versionNo,
commercialNumber,
pdfRef,
contentHash: input.request.contentHash,
draftId: input.request.draftId,
modelVersionId: input.request.modelVersionId,
fingerprint,
});
byProposal.set(proposalKey, [...current, committed]);
byKey.set(key, committed);
reservation?.commit();
return committed;
} catch (error) {
reservation?.rollback();
if (pdfRef !== null) {
hooks.demote(pdfRef);
}
throw error;
}
},
};
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { EDIT_LOCK_TTL_MS } from "../../domain/shared/edit-lock";
import type { DistributedLockService } from "../../ports/distributed-lock";
import { createInMemoryDistributedLock } from "./in-memory-lock";
type TakeoverIsAbsent = DistributedLockService extends { forceTakeover: unknown }
? never
: true;
describe("lock distribuído", () => {
it("não oferece tomada forçada", () => {
const takeoverIsAbsent: TakeoverIsAbsent = true;
expect(takeoverIsAbsent).toBe(true);
});
it("mantém um editor até o TTL e só o dono libera", async () => {
let now = 1_000;
const lock = createInMemoryDistributedLock({ now: () => now });
const first = await lock.acquire({
key: "model:1",
ttlMs: EDIT_LOCK_TTL_MS,
ownerId: "user-1",
});
const blocked = await lock.acquire({
key: "model:1",
ttlMs: EDIT_LOCK_TTL_MS,
ownerId: "user-2",
});
expect(first).not.toBeNull();
expect(blocked).toBeNull();
if (!first) {
return;
}
await expect(lock.release({ ...first, ownerId: "user-2" })).resolves.toBe(false);
await expect(lock.release(first)).resolves.toBe(true);
const afterRelease = await lock.acquire({
key: "model:1",
ttlMs: EDIT_LOCK_TTL_MS,
ownerId: "user-2",
});
expect(afterRelease).not.toBeNull();
if (!afterRelease) {
return;
}
now += EDIT_LOCK_TTL_MS;
const afterExpiry = await lock.acquire({
key: "model:1",
ttlMs: EDIT_LOCK_TTL_MS,
ownerId: "user-3",
});
expect(afterExpiry).not.toBeNull();
});
});
@@ -0,0 +1,42 @@
import type { DistributedLockService, LockHandle } from "../../ports/distributed-lock";
type LockRow = {
readonly ownerId: string;
readonly token: string;
readonly expiresAt: number;
};
export function createInMemoryDistributedLock(clock: {
readonly now: () => number;
}): DistributedLockService {
const rows = new Map<string, LockRow>();
return {
async acquire(input): Promise<LockHandle | null> {
const now = clock.now();
const current = rows.get(input.key);
if (current && current.expiresAt > now) {
return null;
}
const handle: LockHandle = {
key: input.key,
ownerId: input.ownerId,
token: crypto.randomUUID(),
};
rows.set(input.key, {
ownerId: handle.ownerId,
token: handle.token,
expiresAt: now + input.ttlMs,
});
return handle;
},
async release(handle): Promise<boolean> {
const current = rows.get(handle.key);
if (!current || current.token !== handle.token || current.ownerId !== handle.ownerId) {
return false;
}
rows.delete(handle.key);
return true;
},
};
}
@@ -0,0 +1,56 @@
import type {
CommercialNumberAllocator,
NumberReservation,
} from "../../ports/commercial-number-allocator";
/**
* Duplo de teste. O contador único deste arquivo não é a decisão de P-009.
* O token `TEST-######` também não é o número comercial de produção (`PROP-######`).
*/
export function createTestNumberAllocator(): CommercialNumberAllocator & {
readonly reserveCalls: () => number;
readonly committedCount: () => number;
} {
const free: number[] = [];
const committed = new Set<number>();
let watermark = 1;
let reserves = 0;
function take(): number {
free.sort((left, right) => left - right);
const reused = free.shift();
if (reused !== undefined) {
return reused;
}
const issued = watermark;
watermark += 1;
return issued;
}
return {
reserveCalls: () => reserves,
committedCount: () => committed.size,
async reserve(): Promise<NumberReservation> {
reserves += 1;
const issued = take();
let settled = false;
return {
commercialNumber: `TEST-${String(issued).padStart(6, "0")}`,
commit() {
if (settled) {
return;
}
settled = true;
committed.add(issued);
},
rollback() {
if (settled) {
return;
}
settled = true;
free.push(issued);
},
};
},
};
}
@@ -0,0 +1,13 @@
import type { ProposalEmailProvider } from "../../ports/proposal-email-provider";
/** Não envia e-mail e não escolhe provider. */
export function createUnconfiguredEmailProvider(): ProposalEmailProvider {
return {
async send(command) {
if (command.replyTo.status === "unresolved") {
return { status: "blocked", pending: "P-001" };
}
return { status: "blocked", pending: "P-011" };
},
};
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { standaloneCroPrefill } from "./cro-prefill";
describe("pré-preenchimento CRO", () => {
it("segue sem cliente quando não há contexto e não inventa a fonte", async () => {
const cro = standaloneCroPrefill();
await expect(
cro.lookup({ workspaceId: "workspace-a", clientContextId: null }),
).resolves.toEqual({ status: "no-context" });
await expect(
cro.lookup({ workspaceId: "workspace-a", clientContextId: "client-1" }),
).resolves.toEqual({ status: "pending-source", pending: "P-002" });
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Contrato futuro com o CRO. Não compartilha banco, sessão nem componentes.
* P-002: a fonte de Cliente no uso independente não está decidida.
* Sem contexto explícito, o resultado é `no-context` e o fluxo segue.
*/
export interface CroClientPrefillPort {
lookup(input: {
readonly workspaceId: string;
readonly clientContextId: string | null;
}): Promise<
| { readonly status: "no-context" }
| { readonly status: "pending-source"; readonly pending: "P-002" }
>;
}
export function standaloneCroPrefill(): CroClientPrefillPort {
return {
async lookup(input) {
if (input.clientContextId === null) {
return { status: "no-context" };
}
return { status: "pending-source", pending: "P-002" };
},
};
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { scrubForTelemetry } from "./scrub-for-telemetry";
describe("scrub de telemetria", () => {
it("mantém ids de correlação e remove documento, template e PII", () => {
const cyclic: Record<string, unknown> = { requestId: "request-1" };
cyclic.self = cyclic;
const scrubbed = scrubForTelemetry({
requestId: "request-1",
jobId: "job-1",
workspaceId: "workspace-a",
actorId: "user-1",
template: "contrato integral",
nested: {
email: "cliente@example.com",
contentSnapshot: { cliente: "Ana" },
proposalId: "proposal-1",
},
self: cyclic,
});
expect(scrubbed).toMatchObject({
requestId: "request-1",
jobId: "job-1",
workspaceId: "workspace-a",
actorId: "user-1",
template: "[redacted]",
nested: {
email: "[redacted]",
contentSnapshot: "[redacted]",
proposalId: "proposal-1",
},
});
expect(JSON.stringify(scrubbed)).not.toContain("contrato integral");
expect(JSON.stringify(scrubbed)).not.toContain("cliente@example.com");
expect(JSON.stringify(scrubbed)).not.toContain("Ana");
});
});
+48
View File
@@ -0,0 +1,48 @@
const FORBIDDEN_KEYS = new Set([
"email",
"toaddress",
"toemail",
"subject",
"body",
"message",
"document",
"template",
"datajson",
"contentsnapshot",
"normalizedtemplate",
"analysisjson",
"cpf",
"cnpj",
"phone",
"telefone",
"logoref",
"filename",
"rawtext",
"pdfbytes",
]);
/**
* Tags permitidas no Sentry: requestId, jobId, workspaceId, actorId, proposalId, modelId.
* Documento, template, snapshot e PII são removidos antes de qualquer envio.
*/
export function scrubForTelemetry(value: unknown): unknown {
return scrub(value, new WeakSet<object>());
}
function scrub(value: unknown, seen: WeakSet<object>): unknown {
if (value === null || typeof value !== "object") {
return value;
}
if (seen.has(value)) {
return "[circular]";
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => scrub(item, seen));
}
const output: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value)) {
output[key] = FORBIDDEN_KEYS.has(key.toLowerCase()) ? "[redacted]" : scrub(nested, seen);
}
return output;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Reserva de número comercial dentro da transação formal.
* O escopo da sequência (workspace, global ou outro) é P-009 / ADR-001 e não está decidido.
* Não há implementação de produção nesta fase.
*/
export interface NumberReservation {
readonly commercialNumber: string;
commit(): void;
rollback(): void;
}
export interface CommercialNumberAllocator {
reserve(input: {
readonly workspaceId: string;
readonly proposalId: string;
}): Promise<NumberReservation>;
}
+18
View File
@@ -0,0 +1,18 @@
export type LockHandle = {
readonly key: string;
readonly ownerId: string;
readonly token: string;
};
/**
* Lock distribuído. A implementação de produção será Redis.
* Não existe operação de tomada forçada.
*/
export interface DistributedLockService {
acquire(input: {
readonly key: string;
readonly ttlMs: number;
readonly ownerId: string;
}): Promise<LockHandle | null>;
release(handle: LockHandle): Promise<boolean>;
}
+24
View File
@@ -0,0 +1,24 @@
import type { CommittedGeneration, GenerationRequest } from "../domain/generation/types";
import type { CommercialNumberAllocator } from "./commercial-number-allocator";
import type { StagingArtifactStore } from "./staging-artifact";
export type FormalGenerationIdentity = {
readonly workspaceId: string;
readonly proposalId: string;
readonly idempotencyKey: string;
};
/**
* Fonte formal da geração. Em produção, uma transação PostgreSQL.
* Número e versão só persistem se o artefato promovido entrar no mesmo commit.
* Não há update nem delete de versão formal.
*/
export interface FormalGenerationStore {
findByIdempotency(identity: FormalGenerationIdentity): Promise<CommittedGeneration | null>;
commit(input: {
readonly request: GenerationRequest;
readonly stagingRef: string;
readonly numbers: CommercialNumberAllocator;
readonly staging: StagingArtifactStore;
}): Promise<CommittedGeneration>;
}
+15
View File
@@ -0,0 +1,15 @@
import type { FormalGenerationIdentity } from "./formal-generation-store";
export type GenerationClaim = "claimed" | "same-in-progress" | "conflict";
/**
* Estado efêmero do job. Em produção, Redis.
* PostgreSQL continua sendo a fonte do resultado formal.
*/
export interface GenerationAttemptLedger {
claim(input: FormalGenerationIdentity & {
readonly fingerprint: string;
readonly ttlMs: number;
}): Promise<GenerationClaim>;
release(identity: FormalGenerationIdentity): Promise<void>;
}
+5
View File
@@ -0,0 +1,5 @@
import type { GenerationOutcome, GenerationRequest } from "../domain/generation/types";
export interface GenerationCoordinator {
generate(request: GenerationRequest): Promise<GenerationOutcome>;
}
+26
View File
@@ -0,0 +1,26 @@
import type { CorrelationContext } from "../domain/shared/correlation";
import type { ReplyToPolicy } from "../domain/delivery";
export type ProposalEmailCommand = {
readonly workspaceId: string;
readonly proposalVersionId: string;
readonly pdfRef: string;
readonly toAddress: string;
readonly subject: string;
readonly body: string;
readonly replyTo: ReplyToPolicy;
readonly correlation: CorrelationContext;
};
export type ProposalEmailResult =
| { readonly status: "blocked"; readonly pending: "P-001" | "P-011" }
| { readonly status: "accepted"; readonly providerMessageId: string }
| { readonly status: "failed"; readonly providerMessageId: null };
/**
* P-011 / ADR-003: não há provider escolhido.
* O PDF enviado tem de ser o `pdfRef` da versão exibida. Este port não escolhe outra versão.
*/
export interface ProposalEmailProvider {
send(command: ProposalEmailCommand): Promise<ProposalEmailResult>;
}
+22
View File
@@ -0,0 +1,22 @@
import type { CorrelationContext } from "../domain/shared/correlation";
import type { RenderedStagingArtifact } from "./staging-artifact";
export type ProposalPdfRenderInput = {
readonly workspaceId: string;
readonly proposalId: string;
readonly modelVersionId: string;
readonly contentHash: string;
readonly correlation: CorrelationContext;
};
export type ProposalPdfRenderResult =
| ({ readonly status: "rendered" } & RenderedStagingArtifact)
| { readonly status: "failed"; readonly code: "render-failed" };
/**
* Composição do PDF a partir de representação normalizada.
* A stack prevê `@react-pdf/renderer` no adapter futuro. O adapter real não existe nesta fase.
*/
export interface ProposalPdfRenderer {
render(input: ProposalPdfRenderInput): Promise<ProposalPdfRenderResult>;
}
+13
View File
@@ -0,0 +1,13 @@
export type RenderedStagingArtifact = {
readonly stagingRef: string;
readonly contentHash: string;
};
export interface StagingArtifactStore {
/**
* Move o artefato de staging para uma referência formal.
* Só pode ficar visível se o commit da geração concluir.
*/
promote(stagingRef: string): Promise<{ readonly pdfRef: string }>;
discard(stagingRef: string): Promise<void>;
}
+28
View File
@@ -0,0 +1,28 @@
import type { CorrelationContext } from "../domain/shared/correlation";
import type { FileSecurityPolicy } from "../domain/shared/file-security-policy";
export type TemplateMediaType =
| "application/pdf"
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
export type TemplateIngestionResult =
| { readonly status: "blocked"; readonly pending: "P-006" | "P-010" }
| {
readonly status: "extracted";
readonly markers: readonly { readonly fieldKey: string; readonly occurrenceIndex: number }[];
};
/**
* P-010 / ADR-002: não há motor escolhido.
* Marcadores continuam sendo somente `[campo]` explícito. Este port não infere campos.
*/
export interface TemplateIngestionService {
ingest(input: {
readonly workspaceId: string;
readonly sourceFileRef: string;
readonly mediaType: TemplateMediaType;
readonly policy: FileSecurityPolicy;
readonly correlation: CorrelationContext;
}): Promise<TemplateIngestionResult>;
}
+6
View File
@@ -0,0 +1,6 @@
/**
* UI local. O design system nasce no prompt 04, neste repositório,
* preparado para convergir depois com o EloPro por tokens e contratos,
* sem importar componentes de outro repositório.
*/
export const UI_LAYER = "ui" as const;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node"],
"rootDir": "."
},
"include": ["src/**/*.ts", "vitest.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});