Compare commits

..

56 Commits

Author SHA1 Message Date
2aeb745d87 Merge pull request 'feat(database): migration de seed com os 7 templates reais de documento' (#3) from developer into main
Reviewed-on: #3
2026-09-19 21:10:34 -03:00
398579fa82 feat(database): migration de seed com os 7 templates reais de documento
Traz pro código os 7 templates importados na leva anterior (Contrato de
Software, Termo de Cessão, Termo de Portabilidade, Termo de Contratação
STFC, Contrato Canal Finder, Contrato Canal Recorrente, Termo de Adesão) —
uma VM nova já nasce com eles publicados assim que roda `pnpm db:migrate`,
sem precisar reimportar manualmente. Segue o mesmo padrão de seed já usado
neste repo pra dado de catálogo (INSERT ... ON CONFLICT DO NOTHING dentro
da própria migration), idempotente por key.

Corrige de passagem um html_content vazio em 4 dos 7 JSONs originais
(não afeta geração/editor, só o card de preview) — recalculado com o
próprio renderToHtml antes de virar seed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:09:28 -03:00
338677edc1 Merge pull request 'developer' (#2) from developer into main
Reviewed-on: #2
2026-09-19 20:54:09 -03:00
a1e020556e docs: passo a passo para subir uma VM nova de pré-produção
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:49:26 -03:00
33efb9b9ca docs: revert nota de fluxo de git no CLAUDE.md
Desnecessário documentar no repo — é uma convenção de trabalho do
assistente, não do time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:43:49 -03:00
665b0ac6d5 Merge pull request 'docs: documenta fluxo de git (push para developer, merge manual via Gitea)' (#1) from developer into main
Reviewed-on: #1
2026-09-19 20:42:38 -03:00
b3b1e21c20 docs: documenta fluxo de git (push para developer, merge manual via Gitea)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:42:04 -03:00
3785b32c5f feat(documents): suporte a linha condicional (conditional) + catálogo completo
Necessário pra importar os 7 templates reais do legado (fornecidos pelo
usuário via export do OrçaFácil): o Termo de Contratação STFC usa o nó
`conditional` (envelope em volta de uma tableRow, franquia de minutos/0800)
— sem suporte, tanto o backend quanto o editor Tiptap rejeitavam/renderizavam
em branco esse documento específico, mesmo bug já documentado no export do
legado. A condição em si nunca é avaliada nesta leva (fora de escopo) — a
linha sempre passa adiante, comportamento explícito e documentado.

Também adiciona ao variable-catalog.ts as ~25 variáveis que faltavam
(tarifação STFC por banda, 0800, portabilidade, prazo de instalação),
descobertas comparando contra as chaves reais usadas nos 7 templates
importados.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:26:18 -03:00
1545666f17 feat(documents): editor de texto rico (Tiptap) para templates de documento
Substitui o textarea de texto puro por um editor visual real, seguindo o
print de referência do usuário do editor "Contrato de Software" do
OrçaFácil legado: toolbar completa (negrito/itálico/sublinhado/tachado,
alinhamento, listas, tabela, imagem, quebra de página), variáveis inseridas
como chip inline via catálogo fechado (nunca {{texto livre}}), painel
"Variáveis usadas neste modelo" (com badge obrigatório ao vivo) e
"Histórico de versões".

Backend: editor-content-renderer.ts e merge-engine.ts portados/reescritos
a partir do renderEditorContent.js/variableRegistry.js reais do legado
(achados em .tmp/orcafacil-reference) — resolve a árvore Tiptap
diretamente em vez de regex sobre HTML cacheado. Novo catálogo de
variáveis + GET /documents/variable-catalog.

Fora de escopo desta leva (documentado em docs/progress.md): nó
`conditional`, repetição de linha de tabela, bloqueio por variável
obrigatória ausente, e auto-resolução de dados reais do cadastro na hora
de gerar o documento.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 19:49:42 -03:00
553c0d9533 feat(documents): pré-visualização em PDF do rascunho via Chromium
Novo endpoint POST /documents/templates/:id/draft/preview: renderiza o
conteúdo atual (ainda não salvo) do editor com placeholders {{variavel}}
substituídos por [variavel], via Chromium real, sem persistir nada
(sem generation, sem upload S3) — permite validar visualmente o
resultado antes do "Publicar", que é uma ação permanente.

Corrige de passagem um bug real: css_content do template nunca chegava
ao renderPdf na geração real de documento (createGeneration), sendo
descartado silenciosamente.

No frontend, a pré-visualização aparece embutida num iframe na própria
tela (não em nova aba): abrir em nova aba se mostrou não-confiável ao
vivo — a navegação tardia de uma aba pré-aberta pode ser ignorada pelo
navegador quando o gesto do usuário que a autorizou já expirou no tempo
que o Chromium leva pra renderizar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 23:59:02 -03:00
8ab18ababc feat(quotes): catálogo navegável + resumo ao vivo na Nova Oferta
Substitui a tabela de linhas com dropdown de produto por duas colunas,
no padrão do legado OrçaFácil: "Adicionar Produtos" à esquerda (busca,
ordenação, lista com preço da faixa do período de contrato, clique
adiciona ao carrinho) e "Resumo da Oferta" à direita, sticky (itens
com stepper, breakdown de implantação, implantação geral, condição de
pagamento, condição especial e observações — todos migrados pra dentro
do painel —, totais ao vivo com Total do Contrato em destaque).

Fórmula do preview espelha exatamente pricing.ts::computeQuotePricing
— nada muda no backend, é só a experiência de montar a oferta antes de
enviar; o servidor continua sendo a única fonte de verdade nos totais
persistidos.

pnpm turbo run typecheck build: 28/28. Suíte @eden/api: 113/113
(inalterada). Testado ao vivo ponta a ponta, incluindo o caso de
aprovação por desconto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:24:26 -03:00
ef4591b8bc fix(resellers): avisa que o cadastro de revenda continua na edição
Usuário reportou "só tem nome/documento/e-mail" — era a tela de
criação (/resellers/new), que por design só mostra os campos mínimos
até a revenda ser salva (CNPJ-lookup/sócios/representante precisam de
um id existente), sem nenhuma explicação disso na tela. Adicionado
aviso claro dizendo que o resto do cadastro fica disponível assim que
salvar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:37:40 -03:00
57f83598b6 feat(signatures): deriva sócios/representante automaticamente na criação do envelope
signers no CreateEnvelopeDto passa a ser opcional. Quando omitido, o
contratante é derivado automaticamente da origem do envelope:
representante legal autorizado (pj_rep_*/rep_*) + sócios marcados
will_sign (client_registration_partners/reseller_partners), deduplicado
por CPF; para cliente PF, o próprio titular. signers explícito continua
funcionando como override manual completo, sem mudança de comportamento.

Nunca falha silenciosamente: sócio marcado pra assinar sem e-mail de
assinatura, ou nada disponível pra derivar, retorna 400 com mensagem
clara em vez de criar um envelope incompleto.

Suíte: 113/113 (107→113). pnpm turbo run typecheck build: 28/28.
Testado ao vivo contra a API real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:37:35 -03:00
d81553fcfe feat(resellers): campos de representante legal (preparação p/ contrato)
Usuário: o cadastro de revenda precisa ficar completo porque essa
mesma base vai alimentar a criação de contrato com a revenda dentro
do ERP. resellers ganha rep_full_name/rep_cpf/rep_role/rep_email/
rep_mobile_phone/rep_authorization_ack, mesmo shape de
client_registrations.pj_rep_* — um contrato precisa de um signatário
nomeado e autorizado, não só a lista geral de sócios.

Divergência deliberada: ao contrário de pj_rep_cpf (bloqueado do PATCH
genérico em client_registrations, esperando um fluxo de submissão
pública que o EDEN ainda não construiu — hoje uma lacuna conhecida,
nunca preenchido de fato), rep_cpf aqui fica livremente editável —
revenda não tem esse fluxo de submissão pública pra travar atrás.

Nova tela: card "Representante legal" entre "Dados da revenda" e
"Sócios". Suíte: 107/107 (106→107). pnpm turbo run typecheck build:
28/28. Testado ao vivo em navegador real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 15:41:16 -03:00
ec13dccee6 feat(resellers): UI de CNPJ/sócios/endereço/contatos, na ordem certa
Mesma UI construída pro Cadastro de Cliente, aplicada a Revenda, já
direto na ordem pedida (Dados -> Sócios -> Endereço -> Contatos), sem
precisar de uma segunda leva de reordenação. Criar uma revenda agora
redireciona pra edição dela (não pra lista) já que CNPJ-lookup/Sócios
só fazem sentido com um id existente. PartnersSection generalizada
para reuso entre Cliente e Revenda.

Corrige um bug real encontrado na verificação ao vivo: o rótulo do
botão de busca estava amarrado ao que estava sendo digitado, não ao
que já foi salvo — digitar um CNPJ válido trocava pra "Buscar
novamente" antes de qualquer busca real acontecer.

Suíte: 106/106 (inalterada). pnpm turbo run typecheck build: 28/28.
Testado ao vivo com CNPJ real (Petrobras).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 15:18:34 -03:00
2ae61dba22 feat(resellers): expande cadastro (dados PJ, endereço, contatos, sócios) + busca de CNPJ
Mesma expansão feita no Cadastro de Cliente, aplicada a Revenda:
razão social/nome fantasia, inscrição estadual, natureza jurídica,
endereço, contatos e sócios — tabela reseller_partners nova. Endpoint
POST /v1/resellers/:id/cnpj-lookup reaproveita o adapter lookupCnpj já
existente (@eden/integrations), sem duplicar a lógica de consulta.

Decisão deliberada: revenda não ganhou person_type (sempre tratada
como PJ) e document continua livremente editável via PATCH genérico
(diferente de client_registrations, que bloqueia pj_cnpj de propósito)
— a busca de CNPJ aqui é só conveniência adicional, não uma trava nova.

Suíte: 106/106 (100→106). pnpm turbo run typecheck build: 28/28.
Testado ao vivo com CNPJ real (Petrobras, 10 sócios).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 14:47:23 -03:00
9e629bb394 fix(auth): access token de 15min para 8h
Usuário pediu explicitamente pelo menos 8h. O TTL curto original era
deliberado (ADR-0003, reduzir janela de token vazado), mas o frontend
nunca implementou refresh silencioso — na prática o usuário era
derrubado no meio do trabalho sem nenhuma renovação automática. 8h é
um paliativo real, documentado como tal num adendo na própria ADR-0003
(com a mitigação que já existe — revogação de sessão continua granular
— e o que resolveria isso de verdade sem abrir mão de segurança:
refresh silencioso no frontend).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 14:47:12 -03:00
205d83a5f5 feat(client-registrations): reordena tela — Dados PJ, Sócios, Endereço, Contatos
Ordem pedida pelo usuário. O <form> único virou dois (Sócios renderiza
seu próprio <form> interno entre eles, e form aninhado é inválido em
HTML) — ambos chamam o mesmo handleSubmit, que sempre lê o estado React
completo, então salvar continua gravando tudo de uma vez independente
de qual dos dois disparou. Testado ao vivo.

pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:30:35 -03:00
93cd42b22e feat(client-registrations): busca automática de CNPJ (dados, sócios, IE)
Ao digitar/já ter um CNPJ num cadastro de cliente PJ, busca automática
contra a Receita Federal (cnpj.ws primário, BrasilAPI fallback) e
preenche razão social, endereço e inscrição estadual, além de criar os
sócios encontrados (sem duplicar em buscas repetidas) — pra usar depois
na confecção do contrato/assinatura.

Fecha uma lacuna real que já estava prevista mas nunca implementada:
pj_cnpj não tinha nenhuma forma de ser gravado (UpdateClientRegistrationDto
o excluía deliberadamente, esperando por "a dedicated re-verification
flow" — exatamente o que este endpoint é). O servidor sempre refaz a
própria consulta autoritativa a partir do CNPJ recebido, nunca confia
em dado "já encontrado" vindo do cliente.

Suíte: 100/100 (95→100). pnpm turbo run typecheck build: 28/28.
Testado ao vivo contra CNPJs reais (Petrobras, Banco do Brasil) e em
navegador real via Playwright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:12:15 -03:00
53c3031775 feat(nav): menu "Cadastro" agrupando Empresas/Produtos/Revendas/Clientes
Empresas, Produtos (com toda a árvore de submenus), Revendas e Clientes
saem do nível 1 do menu e passam a viver dentro de um novo item
"Cadastro". Isso exercita 3 níveis de submenu aninhado pela primeira
vez (Cadastro > Produtos > Grupos > Subgrupos) — a recursão do AppShell
já suportava isso estruturalmente, mas estava documentada como não
testada; verificado ao vivo via Playwright que funciona corretamente
(auto-expande só o caminho ativo em cada nível).

Suíte: 95/95. pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 12:20:24 -03:00
4e4de2c259 fix(products): rótulo "Nome do X" nos formulários de catálogo + verificação em navegador real
CatalogCreateForm/SimpleCatalogCard ganham nameLabel (default "Nome"),
propagado aos 5 catálogos: "Nome do tipo/grupo/subgrupo", "Nome da
marca/unidade".

Verificado desta vez em navegador real (Playwright headless, sem a
extensão claude-in-chrome disponível nesta sessão) em vez de só por
API — pegou um bug real que a verificação anterior não pegaria: o prop
nameLabel tinha sido adicionado à assinatura mas esquecido de usar no
JSX, então nenhum rótulo mudava apesar do build passar limpo. Corrigido
e reverificado.

Suíte: 95/95. pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 11:04:39 -03:00
96200dc2ba feat(products): submenu de Subgrupos aninhado dentro de Grupos
Mesma lógica do submenu "Produtos" já existente: "Grupos" agora expande
seu próprio submenu com "Subgrupos" (AppShell ganha suporte a um
segundo nível de accordion aninhado). Nova tela lista todos os
subgrupos de todos os grupos, com coluna Grupo e criação via dropdown
de grupo explícito — complementa (não substitui) o painel já existente
dentro da tela de um grupo específico.

listProductSubgroups passa a resolver group_name via JOIN.

Suíte: 95/95. pnpm turbo run typecheck build: 28/28 (core-web,
reseller-web e subscriber-web confirmados sem regressão no AppShell
compartilhado).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 08:52:19 -03:00
f38f2407ff feat(products): catálogos de Tipo/Grupo/Subgrupo/Marca/Unidade com submenu e dropdowns
Menu Produtos ganha submenu (AppShell agora suporta itens aninhados) para
cadastrar Tipos/Grupos/Marcas/Unidades; Subgrupo é gerenciado a partir da
tela do Grupo (sempre pertence a um). O cadastro de item passa a usar
dropdowns desses catálogos em vez de texto livre — exatamente o que o
comentário original da migration de products já previa como trabalho
futuro.

Backend: 5 tabelas de catálogo simples (mesmo padrão dos catálogos
fiscais), products ganha as FKs correspondentes com backfill, e
endpoints CRUD (sem exclusão física, active=false é o "apagar").

Corrige também um buraco de integridade real encontrado na integração:
PATCH /v1/products/:id usava COALESCE para os campos de catálogo
opcionais, então não dava para distinguir "não mexer" de "limpar" —
trocar o Grupo de um produto sem tocar no Subgrupo deixava o vínculo
antigo órfão e inconsistente, sem nenhuma validação pegando isso. Agora
a atualização valida o estado efetivo resultante (bloqueia com 400 em
vez de corromper o vínculo) e productSubgroupId: null limpa de verdade.

Suíte: 95/95 verdes (92→95). pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:46:14 -03:00
9c14671e19 feat(signatures): UI completa do módulo + rate limiting em rotas públicas
Fecha as duas pendências da leva anterior (módulo de Assinatura
Eletrônica): tela de backoffice para gerenciar envelopes (criação,
envio, cancelamento, checkpoint/finalização, auditoria) e as duas
páginas públicas (/assinar/:token, /verificar/:verificationId).

Backend: novos endpoints de streaming de PDF (documento congelado e
PDF final consolidado, autenticados e via token público) que faltavam
para a UI funcionar — o signatário não tinha como ver o próprio
documento antes de confirmar leitura. Rate limiting (guard próprio,
em memória, chaves por IP e por token) em todas as rotas sem sessão:
auth (login/mfa/forgot-password/reset-password/refresh/logout) e todo
o fluxo público de assinatura/verificação.

Suíte: 77/77 verdes. pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:51:18 -03:00
20b114a0bf feat(signatures): módulo completo de assinatura eletrônica
Porta o motor de assinatura do legado com fidelidade algorítmica
exata — hash-chain (SHA256 encadeado, seq bigserial define a ordem,
não timestamp), OTP (6 dígitos via crypto.randomInt, HMAC-SHA256 com
SIGNATURE_OTP_SECRET, TTL 5min, 5 tentativas, único ativo por vez) e
a máquina de estados de 19 estados do envelope + 8 do signatário —
tudo re-verificado contra o código-fonte real do legado por um
subagente dedicado antes de qualquer linha ser escrita, cruzado com
eden.md módulo 4.

5 tabelas novas (signature_envelopes/signers/documents/
otp_challenges/audit_events, esta última append-only via a mesma GUC
eden.allow_audit_mutation que já protege audit_log desde a Fase 1 —
canonicalização de hash extraída para packages/database/src/
hash-chain.ts, compartilhada entre as duas). Três adaptações
deliberadas ao EDEN (documentadas na migration): documentos vêm de
document_generations em vez de um módulo de anexo genérico que não
existe ainda; reseller_id em vez de reseller_registration_id; só a
versão correta (v2) da canonicalização, sem a v1 do legado (existia
lá só para tolerar hashes pré-correção de bug, EDEN nunca teve isso).

Certificado de conclusão renderizado no servidor via Chromium (não
client-side como no legado) — elimina o upload multipart do
certificado pronto. Os dois passos autenticados de finalização
(seal-checkpoint, finalize) continuam explícitos e manuais, não
automáticos, mesmo sendo tecnicamente possível agora — esse
checkpoint humano antes de uma finalização irreversível é
comportamento de negócio herdado do legado, não um detalhe do
desenho client-side do certificado que deixou de existir.

Fluxo público (token do link, nunca envelopeId/signerId do corpo da
requisição) e verificação pública (só para COMPLETED, recomputa
envelope_hash ao vivo, nomes mascarados) inteiros.

Verificado: 28 testes novos (15 puros — hash-chain/máquina de
estados/OTP/token — e 13 de ciclo de vida completo via HTTP real,
incluindo merge de PDF de verdade via pdf-lib contra saída real do
Chromium mockado). Suíte completa: 70/70 verdes. Testado ao vivo
através do container real, ponta a ponta, sem nenhum mock: criação →
convite → fluxo público completo dos dois signatários (OTP
recuperado via HMAC com o segredo real, já que SMTP não está
configurado neste ambiente) → SIGNED → seal-checkpoint → finalize
(certificado real via Chromium, merge real, upload real no MinIO) →
public-verify — SHA-256 do PDF final baixado bate byte a byte com o
gravado no Postgres, integrityValid:true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
2026-09-04 13:44:43 -03:00
d46d44f01b fix(contracts): enforce the edit-lock ADR-0006 promises but never wired
ADR-0006 explicitly commits to preserving the legacy's "trava" once a
contract leaves its editable draft state, overridable only by an
audited super_admin correction — but ContractsService.addItem/addParty
had no status or role check at all, letting anyone with contracts:edit
add items/parties to a contract in any status, including a signed
active one.

Adds assertEditable() (same shape as Quotes' assertNotLocked): draft
is the only free-editing state; past it, only super_admin can still
write, and that write is tagged distinctly in the audit log
(contract.item_added_override) rather than looking like an ordinary
edit. No frontend change needed — ContractDetailPage never called the
add-item/party endpoints in the first place.

Verified live: super_admin override still works past draft, and the
audit_log row for it carries overrode:true and the contract's status
at the time — confirmed directly in Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
2026-09-04 12:21:47 -03:00
ccac936372 feat(quotes): anexo de conformidade ANATEL/RGC no PDF de oferta
Porta o anexo regulatório do legado (Resolução Anatel 765/2023, RGC,
recorte STFC) com fidelidade total ao conteúdo legal, incluindo a
seção de tarifação — em vez do html2canvas+jsPDF client-side do
legado, usa o Chromium server-side já construído para Documents.

Schema: products ganha service_type/metered/tariff_rates/has_ldi
(migration 1788525270647) — campos que a migration original de
products tinha deliberadamente deferido. Snapshot no item da oferta
na criação (nunca referência viva ao produto). Novo pacote
compartilhado packages/domain-shared/src/telecom para o catálogo de
tipos e legendas de tarifação.

Endpoint GET /v1/quotes/:id/anatel-pdf (400 sem item STFC), sempre
renderizado ao vivo (nunca cacheado, já que o conteúdo deriva de
updated_at). Frontend: campos de produto/oferta necessários +
"Baixar PDF ANATEL" na tela de detalhe da oferta. Inclui também o
botão de download de PDF em Documentos (endpoint já existia, faltava
o link na UI).

Verificado ao vivo através do container real: PDF gerado com
Chromium de verdade, texto extraído confere byte a byte com os
valores computados (multa, datas, tarifas, resumo financeiro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
2026-09-04 10:33:33 -03:00
29430d0d96 feat(documents): renderização real de PDF via Chromium headless
Substitui a geração de PDF simulada (pdf_s3_key/pdf_sha256/pdf_size_bytes
sempre nulos) por Chromium real (Playwright), portado do
chromiumPdfRenderer.js do legado: SSRF impossível por construção
(setContent + bloqueio de toda requisição de rede da página), browser
único reaproveitado com relançamento automático, semáforo de
concorrência + timeout duro.

Infra: MinIO (compose.yaml, primeiro consumidor real do adapter S3) e
instalação condicional do Chromium só na imagem da API
(Dockerfile.node). DocumentsService.createGeneration agora renderiza,
faz upload e persiste hash/tamanho de verdade; novo endpoint de
download GET /v1/documents/generations/:id/pdf.

Verificado ao vivo através do container real: PDF gerado, baixado, e
SHA-256 conferido byte a byte contra o valor persistido no Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
2026-09-04 09:26:31 -03:00
4d23af3b23 feat(auth): e-mail transacional real + recuperação de senha
Liga o adapter SMTP existente (packages/integrations, já era fail-soft)
a um consumidor de verdade e adiciona o fluxo completo de recuperação
de senha, faltante desde a Fase 1.

- UsersService.invite() agora envia e-mail com a senha temporária
  (mantém o retorno na resposta também — dupla entrega, eden.md §4.8).
- POST /v1/auth/forgot-password + POST /v1/auth/reset-password: token
  de 256 bits, só o hash SHA-256 persistido (users.reset_token_hash/
  reset_token_expires_at — colunas que já existiam desde a baseline da
  Fase 1, nunca usadas até agora), validade de 1h, uso único, revoga
  todas as sessões ativas do usuário ao concluir. Mesma disciplina
  anti-enumeração do login: resposta idêntica para e-mail cadastrado
  ou não.
- Frontend: link "Esqueci minha senha" no LoginPage, novas
  ForgotPasswordPage/ResetPasswordPage.

Teste de integração novo (password-reset.spec.ts, 3 testes) usa
vi.mock para interceptar sendMail e recuperar o token real do corpo do
e-mail — única forma de testar o caminho feliz de ponta a ponta, já
que o token nunca é devolvido na resposta da API. Suíte completa:
33/33 (30→33). pnpm turbo run typecheck build (28/28). Testado ao vivo
através do proxy same-origin: anti-enumeração confirmada com curl
real, token gravado no Postgres, token bogus rejeitado (400).

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 08:02:33 -03:00
012c00fa4d feat(web): telas de Documentos e Ponto Eletrônico — cobertura completa do EDEN Core
Última leva desta sequência, sem parar para autorização. 2 subagentes
eden-frontend em paralelo.

- Documentos: templates com ciclo draft→publish (edição por texto com
  {{variavel}}, sem editor Tiptap real ainda), preview do HTML
  publicado, geração de documento com extração automática de variáveis
  via regex e aviso de missingVariables. Achado do agente corrigindo
  meu brief: regra de key aceita minúsculas+dígitos+underscore, não só
  minúsculas+underscore.
- Ponto Eletrônico (fundação): dispositivos Control iD (senha nunca
  retornada, só hasCredential), import de AFD com decodificação
  ISO-8859-1 explícita documentada no código, registros AFD só-leitura
  (sem nenhuma ação de edição — imutabilidade legal respeitada também
  na UI). 2 discrepâncias reais encontradas e corrigidas pelo agente:
  reimportar retorna 409 estruturado (não 200), e a listagem de
  registros é um envelope paginado (não array simples).

Reconciliação manual de App.tsx/AuthenticatedLayout.tsx entre os 2
agentes. Verificado: pnpm turbo run typecheck build (28/28); containers
eden-api/eden-core reconstruídos; testado ao vivo através do proxy
same-origin (porta 3001) incluindo confirmação de que a senha do
dispositivo nunca aparece na resposta; trilha de auditoria conferida.

Com esta leva, o EDEN Core tem UI real para todo o backend construído
nesta sessão, exceto Assinatura Eletrônica e apuração/fechamento de
ponto (nenhum dos dois existe no backend ainda).

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 06:14:23 -03:00
828e6e8ddb feat(web): telas de Suporte e Fiscal no EDEN Core
Continuação direta, sem parar para autorização. 2 subagentes
eden-frontend em paralelo.

- Suporte: lista de tickets, criação, detalhe com painel de SLA
  (prazos, cumprido/violado/pendente, aviso "SLA pausado"), thread de
  comentários públicos/internos, histórico de status, transições
  limitadas aos alvos válidos com campos condicionais de causa raiz/
  resolução ao fechar.
- Fiscal: NCM/CFOP/Municípios (CRUD simples), Naturezas de Operação
  (só leitura, seed fixo), nova seção "Fiscal" em ProductFormPage
  (só ao editar) com aviso de que um novo perfil do mesmo componente
  substitui automaticamente o anterior — invariante confirmada ao vivo
  pelo próprio agente antes de entregar.

Reconciliação manual de App.tsx/AuthenticatedLayout.tsx/
ProductFormPage.tsx/format.ts entre os 2 agentes. Ambos restauraram a
base manualmente (sem git merge) — diff -rq contra main confirmou zero
desvio de restauração nos dois casos antes de aceitar qualquer arquivo
como novo.

Verificado: pnpm turbo run typecheck build (28/28); containers
eden-api/eden-core reconstruídos; testado ao vivo através do proxy
same-origin (porta 3001); trilha de auditoria conferida no Postgres.

Com esta leva, o EDEN Core tem UI real para praticamente todo o
backend construído até agora, exceto HR-Timeclock e Documentos.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 05:53:20 -03:00
7021994d8e feat(web): telas de Estoque e Faturamento no EDEN Core
Continuação direta, sem parar para autorização (a pedido do usuário).
2 subagentes eden-frontend em paralelo.

- Estoque: armazéns (CRUD), ativos com ações contextuais por status
  (só oferece Reservar/Instalar/Devolver quando válido para o status
  atual), entrada de ativo, detalhe com histórico de movimentos, ledger
  só-leitura, reservas genéricas. Achado real documentado no código:
  install/return gravam o movimento com reference_type='contract', não
  'serialized_asset' — filtrar o ledger só pelo id do ativo nunca traz
  esse histórico; contornado buscando também pelos movimentos do
  contrato atual do ativo, registrado como pendência de backend.
- Faturamento: assinaturas (valor mensal pré-preenchido do contrato,
  editável com aviso), fechamentos (aviso de idempotência ao regerar),
  detalhe do fechamento com tabela de faturas + consolidação com
  confirmação em duas etapas (irreversível), lista de faturas.

Reconciliação manual de App.tsx/AuthenticatedLayout.tsx entre os 2
agentes, confirmando typecheck limpo a cada mesclagem. Um dos agentes
restaurou a base do zero (sem git merge) — comparação byte a byte
(diff -rq) contra main confirmou zero desvio antes de aceitar qualquer
arquivo como genuinamente novo.

Verificado: pnpm turbo run typecheck build (28/28); containers
eden-api/eden-core reconstruídos; testado ao vivo através do proxy
same-origin (porta 3001); trilha de auditoria conferida no Postgres.
Os próprios agentes já haviam confirmado ao vivo contra a API real:
invariante de serial duplicado (409), ciclo completo de ativo, e o
fluxo idempotente de billing (gerar→regenerar→consolidar→409).

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 23:32:48 -03:00
19e67dbe63 feat(web): telas de Cadastro de Cliente e Contratos no EDEN Core
Continuação direta da leva anterior, sem parar para autorização (a
pedido do usuário). 2 subagentes eden-frontend em paralelo.

- Cadastro de Cliente: lista, criação direta (PF/PJ + portabilidade),
  detalhe rico editável por seção (status, endereço, contatos, dados
  PF/PJ com CPF/CNPJ sempre somente-leitura, portabilidade condicional)
  + sub-seção de Sócios para PJ. Fecha a pendência da leva anterior:
  QuoteDetailPage não pede mais UUID colado à mão para fechar negócio,
  agora é um select real com atalho para criar cadastro novo.
- Contratos: lista, criação (itens produto+quantidade, partes
  contratada/contratante), detalhe com resumo financeiro, aviso de
  fidelidade sem efeito legal quando não aprovada, histórico de status,
  e botões de transição limitados aos alvos válidos a partir do status
  atual (servidor continua sendo a única fonte de verdade real).
  QuoteDetailPage ganha link "Criar contrato a partir desta oferta"
  quando a oferta está fechada.

Reconciliação manual de App.tsx/AuthenticatedLayout.tsx/QuoteDetailPage.tsx
entre os 2 agentes (mais o de Empresas/CRM/Ofertas da leva anterior),
um de cada vez, confirmando typecheck/build limpo a cada mesclagem.

Verificado: pnpm turbo run typecheck build (28/28); containers
eden-api/eden-core reconstruídos; fluxo completo testado ao vivo
ATRAVÉS DO PROXY same-origin (porta 3001, não direto na API) — login,
criação de cadastro, criação de contrato, transição de status inválida
corretamente rejeitada com 409.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:51:40 -03:00
abe056c8b6 feat(infra): proxy same-origin /api/* dentro do nginx dos 3 frontends
A API não tem domínio público próprio (decisão do operador — só IP
interno). O proxy externo que termina SSL nos 3 domínios públicos é
gerenciado fora deste repositório; a peça que este repo controla é o
nginx que já roda dentro de cada container de frontend.

infra/docker/nginx-spa.conf ganha um location /api/ com proxy_pass para
eden-api:8080 (mesma rede eden_net) — resolver 127.0.0.11 (DNS
embutido do Docker) + proxy_pass via variável, para não cachear o IP do
upstream permanentemente. Com isso o browser chamando /api/v1/... no
domínio público (já roteado para a porta 3001/3002/3003 pelo proxy
externo do operador) completa o fluxo same-origin sem precisar de CORS
nem de um domínio próprio para a API.

Testado ao vivo: login real via POST /api/v1/auth/login funcionando
através dos 3 containers reconstruídos (eden-core:3001,
eden-parceiros:3002, eden-assinante:3003).

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:48:11 -03:00
f96c465ee5 feat(web): telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas)
Prioriza a parte web a pedido do usuário. 2 subagentes eden-frontend em
paralelo sobre o shell autenticado já pronto:

- Empresas/Produtos/Revendas: CRUD completo (lista + criar/editar),
  layout modelado nas páginas reais do tema DreamsERP comprado
  (purchase/vendors.tsx, inventory/products.tsx) — máscara de moeda para
  as 5 faixas de preço + implantação. Novo componente Table<T> genérico
  em packages/ui.
- CRM: Leads (lista, criar/editar, converter em oportunidade) e
  Oportunidades, modelados em crm/leads.tsx/dealsDetails.tsx.
- Ofertas/Quotes: o módulo mais rico — criação com lista dinâmica de
  itens produto/quantidade, fidelidade reduzida opcional, desconto
  opcional, totais calculados pelo servidor; detalhe com aprovar/rejeitar
  e fechar negócio. Modelado em sales/salesQuotes*.tsx. Novo componente
  Select em packages/ui.

Correção de instrução no meio da leva: usuário pediu para seguir de
perto o layout real do tema comprado, não só os tokens visuais já
extraídos — as páginas React relevantes do tema foram extraídas para
.tmp/theme-reference/ (nunca versionado) em cada worktree e os agentes
já em execução foram redirecionados via mensagem (não relançados).

Bug real do próprio agente, corrigindo meu brief: Leads/Opportunities
respondem em camelCase (mapLead/mapOpportunity), não snake_case como o
resto da API — verificado contra o código-fonte real antes de aceitar.

Integração manual: um dos 2 agentes não conseguiu git merge no próprio
sandbox e restaurou o shell existente por leitura+escrita — cada arquivo
restaurado foi comparado byte a byte contra main (diff -rq) antes de
aceitar. App.tsx/AuthenticatedLayout.tsx/packages/ui/index.ts
reconciliados manualmente juntando as rotas/exports dos dois agentes.

Verificado: pnpm turbo run typecheck build (28/28); containers
eden-api/eden-core reconstruídos; bundle de produção contém todas as
novas telas; login simulando Origin de browser real; listagens
retornando dados reais; criação de lead confirmando o formato camelCase.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:31:07 -03:00
4d67fe5f1a feat(api,web): fundação de Documentos + EDEN Parceiros/Assinante com login real
Continuação da leva "pelo menos mais 20 etapas" — 2 subagentes em paralelo.

## Documents (apps/api/src/documents/ + migration documents)

Metade "templates + geração" do eden.md módulo 4 (§1.1-1.3): document_templates,
document_template_versions (ciclo draft→publish, no máximo 1 rascunho por
template via índice único parcial, versão publicada imutável), document_generations
(idempotente por (template_version_id, origem), CHECK de origem única). Motor de
merge de variáveis ({{chave}}→valor, nunca lança exceção) como função pura
testável. A metade de Assinatura Eletrônica (envelopes, signatários, OTP,
cadeia de auditoria hash-chain — eden.md §1.4-1.9/§5-7, 9 tabelas satélite)
fica deliberadamente fora — grande e sensível demais para dividir espaço
com esta leva, fica para uma leva própria. PDF real via Chromium também
deferido; document_generations grava rendered_html, pdf_* ficam NULL.

## EDEN Parceiros / EDEN Assinante (apps/reseller-web, apps/subscriber-web)

Replicam o shell autenticado do core-web (leva anterior): login real com
suporte a MFA, sessão, AppShell do Design System — trocando só
applicationKey ("reseller"/"subscriber"), título e chave de localStorage
da sessão. As duas telas "Bootstrap da Fase 1" saem de circulação.

Testado ao vivo contra os containers reais: ciclo completo de template
(criar→draft→409 no segundo draft→editar→publicar→gerar documento com
merge de variáveis funcionando→regeneração idempotente reaproveita o
registro), e login via /v1/auth/login com applicationKey "reseller"/
"subscriber" retornando tokens válidos nos 2 portais reconstruídos.
pnpm turbo run typecheck build (28/28), pnpm --filter @eden/api test
(30/30, sem regressão), trilha de auditoria conferida no Postgres.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:55:52 -03:00
eff515b70d feat(api,ui): MFA/TOTP, Inventory, Support, HR-Timeclock, Fiscal, Billing e Design System
Leva grande, pedida como "pelo menos mais 20 etapas, use subagentes":
fecha a pendência de MFA/TOTP da Fase 1 (feito diretamente, autenticação é
sensível demais para delegar) e lança 5 subagentes em paralelo em 2 ondas
(eden-inventory, eden-support, eden-hr-timeclock; depois eden-finance,
eden-fiscal, eden-frontend), cada um em worktree isolado, com integração
seletiva manual (barrel, app.module.ts, migrations renomeadas/aplicadas,
build/typecheck/teste ao vivo antes de cada merge).

## Backend

- MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `crypto/field-encryption.service.ts`):
  login em duas etapas quando `mfa_enabled=true`, `FieldEncryptionService`
  genérico (AES-256-GCM versionado, ADR-0005) reaproveitado por outros
  segredos reversíveis (ex. credencial de equipamento Control iD).
- Inventory: warehouses, stock_movements (ledger append-only), serialized_assets,
  asset_assignments, stock_reservations — invariante "serial/patrimônio/MAC
  nunca duplicado entre ativos ativos" reforçada por índices únicos parciais
  no banco (Master Prompt §15.3).
- Support/Service Desk: tickets com máquina de estados, SLA com pausa/retomada
  em `waiting_customer`/`waiting_third_party` (funções puras testáveis).
- HR-Timeclock (fundação): dispositivos Control iD (senha cifrada), import de
  AFD com parser + CRC-16/KERMIT, `afd_records` imutável sem exceção nem
  para super_admin (sem válvula de escape, diferente de audit_log).
- Fiscal: NCM/CFOP/municípios/naturezas de operação (eden.md módulo 5, MVP de
  5 das ~19 tabelas), product_fiscal_profiles com invariante de único
  profile ativo por billing_component.
- Billing: subscription→billing_run→invoice idempotente (regenerar em draft
  substitui, nunca duplica) e imutável após consolidar (ADR-0008: nunca
  confunde faturamento/documento fiscal/recebimento).
- FKs cruzadas fechadas: quotes/client_registrations/contracts.

## Frontend — primeira leva

- `packages/ui`: Design System real extraído do tema DreamsERP comprado —
  achado que mudou o escopo: o tema já vem em React+Tailwind (não só
  Angular), tokens visuais reais portados (cores, Geist como fonte real,
  divergindo da doc antiga que citava Nunito/Poppins).
- `apps/core-web`: login real (com suporte a MFA), sessão, shell autenticado
  via `AppShell` — substitui a tela estática "Bootstrap da Fase 1".

## Bugs reais encontrados e corrigidos (todos via teste ao vivo, não leitura)

1. **Segurança**: o token de desafio MFA era aceito como access token normal
   pelo `JwtAuthGuard` (mesma assinatura, nenhuma checagem da claim
   `purpose`) — bypass total do segundo fator. Explorado e fechado ao vivo
   contra o container real; teste de regressão em `test/mfa.spec.ts`.
2. Crase solta dentro de comentário SQL na migration de hr-timeclock quebrava
   o template literal JS (`SyntaxError` ao rodar a migration).
3. CORS ausente por completo na API.
4. `turbo.json` não declarava `VITE_API_BASE_URL` como input de build — o
   Turborepo cacheava o bundle do frontend ignorando mudança na env var.
5. `compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`, que colidia com a
   variável de mesmo nome do `.env` local — o default nunca era aplicado.
6. Não existia `.dockerignore` — todo build copiava node_modules/.git/
   worktrees de subagentes (665MB) para dentro do contexto Docker.

Decisão do operador incorporada: a API nunca tem domínio público próprio
(só IP interno) — os 3 frontends chamam por caminho relativo `/api/v1`,
proxy externo roteia same-origin; CORS só é relevante para dev local.

Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter
@eden/api test` (30/30, cresceu de 15), todo o lote testado ao vivo via
curl contra os containers reais reconstruídos, incluindo os 6 bugs acima
já fechados e confirmados, e a trilha de auditoria conferida no Postgres.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:25:31 -03:00
18838a9031 feat(api): Quotes, Client Registration e esqueleto de Contratos
Fecha o núcleo comercial da Fase 2/3: motor de precificação de Ofertas
(feito diretamente, dado o risco de erro de arredondamento/regra de
negócio), Cadastro de Cliente e esqueleto de Contratos (via 2 subagentes
em paralelo, salvos manualmente após ambos baterem no limite de gasto
mensal da conta — trabalho não commitado nos worktrees foi revisado
arquivo a arquivo e copiado para main, com as peças faltantes
completadas diretamente).

- Quotes: pricing.ts porta as fórmulas do legado (eden.md §7/§8) —
  needsApproval, faixas de condição de pagamento, efeito legal da
  fidelidade condicionado a approval_status==='approved'. Fluxo
  criar→decidir aprovação→fechar negócio (vinculado a um cadastro de
  cliente, com guarda contra fechamento duplo).
- Client Registration: cadastro direto (eden.md §3), MVP autenticado-only
  (sem token público de autoatendimento, upload ou lookup CNPJ/CEP nesta
  fase — documentado em comentário).
- Contracts: máquina de estados do ADR-0006 imposta via SELECT...FOR
  UPDATE + tabela de transições, com signed_at/activated_at e histórico
  de status; 4 das 11 tabelas do ADR-0006 (as demais adiadas).

2 bugs próprios corrigidos antes do commit: UpdateQuoteInput usava
COALESCE para campos financeiros (undefined do JS apagaria valor em
update parcial) — campos tornados obrigatórios; User.reseller_id/
can_approve_discount faltavam em types.ts apesar das colunas já
existirem no banco desde migrations anteriores.

Verificado: pnpm turbo run typecheck build (28/28), pnpm test (15/15),
e todo o fluxo testado ao vivo via curl contra a stack Docker real,
incluindo cenários de erro (transição de status inválida, FK de produto
inexistente, fechamento duplo, 401 sem token) e trilha de auditoria
conferida no Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:25:49 -03:00
e8014b4135 docs: consolida progresso final do lote de agentes paralelos (CRM + QA) 2026-09-03 11:18:43 -03:00
860c21df07 Integra suíte de testes de integração (agente eden-qa)
Vitest + supertest + NestJS TestingModule contra o Postgres real (não
mockado, conforme Master Prompt §15.1). Bootstrap via SWC (não esbuild
puro) porque o esbuild não implementa emitDecoratorMetadata — sem isso,
toda injeção de dependência via decorator do Nest quebra silenciosamente
sob Vitest (achado real do agente, não bug de produto).

5 arquivos de teste, 15 casos, todos verdes contra a stack real:
- auth.spec.ts: anti-enumeração, rotação de refresh, detecção de replay
  fim-a-fim, logout idempotente.
- role-weight.spec.ts: guarda a regressão do bug #1 (papel inexistente
  → 400) e o peso hierárquico (ator fraco não atribui papel mais forte).
- companies.spec.ts: CNPJ inválido rejeitado, CNPJ válido aceito.
- mass-assignment.spec.ts: whitelist rejeita campo inesperado.
- reseller-assignment.spec.ts: guarda a regressão do bug #2 (resellerId
  do corpo não é mais aceito; :id da URL é a única fonte de verdade).

Cada fixture criada pela suíte é prefixada (qa-test-/qa_) e limpa em
afterAll (desativada onde não há endpoint DELETE, removida onde há) —
confirmado que nenhum dado pré-existente (super_admin seedado, Handix
Telecom LTDA, etc.) foi alterado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:17:24 -03:00
159a2964cc Merge: suíte de testes de integração (agente eden-qa) 2026-09-03 11:16:13 -03:00
cdafb7f853 test(api): add Vitest+supertest integration test suite (eden-qa)
Boots the real Nest app (Test.createTestingModule + AppModule) against a
real Postgres — no mocked DB or guards anywhere in this suite. Uses SWC
(unplugin-swc) instead of Vite's default esbuild transform because
esbuild doesn't implement emitDecoratorMetadata, which silently breaks
NestJS's constructor DI (every provider resolves to undefined).

Covers:
- auth.spec.ts: anti-enumeration login message, refresh-token rotation,
  replay-detection revoking the whole session chain, idempotent logout
  (ADR-0003).
- role-weight.spec.ts: invalid role_key on invite returns 400 not 500
  (regression guard), lower-weight actor forbidden from assigning a
  heavier role even when holding users:edit (ADR-0004).
- companies.spec.ts: CNPJ digit-check validation (reject/accept).
- mass-assignment.spec.ts: forbidNonWhitelisted actually rejects
  unexpected fields on companies/products.
- reseller-assignment.spec.ts: assign-user always targets the URL's
  :id, never a body resellerId (regression guard for commit 8d108e0);
  unassign-user clears the assignment.

All fixtures are qa-test-/qa_ tagged and cleaned up (deactivated where
no DELETE endpoint exists, deleted where one does) — verified no
pre-existing seed data (super_admin, Handix Telecom LTDA, Handix revenda
própria) was touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:14:43 -03:00
e7d3668154 fix: renomeia timestamp da migration de CRM (estava antes de resellers já aplicada)
node-pg-migrate recusa rodar uma migration cujo timestamp precede uma já
aplicada (check-order). O agente gerou 1788439012582 (CRM) antes de
1788439142825 (resellers) já ter sido mesclada e aplicada. Renomeado
para 1788444193993, aplicado com sucesso.
2026-09-03 11:03:39 -03:00
f56c3c9054 Integra módulo de CRM (leads/opportunities); corrige 2 bugs reais do agente
- Wire LeadsModule/OpportunitiesModule em app.module.ts; adiciona os
  exports correspondentes ao barrel @eden/database.

Bugs reais encontrados e corrigidos antes do commit (não pegos pelo
próprio agente porque ele não tinha o módulo de auth disponível no
worktree dele para testar de verdade — só stubado e revertido):

1. Os controllers de Leads/Opportunities liam `req.user?.id`, mas nosso
   JwtAuthGuard real popula `request.edenUser`, nunca `request.user` —
   todo evento de auditoria de lead/oportunidade estaria gravando
   actorId: null sempre, perdendo a atribuição de quem fez a ação.
   Trocado para o padrão real (@CurrentUser() + EdenAuthenticatedUser).
2. As chamadas appendAuditEvent() nos dois services omitiam o campo
   obrigatório `actorType` — corrigido para "user" em todas as 6
   ocorrências.

Também corrigidos: import de guards/decorators via um barrel
"../auth" inexistente (trocado pelos caminhos reais), e os dois módulos
não importavam AuthModule (necessário para o Nest resolver as
dependências do JwtAuthGuard via DI).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:02:40 -03:00
a8f77e4068 Add CRM leads/opportunities module (Master Prompt §6.2 MVP)
Greenfield for EDEN — eden.md has no CRM module, so this is driven by the
Master Prompt's requirements, not a legacy port.

- Migration 1788439012582_crm.cjs: leads + opportunities tables, free-text
  status/stage (documented via COMMENT ON COLUMN, no CHECK yet — a
  configurable pipeline is a future refinement), indexes on status/owner_id
  and stage/owner_id, set_updated_at triggers reusing the baseline
  schema's trigger function.
- packages/database/src/repositories/{leads,opportunities}.ts: list/get/
  create/update per companies.ts conventions (COALESCE-based partial
  update), plus convertLeadToOpportunity(leadId, input) wrapped in
  withTransaction — inserts the opportunity (lead_id set) and marks the
  lead 'converted' atomically.
- apps/api/src/{leads,opportunities}/: NestJS modules mirroring the
  Companies module structure (controller/service/module/dto). Routes
  v1/leads and v1/opportunities. Because leads/opportunities carry
  prospect PII (unlike the open-read Companies/Products catalog), reads
  are also gated behind @RequirePermission('<resource>', 'view'), not
  just JwtAuthGuard. Writes require '<resource>', 'edit'.
  POST /v1/leads/:id/convert gated by 'leads'/'edit'. Audit events:
  lead.created/updated/converted, opportunity.created/updated.
- Added class-validator/class-transformer to apps/api/package.json
  (needed for the DTOs; not previously a direct dependency).

Known cross-agent dependency gap (by design, not a bug in this code):
this worktree branched before any sibling agent's work landed, so
apps/api/src/auth/ (JwtAuthGuard, PermissionGuard, RequirePermission) and
the Companies module referenced as this task's template do not exist yet
here, and packages/database/src/index.ts / apps/api/src/app.module.ts are
explicitly off-limits for me to edit (merge-conflict avoidance across the
4 parallel agents). `pnpm --filter @eden/database typecheck/build` pass
unconditionally. `pnpm --filter @eden/api typecheck/build` currently fail
with exactly two error classes: "Cannot find module '../auth'" and
"@eden/database has no exported member <leads/opportunities repo fns,
auditRepo>" — both expected to resolve once the auth module and the
index.ts/app.module.ts wiring land at merge time. Verified locally (not
committed) by stubbing both gaps: with the stub in place, both typecheck
and build pass with zero errors, confirming this module's own code is
correct against the documented interfaces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 10:57:28 -03:00
c9ba96f61f docs: registra progresso do lote de agentes paralelos (Users/Roles, Products, Resellers, S3/SMTP) 2026-09-03 10:18:53 -03:00
8d108e08ed fix: assign-user de reseller usava resellerId do body, não o :id da URL
Bug de design encontrado em revisão: PATCH /v1/resellers/:id/assign-user
aceitava um resellerId diferente no corpo, e a implementação usava esse
valor do corpo como alvo real da atribuição — o :id da URL virava só uma
checagem de existência, não o alvo de fato. Corrigido: o :id da URL é
agora a única fonte de verdade; desatribuição (reseller_id = null) virou
um endpoint próprio (POST /v1/resellers/unassign-user), já que 'nenhuma
revenda' não pertence à URL de nenhuma revenda específica.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 10:06:25 -03:00
1ff55afd89 Integra módulo de Resellers (agente eden-commercial)
Resolve conflitos de merge (users.ts, audit.ts, br-documents.ts,
pnpm-lock/workspace.yaml — o worktree partiu de uma base anterior à
existência do módulo de auth/companies, então recriou versões próprias
de arquivos que já existiam no main; mantido o conteúdo do main,
incorporando só a função nova assignUserToReseller). Adiciona
resellersRepo ao barrel @eden/database e ResellersModule ao app.module.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:54:33 -03:00
eeb6977e36 fix: assertCanAssignRole/assertCanActOnUser validam existência do papel mesmo com bypass do super_admin
Bug encontrado em teste manual: convidar usuário com role_key inexistente,
sendo o ator super_admin, pulava a checagem de peso inteira (incluindo a
validação de existência) e caía direto na constraint FK do banco, virando
um 500 não tratado em vez de um 400 claro. Agora a existência do papel é
sempre validada antes do bypass de peso.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:52:07 -03:00
dca6eb2766 Add operational Resellers module (MVP CRUD, eden.md §1.7/§7)
Migration 1788439142825_resellers.cjs: `resellers` table plus nullable
`users.reseller_id` (scope=reseller per ADR-0004), indexes on
resellers.active and users.reseller_id.

packages/database/src/repositories/resellers.ts: list/get/create/update,
COALESCE-based partial update, mirroring the companies repository
pattern. Also adds repositories/audit.ts (hash-chained appendAuditEvent
over the existing audit_log table) and repositories/users.ts
(assignUserToReseller) - foundational pieces this module depends on that
hadn't landed in this worktree yet; audit.ts mirrors the sibling
worktree's already-built implementation verbatim to minimize merge
friction, and users.ts intentionally contains only the one function the
task asked to append (the fuller users repository belongs to the
auth/identity module being built in parallel).

apps/api/src/resellers/*: controller/service/module/dto mirroring the
Companies module's conventions - read open to any authenticated user,
write gated by @RequirePermission('resellers', 'edit'). Deliberate
deviation from legacy noted in code comments: the legacy hardcoded this
CRUD to requireAdmin and exposed reseller<->user assignment as
`PATCH /users/:id`; EDEN generalizes to the permission model (ADR-0004)
and adds `PATCH /v1/resellers/:id/assign-user` instead. Audits
reseller.created / reseller.updated / reseller.user_assigned.

packages/domain-shared/src/validators/br-documents.ts: CPF/CNPJ
digit-check validators (mirrors the sibling worktree's implementation
verbatim), used for the reseller `document` field the same way Companies
validates cnpj.

Known integration gap (by design, not a bug in this module): this
worktree doesn't yet have apps/api/src/auth/* or the companies-phase
exports in packages/database/src/index.ts - both are being built in
sibling worktrees and are explicitly out of scope here (per the task's
boundaries, app.module.ts and packages/database/src/index.ts are left
untouched for the user to wire up at merge time). @eden/database
typecheck/build pass cleanly; @eden/api typecheck/build currently fail
with exactly ten "cannot find module ../auth/..." /
"no exported member resellersRepo/usersRepo/auditRepo/Reseller" errors,
all attributable to those two integration points and nothing else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:44:45 -03:00
d94885a681 Integra Products e adapters S3/SMTP; corrige desvios pós-merge dos agentes
- Wire ProductsModule em app.module.ts; troca o import "deep path" que o
  agente usou (packages/database/dist/...) pela exportação correta via
  barrel (@eden/database), já que packages/database/src/index.ts é meu
  para editar durante a integração.
- S3/SMTP: troca leitura direta de process.env por loadEnv()/@eden/config
  (que não existia ainda quando o agente rodou no worktree dele).
- Resolve conflito de pnpm-lock.yaml (regenerado via pnpm install).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:44:25 -03:00
676806e1eb Merge: Products/Catalog module (agente eden-commercial) 2026-09-03 09:40:44 -03:00
8f638f2743 CRUD de Usuários e Papéis (role weight, permissões, gitignore de worktrees)
- apps/api/src/users/: invite (senha temporária, mesma entropia do
  legado), PATCH (role/isActive, com checagem de peso), reset de senha
  administrativo, troca de senha self-service, grant/revoke de acesso a
  aplicação — cada ação sensível auditada.
- apps/api/src/roles/: CRUD de papéis e gestão de role_permissions,
  exclusivo super_admin (SuperAdminGuard), mirando o requireSuperAdmin do
  legado — deliberadamente não generalizado para o modelo de permissão
  resource+action+scope, por ser a raiz do próprio sistema de
  autorização (documentado em roles.service.ts).
- apps/api/src/auth/role-weight.ts: assertCanAssignRole/assertCanActOnUser
  (eden.md §1.6) — um ator nunca atribui papel nem mexe em usuário com
  peso maior que o seu; super_admin sempre passa.
- .gitignore: .claude/worktrees/ (worktrees temporários de subagentes
  paralelos nunca devem virar submódulo do repo principal).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:40:28 -03:00
95090ba24b feat(integrations): add S3 and SMTP adapters
Port the legacy server/src/lib/s3.js and mailer.js (eden.md §4/§5) into
packages/integrations behind an adapter/port pattern, isolated from
domain logic per Master Prompt §13.

- s3/client.ts: @aws-sdk/client-s3 wrapper (uploadObject, getObjectStream,
  deleteObject, getClient/getBucket for future multipart Backup uploads).
  Lazily-initialized singleton client, forcePathStyle from
  S3_FORCE_PATH_STYLE (default true). Throws a clear "S3 não configurado
  (S3_ENDPOINT ausente)" whenever S3_ENDPOINT is unset — storage failures
  must be loud, never a silent no-op.

- smtp/mailer.ts: nodemailer wrapper (sendMail). Lazily-initialized
  singleton transporter, secure:true auto-detected on port 465, from
  defaults to SMTP_FROM or SMTP_USER. Deliberately fail-soft: if
  SMTP_HOST is unset, or if sending fails, logs and returns normally —
  no business flow ever fails because of email, matching the legacy's
  behavior exactly.

@eden/config's loadEnv()/env-schema is still a placeholder in this
worktree, so both adapters read process.env directly using the exact
var names already reserved in the target schema; @eden/config is added
as a workspace dependency so this can be swapped for a real loadEnv()
call later without touching callers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:38:41 -03:00
e5fd75c44a Add Products/Catalog module (MVP): migration, repository, API
Ports the legacy products table's core shape (eden.md §2 §1.1, Master
Prompt §6.3) faithfully but MVP-scoped: product_code sequence, the 5 fixed
price tiers (price_0/12/24/36/48), impl_unit_price, and stock/serial/
asset-tag/MAC tracking flags. Leaves out legacy telecom-billing specifics
(service_type, metered, tariff_rates, ixc_product_code) for a later phase.

Mirrors the Companies module's structure and conventions exactly: COALESCE
partial updates, JwtAuthGuard/PermissionGuard with open read / gated write
(products:edit) per ADR-0004, product.created/product.updated audit events.
Money fields are validated as non-negative NUMERIC in the DTO only — no
pricing/calculation logic here, that belongs to the future Quotes phase.

Does not touch apps/api/src/app.module.ts or packages/database/src/index.ts
(wiring is left to integration) to avoid conflicting with sibling agents'
concurrent edits to those files; the ProductsService deep-imports the
repository module directly instead of via the shared barrel, noted in a
code comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:38:35 -03:00
8d81cf506b Auth completo (Argon2id + JWT/refresh rotativo), permissões, Companies e OpenAPI
- Auth (apps/api/src/auth/): Argon2id com parâmetros versionados
  (ADR-0005), access token JWT de 15min, refresh token opaco de 256 bits
  com rotação e detecção de replay (reuso de token já rotacionado revoga
  toda a cadeia de sessões do usuário). Endpoints /api/v1/auth/
  {login,refresh,logout,me}.
- Permissões resource+action+scope (ADR-0004) via PermissionGuard,
  resolvidas do banco a cada request (nunca cacheadas no JWT) — mudança
  de papel tem efeito imediato. super_admin com bypass hardcoded,
  espelhando a trava já existente no banco.
- Companies (migration + CRUD): leitura aberta a qualquer autenticado,
  escrita restrita a companies:manage; validação de CNPJ via
  packages/domain-shared (dígito verificador).
- SeedService: cria o primeiro super_admin a partir de
  EDEN_SUPERADMIN_EMAIL/PASSWORD só quando `users` está vazia.
- OpenAPI/Swagger em /api/docs; ValidationPipe global com
  whitelist+forbidNonWhitelisted (proteção contra mass-assignment).
- packages/database: repositórios (roles/users/sessions/audit/companies)
  reutilizáveis por api e worker.

Validado manualmente contra a stack real (não só typecheck): login com
senha errada → 401 genérico; refresh rotaciona e invalida o token
anterior; reuso do token antigo → replay detectado, cadeia revogada;
logout idempotente; audit log com a sequência completa de eventos;
CNPJ inválido rejeitado; mass-assignment rejeitado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:25:48 -03:00
452 changed files with 74436 additions and 77 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.gitignore
node_modules
**/node_modules
**/dist
**/.turbo
.claude/worktrees
.tmp
*.log
.env
.env.local
.env.*.local
tema_do_Eden.zip

View File

@@ -21,6 +21,29 @@ EDEN_CORE_PORT=3001
EDEN_PARCEIROS_PORT=3002 EDEN_PARCEIROS_PORT=3002
EDEN_ASSINANTE_PORT=3003 EDEN_ASSINANTE_PORT=3003
# --- Frontend: URL base da API consumida pelo browser (Vite, build-time) ---
# Lida via import.meta.env.VITE_API_BASE_URL. Dev local (`pnpm dev`, fora de
# Docker): a API roda em localhost:8080, origem DIFERENTE da porta do Vite —
# precisa de URL absoluta + CORS (ver EDEN_CORS_ORIGINS abaixo). Copiar para
# o .env de cada app (apps/core-web/.env etc.) — Vite não lê o .env da raiz.
VITE_API_BASE_URL=http://localhost:8080/api/v1
# Build de produção via Docker (compose.yaml `VITE_API_BASE_URL` de build
# arg, default /api/v1): a API NÃO tem domínio público próprio (decisão do
# operador — só acessível por IP interno). O browser chama a API pelo MESMO
# domínio do frontend, caminho relativo /api/v1 — o proxy reverso na frente
# de cada um dos 3 domínios públicos precisa rotear internamente esse path
# para o container eden-api (rede interna, nunca exposto publicamente).
# Nesse fluxo (mesma origem) o navegador não faz preflight de CORS.
# --- CORS (origens de browser permitidas a chamar a API cross-origin) ---
# Só é relevante para dev local (core-web em localhost:3001+, API em
# localhost:8080 — origens diferentes). Nunca usar "*" — a API emite bearer
# token (ver packages/config/src/env-schema.ts). Os 3 domínios públicos NÃO
# entram aqui: eles chamam a API por caminho relativo via proxy (acima),
# nunca cross-origin diretamente na API.
EDEN_CORS_ORIGINS=http://localhost:3001,http://localhost:3002,http://localhost:3003
# --- Autenticação / sessão (ADR-0003) --- # --- Autenticação / sessão (ADR-0003) ---
# Gerar com: openssl rand -hex 48 # Gerar com: openssl rand -hex 48
JWT_SIGNING_SECRET= JWT_SIGNING_SECRET=
@@ -45,6 +68,11 @@ SMTP_PASS=
SMTP_FROM= SMTP_FROM=
# --- S3 / storage de objetos (compatível MinIO ou AWS S3) --- # --- S3 / storage de objetos (compatível MinIO ou AWS S3) ---
# Usando o serviço eden-minio do compose.yaml (bundled, sobe junto com o
# resto): endpoint é http://eden-minio:9000, e as credenciais abaixo viram
# MINIO_ROOT_USER/MINIO_ROOT_PASSWORD desse container — gerar com
# openssl rand -hex 12 / openssl rand -hex 24. Usando AWS S3 real: apagar
# S3_ENDPOINT (deixar em branco) e usar as credenciais IAM normais.
S3_ENDPOINT= S3_ENDPOINT=
S3_REGION=us-east-1 S3_REGION=us-east-1
S3_FORCE_PATH_STYLE=true S3_FORCE_PATH_STYLE=true
@@ -55,6 +83,13 @@ S3_BUCKET=
# --- URL pública base (usada para montar links absolutos em e-mails) --- # --- URL pública base (usada para montar links absolutos em e-mails) ---
PUBLIC_BASE_URL=http://localhost:3001 PUBLIC_BASE_URL=http://localhost:3001
# --- Renderização de PDF (Chromium headless via Playwright) ---
# Usado por documentos, PDF de oferta/orçamento (ANATEL) e certificado de
# assinatura — packages/integrations/src/pdf/renderer.ts. Concorrência
# limitada (cada aba do Chromium consome memória real) e timeout obrigatório.
PDF_MAX_CONCURRENCY=3
PDF_RENDER_TIMEOUT_SECONDS=15
# --- Integrações externas (preenchidas quando cada adapter for implementado) --- # --- Integrações externas (preenchidas quando cada adapter for implementado) ---
FOCUS_NFE_API_TOKEN= FOCUS_NFE_API_TOKEN=
FOCUS_NFE_BASE_URL= FOCUS_NFE_BASE_URL=

8
.gitignore vendored
View File

@@ -14,6 +14,7 @@ build/
.env.local .env.local
*.pem *.pem
*.key *.key
CREDENCIAIS-INICIAIS.md
# Logs # Logs
*.log *.log
@@ -45,3 +46,10 @@ infra/docker/pgdata/
# Claude Code local overrides (personal, never shared) # Claude Code local overrides (personal, never shared)
.claude/settings.local.json .claude/settings.local.json
# Temporary git worktrees created for parallel subagent work — each is its
# own nested git checkout; never vendor these into the main repo history.
.claude/worktrees/
# Session-local lock file for scheduled wakeups, not project content.
.claude/scheduled_tasks.lock

22
apps/api/.swcrc Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"keepClassNames": true,
"target": "es2022",
"loose": false,
"externalHelpers": false
},
"module": {
"type": "es6"
}
}

View File

@@ -6,24 +6,44 @@
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"start": "node dist/main.js", "start": "node dist/main.js",
"dev": "tsx watch src/main.ts", "dev": "tsx watch src/main.ts",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
"lint": "echo 'no linter configured yet'", "lint": "echo 'no linter configured yet'",
"test": "echo 'no tests yet'" "test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@eden/config": "workspace:*",
"@eden/database": "workspace:*", "@eden/database": "workspace:*",
"@eden/domain-shared": "workspace:*",
"@eden/integrations": "workspace:*",
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15", "@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15", "@nestjs/platform-express": "^10.4.15",
"@nestjs/swagger": "^8.1.0",
"@nestjs/terminus": "^10.2.3", "@nestjs/terminus": "^10.2.3",
"argon2": "^0.41.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"express": "^4.21.2", "express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"otplib": "^13.5.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/testing": "^10.4.15",
"@swc/core": "^1.10.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^6.0.2",
"pdf-lib": "^1.17.1",
"supertest": "^7.0.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.3" "typescript": "^5.7.3",
"unplugin-swc": "^1.5.1",
"vitest": "^2.1.8"
} }
} }

