diff --git a/apps/api/src/documents/documents.controller.ts b/apps/api/src/documents/documents.controller.ts index 10174b2..29f1718 100644 --- a/apps/api/src/documents/documents.controller.ts +++ b/apps/api/src/documents/documents.controller.ts @@ -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") { diff --git a/apps/api/src/documents/documents.service.ts b/apps/api/src/documents/documents.service.ts index 0ad8b18..bfc3420 100644 --- a/apps/api/src/documents/documents.service.ts +++ b/apps/api/src/documents/documents.service.ts @@ -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 { 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 }; diff --git a/apps/api/src/documents/editor-content-renderer.ts b/apps/api/src/documents/editor-content-renderer.ts index 6e88e67..53690c7 100644 --- a/apps/api/src/documents/editor-content-renderer.ts +++ b/apps/api/src/documents/editor-content-renderer.ts @@ -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; +} -function escapeHtml(value: string): string { - return value +export interface TiptapNode { + type: string; + attrs?: Record; + 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, "&") .replace(//g, ">") @@ -28,35 +39,131 @@ function escapeHtml(value: string): string { .replace(/'/g, "'"); } -/** - * 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) && - typeof (editorContent as Record).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 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 = `${html}`; + else if (mark.type === "italic") html = `${html}`; + else if (mark.type === "underline") html = `${html}`; + else if (mark.type === "strike") html = `${html}`; + else if (mark.type === "textStyle" && mark.attrs?.fontSize) { + html = `${html}`; + } } - 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 `${escapeHtml(label)}`; + } + return ""; + }) + .join(""); +} + +function renderBlock(node: TiptapNode, resolveMergeField?: ResolveMergeField): string { + switch (node.type) { + case "paragraph": + return `${renderInline(node.content, resolveMergeField) || " "}

`; + case "heading": { + const level = typeof node.attrs?.level === "number" ? node.attrs.level : 1; + return `${renderInline(node.content, resolveMergeField)}`; + } + case "bulletList": + return `
    ${(node.content ?? []).map((li) => renderBlock(li, resolveMergeField)).join("")}
`; + case "orderedList": + return `
    ${(node.content ?? []).map((li) => renderBlock(li, resolveMergeField)).join("")}
`; + case "listItem": + return `
  • ${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}
  • `; + case "image": + return `${escapeHtml(node.attrs?.alt ?? `; + case "pageBreak": + return `
    `; + case "table": { + // 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 + ? `${headerRows.map((r) => renderBlock(r, resolveMergeField)).join("")}` + : ""; + const tbodyHtml = `${bodyRows.map((r) => renderBlock(r, resolveMergeField)).join("")}`; + return `${theadHtml}${tbodyHtml}
    `; + } + case "tableRow": + return `${(node.content ?? []).map((cell) => renderBlock(cell, resolveMergeField)).join("")}`; + case "tableHeader": { + const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : ""; + return `${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}`; + } + case "tableCell": { + const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : ""; + 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"; } /** - * `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 `

    ${escapeHtml(resolveSourceText(editorContent))}

    `; + 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(); + + 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]; } diff --git a/apps/api/src/documents/merge-engine.ts b/apps/api/src/documents/merge-engine.ts index 1e77d52..fb1e42a 100644 --- a/apps/api/src/documents/merge-engine.ts +++ b/apps/api/src/documents/merge-engine.ts @@ -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): ResolveDocumentResult { + const keys = collectMergeFieldKeys(editorContent); + const missingVariables = keys.filter((key) => !Object.prototype.hasOwnProperty.call(context, key)); -export function mergeVariables(text: string, context: Record): MergeResult { - const missingVariables: string[] = []; - const seenMissing = new Set(); + 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 }; } diff --git a/apps/api/src/documents/variable-catalog.ts b/apps/api/src/documents/variable-catalog.ts new file mode 100644 index 0000000..b165985 --- /dev/null +++ b/apps/api/src/documents/variable-catalog.ts @@ -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, + }, +]; diff --git a/apps/api/test/documents-pdf.spec.ts b/apps/api/test/documents-pdf.spec.ts index f23db3d..aa0a57d 100644 --- a/apps/api/test/documents-pdf.spec.ts +++ b/apps/api/test/documents-pdf.spec.ts @@ -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 diff --git a/apps/api/test/editor-content-renderer.spec.ts b/apps/api/test/editor-content-renderer.spec.ts new file mode 100644 index 0000000..73cda29 --- /dev/null +++ b/apps/api/test/editor-content-renderer.spec.ts @@ -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( + '

    Olá mundo grande

    ', + ); + }); + + it("renderiza heading, listas e parágrafo vazio como  ", () => { + 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("

    Título

    "); + expect(html).toContain("

     

    "); + expect(html).toContain("
    • item 1

    "); + expect(html).toContain("
    1. item A

    "); + }); + + 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('logo'); + expect(html).toContain('style="break-after:page;page-break-after:always;"'); + }); + + it("gera 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(/.*/s); + expect(html).toMatch(/.*/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("

    ACME LTDA

    "); + 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: "" }] }, + { type: "paragraph", content: [{ type: "mergeField", attrs: { key: "x" } }] }, + ], + }; + + const html = renderToHtml(doc, () => ""); + expect(html).not.toContain("