mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-16 07:58:10 +10:00
- fixes #2562, add better error messages for duplicate resume slugs
- improvements made to ditgar template - general improvements to all templates with backgrounds - update dependencies and translations - improved print function that handles single page and multi page resumes
This commit is contained in:
@@ -166,6 +166,12 @@ export const resumeRouter = {
|
||||
}),
|
||||
)
|
||||
.output(z.string().describe("The ID of the created resume."))
|
||||
.errors({
|
||||
RESUME_SLUG_ALREADY_EXISTS: {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.create({
|
||||
name: input.name,
|
||||
@@ -187,6 +193,12 @@ export const resumeRouter = {
|
||||
})
|
||||
.input(z.object({ data: resumeDataSchema }))
|
||||
.output(z.string().describe("The ID of the imported resume."))
|
||||
.errors({
|
||||
RESUME_SLUG_ALREADY_EXISTS: {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
const name = generateRandomName();
|
||||
const slug = slugify(name);
|
||||
@@ -220,6 +232,12 @@ export const resumeRouter = {
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
.errors({
|
||||
RESUME_SLUG_ALREADY_EXISTS: {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.update({
|
||||
id: input.id,
|
||||
|
||||
@@ -8,12 +8,12 @@ import { getStorageService, uploadFile } from "./storage";
|
||||
|
||||
const pageDimensions = {
|
||||
a4: {
|
||||
width: "210mm",
|
||||
height: "297mm",
|
||||
width: 794,
|
||||
height: 1123,
|
||||
},
|
||||
letter: {
|
||||
width: "8.5in",
|
||||
height: "11in",
|
||||
width: 816,
|
||||
height: 1056,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -27,7 +27,7 @@ async function getBrowser(): Promise<Browser> {
|
||||
|
||||
const connectOptions: ConnectOptions = {
|
||||
acceptInsecureCerts: true,
|
||||
defaultViewport: { width: 794, height: 1123 },
|
||||
defaultViewport: pageDimensions.a4,
|
||||
};
|
||||
|
||||
if (isWebSocket) connectOptions.browserWSEndpoint = env.PRINTER_ENDPOINT;
|
||||
@@ -64,16 +64,30 @@ export const printerService = {
|
||||
await browser.disconnect();
|
||||
},
|
||||
|
||||
/**
|
||||
* Generates a PDF from a resume and uploads it to storage.
|
||||
*
|
||||
* The process:
|
||||
* 1. Clean up any existing PDF for this resume
|
||||
* 2. Navigate to the printer route which renders the resume
|
||||
* 3. Calculate PDF margins (some templates require margins to be applied via PDF)
|
||||
* 4. Adjust CSS variables so content fits within printable area (accounting for margins)
|
||||
* 5. Add page break CSS to ensure each visual resume page becomes a PDF page
|
||||
* 6. Generate the PDF with proper dimensions and margins
|
||||
* 7. Upload to storage and return the URL
|
||||
*/
|
||||
printResumeAsPDF: async (
|
||||
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data">,
|
||||
): Promise<string> => {
|
||||
const { id, userId, data } = input;
|
||||
|
||||
// Step 1: Delete any existing PDF for this resume to ensure fresh generation
|
||||
const storageService = getStorageService();
|
||||
const pdfPrefix = `uploads/${userId}/pdfs/${id}`;
|
||||
|
||||
await storageService.delete(pdfPrefix);
|
||||
|
||||
// Step 2: Prepare the URL and authentication for the printer route
|
||||
// The printer route renders the resume in a format optimized for PDF generation
|
||||
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
|
||||
const domain = new URL(baseUrl).hostname;
|
||||
|
||||
@@ -81,9 +95,13 @@ export const printerService = {
|
||||
const locale = data.metadata.page.locale;
|
||||
const template = data.metadata.template;
|
||||
|
||||
// Generate a secure token to authenticate the printer request
|
||||
const token = generatePrinterToken(id);
|
||||
const url = `${baseUrl}/printer/${id}?token=${token}`;
|
||||
|
||||
// Step 3: Calculate PDF margins
|
||||
// Some templates require margins to be applied via PDF (they use print:p-0 to remove CSS padding)
|
||||
// Convert from CSS pixels to PDF points (divide by 0.75 since 1pt = 0.75px at 72dpi)
|
||||
let marginX = 0;
|
||||
let marginY = 0;
|
||||
|
||||
@@ -92,21 +110,63 @@ export const printerService = {
|
||||
marginY = Math.round(data.metadata.page.marginY / 0.75);
|
||||
}
|
||||
|
||||
// Step 4: Connect to the browser and navigate to the printer route
|
||||
const browser = await getBrowser();
|
||||
|
||||
// Set locale cookie so the resume renders in the correct language
|
||||
await browser.setCookie({ name: "locale", value: locale, domain });
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Wait for the page to fully load (network idle + custom loaded attribute)
|
||||
await page.goto(url, { waitUntil: "networkidle0" });
|
||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
||||
|
||||
// Step 5: Adjust the DOM for proper PDF pagination
|
||||
// This runs in the browser context to modify CSS before PDF generation
|
||||
await page.evaluate((marginY: number) => {
|
||||
const root = document.documentElement;
|
||||
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
|
||||
|
||||
// The --page-height CSS variable controls the height of each resume page.
|
||||
// We need to reduce it by the PDF margins so content fits within the printable area.
|
||||
// Without this, content would overflow and create empty pages.
|
||||
const containerHeight = container ? getComputedStyle(container).getPropertyValue("--page-height").trim() : null;
|
||||
const rootHeight = getComputedStyle(root).getPropertyValue("--page-height").trim();
|
||||
const currentHeight = containerHeight || rootHeight;
|
||||
const heightValue = Number.parseFloat(currentHeight);
|
||||
|
||||
if (!Number.isNaN(heightValue)) {
|
||||
// Subtract top + bottom margins from page height
|
||||
const newHeight = `${heightValue - marginY * 2}px`;
|
||||
if (container) container.style.setProperty("--page-height", newHeight);
|
||||
root.style.setProperty("--page-height", newHeight);
|
||||
}
|
||||
|
||||
// Add page break CSS to each resume page element (identified by data-page-index attribute)
|
||||
// This ensures each visual resume page starts a new PDF page
|
||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||
|
||||
for (const el of pageElements) {
|
||||
const element = el as HTMLElement;
|
||||
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
|
||||
|
||||
// Force a page break before each page except the first
|
||||
if (index > 0) element.style.breakBefore = "page";
|
||||
|
||||
// Allow content within a page to break naturally if it overflows
|
||||
// (e.g., if a single page has more content than fits on one PDF page)
|
||||
element.style.breakInside = "auto";
|
||||
}
|
||||
}, marginY);
|
||||
|
||||
// Step 6: Generate the PDF with the specified dimensions and margins
|
||||
const pdfBuffer = await page.pdf({
|
||||
width: pageDimensions[format].width,
|
||||
height: pageDimensions[format].height,
|
||||
tagged: true,
|
||||
waitForFonts: true,
|
||||
printBackground: true,
|
||||
width: `${pageDimensions[format].width}px`,
|
||||
height: `${pageDimensions[format].height}px`,
|
||||
tagged: true, // Adds accessibility tags to the PDF
|
||||
waitForFonts: true, // Ensures all fonts are loaded before rendering
|
||||
printBackground: true, // Includes background colors and images
|
||||
margin: {
|
||||
top: marginY,
|
||||
right: marginX,
|
||||
@@ -117,6 +177,7 @@ export const printerService = {
|
||||
|
||||
await page.close();
|
||||
|
||||
// Step 7: Upload the generated PDF to storage
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
resumeId: id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { and, arrayContains, asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { get } from "es-toolkit/compat";
|
||||
import { match } from "ts-pattern";
|
||||
import { schema } from "@/integrations/drizzle";
|
||||
import { db } from "@/integrations/drizzle/client";
|
||||
@@ -238,16 +239,26 @@ export const resumeService = {
|
||||
input.data = input.data ?? defaultResumeData;
|
||||
input.data.metadata.page.locale = input.locale;
|
||||
|
||||
await db.insert(schema.resume).values({
|
||||
id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
tags: input.tags,
|
||||
userId: input.userId,
|
||||
data: input.data,
|
||||
});
|
||||
try {
|
||||
await db.insert(schema.resume).values({
|
||||
id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
tags: input.tags,
|
||||
userId: input.userId,
|
||||
data: input.data,
|
||||
});
|
||||
|
||||
return id;
|
||||
return id;
|
||||
} catch (error) {
|
||||
const constraint = get(error, "cause.constraint") as string | undefined;
|
||||
|
||||
if (constraint === "resume_slug_user_id_unique") {
|
||||
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
update: async (input: {
|
||||
@@ -274,12 +285,24 @@ export const resumeService = {
|
||||
isPublic: input.isPublic,
|
||||
};
|
||||
|
||||
await db
|
||||
.update(schema.resume)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(eq(schema.resume.id, input.id), eq(schema.resume.isLocked, false), eq(schema.resume.userId, input.userId)),
|
||||
);
|
||||
try {
|
||||
await db
|
||||
.update(schema.resume)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.resume.id, input.id),
|
||||
eq(schema.resume.isLocked, false),
|
||||
eq(schema.resume.userId, input.userId),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
|
||||
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setLocked: async (input: { id: string; userId: string; isLocked: boolean }): Promise<void> => {
|
||||
|
||||
@@ -27,7 +27,8 @@ export const getQueryClient = () => {
|
||||
},
|
||||
},
|
||||
mutationCache: new MutationCache({
|
||||
onSettled: () => {
|
||||
onSettled: (_1, _2, _3, _4, _5, context) => {
|
||||
if (context?.meta?.noInvalidate) return;
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user