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>
This commit is contained in:
2026-09-19 19:49:42 -03:00
parent 553c0d9533
commit 1545666f17
20 changed files with 1999 additions and 312 deletions

View File

@@ -25,6 +25,12 @@ import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
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") {

View File

@@ -5,8 +5,9 @@ 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 { mergeVariables } from "./merge-engine";
import { renderEditorContentToHtml } from "./editor-content-renderer";
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";
@@ -25,6 +26,10 @@ const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError
*/
@Injectable()
export class DocumentsService {
getVariableCatalog() {
return VARIABLE_CATALOG;
}
async listTemplates(docGroup?: "cliente" | "revenda") {
return documentsRepo.listTemplates(docGroup ? { docGroup } : {});
}
@@ -122,22 +127,18 @@ export class DocumentsService {
* (editar → pré-visualizar → editar) sem precisar salvar a cada volta.
*
* Como não há um contexto real (nenhum cliente/revenda selecionado nesta
* tela), toda `{{variavel}}` encontrada vira um placeholder visível
* (`[variavel]`) em vez de string vazia — o merge-engine já reporta
* exatamente essas chaves em `missingVariables` quando o contexto é
* vazio, então é reaproveitado igual, só invertendo o que fazer com o
* resultado.
* 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 html = renderEditorContentToHtml(dto.editorContent);
const { missingVariables } = mergeVariables(html, {});
const placeholderContext = Object.fromEntries(missingVariables.map((key) => [key, `[${key}]`]));
const { resolved } = mergeVariables(html, placeholderContext);
const keys = collectMergeFieldKeys(dto.editorContent);
const placeholderContext = Object.fromEntries(keys.map((key) => [key, `[${key}]`]));
const { html } = resolveDocument(dto.editorContent, placeholderContext);
return renderPdf({ html: resolved, css: dto.cssContent });
return renderPdf({ html, css: dto.cssContent });
}
async publish(templateId: string, user: EdenAuthenticatedUser) {
@@ -182,11 +183,9 @@ export class DocumentsService {
}
// eden.md §4 descreve o pipeline completo com sanitizeDocumentHtml —
// não reimplementado nesta leva (editor_content ainda não valida a
// árvore Tiptap real, ver editor-content-renderer.ts); mas a
// renderização de PDF em si (Chromium) e o upload para S3 já são
// reais a partir daqui.
const { resolved, missingVariables } = mergeVariables(published.version.html_content, dto.context ?? {});
// 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 };

View File

@@ -1,26 +1,37 @@
/**
* Stand-in SIMPLIFICADO para o `renderToHtml()`/`renderEditorContent.js`
* do legado (eden.md §2.2) — aquele renderer conhece toda a árvore
* Tiptap (paragraph/heading/table/mergeField/conditional/...) e escapa
* HTML nó a nó. Esta leva de backend não tem o editor visual real ainda
* (`editor_content` é aceito como JSONB genérico, não validado), então
* em vez de reimplementar aquele renderer pela metade, este módulo faz
* só o mínimo necessário para `document_template_versions.html_content`
* nunca ficar dessincronizado do `editor_content` salvo:
* 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).
*
* - se `editor_content` tiver uma propriedade de nível raiz
* `plainText` (string), ela é usada como o texto do documento;
* - caso contrário, um placeholder determinístico é usado.
*
* NÃO deve ser confundido com o merge-engine (merge-engine.ts), que roda
* DEPOIS, sobre o texto aqui extraído, para resolver `{{variavel}}`.
* 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, marks bold/
* italic/underline/strike/textStyle(fontSize). NÃO implementado (fora de
* escopo desta leva, ver docs/progress.md): nó `conditional` e
* `tableRow.attrs.repeat` (expansão de linha de tabela) — o editor/catálogo
* de variáveis desta leva nunca produz nenhum dos dois.
*/
export const EDITOR_CONTENT_PLACEHOLDER_TEXT =
"[Conteúdo gerado pelo editor visual — renderização completa pendente de implementação]";
export interface TiptapMark {
type: string;
attrs?: Record<string, unknown>;
}
function escapeHtml(value: string): string {
return value
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;")
@@ -28,35 +39,131 @@ function escapeHtml(value: string): string {
.replace(/'/g, "&#39;");
}
/**
* Returns `editor_content.plainText` when present as a root-level string
* property, or `null` otherwise. Never throws on a malformed/unexpected
* shape — this leva doesn't validate the Tiptap tree at all (that's the
* visual editor's job once it exists).
*/
export function extractPlainText(editorContent: unknown): string | null {
if (
editorContent !== null &&
typeof editorContent === "object" &&
"plainText" in (editorContent as Record<string, unknown>) &&
typeof (editorContent as Record<string, unknown>).plainText === "string"
) {
return (editorContent as { plainText: string }).plainText;
// 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 null;
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>`;
}
default:
return "";
}
}
function isTiptapDoc(value: unknown): value is { content?: TiptapNode[] } {
return value !== null && typeof value === "object" && (value as { type?: unknown }).type === "doc";
}
/**
* `extractPlainText` with the deterministic placeholder as fallback —
* this is the actual text fed to the merge engine at generation time
* (documents.service.ts), never `null`/empty on that path.
* 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 resolveSourceText(editorContent: unknown): string {
const plainText = extractPlainText(editorContent);
return plainText && plainText.length > 0 ? plainText : EDITOR_CONTENT_PLACEHOLDER_TEXT;
export function renderToHtml(editorContent: unknown, resolveMergeField?: ResolveMergeField): string {
if (!isTiptapDoc(editorContent)) return "";
return (editorContent.content ?? []).map((node) => renderBlock(node, resolveMergeField)).join("\n");
}
/** Recomputes `document_template_versions.html_content` (eden.md §1.2 step 2). */
/** `renderEditorContentToHtml` do stand-in anterior — mantido como nome de import estável para os chamadores existentes. */
export function renderEditorContentToHtml(editorContent: unknown): string {
return `<p>${escapeHtml(resolveSourceText(editorContent))}</p>`;
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

@@ -1,49 +1,29 @@
/**
* Motor de merge de variáveis — versão SIMPLIFICADA para esta leva,
* inspirada no `resolveDocument()` do legado (eden.md §3.2), mas
* operando sobre TEXTO PURO com placeholders `{{variavel}}` em vez da
* árvore Tiptap completa. A árvore Tiptap real (nós `mergeField`,
* `conditional`, `tableRow.attrs.repeat`, catálogo de variáveis
* `VARIABLE_CATALOG` com formatação por tipo — eden.md §2.2, §3.1, §3.3,
* §3.4) fica para quando o editor visual real existir; não é
* reimplementada aqui.
*
* Pura e sem I/O — testável isoladamente (ver
* apps/api/test/merge-engine.spec.ts). Diferença deliberada em relação
* ao motor completo do legado: aquele lança `MISSING_REQUIRED_FIELDS`/
* `UNRESOLVED_VARIABLES` (HTTP 422) e bloqueia a geração; este NUNCA
* lança exceção — variáveis ausentes do contexto são substituídas por
* string vazia e apenas reportadas em `missingVariables` (deduplicadas,
* na ordem da primeira ocorrência). O chamador decide o que fazer com
* elas (bloquear, avisar, etc.); o motor não decide isso sozinho.
* 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 MergeResult {
/** Texto com todo `{{variavel}}` substituído — nunca sobra placeholder pendente, mesmo quando ausente do contexto (vira string vazia). */
resolved: string;
/** Chaves referenciadas no texto mas ausentes de `context`, deduplicadas, na ordem da primeira ocorrência. */
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[];
}
// Mesma sintaxe de chave do catálogo legado (eden.md §3.1): namespace.campo,
// ex. "cliente.razao_social". Espaços em volta da chave são tolerados
// ("{{ cliente.razao_social }}") e ignorados.
const MERGE_FIELD_PATTERN = /\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g;
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));
export function mergeVariables(text: string, context: Record<string, string>): MergeResult {
const missingVariables: string[] = [];
const seenMissing = new Set<string>();
const html = renderToHtml(editorContent, (key) => context[key] ?? "");
const resolved = text.replace(MERGE_FIELD_PATTERN, (_match: string, key: string) => {
if (Object.prototype.hasOwnProperty.call(context, key)) {
return context[key] ?? "";
}
if (!seenMissing.has(key)) {
seenMissing.add(key);
missingVariables.push(key);
}
return "";
});
return { resolved, missingVariables };
return { html, missingVariables };
}

View File

@@ -0,0 +1,150 @@
/**
* 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 },
// 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 },
// 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: "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

@@ -48,7 +48,18 @@ describe("documents — PDF generation + download", () => {
.patch(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken))
.send({
editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" },
editorContent: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Revenda: " },
{ type: "mergeField", attrs: { key: "revenda.nome", label: "Nome da Revenda" } },
],
},
],
},
cssContent: "p { color: red; }",
})
.expect(200);
@@ -146,7 +157,21 @@ describe("documents — PDF generation + download", () => {
.post(`/api/v1/documents/templates/${templateId}/draft/preview`)
.set(...bearer(admin.accessToken))
.send({
editorContent: { plainText: "Olá {{cliente.nome}}, plano {{plano}}." },
editorContent: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Olá " },
{ type: "mergeField", attrs: { key: "cliente.nome", label: "Nome do Cliente" } },
{ type: "text", text: ", plano " },
{ type: "mergeField", attrs: { key: "plano", label: "Plano" } },
{ type: "text", text: "." },
],
},
],
},
cssContent: "p { color: blue; }",
})
.expect(201);
@@ -158,7 +183,7 @@ describe("documents — PDF generation + download", () => {
const call = renderPdfMock.mock.calls[0]![0];
expect(call.html).toContain("[cliente.nome]");
expect(call.html).toContain("[plano]");
expect(call.html).not.toContain("{{");
expect(call.html).not.toContain("merge-field-chip");
expect(call.css).toBe("p { color: blue; }");
// Never persisted: no S3 upload, and the real (published) generation

View File

@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import { collectMergeFieldKeys, renderToHtml } from "../src/documents/editor-content-renderer";
/**
* Unidade pura, sem app/DB — cobre a porta do serializador Tiptap→HTML do
* legado (renderEditorContent.js). documents-pdf.spec.ts cobre o pipeline
* completo (geração real via HTTP); aqui é só a árvore de nós, isolada.
*/
describe("editor-content-renderer", () => {
it("renderiza parágrafo com alinhamento e marks (bold/italic/underline/strike/fontSize)", () => {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
attrs: { textAlign: "center" },
content: [
{ type: "text", text: "Olá ", marks: [{ type: "bold" }] },
{ type: "text", text: "mundo", marks: [{ type: "italic" }, { type: "underline" }, { type: "strike" }] },
{ type: "text", text: " grande", marks: [{ type: "textStyle", attrs: { fontSize: "18px" } }] },
],
},
],
};
const html = renderToHtml(doc);
expect(html).toBe(
'<p style="text-align:center"><strong>Olá </strong><s><u><em>mundo</em></u></s><span style="font-size:18px"> grande</span></p>',
);
});
it("renderiza heading, listas e parágrafo vazio como &nbsp;", () => {
const doc = {
type: "doc",
content: [
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Título" }] },
{ type: "paragraph", content: [] },
{
type: "bulletList",
content: [
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "item 1" }] }] },
],
},
{
type: "orderedList",
content: [
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "item A" }] }] },
],
},
],
};
const html = renderToHtml(doc);
expect(html).toContain("<h2>Título</h2>");
expect(html).toContain("<p>&nbsp;</p>");
expect(html).toContain("<ul><li><p>item 1</p></li></ul>");
expect(html).toContain("<ol><li><p>item A</p></li></ol>");
});
it("renderiza imagem e quebra de página", () => {
const doc = {
type: "doc",
content: [
{ type: "image", attrs: { src: "data:image/png;base64,abc", alt: "logo" } },
{ type: "pageBreak" },
],
};
const html = renderToHtml(doc);
expect(html).toContain('<img src="data:image/png;base64,abc" alt="logo" />');
expect(html).toContain('style="break-after:page;page-break-after:always;"');
});
it("gera <thead> real só quando há tableHeader, mesmo no meio das linhas", () => {
const doc = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [{ type: "tableHeader", attrs: { colspan: 2 }, content: [{ type: "paragraph", content: [] }] }],
},
{
type: "tableRow",
content: [{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }] }],
},
],
},
],
};
const html = renderToHtml(doc);
expect(html).toMatch(/<thead>.*<th colspan="2">/s);
expect(html).toMatch(/<tbody>.*<td>/s);
});
it("mergeField vira chip visual sem resolveMergeField, e valor resolvido com resolveMergeField", () => {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "mergeField", attrs: { key: "cliente.razao_social", label: "Razão Social" } }],
},
],
};
const chipHtml = renderToHtml(doc);
expect(chipHtml).toContain('data-merge-field="cliente.razao_social"');
expect(chipHtml).toContain("Razão Social");
const resolvedHtml = renderToHtml(doc, (key) => (key === "cliente.razao_social" ? "ACME LTDA" : ""));
expect(resolvedHtml).toBe("<p>ACME LTDA</p>");
expect(resolvedHtml).not.toContain("merge-field-chip");
});
it("escapa HTML de texto e de valores resolvidos (proteção contra XSS)", () => {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "<script>alert(1)</script>" }] },
{ type: "paragraph", content: [{ type: "mergeField", attrs: { key: "x" } }] },
],
};
const html = renderToHtml(doc, () => "<img onerror=alert(1)>");
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
expect(html).not.toContain("<img onerror");
expect(html).toContain("&lt;img onerror=alert(1)&gt;");
});
it("collectMergeFieldKeys varre a árvore inteira e dedupe chaves repetidas", () => {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "mergeField", attrs: { key: "cliente.razao_social" } },
{ type: "mergeField", attrs: { key: "cliente.cnpj" } },
],
},
{
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
content: [{ type: "paragraph", content: [{ type: "mergeField", attrs: { key: "cliente.razao_social" } }] }],
},
],
},
],
},
],
};
expect(collectMergeFieldKeys(doc)).toEqual(["cliente.razao_social", "cliente.cnpj"]);
});
it("devolve string vazia para conteúdo que não é um doc Tiptap válido", () => {
expect(renderToHtml(null)).toBe("");
expect(renderToHtml({ type: "doc" })).toBe("");
expect(collectMergeFieldKeys(undefined)).toEqual([]);
});
});

View File

@@ -52,7 +52,9 @@ describe("signature envelopes — automatic contratante signer derivation", () =
await agent(app)
.patch(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken))
.send({ editorContent: { type: "doc", plainText: "Contrato de teste." } })
.send({
editorContent: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Contrato de teste." }] }] },
})
.expect(200);
await agent(app).post(`/api/v1/documents/templates/${templateId}/publish`).set(...bearer(admin.accessToken)).expect(201);
const generation = await agent(app)

View File

@@ -118,7 +118,14 @@ describe("signature envelopes — full lifecycle", () => {
await agent(app)
.patch(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken))
.send({ editorContent: { type: "doc", plainText: "Contrato de teste para assinatura eletrônica." } })
.send({
editorContent: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Contrato de teste para assinatura eletrônica." }] },
],
},
})
.expect(200);
await agent(app)
.post(`/api/v1/documents/templates/${templateId}/publish`)