View File

@@ -1,9 +1,51 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { TerminusModule } from "@nestjs/terminus"; import { TerminusModule } from "@nestjs/terminus";
import { HealthController } from "./health/health.controller"; import { HealthController } from "./health/health.controller.js";
import { ConfigModule } from "./config/config.module.js";
import { AuthModule } from "./auth/auth.module.js";
import { BootstrapModule } from "./bootstrap/bootstrap.module.js";
import { CompaniesModule } from "./companies/companies.module.js";
import { UsersModule } from "./users/users.module.js";
import { RolesModule } from "./roles/roles.module.js";
import { ProductsModule } from "./products/products.module.js";
import { ResellersModule } from "./resellers/resellers.module.js";
import { LeadsModule } from "./leads/leads.module.js";
import { OpportunitiesModule } from "./opportunities/opportunities.module.js";
import { QuotesModule } from "./quotes/quotes.module.js";
import { ClientRegistrationsModule } from "./client-registrations/client-registrations.module.js";
import { ContractsModule } from "./contracts/contracts.module.js";
import { InventoryModule } from "./inventory/inventory.module.js";
import { SupportModule } from "./support/support.module.js";
import { HrTimeclockModule } from "./hr-timeclock/hr-timeclock.module.js";
import { FiscalModule } from "./fiscal/fiscal.module.js";
import { BillingModule } from "./billing/billing.module.js";
import { DocumentsModule } from "./documents/documents.module.js";
import { SignaturesModule } from "./signatures/signatures.module.js";
@Module({ @Module({
imports: [TerminusModule], imports: [
ConfigModule,
TerminusModule,
AuthModule,
BootstrapModule,
CompaniesModule,
UsersModule,
RolesModule,
ProductsModule,
ResellersModule,
LeadsModule,
OpportunitiesModule,
QuotesModule,
ClientRegistrationsModule,
ContractsModule,
InventoryModule,
SupportModule,
HrTimeclockModule,
FiscalModule,
BillingModule,
DocumentsModule,
SignaturesModule,
],
controllers: [HealthController], controllers: [HealthController],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -0,0 +1,116 @@
import { Body, Controller, Get, HttpCode, Post, Req, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import type { Request } from "express";
import { AuthService } from "./auth.service.js";
import { MfaService } from "./mfa.service.js";
import { LoginDto } from "./dto/login.dto.js";
import { RefreshDto } from "./dto/refresh.dto.js";
import { MfaChallengeDto } from "./dto/mfa-challenge.dto.js";
import { MfaCodeDto } from "./dto/mfa-code.dto.js";
import { ForgotPasswordDto } from "./dto/forgot-password.dto.js";
import { ResetPasswordDto } from "./dto/reset-password.dto.js";
import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { CurrentUser } from "./decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
import { RateLimitGuard } from "../common/rate-limit/rate-limit.guard.js";
import { RateLimit } from "../common/rate-limit/rate-limit.decorator.js";
import {
AUTH_FORGOT_PASSWORD_RULE,
AUTH_LOGIN_RULE,
AUTH_MFA_CHALLENGE_RULE,
AUTH_LOGOUT_RULE,
AUTH_REFRESH_RULE,
AUTH_RESET_PASSWORD_RULE,
} from "../common/rate-limit/rate-limit-rules.js";
function requestContext(req: Request) {
return {
ipAddress: req.ip ?? null,
userAgent: req.headers["user-agent"] ?? null,
};
}
@ApiTags("auth")
@Controller("v1/auth")
@UseGuards(RateLimitGuard)
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly mfaService: MfaService,
) {}
@Post("login")
@HttpCode(200)
@RateLimit(AUTH_LOGIN_RULE)
async login(@Body() dto: LoginDto, @Req() req: Request) {
return this.auth.login(dto.email, dto.password, dto.applicationKey, requestContext(req));
}
@Post("mfa/challenge")
@HttpCode(200)
@RateLimit(AUTH_MFA_CHALLENGE_RULE)
async mfaChallenge(@Body() dto: MfaChallengeDto, @Req() req: Request) {
return this.auth.completeMfaLogin(dto.mfaChallenge, dto.code, requestContext(req));
}
@Post("mfa/enroll")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async mfaEnroll(@CurrentUser() user: EdenAuthenticatedUser) {
return this.mfaService.startEnrollment(user.id, user.email);
}
@Post("mfa/confirm")
@HttpCode(204)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async mfaConfirm(@Body() dto: MfaCodeDto, @CurrentUser() user: EdenAuthenticatedUser) {
await this.mfaService.confirmEnrollment(user.id, dto.code);
}
@Post("mfa/disable")
@HttpCode(204)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async mfaDisable(@Body() dto: MfaCodeDto, @CurrentUser() user: EdenAuthenticatedUser) {
await this.mfaService.disable(user.id, dto.code);
}
@Post("forgot-password")
@HttpCode(200)
@RateLimit(AUTH_FORGOT_PASSWORD_RULE)
async forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: Request) {
await this.auth.forgotPassword(dto.email, requestContext(req));
// Always the same response regardless of whether the email is
// registered — anti-enumeration (eden.md §4.1), mirrors login.
return { message: "Se este e-mail estiver cadastrado, você receberá instruções para redefinir sua senha." };
}
@Post("reset-password")
@HttpCode(204)
@RateLimit(AUTH_RESET_PASSWORD_RULE)
async resetPassword(@Body() dto: ResetPasswordDto, @Req() req: Request) {
await this.auth.resetPassword(dto.token, dto.newPassword, requestContext(req));
}
@Post("refresh")
@HttpCode(200)
@RateLimit(AUTH_REFRESH_RULE)
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {
return this.auth.refresh(dto.refreshToken, requestContext(req));
}
@Post("logout")
@HttpCode(204)
@RateLimit(AUTH_LOGOUT_RULE)
async logout(@Body() dto: RefreshDto, @Req() req: Request) {
await this.auth.logout(dto.refreshToken, requestContext(req));
}
@Get("me")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async me(@CurrentUser() user: EdenAuthenticatedUser) {
return this.auth.getProfile(user.id);
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller.js";
import { AuthService } from "./auth.service.js";
import { PasswordService } from "./password.service.js";
import { TokenService } from "./token.service.js";
import { MfaService } from "./mfa.service.js";
import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { PermissionGuard } from "./permission.guard.js";
import { SuperAdminGuard } from "./super-admin.guard.js";
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
import { RateLimitModule } from "../common/rate-limit/rate-limit.module.js";
@Module({
imports: [RateLimitModule],
controllers: [AuthController],
providers: [
AuthService,
PasswordService,
TokenService,
MfaService,
FieldEncryptionService,
JwtAuthGuard,
PermissionGuard,
SuperAdminGuard,
],
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard, SuperAdminGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,323 @@
import { randomBytes, createHash } from "node:crypto";
import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from "@nestjs/common";
import { usersRepo, sessionsRepo, auditRepo, type User } from "@eden/database";
import { sendMail } from "@eden/integrations";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import { PasswordService } from "./password.service.js";
import { TokenService } from "./token.service.js";
import { MfaService } from "./mfa.service.js";
import { resolvePermissions } from "./permissions.js";
export interface RequestContext {
ipAddress?: string | null;
userAgent?: string | null;
}
export interface AuthResult {
accessToken: string;
expiresIn: number;
refreshToken: string;
user: PublicUser;
}
export interface MfaRequiredResult {
mfaRequired: true;
mfaChallenge: string;
expiresIn: number;
}
export type LoginResult = AuthResult | MfaRequiredResult;
export interface PublicUser {
id: string;
email: string;
fullName: string | null;
roleKey: string;
}
function toPublicUser(user: User): PublicUser {
return { id: user.id, email: user.email, fullName: user.full_name, roleKey: user.role_key };
}
const GENERIC_LOGIN_ERROR = "Credenciais inválidas";
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h — deliberately short-lived (this token grants a password change)
function hashResetToken(plaintext: string): string {
return createHash("sha256").update(plaintext).digest("hex");
}
@Injectable()
export class AuthService {
constructor(
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
private readonly mfa: MfaService,
@Inject(EDEN_ENV) private readonly env: EdenEnv,
) {}
async login(
email: string,
password: string,
applicationKey: "core" | "reseller" | "subscriber",
ctx: RequestContext,
): Promise<LoginResult> {
const user = await usersRepo.findUserByEmail(email);
// Same generic error whether the account doesn't exist or the password
// is wrong — anti-enumeration, mirrors eden.md §4.1.
if (!user || !user.password_hash) {
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
}
const valid = await this.passwords.verify(user.password_hash, password);
if (!valid) {
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
}
if (this.passwords.needsRehash(user.password_algo_version)) {
const { hash, algoVersion } = await this.passwords.hash(password);
await usersRepo.updateUserPassword(user.id, hash, algoVersion);
}
const grantedApps = await usersRepo.getUserApplications(user.id);
if (!grantedApps.includes(applicationKey)) {
throw new ForbiddenException("Usuário sem acesso a esta aplicação");
}
if (user.mfa_enabled) {
const challenge = this.tokens.signMfaChallenge({ sub: user.id, app: applicationKey });
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.mfa_challenge_issued",
resourceType: "user",
resourceId: user.id,
metadata: { applicationKey },
ipAddress: ctx.ipAddress,
});
return { mfaRequired: true, mfaChallenge: challenge.token, expiresIn: challenge.expiresIn };
}
const result = await this.issueTokens(user, applicationKey, ctx);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.login",
resourceType: "user",
resourceId: user.id,
metadata: { applicationKey },
ipAddress: ctx.ipAddress,
});
return result;
}
/** Second step of login when the account has MFA enabled — exchanges a password-verified challenge + TOTP code for real tokens. */
async completeMfaLogin(challengeToken: string, code: string, ctx: RequestContext): Promise<AuthResult> {
let payload;
try {
payload = this.tokens.verifyMfaChallenge(challengeToken);
} catch {
throw new UnauthorizedException("Desafio MFA inválido ou expirado");
}
const user = await usersRepo.findUserById(payload.sub);
if (!user || !user.is_active || !user.mfa_enabled || !user.mfa_secret_encrypted) {
throw new UnauthorizedException("Desafio MFA inválido ou expirado");
}
if (!(await this.mfa.verifyCode(user.mfa_secret_encrypted, code))) {
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.mfa_challenge_failed",
resourceType: "user",
resourceId: user.id,
ipAddress: ctx.ipAddress,
});
throw new UnauthorizedException("Código inválido");
}
const result = await this.issueTokens(user, payload.app, ctx);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.login",
resourceType: "user",
resourceId: user.id,
metadata: { applicationKey: payload.app, viaMfa: true },
ipAddress: ctx.ipAddress,
});
return result;
}
async refresh(refreshTokenPlaintext: string, ctx: RequestContext): Promise<AuthResult> {
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
const active = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
if (!active) {
// Replay detection: a token that exists but is revoked/expired being
// reused is a signal the refresh token chain may be compromised —
// revoke every session of that user defensively (ADR-0003).
const anyState = await sessionsRepo.findSessionByRefreshTokenHashAnyState(hash);
if (anyState?.revoked_at) {
await sessionsRepo.revokeAllUserSessions(anyState.user_id);
await auditRepo.appendAuditEvent({
actorType: "system",
actorId: anyState.user_id,
action: "auth.refresh_replay_detected",
resourceType: "session",
resourceId: anyState.id,
ipAddress: ctx.ipAddress,
});
}
throw new UnauthorizedException("Sessão inválida ou expirada");
}
const user = await usersRepo.findUserById(active.user_id);
if (!user || !user.is_active) {
throw new UnauthorizedException("Sessão inválida ou expirada");
}
await sessionsRepo.touchSession(active.id);
const result = await this.issueTokens(user, active.application_key, ctx, active.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.refresh",
resourceType: "session",
resourceId: active.id,
ipAddress: ctx.ipAddress,
});
return result;
}
async logout(refreshTokenPlaintext: string, ctx: RequestContext): Promise<void> {
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
const session = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
if (!session) return; // idempotent — already logged out
await sessionsRepo.revokeSession(session.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: session.user_id,
action: "auth.logout",
resourceType: "session",
resourceId: session.id,
ipAddress: ctx.ipAddress,
});
}
async getProfile(userId: string) {
const user = await usersRepo.findUserById(userId);
if (!user || !user.is_active) throw new UnauthorizedException();
const permissions = await resolvePermissions(user.role_key);
const applications = await usersRepo.getUserApplications(user.id);
return {
user: toPublicUser(user),
permissions: permissions.superAdmin
? ("ALL" as const)
: permissions.grants.map((g) => ({ resource: g.resource, action: g.action, scope: g.scope })),
applications,
};
}
private async issueTokens(
user: User,
applicationKey: "core" | "reseller" | "subscriber",
ctx: RequestContext,
replacesSessionId?: string,
): Promise<AuthResult> {
const access = this.tokens.signAccessToken({ sub: user.id, app: applicationKey });
const refresh = this.tokens.generateRefreshToken();
await sessionsRepo.createSession({
userId: user.id,
applicationKey,
refreshTokenHash: refresh.hash,
expiresAt: refresh.expiresAt,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
replacesSessionId,
});
return {
accessToken: access.token,
expiresIn: access.expiresIn,
refreshToken: refresh.plaintext,
user: toPublicUser(user),
};
}
/**
* Always resolves the same way whether or not the email is registered —
* same anti-enumeration discipline as login (eden.md §4.1). If the
* account exists (and is active), stages a reset token and emails a
* link; the mailer is fail-soft (packages/integrations), so a delivery
* failure never surfaces as an error here either.
*/
async forgotPassword(email: string, ctx: RequestContext): Promise<void> {
const user = await usersRepo.findUserByEmail(email);
if (!user) return;
const plaintextToken = randomBytes(32).toString("hex");
const tokenHash = hashResetToken(plaintextToken);
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS);
await usersRepo.setResetToken(user.id, tokenHash, expiresAt);
const resetUrl = `${this.env.PUBLIC_BASE_URL ?? "http://localhost:3001"}/reset-password?token=${plaintextToken}`;
await sendMail({
to: user.email,
subject: "EDEN — Redefinição de senha",
html: `
<p>Olá${user.full_name ? `, ${user.full_name}` : ""},</p>
<p>Recebemos um pedido para redefinir sua senha no EDEN. Se foi você, clique no link abaixo (válido por 1 hora):</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p>Se você não pediu essa redefinição, pode ignorar este e-mail — sua senha continua a mesma.</p>
`,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.password_reset_requested",
resourceType: "user",
resourceId: user.id,
ipAddress: ctx.ipAddress,
});
}
/**
* Consumes a reset token issued by forgotPassword(). The token is
* cleared whether or not this call succeeds past validation, so a
* token can never be replayed — and every existing session is revoked
* (a password change invalidates any session that might have been
* established under the old, possibly-compromised credential).
*/
async resetPassword(plaintextToken: string, newPassword: string, ctx: RequestContext): Promise<void> {
const tokenHash = hashResetToken(plaintextToken);
const user = await usersRepo.findUserByResetTokenHash(tokenHash);
if (!user) {
throw new BadRequestException("Token inválido ou expirado");
}
const { hash, algoVersion } = await this.passwords.hash(newPassword);
await usersRepo.updateUserPassword(user.id, hash, algoVersion);
await usersRepo.clearResetToken(user.id);
await sessionsRepo.revokeAllUserSessions(user.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.password_reset_completed",
resourceType: "user",
resourceId: user.id,
ipAddress: ctx.ipAddress,
});
}
}

