/** * 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 | 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; } export interface CreateGenerationInput { templateId: string; clientRegistrationId?: string; resellerId?: string; context: Record; } 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; 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 { return apiRequest("/documents/templates", { method: "GET" }, { accessToken }); } export function getDocumentTemplate(id: string, accessToken: string): Promise { return apiRequest(`/documents/templates/${id}`, { method: "GET" }, { accessToken }); } export function createDocumentTemplate( input: CreateDocumentTemplateInput, accessToken: string, ): Promise { return apiRequest( "/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 { return apiRequest( `/documents/templates/${templateId}/draft`, { method: "POST" }, { accessToken }, ); } export function updateDraft( templateId: string, input: UpdateDraftInput, accessToken: string, ): Promise { return apiRequest( `/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 { return apiRequest( `/documents/templates/${templateId}/publish`, { method: "POST" }, { accessToken }, ); } export function listGenerations(accessToken: string): Promise { return apiRequest("/documents/generations", { method: "GET" }, { accessToken }); } export function createGeneration( input: CreateGenerationInput, accessToken: string, ): Promise { return apiRequest( "/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 `` 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 { 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 (`