mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 07:12:18 +10:00
feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown export (#3217)
* feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown Let people export the resume and cover letter as distinct documents, and add a Markdown format alongside PDF / DOCX / JSON (handy for AI agents). - Server/API: scope PDF generation and download URLs to a resume/cover-letter target. - Export domain: getResumeExportData + resumeHasCoverLetter in @reactive-resume/resume. - Redesign the download dialog: one global "What to export" scope toggle (Tabs) plus flattened per-format rows, reusing existing UI components and design language. - Add Markdown export (@reactive-resume/resume/markdown) with a small tiptap-HTML converter. - Fix blank section headings in DOCX and Markdown by injecting the locale-aware section-title resolver (titles are stored empty and resolved at render time). - Locale catalogs updated for the new strings. * test(e2e): open the download dialog before exporting JSON The JSON export moved into the redesigned download dialog, so the spec now opens the dialog from the Export sidebar section before clicking "Download JSON".
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { createResumePdfFile } from "@reactive-resume/pdf/server";
|
||||
import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resume/export-sections";
|
||||
import { generateFilename } from "@reactive-resume/utils/file";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { pdfExportRateLimit } from "../../middleware/rate-limit";
|
||||
@@ -15,14 +17,20 @@ export {
|
||||
type CreateResumePdfDownloadInput = {
|
||||
id: string;
|
||||
userId: string;
|
||||
target?: ResumeExportTarget;
|
||||
};
|
||||
|
||||
export async function createResumePdfDownload(input: CreateResumePdfDownloadInput) {
|
||||
const resume = await resumeService.getById({ id: input.id, userId: input.userId });
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const target = input.target ?? "resume";
|
||||
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "No cover letter found for this resume" });
|
||||
}
|
||||
|
||||
const filename = generateFilename(target === "cover-letter" ? `${resume.name} Cover Letter` : resume.name, "pdf");
|
||||
|
||||
try {
|
||||
const body = await createResumePdfFile({ data: resume.data, filename });
|
||||
const body = await createResumePdfFile({ data: getResumeExportData(resume.data, target), filename });
|
||||
|
||||
return {
|
||||
headers: {
|
||||
@@ -48,7 +56,12 @@ export const downloadResumePdfProcedure = protectedProcedure
|
||||
successDescription: "The generated resume PDF.",
|
||||
outputStructure: "detailed",
|
||||
})
|
||||
.input(z.object({ id: z.string().describe("The ID of the resume.") }))
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().describe("The ID of the resume."),
|
||||
target: z.enum(["resume", "cover-letter"]).optional().describe("Which document to download."),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
headers: z.object({
|
||||
@@ -59,5 +72,9 @@ export const downloadResumePdfProcedure = protectedProcedure
|
||||
)
|
||||
.use(pdfExportRateLimit)
|
||||
.handler(async ({ context, input }) => {
|
||||
return createResumePdfDownload({ id: input.id, userId: context.user.id });
|
||||
return createResumePdfDownload({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
...(input.target ? { target: input.target } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,27 @@ describe("resume PDF signed download URLs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("can include the cover letter target without changing token verification", () => {
|
||||
const result = createResumePdfDownloadUrl({
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "cover-letter",
|
||||
now: new Date("2026-06-01T10:00:00.000Z"),
|
||||
});
|
||||
const url = new URL(result.url);
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
expect(url.searchParams.get("target")).toBe("cover-letter");
|
||||
if (!token) throw new Error("Expected signed URL token");
|
||||
expect(
|
||||
verifyResumePdfDownloadToken({
|
||||
resumeId: "resume-1",
|
||||
token,
|
||||
now: new Date("2026-06-01T10:01:00.000Z"),
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects expired, tampered, and mismatched tokens", () => {
|
||||
const result = createResumePdfDownloadUrl({
|
||||
resumeId: "resume-1",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
@@ -15,6 +16,7 @@ type CreateResumePdfDownloadUrlInput = {
|
||||
resumeId: string;
|
||||
userId: string;
|
||||
now?: Date;
|
||||
target?: ResumeExportTarget;
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
|
||||
@@ -77,6 +79,7 @@ export function createResumePdfDownloadUrl({
|
||||
resumeId,
|
||||
userId,
|
||||
now = new Date(),
|
||||
target,
|
||||
ttlSeconds,
|
||||
}: CreateResumePdfDownloadUrlInput) {
|
||||
const expiresInSeconds = resolveTtlSeconds(ttlSeconds);
|
||||
@@ -91,6 +94,7 @@ export function createResumePdfDownloadUrl({
|
||||
const token = `${payload}.${sign(payload)}`;
|
||||
const url = new URL(`/api/resumes/${encodeURIComponent(resumeId)}/pdf`, env.APP_URL);
|
||||
url.searchParams.set("token", token);
|
||||
if (target) url.searchParams.set("target", target);
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
|
||||
Reference in New Issue
Block a user