View File

@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<Request>();
return request.edenUser;
});

View File

@@ -0,0 +1,19 @@
import { SetMetadata } from "@nestjs/common";
import type { RolePermission } from "@eden/database";
export const REQUIRE_PERMISSION_KEY = "eden:requirePermission";
export interface RequiredPermission {
resource: string;
action: RolePermission["action"];
}
/**
* Declares that an endpoint needs (resource, action) per ADR-0004.
* super_admin always passes. Confirms the actor holds *some* scope for
* this resource/action — row-level filtering by the granted scope (own
* vs. all, etc.) is the responsibility of the service/query layer, which
* reads it off `request.edenUser.permissions` (see permissions.ts).
*/
export const RequirePermission = (resource: string, action: RequiredPermission["action"]) =>
SetMetadata(REQUIRE_PERMISSION_KEY, { resource, action } satisfies RequiredPermission);

View File

@@ -0,0 +1,6 @@
import { IsEmail } from "class-validator";
export class ForgotPasswordDto {
@IsEmail()
email!: string;
}

View File

@@ -0,0 +1,13 @@
import { IsEmail, IsIn, IsString, MinLength } from "class-validator";
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(1)
password!: string;
@IsIn(["core", "reseller", "subscriber"])
applicationKey!: "core" | "reseller" | "subscriber";
}

View File

@@ -0,0 +1,11 @@
import { IsString, Length, MinLength } from "class-validator";
export class MfaChallengeDto {
@IsString()
@MinLength(1)
mfaChallenge!: string;
@IsString()
@Length(6, 6)
code!: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, Length } from "class-validator";
export class MfaCodeDto {
@IsString()
@Length(6, 6)
code!: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class RefreshDto {
@IsString()
@MinLength(32)
refreshToken!: string;
}

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from "class-validator";
export class ResetPasswordDto {
@IsString()
token!: string;
@IsString()
@MinLength(8, { message: "A nova senha precisa ter ao menos 8 caracteres" })
newPassword!: string;
}

View File

@@ -0,0 +1,71 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import type { Request } from "express";
import { usersRepo } from "@eden/database";
import { TokenService } from "./token.service.js";
import { resolvePermissions, type ResolvedPermissions } from "./permissions.js";
export interface EdenAuthenticatedUser {
id: string;
email: string;
fullName: string | null;
roleKey: string;
applicationKey: "core" | "reseller" | "subscriber";
permissions: ResolvedPermissions;
/** Nullable — scopes the actor to a reseller for scope='reseller' permission checks (eden.md §7, ADR-0004). */
resellerId: string | null;
/** eden.md §1.1/§3.3 — only meaningful combined with roleKey==='admin'; super_admin bypasses regardless. */
canApproveDiscount: boolean;
}
declare module "express" {
interface Request {
edenUser?: EdenAuthenticatedUser;
}
}
/**
* Verifies the access token's signature/expiry, then re-resolves the user
* and their permissions from the database on every request — deliberately
* not cached in the JWT payload, so a role/permission change (or account
* deactivation) takes effect immediately rather than waiting out the
* token's 15-minute lifetime. Mirrors the legacy's LEFT JOIN-on-every-
* request philosophy (eden.md §1, §3.3).
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly tokens: TokenService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const header = request.headers.authorization;
if (!header?.startsWith("Bearer ")) {
throw new UnauthorizedException("Token de acesso ausente");
}
let payload;
try {
payload = this.tokens.verifyAccessToken(header.slice("Bearer ".length));
} catch {
throw new UnauthorizedException("Token de acesso inválido ou expirado");
}
const user = await usersRepo.findUserById(payload.sub);
if (!user || !user.is_active) {
throw new UnauthorizedException("Usuário inativo ou inexistente");
}
const permissions = await resolvePermissions(user.role_key);
request.edenUser = {
id: user.id,
email: user.email,
fullName: user.full_name,
roleKey: user.role_key,
applicationKey: payload.app,
permissions,
resellerId: user.reseller_id,
canApproveDiscount: user.can_approve_discount,
};
return true;
}
}

View File

@@ -0,0 +1,49 @@
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
import { generateSecret, generateURI, verify } from "otplib";
import { usersRepo } from "@eden/database";
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
const ISSUER = "EDEN";
@Injectable()
export class MfaService {
constructor(private readonly fieldEncryption: FieldEncryptionService) {}
/** Stages a new TOTP secret (not yet active — see confirmEnrollment). */
async startEnrollment(userId: string, email: string): Promise<{ secret: string; otpauthUrl: string }> {
const secret = generateSecret();
await usersRepo.setMfaSecret(userId, this.fieldEncryption.encrypt(secret));
const otpauthUrl = generateURI({ issuer: ISSUER, label: email, secret });
return { secret, otpauthUrl };
}
/** Verifies the first code against the staged secret and flips mfa_enabled on. */
async confirmEnrollment(userId: string, code: string): Promise<void> {
const user = await usersRepo.findUserById(userId);
if (!user?.mfa_secret_encrypted) {
throw new BadRequestException("Nenhum cadastro de MFA pendente para este usuário");
}
if (!(await this.verifyCode(user.mfa_secret_encrypted, code))) {
throw new BadRequestException("Código inválido");
}
await usersRepo.confirmMfaEnrollment(userId);
}
/** Requires a currently-valid code — proves the factor, not just an authenticated session. */
async disable(userId: string, code: string): Promise<void> {
const user = await usersRepo.findUserById(userId);
if (!user?.mfa_enabled || !user.mfa_secret_encrypted) {
throw new BadRequestException("MFA não está habilitado para este usuário");
}
if (!(await this.verifyCode(user.mfa_secret_encrypted, code))) {
throw new UnauthorizedException("Código inválido");
}
await usersRepo.disableMfa(userId);
}
async verifyCode(encryptedSecret: string, code: string): Promise<boolean> {
const secret = this.fieldEncryption.decrypt(encryptedSecret);
const result = await verify({ token: code, secret });
return result.valid;
}
}

View File

@@ -0,0 +1,44 @@
import { Injectable } from "@nestjs/common";
import * as argon2 from "argon2";
/**
* Argon2id password hashing with versioned parameters (ADR-0005). Bumping
* CURRENT_ALGO_VERSION and its entry in PARAMS_BY_VERSION lets us raise
* cost over time without invalidating existing hashes — verification
* always uses the parameters embedded in argon2's own encoded hash
* string, and a stored user is only re-hashed with the new parameters on
* their next successful login (see needsRehash()).
*/
const PARAMS_BY_VERSION: Record<number, argon2.Options> = {
1: {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB — OWASP minimum recommendation for argon2id
timeCost: 2,
parallelism: 1,
},
};
export const CURRENT_ALGO_VERSION = 1;
@Injectable()
export class PasswordService {
async hash(plain: string): Promise<{ hash: string; algoVersion: number }> {
const params = PARAMS_BY_VERSION[CURRENT_ALGO_VERSION];
const hash = await argon2.hash(plain, params);
return { hash, algoVersion: CURRENT_ALGO_VERSION };
}
async verify(hash: string, plain: string): Promise<boolean> {
try {
return await argon2.verify(hash, plain);
} catch {
// Malformed/foreign hash — treat as verification failure, never throw
// into the caller (which would otherwise leak a 500 instead of 401).
return false;
}
}
needsRehash(algoVersion: number): boolean {
return algoVersion !== CURRENT_ALGO_VERSION;
}
}

View File

@@ -0,0 +1,36 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import type { Request } from "express";
import { REQUIRE_PERMISSION_KEY, type RequiredPermission } from "./decorators/require-permission.decorator.js";
import { hasPermission } from "./permissions.js";
/**
* Runs after JwtAuthGuard (which populates request.edenUser). Endpoints
* without @RequirePermission are left alone — authentication only.
*/
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.get<RequiredPermission | undefined>(
REQUIRE_PERMISSION_KEY,
context.getHandler(),
);
if (!required) return true;
const request = context.switchToHttp().getRequest<Request>();
const user = request.edenUser;
if (!user) {
throw new ForbiddenException("Autenticação necessária");
}
if (!hasPermission(user.permissions, required.resource, required.action)) {
throw new ForbiddenException(
`Sem permissão para '${required.action}' em '${required.resource}'`,
);
}
return true;
}
}

View File

@@ -0,0 +1,55 @@
import { rolesRepo, type RolePermission } from "@eden/database";
export const SUPER_ADMIN_ROLE = "super_admin";
export type ResolvedPermissions =
| { superAdmin: true }
| { superAdmin: false; grants: RolePermission[] };
/**
* super_admin is never looked up in role_permissions — hardcoded bypass,
* mirrored from the legacy system and enforced again at the DB level
* (docs/adr/0004-permission-model.md, chk_role_permissions_no_super_admin).
*/
export async function resolvePermissions(roleKey: string): Promise<ResolvedPermissions> {
if (roleKey === SUPER_ADMIN_ROLE) return { superAdmin: true };
const grants = await rolesRepo.getRolePermissions(roleKey);
return { superAdmin: false, grants };
}
/**
* Returns the maximum scope the actor holds for (resource, action), or
* null if they hold none. Scope precedence — broader wins when a role
* somehow has more than one row for the same resource+action (shouldn't
* happen given the UNIQUE constraint per scope, but a role could have
* both e.g. 'own' and 'all' rows intentionally):
*/
const SCOPE_RANK: Record<RolePermission["scope"], number> = {
own: 1,
team: 2,
reseller: 3,
legal_entity: 4,
all: 5,
};
export function maxGrantedScope(
permissions: ResolvedPermissions,
resource: string,
action: RolePermission["action"],
): RolePermission["scope"] | "all" | null {
if (permissions.superAdmin) return "all";
const matches = permissions.grants.filter((g) => g.resource === resource && g.action === action);
if (matches.length === 0) return null;
return matches.reduce<RolePermission["scope"]>(
(best, g) => (SCOPE_RANK[g.scope] > SCOPE_RANK[best] ? g.scope : best),
matches[0]!.scope,
);
}
export function hasPermission(
permissions: ResolvedPermissions,
resource: string,
action: RolePermission["action"],
): boolean {
return maxGrantedScope(permissions, resource, action) !== null;
}

View File

@@ -0,0 +1,42 @@
import { BadRequestException, ForbiddenException } from "@nestjs/common";
import { rolesRepo } from "@eden/database";
import { SUPER_ADMIN_ROLE } from "./permissions.js";
/**
* Role weight (eden.md §1.6, ADR-0004): an actor can never assign a role
* heavier than their own, nor act on a user whose *current* role is
* heavier than the actor's — orthogonal to the resource/action/scope
* permission check. super_admin (weight 100, hardcoded ceiling) always
* passes the *weight comparison* — but role existence is still validated
* even for super_admin, otherwise an invalid role key would sail past
* this check and only fail later as an unhandled FK-constraint 500 from
* the database (an earlier bug caught by manual testing: this function
* must be the single place that turns "role doesn't exist" into a clean
* 400, not the DB constraint).
*/
async function getWeight(roleKey: string): Promise<number> {
const role = await rolesRepo.getRoleByKey(roleKey);
if (!role) throw new BadRequestException(`Papel '${roleKey}' não existe`);
return role.weight;
}
export async function assertCanAssignRole(actorRoleKey: string, targetRoleKey: string): Promise<void> {
const targetWeight = await getWeight(targetRoleKey);
if (actorRoleKey === SUPER_ADMIN_ROLE) return;
const actorWeight = await getWeight(actorRoleKey);
if (targetWeight > actorWeight) {
throw new ForbiddenException("Você não pode atribuir um papel com peso maior que o seu.");
}
}
export async function assertCanActOnUser(
actorRoleKey: string,
targetUserRoleKey: string,
): Promise<void> {
const targetWeight = await getWeight(targetUserRoleKey);
if (actorRoleKey === SUPER_ADMIN_ROLE) return;
const actorWeight = await getWeight(actorRoleKey);
if (targetWeight > actorWeight) {
throw new ForbiddenException("Você não pode alterar um usuário com papel de peso maior que o seu.");
}
}

View File

@@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import type { Request } from "express";
import { SUPER_ADMIN_ROLE } from "./permissions.js";
/**
* Role definition and the permission matrix itself (role_permissions) are
* the root of the whole authorization system — deliberately kept
* super_admin-only and hardcoded, exactly like the legacy's
* `requireSuperAdmin` (eden.md §8), rather than generalized to the
* resource/action/scope model. Generalizing config-of-the-authorization-
* system-itself would let a misconfigured permission accidentally grant
* permission-configuration rights — not worth the flexibility.
*/
@Injectable()
export class SuperAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
if (request.edenUser?.roleKey !== SUPER_ADMIN_ROLE) {
throw new ForbiddenException("Exclusivo do super_admin");
}
return true;
}
}

View File

@@ -0,0 +1,96 @@
import { randomBytes, createHash } from "node:crypto";
import { Inject, Injectable } from "@nestjs/common";
import jwt from "jsonwebtoken";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
export interface AccessTokenPayload {
sub: string; // user id
app: "core" | "reseller" | "subscriber";
}
/**
* 8h — revisado a partir dos 15min originais da ADR-0003 (ver o adendo no
* próprio documento): o frontend não implementa refresh silencioso ainda
* (`AuthContext.tsx`, `session.ts` — "No refresh-token rotation in this
* leva"), então um access token de 15min derrubava o usuário no meio do
* trabalho. 8h cobre um expediente inteiro sem exigir login de novo;
* revogação continua granular via a tabela `sessions` (logout/"encerrar
* todas as sessões" não dependem da expiração do JWT).
*/
const ACCESS_TOKEN_TTL_SECONDS = 8 * 60 * 60;
const REFRESH_TOKEN_TTL_DAYS = 30;
const MFA_CHALLENGE_TTL_SECONDS = 2 * 60;
const MFA_CHALLENGE_PURPOSE = "mfa_challenge" as const;
export interface MfaChallengePayload {
sub: string;
app: "core" | "reseller" | "subscriber";
purpose: typeof MFA_CHALLENGE_PURPOSE;
}
@Injectable()
export class TokenService {
private readonly secret: string;
constructor(@Inject(EDEN_ENV) env: EdenEnv) {
this.secret = env.JWT_SIGNING_SECRET;
}
signAccessToken(payload: AccessTokenPayload): { token: string; expiresIn: number } {
const token = jwt.sign(payload, this.secret, { expiresIn: ACCESS_TOKEN_TTL_SECONDS });
return { token, expiresIn: ACCESS_TOKEN_TTL_SECONDS };
}
verifyAccessToken(token: string): AccessTokenPayload {
const payload = jwt.verify(token, this.secret) as AccessTokenPayload & { purpose?: unknown };
// Both token kinds are signed with the same secret, so signature
// verification alone doesn't distinguish them — a real access token
// must never carry a `purpose` claim (only single-purpose tokens like
// the MFA challenge do). Without this check, a password-verified MFA
// challenge token would be indistinguishable from a full access token
// and grant complete API access without ever checking the second
// factor — this was caught live (a real bug, not hypothetical).
if (payload.purpose !== undefined) {
throw new Error("Token não é um access token válido");
}
return payload;
}
/** Opaque, high-entropy refresh token — never a JWT (ADR-0003). Returns the
* plaintext (shown once to the client) and its SHA-256 hash (the only
* thing persisted). */
generateRefreshToken(): { plaintext: string; hash: string; expiresAt: Date } {
const plaintext = randomBytes(32).toString("hex"); // 256 bits
const hash = this.hashRefreshToken(plaintext);
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
return { plaintext, hash, expiresAt };
}
hashRefreshToken(plaintext: string): string {
return createHash("sha256").update(plaintext).digest("hex");
}
/**
* Short-lived, single-purpose token issued after password verification
* when the account has MFA enabled — proves "password already checked"
* without granting API access. The `purpose` claim is what keeps this
* out of JwtAuthGuard (see verifyAccessToken's explicit rejection of any
* `purpose` claim) — without it this token would be a fully valid
* access token, defeating MFA entirely.
*/
signMfaChallenge(payload: Omit<MfaChallengePayload, "purpose">): { token: string; expiresIn: number } {
const token = jwt.sign({ ...payload, purpose: MFA_CHALLENGE_PURPOSE }, this.secret, {
expiresIn: MFA_CHALLENGE_TTL_SECONDS,
});
return { token, expiresIn: MFA_CHALLENGE_TTL_SECONDS };
}
verifyMfaChallenge(token: string): MfaChallengePayload {
const payload = jwt.verify(token, this.secret) as Partial<MfaChallengePayload>;
if (payload.purpose !== MFA_CHALLENGE_PURPOSE || !payload.sub || !payload.app) {
throw new Error("Token de desafio MFA inválido");
}
return payload as MfaChallengePayload;
}
}

