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:
2026-09-18 23:59:02 -03:00
parent 8ab18ababc
commit 553c0d9533
7 changed files with 222 additions and 5 deletions

View File

@@ -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) {

View File

@@ -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");

View 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;
}