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.bookletGeneratedAtis 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 /app/apps/api/fonts /app/fonts
Quick Start Guide (templates/quick-start-guide.tsx)
Customer-facing document. Pages:
| Page | Content |
|---|---|
| Cover | Build date, order ID, customer name; coverPhoto |
| Welcome | Build overview text; foamPhoto |
| Setup | How to connect power; psuPhoto + powerBtnPhoto; panelAPhoto + panelBPhoto |
| Understanding Ports | Generic port guide table (HDMI, DisplayPort, USB-A/C, Ethernet, 3.5mm, USB 3.0) |
| Your PC's Ports | mboIoPhoto, gpuIoPhoto, caseIoPhoto |
| Your PC | Component table, OS info, peripherals, system config |
| QA | QA checklist with pass/fail status, signed-off by staff |
| Warranty | Warranty items with start/end dates |
| Back cover | Contact info, logo |
Color palette: #400080 primary, #07060d dark, #f5f2ff light cover background
Technical Dossier (templates/technical-dossier.tsx)
Internal/staff document. More detailed. Pages:
| Page | Content |
|---|---|
| Cover | Build ID, technician, dates |
| Your PC | Components with serial numbers + spec snapshots; OS with license keys; peripherals |
| QA | QA result, notes, checklist, sign-off |
| System Config | Full systemConfig JSON |
| Benchmarks | One page per benchmark run: score, date, notes, screenshot (from photoAttachmentId) |
| Stress Tests | All stress test records in table |
| Temperatures | CPU/GPU idle+load, ambient; thermalPhoto |
| Back cover | Internal reference |
Upload and Storage
After rendering:
- Uploads to
builds/booklets/prefix in Hetzner Object Storage - Creates
Attachmentrecord withlabel: 'booklet_quick_start'or'booklet_technical_dossier' - Updates
Build.quickStartBookletId/Build.technicalDossierBookletId - 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 renderingNotoSansfont files atapps/api/fonts/(committed to repo, copied into Docker image)AttachmentsService— for resolving photo URLs and storing generated PDFsIStorageService— for uploading PDF buffersPrismaService— for reading build data and updating booklet fields