View File

@@ -0,0 +1,104 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { BillingService } from "./billing.service.js";
import { CreateBillingSubscriptionDto } from "./dto/create-billing-subscription.dto.js";
import { CreateBillingRunDto } from "./dto/create-billing-run.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type {
BillingRunStatus,
BillingSubscriptionStatus,
InvoiceStatus,
} from "@eden/database";
/**
* Billing — Master Prompt §6.10, ADR-0008. Recurring subscription ->
* billing_run -> invoice MVP. Explicitly NOT Finance/AR-AP (títulos,
* boletos, dunning) or Fiscal (documento fiscal) — see ADR-0008. See
* billing.service.ts for the authorization rules (why run
* creation/consolidation requires 'all' scope).
*/
@ApiTags("billing")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/billing")
export class BillingController {
constructor(private readonly billing: BillingService) {}
@Get("subscriptions")
@RequirePermission("billing", "view")
listSubscriptions(
@CurrentUser() user: EdenAuthenticatedUser,
@Query("status") status?: BillingSubscriptionStatus,
) {
return this.billing.listSubscriptions(user, status);
}
@Post("subscriptions")
@RequirePermission("billing", "edit")
createSubscription(
@Body() dto: CreateBillingSubscriptionDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.billing.createSubscription(dto, user);
}
@Patch("subscriptions/:id/cancel")
@RequirePermission("billing", "edit")
cancelSubscription(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.billing.cancelSubscription(id, user);
}
@Post("runs")
@RequirePermission("billing", "edit")
createRun(@Body() dto: CreateBillingRunDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.billing.createOrRegenerateRun(dto, user);
}
@Get("runs")
@RequirePermission("billing", "view")
listRuns(
@CurrentUser() user: EdenAuthenticatedUser,
@Query("referenceYear") referenceYear?: string,
@Query("referenceMonth") referenceMonth?: string,
@Query("status") status?: BillingRunStatus,
) {
return this.billing.listRuns(user, {
referenceYear: referenceYear != null ? Number(referenceYear) : undefined,
referenceMonth: referenceMonth != null ? Number(referenceMonth) : undefined,
status,
});
}
@Get("runs/:id")
@RequirePermission("billing", "view")
getRun(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.billing.getRun(id, user);
}
@Post("runs/:id/consolidate")
@RequirePermission("billing", "edit")
consolidateRun(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.billing.consolidateRun(id, user);
}
@Get("invoices")
@RequirePermission("billing", "view")
listInvoices(
@CurrentUser() user: EdenAuthenticatedUser,
@Query("status") status?: InvoiceStatus,
@Query("referenceYear") referenceYear?: string,
@Query("referenceMonth") referenceMonth?: string,
@Query("resellerId") resellerId?: string,
) {
return this.billing.listInvoices(user, {
status,
referenceYear: referenceYear != null ? Number(referenceYear) : undefined,
referenceMonth: referenceMonth != null ? Number(referenceMonth) : undefined,
resellerId,
});
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { BillingController } from "./billing.controller.js";
import { BillingService } from "./billing.service.js";
@Module({
imports: [AuthModule],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -0,0 +1,262 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import {
auditRepo,
billingRepo,
type BillingRun,
type BillingRunWithInvoices,
type BillingSubscription,
type Invoice,
} from "@eden/database";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import type { CreateBillingSubscriptionDto } from "./dto/create-billing-subscription.dto.js";
import type { CreateBillingRunDto } from "./dto/create-billing-run.dto.js";
const {
BillingSubscriptionNotFoundError,
BillingRunNotFoundError,
BillingRunConsolidatedError,
BillingPeriodAlreadyConsolidatedError,
} = billingRepo;
const RESOURCE = "billing";
/**
* Billing (Master Prompt §6.10, ADR-0008) — recurring subscription ->
* billing_run -> invoice flow. Strictly distinct from Finance/AR-AP
* (títulos/boletos/cobrança) and Fiscal (documento fiscal) — neither is
* touched here.
*
* Authorization: creating/regenerating and consolidating a billing_run
* always requires 'all' scope for 'billing'/'edit' — a run spans every
* reseller with a matching billing_day, so only central back-office
* operators (not a single reseller) can trigger a billing close. Reading
* and per-subscription/invoice mutations respect the actor's scope
* ('all' vs 'reseller'), same pattern as contracts.service.ts.
*/
@Injectable()
export class BillingService {
// -------------------------------------------------------------------
// billing_subscriptions
// -------------------------------------------------------------------
async listSubscriptions(
user: EdenAuthenticatedUser,
status?: BillingSubscription["status"],
): Promise<BillingSubscription[]> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
if (scope === "all") return billingRepo.listBillingSubscriptions({ scope: "all", status });
return billingRepo.listBillingSubscriptions({
scope: "reseller",
resellerId: user.resellerId,
status,
});
}
async createSubscription(
dto: CreateBillingSubscriptionDto,
user: EdenAuthenticatedUser,
): Promise<BillingSubscription> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "edit");
if (!scope) throw new ForbiddenException(`Sem permissão para 'edit' em '${RESOURCE}'`);
// Never trust a client-supplied resellerId to place a subscription
// under an arbitrary reseller (CLAUDE.md: no authz decision trusts a
// client-supplied reseller_id). A 'reseller'-scoped actor always gets
// their own session-resolved reseller; only 'all' scope may target an
// explicit resellerId from the body.
let resellerId: string | null;
if (scope === "all") {
resellerId = dto.resellerId ?? null;
} else {
resellerId = user.resellerId;
if (!resellerId) {
throw new ForbiddenException("Usuário não está vinculado a nenhuma revenda");
}
}
const subscription = await billingRepo.createBillingSubscription({
contractId: dto.contractId,
resellerId,
monthlyValue: dto.monthlyValue,
billingDay: dto.billingDay,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "billing_subscription.created",
resourceType: "billing_subscription",
resourceId: subscription.id,
metadata: { contractId: dto.contractId, billingDay: dto.billingDay },
});
return subscription;
}
async cancelSubscription(id: string, user: EdenAuthenticatedUser): Promise<BillingSubscription> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "edit");
if (!scope) throw new ForbiddenException(`Sem permissão para 'edit' em '${RESOURCE}'`);
const existing = await billingRepo.getBillingSubscriptionById(id);
if (!existing) throw new NotFoundException("Subscription não encontrada");
if (scope !== "all" && existing.reseller_id !== user.resellerId) {
throw new NotFoundException("Subscription não encontrada");
}
const cancelled = await billingRepo.cancelBillingSubscription(id, user.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "billing_subscription.cancelled",
resourceType: "billing_subscription",
resourceId: id,
metadata: {},
});
return cancelled;
}
// -------------------------------------------------------------------
// billing_runs / invoices
// -------------------------------------------------------------------
/**
* Creates a new draft run, or — if a draft already exists for the same
* (referenceYear, referenceMonth, billingDay) — regenerates its
* invoices in place (idempotent: never duplicates). 409s if a run for
* that exact period is already consolidated (controlled re-billing is
* deferred, see PENDING_billing.cjs header).
*/
async createOrRegenerateRun(
dto: CreateBillingRunDto,
user: EdenAuthenticatedUser,
): Promise<BillingRunWithInvoices> {
this.requireAllScope(user, "edit");
try {
const run = await billingRepo.generateOrRegenerateBillingRun({
referenceYear: dto.referenceYear,
referenceMonth: dto.referenceMonth,
billingDay: dto.billingDay,
triggeredBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "billing_run.generated",
resourceType: "billing_run",
resourceId: run.id,
metadata: {
referenceYear: dto.referenceYear,
referenceMonth: dto.referenceMonth,
billingDay: dto.billingDay,
invoiceCount: run.invoices.length,
totalAmount: run.totalAmount,
},
});
return run;
} catch (err) {
if (err instanceof BillingPeriodAlreadyConsolidatedError) {
throw new ConflictException(err.message);
}
throw err;
}
}
async listRuns(
user: EdenAuthenticatedUser,
filter: { referenceYear?: number; referenceMonth?: number; status?: BillingRun["status"] },
): Promise<BillingRun[]> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
return billingRepo.listBillingRuns(filter);
}
async getRun(id: string, user: EdenAuthenticatedUser): Promise<BillingRunWithInvoices> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
const run = await billingRepo.getBillingRunById(id);
if (!run) throw new NotFoundException("Billing run não encontrado");
if (scope !== "all") {
// Reseller-scoped actors only ever see their own reseller's slice
// of a run's invoices — never another reseller's amounts.
run.invoices = run.invoices.filter((inv: Invoice) => inv.reseller_id === user.resellerId);
}
return run;
}
async consolidateRun(id: string, user: EdenAuthenticatedUser): Promise<BillingRunWithInvoices> {
this.requireAllScope(user, "edit");
try {
const run = await billingRepo.consolidateBillingRun(id, user.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "billing_run.consolidated",
resourceType: "billing_run",
resourceId: id,
metadata: { invoiceCount: run.invoices.length, totalAmount: run.totalAmount },
});
return run;
} catch (err) {
if (err instanceof BillingRunNotFoundError) throw new NotFoundException("Billing run não encontrado");
if (err instanceof BillingRunConsolidatedError) {
throw new ConflictException("Billing run já está consolidado");
}
throw err;
}
}
async listInvoices(
user: EdenAuthenticatedUser,
filter: {
status?: Invoice["status"];
referenceYear?: number;
referenceMonth?: number;
/** Only honored when the actor holds 'all' scope — see below. */
resellerId?: string;
},
): Promise<Invoice[]> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
const { resellerId: requestedResellerId, ...rest } = filter;
if (scope === "all") {
// A client-supplied resellerId is only ever honored once the
// actor's own server-resolved scope already grants full ('all')
// visibility — it narrows an already-unrestricted view, it never
// grants access beyond what the session allows.
return billingRepo.listInvoices(
requestedResellerId
? { scope: "reseller", resellerId: requestedResellerId, ...rest }
: { scope: "all", ...rest },
);
}
return billingRepo.listInvoices({ scope: "reseller", resellerId: user.resellerId, ...rest });
}
private requireAllScope(user: EdenAuthenticatedUser, action: "view" | "edit"): void {
const scope = maxGrantedScope(user.permissions, RESOURCE, action);
if (scope !== "all") {
throw new ForbiddenException(
`Fechamento de billing exige escopo 'all' para '${RESOURCE}:${action}'`,
);
}
}
}

View File

@@ -0,0 +1,18 @@
import { IsInt, Max, Min } from "class-validator";
export class CreateBillingRunDto {
@IsInt()
@Min(2000)
@Max(2100)
referenceYear!: number;
@IsInt()
@Min(1)
@Max(12)
referenceMonth!: number;
@IsInt()
@Min(1)
@Max(28)
billingDay!: number;
}

View File

