Compare commits
8 Commits
1545666f17
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2aeb745d87 | |||
| 398579fa82 | |||
| 338677edc1 | |||
| a1e020556e | |||
| 33efb9b9ca | |||
| 665b0ac6d5 | |||
| b3b1e21c20 | |||
| 3785b32c5f |
@@ -7,11 +7,13 @@
|
|||||||
*
|
*
|
||||||
* Cobre os nós que o editor visual (apps/core-web/src/routes/documents/editor)
|
* Cobre os nós que o editor visual (apps/core-web/src/routes/documents/editor)
|
||||||
* realmente produz: paragraph/heading/bulletList/orderedList/listItem/image/
|
* realmente produz: paragraph/heading/bulletList/orderedList/listItem/image/
|
||||||
* pageBreak/table+tableRow+tableHeader+tableCell/mergeField, marks bold/
|
* pageBreak/table+tableRow+tableHeader+tableCell/mergeField/conditional,
|
||||||
* italic/underline/strike/textStyle(fontSize). NÃO implementado (fora de
|
* marks bold/italic/underline/strike/textStyle(fontSize). `conditional`
|
||||||
* escopo desta leva, ver docs/progress.md): nó `conditional` e
|
* (envelope em volta de uma `tableRow`, usado pelo Termo de Contratação
|
||||||
* `tableRow.attrs.repeat` (expansão de linha de tabela) — o editor/catálogo
|
* STFC importado do legado) sempre RENDERIZA sua linha — avaliação real da
|
||||||
* de variáveis desta leva nunca produz nenhum dos dois.
|
* 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 {
|
export interface TiptapMark {
|
||||||
@@ -117,6 +119,13 @@ function renderBlock(node: TiptapNode, resolveMergeField?: ResolveMergeField): s
|
|||||||
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
|
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
|
||||||
return `<td${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</td>`;
|
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:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,21 @@ export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
|
|||||||
{ key: "oferta.franquia_minutos", label: "Franquia de Minutos", 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.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.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.
|
// 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.numero", label: "Número do Contrato IXC", category: "Contrato", required: false },
|
||||||
@@ -111,6 +126,7 @@ export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
|
|||||||
{ key: "contrato.software_nome", label: "Nome do Software", 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.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.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.
|
// 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.prestadora_doadora", label: "Prestadora Doadora", category: "Portabilidade", required: false },
|
||||||
@@ -121,6 +137,12 @@ export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
|
|||||||
category: "Portabilidade",
|
category: "Portabilidade",
|
||||||
required: false,
|
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.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 },
|
{ key: "cessao.numeros_contratados", label: "Números Contratados", category: "Cessão", required: false },
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,56 @@ describe("editor-content-renderer", () => {
|
|||||||
expect(resolvedHtml).not.toContain("merge-field-chip");
|
expect(resolvedHtml).not.toContain("merge-field-chip");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("conditional sempre renderiza sua linha (avaliação real de condição é trabalho futuro)", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Coluna" }] }] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "conditional",
|
||||||
|
attrs: { field: "oferta.franquia_minutos", operator: "exists" },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [
|
||||||
|
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "linha condicional" }] }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = renderToHtml(doc);
|
||||||
|
expect(html).toMatch(/<thead>.*<th><p>Coluna<\/p><\/th>.*<\/thead>/s);
|
||||||
|
expect(html).toContain("<tr><td><p>linha condicional</p></td></tr>");
|
||||||
|
expect(html).not.toContain("conditional");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collectMergeFieldKeys enxerga chaves dentro de um conditional", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "conditional",
|
||||||
|
attrs: { field: "x" },
|
||||||
|
content: [
|
||||||
|
{ type: "tableRow", content: [{ type: "tableCell", content: [{ type: "mergeField", attrs: { key: "a.b" } }] }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(collectMergeFieldKeys(doc)).toEqual(["a.b"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("escapa HTML de texto e de valores resolvidos (proteção contra XSS)", () => {
|
it("escapa HTML de texto e de valores resolvidos (proteção contra XSS)", () => {
|
||||||
const doc = {
|
const doc = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
|
|||||||
@@ -13,8 +13,18 @@ import { TableCell } from "@tiptap/extension-table-cell";
|
|||||||
import type { VariableCatalogEntry } from "../../../lib/api/documents.js";
|
import type { VariableCatalogEntry } from "../../../lib/api/documents.js";
|
||||||
import { MergeField } from "./mergeFieldExtension.js";
|
import { MergeField } from "./mergeFieldExtension.js";
|
||||||
import { PageBreak } from "./pageBreakExtension.js";
|
import { PageBreak } from "./pageBreakExtension.js";
|
||||||
|
import { ConditionalRow } from "./conditionalRowExtension.js";
|
||||||
import { EditorToolbar } from "./EditorToolbar.js";
|
import { EditorToolbar } from "./EditorToolbar.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Table` precisa aceitar `conditional` como filho direto, além de
|
||||||
|
* `tableRow` — sem isso o Tiptap rejeita o documento inteiro (renderiza em
|
||||||
|
* branco) sempre que uma tabela contém uma linha condicional, exatamente o
|
||||||
|
* bug documentado no export dos templates do legado (README dentro de
|
||||||
|
* `document_templates_export.zip`) e já corrigido lá da mesma forma.
|
||||||
|
*/
|
||||||
|
const TableWithConditional = Table.extend({ content: "(tableRow|conditional)+" });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editor de rascunho real (Tiptap) — substitui o `<textarea>` de texto puro
|
* Editor de rascunho real (Tiptap) — substitui o `<textarea>` de texto puro
|
||||||
* das levas anteriores. `content` é só o valor INICIAL (Tiptap é
|
* das levas anteriores. `content` é só o valor INICIAL (Tiptap é
|
||||||
@@ -39,12 +49,13 @@ export function DocumentEditor({
|
|||||||
FontSize,
|
FontSize,
|
||||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||||
Image,
|
Image,
|
||||||
Table.configure({ resizable: false }),
|
TableWithConditional.configure({ resizable: false }),
|
||||||
TableRow,
|
TableRow,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableCell,
|
TableCell,
|
||||||
MergeField,
|
MergeField,
|
||||||
PageBreak,
|
PageBreak,
|
||||||
|
ConditionalRow,
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor: updated }) => onChange(updated.getJSON()),
|
onUpdate: ({ editor: updated }) => onChange(updated.getJSON()),
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envolve uma única `tableRow` que só deveria aparecer no documento final se
|
||||||
|
* uma condição bater contra o contexto (avaliada no backend — ver
|
||||||
|
* apps/api/src/documents/editor-content-renderer.ts, case 'conditional').
|
||||||
|
* Porta de `ConditionalRow.jsx` (export dos templates do legado,
|
||||||
|
* `/home/handix/document_templates_export.zip`) — necessário pra abrir o
|
||||||
|
* Termo de Contratação STFC, o único dos 7 templates importados que usa
|
||||||
|
* linhas condicionais (portabilidade/franquia de minutos).
|
||||||
|
*
|
||||||
|
* Nesta leva a condição NUNCA é avaliada de verdade (fora de escopo, ver
|
||||||
|
* docs/progress.md) — a linha sempre aparece no PDF final, igual ao
|
||||||
|
* comportamento do backend quando `conditional` não é resolvido.
|
||||||
|
*
|
||||||
|
* Renderiza como `<tbody style="display:contents">` (sem caixa própria) —
|
||||||
|
* a `<tr>` de dentro participa do layout da tabela normalmente, só o
|
||||||
|
* "envelope" de identificação some visualmente no editor.
|
||||||
|
*/
|
||||||
|
export const ConditionalRow = Node.create({
|
||||||
|
name: "conditional",
|
||||||
|
group: "block",
|
||||||
|
content: "tableRow",
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
field: { default: null },
|
||||||
|
operator: { default: "exists" },
|
||||||
|
value: { default: null },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: "tbody[data-conditional-field]" }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"tbody",
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
style: "display:contents",
|
||||||
|
"data-conditional-field": node.attrs.field || "",
|
||||||
|
title: `Linha condicional — sempre visível nesta leva (avaliação real de "${node.attrs.field || "?"}" é trabalho futuro)`,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ node }) => {
|
||||||
|
const dom = document.createElement("tbody");
|
||||||
|
dom.style.cssText = "display:contents";
|
||||||
|
dom.setAttribute("data-conditional-field", node.attrs.field || "");
|
||||||
|
dom.title = `Linha condicional — sempre visível nesta leva (avaliação real de "${node.attrs.field || "?"}" é trabalho futuro)`;
|
||||||
|
return { dom, contentDOM: dom };
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -913,3 +913,98 @@ Publicado o rascunho e confirmado que a versão publicada aparece corretamente n
|
|||||||
"Razão Social"/"CNPJ/CPF" renderizado com o estilo `.merge-field-chip` aplicado). Template de teste
|
"Razão Social"/"CNPJ/CPF" renderizado com o estilo `.merge-field-chip` aplicado). Template de teste
|
||||||
(`qa_test_editor_*`) desativado ao final via `UPDATE document_templates SET active=false` (sem endpoint de
|
(`qa_test_editor_*`) desativado ao final via `UPDATE document_templates SET active=false` (sem endpoint de
|
||||||
exclusão, mesmo padrão de higiene de dados de teste já seguido nesta sessão).
|
exclusão, mesmo padrão de higiene de dados de teste já seguido nesta sessão).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Importação dos 7 templates reais do legado + suporte a `conditional`
|
||||||
|
|
||||||
|
Data: 2026-09-19. Usuário forneceu `/home/handix/document_templates_export.zip` — export real dos 7
|
||||||
|
templates de documento do OrçaFácil legado (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), cada um com
|
||||||
|
seu `editor_content` (árvore Tiptap real, não simplificada) e as 4 extensões Tiptap customizadas do
|
||||||
|
próprio editor legado (`mergeField`, `pageBreak`, `conditional`, `tableRow.repeat`). Pediu pra trazer esses
|
||||||
|
templates pro EDEN "pra ver como ficou" — validação de ponta a ponta do editor rico construído na leva
|
||||||
|
anterior contra conteúdo de produção real, não sintético.
|
||||||
|
|
||||||
|
**Achado real**: dos 7 templates, 6 usam só o que já era suportado (paragraph/table/mergeField/bold/
|
||||||
|
textAlign/colspan); o Termo de Contratação STFC usa também o nó `conditional` (envelope em volta de uma
|
||||||
|
`tableRow`, usado nas linhas de franquia de minutos/0800) — não suportado na leva anterior. Sem essa
|
||||||
|
extensão, o **editor renderizava em branco** pra esse documento específico — exatamente o mesmo bug já
|
||||||
|
documentado (e corrigido) no próprio export do legado (`ConditionalRow.jsx`, comentário no README do zip).
|
||||||
|
Nenhum dos 7 usa `tableRow.attrs.repeat` — não precisou ser implementado.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
`editor-content-renderer.ts`: novo `case "conditional"` em `renderBlock` — sempre renderiza a linha
|
||||||
|
(avaliação real da condição contra contexto cru é trabalho futuro, fora de escopo, igual já documentado
|
||||||
|
pro nó em si). `collectMergeFieldKeys` já enxergava por dentro de qualquer node type genericamente, não
|
||||||
|
precisou mudar. `variable-catalog.ts`: adicionadas as ~25 entradas que faltavam (tabela de tarifação STFC
|
||||||
|
por banda/normal-reduzida, 0800, `comercializacao_inicio/fim`, `contrato.prazo_instalacao`,
|
||||||
|
`portabilidade.titular_cpf_cnpj_doadora`) — descobertas comparando as chaves realmente usadas nos 7
|
||||||
|
templates contra o catálogo já portado, todas historicamente do `variableRegistry.js` original.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
Novo `conditionalRowExtension.ts` (porta de `ConditionalRow.jsx` do export) + `Table.extend({ content:
|
||||||
|
"(tableRow|conditional)+" })` em `DocumentEditor.tsx` — sem essa extensão de schema o Tiptap rejeita
|
||||||
|
qualquer documento com `conditional` como filho direto de `table`, mesmo bug do parágrafo acima.
|
||||||
|
|
||||||
|
### Importação
|
||||||
|
Script único (`scratchpad`, não versionado — é carga de dados, não feature) reaproveitando os endpoints já
|
||||||
|
existentes (create template → create draft → update draft com o `editor_content` real → publish) pra cada
|
||||||
|
um dos 7 JSONs, exatamente como um humano faria pela UI — nenhum endpoint de "importar" novo foi criado.
|
||||||
|
|
||||||
|
### Verificação
|
||||||
|
`pnpm turbo run typecheck build` 28/28. Suíte 125/125 (123→125: 2 novos casos de `conditional`). Os 7
|
||||||
|
templates reais importados e publicados com sucesso contra o ambiente docker local. Testado ao vivo: tela
|
||||||
|
de overview do Termo de Contratação STFC renderiza o contrato completo (3 páginas) com a tabela de
|
||||||
|
tarifação (LC/LDN/VC1/VC2/VC3/LDI × Normal/Reduzido) e as linhas condicionais de franquia/0800 no lugar
|
||||||
|
certo; o editor rico abre o MESMO documento sem tela em branco, com as 53 variáveis usadas listadas
|
||||||
|
corretamente no painel lateral (com badges "obrigatório"). Contrato de Software (o maior, 162KB de JSON)
|
||||||
|
também verificado: abre no editor sem erro de console/página, gera pré-visualização em PDF real sem erro.
|
||||||
|
PDF do STFC gerado via curl direto (Chromium real) e conferido via `pdftotext`: 3 páginas, texto
|
||||||
|
`[oferta.franquia_minutos]`/`[oferta.inclui_0800]`/`[oferta.numero_0800]`/tarifas em `[chave]` no lugar
|
||||||
|
certo do documento — confirma que o `conditional` nunca é removido, só passa adiante sem avaliação real
|
||||||
|
(comportamento documentado, não um bug).
|
||||||
|
|
||||||
|
### Achado colateral, não corrigido (fora de escopo)
|
||||||
|
A tela de listagem de templates estava com ~145 registros `qa_test_*` acumulados de suítes de teste
|
||||||
|
anteriores desta sessão — desativados via `UPDATE document_templates SET active=false` (mesmo padrão já
|
||||||
|
usado antes). Restaram ~9 templates de teste manual mais antigos (`client_test_contract`, `live_test_*`,
|
||||||
|
`smoke_*`, `teste_web_final` etc.) que não seguem o prefixo `qa_test_` automatizado — não mexidos, por
|
||||||
|
poderem ser exploração manual do próprio usuário.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Os 7 templates viram parte do código (migration de seed)
|
||||||
|
|
||||||
|
Data: 2026-09-19. Usuário pediu pra embutir os 7 templates importados na leva anterior no próprio código,
|
||||||
|
pra que uma máquina nova (VM de pré-produção) já nasça com eles, sem precisar rodar o script de importação
|
||||||
|
manualmente de novo.
|
||||||
|
|
||||||
|
Padrão já usado neste repo pra dado de catálogo/referência é seed **dentro de uma migration** (`INSERT ...
|
||||||
|
ON CONFLICT DO NOTHING`, ver `1788457368744_fiscal.cjs`/`fiscal_operation_nature` e
|
||||||
|
`1788430575858_baseline-schema.cjs`/`applications`/`roles`) — não um serviço à parte. Nova migration
|
||||||
|
`packages/database/migrations/1789862600000_seed-document-templates.cjs` segue o mesmo padrão, com os 7
|
||||||
|
JSONs (key/name/doc_group/editor_content/html_content/css_content) copiados em
|
||||||
|
`document-template-seeds/*.json` ao lado dela. Idempotente por `key`: nunca sobrescreve um template já
|
||||||
|
existente (ex.: se alguém já editou manualmente).
|
||||||
|
|
||||||
|
Único `up`/`down` assíncrono desta pasta de migrations (as outras usam `pgm.sql` síncrono) — precisa do
|
||||||
|
`id` gerado pelo INSERT do template antes de inserir a versão, e do `id` da versão antes de apontar
|
||||||
|
`current_version_id` de volta, então usa `pgm.db.query` parametrizado em vez de `pgm.sql`.
|
||||||
|
|
||||||
|
**Achado real, corrigido no caminho**: o `html_content` do export original vinha **vazio** pra 4 dos 7
|
||||||
|
templates (`client_cession_term`/`client_portability_term`/`client_software_contract`/`client_stfc_term`)
|
||||||
|
— só os 3 `reseller_*` tinham. Não afeta geração real nem o editor (ambos leem `editor_content` direto),
|
||||||
|
só deixaria o card "Preview da versão publicada" vazio pra esses 4 numa instalação nova. Corrigido
|
||||||
|
recalculando o `html_content` desses 4 offline com o próprio `renderToHtml` (o mesmo já testado nesta
|
||||||
|
sessão, via `apps/api/dist/documents/editor-content-renderer.js` compilado) antes de gravar os JSONs de
|
||||||
|
seed — não precisou mudar nada no motor em si.
|
||||||
|
|
||||||
|
### Verificação
|
||||||
|
Testado contra banco **totalmente vazio** (não só o de desenvolvimento, que já tinha os 7 da importação
|
||||||
|
manual anterior): criado `eden_migration_test` no mesmo Postgres, rodado `node-pg-migrate up` do zero
|
||||||
|
(todas as migrations, não só esta) — os 7 templates nasceram publicados, com `editor_content` e
|
||||||
|
`html_content` corretos (conferido via query direta: `jsonb_array_length`/`length(html_content)` > 0 para
|
||||||
|
os 7). Banco de teste apagado depois. `pnpm --filter @eden/database typecheck` e suíte da API
|
||||||
|
125/125 sem regressão. Runbook (`docs/runbooks/setup-vm-pre-producao.md`) atualizado avisando que o passo
|
||||||
|
de migrations já traz os templates.
|
||||||
|
|||||||
92
docs/runbooks/setup-vm-pre-producao.md
Normal file
92
docs/runbooks/setup-vm-pre-producao.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Setup de uma nova VM (pré-produção)
|
||||||
|
|
||||||
|
Passo a passo mínimo para subir o EDEN do zero numa VM nova. Ver `docs/adr/0002-container-topology.md`
|
||||||
|
para o porquê da topologia (Postgres isolado, 1 container por app).
|
||||||
|
|
||||||
|
## 1. Requisitos da VM
|
||||||
|
|
||||||
|
- Linux (Debian/Ubuntu), 4 vCPU / 8GB RAM como piso razoável (Chromium headless para PDF consome memória).
|
||||||
|
- Docker Engine + plugin `docker compose` instalados.
|
||||||
|
- Git.
|
||||||
|
- Portas liberadas no firewall: só as que forem expostas publicamente (ver passo 5) — nunca a porta do
|
||||||
|
Postgres.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
sudo usermod -aG docker $USER # relogar depois disso
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Clonar o repositório
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.falehandix.com.br/Matheus/eden.git /opt/eden
|
||||||
|
cd /opt/eden
|
||||||
|
git checkout main # pré-produção valida o que já foi mergeado — não a branch developer
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Configurar o `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Preencher no `.env` (nunca commitar este arquivo):
|
||||||
|
|
||||||
|
- `EDEN_DATABASE_PASSWORD` / ajustar `DATABASE_URL` com a mesma senha.
|
||||||
|
- `JWT_SIGNING_SECRET` — `openssl rand -hex 48` (segredo próprio desta VM, nunca reaproveitar o de outro ambiente).
|
||||||
|
- `SIGNATURE_OTP_SECRET` — `openssl rand -hex 32`.
|
||||||
|
- `EDEN_FIELD_ENCRYPTION_KEY` — `openssl rand -hex 32`.
|
||||||
|
- `EDEN_SUPERADMIN_EMAIL` / `EDEN_SUPERADMIN_PASSWORD` — só usado para criar o primeiro usuário se a
|
||||||
|
tabela `users` estiver vazia.
|
||||||
|
- `S3_*` — credenciais do MinIO bundled (`openssl rand -hex 12`/`openssl rand -hex 24` para user/senha) ou
|
||||||
|
de um S3 real.
|
||||||
|
- `SMTP_*` — se já tiver um provedor de e-mail pra este ambiente.
|
||||||
|
- `PUBLIC_BASE_URL` — domínio público desta VM (usado em links de e-mail).
|
||||||
|
- Portas (`EDEN_API_PORT`/`EDEN_CORE_PORT`/`EDEN_PARCEIROS_PORT`/`EDEN_ASSINANTE_PORT`) — só mudar se for
|
||||||
|
rodar mais de uma instância na mesma máquina.
|
||||||
|
|
||||||
|
## 4. Subir os containers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Sobe Postgres, MinIO, API, worker e as 3 apps web. Cada serviço tem healthcheck — conferir com:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Rodar as migrations
|
||||||
|
|
||||||
|
A imagem do `eden-api` carrega o monorepo inteiro (ver `infra/docker/Dockerfile.node`), então dá pra
|
||||||
|
rodar direto de dentro do container — `DATABASE_URL` já está no ambiente dele:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env exec eden-api pnpm --filter @eden/database exec node-pg-migrate up
|
||||||
|
```
|
||||||
|
|
||||||
|
Isso já cria e publica os 7 templates de documento reais (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) — vêm junto com as migrations (`1789862600000_seed-document-templates.cjs`), não precisa reimportar
|
||||||
|
manualmente numa VM nova.
|
||||||
|
|
||||||
|
(Ou, se tiver Node 22 + pnpm instalados na própria VM, fora de Docker: `pnpm install && pnpm db:migrate`,
|
||||||
|
com `DATABASE_URL` apontando pra porta mapeada do Postgres no host.)
|
||||||
|
|
||||||
|
## 6. Confirmar o primeiro acesso
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:${EDEN_API_PORT:-8080}/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"email\":\"$EDEN_SUPERADMIN_EMAIL\",\"password\":\"$EDEN_SUPERADMIN_PASSWORD\",\"applicationKey\":\"core\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Deve devolver um `accessToken`. Depois logar normalmente em `http://<host>:${EDEN_CORE_PORT}`.
|
||||||
|
|
||||||
|
## 7. Proxy reverso / domínio (se for expor publicamente)
|
||||||
|
|
||||||
|
- Cada app tem um domínio próprio (core/parceiros/assinante); a API **não** tem domínio público — o proxy
|
||||||
|
na frente de cada domínio deve rotear `/api/*` internamente para `eden-api:8080` na rede docker
|
||||||
|
(`eden_net`), nunca expor a API direto.
|
||||||
|
- Nunca expor a porta do Postgres (`EDEN_POSTGRES_DEV_PORT`) fora da rede interna em pré-produção.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Semeia os 7 templates reais de documento herdados do OrçaFácil legado
|
||||||
|
* (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) — eden.md §1.1: "Registros existentes hoje (query real ao
|
||||||
|
* banco): 7 templates". A migration `1788468521075_documents.cjs` que criou
|
||||||
|
* o schema deixou essas 7 linhas explicitamente de fora ("Handix production
|
||||||
|
* data to be migrada later, not schema") — esta migration é esse "depois":
|
||||||
|
* assim toda máquina nova (`pnpm db:migrate` / `docker compose ... exec
|
||||||
|
* eden-api pnpm --filter @eden/database exec node-pg-migrate up`, ver
|
||||||
|
* docs/runbooks/setup-vm-pre-producao.md) já nasce com os 7 templates
|
||||||
|
* publicados, sem precisar reimportar manualmente.
|
||||||
|
*
|
||||||
|
* `editor_content`/`html_content` de cada um vêm de
|
||||||
|
* `document-template-seeds/<key>.json` ao lado deste arquivo — cópia
|
||||||
|
* exata da versão PUBLICADA fornecida pelo usuário (export do sistema
|
||||||
|
* legado, `/home/handix/document_templates_export.zip`), o mesmo conteúdo
|
||||||
|
* já testado ao vivo nesta sessão (renderiza no editor Tiptap, gera PDF via
|
||||||
|
* Chromium, inclusive o nó `conditional` do Termo de Contratação STFC).
|
||||||
|
* `html_content` já vem pré-renderizado (não precisa rodar
|
||||||
|
* `renderEditorContentToHtml` aqui — este pacote não pode depender do
|
||||||
|
* código do apps/api).
|
||||||
|
*
|
||||||
|
* Idempotente via `ON CONFLICT (key) DO NOTHING` (mesmo padrão de
|
||||||
|
* `fiscal_operation_nature`/`applications`/`roles` — ver
|
||||||
|
* 1788457368744_fiscal.cjs e 1788430575858_baseline-schema.cjs): se o
|
||||||
|
* template já existe (por exemplo, alguém já editou manualmente uma versão
|
||||||
|
* mais nova), esta migration NUNCA sobrescreve — só cria o que falta.
|
||||||
|
*
|
||||||
|
* Único par de `up`/`down` assíncrono desta pasta (as outras migrations
|
||||||
|
* usam `pgm.sql` síncrono) — precisa do `id` gerado pelo INSERT do
|
||||||
|
* template antes de inserir a versão (e o `id` da versão antes de apontar
|
||||||
|
* `current_version_id` de volta), então usa `pgm.db.query` (parametrizado,
|
||||||
|
* `$1`/`$2`/...) em vez de `pgm.sql` (que só monta SQL, não devolve linhas
|
||||||
|
* pro código da migration decidir o próximo passo).
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const SEED_DIR = path.join(__dirname, "document-template-seeds");
|
||||||
|
const SEED_FILES = [
|
||||||
|
"client_software_contract.json",
|
||||||
|
"client_cession_term.json",
|
||||||
|
"client_portability_term.json",
|
||||||
|
"client_stfc_term.json",
|
||||||
|
"reseller_contract_finder.json",
|
||||||
|
"reseller_contract_recorrente.json",
|
||||||
|
"reseller_adhesion_term.json",
|
||||||
|
];
|
||||||
|
|
||||||
|
function loadSeeds() {
|
||||||
|
return SEED_FILES.map((file) => JSON.parse(fs.readFileSync(path.join(SEED_DIR, file), "utf8")));
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.shorthands = undefined;
|
||||||
|
|
||||||
|
exports.up = async (pgm) => {
|
||||||
|
for (const seed of loadSeeds()) {
|
||||||
|
const { rows: templateRows } = await pgm.db.query(
|
||||||
|
`INSERT INTO document_templates (key, name, doc_group)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
RETURNING id`,
|
||||||
|
[seed.key, seed.name, seed.doc_group],
|
||||||
|
);
|
||||||
|
const template = templateRows[0];
|
||||||
|
if (!template) continue; // já existe — nunca sobrescreve.
|
||||||
|
|
||||||
|
const { rows: versionRows } = await pgm.db.query(
|
||||||
|
`INSERT INTO document_template_versions
|
||||||
|
(template_id, version, status, editor_content, html_content, css_content, page_config, published_at)
|
||||||
|
VALUES ($1, 1, 'published', $2::jsonb, $3, $4, '{}'::jsonb, now())
|
||||||
|
RETURNING id`,
|
||||||
|
[template.id, JSON.stringify(seed.editor_content), seed.html_content ?? "", seed.css_content ?? null],
|
||||||
|
);
|
||||||
|
const version = versionRows[0];
|
||||||
|
|
||||||
|
await pgm.db.query(`UPDATE document_templates SET current_version_id = $1 WHERE id = $2`, [
|
||||||
|
version.id,
|
||||||
|
template.id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async (pgm) => {
|
||||||
|
const keys = loadSeeds().map((seed) => seed.key);
|
||||||
|
// ON DELETE CASCADE em document_template_versions.template_id cuida da versão junto.
|
||||||
|
await pgm.db.query(`DELETE FROM document_templates WHERE key = ANY($1::text[])`, [keys]);
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user