From 5042ad9d1fa595ee309ffe5a165063d39942a982 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Fri, 8 May 2026 00:05:55 +0200 Subject: [PATCH] feat: add PDF download endpoint and export createLocalizedResumeDocument function --- .../src/libs/resume/pdf-document.server.tsx | 13 ++++++ apps/web/src/libs/resume/pdf-document.tsx | 2 +- .../web/src/routes/api/-helpers/resume-pdf.ts | 46 +++++++++++++++++++ apps/web/src/routes/api/openapi.$.ts | 13 +++++- docs/spec.json | 26 ++++++++++- 5 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/libs/resume/pdf-document.server.tsx create mode 100644 apps/web/src/routes/api/-helpers/resume-pdf.ts diff --git a/apps/web/src/libs/resume/pdf-document.server.tsx b/apps/web/src/libs/resume/pdf-document.server.tsx new file mode 100644 index 000000000..5f925ea84 --- /dev/null +++ b/apps/web/src/libs/resume/pdf-document.server.tsx @@ -0,0 +1,13 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { Template } from "@reactive-resume/schema/templates"; +import { renderToBuffer } from "@react-pdf/renderer"; +import { createLocalizedResumeDocument } from "./pdf-document"; + +export const createResumePdfFile = async (data: ResumeData, filename: string, template?: Template) => { + const document = await createLocalizedResumeDocument(data, template); + const buffer = await renderToBuffer(document); + const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength)); + bytes.set(buffer); + + return new File([bytes], filename, { type: "application/pdf" }); +}; diff --git a/apps/web/src/libs/resume/pdf-document.tsx b/apps/web/src/libs/resume/pdf-document.tsx index 07180318e..f19f68341 100644 --- a/apps/web/src/libs/resume/pdf-document.tsx +++ b/apps/web/src/libs/resume/pdf-document.tsx @@ -21,7 +21,7 @@ export const useLocalizedResumeDocument = (data?: ResumeData, template?: Templat }, [data, template, sectionTitleResolver]); }; -const createLocalizedResumeDocument = async (data: ResumeData, template?: Template) => { +export const createLocalizedResumeDocument = async (data: ResumeData, template?: Template) => { const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale); return ( diff --git a/apps/web/src/routes/api/-helpers/resume-pdf.ts b/apps/web/src/routes/api/-helpers/resume-pdf.ts new file mode 100644 index 000000000..37163d57b --- /dev/null +++ b/apps/web/src/routes/api/-helpers/resume-pdf.ts @@ -0,0 +1,46 @@ +import { ORPCError } from "@orpc/server"; +import z from "zod"; +import { protectedProcedure } from "@reactive-resume/api/context"; +import { resumeService } from "@reactive-resume/api/services/resume"; +import { generateFilename } from "@reactive-resume/utils/file"; +import { createResumePdfFile } from "@/libs/resume/pdf-document.server"; + +export const downloadResumePdfProcedure = protectedProcedure + .route({ + method: "GET", + path: "/resumes/{id}/pdf", + tags: ["Resumes"], + operationId: "downloadResumePdf", + summary: "Download resume as PDF", + description: + "Generates a PDF for the specified resume and returns it as a forced download. Only resumes belonging to the authenticated user can be downloaded. Requires authentication.", + successDescription: "The generated resume PDF.", + outputStructure: "detailed", + }) + .input(z.object({ id: z.string().describe("The ID of the resume.") })) + .output( + z.object({ + headers: z.object({ + "content-disposition": z.string(), + }), + body: z.file().mime("application/pdf"), + }), + ) + .handler(async ({ context, input }) => { + const resume = await resumeService.getById({ id: input.id, userId: context.user.id }); + const filename = generateFilename(resume.name, "pdf"); + + try { + const body = await createResumePdfFile(resume.data, filename); + + return { + headers: { + "content-disposition": `attachment; filename="${filename}"`, + }, + body, + }; + } catch (error) { + console.error("[PDF API] Failed to render resume PDF", { resumeId: input.id, error }); + throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to generate resume PDF" }); + } + }); diff --git a/apps/web/src/routes/api/openapi.$.ts b/apps/web/src/routes/api/openapi.$.ts index c6d67e99e..bc18d70ac 100644 --- a/apps/web/src/routes/api/openapi.$.ts +++ b/apps/web/src/routes/api/openapi.$.ts @@ -9,9 +9,18 @@ import router from "@reactive-resume/api/routers"; import { env } from "@reactive-resume/env/server"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; import { getLocale } from "@/libs/locale"; +import { downloadResumePdfProcedure } from "./-helpers/resume-pdf"; + +const openAPIRouter = { + ...router, + resume: { + ...router.resume, + downloadPdf: downloadResumePdfProcedure, + }, +}; async function handler({ request }: { request: Request }) { - const openAPIHandler = new OpenAPIHandler(router, { + const openAPIHandler = new OpenAPIHandler(openAPIRouter, { plugins: [ new BatchHandlerPlugin(), new RequestHeadersPlugin(), @@ -34,7 +43,7 @@ async function handler({ request }: { request: Request }) { const locale = await getLocale(); if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) { - const spec = await openAPIGenerator.generate(router, { + const spec = await openAPIGenerator.generate(openAPIRouter, { info: { title: "Reactive Resume", version: __APP_VERSION__, diff --git a/docs/spec.json b/docs/spec.json index 9fab6ed5b..fd76521e4 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -1730,7 +1730,8 @@ "description": "Metadata for the resume, such as template, layout, typography, etc. This section describes the overall design and appearance of the resume." } }, - "required": ["picture", "basics", "summary", "sections", "customSections", "metadata"] + "required": ["picture", "basics", "summary", "sections", "customSections", "metadata"], + "additionalProperties": {} } } }, @@ -3121,6 +3122,29 @@ } } }, + "/resumes/{id}/pdf": { + "get": { + "operationId": "downloadResumePdf", + "summary": "Download resume as PDF", + "description": "Generates a PDF for the specified resume and returns it as a forced download. Only resumes belonging to the authenticated user can be downloaded. Requires authentication.", + "tags": ["Resumes"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "description": "The ID of the resume." } + } + ], + "responses": { + "200": { + "description": "The generated resume PDF.", + "headers": { "content-disposition": { "schema": { "type": "string" }, "required": true } }, + "content": { "application/pdf": { "schema": { "type": "string", "contentMediaType": "application/pdf" } } } + } + } + } + }, "/statistics/users": { "get": { "operationId": "getUserCount",