@@ -0,0 +1,30 @@
import { IsInt, IsNumber, IsOptional, IsUUID, Max, Min } from "class-validator";
/**
* `monthlyValue` is accepted directly in the body (never recalculated
* from a contract) — this worktree/leva has no guaranteed access to the
* real `contracts` module to look the value up server-side, so the
* caller (typically a Contracts-side workflow) supplies the snapshot
* explicitly. `resellerId` is only actually trusted from the client when
* the caller holds 'all' scope; for 'reseller' scope it is always
* overridden server-side with the actor's own reseller (see
* billing.service.ts#create) — no authorization decision ever trusts a
* client-supplied resellerId (CLAUDE.md).
*/
export class CreateBillingSubscriptionDto {
@IsUUID()
contractId!: string;
@IsOptional()
@IsUUID()
resellerId?: string;
@IsNumber()
@Min(0)
monthlyValue!: number;
@IsInt()
@Min(1)
@Max(28)
billingDay!: number;
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { SeedService } from "./seed.service.js";
@Module({
imports: [AuthModule],
providers: [SeedService],
})
export class BootstrapModule {}

View File

@@ -0,0 +1,50 @@
import { Inject, Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { usersRepo } from "@eden/database";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import { PasswordService } from "../auth/password.service.js";
import { SUPER_ADMIN_ROLE } from "../auth/permissions.js";
/**
* Mirrors the legacy's seedAdmin() (eden.md §5.7): runs once at boot,
* only acts if `users` is empty, creates the first super_admin from env
* vars — never hardcoded, never auto-generated-and-logged. If the env
* vars aren't set, boot proceeds without seeding (useful for CI/tests
* that don't need a real login) rather than crashing.
*/
@Injectable()
export class SeedService implements OnModuleInit {
private readonly logger = new Logger(SeedService.name);
constructor(
@Inject(EDEN_ENV) private readonly env: EdenEnv,
private readonly passwords: PasswordService,
) {}
async onModuleInit() {
const existing = await usersRepo.countUsers();
if (existing > 0) return;
if (!this.env.EDEN_SUPERADMIN_EMAIL || !this.env.EDEN_SUPERADMIN_PASSWORD) {
this.logger.warn(
"users está vazia e EDEN_SUPERADMIN_EMAIL/EDEN_SUPERADMIN_PASSWORD não estão definidos — pulando seed do super_admin.",
);
return;
}
const { hash, algoVersion } = await this.passwords.hash(this.env.EDEN_SUPERADMIN_PASSWORD);
const user = await usersRepo.createUser({
email: this.env.EDEN_SUPERADMIN_EMAIL,
passwordHash: hash,
passwordAlgoVersion: algoVersion,
roleKey: SUPER_ADMIN_ROLE,
fullName: "Super Administrador",
});
await usersRepo.grantUserApplication(user.id, "core");
await usersRepo.grantUserApplication(user.id, "reseller");
await usersRepo.grantUserApplication(user.id, "subscriber");
this.logger.log(`super_admin inicial criado (${user.email}) a partir de EDEN_SUPERADMIN_EMAIL.`);
}
}

View File

@@ -0,0 +1,100 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ClientRegistrationsService } from "./client-registrations.service.js";
import { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
import { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
import { CreatePartnerDto } from "./dto/create-partner.dto.js";
import { UpdatePartnerDto } from "./dto/update-partner.dto.js";
import { ApplyCnpjLookupDto } from "./dto/apply-cnpj-lookup.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Customer 360 / Client Registration (eden.md §3, Master Prompt §6.5) —
* authenticated-only MVP. Unlike Companies/Products/Resellers (open read),
* every route here — including reads — requires both authentication and
* the `client-registrations` resource permission, since this data carries
* customer PII (CPF/CNPJ, contacts, address). Deliberately out of scope
* for this round: the public unauthenticated token-based self-service
* form, file attachments, CEP-only lookup — see the migration and
* repository comments for the full list. CNPJ lookup (`POST
* :id/cnpj-lookup`) IS implemented — see `client-registrations.service.ts`.
*/
@ApiTags("client-registrations")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/client-registrations")
export class ClientRegistrationsController {
constructor(private readonly clientRegistrations: ClientRegistrationsService) {}
@Get()
@RequirePermission("client-registrations", "view")
list(@CurrentUser() user: EdenAuthenticatedUser) {
return this.clientRegistrations.list(user);
}
@Post("direct")
@RequirePermission("client-registrations", "edit")
createDirect(
@Body() dto: CreateDirectClientRegistrationDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.clientRegistrations.createDirect(dto, user);
}
@Get(":id")
@RequirePermission("client-registrations", "view")
get(@Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.clientRegistrations.get(id, user);
}
@Patch(":id")
@RequirePermission("client-registrations", "edit")
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateClientRegistrationDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.clientRegistrations.update(id, dto, user);
}
@Get(":id/partners")
@RequirePermission("client-registrations", "view")
listPartners(@Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.clientRegistrations.listPartners(id, user);
}
@Post(":id/partners")
@RequirePermission("client-registrations", "edit")
createPartner(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CreatePartnerDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.clientRegistrations.createPartner(id, dto, user);
}
@Patch(":id/partners/:partnerId")
@RequirePermission("client-registrations", "edit")
updatePartner(
@Param("id", ParseUUIDPipe) id: string,
@Param("partnerId", ParseUUIDPipe) partnerId: string,
@Body() dto: UpdatePartnerDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.clientRegistrations.updatePartner(id, partnerId, dto, user);
}
@Post(":id/cnpj-lookup")
@RequirePermission("client-registrations", "edit")
applyCnpjLookup(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ApplyCnpjLookupDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.clientRegistrations.applyCnpjLookup(id, dto, user);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { ClientRegistrationsController } from "./client-registrations.controller.js";
import { ClientRegistrationsService } from "./client-registrations.service.js";
@Module({
imports: [AuthModule],
controllers: [ClientRegistrationsController],
providers: [ClientRegistrationsService],
exports: [ClientRegistrationsService],
})
export class ClientRegistrationsModule {}

View File

@@ -0,0 +1,328 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import {
auditRepo,
clientRegistrationsRepo,
clientRegistrationPartnersRepo,
type ClientRegistration,
type ClientRegistrationPartner,
} from "@eden/database";
import { lookupCnpj } from "@eden/integrations";
import { maxGrantedScope } from "../auth/permissions.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
import type { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
import type { CreatePartnerDto } from "./dto/create-partner.dto.js";
import type { UpdatePartnerDto } from "./dto/update-partner.dto.js";
import type { ApplyCnpjLookupDto } from "./dto/apply-cnpj-lookup.dto.js";
/**
* Source tag for partners created by the CNPJ-lookup flow below —
* deliberately not the legacy's hardcoded `'brasilapi'` (see
* `client-registration-partners.ts`'s module comment): cnpj.ws is the
* primary source in this port, so a fixed `'brasilapi'` tag would
* misattribute provenance on the (common) success path.
*/
const CNPJ_LOOKUP_PARTNER_SOURCE = "cnpj_lookup";
/** `undefined`/blank → `null`, so a field the lookup didn't bring back never clobbers an existing value (COALESCE in the repo). */
function orNull(value: string | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
type ResolvedScope =
| { kind: "all" }
| { kind: "own"; actorId: string }
| { kind: "reseller"; resellerId: string };
@Injectable()
export class ClientRegistrationsService {
/**
* eden.md §7.1 documents two hardcoded feature keys with two different
* scoping rules: `clientes_todos` (full backoffice view, scoped by
* *authorship* — `created_by_id = user.id` when the actor lacks the
* "todos" grant) vs. `clientes_revenda` (minimal directory, scoped by
* *reseller* — `reseller_id = user.reseller_id`). EDEN generalizes both
* into the single resource+action+scope permission model (ADR-0004)
* instead of two separate feature keys — a deliberate generalization,
* not a literal port:
* - granted scope `all` → see everything.
* - granted scope `reseller` (actor has a reseller) → scoped to that
* reseller_id, mirroring `clientes_revenda`.
* - anything narrower (`own`/`team`/`legal_entity`), or `reseller`
* without an actual reseller_id on the actor → falls back to
* authorship (`own`), mirroring `clientes_todos`'s non-admin default.
*/
private resolveScope(user: EdenAuthenticatedUser, action: "view" | "edit"): ResolvedScope {
const granted = maxGrantedScope(user.permissions, "client-registrations", action);
if (granted === "all") return { kind: "all" };
if (granted === "reseller" && user.resellerId) {
return { kind: "reseller", resellerId: user.resellerId };
}
return { kind: "own", actorId: user.id };
}
private isVisible(registration: ClientRegistration, scope: ResolvedScope): boolean {
if (scope.kind === "all") return true;
if (scope.kind === "own") return registration.created_by === scope.actorId;
return registration.reseller_id === scope.resellerId;
}
list(user: EdenAuthenticatedUser): Promise<ClientRegistration[]> {
const scope = this.resolveScope(user, "view");
if (scope.kind === "all") {
return clientRegistrationsRepo.listClientRegistrations({ scope: "all" });
}
if (scope.kind === "reseller") {
return clientRegistrationsRepo.listClientRegistrations({
scope: "reseller",
resellerId: scope.resellerId,
});
}
return clientRegistrationsRepo.listClientRegistrations({ scope: "own", actorId: scope.actorId });
}
/** 404, never 403, for a record outside the actor's scope — don't confirm a UUID exists (eden-commercial rules). */
async get(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistration> {
const scope = this.resolveScope(user, "view");
const registration = await clientRegistrationsRepo.getClientRegistrationById(id);
if (!registration || !this.isVisible(registration, scope)) {
throw new NotFoundException("Cadastro de cliente não encontrado");
}
return registration;
}
/** Same 404-not-403 treatment, but scoped by the actor's *edit* grant — write access shouldn't leak past what read visibility would allow either. */
private async getForEdit(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistration> {
const scope = this.resolveScope(user, "edit");
const registration = await clientRegistrationsRepo.getClientRegistrationById(id);
if (!registration || !this.isVisible(registration, scope)) {
throw new NotFoundException("Cadastro de cliente não encontrado");
}
return registration;
}
/**
* Cadastro "direto" (eden.md §2.6). `quoteId` is never accepted here —
* no Quotes integration in this worktree yet (built in parallel).
*
* Legacy rule (eden.md §2.6): "quem não é admin/backoffice só cria para
* a própria revenda (req.user.reseller_id); admin/backoffice pode
* opcionalmente indicar reseller_id." Replicated here, generalized
* through the scope model instead of a role check: only an actor
* holding `all` edit scope for client-registrations may set an
* arbitrary `resellerId`; anyone narrower has their *own* reseller_id
* forced server-side — the body's `resellerId` is ignored in that case,
* never trusted for an authorization-relevant assignment.
*/
async createDirect(
dto: CreateDirectClientRegistrationDto,
user: EdenAuthenticatedUser,
): Promise<ClientRegistration> {
const editScope = maxGrantedScope(user.permissions, "client-registrations", "edit");
const resellerId = editScope === "all" ? (dto.resellerId ?? null) : (user.resellerId ?? null);
const registration = await clientRegistrationsRepo.createClientRegistration({
personType: dto.personType,
isPortability: dto.isPortability,
resellerId,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "client_registration.created",
resourceType: "client_registration",
resourceId: registration.id,
metadata: { personType: registration.person_type, isPortability: registration.is_portability },
});
return registration;
}
/**
* Single generic PATCH, matching the legacy (eden.md §1.1.1) — no
* dedicated status-transition endpoint/guard. `dto.registrationStatus`,
* if present, is applied via `updateClientRegistrationStatus` (which
* also stamps `activated_at` the first time it becomes `ativo`); every
* other field goes through `updateClientRegistrationProfile`, which
* structurally cannot touch pf_cpf/pj_cnpj/pj_rep_cpf (see that
* function's Input type).
*/
async update(
id: string,
dto: UpdateClientRegistrationDto,
user: EdenAuthenticatedUser,
): Promise<ClientRegistration> {
await this.getForEdit(id, user); // 404 if missing or outside the actor's edit scope
let updated: ClientRegistration | null = null;
if (dto.registrationStatus) {
updated = await clientRegistrationsRepo.updateClientRegistrationStatus(id, dto.registrationStatus);
}
const { registrationStatus: _registrationStatus, ...profileFields } = dto;
const hasProfileFields = Object.values(profileFields).some((value) => value !== undefined);
if (hasProfileFields) {
updated = await clientRegistrationsRepo.updateClientRegistrationProfile(id, profileFields);
}
if (!updated) {
updated = await clientRegistrationsRepo.getClientRegistrationById(id);
}
if (!updated) throw new NotFoundException("Cadastro de cliente não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "client_registration.updated",
resourceType: "client_registration",
resourceId: id,
metadata: { fields: Object.keys(dto) },
});
return updated;
}
async listPartners(id: string, user: EdenAuthenticatedUser): Promise<ClientRegistrationPartner[]> {
await this.get(id, user); // reuse the view-scope visibility check
return clientRegistrationPartnersRepo.listPartnersForRegistration(id);
}
async createPartner(
id: string,
dto: CreatePartnerDto,
user: EdenAuthenticatedUser,
): Promise<ClientRegistrationPartner> {
await this.getForEdit(id, user);
const partner = await clientRegistrationPartnersRepo.createPartner({
clientRegistrationId: id,
name: dto.name,
document: dto.document,
qualification: dto.qualification,
cpf: dto.cpf,
willSign: dto.willSign,
signatureEmail: dto.signatureEmail,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "client_registration.partner_created",
resourceType: "client_registration_partner",
resourceId: partner.id,
metadata: { clientRegistrationId: id },
});
return partner;
}
async updatePartner(
id: string,
partnerId: string,
dto: UpdatePartnerDto,
user: EdenAuthenticatedUser,
): Promise<ClientRegistrationPartner> {
await this.getForEdit(id, user);
const existing = await clientRegistrationPartnersRepo.getPartnerById(partnerId);
if (!existing || existing.client_registration_id !== id) {
throw new NotFoundException("Sócio não encontrado");
}
const partner = await clientRegistrationPartnersRepo.updatePartner(partnerId, {
cpf: dto.cpf,
willSign: dto.willSign,
signatureEmail: dto.signatureEmail,
});
if (!partner) throw new NotFoundException("Sócio não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "client_registration.partner_updated",
resourceType: "client_registration_partner",
resourceId: partnerId,
metadata: { clientRegistrationId: id, fields: Object.keys(dto) },
});
return partner;
}
/**
* `POST /:id/cnpj-lookup` — single dedicated action that resolves and
* applies CNPJ data in one step (no separate "preview" endpoint; mirrors
* the legacy's on-blur autofill). The server always re-runs the
* authoritative lookup itself from `dto.cnpj` — never trusts a
* client-submitted "already found" payload for persistence (Master
* Prompt §23 / this file's class-level discipline).
*/
async applyCnpjLookup(
id: string,
dto: ApplyCnpjLookupDto,
user: EdenAuthenticatedUser,
): Promise<{ registration: ClientRegistration; partners: ClientRegistrationPartner[] }> {
const registration = await this.getForEdit(id, user);
if (registration.person_type !== "pj") {
throw new BadRequestException("Lookup de CNPJ só se aplica a cadastros de pessoa jurídica (PJ)");
}
const result = await lookupCnpj(dto.cnpj);
if (!result) {
throw new NotFoundException("CNPJ não encontrado — preencha os dados manualmente.");
}
const normalizedCnpj = dto.cnpj.replace(/\D/g, "");
const updated = await clientRegistrationsRepo.applyCnpjLookup(id, {
cnpj: normalizedCnpj,
companyName: orNull(result.companyName),
tradeName: orNull(result.tradeName),
stateRegistration: orNull(result.stateRegistration),
openingDate: orNull(result.openingDate),
legalNature: orNull(result.legalNature),
cnpjSituation: orNull(result.cnpjSituation),
mainActivity: orNull(result.mainActivity),
addressZip: orNull(result.address.zip),
addressStreet: orNull(result.address.street),
addressNumber: orNull(result.address.number),
addressComplement: orNull(result.address.complement),
addressNeighborhood: orNull(result.address.neighborhood),
addressCity: orNull(result.address.city),
addressState: orNull(result.address.state),
});
if (!updated) throw new NotFoundException("Cadastro de cliente não encontrado");
let partnersCreated = 0;
for (const partner of result.partners) {
const document = orNull(partner.document);
if (document) {
const existing = await clientRegistrationPartnersRepo.findPartnerByDocument(id, document);
if (existing) continue;
}
await clientRegistrationPartnersRepo.createPartner({
clientRegistrationId: id,
name: partner.name,
document,
qualification: orNull(partner.qualification),
source: CNPJ_LOOKUP_PARTNER_SOURCE,
});
partnersCreated += 1;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "client_registration.cnpj_lookup_applied",
resourceType: "client_registration",
resourceId: id,
metadata: { cnpj: normalizedCnpj, partnersFound: result.partners.length, partnersCreated },
});
const partners = await clientRegistrationPartnersRepo.listPartnersForRegistration(id);
return { registration: updated, partners };
}
}

View File

@@ -0,0 +1,15 @@
import { IsString, MinLength } from "class-validator";
/**
* Body for `POST /:id/cnpj-lookup`. The server re-resolves this CNPJ
* itself against the authoritative lookup APIs (`@eden/integrations`'s
* `lookupCnpj`) — this DTO carries only the raw input, never any
* "already looked up" data from the client. Full 14-digit/malformed
* validation happens inside `lookupCnpj` itself (returns `null`, mapped
* to 404 by the service); this is just a basic shape guard.
*/
export class ApplyCnpjLookupDto {
@IsString()
@MinLength(11)
cnpj!: string;
}

View File

@@ -0,0 +1,28 @@
import { IsBoolean, IsIn, IsOptional, IsUUID } from "class-validator";
export const PERSON_TYPES = ["pf", "pj"] as const;
export type PersonType = (typeof PERSON_TYPES)[number];
/**
* Cadastro "direto" (eden.md §2.6) — the only creation path in this MVP,
* `quote_id` stays null (no Quotes integration yet, being built in
* parallel — see the migration comment). `quoteId` is deliberately NOT a
* field here.
*/
export class CreateDirectClientRegistrationDto {
@IsIn(PERSON_TYPES)
personType!: PersonType;
@IsBoolean()
isPortability!: boolean;
/**
* Optional; only actually honored server-side when the actor holds
* `all` edit scope for client-registrations — see the service's
* `createDirect` comment. Never trusted at face value for a narrower
* scope (cross-tenant isolation, ADR-0004).
*/
@IsOptional()
@IsUUID()
resellerId?: string;
}

View File

@@ -0,0 +1,22 @@
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
/**
* `source` is not client-settable — always defaults to `'manual'`
* (DB default, packages/database/migrations/*_client-registrations.cjs)
* for partners entered through this endpoint. Partners populated by the
* CNPJ-lookup flow (`POST :id/cnpj-lookup`) go through a different,
* server-only path (`clientRegistrationPartnersRepo.createPartner`'s
* `source` param) and are tagged `'cnpj_lookup'` instead — see
* `client-registrations.service.ts`.
*/
export class CreatePartnerDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsString() document?: string;
@IsOptional() @IsString() qualification?: string;
@IsOptional() @IsString() cpf?: string;
@IsOptional() @IsBoolean() willSign?: boolean;
@IsOptional() @IsEmail() signatureEmail?: string;
}

View File

@@ -0,0 +1,107 @@
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDateString,
IsEmail,
IsIn,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export const REGISTRATION_STATUSES = [
"rascunho",
"pendente_validacao",
"ativo",
"bloqueado",
"inativo",
] as const;
export type RegistrationStatus = (typeof REGISTRATION_STATUSES)[number];
export class PortabilityRangeDto {
@IsString()
first!: string;
@IsString()
last!: string;
}
/**
* Administratively editable fields per eden.md §1.1.2 — and *only* those.
* `pfCpf`, `pjCnpj`, and `pjRepCpf` are deliberately not declared here:
* with the global ValidationPipe's `forbidNonWhitelisted` (main.ts), any
* request that tries to send them is rejected outright rather than
* silently ignored. Changing the document itself would need a dedicated
* re-verification flow — never a side effect of a generic admin PATCH.
*
* `registrationStatus` is validated as one of the 5 legal *values* here;
* full state-machine transition legality (e.g. blocking `rascunho`→`ativo`
* directly) is NOT enforced in this MVP — matches the legacy, which also
* has no dedicated transition endpoint (eden.md §1.1.1: "não há endpoint
* dedicado de transição", just this same generic PATCH).
*/
export class UpdateClientRegistrationDto {
@IsOptional()
@IsIn(REGISTRATION_STATUSES)
registrationStatus?: RegistrationStatus;
@IsOptional() @IsString() internalNotes?: string;
@IsOptional() @IsString() ixcClientId?: string;
@IsOptional() @IsString() ixcContractNumber?: string;
@IsOptional() @IsString() addressZip?: string;
@IsOptional() @IsString() addressStreet?: string;
@IsOptional() @IsString() addressNumber?: string;
@IsOptional() @IsString() addressComplement?: string;
@IsOptional() @IsString() addressNeighborhood?: string;
@IsOptional() @IsString() addressCity?: string;
@IsOptional() @IsString() addressState?: string;
@IsOptional() @IsString() addressCountry?: string;
@IsOptional() @IsString() contactPrincipalName?: string;
@IsOptional() @IsEmail() contactPrincipalEmail?: string;
@IsOptional() @IsString() contactPrincipalPhone?: string;
@IsOptional() @IsString() contactFinancialName?: string;
@IsOptional() @IsEmail() contactFinancialEmail?: string;
@IsOptional() @IsString() contactFinancialPhone?: string;
@IsOptional() @IsString() contactTechnicalName?: string;
@IsOptional() @IsEmail() contactTechnicalEmail?: string;
@IsOptional() @IsString() contactTechnicalPhone?: string;
// PF — pfCpf intentionally absent, see class comment.
@IsOptional() @IsString() pfFullName?: string;
@IsOptional() @IsDateString() pfBirthDate?: string;
@IsOptional() @IsEmail() pfEmail?: string;
@IsOptional() @IsString() pfMobilePhone?: string;
@IsOptional() @IsString() pfAltPhone?: string;
@IsOptional() @IsString() pfSocialName?: string;
@IsOptional() @IsString() pfIdDocument?: string;
@IsOptional() @IsString() pfIdIssuer?: string;
// PJ — pjCnpj and pjRepCpf intentionally absent, see class comment.
@IsOptional() @IsString() pjCompanyName?: string;
@IsOptional() @IsString() pjTradeName?: string;
@IsOptional() @IsString() pjStateRegistration?: string;
@IsOptional() @IsString() pjMunicipalRegistration?: string;
@IsOptional() @IsDateString() pjOpeningDate?: string;
@IsOptional() @IsString() pjLegalNature?: string;
@IsOptional() @IsString() pjCnpjSituation?: string;
@IsOptional() @IsString() pjMainActivity?: string;
@IsOptional() @IsString() pjRepFullName?: string;
@IsOptional() @IsString() pjRepRole?: string;
@IsOptional() @IsEmail() pjRepEmail?: string;
@IsOptional() @IsString() pjRepMobilePhone?: string;
@IsOptional() @IsBoolean() pjRepAuthorizationAck?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
portabilityNumbers?: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PortabilityRangeDto)
portabilityRanges?: PortabilityRangeDto[];
}

View File

@@ -0,0 +1,8 @@
import { IsBoolean, IsEmail, IsOptional, IsString } from "class-validator";
/** eden.md §2.5: backoffice can correct only these 3 fields post-submission. */
export class UpdatePartnerDto {
@IsOptional() @IsString() cpf?: string;
@IsOptional() @IsBoolean() willSign?: boolean;
@IsOptional() @IsEmail() signatureEmail?: string;
}

View File

@@ -0,0 +1,120 @@
import type { RateLimitRule } from "./rate-limit.decorator.js";
const MINUTE = 60_000;
/**
* Concrete limits for every public (no-session) route in this API.
* Numbers are deliberately round, chosen for two constraints at once:
* - generous enough that this project's own real-app integration test
* suite (apps/api/test/*.spec.ts — real login/refresh/signature flows,
* all from the same loopback IP) never trips them by accident;
* - tight enough to turn an unthrottled endpoint (previously: unlimited
* requests/second) into a hard, auditable ceiling per IP or per link.
* None of these claim to fully stop a determined, IP-rotating attacker —
* that's a documented residual risk in docs/security/threat-model.md,
* not a gap this change pretends to close.
*/
// --- apps/api/src/auth/auth.controller.ts -----------------------------
/** Credential guessing target — argon2 already raises the per-guess cost; this caps guess *rate*. */
export const AUTH_LOGIN_RULE: RateLimitRule = { name: "auth-login", keyBy: "ip", limit: 10, windowMs: MINUTE };
/** Same order of magnitude as login: this is the second half of an MFA login, equally sensitive. */
export const AUTH_MFA_CHALLENGE_RULE: RateLimitRule = {
name: "auth-mfa-challenge",
keyBy: "ip",
limit: 10,
windowMs: MINUTE,
};
/** Doesn't leak account existence (anti-enumeration response either way), but still caps inbox-spam/abuse potential. */
export const AUTH_FORGOT_PASSWORD_RULE: RateLimitRule = {
name: "auth-forgot-password",
keyBy: "ip",
limit: 10,
windowMs: MINUTE,
};
/** Token is 256-bit random (auth.service.ts) — not brute-forceable; this is generic abuse/DoS hygiene. */
export const AUTH_RESET_PASSWORD_RULE: RateLimitRule = {
name: "auth-reset-password",
keyBy: "ip",
limit: 10,
windowMs: MINUTE,
};
/** Refresh tokens are 256-bit random too; higher ceiling because legitimate multi-tab/background refresh is frequent by design. */
export const AUTH_REFRESH_RULE: RateLimitRule = { name: "auth-refresh", keyBy: "ip", limit: 30, windowMs: MINUTE };
/**
* Not in the task's original enumerated list, but POST /v1/auth/logout has
* no JwtAuthGuard either (auth.controller.ts) — genuinely public. No
* guessing target (needs an already-known refresh token), so this is
* generic abuse/DoS hygiene only, same ceiling as refresh.
*/
export const AUTH_LOGOUT_RULE: RateLimitRule = { name: "auth-logout", keyBy: "ip", limit: 30, windowMs: MINUTE };
// --- apps/api/src/signatures/public-signature.controller.ts -----------
/**
* Shared across every /v1/public-signature/:token/* handler, keyed by IP —
* catches one source scanning/hammering many DIFFERENT tokens (link
* enumeration or scraping), which a per-token limit alone can't see.
*/
export const PUBLIC_SIGNATURE_IP_RULE: RateLimitRule = {
name: "pubsig-ip",
keyBy: "ip",
limit: 60,
windowMs: MINUTE,
};
/**
* Per-token ceiling for the ordinary read/ack steps of the flow — a real
* signer hits each of these once or twice (reload, double-click); this
* only bites automated hammering of one leaked link, from any number of
* source IPs (that's the point of keying by token, not IP, here).
*/
export const PUBLIC_SIGNATURE_TOKEN_ACTION_RULE: RateLimitRule = {
name: "pubsig-token-action",
keyBy: "token",
limit: 20,
windowMs: 5 * MINUTE,
};
/** Sends an email — tighter, since abuse here has a real cost (spamming the signer's inbox), not just a wasted read. */
export const PUBLIC_SIGNATURE_OTP_REQUEST_RULE: RateLimitRule = {
name: "pubsig-otp-request",
keyBy: "token",
limit: 5,
windowMs: 15 * MINUTE,
};
/**
* Complementary to the DB-persisted per-challenge lockout already in
* public-signature.service.ts::verifyOtp (blocks after N wrong codes,
* survives across source IPs) — this caps raw request *rate* against the
* endpoint itself, independent of whether attempts are right or wrong.
*/
export const PUBLIC_SIGNATURE_OTP_VERIFY_RULE: RateLimitRule = {
name: "pubsig-otp-verify",
keyBy: "token",
limit: 15,
windowMs: 5 * MINUTE,
};
// --- apps/api/src/signatures/public-verify.controller.ts --------------
export const PUBLIC_VERIFY_IP_RULE: RateLimitRule = {
name: "pubverify-ip",
keyBy: "ip",
limit: 60,
windowMs: MINUTE,
};
export const PUBLIC_VERIFY_ID_RULE: RateLimitRule = {
name: "pubverify-id",
keyBy: "verificationId",
limit: 30,
windowMs: MINUTE,
};

View File

@@ -0,0 +1,16 @@
import { SetMetadata } from "@nestjs/common";
export type RateLimitKeyBy = "ip" | "token" | "verificationId";
export interface RateLimitRule {
/** Bucket namespace — must be unique per logical limit, shared across handlers on purpose (e.g. one IP-wide budget for a whole controller). */
name: string;
keyBy: RateLimitKeyBy;
limit: number;
windowMs: number;
}
export const RATE_LIMIT_KEY = "rateLimit";
/** One or more independent limits checked for a route — ALL must pass. */
export const RateLimit = (...rules: RateLimitRule[]) => SetMetadata(RATE_LIMIT_KEY, rules);

View File

@@ -0,0 +1,75 @@
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import type { Request, Response } from "express";
import { RATE_LIMIT_KEY, type RateLimitRule } from "./rate-limit.decorator.js";
import { RateLimiterService } from "./rate-limiter.service.js";
/**
* Enforces every `@RateLimit(...)` rule declared on a handler (public
* routes only — see auth.controller.ts / public-signature.controller.ts /
* public-verify.controller.ts). Handlers without the decorator are left
* alone (returns true immediately), so this guard is safe to attach at
* controller level without affecting authenticated routes.
*
* All rules on a handler must pass; the first one that doesn't wins the
* `Retry-After` value and gets logged. A hit here is a signal worth
* operator attention (repeated 429s from one source usually means brute
* force or scraping, not an accident), hence `warn`, not `debug`.
*/
@Injectable()
export class RateLimitGuard implements CanActivate {
private readonly logger = new Logger(RateLimitGuard.name);
constructor(
private readonly reflector: Reflector,
private readonly limiter: RateLimiterService,
) {}
canActivate(context: ExecutionContext): boolean {
const rules = this.reflector.get<RateLimitRule[] | undefined>(RATE_LIMIT_KEY, context.getHandler());
if (!rules || rules.length === 0) return true;
const request = context.switchToHttp().getRequest<Request>();
for (const rule of rules) {
const keyValue = this.resolveKeyValue(rule, request);
const bucketKey = `${rule.name}:${keyValue}`;
const verdict = this.limiter.consume(bucketKey, rule.limit, rule.windowMs);
if (!verdict.allowed) {
this.logger.warn(
`rate limit exceeded: rule=${rule.name} keyBy=${rule.keyBy} key=${keyValue} ip=${request.ip ?? "unknown"} route=${request.method} ${request.originalUrl}`,
);
const response = context.switchToHttp().getResponse<Response>();
response.set("Retry-After", String(verdict.retryAfterSeconds));
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: "Muitas requisições. Tente novamente mais tarde.",
retryAfterSeconds: verdict.retryAfterSeconds,
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
return true;
}
private resolveKeyValue(rule: RateLimitRule, request: Request): string {
switch (rule.keyBy) {
case "ip":
return request.ip ?? "unknown";
case "token":
return this.stringParam(request.params.token);
case "verificationId":
return this.stringParam(request.params.verificationId);
}
}
/** @types/express v5's ParamsDictionary types values as `string | string[]` — every route here declares a single `:param`, so this is always a scalar in practice. */
private stringParam(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? "unknown";
return value ?? "unknown";
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { RateLimiterService } from "./rate-limiter.service.js";
import { RateLimitGuard } from "./rate-limit.guard.js";
/**
* Imported by every module that has public (no-session) routes. Nest
* dedupes module imports within one application graph, so AuthModule and
* SignaturesModule importing this both resolve to the SAME
* RateLimiterService instance — one shared in-memory store, bucket names
* are what keep routes from colliding (see rate-limit.decorator.ts).
*/
@Module({
providers: [RateLimiterService, RateLimitGuard],
exports: [RateLimiterService, RateLimitGuard],
})
export class RateLimitModule {}

View File

@@ -0,0 +1,55 @@
import { Injectable } from "@nestjs/common";
export interface RateLimitVerdict {
allowed: boolean;
/** Whole seconds until the current window resets — for the `Retry-After` header. */
retryAfterSeconds: number;
}
interface Bucket {
count: number;
windowStartedAt: number;
}
/**
* In-memory fixed-window counter — no Redis in this environment yet
* (compose.yaml/docs: "BullMQ quando Redis existir"), and this API runs as
* a single instance (ADR-0002), so a process-local Map is both sufficient
* and correct for this stage. Fixed-window (not sliding-window/token-
* bucket) is a deliberate simplicity choice: it lets a caller burst up to
* 2x the nominal limit right at a window boundary, which is an acceptable
* trade-off for closing "no rate limiting at all" — revisit only if real
* abuse patterns exploit that boundary.
*
* One instance of this service is shared (via RateLimitModule, imported by
* both AuthModule and SignaturesModule) across every route this API
* protects — buckets are namespaced by the caller-supplied `key`, which
* always embeds a rule name so different routes never collide.
*/
@Injectable()
export class RateLimiterService {
private readonly buckets = new Map<string, Bucket>();
consume(key: string, limit: number, windowMs: number): RateLimitVerdict {
const now = Date.now();
const existing = this.buckets.get(key);
if (!existing || now - existing.windowStartedAt >= windowMs) {
this.buckets.set(key, { count: 1, windowStartedAt: now });
return { allowed: true, retryAfterSeconds: 0 };
}
if (existing.count >= limit) {
const retryAfterSeconds = Math.ceil((existing.windowStartedAt + windowMs - now) / 1000);
return { allowed: false, retryAfterSeconds: Math.max(retryAfterSeconds, 1) };
}
existing.count += 1;
return { allowed: true, retryAfterSeconds: 0 };
}
/** Test-only escape hatch — never called from production code paths. */
reset(): void {
this.buckets.clear();
}
}

View File

@@ -0,0 +1,50 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { CompaniesService } from "./companies.service.js";
import { CreateCompanyDto } from "./dto/create-company.dto.js";
import { UpdateCompanyDto } from "./dto/update-company.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Read is open to any authenticated user (not gated by a resource
* permission) — company data isn't sensitive and is needed to render
* contracts/documents from screens any seller/backoffice user can reach,
* mirroring the legacy (eden.md §1, §6). Write requires companies:manage.
*/
@ApiTags("companies")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/companies")
export class CompaniesController {
constructor(private readonly companies: CompaniesService) {}
@Get()
list(@Query("includeInactive") includeInactive?: string) {
return this.companies.list(includeInactive === "true");
}
@Get(":id")
get(@Param("id") id: string) {
return this.companies.get(id);
}
@Post()
@RequirePermission("companies", "manage")
create(@Body() dto: CreateCompanyDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.companies.create(dto, user.id);
}
@Patch(":id")
@RequirePermission("companies", "manage")
update(
@Param("id") id: string,
@Body() dto: UpdateCompanyDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.companies.update(id, dto, user.id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { CompaniesController } from "./companies.controller.js";
import { CompaniesService } from "./companies.service.js";
@Module({
imports: [AuthModule],
controllers: [CompaniesController],
providers: [CompaniesService],
})
export class CompaniesModule {}

View File

@@ -0,0 +1,102 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { companiesRepo, auditRepo, type Company } from "@eden/database";
import { onlyDigits, isValidCNPJ } from "@eden/domain-shared";
import type { CreateCompanyDto } from "./dto/create-company.dto.js";
import type { UpdateCompanyDto } from "./dto/update-company.dto.js";
@Injectable()
export class CompaniesService {
list(includeInactive: boolean): Promise<Company[]> {
return companiesRepo.listCompanies(includeInactive);
}
async get(id: string): Promise<Company> {
const company = await companiesRepo.getCompanyById(id);
if (!company) throw new NotFoundException("Empresa não encontrada");
return company;
}
async create(dto: CreateCompanyDto, actorId: string): Promise<Company> {
this.assertValidCnpjIfPresent(dto.cnpj);
const company = await companiesRepo.createCompany({
companyName: dto.companyName,
tradeName: dto.tradeName,
cnpj: dto.cnpj ? onlyDigits(dto.cnpj) : undefined,
stateRegistration: dto.stateRegistration,
municipalRegistration: dto.municipalRegistration,
addressZip: dto.addressZip,
addressStreet: dto.addressStreet,
addressNumber: dto.addressNumber,
addressComplement: dto.addressComplement,
addressNeighborhood: dto.addressNeighborhood,
addressCity: dto.addressCity,
addressState: dto.addressState,
addressCountry: dto.addressCountry,
phone: dto.phone,
email: dto.email,
website: dto.website,
legalRepName: dto.legalRepName,
legalRepCpf: dto.legalRepCpf ? onlyDigits(dto.legalRepCpf) : undefined,
legalRepRole: dto.legalRepRole,
legalRepEmail: dto.legalRepEmail,
createdBy: actorId,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
action: "company.created",
resourceType: "company",
resourceId: company.id,
});
return company;
}
async update(id: string, dto: UpdateCompanyDto, actorId: string): Promise<Company> {
await this.get(id); // 404 if missing
this.assertValidCnpjIfPresent(dto.cnpj);
const company = await companiesRepo.updateCompany(id, {
companyName: dto.companyName,
tradeName: dto.tradeName,
cnpj: dto.cnpj ? onlyDigits(dto.cnpj) : undefined,
stateRegistration: dto.stateRegistration,
municipalRegistration: dto.municipalRegistration,
addressZip: dto.addressZip,
addressStreet: dto.addressStreet,
addressNumber: dto.addressNumber,
addressComplement: dto.addressComplement,
addressNeighborhood: dto.addressNeighborhood,
addressCity: dto.addressCity,
addressState: dto.addressState,
addressCountry: dto.addressCountry,
phone: dto.phone,
email: dto.email,
website: dto.website,
legalRepName: dto.legalRepName,
legalRepCpf: dto.legalRepCpf ? onlyDigits(dto.legalRepCpf) : undefined,
legalRepRole: dto.legalRepRole,
legalRepEmail: dto.legalRepEmail,
isActive: dto.isActive,
});
if (!company) throw new NotFoundException("Empresa não encontrada");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
action: "company.updated",
resourceType: "company",
resourceId: id,
metadata: { fields: Object.keys(dto) },
});
return company;
}
private assertValidCnpjIfPresent(cnpj: string | undefined): void {
if (cnpj && !isValidCNPJ(cnpj)) {
throw new BadRequestException("CNPJ inválido");
}
}
}

View File

@@ -0,0 +1,27 @@
import { IsEmail, IsOptional, IsString, MinLength } from "class-validator";
export class CreateCompanyDto {
@IsString()
@MinLength(1)
companyName!: string;
@IsOptional() @IsString() tradeName?: string;
@IsOptional() @IsString() cnpj?: string;
@IsOptional() @IsString() stateRegistration?: string;
@IsOptional() @IsString() municipalRegistration?: string;
@IsOptional() @IsString() addressZip?: string;
@IsOptional() @IsString() addressStreet?: string;
@IsOptional() @IsString() addressNumber?: string;
@IsOptional() @IsString() addressComplement?: string;
@IsOptional() @IsString() addressNeighborhood?: string;
@IsOptional() @IsString() addressCity?: string;
@IsOptional() @IsString() addressState?: string;
@IsOptional() @IsString() addressCountry?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsEmail() email?: string;
@IsOptional() @IsString() website?: string;
@IsOptional() @IsString() legalRepName?: string;
@IsOptional() @IsString() legalRepCpf?: string;
@IsOptional() @IsString() legalRepRole?: string;
@IsOptional() @IsEmail() legalRepEmail?: string;
}

View File

@@ -0,0 +1,9 @@
import { PartialType } from "@nestjs/swagger";
import { IsOptional, IsBoolean } from "class-validator";
import { CreateCompanyDto } from "./create-company.dto.js";
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1 @@
export const EDEN_ENV = Symbol("EDEN_ENV");

View File

@@ -0,0 +1,19 @@
import { Global, Module } from "@nestjs/common";
import { loadEnv } from "@eden/config";
import { EDEN_ENV } from "./config.constants.js";
/**
* Fails fast at boot (via loadEnv) if a required secret/config var is
* missing — see packages/config/src/env-schema.ts and eden.md §5.6.
*/
@Global()
@Module({
providers: [
{
provide: EDEN_ENV,
useFactory: () => loadEnv(),
},
],
exports: [EDEN_ENV],
})
export class ConfigModule {}

View File

@@ -0,0 +1,74 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ContractsService } from "./contracts.service.js";
import { CreateContractDto } from "./dto/create-contract.dto.js";
import { UpdateContractStatusDto } from "./dto/update-contract-status.dto.js";
import { ContractItemInputDto } from "./dto/contract-item-input.dto.js";
import { ContractPartyInputDto } from "./dto/contract-party-input.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Contracts as a first-class aggregate — docs/adr/0006-contract-first-class.md.
* Every write recalculates monthly_value/contract_total server-side from
* the items array (Master Prompt §6.6/§24) — a client-supplied total is
* never trusted.
*/
@ApiTags("contracts")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/contracts")
export class ContractsController {
constructor(private readonly contracts: ContractsService) {}
@Get()
@RequirePermission("contracts", "view")
list(@CurrentUser() user: EdenAuthenticatedUser) {
return this.contracts.list(user);
}
@Get(":id")
@RequirePermission("contracts", "view")
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.contracts.get(id, user);
}
@Post()
@RequirePermission("contracts", "edit")
create(@Body() dto: CreateContractDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.contracts.create(dto, user);
}
@Patch(":id/status")
@RequirePermission("contracts", "edit")
updateStatus(
@Param("id") id: string,
@Body() dto: UpdateContractStatusDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.contracts.updateStatus(id, dto, user);
}
@Post(":id/items")
@RequirePermission("contracts", "edit")
addItem(
@Param("id") id: string,
@Body() dto: ContractItemInputDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.contracts.addItem(id, dto, user);
}
@Post(":id/parties")
@RequirePermission("contracts", "edit")
addParty(
@Param("id") id: string,
@Body() dto: ContractPartyInputDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.contracts.addParty(id, dto, user);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { ContractsController } from "./contracts.controller.js";
import { ContractsService } from "./contracts.service.js";
@Module({
imports: [AuthModule],
controllers: [ContractsController],
providers: [ContractsService],
exports: [ContractsService],
})
export class ContractsModule {}

View File

@@ -0,0 +1,216 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import {
auditRepo,
contractsRepo,
productsRepo,
type Contract,
type ContractWithDetails,
type CreateContractItemInput,
} from "@eden/database";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import type { CreateContractDto } from "./dto/create-contract.dto.js";
import type { UpdateContractStatusDto } from "./dto/update-contract-status.dto.js";
import type { ContractItemInputDto } from "./dto/contract-item-input.dto.js";
import type { ContractPartyInputDto } from "./dto/contract-party-input.dto.js";
const { ContractNotFoundError, InvalidContractTransitionError } = contractsRepo;
const SUPER_ADMIN = "super_admin";
@Injectable()
export class ContractsService {
async list(user: EdenAuthenticatedUser): Promise<Contract[]> {
const scope = maxGrantedScope(user.permissions, "contracts", "view");
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'contracts'");
if (scope === "all") return contractsRepo.listContracts({ scope: "all" });
return contractsRepo.listContracts({ scope: "reseller", resellerId: user.resellerId });
}
async get(id: string, user: EdenAuthenticatedUser): Promise<ContractWithDetails> {
const scope = maxGrantedScope(user.permissions, "contracts", "view");
const contract = await contractsRepo.getContractById(id);
if (!contract || !scope) throw new NotFoundException("Contrato não encontrado");
if (scope !== "all" && contract.reseller_id !== user.resellerId) {
throw new NotFoundException("Contrato não encontrado");
}
return contract;
}
async create(dto: CreateContractDto, user: EdenAuthenticatedUser): Promise<ContractWithDetails> {
if (dto.fidelityPeriod != null && dto.fidelityPeriod > dto.contractPeriod) {
throw new BadRequestException("fidelityPeriod não pode ser maior que contractPeriod");
}
const items = await this.buildItems(dto.items, dto.contractPeriod);
const monthlyValue = round2(items.reduce((sum, item) => sum + item.total, 0));
const implementationFee = dto.implementationFee ?? 0;
const contractTotal = round2(monthlyValue * (dto.contractPeriod || 1) + implementationFee);
const contract = await contractsRepo.createContract({
quoteId: dto.quoteId ?? null,
clientRegistrationId: dto.clientRegistrationId ?? null,
resellerId: dto.resellerId ?? null,
companyId: dto.companyId ?? null,
contractPeriod: dto.contractPeriod,
fidelityPeriod: dto.fidelityPeriod ?? null,
// fidelity_approved always starts false — approval is a distinct,
// explicit act (mirrors quotes.approval_status never starting
// 'approved' on creation either); no endpoint to set it yet in this
// MVP since the approval workflow lives on Quotes for now — a
// contract created from an already-approved quote will carry that
// forward once the Quotes→Contract integration lands.
fidelityApproved: false,
monthlyValue,
contractTotal,
implementationFee,
notes: dto.notes ?? null,
createdBy: user.id,
items: items.map((item, index) => ({ ...item, sortOrder: index })),
parties: dto.parties.map(this.mapParty),
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "contract.created",
resourceType: "contract",
resourceId: contract.id,
metadata: { contractTotal, itemCount: items.length },
});
return contract;
}
async updateStatus(id: string, dto: UpdateContractStatusDto, user: EdenAuthenticatedUser): Promise<Contract> {
await this.get(id, user); // scope check — 404 before attempting the transition
try {
const updated = await contractsRepo.updateContractStatus(id, dto.toStatus, dto.reason ?? null, user.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "contract.status_changed",
resourceType: "contract",
resourceId: id,
metadata: { toStatus: dto.toStatus, reason: dto.reason ?? null },
});
return updated;
} catch (err) {
if (err instanceof ContractNotFoundError) throw new NotFoundException("Contrato não encontrado");
if (err instanceof InvalidContractTransitionError) {
throw new ConflictException(err.message);
}
throw err;
}
}
async addItem(id: string, dto: ContractItemInputDto, user: EdenAuthenticatedUser) {
const contract = await this.get(id, user);
const overrode = this.assertEditable(contract, user);
const [item] = await this.buildItems([dto], contract.contract_period);
const created = await contractsRepo.addContractItem(id, { ...item!, sortOrder: contract.items.length });
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: overrode ? "contract.item_added_override" : "contract.item_added",
resourceType: "contract",
resourceId: id,
metadata: { productId: dto.productId, contractStatus: contract.status, overrode },
});
return created;
}
async addParty(id: string, dto: ContractPartyInputDto, user: EdenAuthenticatedUser) {
const contract = await this.get(id, user);
const overrode = this.assertEditable(contract, user);
const created = await contractsRepo.addContractParty(id, this.mapParty(dto));
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: overrode ? "contract.party_added_override" : "contract.party_added",
resourceType: "contract",
resourceId: id,
metadata: { partyRole: dto.partyRole, contractStatus: contract.status, overrode },
});
return created;
}
/**
* ADR-0006's own stated invariant: "fluxo de fechamento de oferta →
* geração de contrato preserva a trava equivalente [...] exceto por
* papel com permissão de correção auditada equivalente ao super_admin
* do legado" — ported here from Quotes' assertNotLocked (same shape:
* hard lock once the aggregate has moved past its editable draft state,
* super_admin-only override). `draft` is the only status items/parties
* can be freely added to; anything past it means signing has started or
* the contract is already live — an edit past that point is either a
* correction (audited, super_admin-only) or should go through a future
* amendment instead (contract_amendments, not yet built — ADR-0006).
* Returns whether this call IS an override, so the caller can tag the
* audit event distinctly.
*/
private assertEditable(contract: Contract, user: EdenAuthenticatedUser): boolean {
if (contract.status === "draft") return false;
if (user.roleKey !== SUPER_ADMIN) {
throw new ConflictException(
`Este contrato não está mais em rascunho (status atual: ${contract.status}) — itens/partes só podem ser adicionados enquanto o contrato está em rascunho.`,
);
}
return true;
}
private mapParty(dto: ContractPartyInputDto) {
return {
partyRole: dto.partyRole,
personType: dto.personType ?? null,
fullName: dto.fullName,
document: dto.document ?? null,
email: dto.email ?? null,
phone: dto.phone ?? null,
roleTitle: dto.roleTitle ?? null,
};
}
private async buildItems(
inputs: ContractItemInputDto[],
contractPeriod: number,
): Promise<CreateContractItemInput[]> {
const priceKey = `price_${contractPeriod}` as "price_0" | "price_12" | "price_24" | "price_36" | "price_48";
const items: CreateContractItemInput[] = [];
for (const input of inputs) {
const product = await productsRepo.getProductById(input.productId);
if (!product) throw new BadRequestException(`Produto ${input.productId} não encontrado`);
const unitPriceRaw = product[priceKey];
const unitPrice = unitPriceRaw != null ? Number(unitPriceRaw) : 0;
items.push({
productId: product.id,
productName: product.name,
quantity: input.quantity,
unitPrice,
total: round2(unitPrice * input.quantity),
sortOrder: 0,
});
}
return items;
}
}
function round2(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}

View File

@@ -0,0 +1,11 @@
import { IsNumber, IsUUID, Min } from "class-validator";
/** Same principle as Quotes' item DTO: only productId/quantity are client-supplied; unitPrice/productName are always derived server-side from the live product record. */
export class ContractItemInputDto {
@IsUUID()
productId!: string;
@IsNumber()
@Min(0.01)
quantity!: number;
}

View File

@@ -0,0 +1,17 @@
import { IsEmail, IsIn, IsOptional, IsString, MinLength } from "class-validator";
export class ContractPartyInputDto {
@IsIn(["contratada", "contratante"])
partyRole!: "contratada" | "contratante";
@IsOptional() @IsIn(["pf", "pj"]) personType?: "pf" | "pj";
@IsString()
@MinLength(1)
fullName!: string;
@IsOptional() @IsString() document?: string;
@IsOptional() @IsEmail() email?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() roleTitle?: string;
}

View File

@@ -0,0 +1,45 @@
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from "class-validator";
import { ContractItemInputDto } from "./contract-item-input.dto.js";
import { ContractPartyInputDto } from "./contract-party-input.dto.js";
export class CreateContractDto {
@IsOptional() @IsUUID() resellerId?: string;
@IsOptional() @IsUUID() companyId?: string;
@IsOptional() @IsUUID() quoteId?: string;
@IsOptional() @IsUUID() clientRegistrationId?: string;
@IsIn([0, 12, 24, 36, 48])
contractPeriod!: number;
@IsOptional()
@IsInt()
@Min(0)
fidelityPeriod?: number;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ContractItemInputDto)
items!: ContractItemInputDto[];
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ContractPartyInputDto)
parties!: ContractPartyInputDto[];
@IsOptional() @IsNumber() @Min(0) implementationFee?: number;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,16 @@
import { IsIn, IsOptional, IsString } from "class-validator";
export class UpdateContractStatusDto {
@IsIn(["draft", "pending_signature", "active", "suspended", "cancelled", "terminated", "expired", "renewed"])
toStatus!:
| "draft"
| "pending_signature"
| "active"
| "suspended"
| "cancelled"
| "terminated"
| "expired"
| "renewed";
@IsOptional() @IsString() reason?: string;
}

View File

@@ -0,0 +1,61 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { Inject, Injectable } from "@nestjs/common";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
const KEY_VERSION = "v1";
const IV_LENGTH_BYTES = 12; // 96 bits — AES-GCM recommended nonce size
/**
* Generic reversible field encryption (ADR-0005) — AES-256-GCM, random IV
* per operation, authenticated (tampering makes decrypt() throw). Stored
* format mirrors the legacy's `iv:tag:ciphertext` scheme with an added key
* version prefix so a future root-key rotation can be applied incrementally
* instead of re-encrypting everything at once: `v1:iv:tag:ciphertext` (all
* three payload segments base64).
*
* Root key comes from EDEN_FIELD_ENCRYPTION_KEY (env-only, never in the
* database) and is hashed to exactly 32 bytes via SHA-256 so any
* sufficiently-long passphrase works as AES-256 key material.
*/
@Injectable()
export class FieldEncryptionService {
private readonly key: Buffer | null;
constructor(@Inject(EDEN_ENV) env: EdenEnv) {
this.key = env.EDEN_FIELD_ENCRYPTION_KEY
? createHash("sha256").update(env.EDEN_FIELD_ENCRYPTION_KEY).digest()
: null;
}
encrypt(plaintext: string): string {
const key = this.requireKey();
const iv = randomBytes(IV_LENGTH_BYTES);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [KEY_VERSION, iv.toString("base64"), tag.toString("base64"), ciphertext.toString("base64")].join(":");
}
decrypt(stored: string): string {
const key = this.requireKey();
const [version, ivB64, tagB64, ciphertextB64] = stored.split(":");
if (version !== KEY_VERSION || !ivB64 || !tagB64 || !ciphertextB64) {
throw new Error("Formato de campo cifrado inválido ou versão de chave desconhecida");
}
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64"));
decipher.setAuthTag(Buffer.from(tagB64, "base64"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertextB64, "base64")),
decipher.final(),
]);
return plaintext.toString("utf8");
}
private requireKey(): Buffer {
if (!this.key) {
throw new Error("EDEN_FIELD_ENCRYPTION_KEY não configurada — obrigatória para criptografia de campo reversível");
}
return this.key;
}
}

