Skip to main content

Booklet Generation (PDF)

Staff-facing feature that generates two PDF documents per completed build: a customer-facing Quick Start Guide and an internal Technical Dossier.

Implementation

BookletsService uses @react-pdf/renderer to render React components into PDF buffers server-side. The PDFs are uploaded to Hetzner Object Storage and linked to the build as Attachment records.

// Triggered automatically on build completion, or manually via /regenerate
const [qsgBuffer, tdBuffer] = await Promise.all([
renderToBuffer(<QuickStartGuide build={buildData} />),
renderToBuffer(<TechnicalDossier build={buildData} />),
]);

Fonts — Critical Requirement

NotoSans fonts must be present at /app/fonts/ inside the Docker container.

Font registration:

Font.register({
family: "NotoSans",
fonts: [
{ src: "/app/fonts/NotoSans-Regular.ttf" },
{ src: "/app/fonts/NotoSans-Bold.ttf", fontWeight: "bold" },
{ src: "/app/fonts/NotoSans-Italic.ttf", fontStyle: "italic" },
],
});

Silent Failure Mode

If font files are missing, @react-pdf/renderer does not throw an error — it silently generates a PDF with no text rendered (or falls back to a default font). The API call succeeds, booklet generation "completes", but the generated PDFs are unreadable.

Symptoms of missing fonts:

  • Build.bookletGeneratedAt is set (generation "succeeded")
  • Downloaded PDF has no text
  • No error in logs

Fix: Verify fonts are committed to apps/api/fonts/ and that the Dockerfile copies them:

COPY --from=builder /app/apps/api/fonts /app/fonts

Quick Start Guide (templates/quick-start-guide.tsx)

Customer-facing document. Pages:

PageContent
CoverBuild date, order ID, customer name; coverPhoto
WelcomeBuild overview text; foamPhoto
SetupHow to connect power; psuPhoto + powerBtnPhoto; panelAPhoto + panelBPhoto
Understanding PortsGeneric port guide table (HDMI, DisplayPort, USB-A/C, Ethernet, 3.5mm, USB 3.0)
Your PC's PortsmboIoPhoto, gpuIoPhoto, caseIoPhoto
Your PCComponent table, OS info, peripherals, system config
QAQA checklist with pass/fail status, signed-off by staff
WarrantyWarranty items with start/end dates
Back coverContact info, logo

Color palette: #400080 primary, #07060d dark, #f5f2ff light cover background


Technical Dossier (templates/technical-dossier.tsx)

Internal/staff document. More detailed. Pages:

PageContent
CoverBuild ID, technician, dates
Your PCComponents with serial numbers + spec snapshots; OS with license keys; peripherals
QAQA result, notes, checklist, sign-off
System ConfigFull systemConfig JSON
BenchmarksOne page per benchmark run: score, date, notes, screenshot (from photoAttachmentId)
Stress TestsAll stress test records in table
TemperaturesCPU/GPU idle+load, ambient; thermalPhoto
Back coverInternal reference

Upload and Storage

After rendering:

  1. Uploads to builds/booklets/ prefix in Hetzner Object Storage
  2. Creates Attachment record with label: 'booklet_quick_start' or 'booklet_technical_dossier'
  3. Updates Build.quickStartBookletId / Build.technicalDossierBookletId
  4. Sets Build.bookletGeneratedAt = now

Fire-and-Forget

Booklet generation is always fire-and-forget:

// In BuildsService.update(), when status → completed:
this.booklets.generateBooklets(buildId).catch((err) => {
this.logger.error({ err }, "Booklet generation failed");
});
// No await — the API response returns immediately

Why? PDF rendering is CPU-intensive and can take 5–30 seconds for complex builds. Making the status-update endpoint wait for PDF generation would create a poor user experience and risk HTTP timeouts.

Implication: After a build is marked completed, the booklets may not be immediately available. Staff should wait a moment or refresh the build detail page.


Manual Regeneration

POST /builds/:id/booklets/regenerate — also fire-and-forget. Returns 202 Accepted immediately. Use this when:

  • Fonts were fixed and booklets need to be regenerated
  • Photo slots were updated after the initial generation
  • The generated PDFs are corrupted or missing

Photo Resolution

Before rendering, BookletsService resolves all photo slot presigned URLs:

const photos = await Promise.all(
PHOTO_SLOTS.map(async (slot) => {
const attachmentId = build[slot];
if (!attachmentId) return null;
const attachment = await this.attachments.findOne(attachmentId);
return this.storage.resolvePath(attachment.storageKey);
}),
);

Each presigned URL is valid for 1 hour. The PDF rendering embeds the images by fetching these URLs — if a URL has expired by the time @react-pdf/renderer fetches it, that image will be missing from the PDF.


Dependencies

  • @react-pdf/renderer — React-based PDF rendering
  • NotoSans font files at apps/api/fonts/ (committed to repo, copied into Docker image)
  • AttachmentsService — for resolving photo URLs and storing generated PDFs
  • IStorageService — for uploading PDF buffers
  • PrismaService — for reading build data and updating booklet fields