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>
282 lines
10 KiB
TypeScript
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];
|
|
}
|