From 553c0d95332771408afd30ce0f9a9af671aa97a9 Mon Sep 17 00:00:00 2001 From: "Matheus (Handix)" Date: Fri, 18 Sep 2026 23:59:02 -0300 Subject: [PATCH] =?UTF-8?q?feat(documents):=20pr=C3=A9-visualiza=C3=A7?= =?UTF-8?q?=C3=A3o=20em=20PDF=20do=20rascunho=20via=20Chromium?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novo endpoint POST /documents/templates/:id/draft/preview: renderiza o conteúdo atual (ainda não salvo) do editor com placeholders {{variavel}} substituídos por [variavel], via Chromium real, sem persistir nada (sem generation, sem upload S3) — permite validar visualmente o resultado antes do "Publicar", que é uma ação permanente. Corrige de passagem um bug real: css_content do template nunca chegava ao renderPdf na geração real de documento (createGeneration), sendo descartado silenciosamente. No frontend, a pré-visualização aparece embutida num iframe na própria tela (não em nova aba): abrir em nova aba se mostrou não-confiável ao vivo — a navegação tardia de uma aba pré-aberta pode ser ignorada pelo navegador quando o gesto do usuário que a autorizou já expirou no tempo que o Chromium leva pra renderizar. Co-Authored-By: Claude Sonnet 5 --- .../api/src/documents/documents.controller.ts | 14 +++++ apps/api/src/documents/documents.service.ts | 31 +++++++++- .../src/documents/dto/preview-draft.dto.ts | 12 ++++ apps/api/test/documents-pdf.spec.ts | 57 ++++++++++++++++++- apps/core-web/src/lib/api/documents.ts | 35 ++++++++++++ .../routes/documents/TemplateDetailPage.tsx | 57 ++++++++++++++++++- docs/progress.md | 21 +++++++ 7 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/documents/dto/preview-draft.dto.ts diff --git a/apps/api/src/documents/documents.controller.ts b/apps/api/src/documents/documents.controller.ts index e138e7d..10174b2 100644 --- a/apps/api/src/documents/documents.controller.ts +++ b/apps/api/src/documents/documents.controller.ts @@ -4,6 +4,7 @@ import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { DocumentsService } from "./documents.service"; import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto"; import { UpdateDraftDto } from "./dto/update-draft.dto"; +import { PreviewDraftDto } from "./dto/preview-draft.dto"; import { CreateGenerationDto } from "./dto/create-generation.dto"; import { JwtAuthGuard } from "../auth/jwt-auth.guard"; import { PermissionGuard } from "../auth/permission.guard"; @@ -54,6 +55,19 @@ export class DocumentsController { return this.documents.updateDraft(id, dto, user); } + @Post("templates/:id/draft/preview") + @RequirePermission("documents", "edit") + @Header("Content-Type", "application/pdf") + async previewDraft( + @Param("id") id: string, + @Body() dto: PreviewDraftDto, + @Res({ passthrough: true }) res: Response, + ) { + const buffer = await this.documents.previewDraft(id, dto); + res.set({ "Content-Disposition": 'inline; filename="preview.pdf"' }); + return new StreamableFile(buffer); + } + @Post("templates/:id/publish") @RequirePermission("documents", "edit") publish(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) { diff --git a/apps/api/src/documents/documents.service.ts b/apps/api/src/documents/documents.service.ts index 6b40775..0ad8b18 100644 --- a/apps/api/src/documents/documents.service.ts +++ b/apps/api/src/documents/documents.service.ts @@ -9,6 +9,7 @@ import { mergeVariables } from "./merge-engine"; import { renderEditorContentToHtml } from "./editor-content-renderer"; 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"; import type { CreateGenerationDto } from "./dto/create-generation.dto"; const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError, DraftNotFoundError } = @@ -111,6 +112,34 @@ export class DocumentsService { } } + /** + * Pré-visualização em PDF de um rascunho — nunca persiste nada (sem + * `document_generations`, sem upload S3), é só uma renderização + * descartável pra validar o layout/CSS antes da publicação irreversível. + * Roda sobre o `editorContent`/`cssContent` recebidos diretamente no + * corpo da requisição (o estado atual do editor, possivelmente ainda não + * salvo) — não busca o rascunho persistido, pra permitir iterar + * (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. + */ + 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); + + return renderPdf({ html: resolved, css: dto.cssContent }); + } + async publish(templateId: string, user: EdenAuthenticatedUser) { try { const published = await documentsRepo.publishDraft(templateId, user.id); @@ -169,7 +198,7 @@ export class DocumentsService { return { ...existing, missingVariables, deduped: true }; } - const pdfBuffer = await renderPdf({ html: resolved }); + const pdfBuffer = await renderPdf({ html: resolved, css: published.version.css_content ?? undefined }); const pdfSha256 = createHash("sha256").update(pdfBuffer).digest("hex"); const pdfS3Key = `documents/generations/${randomUUID()}.pdf`; await uploadObject(pdfS3Key, pdfBuffer, "application/pdf"); diff --git a/apps/api/src/documents/dto/preview-draft.dto.ts b/apps/api/src/documents/dto/preview-draft.dto.ts new file mode 100644 index 0000000..b198eb7 --- /dev/null +++ b/apps/api/src/documents/dto/preview-draft.dto.ts @@ -0,0 +1,12 @@ +import { IsObject, IsOptional, IsString } from "class-validator"; + +/** + * `POST .../draft/preview` body — deliberately takes the CURRENT, possibly + * unsaved, editor state (same shape as `UpdateDraftDto`) instead of reading + * the persisted draft: the point is fast iteration (edit → preview → edit) + * without forcing a save first. Nothing here is persisted. + */ +export class PreviewDraftDto { + @IsObject() editorContent!: Record; + @IsOptional() @IsString() cssContent?: string; +} diff --git a/apps/api/test/documents-pdf.spec.ts b/apps/api/test/documents-pdf.spec.ts index e815028..f23db3d 100644 --- a/apps/api/test/documents-pdf.spec.ts +++ b/apps/api/test/documents-pdf.spec.ts @@ -14,11 +14,11 @@ import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type Authe * persist the result, skip re-rendering on dedup, and stream the right S3 * key back out on download. */ -const renderPdfMock = vi.fn(async (_input: { html: string }) => Buffer.from("%PDF-1.4 fake pdf bytes")); +const renderPdfMock = vi.fn(async (_input: { html: string; css?: string }) => Buffer.from("%PDF-1.4 fake pdf bytes")); const uploadObjectMock = vi.fn(async (_key: string, _buffer: Buffer, _mimeType: string) => undefined); const getObjectStreamMock = vi.fn(async (key: string) => Readable.from([Buffer.from(`content-of:${key}`)])); vi.mock("@eden/integrations", () => ({ - renderPdf: (input: { html: string }) => renderPdfMock(input), + renderPdf: (input: { html: string; css?: string }) => renderPdfMock(input), uploadObject: (key: string, buffer: Buffer, mimeType: string) => uploadObjectMock(key, buffer, mimeType), getObjectStream: (key: string) => getObjectStreamMock(key), })); @@ -47,7 +47,10 @@ describe("documents — PDF generation + download", () => { await agent(app) .patch(`/api/v1/documents/templates/${templateId}/draft`) .set(...bearer(admin.accessToken)) - .send({ editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" } }) + .send({ + editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" }, + cssContent: "p { color: red; }", + }) .expect(200); await agent(app) .post(`/api/v1/documents/templates/${templateId}/publish`) @@ -88,6 +91,10 @@ describe("documents — PDF generation + download", () => { const renderedHtml = renderPdfMock.mock.calls[0]![0].html; expect(renderedHtml).toContain("Revenda: ACME"); expect(renderedHtml).not.toContain("{{revenda.nome}}"); + // The template version's css_content must actually reach the renderer — + // real gap found and fixed alongside the draft-preview feature: this + // used to be silently discarded (renderPdf({ html: resolved }) only). + expect(renderPdfMock.mock.calls[0]![0].css).toBe("p { color: red; }"); // Uploaded buffer's hash matches the mock's output and what got persisted. const uploadCall = uploadObjectMock.mock.calls[0]!; @@ -129,4 +136,48 @@ describe("documents — PDF generation + download", () => { expect(getObjectStreamMock).toHaveBeenCalledWith(generation.pdf_s3_key); expect(Buffer.isBuffer(download.body) && download.body.toString()).toBe(`content-of:${generation.pdf_s3_key}`); }); + + describe("draft preview", () => { + it("renders the CURRENT (unsaved) editor state with placeholder variables, never persisting anything", async () => { + renderPdfMock.mockClear(); + uploadObjectMock.mockClear(); + + const res = await agent(app) + .post(`/api/v1/documents/templates/${templateId}/draft/preview`) + .set(...bearer(admin.accessToken)) + .send({ + editorContent: { plainText: "Olá {{cliente.nome}}, plano {{plano}}." }, + cssContent: "p { color: blue; }", + }) + .expect(201); + + expect(res.headers["content-type"]).toBe("application/pdf"); + expect(Buffer.isBuffer(res.body) && res.body.toString()).toBe("%PDF-1.4 fake pdf bytes"); + + expect(renderPdfMock).toHaveBeenCalledTimes(1); + 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.css).toBe("p { color: blue; }"); + + // Never persisted: no S3 upload, and the real (published) generation + // count for this template is unaffected. + expect(uploadObjectMock).not.toHaveBeenCalled(); + const listRes = await agent(app) + .get("/api/v1/documents/generations") + .set(...bearer(admin.accessToken)) + .query({ resellerId }) + .expect(200); + expect(listRes.body).toHaveLength(1); // still just the one from the earlier test + }); + + it("404s for an unknown template id", async () => { + await agent(app) + .post("/api/v1/documents/templates/00000000-0000-0000-0000-000000000000/draft/preview") + .set(...bearer(admin.accessToken)) + .send({ editorContent: { plainText: "x" } }) + .expect(404); + }); + }); }); diff --git a/apps/core-web/src/lib/api/documents.ts b/apps/core-web/src/lib/api/documents.ts index f098885..a07c804 100644 --- a/apps/core-web/src/lib/api/documents.ts +++ b/apps/core-web/src/lib/api/documents.ts @@ -208,6 +208,41 @@ export async function downloadGenerationPdf(id: string, accessToken: string, fil 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 `{{variavel}}` shows as `[variavel]` in the result (no real + * client/reseller context at this stage). + * + * Returns an object URL for the caller to show inline (`