View File

@@ -0,0 +1,107 @@
import { Body, Controller, Get, Header, Param, Patch, Post, Query, Res, StreamableFile, UseGuards } from "@nestjs/common";
import type { Response } from "express";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { DocumentsService } from "./documents.service";
import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
import { UpdateDraftDto } from "./dto/update-draft.dto";
import { PreviewDraftDto } from "./dto/preview-draft.dto";
import { CreateGenerationDto } from "./dto/create-generation.dto";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { PermissionGuard } from "../auth/permission.guard";
import { RequirePermission } from "../auth/decorators/require-permission.decorator";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
/**
* Templates de documento + geração (eden.md §4, seções 1.1-1.3). Só a
* metade "templates + geração" do módulo 4 — Assinatura Eletrônica
* (seções 1.4-1.9) fica para uma leva dedicada futura (ver o comentário
* no topo da migration PENDING_documents.cjs).
*/
@ApiTags("documents")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/documents")
export class DocumentsController {
constructor(private readonly documents: DocumentsService) {}
@Get("variable-catalog")
@RequirePermission("documents", "view")
getVariableCatalog() {
return this.documents.getVariableCatalog();
}
@Get("templates")
@RequirePermission("documents", "view")
listTemplates(@Query("docGroup") docGroup?: "cliente" | "revenda") {
return this.documents.listTemplates(docGroup);
}
@Post("templates")
@RequirePermission("documents", "edit")
createTemplate(@Body() dto: CreateDocumentTemplateDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.documents.createTemplate(dto, user);
}
@Get("templates/:id")
@RequirePermission("documents", "view")
getTemplate(@Param("id") id: string) {
return this.documents.getTemplate(id);
}
@Post("templates/:id/draft")
@RequirePermission("documents", "edit")
createDraft(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.documents.createDraft(id, user);
}
@Patch("templates/:id/draft")
@RequirePermission("documents", "edit")
updateDraft(@Param("id") id: string, @Body() dto: UpdateDraftDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.documents.updateDraft(id, dto, user);
}
@Post("templates/:id/draft/preview")
@RequirePermission("documents", "edit")
@Header("Content-Type", "application/pdf")
async previewDraft(
@Param("id") id: string,
@Body() dto: PreviewDraftDto,
@Res({ passthrough: true }) res: Response,
) {
const buffer = await this.documents.previewDraft(id, dto);
res.set({ "Content-Disposition": 'inline; filename="preview.pdf"' });
return new StreamableFile(buffer);
}
@Post("templates/:id/publish")
@RequirePermission("documents", "edit")
publish(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.documents.publish(id, user);
}
@Post("generations")
@RequirePermission("documents", "edit")
createGeneration(@Body() dto: CreateGenerationDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.documents.createGeneration(dto, user);
}
@Get("generations/:id/pdf")
@RequirePermission("documents", "view")
@Header("Content-Type", "application/pdf")
async downloadGenerationPdf(@Param("id") id: string, @Res({ passthrough: true }) res: Response) {
const { generation, stream } = await this.documents.getGenerationPdf(id);
res.set({ "Content-Disposition": `inline; filename="${generation.id}.pdf"` });
return new StreamableFile(stream);
}
@Get("generations")
@RequirePermission("documents", "view")
listGenerations(
@Query("templateId") templateId?: string,
@Query("clientRegistrationId") clientRegistrationId?: string,
@Query("resellerId") resellerId?: string,
) {
return this.documents.listGenerations({ templateId, clientRegistrationId, resellerId });
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { DocumentsController } from "./documents.controller";
import { DocumentsService } from "./documents.service";
@Module({
imports: [AuthModule],
controllers: [DocumentsController],
providers: [DocumentsService],
exports: [DocumentsService],
})
export class DocumentsModule {}

View File

@@ -0,0 +1,243 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { createHash, randomUUID } from "node:crypto";
import type { Readable } from "node:stream";
import { auditRepo, documentsRepo } from "@eden/database";
import type { DocumentGeneration } from "@eden/database";
import { getObjectStream, renderPdf, uploadObject } from "@eden/integrations";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
import { resolveDocument } from "./merge-engine";
import { collectMergeFieldKeys, renderEditorContentToHtml } from "./editor-content-renderer";
import { VARIABLE_CATALOG } from "./variable-catalog";
import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
import type { UpdateDraftDto } from "./dto/update-draft.dto";
import type { PreviewDraftDto } from "./dto/preview-draft.dto";
import type { CreateGenerationDto } from "./dto/create-generation.dto";
const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError, DraftNotFoundError } =
documentsRepo;
/**
* Templates de documento + geração — eden.md §4, seções 1.1-1.3. Só a
* metade "templates + geração" do módulo 4; Assinatura Eletrônica
* (seções 1.4-1.9) fica para uma leva dedicada futura (ver o comentário
* no topo da migration PENDING_documents.cjs). Endpoint-level
* authorization (@RequirePermission) lives on DocumentsController — this
* service assumes the caller already holds the resource+action it needs.
*/
@Injectable()
export class DocumentsService {
getVariableCatalog() {
return VARIABLE_CATALOG;
}
async listTemplates(docGroup?: "cliente" | "revenda") {
return documentsRepo.listTemplates(docGroup ? { docGroup } : {});
}
async getTemplate(id: string) {
const template = await documentsRepo.getTemplateWithVersions(id);
if (!template) throw new NotFoundException("Template de documento não encontrado");
return template;
}
async createTemplate(dto: CreateDocumentTemplateDto, user: EdenAuthenticatedUser) {
try {
const template = await documentsRepo.createTemplate({
key: dto.key,
name: dto.name,
docGroup: dto.docGroup,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "document_template.created",
resourceType: "document_template",
resourceId: template.id,
metadata: { key: template.key, docGroup: template.doc_group },
});
return template;
} catch (err) {
if (err instanceof TemplateKeyConflictError) {
throw new ConflictException(`A chave '${dto.key}' já está em uso por outro template`);
}
throw err;
}
}
async createDraft(templateId: string, user: EdenAuthenticatedUser) {
try {
const draft = await documentsRepo.createDraft(templateId, user.id, renderEditorContentToHtml);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "document_template.draft_created",
resourceType: "document_template_version",
resourceId: draft.id,
metadata: { templateId, version: draft.version },
});
return draft;
} catch (err) {
if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado");
if (err instanceof DraftAlreadyExistsError) {
throw new ConflictException("Este template já possui um rascunho em andamento");
}
throw err;
}
}
async updateDraft(templateId: string, dto: UpdateDraftDto, user: EdenAuthenticatedUser) {
try {
const updated = await documentsRepo.updateDraft(
templateId,
{ editorContent: dto.editorContent, cssContent: dto.cssContent, pageConfig: dto.pageConfig },
renderEditorContentToHtml,
);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "document_template.draft_updated",
resourceType: "document_template_version",
resourceId: updated.id,
metadata: { templateId },
});
return updated;
} catch (err) {
if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado");
if (err instanceof DraftNotFoundError) {
throw new NotFoundException("Este template não possui um rascunho para editar");
}
throw err;
}
}
/**
* Pré-visualização em PDF de um rascunho — nunca persiste nada (sem
* `document_generations`, sem upload S3), é só uma renderização
* descartável pra validar o layout/CSS antes da publicação irreversível.
* Roda sobre o `editorContent`/`cssContent` recebidos diretamente no
* corpo da requisição (o estado atual do editor, possivelmente ainda não
* salvo) — não busca o rascunho persistido, pra permitir iterar
* (editar → pré-visualizar → editar) sem precisar salvar a cada volta.
*
* Como não há um contexto real (nenhum cliente/revenda selecionado nesta
* tela), toda `mergeField` encontrada vira um placeholder visível
* (`[variavel]`) em vez de string vazia.
*/
async previewDraft(templateId: string, dto: PreviewDraftDto): Promise<Buffer> {
const template = await documentsRepo.getTemplateById(templateId);
if (!template) throw new NotFoundException("Template de documento não encontrado");
const keys = collectMergeFieldKeys(dto.editorContent);
const placeholderContext = Object.fromEntries(keys.map((key) => [key, `[${key}]`]));
const { html } = resolveDocument(dto.editorContent, placeholderContext);
return renderPdf({ html, css: dto.cssContent });
}
async publish(templateId: string, user: EdenAuthenticatedUser) {
try {
const published = await documentsRepo.publishDraft(templateId, user.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "document_template.published",
resourceType: "document_template_version",
resourceId: published.id,
metadata: { templateId, version: published.version },
});
return published;
} catch (err) {
if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado");
if (err instanceof DraftNotFoundError) {
throw new ConflictException("Este template não possui um rascunho para publicar");
}
throw err;
}
}
async createGeneration(dto: CreateGenerationDto, user: EdenAuthenticatedUser) {
const hasClient = dto.clientRegistrationId != null;
const hasReseller = dto.resellerId != null;
if (hasClient === hasReseller) {
throw new BadRequestException("Informe exatamente uma origem: clientRegistrationId OU resellerId");
}
let published: Awaited<ReturnType<typeof documentsRepo.getPublishedVersionForTemplate>>;
try {
published = await documentsRepo.getPublishedVersionForTemplate(dto.templateId);
} catch (err) {
if (err instanceof TemplateNotFoundError) throw new NotFoundException("Template de documento não encontrado");
throw err;
}
if (!published) {
throw new NotFoundException("Este template ainda não tem uma versão publicada");
}
// eden.md §4 descreve o pipeline completo com sanitizeDocumentHtml —
// não reimplementado nesta leva; mas a renderização de PDF em si
// (Chromium) e o upload para S3 já são reais a partir daqui.
const { html: resolved, missingVariables } = resolveDocument(published.version.editor_content, dto.context ?? {});
const origin = { clientRegistrationId: dto.clientRegistrationId ?? null, resellerId: dto.resellerId ?? null };
// Evita renderizar/subir um PDF à toa quando o resultado vai ser
// descartado de qualquer forma pela idempotência (mesmo par
// template_version + origem já finalizado).
const existing = await documentsRepo.findFinalGeneration(published.version.id, origin);
if (existing) {
return { ...existing, missingVariables, deduped: true };
}
const pdfBuffer = await renderPdf({ html: resolved, css: published.version.css_content ?? undefined });
const pdfSha256 = createHash("sha256").update(pdfBuffer).digest("hex");
const pdfS3Key = `documents/generations/${randomUUID()}.pdf`;
await uploadObject(pdfS3Key, pdfBuffer, "application/pdf");
const { generation, deduped } = await documentsRepo.createGenerationIdempotent({
templateId: dto.templateId,
templateVersionId: published.version.id,
clientRegistrationId: origin.clientRegistrationId,
resellerId: origin.resellerId,
renderContext: dto.context ?? {},
renderedHtml: resolved,
generatedBy: user.id,
pdfS3Key,
pdfSha256,
pdfSizeBytes: pdfBuffer.length,
});
if (!deduped) {
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "document_generation.created",
resourceType: "document_generation",
resourceId: generation.id,
metadata: { templateId: dto.templateId, templateVersionId: published.version.id, missingVariables },
});
}
return { ...generation, missingVariables, deduped };
}
async getGenerationPdf(id: string): Promise<{ generation: DocumentGeneration; stream: Readable }> {
const generation = await documentsRepo.getGenerationById(id);
if (!generation) throw new NotFoundException("Geração de documento não encontrada");
if (!generation.pdf_s3_key) throw new NotFoundException("Esta geração ainda não tem um PDF disponível");
const stream = await getObjectStream(generation.pdf_s3_key);
return { generation, stream };
}
async listGenerations(filter: { templateId?: string; clientRegistrationId?: string; resellerId?: string }) {
return documentsRepo.listGenerations(filter);
}
}

View File

@@ -0,0 +1,18 @@
import { IsIn, IsString, Matches, MaxLength } from "class-validator";
export class CreateDocumentTemplateDto {
/** Identificador estável fixado pelo sistema (eden.md §1.1) — só ASCII minúsculo/underscore, nunca editável depois de criado nesta leva. */
@IsString()
@Matches(/^[a-z][a-z0-9_]*$/, {
message: "key deve começar com letra minúscula e conter apenas letras minúsculas, números e underscore",
})
@MaxLength(100)
key!: string;
@IsString()
@MaxLength(200)
name!: string;
@IsIn(["cliente", "revenda"])
docGroup!: "cliente" | "revenda";
}

View File

@@ -0,0 +1,19 @@
import { IsObject, IsOptional, IsUUID } from "class-validator";
/**
* POST /v1/documents/generations body (eden.md §1.3, simplified for this
* leva). Exactly one of clientRegistrationId/resellerId is required —
* enforced in DocumentsService (mirrors the DB CHECK
* chk_document_generations_one_source), not here, since class-validator
* doesn't cleanly express an XOR across two optional fields.
*/
export class CreateGenerationDto {
@IsUUID() templateId!: string;
@IsOptional() @IsUUID() clientRegistrationId?: string;
@IsOptional() @IsUUID() resellerId?: string;
/** {chave: valor} usado pelo merge engine (merge-engine.ts) para resolver `{{variavel}}`. */
@IsObject() context!: Record<string, string>;
}

View File

@@ -0,0 +1,12 @@
import { IsObject, IsOptional, IsString } from "class-validator";
/**
* `POST .../draft/preview` body — deliberately takes the CURRENT, possibly
* unsaved, editor state (same shape as `UpdateDraftDto`) instead of reading
* the persisted draft: the point is fast iteration (edit → preview → edit)
* without forcing a save first. Nothing here is persisted.
*/
export class PreviewDraftDto {
@IsObject() editorContent!: Record<string, unknown>;
@IsOptional() @IsString() cssContent?: string;
}

View File

@@ -0,0 +1,14 @@
import { IsObject, IsOptional, IsString } from "class-validator";
/**
* PATCH .../draft body (eden.md §1.2 step 2) — every field optional and
* independently omit-able; an omitted field keeps the draft's current
* value (see documents.ts's updateDraft `patch.field !== undefined`
* semantics). `editorContent` is accepted as generic JSONB — this leva
* does not validate the Tiptap tree shape.
*/
export class UpdateDraftDto {
@IsOptional() @IsObject() editorContent?: Record<string, unknown>;
@IsOptional() @IsString() cssContent?: string;
@IsOptional() @IsObject() pageConfig?: Record<string, unknown>;
}

View File

@@ -0,0 +1,178 @@
/**
* Serializador do documento Tiptap (`editor_content`) para HTML — porta fiel
* de `renderEditorContent.js` do legado OrçaFácil
* (`.tmp/orcafacil-reference/server/src/lib/documentTemplates/renderEditorContent.js`),
* escrito à mão sem depender de `@tiptap/html` (este pacote não pode
* depender de bibliotecas do editor/frontend — eden.md §2.2).
*
* Cobre os nós que o editor visual (apps/core-web/src/routes/documents/editor)
* realmente produz: paragraph/heading/bulletList/orderedList/listItem/image/
* pageBreak/table+tableRow+tableHeader+tableCell/mergeField/conditional,
* marks bold/italic/underline/strike/textStyle(fontSize). `conditional`
* (envelope em volta de uma `tableRow`, usado pelo Termo de Contratação
* STFC importado do legado) sempre RENDERIZA sua linha — avaliação real da
* condição contra o contexto é fora de escopo desta leva (ver
* docs/progress.md). NÃO implementado: `tableRow.attrs.repeat` (expansão de
* linha de tabela) — nenhum dos 7 templates importados do legado usa.
*/
export interface TiptapMark {
type: string;
attrs?: Record<string, unknown>;
}
export interface TiptapNode {
type: string;
attrs?: Record<string, unknown>;
content?: TiptapNode[];
marks?: TiptapMark[];
text?: string;
}
/** Resolve o valor final de uma `mergeField` pela sua `key` — quando omitida, o nó vira o chip visual (preview "com variáveis"). */
export type ResolveMergeField = (key: string) => string;
function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// textAlign é atributo do NODE (parágrafo/título) no Tiptap — não é mark.
function textAlignStyle(node: TiptapNode): string {
const align = node.attrs?.textAlign;
return typeof align === "string" && align.length > 0 ? ` style="text-align:${escapeHtml(align)}"` : "";
}
// fontSize é mark (aplicado por trecho de texto) da extensão oficial
// @tiptap/extension-text-style — vira um <span> em volta do texto marcado.
function renderMarks(text: string, marks: TiptapMark[] | undefined): string {
let html = escapeHtml(text);
for (const mark of marks ?? []) {
if (mark.type === "bold") html = `<strong>${html}</strong>`;
else if (mark.type === "italic") html = `<em>${html}</em>`;
else if (mark.type === "underline") html = `<u>${html}</u>`;
else if (mark.type === "strike") html = `<s>${html}</s>`;
else if (mark.type === "textStyle" && mark.attrs?.fontSize) {
html = `<span style="font-size:${escapeHtml(mark.attrs.fontSize)}">${html}</span>`;
}
}
return html;
}
function renderInline(nodes: TiptapNode[] | undefined, resolveMergeField?: ResolveMergeField): string {
return (nodes ?? [])
.map((node) => {
if (node.type === "text") return renderMarks(node.text ?? "", node.marks);
if (node.type === "mergeField") {
const key = String(node.attrs?.key ?? "");
if (resolveMergeField) return escapeHtml(resolveMergeField(key));
const label = typeof node.attrs?.label === "string" ? node.attrs.label : key;
return `<span class="merge-field-chip" data-merge-field="${escapeHtml(key)}">${escapeHtml(label)}</span>`;
}
return "";
})
.join("");
}
function renderBlock(node: TiptapNode, resolveMergeField?: ResolveMergeField): string {
switch (node.type) {
case "paragraph":
return `<p${textAlignStyle(node)}>${renderInline(node.content, resolveMergeField) || "&nbsp;"}</p>`;
case "heading": {
const level = typeof node.attrs?.level === "number" ? node.attrs.level : 1;
return `<h${level}${textAlignStyle(node)}>${renderInline(node.content, resolveMergeField)}</h${level}>`;
}
case "bulletList":
return `<ul>${(node.content ?? []).map((li) => renderBlock(li, resolveMergeField)).join("")}</ul>`;
case "orderedList":
return `<ol>${(node.content ?? []).map((li) => renderBlock(li, resolveMergeField)).join("")}</ol>`;
case "listItem":
return `<li>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</li>`;
case "image":
return `<img src="${escapeHtml(node.attrs?.src)}" alt="${escapeHtml(node.attrs?.alt ?? "")}" />`;
case "pageBreak":
return `<div class="page-break" style="break-after:page;page-break-after:always;"></div>`;
case "table": {
// <thead> de verdade (não só a 1ª linha) — é o que faz o Chromium
// repetir o cabeçalho da tabela em toda página nova quando ela
// atravessa uma quebra.
const rows = node.content ?? [];
const headerRows = rows.filter((r) => r.content?.[0]?.type === "tableHeader");
const bodyRows = rows.filter((r) => r.content?.[0]?.type !== "tableHeader");
const theadHtml = headerRows.length
? `<thead>${headerRows.map((r) => renderBlock(r, resolveMergeField)).join("")}</thead>`
: "";
const tbodyHtml = `<tbody>${bodyRows.map((r) => renderBlock(r, resolveMergeField)).join("")}</tbody>`;
return `<table>${theadHtml}${tbodyHtml}</table>`;
}
case "tableRow":
return `<tr>${(node.content ?? []).map((cell) => renderBlock(cell, resolveMergeField)).join("")}</tr>`;
case "tableHeader": {
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
return `<th${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</th>`;
}
case "tableCell": {
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
return `<td${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</td>`;
}
case "conditional":
// Envelope em volta de uma `tableRow` (attrs: field/operator/value) —
// avaliação real da condição contra o contexto (eden.md §3.2,
// `applyConditionals`) é fora de escopo desta leva (ver
// docs/progress.md): a linha sempre passa adiante, igual ao
// comportamento do próprio legado quando não avaliada.
return (node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("");
default:
return "";
}
}
function isTiptapDoc(value: unknown): value is { content?: TiptapNode[] } {
return value !== null && typeof value === "object" && (value as { type?: unknown }).type === "doc";
}
/**
* Serializa `editor_content` para HTML. Sem `resolveMergeField`, toda
* `mergeField` vira o chip visual (é o que fica cacheado em
* `document_template_versions.html_content`, eden.md §1.2); com
* `resolveMergeField`, cada uma vira o valor final resolvido (usado na
* geração real e na pré-visualização de rascunho).
*/
export function renderToHtml(editorContent: unknown, resolveMergeField?: ResolveMergeField): string {
if (!isTiptapDoc(editorContent)) return "";
return (editorContent.content ?? []).map((node) => renderBlock(node, resolveMergeField)).join("\n");
}
/** `renderEditorContentToHtml` do stand-in anterior — mantido como nome de import estável para os chamadores existentes. */
export function renderEditorContentToHtml(editorContent: unknown): string {
return renderToHtml(editorContent);
}
/**
* Varre a árvore e devolve todas as chaves de `mergeField` usadas no
* documento — usado tanto para "Variáveis usadas neste modelo" (frontend)
* quanto para achar `missingVariables` na resolução (merge-engine.ts).
* Sem suporte a `tableRow.attrs.repeat`/escopo `collection` nesta leva
* (fora de escopo — ver docblock do arquivo).
*/
export function collectMergeFieldKeys(editorContent: unknown): string[] {
const keys = new Set<string>();
function walk(node: TiptapNode | undefined): void {
if (!node) return;
if (node.type === "mergeField" && typeof node.attrs?.key === "string") {
keys.add(node.attrs.key);
return;
}
(node.content ?? []).forEach(walk);
}
if (isTiptapDoc(editorContent)) {
(editorContent.content ?? []).forEach(walk);
}
return [...keys];
}

View File

@@ -0,0 +1,29 @@
/**
* Motor de resolução de variáveis — opera direto sobre a árvore Tiptap
* (`editor_content`), não mais sobre `{{variavel}}` em texto puro (essa era
* a versão stand-in de uma leva anterior, antes do editor visual real
* existir). Inspirado em `resolveDocument()` do legado (eden.md §3.2), mas
* simplificado: sem nó `conditional`, sem expansão de `tableRow.attrs.repeat`
* (fora de escopo — o editor/catálogo desta leva nunca produz nenhum dos
* dois) e sem bloquear com `MISSING_REQUIRED_FIELDS`/`UNRESOLVED_VARIABLES`
* (HTTP 422) — uma `mergeField` sem valor em `context` vira string vazia e é
* só reportada em `missingVariables` (deduplicada, ordem da primeira
* ocorrência); o chamador decide o que fazer com isso.
*/
import { collectMergeFieldKeys, renderToHtml } from "./editor-content-renderer";
export interface ResolveDocumentResult {
/** HTML final, com toda `mergeField` já substituída pelo valor do contexto (ou string vazia, quando ausente). */
html: string;
/** Chaves referenciadas no documento mas ausentes de `context`, deduplicadas, na ordem de `collectMergeFieldKeys`. */
missingVariables: string[];
}
export function resolveDocument(editorContent: unknown, context: Record<string, string>): ResolveDocumentResult {
const keys = collectMergeFieldKeys(editorContent);
const missingVariables = keys.filter((key) => !Object.prototype.hasOwnProperty.call(context, key));
const html = renderToHtml(editorContent, (key) => context[key] ?? "");
return { html, missingVariables };
}

View File

@@ -0,0 +1,172 @@
/**
* Catálogo fechado de variáveis (whitelist) — porta de `VARIABLE_CATALOG`
* em `.tmp/orcafacil-reference/server/src/lib/documentTemplates/variableRegistry.js`,
* só as entradas `scope: 'document'` (sem `item.*`/`pdf.*` — resolução por
* collection/renderer está fora de escopo desta leva, ver docs/progress.md).
*
* O editor nunca deixa o usuário digitar uma chave livre, só escolher daqui
* — cada `mergeField` inserido carrega `key`/`label` vindos de uma entrada
* deste catálogo. Servido também ao frontend via
* `GET /v1/documents/variable-catalog` para montar o menu "Inserir
* variável" e o painel "Variáveis usadas neste modelo" sem duplicar a lista
* à mão nos dois lados.
*/
export interface VariableCatalogEntry {
key: string;
label: string;
category: string;
required: boolean;
}
function cedenteCessionarioEntries(): VariableCatalogEntry[] {
return (["cedente", "cessionario"] as const).flatMap((prefix) => {
const label = prefix === "cedente" ? "Cedente" : "Cessionária";
return [
{ key: `${prefix}.razao_social`, label: `Razão Social (${label})`, category: label, required: false },
{ key: `${prefix}.cnpj`, label: `CNPJ/CPF (${label})`, category: label, required: false },
{ key: `${prefix}.email`, label: `E-mail (${label})`, category: label, required: false },
{ key: `${prefix}.endereco`, label: `Endereço (${label})`, category: label, required: false },
{ key: `${prefix}.bairro`, label: `Bairro (${label})`, category: label, required: false },
{ key: `${prefix}.cidade`, label: `Cidade (${label})`, category: label, required: false },
{ key: `${prefix}.uf`, label: `UF (${label})`, category: label, required: false },
{ key: `${prefix}.cep`, label: `CEP (${label})`, category: label, required: false },
{ key: `${prefix}.responsavel_nome`, label: `Nome do Responsável (${label})`, category: label, required: false },
{ key: `${prefix}.responsavel_cpf`, label: `CPF do Responsável (${label})`, category: label, required: false },
{
key: `${prefix}.responsavel_telefone`,
label: `Telefone do Responsável (${label})`,
category: label,
required: false,
},
];
});
}
export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
// CLIENTE — client_registrations (PF/PJ) OU resellers, dependendo da origem do documento.
{ key: "cliente.razao_social", label: "Razão Social", category: "Cliente", required: true },
{ key: "cliente.nome_fantasia", label: "Nome Fantasia", category: "Cliente", required: false },
{ key: "cliente.cnpj", label: "CNPJ/CPF", category: "Cliente", required: true },
{ key: "cliente.email", label: "E-mail", category: "Cliente", required: false },
{ key: "cliente.telefone", label: "Telefone", category: "Cliente", required: false },
{ key: "cliente.cep", label: "CEP", category: "Cliente", required: false },
{ key: "cliente.endereco", label: "Endereço", category: "Cliente", required: true },
{ key: "cliente.complemento", label: "Complemento", category: "Cliente", required: false },
{ key: "cliente.bairro", label: "Bairro", category: "Cliente", required: false },
{ key: "cliente.cidade", label: "Cidade", category: "Cliente", required: false },
{ key: "cliente.uf", label: "UF", category: "Cliente", required: false },
{ key: "cliente.ie_rg", label: "IE/RG", category: "Cliente", required: false },
{ key: "cliente.responsavel_nome", label: "Nome do Responsável", category: "Cliente", required: false },
{ key: "cliente.responsavel_cpf", label: "CPF do Responsável", category: "Cliente", required: false },
{ key: "cliente.responsavel_telefone", label: "Telefone do Responsável", category: "Cliente", required: false },
{ key: "cliente.celular_pf", label: "Celular (pessoa física)", category: "Cliente", required: false },
...cedenteCessionarioEntries(),
// EMPRESA EMISSORA
{ key: "empresa.razao_social", label: "Razão Social da Empresa", category: "Empresa", required: true },
{ key: "empresa.nome_fantasia", label: "Nome Fantasia da Empresa", category: "Empresa", required: false },
{ key: "empresa.cnpj", label: "CNPJ da Empresa", category: "Empresa", required: true },
{ key: "empresa.ie", label: "Inscrição Estadual da Empresa", category: "Empresa", required: false },
{ key: "empresa.im", label: "Inscrição Municipal da Empresa", category: "Empresa", required: false },
{ key: "empresa.endereco", label: "Endereço da Empresa", category: "Empresa", required: false },
{ key: "empresa.bairro", label: "Bairro da Empresa", category: "Empresa", required: false },
{ key: "empresa.cidade", label: "Cidade da Empresa", category: "Empresa", required: false },
{ key: "empresa.uf", label: "UF da Empresa", category: "Empresa", required: false },
{ key: "empresa.cep", label: "CEP da Empresa", category: "Empresa", required: false },
{ key: "empresa.cidade_uf", label: "Cidade/UF da Empresa", category: "Empresa", required: false },
{ key: "empresa.telefone", label: "Telefone da Empresa", category: "Empresa", required: false },
{ key: "empresa.email", label: "E-mail da Empresa", category: "Empresa", required: false },
{ key: "empresa.website", label: "Site da Empresa", category: "Empresa", required: false },
// OFERTA — tabela quotes.
{ key: "oferta.numero", label: "Número da Oferta", category: "Oferta", required: false },
{ key: "oferta.data", label: "Data da Oferta", category: "Oferta", required: false },
{ key: "oferta.validade", label: "Validade da Oferta", category: "Oferta", required: false },
{ key: "oferta.fidelidade", label: "Fidelidade (meses)", category: "Oferta", required: false },
{ key: "oferta.valor_mensal", label: "Valor Mensal", category: "Oferta", required: false },
{ key: "oferta.valor_implantacao", label: "Valor de Implantação", category: "Oferta", required: false },
{ key: "oferta.valor_total", label: "Valor Total do Contrato", category: "Oferta", required: false },
{ key: "oferta.nome_comercial", label: "Nome Comercial da Oferta", category: "Oferta", required: false },
{ key: "oferta.vigencia_texto", label: "Prazo de Vigência (texto)", category: "Oferta", required: false },
{ key: "oferta.destinacao", label: "Destinação (Corporativo/Residencial)", category: "Oferta", required: false },
{ key: "oferta.combo", label: "Contratação em Combo (Sim/Não)", category: "Oferta", required: false },
{
key: "oferta.permanencia_aplicavel",
label: "Prazo de Permanência Aplicável (Sim/Não)",
category: "Oferta",
required: false,
},
{ key: "oferta.franquia_minutos", label: "Franquia de Minutos", category: "Oferta", required: false },
{ key: "oferta.desconto_fidelidade", label: "Desconto por Fidelidade", category: "Oferta", required: false },
{ key: "oferta.total_beneficios", label: "Total de Benefícios (VTB)", category: "Oferta", required: false },
{ key: "oferta.comercializacao_inicio", label: "Início do Prazo de Comercialização", category: "Oferta", required: false },
{ key: "oferta.comercializacao_fim", label: "Fim do Prazo de Comercialização", category: "Oferta", required: false },
// 0800 — sem cadastro próprio, preenchidos manualmente na geração.
{ key: "oferta.inclui_0800", label: "Inclui Número 0800? (Sim/Não)", category: "Oferta", required: false },
{ key: "oferta.numero_0800", label: "Número 0800", category: "Oferta", required: false },
{ key: "oferta.franquia_0800_min", label: "Franquia Mensal de 0800 (min)", category: "Oferta", required: false },
{ key: "oferta.valor_0800_padrao", label: "Valor 0800 — Padrão (por minuto)", category: "Oferta", required: false },
{ key: "oferta.valor_0800_reduzido", label: "Valor 0800 — Reduzido (por minuto)", category: "Oferta", required: false },
// Tabela de tarifação STFC — automática a partir de products.tariff_rates do produto medido da oferta.
...(["lc", "ldn", "vc1", "vc2", "vc3", "ldi"] as const).flatMap((t) => [
{ key: `oferta.tarifa_${t}_normal`, label: `Tarifa ${t.toUpperCase()} — Normal (por minuto)`, category: "Oferta", required: false },
{ key: `oferta.tarifa_${t}_reduzida`, label: `Tarifa ${t.toUpperCase()} — Reduzida (por minuto)`, category: "Oferta", required: false },
]),
// CONTRATO — client_registrations.ixc_contract_number + quotes.contract_period/fidelity_period.
{ key: "contrato.numero", label: "Número do Contrato IXC", category: "Contrato", required: false },
{ key: "contrato.codigo_cliente_ixc", label: "Código do Cliente IXC", category: "Contrato", required: false },
{ key: "contrato.vigencia_meses", label: "Vigência (meses)", category: "Contrato", required: false },
{ key: "contrato.data_inicio", label: "Início da Vigência", category: "Contrato", required: false },
{ key: "contrato.data_termino", label: "Fim da Vigência", category: "Contrato", required: false },
{ key: "contrato.software_nome", label: "Nome do Software", category: "Contrato", required: false },
{ key: "contrato.numeros_portar", label: "Números a Portar", category: "Contrato", required: false },
{ key: "contrato.quantidade_portar", label: "Quantidade de Números a Portar", category: "Contrato", required: false },
{ key: "contrato.prazo_instalacao", label: "Prazo de Instalação", category: "Contrato", required: false },
// PORTABILIDADE / CESSÃO — sem cadastro próprio, preenchidos manualmente na geração.
{ key: "portabilidade.prestadora_doadora", label: "Prestadora Doadora", category: "Portabilidade", required: false },
{ key: "portabilidade.cnpj_doadora", label: "CNPJ da Doadora", category: "Portabilidade", required: false },
{
key: "portabilidade.titularidade_doadora",
label: "Titularidade na Doadora",
category: "Portabilidade",
required: false,
},
{
key: "portabilidade.titular_cpf_cnpj_doadora",
label: "CNPJ/CPF do Titular na Doadora",
category: "Portabilidade",
required: false,
},
{ key: "cessao.codigo_acesso_cedido", label: "Código(s) de Acesso Cedido(s)", category: "Cessão", required: false },
{ key: "cessao.numeros_contratados", label: "Números Contratados", category: "Cessão", required: false },
// ASSINATURA — leitura do módulo de assinatura eletrônica já existente.
{ key: "assinatura.signatario_nome", label: "Nome do Signatário", category: "Assinatura", required: false },
{ key: "assinatura.signatario_cpf", label: "CPF do Signatário", category: "Assinatura", required: false },
{ key: "assinatura.signatario_email", label: "E-mail do Signatário", category: "Assinatura", required: false },
{ key: "assinatura.data", label: "Data da Assinatura", category: "Assinatura", required: false },
{ key: "assinatura.envelope_numero", label: "Número do Envelope", category: "Assinatura", required: false },
{
key: "assinatura.codigo_autenticidade",
label: "Código de Autenticidade",
category: "Assinatura",
required: false,
},
// DOCUMENTO — metadados do próprio documento sendo gerado.
{ key: "documento.numero", label: "Número do Documento", category: "Documento", required: false },
{ key: "documento.ano_atual", label: "Ano Atual", category: "Documento", required: false },
{ key: "documento.data_geracao", label: "Data de Geração", category: "Documento", required: false },
{
key: "documento.data_geracao_extenso",
label: "Data de Geração (por extenso)",
category: "Documento",
required: false,
},
];

