feat(documents): pré-visualização em PDF do rascunho via Chromium
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
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");
|
||||
|
||||
12
apps/api/src/documents/dto/preview-draft.dto.ts
Normal file
12
apps/api/src/documents/dto/preview-draft.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||
@IsOptional() @IsString() cssContent?: string;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user