Files
eden/apps/core-web/src/lib/api/documents.ts
Matheus (Handix) 1545666f17 feat(documents): editor de texto rico (Tiptap) para templates de documento
Substitui o textarea de texto puro por um editor visual real, seguindo o
print de referência do usuário do editor "Contrato de Software" do
OrçaFácil legado: toolbar completa (negrito/itálico/sublinhado/tachado,
alinhamento, listas, tabela, imagem, quebra de página), variáveis inseridas
como chip inline via catálogo fechado (nunca {{texto livre}}), painel
"Variáveis usadas neste modelo" (com badge obrigatório ao vivo) e
"Histórico de versões".

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 19:49:42 -03:00

282 lines
10 KiB
TypeScript

/**
* Client for `apps/api` `/v1/documents/templates` and `/v1/documents/generations`
* — the "templates + generation" half of Documents (eden.md módulo 4, §1.1-1.3).
*
* `editorContent` is a real Tiptap document tree (`JSONContent`), edited via
* the rich editor in `./documents/editor` — resolved server-side by
* `apps/api/src/documents/editor-content-renderer.ts` (ported from the
* legacy `renderEditorContent.js`). PDF generation is real (Chromium via
* packages/integrations/src/pdf/renderer.ts) — `pdf_s3_key`/`pdf_sha256`/
* `pdf_size_bytes` are populated on every `status: 'final'` generation.
*
* Like Contracts/Resellers, template/version/generation responses are
* snake_case straight off the DB row — except the two computed fields on a
* generation response (`missingVariables`, `deduped`), which come back
* camelCase exactly as documented in the task brief; mirrored as-is below,
* not "fixed" to snake_case.
*/
import type { JSONContent } from "@tiptap/core";
import { API_BASE_URL, apiRequest } from "./http.js";
export type DocGroup = "cliente" | "revenda";
export type DocumentVersionStatus = "draft" | "published";
export type GenerationStatus = "generating" | "final" | "error";
/**
* Regex the server validates `key` against — confirmed live against the API
* ("key deve começar com letra minúscula e conter apenas letras
* minúsculas, números e underscore"): starts with a lowercase letter,
* followed by lowercase letters/digits/underscore, e.g.
* `client_software_contract`. Shown as a hint in the create form.
*/
export const DOCUMENT_TEMPLATE_KEY_PATTERN = /^[a-z][a-z0-9_]*$/;
/** Empty Tiptap doc — mirrors `EMPTY_DOC_CONTENT` in packages/database/src/repositories/documents.ts. */
export const EMPTY_EDITOR_CONTENT: JSONContent = { type: "doc", content: [{ type: "paragraph", content: [] }] };
/** Uma entrada do catálogo fechado de variáveis — `GET /documents/variable-catalog`. */
export interface VariableCatalogEntry {
key: string;
label: string;
category: string;
required: boolean;
}
/** Response shape for `GET /documents/templates` (list) and the bare fields of the detail response. */
export interface DocumentTemplate {
id: string;
created_at: string;
updated_at: string;
key: string;
name: string;
doc_group: DocGroup;
active: boolean;
current_version_id: string | null;
created_by: string | null;
}
export interface DocumentTemplateVersion {
id: string;
template_id: string;
version: number;
status: DocumentVersionStatus;
editor_content: JSONContent;
html_content: string | null;
css_content: string | null;
page_config: Record<string, unknown> | null;
created_by: string | null;
published_at: string | null;
published_by: string | null;
created_at: string;
}
/** Response shape for `GET /documents/templates/:id` — adds the version list. */
export interface DocumentTemplateWithVersions extends DocumentTemplate {
versions: DocumentTemplateVersion[];
}
/** Body of `POST /documents/templates` — matches `CreateDocumentTemplateDto` exactly. */
export interface CreateDocumentTemplateInput {
key: string;
name: string;
docGroup: DocGroup;
}
/** Body of `PATCH /documents/templates/:id/draft` — matches `UpdateDraftDto`. `pageConfig` is deliberately omitted from the UI. */
export interface UpdateDraftInput {
editorContent?: JSONContent;
cssContent?: string;
pageConfig?: Record<string, unknown>;
}
export interface CreateGenerationInput {
templateId: string;
clientRegistrationId?: string;
resellerId?: string;
context: Record<string, string>;
}
export interface DocumentGeneration {
id: string;
created_at: string;
template_id: string;
template_version_id: string;
client_registration_id: string | null;
reseller_id: string | null;
render_context: Record<string, unknown>;
rendered_html: string;
pdf_s3_key: string | null;
pdf_sha256: string | null;
pdf_size_bytes: number | null;
status: GenerationStatus;
error_message: string | null;
generated_by: string | null;
}
/**
* Response of `POST /documents/generations` only — confirmed against the
* live API that `GET /documents/generations` (list) does NOT include these
* two computed fields, so they're kept off the bare `DocumentGeneration`
* shared by both responses. camelCase per API contract, not "fixed" to
* snake_case.
*/
export interface DocumentGenerationResult extends DocumentGeneration {
/** Variables referenced in the template but absent from the submitted `context`. */
missingVariables: string[];
/** `true` when an existing generation was reused instead of creating a new one. */
deduped: boolean;
}
export function listDocumentTemplates(accessToken: string): Promise<DocumentTemplate[]> {
return apiRequest<DocumentTemplate[]>("/documents/templates", { method: "GET" }, { accessToken });
}
export function getDocumentTemplate(id: string, accessToken: string): Promise<DocumentTemplateWithVersions> {
return apiRequest<DocumentTemplateWithVersions>(`/documents/templates/${id}`, { method: "GET" }, { accessToken });
}
export function createDocumentTemplate(
input: CreateDocumentTemplateInput,
accessToken: string,
): Promise<DocumentTemplate> {
return apiRequest<DocumentTemplate>(
"/documents/templates",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
/** Only succeeds if the template has no draft yet — 409 (`ApiError.status === 409`) when one already exists. */
export function createDraft(templateId: string, accessToken: string): Promise<DocumentTemplateVersion> {
return apiRequest<DocumentTemplateVersion>(
`/documents/templates/${templateId}/draft`,
{ method: "POST" },
{ accessToken },
);
}
export function updateDraft(
templateId: string,
input: UpdateDraftInput,
accessToken: string,
): Promise<DocumentTemplateVersion> {
return apiRequest<DocumentTemplateVersion>(
`/documents/templates/${templateId}/draft`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
/** Marks the current draft as published — permanent, the resulting version is never editable again. */
export function publishDraft(templateId: string, accessToken: string): Promise<DocumentTemplateVersion> {
return apiRequest<DocumentTemplateVersion>(
`/documents/templates/${templateId}/publish`,
{ method: "POST" },
{ accessToken },
);
}
export function listGenerations(accessToken: string): Promise<DocumentGeneration[]> {
return apiRequest<DocumentGeneration[]>("/documents/generations", { method: "GET" }, { accessToken });
}
export function createGeneration(
input: CreateGenerationInput,
accessToken: string,
): Promise<DocumentGenerationResult> {
return apiRequest<DocumentGenerationResult>(
"/documents/generations",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
/**
* Fetches the generated PDF and triggers a browser download. Not routed
* through `apiRequest` (JSON-only) — this needs the raw binary response,
* but still needs the bearer token, so a plain `<a href>` won't work
* either (the API route is behind JwtAuthGuard). Standard SPA pattern:
* fetch as a Blob, hand the browser a temporary object URL.
*/
export async function downloadGenerationPdf(id: string, accessToken: string, filename: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/documents/generations/${id}/pdf`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error("Não foi possível baixar o PDF.");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
/**
* Renders the CURRENT (possibly unsaved) draft content to PDF via Chromium
* — nothing is persisted server-side. Same raw-fetch pattern as
* `downloadGenerationPdf` (binary response, still needs the bearer token).
* Every `mergeField` shows as `[chave]` in the result (no real
* client/reseller context at this stage).
*
* Returns an object URL for the caller to show inline (`<iframe>`), rather
* than opening a new tab: a Chromium render is slow enough (real
* server-side rendering, not instant) that by the time the blob is ready,
* the browser's "this navigation is tied to a user gesture" grace window
* has often already expired — `window.open()` (even pre-opened and
* navigated later) can then be silently ignored, leaving a blank tab.
* Confirmed live: a synchronous in-memory blob navigates the pre-opened
* tab fine, but the real (few-hundred-ms) round trip through this
* endpoint does not. Showing the PDF in the same page sidesteps the whole
* class of popup-timing issues.
*/
export async function previewDraft(
templateId: string,
input: { editorContent: JSONContent; cssContent?: string },
accessToken: string,
): Promise<string> {
const response = await fetch(`${API_BASE_URL}/documents/templates/${templateId}/draft/preview`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({ editorContent: input.editorContent, cssContent: input.cssContent }),
});
if (!response.ok) {
throw new Error("Não foi possível gerar a pré-visualização.");
}
const blob = await response.blob();
return URL.createObjectURL(blob);
}
export function getVariableCatalog(accessToken: string): Promise<VariableCatalogEntry[]> {
return apiRequest<VariableCatalogEntry[]>("/documents/variable-catalog", { method: "GET" }, { accessToken });
}
/**
* Varre a árvore Tiptap e devolve as chaves de `mergeField` usadas —
* espelha `collectMergeFieldKeys` em
* apps/api/src/documents/editor-content-renderer.ts (duplicado de
* propósito: o editor não pode depender do backend, e vice-versa, mesma
* lógica do legado). Usado tanto no painel "Variáveis usadas neste modelo"
* quanto para auto-detectar o formulário de contexto em
* `GenerationFormPage.tsx`.
*/
export function collectMergeFieldKeys(editorContent: JSONContent | null | undefined): string[] {
const keys = new Set<string>();
function walk(node: JSONContent | 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 (editorContent?.type === "doc") {
(editorContent.content ?? []).forEach(walk);
}
return [...keys];
}