Implementação MVP
This commit is contained in:
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.
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+3032
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Logo obrigatório para identidade da proposta. Cores são opcionais. */
|
||||
export const BRANDING_MODULE = "domain.branding" as const;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Propostas, rascunho e versões formais entram nos prompts 09–13. */
|
||||
export const PROPOSALS_MODULE = "domain.proposals" as const;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { GenerationOutcome, GenerationRequest } from "../domain/generation/types";
|
||||
|
||||
export interface GenerationCoordinator {
|
||||
generate(request: GenerationRequest): Promise<GenerationOutcome>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user