From 77179b2b94428a1434c80d0289a660d3a0e78580 Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 5 Apr 2026 23:47:25 -0400 Subject: [PATCH] feat(orchestrator): local JSON base resume for PDF generation Use JOBOPS_LOCAL_RESUME_PATH or Settings local path as the tailored PDF source when Reactive Resume template is not selected. Align GET /api/profile/status with local resume configuration. Update route test mocks for resolveLocalResumeFilePath. Includes discovered-panel UI tweaks in DecideMode and DiscoveredPanel. Made-with: Cursor --- .../discovered-panel/DecideMode.tsx | 16 +++- .../discovered-panel/DiscoveredPanel.tsx | 5 ++ .../src/server/api/routes/profile.test.ts | 24 +++++- orchestrator/src/server/api/routes/profile.ts | 24 +++++- .../src/server/api/routes/test-utils.ts | 1 + .../services/pdf-skills-validation.test.ts | 1 + .../src/server/services/pdf-tailoring.test.ts | 16 ++++ orchestrator/src/server/services/pdf.ts | 83 ++++++++++++++----- 8 files changed, 147 insertions(+), 23 deletions(-) diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index 47b226e..618a2be 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -19,7 +19,12 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; -import { FitAssessment, JobHeader, TailoredSummary } from ".."; +import { + CoverLetterDisplay, + FitAssessment, + JobHeader, + TailoredSummary, +} from ".."; import { KbdHint } from "../KbdHint"; import { OpenJobListingButton } from "../OpenJobListingButton"; import { CollapsibleSection } from "./CollapsibleSection"; @@ -32,6 +37,8 @@ interface DecideModeProps { isSkipping: boolean; onRescore: () => void; isRescoring: boolean; + onRequestCoverLetter: () => void; + coverLetterGenerating: boolean; onEditDetails: () => void; onCheckSponsor?: () => Promise; } @@ -43,6 +50,8 @@ export const DecideMode: React.FC = ({ isSkipping, onRescore, isRescoring, + onRequestCoverLetter, + coverLetterGenerating, onEditDetails, onCheckSponsor, }) => { @@ -98,6 +107,11 @@ export const DecideMode: React.FC = ({
+ = ({ const previousJobIdRef = useRef(null); const skipJobMutation = useSkipJobMutation(); const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated); + const { generateForJob, inFlight: coverLetterGenerating } = + useCoverLetterGeneration(onJobUpdated); useEffect(() => { const currentJobId = job?.id ?? null; @@ -138,6 +141,8 @@ export const DiscoveredPanel: React.FC = ({ isSkipping={isSkipping} onRescore={handleRescore} isRescoring={isRescoring} + onRequestCoverLetter={() => void generateForJob(job.id)} + coverLetterGenerating={coverLetterGenerating} onEditDetails={() => setIsEditDetailsOpen(true)} onCheckSponsor={async () => { try { diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts index 1c84ccf..30aaee1 100644 --- a/orchestrator/src/server/api/routes/profile.test.ts +++ b/orchestrator/src/server/api/routes/profile.test.ts @@ -18,6 +18,7 @@ vi.mock("@server/services/rxresume", () => ({ vi.mock("@server/services/profile", () => ({ getProfile: vi.fn(), clearProfileCache: vi.fn(), + resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null), })); // Mock the settings repository @@ -30,7 +31,10 @@ vi.mock("@server/repositories/settings", async (importOriginal) => { }); import { getSetting } from "@server/repositories/settings"; -import { getProfile } from "@server/services/profile"; +import { + getProfile, + resolveLocalResumeFilePath, +} from "@server/services/profile"; import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume"; describe.sequential("Profile API routes", () => { @@ -41,6 +45,7 @@ describe.sequential("Profile API routes", () => { beforeEach(async () => { vi.clearAllMocks(); + vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(null); ({ server, baseUrl, closeDb, tempDir } = await startServer()); }); @@ -159,6 +164,23 @@ describe.sequential("Profile API routes", () => { }); describe("GET /api/profile/status", () => { + it("returns exists: true when a local resume path is configured and readable", async () => { + vi.mocked(resolveLocalResumeFilePath).mockResolvedValue( + "/data/resumes/local.json", + ); + vi.mocked(getProfile).mockResolvedValue({ + basics: { name: "Local User" }, + } as any); + + const res = await fetch(`${baseUrl}/api/profile/status`); + const body = await res.json(); + + expect(res.ok).toBe(true); + expect(body.ok).toBe(true); + expect(body.data.exists).toBe(true); + expect(body.data.error).toBeNull(); + }); + it("returns exists: false when rxresumeBaseResumeId is not configured", async () => { vi.mocked(getSetting).mockResolvedValue(null); diff --git a/orchestrator/src/server/api/routes/profile.ts b/orchestrator/src/server/api/routes/profile.ts index e399981..cc036c5 100644 --- a/orchestrator/src/server/api/routes/profile.ts +++ b/orchestrator/src/server/api/routes/profile.ts @@ -2,7 +2,11 @@ import { toAppError } from "@infra/errors"; import { fail, ok } from "@infra/http"; import { isDemoMode } from "@server/config/demo"; import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults"; -import { clearProfileCache, getProfile } from "@server/services/profile"; +import { + clearProfileCache, + getProfile, + resolveLocalResumeFilePath, +} from "@server/services/profile"; import { extractProjectsFromProfile } from "@server/services/resumeProjects"; import { clearRxResumeResumeCache, @@ -48,6 +52,24 @@ profileRouter.get("/", async (_req: Request, res: Response) => { */ profileRouter.get("/status", async (_req: Request, res: Response) => { try { + const localPath = await resolveLocalResumeFilePath(); + if (localPath) { + try { + await getProfile(); + ok(res, { exists: true, error: null }); + return; + } catch (error) { + if (error instanceof RxResumeAuthConfigError) { + ok(res, { exists: false, error: error.message }); + return; + } + const message = + error instanceof Error ? error.message : "Unknown error"; + ok(res, { exists: false, error: message }); + return; + } + } + const { resumeId: rxresumeBaseResumeId } = await getConfiguredRxResumeBaseResumeId(); diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index 4e62a90..5117ddf 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -62,6 +62,7 @@ vi.mock("@server/services/scorer", () => ({ vi.mock("@server/services/profile", () => ({ getProfile: vi.fn().mockResolvedValue({}), clearProfileCache: vi.fn(), + resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null), })); vi.mock("@server/services/visa-sponsors/index", () => ({ diff --git a/orchestrator/src/server/services/pdf-skills-validation.test.ts b/orchestrator/src/server/services/pdf-skills-validation.test.ts index 550d36b..a511ce1 100644 --- a/orchestrator/src/server/services/pdf-skills-validation.test.ts +++ b/orchestrator/src/server/services/pdf-skills-validation.test.ts @@ -126,6 +126,7 @@ vi.mock("../repositories/settings", () => ({ // Mock the profile service - getProfile now fetches from v4 API vi.mock("./profile", () => ({ getProfile: vi.fn().mockResolvedValue(mockProfile), + resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null), })); vi.mock("./projectSelection", () => ({ diff --git a/orchestrator/src/server/services/pdf-tailoring.test.ts b/orchestrator/src/server/services/pdf-tailoring.test.ts index 9b4e88f..17fa0e2 100644 --- a/orchestrator/src/server/services/pdf-tailoring.test.ts +++ b/orchestrator/src/server/services/pdf-tailoring.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { generatePdf } from "./pdf"; +import { resolveLocalResumeFilePath } from "./profile"; import * as projectSelection from "./projectSelection"; +import { getResume } from "./rxresume"; // Define mock data in hoisted block const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => { @@ -115,6 +117,7 @@ vi.mock("../repositories/settings", () => ({ // Mock the profile service - getProfile now fetches from v4 API vi.mock("./profile", () => ({ getProfile: vi.fn().mockResolvedValue(mockProfile), + resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null), })); vi.mock("./projectSelection", () => ({ @@ -282,6 +285,7 @@ vi.stubGlobal( describe("PDF Service Tailoring Logic", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(null); mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); mockRxResume.clearLastCreateData(); mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue( @@ -292,6 +296,18 @@ describe("PDF Service Tailoring Logic", () => { }); }); + it("loads base resume from local file when a local path is configured", async () => { + vi.mocked(resolveLocalResumeFilePath).mockResolvedValue( + "/tmp/jobops-base.json", + ); + mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); + + await generatePdf("job-local-base", {}, "desc", undefined, "p1"); + + expect(getResume).not.toHaveBeenCalled(); + expect(mockRxResume.importResume).toHaveBeenCalled(); + }); + it("should use provided selectedProjectIds and BYPASS AI selection", async () => { const tailoredContent = { summary: "New Sum", diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 725e08d..112ff9e 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -1,14 +1,18 @@ /** - * Service for generating PDF resumes using Reactive Resume. + * Service for generating tailored PDF resumes via Reactive Resume print API. + * Base resume JSON is loaded from a local file when configured (same as profile), + * otherwise from the selected Reactive Resume template. */ import { createWriteStream, existsSync } from "node:fs"; -import { access, mkdir } from "node:fs/promises"; +import { access, mkdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { logger } from "@infra/logger"; +import type { RxResumeMode } from "@shared/types"; import { getDataDir } from "../config/dataDir"; +import { resolveLocalResumeFilePath } from "./profile"; import { deleteResume as deleteRemoteResume, exportResumePdf, @@ -17,6 +21,7 @@ import { prepareTailoredResumeForPdf, } from "./rxresume"; import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId"; +import { inferRxResumeModeFromData } from "./rxresume/tailoring"; const OUTPUT_DIR = join(getDataDir(), "pdfs"); @@ -65,17 +70,18 @@ async function downloadFile(url: string, outputPath: string): Promise { * Generate a tailored PDF resume for a job using Reactive Resume. * * Flow: - * 1. Prepare resume data with tailored content and project selection - * 2. Import/create resume on Reactive Resume - * 3. Request print to get PDF URL - * 4. Download PDF locally - * 5. Delete temporary resume from Reactive Resume + * 1. Load base resume JSON (local file if JOBOPS_LOCAL_RESUME_PATH / Settings, else Rx template) + * 2. Prepare resume data with tailored content and project selection + * 3. Import/create resume on Reactive Resume + * 4. Request print to get PDF URL + * 5. Download PDF locally + * 6. Delete temporary resume from Reactive Resume */ export async function generatePdf( jobId: string, tailoredContent: TailoredPdfContent, jobDescription: string, - _baseResumePath?: string, // Deprecated: now always uses configured Reactive Resume base resume + _baseResumePath?: string, // Deprecated: uses local path or configured Reactive Resume base resume selectedProjectIds?: string | null, options?: GeneratePdfOptions, ): Promise { @@ -87,23 +93,60 @@ export async function generatePdf( await mkdir(OUTPUT_DIR, { recursive: true }); } - const { resumeId: baseResumeId } = - await getConfiguredRxResumeBaseResumeId(); - if (!baseResumeId) { - throw new Error( - "Base resume not configured. Please select a base resume from your Reactive Resume account in Settings.", - ); - } - const baseResume = await getRxResume(baseResumeId); - if (!baseResume.data || typeof baseResume.data !== "object") { - throw new Error("Reactive Resume base resume is empty or invalid."); + let baseResumeData: Record; + let prepareMode: RxResumeMode | undefined; + + const localPath = await resolveLocalResumeFilePath(); + if (localPath) { + let raw: string; + try { + raw = await readFile(localPath, "utf8"); + } catch (error) { + const detail = error instanceof Error ? error.message : "Unknown error"; + throw new Error( + `Cannot read local resume file at ${localPath}. ${detail}`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + `Local resume file at ${localPath} is not valid JSON (Reactive Resume export shape expected).`, + ); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error( + `Local resume file at ${localPath} must be a single JSON object.`, + ); + } + baseResumeData = parsed as Record; + prepareMode = inferRxResumeModeFromData(baseResumeData) ?? undefined; + if (!prepareMode) { + const { mode } = await getConfiguredRxResumeBaseResumeId(); + prepareMode = mode; + } + } else { + const { resumeId: baseResumeId, mode } = + await getConfiguredRxResumeBaseResumeId(); + if (!baseResumeId) { + throw new Error( + "Base resume not configured. Set JOBOPS_LOCAL_RESUME_PATH or local resume path in Settings, or select a base resume from Reactive Resume.", + ); + } + const baseResume = await getRxResume(baseResumeId); + if (!baseResume.data || typeof baseResume.data !== "object") { + throw new Error("Reactive Resume base resume is empty or invalid."); + } + baseResumeData = baseResume.data as Record; + prepareMode = baseResume.mode ?? mode; } let preparedResumeData: Record; try { const prepared = await prepareTailoredResumeForPdf({ - resumeData: baseResume.data, - mode: baseResume.mode, + resumeData: baseResumeData, + mode: prepareMode, tailoredContent, jobDescription, selectedProjectIds,