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:
@@ -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") {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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, "&")
|
||||
.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<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) || " "}</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];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
150
apps/api/src/documents/variable-catalog.ts
Normal file
150
apps/api/src/documents/variable-catalog.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
@@ -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
|
||||
|
||||
171
apps/api/test/editor-content-renderer.spec.ts
Normal file
171
apps/api/test/editor-content-renderer.spec.ts
Normal 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 ", () => {
|
||||
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> </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("<script>");
|
||||
expect(html).not.toContain("<img onerror");
|
||||
expect(html).toContain("<img onerror=alert(1)>");
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user