View File

@@ -0,0 +1,21 @@
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
/**
* direction/scope are free-text, no CHECK/enum — exactly as the legacy
* (eden.md §4): the CFOP code's 1st digit encodes direction+scope in
* practice, but neither this DTO nor the schema derives it automatically.
*/
export class CreateCfopDto {
@IsString() code!: string;
@IsString() description!: string;
@IsOptional() @IsString() direction?: string;
@IsOptional() @IsString() scope?: string;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validUntil?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,12 @@
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
/** fiscal_municipality has NO valid_from/valid_until/metadata — eden.md §2.3 ("não faz sentido um município expirar"). */
export class CreateMunicipalityDto {
@IsString() code!: string;
@IsString() name!: string;
@IsString() @Length(2, 2) state!: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
}

View File

@@ -0,0 +1,14 @@
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
export class CreateNcmDto {
@IsString() code!: string;
@IsString() description!: string;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validUntil?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,23 @@
import { IsDateString, IsIn, IsOptional, IsUUID } from "class-validator";
/**
* Creates a new ACTIVE profile for a (productId, billingComponent) pair.
* If one is already active, the service inactivates it and creates this
* one in the same transaction (eden.md §2.5's central invariant) — never a
* destructive update of the old profile.
*/
export class CreateProductFiscalProfileDto {
@IsIn(["RECURRING", "IMPLEMENTATION"])
billingComponent!: "RECURRING" | "IMPLEMENTATION";
@IsOptional()
@IsIn(["NFCOM", "NFSE", "NFE", "NONE"])
documentType?: "NFCOM" | "NFSE" | "NFE" | "NONE";
@IsOptional() @IsUUID() ncmId?: string;
@IsOptional() @IsUUID() cfopId?: string;
@IsOptional() @IsUUID() municipalityId?: string;
@IsOptional() @IsUUID() operationNatureId?: string;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validUntil?: string;
}

View File

@@ -0,0 +1,15 @@
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
export class UpdateCfopDto {
@IsOptional() @IsString() code?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() direction?: string;
@IsOptional() @IsString() scope?: string;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validUntil?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,10 @@
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
export class UpdateMunicipalityDto {
@IsOptional() @IsString() code?: string;
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() @Length(2, 2) state?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
}

View File

@@ -0,0 +1,14 @@
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
/** All fields optional — PATCH is a partial update, including flipping `active` alone. */
export class UpdateNcmDto {
@IsOptional() @IsString() code?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validUntil?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,142 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { FiscalService } from "./fiscal.service.js";
import { CreateNcmDto } from "./dto/create-ncm.dto.js";
import { UpdateNcmDto } from "./dto/update-ncm.dto.js";
import { CreateCfopDto } from "./dto/create-cfop.dto.js";
import { UpdateCfopDto } from "./dto/update-cfop.dto.js";
import { CreateMunicipalityDto } from "./dto/create-municipality.dto.js";
import { UpdateMunicipalityDto } from "./dto/update-municipality.dto.js";
import { CreateProductFiscalProfileDto } from "./dto/create-product-fiscal-profile.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Fiscal catalogs + product fiscal profiles (eden.md módulo 5). Uses the
* same `fiscal` permission feature as the legacy, with `view`/`edit`
* levels (eden.md §8). No bulk/mass operations in this MVP — writes always
* touch a single catalog row, never a whole table.
*/
@ApiTags("fiscal")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/fiscal")
export class FiscalController {
constructor(private readonly fiscal: FiscalService) {}
// -- fiscal_ncm ---------------------------------------------------------
@Get("ncm")
@RequirePermission("fiscal", "view")
listNcm(@Query("q") q?: string, @Query("active") active?: string) {
return this.fiscal.listNcm(q, parseActive(active));
}
@Get("ncm/:id")
@RequirePermission("fiscal", "view")
getNcm(@Param("id") id: string) {
return this.fiscal.getNcm(id);
}
@Post("ncm")
@RequirePermission("fiscal", "edit")
createNcm(@Body() dto: CreateNcmDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.fiscal.createNcm(dto, user);
}
@Patch("ncm/:id")
@RequirePermission("fiscal", "edit")
updateNcm(@Param("id") id: string, @Body() dto: UpdateNcmDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.fiscal.updateNcm(id, dto, user);
}
// -- fiscal_cfop ----------------------------------------------------------
@Get("cfop")
@RequirePermission("fiscal", "view")
listCfop(@Query("q") q?: string, @Query("active") active?: string) {
return this.fiscal.listCfop(q, parseActive(active));
}
@Get("cfop/:id")
@RequirePermission("fiscal", "view")
getCfop(@Param("id") id: string) {
return this.fiscal.getCfop(id);
}
@Post("cfop")
@RequirePermission("fiscal", "edit")
createCfop(@Body() dto: CreateCfopDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.fiscal.createCfop(dto, user);
}
@Patch("cfop/:id")
@RequirePermission("fiscal", "edit")
updateCfop(@Param("id") id: string, @Body() dto: UpdateCfopDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.fiscal.updateCfop(id, dto, user);
}
// -- fiscal_municipality --------------------------------------------------
@Get("municipalities")
@RequirePermission("fiscal", "view")
listMunicipalities(@Query("q") q?: string, @Query("active") active?: string) {
return this.fiscal.listMunicipalities(q, parseActive(active));
}
@Get("municipalities/:id")
@RequirePermission("fiscal", "view")
getMunicipality(@Param("id") id: string) {
return this.fiscal.getMunicipality(id);
}
@Post("municipalities")
@RequirePermission("fiscal", "edit")
createMunicipality(@Body() dto: CreateMunicipalityDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.fiscal.createMunicipality(dto, user);
}
@Patch("municipalities/:id")
@RequirePermission("fiscal", "edit")
updateMunicipality(
@Param("id") id: string,
@Body() dto: UpdateMunicipalityDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.fiscal.updateMunicipality(id, dto, user);
}
// -- fiscal_operation_nature — read-only in this MVP -----------------------
@Get("operation-natures")
@RequirePermission("fiscal", "view")
listOperationNatures() {
return this.fiscal.listOperationNatures();
}
// -- product_fiscal_profiles ----------------------------------------------
@Get("products/:productId/profiles")
@RequirePermission("fiscal", "view")
listProfiles(@Param("productId") productId: string) {
return this.fiscal.listProfiles(productId);
}
@Post("products/:productId/profiles")
@RequirePermission("fiscal", "edit")
createProfile(
@Param("productId") productId: string,
@Body() dto: CreateProductFiscalProfileDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.fiscal.createProfile(productId, dto, user);
}
}
function parseActive(value?: string): boolean | undefined {
if (value === undefined) return undefined;
return value === "true";
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { FiscalController } from "./fiscal.controller.js";
import { FiscalService } from "./fiscal.service.js";
@Module({
imports: [AuthModule],
controllers: [FiscalController],
providers: [FiscalService],
exports: [FiscalService],
})
export class FiscalModule {}

View File

@@ -0,0 +1,219 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
auditRepo,
fiscalRepo,
productsRepo,
type FiscalCfop,
type FiscalMunicipality,
type FiscalNcm,
type FiscalOperationNature,
type ProductFiscalProfile,
} from "@eden/database";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type { CreateNcmDto } from "./dto/create-ncm.dto.js";
import type { UpdateNcmDto } from "./dto/update-ncm.dto.js";
import type { CreateCfopDto } from "./dto/create-cfop.dto.js";
import type { UpdateCfopDto } from "./dto/update-cfop.dto.js";
import type { CreateMunicipalityDto } from "./dto/create-municipality.dto.js";
import type { UpdateMunicipalityDto } from "./dto/update-municipality.dto.js";
import type { CreateProductFiscalProfileDto } from "./dto/create-product-fiscal-profile.dto.js";
/**
* Fiscal catalogs (eden.md módulo 5) — see PENDING_fiscal.cjs migration
* header for the MVP scope. Read (`fiscal:view`) is open to any
* authenticated user holding that grant; write (`fiscal:edit`) creates or
* PATCHes a single catalog row — no bulk/mass operations in this MVP.
*/
@Injectable()
export class FiscalService {
// ---------------------------------------------------------------------
// fiscal_ncm
// ---------------------------------------------------------------------
async listNcm(q?: string, active?: boolean): Promise<FiscalNcm[]> {
return fiscalRepo.listNcm({ q, active });
}
async getNcm(id: string): Promise<FiscalNcm> {
const row = await fiscalRepo.getNcmById(id);
if (!row) throw new NotFoundException("NCM não encontrado");
return row;
}
async createNcm(dto: CreateNcmDto, user: EdenAuthenticatedUser): Promise<FiscalNcm> {
const row = await fiscalRepo.createNcm(dto);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.ncm.created",
resourceType: "fiscal_ncm",
resourceId: row.id,
metadata: { code: row.code },
});
return row;
}
async updateNcm(id: string, dto: UpdateNcmDto, user: EdenAuthenticatedUser): Promise<FiscalNcm> {
const row = await fiscalRepo.updateNcm(id, dto);
if (!row) throw new NotFoundException("NCM não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.ncm.updated",
resourceType: "fiscal_ncm",
resourceId: row.id,
metadata: { ...dto },
});
return row;
}
// ---------------------------------------------------------------------
// fiscal_cfop
// ---------------------------------------------------------------------
async listCfop(q?: string, active?: boolean): Promise<FiscalCfop[]> {
return fiscalRepo.listCfop({ q, active });
}
async getCfop(id: string): Promise<FiscalCfop> {
const row = await fiscalRepo.getCfopById(id);
if (!row) throw new NotFoundException("CFOP não encontrado");
return row;
}
async createCfop(dto: CreateCfopDto, user: EdenAuthenticatedUser): Promise<FiscalCfop> {
const row = await fiscalRepo.createCfop(dto);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.cfop.created",
resourceType: "fiscal_cfop",
resourceId: row.id,
metadata: { code: row.code },
});
return row;
}
async updateCfop(id: string, dto: UpdateCfopDto, user: EdenAuthenticatedUser): Promise<FiscalCfop> {
const row = await fiscalRepo.updateCfop(id, dto);
if (!row) throw new NotFoundException("CFOP não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.cfop.updated",
resourceType: "fiscal_cfop",
resourceId: row.id,
metadata: { ...dto },
});
return row;
}
// ---------------------------------------------------------------------
// fiscal_municipality
// ---------------------------------------------------------------------
async listMunicipalities(q?: string, active?: boolean): Promise<FiscalMunicipality[]> {
return fiscalRepo.listMunicipalities({ q, active });
}
async getMunicipality(id: string): Promise<FiscalMunicipality> {
const row = await fiscalRepo.getMunicipalityById(id);
if (!row) throw new NotFoundException("Município não encontrado");
return row;
}
async createMunicipality(
dto: CreateMunicipalityDto,
user: EdenAuthenticatedUser,
): Promise<FiscalMunicipality> {
const row = await fiscalRepo.createMunicipality(dto);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.municipality.created",
resourceType: "fiscal_municipality",
resourceId: row.id,
metadata: { code: row.code },
});
return row;
}
async updateMunicipality(
id: string,
dto: UpdateMunicipalityDto,
user: EdenAuthenticatedUser,
): Promise<FiscalMunicipality> {
const row = await fiscalRepo.updateMunicipality(id, dto);
if (!row) throw new NotFoundException("Município não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.municipality.updated",
resourceType: "fiscal_municipality",
resourceId: row.id,
metadata: { ...dto },
});
return row;
}
// ---------------------------------------------------------------------
// fiscal_operation_nature — read-only in this MVP (fixed business enum,
// seeded by the migration)
// ---------------------------------------------------------------------
async listOperationNatures(): Promise<FiscalOperationNature[]> {
return fiscalRepo.listOperationNatures();
}
// ---------------------------------------------------------------------
// product_fiscal_profiles
// ---------------------------------------------------------------------
async listProfiles(productId: string): Promise<ProductFiscalProfile[]> {
await this.assertProductExists(productId);
return fiscalRepo.listProductFiscalProfiles(productId);
}
/**
* Creates a new ACTIVE profile for (productId, billingComponent). If one
* is already active, the repository layer inactivates it and creates the
* new one in a single transaction — never a destructive update of the
* old profile (eden.md §2.5).
*/
async createProfile(
productId: string,
dto: CreateProductFiscalProfileDto,
user: EdenAuthenticatedUser,
): Promise<ProductFiscalProfile> {
await this.assertProductExists(productId);
const created = await fiscalRepo.createActiveProductFiscalProfile({
productId,
billingComponent: dto.billingComponent,
documentType: dto.documentType,
ncmId: dto.ncmId ?? null,
cfopId: dto.cfopId ?? null,
municipalityId: dto.municipalityId ?? null,
operationNatureId: dto.operationNatureId ?? null,
validFrom: dto.validFrom ?? null,
validUntil: dto.validUntil ?? null,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.product_profile.created",
resourceType: "product_fiscal_profile",
resourceId: created.id,
metadata: { productId, billingComponent: dto.billingComponent, documentType: created.document_type },
});
return created;
}
private async assertProductExists(productId: string): Promise<void> {
const product = await productsRepo.getProductById(productId);
if (!product) throw new NotFoundException("Produto não encontrado");
}
}

View File

@@ -0,0 +1,35 @@
import { Body, Controller, Get, Param, Post, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { AfdImportsService } from "./afd-imports.service.js";
import { CreateAfdImportDto } from "./dto/create-afd-import.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* eden.md módulo 6 §4.4/§4.5 — AFD import. See afd-imports.service.ts and
* CreateAfdImportDto for this leva's transport simplification (JSON
* string body, not multipart) and its documented encoding caveat.
*/
@ApiTags("hr-timeclock-afd-imports")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
@Controller("v1/hr-timeclock/afd-imports")
export class AfdImportsController {
constructor(private readonly afdImports: AfdImportsService) {}
@Post()
@RequirePermission("afd-import", "edit")
import(@Body() dto: CreateAfdImportDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.afdImports.import(dto, user);
}
@Get(":id")
@RequirePermission("afd-import", "view")
get(@Param("id") id: string) {
return this.afdImports.getById(id);
}
}

View File

@@ -0,0 +1,202 @@
import { createHash } from "node:crypto";
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import {
auditRepo,
hrTimeclockRepo,
type AfdImportBatch,
type AfdImportBatchStatus,
type TimeclockEmployee,
} from "@eden/database";
import { parseAfdFile, normalizeAfdCpf, type AfdParseSummary } from "./afd-parser.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type { CreateAfdImportDto } from "./dto/create-afd-import.dto.js";
export interface ImportAfdFileResult {
alreadyImported: boolean;
batch: AfdImportBatch;
summary?: AfdParseSummary;
}
function isUniqueViolation(err: unknown): boolean {
return typeof err === "object" && err !== null && (err as { code?: unknown }).code === "23505";
}
@Injectable()
export class AfdImportsService {
/**
* The whole AFD import orchestration: whole-file idempotency (SHA-256),
* pure parsing (afd-parser.ts — never touches the DB), best-effort
* employee resolution by CPF (never guesses — ambiguous/unmatched stays
* null), then the transactional write (hrTimeclockRepo.importAfdBatch,
* which does the per-device locking + per-record idempotency).
*
* ENCODING CAVEAT (see afd-parser.ts's header): `dto.fileContent` is
* expected to already be decoded as ISO-8859-1 by the caller — this
* leva's transport is a JSON string, not a raw multipart upload.
*/
async import(dto: CreateAfdImportDto, user: EdenAuthenticatedUser): Promise<ImportAfdFileResult> {
const device = await hrTimeclockRepo.getTimeclockDeviceById(dto.deviceId);
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
// Whole-file idempotency layer (eden.md módulo 6 §12) — hash is over
// the content AS RECEIVED by this endpoint (see migration comment on
// afd_import_batches.file_hash for why this isn't necessarily
// byte-identical to a raw .afd file's own hash in this leva's
// string-transport simplification).
const fileHash = createHash("sha256").update(dto.fileContent, "utf8").digest("hex");
const fileSize = Buffer.byteLength(dto.fileContent, "utf8");
const existing = await hrTimeclockRepo.findAfdImportBatchByFileHash(fileHash);
if (existing) {
throw new ConflictException({
message: "Este arquivo já foi importado anteriormente (idempotência por SHA-256)",
alreadyImported: true,
importId: existing.id,
status: existing.status,
});
}
const parseResult = parseAfdFile(dto.fileContent);
const employeeIdByCpf = await this.resolveEmployeeIds(device.company_id, parseResult.records.map((r) => r.employeeCpfRaw));
const byStatus = parseResult.summary.byValidationStatus;
const validCount = byStatus.VALID ?? 0;
const warningCount = byStatus.WARNING ?? 0;
const invalidCount = byStatus.INVALID ?? 0;
const unsupportedCount = byStatus.UNSUPPORTED_LAYOUT ?? 0;
const parseFailureCount = parseResult.failures.length;
// Batch-level status: INVALID when nothing usable came out of the
// file at all; WARNING when the file is usable but has some
// invalid/unsupported/failed lines; VALID only when everything parsed
// clean. Never silently treated as fully VALID when problems exist.
const status: AfdImportBatchStatus =
validCount === 0 ? "INVALID" : invalidCount + unsupportedCount + parseFailureCount > 0 ? "WARNING" : "VALID";
let result;
try {
result = await hrTimeclockRepo.importAfdBatch({
batch: {
deviceId: dto.deviceId,
companyId: device.company_id,
fileHash,
fileSize,
recordCount: parseResult.summary.parsedCount,
validCount,
warningCount,
invalidCount,
unsupportedCount,
parseFailureCount,
status,
firstNsr: parseResult.summary.firstNsr,
lastNsr: parseResult.summary.lastNsr,
summary: parseResult.summary as unknown as Record<string, unknown>,
importedBy: user.id,
},
records: parseResult.records.map((r) => ({
nsr: r.nsr,
recordType: r.recordType,
eventDatetime: r.eventDatetime,
eventDatetimeRaw: r.eventDatetimeRaw,
employeeCpfRaw: r.employeeCpfRaw,
employeeId: employeeIdByCpf.get(r.employeeCpfRaw ?? "") ?? null,
rawLine: r.rawLine,
crcReceived: r.crcReceived,
crcCalculated: r.crcCalculated,
crcValid: r.crcValid,
parsedData: r.parsedData,
validationStatus: r.validationStatus,
validationNotes: r.validationNotes,
})),
});
} catch (err) {
if (err instanceof hrTimeclockRepo.TimeclockDeviceNotFoundError) {
throw new NotFoundException("Equipamento de ponto não encontrado");
}
if (isUniqueViolation(err)) {
// Race: another request imported the same file_hash between our
// check above and this INSERT. Never duplicate — report the
// existing batch exactly like the up-front check would have.
const raced = await hrTimeclockRepo.findAfdImportBatchByFileHash(fileHash);
if (raced) {
throw new ConflictException({
message: "Este arquivo já foi importado anteriormente (idempotência por SHA-256)",
alreadyImported: true,
importId: raced.id,
status: raced.status,
});
}
}
throw err;
}
// Import itself is a mutation — audited like any other, per the task
// brief's explicit instruction ("obrigatório em toda mutação incluindo
// o próprio import").
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "afd_import.imported",
resourceType: "afd_import_batch",
resourceId: result.batch.id,
metadata: {
deviceId: dto.deviceId,
fileHash,
recordCount: parseResult.summary.parsedCount,
insertedCount: result.insertedCount,
status,
},
});
return { alreadyImported: false, batch: result.batch, summary: parseResult.summary };
}
async getById(id: string): Promise<AfdImportBatch> {
const batch = await hrTimeclockRepo.getAfdImportBatchById(id);
if (!batch) throw new NotFoundException("Importação de AFD não encontrada");
return batch;
}
/**
* Best-effort CPF -> timeclock_employees.id resolution. NEVER guesses:
* a CPF matched in more than one company when the device has no
* company (or vice-versa, ambiguous within the same company — can't
* actually happen given the (company_id, cpf) UNIQUE constraint, but
* the code doesn't rely on that) is left unresolved (null) rather than
* picking arbitrarily. Returns a map keyed by the RAW (unnormalized)
* cpf field, since that's what afd_records.employee_cpf_raw stores and
* what callers look records up by.
*/
private async resolveEmployeeIds(
deviceCompanyId: string | null,
rawCpfs: (string | null)[],
): Promise<Map<string, string | null>> {
const normalizedByRaw = new Map<string, string>();
for (const raw of rawCpfs) {
if (!raw) continue;
const normalized = normalizeAfdCpf(raw);
if (normalized && normalized.length === 11) normalizedByRaw.set(raw, normalized);
}
const distinctNormalized = [...new Set(normalizedByRaw.values())];
const candidates = distinctNormalized.length > 0
? await hrTimeclockRepo.findTimeclockEmployeesByCpfs(distinctNormalized)
: [];
const candidatesByCpf = new Map<string, TimeclockEmployee[]>();
for (const employee of candidates) {
const list = candidatesByCpf.get(employee.cpf) ?? [];
list.push(employee);
candidatesByCpf.set(employee.cpf, list);
}
const result = new Map<string, string | null>();
for (const [raw, normalized] of normalizedByRaw) {
const matches = candidatesByCpf.get(normalized) ?? [];
const scoped = deviceCompanyId ? matches.filter((m) => m.company_id === deviceCompanyId) : matches;
result.set(raw, scoped.length === 1 ? scoped[0]!.id : null);
}
return result;
}
}

View File

@@ -0,0 +1,495 @@
/**
* AFD (Arquivo Fonte de Dados — Portaria MTP 671/2021, leiaute versão 004)
* parser/validator. Ported faithfully from the legacy OrçaFácil
* `parser.js`/`crc16.js` — see `eden.md` módulo 6 §4 (linhas ~3660-3771).
*
* Pure functions only — no I/O, no DB access. This module intentionally
* knows nothing about how the caller obtained the file content or what it
* will do with the parsed records (persist, preview, whatever).
*
* SCOPE OF THIS LEVA: parsing + CRC/structural validation only. Nothing
* here computes apuração (eden.md §5), fechamento de período (§6), ajustes
* (§7), banco de horas (§9) or relatórios (§10) — those are a future leva.
*
* ── Encoding note (real limitation of this leva's transport, documented
* per instruction) ──────────────────────────────────────────────────────
* The real AFD file is ISO-8859-1 (latin1), never UTF-8. This leva's HTTP
* endpoint accepts the file content as a plain JSON string (multipart
* upload is deferred — see afd-imports.controller.ts), which means the
* CALLER is responsible for decoding the original file bytes as latin1
* into a JS string *before* putting it in the JSON body. If the caller
* instead decodes as UTF-8 (or lets some HTTP client guess), any byte
* ≥ 0x80 (accented characters in type-2 `razao_social`/`local` fields —
* type-3 punch records, the ones that matter for attendance, are plain
* ASCII/digits and unaffected) will be corrupted and CRC validation for
* that line will legitimately fail (which is the CORRECT behavior for
* corrupted input — the parser never silently "fixes" a CRC mismatch).
* This module reconstructs bytes for CRC purposes via
* `Buffer.from(str, "latin1")`, which round-trips correctly only if the
* string was decoded as latin1 to begin with.
*
* ── Field positions ─────────────────────────────────────────────────────
* eden.md documents positions as 1-indexed, inclusive ranges (e.g.
* "011-034" = characters 11 through 34, 24 chars wide). Converted here to
* JS's 0-indexed, end-exclusive `slice(a-1, b)`. Every record starts with
* a 9-digit NSR (positions 001-009) + 1-digit record type (position 010),
* i.e. `slice(0, 9)` / `slice(9, 10)` — so every type-specific field list
* below picks up at `slice(10, ...)`, matching "011" in the source doc.
*
* Principle carried over verbatim from the legacy: only fields with an
* exact, documented position are decoded. Everything else — undocumented
* trailer bytes, unsupported record types, truncated lines — is captured
* as `raw_tail` inside `parsedData`, never guessed/invented.
*/
// ─────────────────────────────────────────────────────────────────────────
// CRC-16/KERMIT
// ─────────────────────────────────────────────────────────────────────────
/**
* CRC-16/KERMIT: polynomial 0x1021 reflected (used in the bit-reversed
* form 0x8408), init 0x0000, no final XOR. Applies to AFD record types
* 2/3/4 only — type 7 uses its own hash chain (never recomputed here) and
* types 1/5/6/9 have no CRC field at all.
*
* Known test vector (verified in afd-parser.spec.ts and via an ad-hoc
* script before this was wired into the import path): CRC-16/KERMIT of
* the ASCII string "123456789" is 0x2189.
*/
export function crc16Kermit(data: string | Buffer): number {
const buffer = typeof data === "string" ? Buffer.from(data, "latin1") : data;
let crc = 0x0000;
for (let i = 0; i < buffer.length; i++) {
crc ^= buffer[i]!;
for (let bit = 0; bit < 8; bit++) {
if (crc & 0x0001) {
crc = (crc >>> 1) ^ 0x8408;
} else {
crc = crc >>> 1;
}
}
}
return crc & 0xffff;
}
/** Zero-padded 4-hex-digit representation, uppercase, matching AFD's own CRC field format. */
export function crc16Hex(data: string | Buffer): string {
return crc16Kermit(data).toString(16).padStart(4, "0").toUpperCase();
}
// ─────────────────────────────────────────────────────────────────────────
// CPF normalization
// ─────────────────────────────────────────────────────────────────────────
/**
* The CPF field in AFD types 3/7 is 12 characters wide in the documented
* position, but a real CPF is 11 digits — eden.md §4.3: take the LAST 11
* numeric digits of the raw field, never assume exact formatting. If
* fewer than 11 digits are present (corrupt/short data), returns exactly
* what was found — never left-pads with invented zeros, since that would
* fabricate digits that were never actually in the file.
*/
export function normalizeAfdCpf(raw: string | null | undefined): string | null {
if (!raw) return null;
const digits = raw.replace(/\D/g, "");
if (digits.length === 0) return null;
return digits.length >= 11 ? digits.slice(-11) : digits;
}
// ─────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────
export type AfdCrcValidity = "VALID" | "INVALID" | "NOT_APPLICABLE" | "NOT_VALIDATED";
export type AfdValidationStatus = "VALID" | "WARNING" | "INVALID" | "UNSUPPORTED_LAYOUT";
export interface ParsedAfdRecord {
lineNumber: number;
nsr: number | null;
nsrRaw: string;
recordType: string;
eventDatetime: Date | null;
eventDatetimeRaw: string | null;
employeeCpfRaw: string | null;
rawLine: string;
crcReceived: string | null;
crcCalculated: string | null;
crcValid: AfdCrcValidity;
/** Type-specific decoded fields (only those with a documented position) + `raw_tail` for the rest. Never guessed. */
parsedData: Record<string, unknown>;
validationStatus: AfdValidationStatus;
validationNotes: string | null;
}
export interface AfdParseFailure {
lineNumber: number;
rawLine: string;
error: string;
}
export interface AfdParseSummary {
totalLines: number;
parsedCount: number;
failureCount: number;
byRecordType: Record<string, number>;
byValidationStatus: Record<string, number>;
/** Includes the sentinel trailer NSR (999999999); records whose NSR could not be parsed as a number are excluded. */
firstNsr: number | null;
lastNsr: number | null;
}
export interface AfdParseResult {
records: ParsedAfdRecord[];
failures: AfdParseFailure[];
summary: AfdParseSummary;
}
// ─────────────────────────────────────────────────────────────────────────
// Datetime parsing — AAAA-MM-ddThh:mm:00ZZZZZ (24 chars), e.g. "2026-08-06T08:30:00-0300"
// ─────────────────────────────────────────────────────────────────────────
const AFD_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-]\d{4})$/;
/**
* Parses the fixed-width AFD datetime field. The UTC offset is always
* taken verbatim from the text (never assumed/injected from the server's
* own timezone) — in practice this is always -0300 (America/Sao_Paulo,
* no DST in Brazil since 2019), but this function doesn't hardcode that;
* it trusts whatever offset the equipment actually wrote, exactly per the
* "never invent" principle. Returns null (never throws) if the 24-char
* field doesn't match the documented format — the caller still keeps the
* raw text in `eventDatetimeRaw` regardless.
*/
function parseAfdDatetime(raw: string): Date | null {
const match = AFD_DATETIME_RE.exec(raw.trim());
if (!match) return null;
const [, y, mo, d, h, mi, s, offset] = match;
const offsetWithColon = `${offset!.slice(0, 3)}:${offset!.slice(3)}`;
const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}${offsetWithColon}`;
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? null : date;
}
// ─────────────────────────────────────────────────────────────────────────
// Per-line parsing
// ─────────────────────────────────────────────────────────────────────────
const HEADER_LENGTH = 10; // 9-digit NSR + 1-digit record type
function parseNsr(nsrRaw: string): number | null {
// Never default to 0/autoincrement — only a well-formed 9-digit numeric
// field becomes a number; anything else stays null (eden.md módulo 6 §12).
return /^\d{9}$/.test(nsrRaw) ? Number(nsrRaw) : null;
}
interface CrcCheck {
crcReceived: string | null;
crcCalculated: string | null;
crcValid: AfdCrcValidity;
note: string | null;
}
/** CRC is computed over the line bytes up to (not including) the CRC field itself, per eden.md §4.2. */
function checkCrc(line: string, dataEnd: number, crcFieldStart: number, crcFieldEnd: number): CrcCheck {
if (line.length < crcFieldEnd) {
return { crcReceived: null, crcCalculated: null, crcValid: "INVALID", note: "linha mais curta que o esperado para conter o campo de CRC" };
}
const crcReceived = line.slice(crcFieldStart, crcFieldEnd).trim();
const crcCalculated = crc16Hex(line.slice(0, dataEnd));
const valid = crcReceived.toUpperCase() === crcCalculated;
return {
crcReceived,
crcCalculated,
crcValid: valid ? "VALID" : "INVALID",
note: valid ? null : `CRC divergente: recebido=${crcReceived || "(vazio)"} calculado=${crcCalculated}`,
};
}
/**
* Parses a single AFD line. Never throws under normal malformed input —
* a line that doesn't fit any documented shape still produces a record
* (validationStatus INVALID/UNSUPPORTED_LAYOUT, nsr possibly null,
* rawLine always preserved) rather than being silently dropped, per
* eden.md's "preservar mesmo se inválido" invariant. `parseAfdFile` wraps
* this in a try/catch as a second line of defense for truly unexpected
* exceptions (defensive programming, not the normal path).
*/
export function parseAfdLine(rawLine: string, lineNumber: number): ParsedAfdRecord {
const notes: string[] = [];
if (rawLine.length < HEADER_LENGTH) {
return {
lineNumber,
nsr: null,
nsrRaw: rawLine,
recordType: "",
eventDatetime: null,
eventDatetimeRaw: null,
employeeCpfRaw: null,
rawLine,
crcReceived: null,
crcCalculated: null,
crcValid: "NOT_VALIDATED",
parsedData: { raw_tail: rawLine },
validationStatus: "INVALID",
validationNotes: "linha menor que o cabeçalho mínimo (NSR de 9 dígitos + 1 dígito de tipo, 10 caracteres)",
};
}
const nsrRaw = rawLine.slice(0, 9);
const recordType = rawLine.slice(9, 10);
const nsr = parseNsr(nsrRaw);
if (nsr === null) {
notes.push("NSR ausente ou não numérico — preservado como null, nunca substituído por 0/autoincrement");
}
let eventDatetimeRaw: string | null = null;
let employeeCpfRaw: string | null = null;
let parsedData: Record<string, unknown> = {};
let crcValid: AfdCrcValidity = "NOT_VALIDATED";
let crcReceived: string | null = null;
let crcCalculated: string | null = null;
let validationStatus: AfdValidationStatus = "VALID";
switch (recordType) {
case "1": {
// Cabeçalho do arquivo — só o NSR fixo (000000000) + tipo são documentados.
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH) };
if (nsrRaw !== "000000000") notes.push("NSR do cabeçalho difere do valor fixo esperado (000000000)");
break;
}
case "2": {
// Inclusão/alteração do empregador no REP
const dh = rawLine.slice(10, 34);
const cpfResp = rawLine.slice(34, 48);
const tipoIdent = rawLine.slice(48, 49);
const cnpjEmpregador = rawLine.slice(49, 63);
const cnoCaepf = rawLine.slice(63, 77);
const razaoSocial = rawLine.slice(77, 227);
const local = rawLine.slice(227, 327);
eventDatetimeRaw = dh;
parsedData = { dh, cpf_resp: cpfResp, tipo_ident: tipoIdent, cnpj_empregador: cnpjEmpregador, cno_caepf: cnoCaepf, razao_social: razaoSocial, local };
if (rawLine.length < 331) {
validationStatus = "INVALID";
notes.push("linha mais curta que o esperado para o tipo 2 (esperado >= 331 caracteres)");
crcValid = "INVALID";
} else {
const crc = checkCrc(rawLine, 327, 327, 331);
crcReceived = crc.crcReceived;
crcCalculated = crc.crcCalculated;
crcValid = crc.crcValid;
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
}
break;
}
case "3": {
// Marcação REP-C/REP-A — a principal (batida de ponto convencional)
const dh = rawLine.slice(10, 34);
const cpf = rawLine.slice(34, 46);
eventDatetimeRaw = dh;
employeeCpfRaw = cpf;
parsedData = { dh, cpf };
if (rawLine.length < 50) {
validationStatus = "INVALID";
notes.push("linha mais curta que o esperado para o tipo 3 (esperado 50 caracteres)");
crcValid = "INVALID";
} else {
const crc = checkCrc(rawLine, 46, 46, 50);
crcReceived = crc.crcReceived;
crcCalculated = crc.crcCalculated;
crcValid = crc.crcValid;
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
}
break;
}
case "4": {
// Ajuste do relógio do REP
const dhAntes = rawLine.slice(10, 34);
const dhAjustada = rawLine.slice(34, 58);
const cpfResp = rawLine.slice(58, 69);
eventDatetimeRaw = dhAjustada;
// cpf_resp here is the CPF of whoever adjusted the clock, not
// necessarily a timeclock_employees row — stored in the same
// generic employee_cpf_raw column; resolution against
// timeclock_employees simply won't match if they aren't one
// (harmless — never fabricates a match).
employeeCpfRaw = cpfResp;
parsedData = { dh_antes: dhAntes, dh_ajustada: dhAjustada, cpf_resp: cpfResp };
if (rawLine.length < 73) {
validationStatus = "INVALID";
notes.push("linha mais curta que o esperado para o tipo 4 (esperado 73 caracteres)");
crcValid = "INVALID";
} else {
const crc = checkCrc(rawLine, 69, 69, 73);
crcReceived = crc.crcReceived;
crcCalculated = crc.crcCalculated;
crcValid = crc.crcValid;
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
}
break;
}
case "5": {
// Inclusão/alteração/exclusão de empregado no REP — só dh + operação documentados
const dh = rawLine.slice(10, 34);
const operacao = rawLine.slice(34, 35);
eventDatetimeRaw = dh;
parsedData = { dh, operacao, raw_tail: rawLine.slice(35) };
if (!["I", "A", "E"].includes(operacao)) {
notes.push(`código de operação não documentado: '${operacao}'`);
validationStatus = "WARNING";
}
break;
}
case "6": {
// Evento sensível do REP — só dh documentado
const dh = rawLine.slice(10, 34);
eventDatetimeRaw = dh;
parsedData = { dh, raw_tail: rawLine.slice(34) };
break;
}
case "7": {
// Marcação REP-P — usa hash próprio de encadeamento, NUNCA recalculado aqui
const dhMarcacao = rawLine.slice(10, 34);
const cpf = rawLine.slice(34, 46);
const dhGravacao = rawLine.slice(46, 70);
const coletor = rawLine.slice(70, 72);
const onlineOffline = rawLine.slice(72, 73);
const hash = rawLine.slice(73, 137);
eventDatetimeRaw = dhMarcacao;
employeeCpfRaw = cpf;
parsedData = { dh_marcacao: dhMarcacao, cpf, dh_gravacao: dhGravacao, coletor, online_offline: onlineOffline, hash };
crcValid = "NOT_APPLICABLE";
if (rawLine.length < 137) {
validationStatus = "INVALID";
notes.push("linha mais curta que o esperado para o tipo 7 (esperado 137 caracteres, hash de 64 posições incompleto)");
}
break;
}
case "9": {
// Trailer — só o NSR fixo (999999999) é documentado; contagens internas não têm posição documentada.
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH), trailer_cross_check: "not_implemented_no_documented_positions" };
if (nsrRaw !== "999999999") notes.push("NSR do trailer difere do valor fixo esperado (999999999)");
break;
}
default: {
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH) };
validationStatus = "UNSUPPORTED_LAYOUT";
notes.push(`tipo de registro não reconhecido: '${recordType}'`);
break;
}
}
const eventDatetime = eventDatetimeRaw ? parseAfdDatetime(eventDatetimeRaw) : null;
if (eventDatetimeRaw && eventDatetime === null) {
notes.push(`campo de data/hora fora do formato esperado (AAAA-MM-ddThh:mm:00ZZZZZ): '${eventDatetimeRaw}'`);
if (validationStatus === "VALID") validationStatus = "WARNING";
}
if (nsr === null && validationStatus === "VALID") {
validationStatus = "INVALID";
}
return {
lineNumber,
nsr,
nsrRaw,
recordType,
eventDatetime,
eventDatetimeRaw,
employeeCpfRaw,
rawLine,
crcReceived,
crcCalculated,
crcValid,
parsedData,
validationStatus,
validationNotes: notes.length > 0 ? notes.join("; ") : null,
};
}
// ─────────────────────────────────────────────────────────────────────────
// File-level parsing
// ─────────────────────────────────────────────────────────────────────────
/**
* Splits AFD file content into lines (CRLF per spec; also tolerates bare
* LF defensively) and parses each one. Never throws for a bad line and
* never stops processing because of one — see module doc for the
* "records vs. failures" partial-error policy:
*
* - A line that is merely malformed AFD data (wrong type, bad CRC, too
* short, unsupported record type, etc.) still produces a `records`
* entry with an INVALID/WARNING/UNSUPPORTED_LAYOUT status — it is
* NEVER dropped, because raw_line is legally-relevant raw data that
* must always be preserved (eden.md módulo 6 §12).
* - A line that causes an actual unexpected exception in `parseAfdLine`
* (a defensive safety net, not the normal path) still produces a
* fallback INVALID record so nothing is silently lost, AND is also
* recorded in `failures` for import-summary visibility.
*
* Fully blank lines (an artifact of a trailing CRLF at EOF, or stray
* blank lines in a hand-edited file) are skipped without generating a
* record — they carry no data to preserve.
*/
export function parseAfdFile(content: string): AfdParseResult {
const rawLines = content.split(/\r\n|\n\r|\n/);
const records: ParsedAfdRecord[] = [];
const failures: AfdParseFailure[] = [];
let lineNumber = 0;
for (const line of rawLines) {
lineNumber += 1;
if (line.length === 0) continue; // trailing EOF newline / stray blank line — no data to preserve
try {
records.push(parseAfdLine(line, lineNumber));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
failures.push({ lineNumber, rawLine: line, error: message });
records.push({
lineNumber,
nsr: null,
nsrRaw: line.slice(0, 9),
recordType: line.slice(9, 10) || "",
eventDatetime: null,
eventDatetimeRaw: null,
employeeCpfRaw: null,
rawLine: line,
crcReceived: null,
crcCalculated: null,
crcValid: "NOT_VALIDATED",
parsedData: { raw_tail: line },
validationStatus: "INVALID",
validationNotes: `Erro inesperado ao parsear linha: ${message}`,
});
}
}
const byRecordType: Record<string, number> = {};
const byValidationStatus: Record<string, number> = {};
let firstNsr: number | null = null;
let lastNsr: number | null = null;
for (const record of records) {
byRecordType[record.recordType] = (byRecordType[record.recordType] ?? 0) + 1;
byValidationStatus[record.validationStatus] = (byValidationStatus[record.validationStatus] ?? 0) + 1;
if (record.nsr !== null) {
if (firstNsr === null) firstNsr = record.nsr;
lastNsr = record.nsr;
}
}
return {
records,
failures,
summary: {
totalLines: lineNumber,
parsedCount: records.length,
failureCount: failures.length,
byRecordType,
byValidationStatus,
firstNsr,
lastNsr,
},
};
}

View File

@@ -0,0 +1,37 @@
import { Controller, Get, Param, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { AfdRecordsService } from "./afd-records.service.js";
import { ListAfdRecordsQueryDto } from "./dto/list-afd-records-query.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
/**
* READ-ONLY — eden.md módulo 6 §12: afd_records (dado bruto de ponto) é
* imutável. This controller intentionally has no POST/PATCH/PUT/DELETE
* handler, at any permission level — the only way a row is ever created
* is AfdImportsController's import endpoint (via
* hrTimeclockRepo.importAfdBatch's INSERT), and the database itself
* additionally rejects UPDATE/DELETE via a trigger with no bypass (see
* the migration) — so this is defense in depth, not the only guard.
*/
@ApiTags("hr-timeclock-afd-records")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
@Controller("v1/hr-timeclock/afd-records")
export class AfdRecordsController {
constructor(private readonly afdRecords: AfdRecordsService) {}
@Get()
@RequirePermission("afd-import", "view")
list(@Query() query: ListAfdRecordsQueryDto) {
return this.afdRecords.list(query);
}
@Get(":id")
@RequirePermission("afd-import", "view")
get(@Param("id") id: string) {
return this.afdRecords.get(id);
}
}

View File

@@ -0,0 +1,45 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { hrTimeclockRepo, type AfdRecord } from "@eden/database";
import type { ListAfdRecordsQueryDto } from "./dto/list-afd-records-query.dto.js";
/**
* Read-only, matching eden.md §4.5 ("nunca há rota de escrita para esta
* tabela"). There is deliberately no create/update/delete method here —
* see hr-timeclock/afd-records.controller.ts, which exposes GET only.
*/
@Injectable()
export class AfdRecordsService {
async list(query: ListAfdRecordsQueryDto): Promise<{ records: AfdRecord[]; total: number; limit: number; offset: number }> {
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
// dateTo is inclusive at the day level per eden.md §4.5 ("date_to usa
// limite exclusivo do dia seguinte para incluir o dia inteiro") — the
// query DTO takes a plain date/datetime string; if it looks like a
// bare date (no time component), advance to the start of the next
// day so the whole end day is included.
let dateTo: Date | undefined;
if (query.dateTo) {
const parsed = new Date(query.dateTo);
dateTo = /^\d{4}-\d{2}-\d{2}$/.test(query.dateTo) ? new Date(parsed.getTime() + 24 * 60 * 60 * 1000) : parsed;
}
const { records, total } = await hrTimeclockRepo.listAfdRecords({
deviceId: query.deviceId,
employeeId: query.employeeId,
recordType: query.recordType,
dateFrom: query.dateFrom ? new Date(query.dateFrom) : undefined,
dateTo,
limit,
offset,
});
return { records, total, limit, offset };
}
async get(id: string): Promise<AfdRecord> {
const record = await hrTimeclockRepo.getAfdRecordById(id);
if (!record) throw new NotFoundException("Registro de AFD não encontrado");
return record;
}
}

View File

@@ -0,0 +1,20 @@
import { IsString, IsUUID, MinLength } from "class-validator";
/**
* SIMPLIFICATION OF THIS LEVA: the AFD file is accepted as a plain string
* in the JSON body, not a real multipart upload — see
* afd-imports.controller.ts / afd-parser.ts header comments for why (and
* the encoding caveat this implies). Real multipart upload (matching
* eden.md §4.5's `POST /afd-imports/import`) is future work.
*/
export class CreateAfdImportDto {
@IsUUID() deviceId!: string;
/**
* The AFD file's full text content, decoded by the CALLER as
* ISO-8859-1 (latin1) — see afd-parser.ts's encoding note. Sending
* UTF-8-decoded content here will cause CRC validation to legitimately
* fail for any line with a byte ≥ 0x80.
*/
@IsString() @MinLength(1) fileContent!: string;
}

View File

@@ -0,0 +1,20 @@
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min, MinLength } from "class-validator";
export class CreateTimeclockDeviceDto {
@IsOptional() @IsUUID() companyId?: string;
@IsString() @MinLength(1) @MaxLength(200) name!: string;
@IsOptional() @IsString() @MaxLength(2000) description?: string;
@IsOptional() @IsString() @MaxLength(200) branch?: string;
@IsString() @MinLength(1) @MaxLength(255) host!: string;
@IsOptional() @IsInt() @Min(1) @Max(65535) port?: number;
@IsOptional() @IsBoolean() useHttps?: boolean;
@IsString() @MinLength(1) @MaxLength(200) username!: string;
/** Plaintext in — encrypted server-side via FieldEncryptionService before storage, never persisted or logged as-is. */
@IsString() @MinLength(1) @MaxLength(500) password!: string;
@IsOptional() @IsString() @MaxLength(100) timezone?: string;
}

View File

@@ -0,0 +1,18 @@
import { Type } from "class-transformer";
import { IsDateString, IsIn, IsInt, IsOptional, IsUUID, Max, Min } from "class-validator";
const AFD_RECORD_TYPES = ["1", "2", "3", "4", "5", "6", "7", "9"] as const;
export class ListAfdRecordsQueryDto {
@IsOptional() @IsUUID() deviceId?: string;
@IsOptional() @IsUUID() employeeId?: string;
@IsOptional() @IsIn(AFD_RECORD_TYPES) recordType?: string;
/** Inclusive lower bound (ISO date/datetime string). */
@IsOptional() @IsDateString() dateFrom?: string;
/** Inclusive upper bound at the DAY level — the service applies the next-day-exclusive convention (eden.md §4.5). */
@IsOptional() @IsDateString() dateTo?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) limit?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) offset?: number;
}

View File

@@ -0,0 +1,18 @@
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from "class-validator";
export class UpdateTimeclockDeviceDto {
@IsOptional() @IsString() @MinLength(1) @MaxLength(200) name?: string;
@IsOptional() @IsString() @MaxLength(2000) description?: string;
@IsOptional() @IsString() @MaxLength(200) branch?: string;
@IsOptional() @IsString() @MinLength(1) @MaxLength(255) host?: string;
@IsOptional() @IsInt() @Min(1) @Max(65535) port?: number;
@IsOptional() @IsBoolean() useHttps?: boolean;
@IsOptional() @IsString() @MinLength(1) @MaxLength(200) username?: string;
/** Only re-encrypted/stored when present — omit to leave the current credential untouched. */
@IsOptional() @IsString() @MinLength(1) @MaxLength(500) password?: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -0,0 +1,45 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "../config/config.module.js";
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { TokenService } from "../auth/token.service.js";
import { TimeclockDevicesController } from "./timeclock-devices.controller.js";
import { TimeclockDevicesService } from "./timeclock-devices.service.js";
import { AfdImportsController } from "./afd-imports.controller.js";
import { AfdImportsService } from "./afd-imports.service.js";
import { AfdRecordsController } from "./afd-records.controller.js";
import { AfdRecordsService } from "./afd-records.service.js";
/**
* HR/Timeclock (eden.md módulo 6) — foundation leva. See
* afd-parser.ts and packages/database/migrations/PENDING_hr-timeclock.cjs
* for the full scope/deferral notes.
*
* INTEGRATION NOTE for whoever merges this: `ConfigModule`,
* `FieldEncryptionService`, `JwtAuthGuard`, `PermissionGuard` and
* `TokenService` are provided directly here (rather than imported from a
* shared `AuthModule`) because this worktree was isolated at a bootstrap
* commit with no Identity/Auth module yet — see apps/api/src/auth/*'s
* header comments. Once the real Identity/Auth module lands with its own
* `AuthModule` (exporting these same classes) and a global `ConfigModule`
* wired into `AppModule`, this module's `providers` list should shrink to
* just the HR-specific services and `imports: [AuthModule]` should
* replace the local guard/token/config providers below — no controller or
* service code needs to change, since they all import from the same
* `../auth/...`/`../crypto/...` paths either way.
*/
@Module({
imports: [ConfigModule],
controllers: [TimeclockDevicesController, AfdImportsController, AfdRecordsController],
providers: [
FieldEncryptionService,
TokenService,
JwtAuthGuard,
PermissionGuard,
TimeclockDevicesService,
AfdImportsService,
AfdRecordsService,
],
})
export class HrTimeclockModule {}

View File

@@ -0,0 +1,57 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { TimeclockDevicesService } from "./timeclock-devices.service.js";
import { CreateTimeclockDeviceDto } from "./dto/create-timeclock-device.dto.js";
import { UpdateTimeclockDeviceDto } from "./dto/update-timeclock-device.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* eden.md módulo 6 §1.1/§2 — Control iD REP iDClass device registry.
* password_encrypted is never returned in plaintext by any route here —
* see TimeclockDevicesService's toView (hasCredential flag only).
*
* NOTE: `@UsePipes(ValidationPipe)` is applied locally (rather than
* relying on a global pipe in main.ts) because this bootstrap-stage
* worktree's main.ts doesn't wire one up yet — see this task's final
* summary. Harmless/redundant once the real global pipe lands.
*/
@ApiTags("hr-timeclock-devices")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
@Controller("v1/hr-timeclock/devices")
export class TimeclockDevicesController {
constructor(private readonly devices: TimeclockDevicesService) {}
@Get()
@RequirePermission("timeclock-devices", "view")
list(@Query("includeInactive") includeInactive?: string) {
return this.devices.list(includeInactive === "true");
}
@Get(":id")
@RequirePermission("timeclock-devices", "view")
get(@Param("id") id: string) {
return this.devices.get(id);
}
@Post()
@RequirePermission("timeclock-devices", "edit")
create(@Body() dto: CreateTimeclockDeviceDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.devices.create(dto, user);
}
@Patch(":id")
@RequirePermission("timeclock-devices", "edit")
update(
@Param("id") id: string,
@Body() dto: UpdateTimeclockDeviceDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.devices.update(id, dto, user);
}
}

View File

@@ -0,0 +1,95 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { auditRepo, hrTimeclockRepo, type TimeclockDevice } from "@eden/database";
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type { CreateTimeclockDeviceDto } from "./dto/create-timeclock-device.dto.js";
import type { UpdateTimeclockDeviceDto } from "./dto/update-timeclock-device.dto.js";
/** Never expose password_encrypted — GET responses only ever carry hasCredential. */
export type TimeclockDeviceView = Omit<TimeclockDevice, "password_encrypted"> & { hasCredential: boolean };
function toView(device: TimeclockDevice): TimeclockDeviceView {
const { password_encrypted, ...rest } = device;
return { ...rest, hasCredential: password_encrypted.length > 0 };
}
@Injectable()
export class TimeclockDevicesService {
constructor(private readonly fieldEncryption: FieldEncryptionService) {}
async list(includeInactive: boolean): Promise<TimeclockDeviceView[]> {
const devices = await hrTimeclockRepo.listTimeclockDevices(includeInactive);
return devices.map(toView);
}
async get(id: string): Promise<TimeclockDeviceView> {
const device = await hrTimeclockRepo.getTimeclockDeviceById(id);
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
return toView(device);
}
async create(dto: CreateTimeclockDeviceDto, user: EdenAuthenticatedUser): Promise<TimeclockDeviceView> {
const passwordEncrypted = this.fieldEncryption.encrypt(dto.password);
const device = await hrTimeclockRepo.createTimeclockDevice({
companyId: dto.companyId ?? null,
name: dto.name,
description: dto.description ?? null,
branch: dto.branch ?? null,
host: dto.host,
port: dto.port,
useHttps: dto.useHttps,
username: dto.username,
passwordEncrypted,
timezone: dto.timezone,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "timeclock_device.created",
resourceType: "timeclock_device",
resourceId: device.id,
metadata: { name: device.name, host: device.host },
});
return toView(device);
}
async update(
id: string,
dto: UpdateTimeclockDeviceDto,
user: EdenAuthenticatedUser,
): Promise<TimeclockDeviceView> {
await this.get(id); // 404 before attempting the update
const passwordEncrypted = dto.password ? this.fieldEncryption.encrypt(dto.password) : undefined;
const device = await hrTimeclockRepo.updateTimeclockDevice(id, {
name: dto.name,
description: dto.description,
branch: dto.branch,
host: dto.host,
port: dto.port,
useHttps: dto.useHttps,
username: dto.username,
passwordEncrypted,
active: dto.active,
updatedBy: user.id,
});
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "timeclock_device.updated",
resourceType: "timeclock_device",
resourceId: id,
// Never log the password itself, even encrypted — just whether it changed.
metadata: { fields: Object.keys(dto).filter((f) => f !== "password"), credentialChanged: passwordEncrypted !== undefined },
});
return toView(device);
}
}

View File

@@ -0,0 +1,21 @@
import { IsDateString, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
/**
* At least one of serial/patrimonyTag is required — enforced in the
* service (not here, class-validator doesn't do cross-field rules
* cleanly) so the 400 message can name both fields together. Mirrors the
* DB-level chk_serialized_assets_has_identifier constraint.
*/
export class CreateAssetDto {
@IsUUID()
productId!: string;
@IsUUID()
warehouseId!: string;
@IsOptional() @IsString() @MinLength(1) serial?: string;
@IsOptional() @IsString() @MinLength(1) patrimonyTag?: string;
@IsOptional() @IsString() @MinLength(1) macAddress?: string;
@IsOptional() @IsDateString() warrantyExpiresAt?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,22 @@
import { IsNumber, IsOptional, IsString, IsUUID, Min } from "class-validator";
/**
* Generic reservation — product+quantity, no serial selected yet. The
* first step of the Master Prompt §6.8 flow (reserve → later pick the
* specific serialized unit → install).
*/
export class CreateReservationDto {
@IsUUID()
warehouseId!: string;
@IsUUID()
productId!: string;
@IsNumber()
@Min(0.001)
quantity!: number;
@IsOptional() @IsString() referenceType?: string;
@IsOptional() @IsUUID() referenceId?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,16 @@
import { IsBoolean, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
export class CreateWarehouseDto {
@IsUUID()
companyId!: string;
@IsString()
@MinLength(1)
name!: string;
@IsString()
@MinLength(1)
code!: string;
@IsOptional() @IsBoolean() isActive?: boolean;
}

View File

@@ -0,0 +1,11 @@
import { IsIn, IsOptional, IsString, IsUUID } from "class-validator";
export class InstallAssetDto {
@IsUUID()
contractId!: string;
@IsIn(["installed", "comodato"])
assignmentType!: "installed" | "comodato";
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsOptional, IsString, IsUUID } from "class-validator";
export class ReserveAssetDto {
@IsOptional() @IsString() referenceType?: string;
@IsOptional() @IsUUID() referenceId?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,9 @@
import { IsOptional, IsString, IsUUID } from "class-validator";
export class ReturnAssetDto {
/** Warehouse receiving the physical unit back — see serialized_assets.warehouse_id comment in the migration for why this can't default to "the warehouse it left from" (that location was cleared on install). */
@IsUUID()
warehouseId!: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional, IsString, MinLength } from "class-validator";
export class UpdateWarehouseDto {
@IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsString() @MinLength(1) code?: string;
@IsOptional() @IsBoolean() isActive?: boolean;
}

View File

@@ -0,0 +1,120 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { InventoryService } from "./inventory.service.js";
import { CreateAssetDto } from "./dto/create-asset.dto.js";
import { ReserveAssetDto } from "./dto/reserve-asset.dto.js";
import { InstallAssetDto } from "./dto/install-asset.dto.js";
import { ReturnAssetDto } from "./dto/return-asset.dto.js";
import { CreateReservationDto } from "./dto/create-reservation.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Serialized assets, the stock ledger, and reservations — Master Prompt
* §6.8, ADR-0007. Full cycle modeled here: reserve (generic or a specific
* serial) → install/comodato → return. Every mutation appends a
* stock_movement (ledger, never an editable balance column — see
* PENDING_inventory.cjs's header comment) and an audit_log event.
*/
@ApiTags("inventory")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/inventory")
export class InventoryController {
constructor(private readonly inventory: InventoryService) {}
@Get("assets")
@RequirePermission("inventory", "view")
listAssets(
@Query("status") status?: string,
@Query("warehouseId") warehouseId?: string,
@Query("productId") productId?: string,
) {
return this.inventory.listAssets(status, warehouseId, productId);
}
@Get("assets/:id")
@RequirePermission("inventory", "view")
getAsset(@Param("id") id: string) {
return this.inventory.getAsset(id);
}
@Post("assets")
@RequirePermission("inventory", "edit")
createAsset(@Body() dto: CreateAssetDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.inventory.createAsset(dto, user);
}
/**
* Reserves this SPECIFIC serialized asset (quantity is always 1 —
* the serial has already been picked). For the generic product+quantity
* reservation described in the Master Prompt as the step BEFORE a
* serial is picked, see POST /v1/inventory/reservations instead.
*/
@Post("assets/:id/reserve")
@RequirePermission("inventory", "edit")
reserveAsset(
@Param("id") id: string,
@Body() dto: ReserveAssetDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.inventory.reserveAsset(id, dto, user);
}
@Post("assets/:id/install")
@RequirePermission("inventory", "edit")
installAsset(
@Param("id") id: string,
@Body() dto: InstallAssetDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.inventory.installAsset(id, dto, user);
}
@Post("assets/:id/return")
@RequirePermission("inventory", "edit")
returnAsset(
@Param("id") id: string,
@Body() dto: ReturnAssetDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.inventory.returnAsset(id, dto, user);
}
@Get("movements")
@RequirePermission("inventory", "view")
listMovements(
@Query("warehouseId") warehouseId?: string,
@Query("productId") productId?: string,
@Query("referenceType") referenceType?: string,
@Query("referenceId") referenceId?: string,
) {
return this.inventory.listMovements(warehouseId, productId, referenceType, referenceId);
}
/** Generic quantity reservation (product+warehouse, no serial picked yet) — the first step of the Master Prompt §6.8 flow. */
@Get("reservations")
@RequirePermission("inventory", "view")
listReservations(
@Query("status") status?: string,
@Query("warehouseId") warehouseId?: string,
@Query("productId") productId?: string,
) {
return this.inventory.listReservations(status, warehouseId, productId);
}
@Post("reservations")
@RequirePermission("inventory", "edit")
createReservation(@Body() dto: CreateReservationDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.inventory.createReservation(dto, user);
}
@Post("reservations/:id/release")
@RequirePermission("inventory", "edit")
releaseReservation(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.inventory.releaseReservation(id, user);
}
}

Some files were not shown because too many files have changed in this diff Show More