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
This commit is contained in:
parent
87dbda3df1
commit
77179b2b94
@ -19,7 +19,12 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { FitAssessment, JobHeader, TailoredSummary } from "..";
|
import {
|
||||||
|
CoverLetterDisplay,
|
||||||
|
FitAssessment,
|
||||||
|
JobHeader,
|
||||||
|
TailoredSummary,
|
||||||
|
} from "..";
|
||||||
import { KbdHint } from "../KbdHint";
|
import { KbdHint } from "../KbdHint";
|
||||||
import { OpenJobListingButton } from "../OpenJobListingButton";
|
import { OpenJobListingButton } from "../OpenJobListingButton";
|
||||||
import { CollapsibleSection } from "./CollapsibleSection";
|
import { CollapsibleSection } from "./CollapsibleSection";
|
||||||
@ -32,6 +37,8 @@ interface DecideModeProps {
|
|||||||
isSkipping: boolean;
|
isSkipping: boolean;
|
||||||
onRescore: () => void;
|
onRescore: () => void;
|
||||||
isRescoring: boolean;
|
isRescoring: boolean;
|
||||||
|
onRequestCoverLetter: () => void;
|
||||||
|
coverLetterGenerating: boolean;
|
||||||
onEditDetails: () => void;
|
onEditDetails: () => void;
|
||||||
onCheckSponsor?: () => Promise<void>;
|
onCheckSponsor?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@ -43,6 +50,8 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
isSkipping,
|
isSkipping,
|
||||||
onRescore,
|
onRescore,
|
||||||
isRescoring,
|
isRescoring,
|
||||||
|
onRequestCoverLetter,
|
||||||
|
coverLetterGenerating,
|
||||||
onEditDetails,
|
onEditDetails,
|
||||||
onCheckSponsor,
|
onCheckSponsor,
|
||||||
}) => {
|
}) => {
|
||||||
@ -98,6 +107,11 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
|
|
||||||
<div className="flex-1 py-6 space-y-6 overflow-y-auto">
|
<div className="flex-1 py-6 space-y-6 overflow-y-auto">
|
||||||
<FitAssessment job={job} />
|
<FitAssessment job={job} />
|
||||||
|
<CoverLetterDisplay
|
||||||
|
job={job}
|
||||||
|
onRequestGenerate={onRequestCoverLetter}
|
||||||
|
generateInFlight={coverLetterGenerating}
|
||||||
|
/>
|
||||||
<TailoredSummary job={job} />
|
<TailoredSummary job={job} />
|
||||||
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import { useSkipJobMutation } from "@client/hooks/queries/useJobMutations";
|
import { useSkipJobMutation } from "@client/hooks/queries/useJobMutations";
|
||||||
|
import { useCoverLetterGeneration } from "@client/hooks/useCoverLetterGeneration";
|
||||||
import { useRescoreJob } from "@client/hooks/useRescoreJob";
|
import { useRescoreJob } from "@client/hooks/useRescoreJob";
|
||||||
import type { Job } from "@shared/types.js";
|
import type { Job } from "@shared/types.js";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
@ -34,6 +35,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
const previousJobIdRef = useRef<string | null>(null);
|
const previousJobIdRef = useRef<string | null>(null);
|
||||||
const skipJobMutation = useSkipJobMutation();
|
const skipJobMutation = useSkipJobMutation();
|
||||||
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
|
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
|
||||||
|
const { generateForJob, inFlight: coverLetterGenerating } =
|
||||||
|
useCoverLetterGeneration(onJobUpdated);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentJobId = job?.id ?? null;
|
const currentJobId = job?.id ?? null;
|
||||||
@ -138,6 +141,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
isSkipping={isSkipping}
|
isSkipping={isSkipping}
|
||||||
onRescore={handleRescore}
|
onRescore={handleRescore}
|
||||||
isRescoring={isRescoring}
|
isRescoring={isRescoring}
|
||||||
|
onRequestCoverLetter={() => void generateForJob(job.id)}
|
||||||
|
coverLetterGenerating={coverLetterGenerating}
|
||||||
onEditDetails={() => setIsEditDetailsOpen(true)}
|
onEditDetails={() => setIsEditDetailsOpen(true)}
|
||||||
onCheckSponsor={async () => {
|
onCheckSponsor={async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ vi.mock("@server/services/rxresume", () => ({
|
|||||||
vi.mock("@server/services/profile", () => ({
|
vi.mock("@server/services/profile", () => ({
|
||||||
getProfile: vi.fn(),
|
getProfile: vi.fn(),
|
||||||
clearProfileCache: vi.fn(),
|
clearProfileCache: vi.fn(),
|
||||||
|
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the settings repository
|
// Mock the settings repository
|
||||||
@ -30,7 +31,10 @@ vi.mock("@server/repositories/settings", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { getSetting } from "@server/repositories/settings";
|
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";
|
import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume";
|
||||||
|
|
||||||
describe.sequential("Profile API routes", () => {
|
describe.sequential("Profile API routes", () => {
|
||||||
@ -41,6 +45,7 @@ describe.sequential("Profile API routes", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(null);
|
||||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -159,6 +164,23 @@ describe.sequential("Profile API routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/profile/status", () => {
|
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 () => {
|
it("returns exists: false when rxresumeBaseResumeId is not configured", async () => {
|
||||||
vi.mocked(getSetting).mockResolvedValue(null);
|
vi.mocked(getSetting).mockResolvedValue(null);
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,11 @@ import { toAppError } from "@infra/errors";
|
|||||||
import { fail, ok } from "@infra/http";
|
import { fail, ok } from "@infra/http";
|
||||||
import { isDemoMode } from "@server/config/demo";
|
import { isDemoMode } from "@server/config/demo";
|
||||||
import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults";
|
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 { extractProjectsFromProfile } from "@server/services/resumeProjects";
|
||||||
import {
|
import {
|
||||||
clearRxResumeResumeCache,
|
clearRxResumeResumeCache,
|
||||||
@ -48,6 +52,24 @@ profileRouter.get("/", async (_req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
profileRouter.get("/status", async (_req: Request, res: Response) => {
|
profileRouter.get("/status", async (_req: Request, res: Response) => {
|
||||||
try {
|
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 } =
|
const { resumeId: rxresumeBaseResumeId } =
|
||||||
await getConfiguredRxResumeBaseResumeId();
|
await getConfiguredRxResumeBaseResumeId();
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,7 @@ vi.mock("@server/services/scorer", () => ({
|
|||||||
vi.mock("@server/services/profile", () => ({
|
vi.mock("@server/services/profile", () => ({
|
||||||
getProfile: vi.fn().mockResolvedValue({}),
|
getProfile: vi.fn().mockResolvedValue({}),
|
||||||
clearProfileCache: vi.fn(),
|
clearProfileCache: vi.fn(),
|
||||||
|
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@server/services/visa-sponsors/index", () => ({
|
vi.mock("@server/services/visa-sponsors/index", () => ({
|
||||||
|
|||||||
@ -126,6 +126,7 @@ vi.mock("../repositories/settings", () => ({
|
|||||||
// Mock the profile service - getProfile now fetches from v4 API
|
// Mock the profile service - getProfile now fetches from v4 API
|
||||||
vi.mock("./profile", () => ({
|
vi.mock("./profile", () => ({
|
||||||
getProfile: vi.fn().mockResolvedValue(mockProfile),
|
getProfile: vi.fn().mockResolvedValue(mockProfile),
|
||||||
|
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./projectSelection", () => ({
|
vi.mock("./projectSelection", () => ({
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { generatePdf } from "./pdf";
|
import { generatePdf } from "./pdf";
|
||||||
|
import { resolveLocalResumeFilePath } from "./profile";
|
||||||
import * as projectSelection from "./projectSelection";
|
import * as projectSelection from "./projectSelection";
|
||||||
|
import { getResume } from "./rxresume";
|
||||||
|
|
||||||
// Define mock data in hoisted block
|
// Define mock data in hoisted block
|
||||||
const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => {
|
const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => {
|
||||||
@ -115,6 +117,7 @@ vi.mock("../repositories/settings", () => ({
|
|||||||
// Mock the profile service - getProfile now fetches from v4 API
|
// Mock the profile service - getProfile now fetches from v4 API
|
||||||
vi.mock("./profile", () => ({
|
vi.mock("./profile", () => ({
|
||||||
getProfile: vi.fn().mockResolvedValue(mockProfile),
|
getProfile: vi.fn().mockResolvedValue(mockProfile),
|
||||||
|
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./projectSelection", () => ({
|
vi.mock("./projectSelection", () => ({
|
||||||
@ -282,6 +285,7 @@ vi.stubGlobal(
|
|||||||
describe("PDF Service Tailoring Logic", () => {
|
describe("PDF Service Tailoring Logic", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(null);
|
||||||
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
||||||
mockRxResume.clearLastCreateData();
|
mockRxResume.clearLastCreateData();
|
||||||
mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue(
|
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 () => {
|
it("should use provided selectedProjectIds and BYPASS AI selection", async () => {
|
||||||
const tailoredContent = {
|
const tailoredContent = {
|
||||||
summary: "New Sum",
|
summary: "New Sum",
|
||||||
|
|||||||
@ -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 { 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 { join } from "node:path";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
|
import type { RxResumeMode } from "@shared/types";
|
||||||
import { getDataDir } from "../config/dataDir";
|
import { getDataDir } from "../config/dataDir";
|
||||||
|
import { resolveLocalResumeFilePath } from "./profile";
|
||||||
import {
|
import {
|
||||||
deleteResume as deleteRemoteResume,
|
deleteResume as deleteRemoteResume,
|
||||||
exportResumePdf,
|
exportResumePdf,
|
||||||
@ -17,6 +21,7 @@ import {
|
|||||||
prepareTailoredResumeForPdf,
|
prepareTailoredResumeForPdf,
|
||||||
} from "./rxresume";
|
} from "./rxresume";
|
||||||
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
|
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
|
||||||
|
import { inferRxResumeModeFromData } from "./rxresume/tailoring";
|
||||||
|
|
||||||
const OUTPUT_DIR = join(getDataDir(), "pdfs");
|
const OUTPUT_DIR = join(getDataDir(), "pdfs");
|
||||||
|
|
||||||
@ -65,17 +70,18 @@ async function downloadFile(url: string, outputPath: string): Promise<void> {
|
|||||||
* Generate a tailored PDF resume for a job using Reactive Resume.
|
* Generate a tailored PDF resume for a job using Reactive Resume.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Prepare resume data with tailored content and project selection
|
* 1. Load base resume JSON (local file if JOBOPS_LOCAL_RESUME_PATH / Settings, else Rx template)
|
||||||
* 2. Import/create resume on Reactive Resume
|
* 2. Prepare resume data with tailored content and project selection
|
||||||
* 3. Request print to get PDF URL
|
* 3. Import/create resume on Reactive Resume
|
||||||
* 4. Download PDF locally
|
* 4. Request print to get PDF URL
|
||||||
* 5. Delete temporary resume from Reactive Resume
|
* 5. Download PDF locally
|
||||||
|
* 6. Delete temporary resume from Reactive Resume
|
||||||
*/
|
*/
|
||||||
export async function generatePdf(
|
export async function generatePdf(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
tailoredContent: TailoredPdfContent,
|
tailoredContent: TailoredPdfContent,
|
||||||
jobDescription: string,
|
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,
|
selectedProjectIds?: string | null,
|
||||||
options?: GeneratePdfOptions,
|
options?: GeneratePdfOptions,
|
||||||
): Promise<PdfResult> {
|
): Promise<PdfResult> {
|
||||||
@ -87,23 +93,60 @@ export async function generatePdf(
|
|||||||
await mkdir(OUTPUT_DIR, { recursive: true });
|
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { resumeId: baseResumeId } =
|
let baseResumeData: Record<string, unknown>;
|
||||||
await getConfiguredRxResumeBaseResumeId();
|
let prepareMode: RxResumeMode | undefined;
|
||||||
if (!baseResumeId) {
|
|
||||||
throw new Error(
|
const localPath = await resolveLocalResumeFilePath();
|
||||||
"Base resume not configured. Please select a base resume from your Reactive Resume account in Settings.",
|
if (localPath) {
|
||||||
);
|
let raw: string;
|
||||||
}
|
try {
|
||||||
const baseResume = await getRxResume(baseResumeId);
|
raw = await readFile(localPath, "utf8");
|
||||||
if (!baseResume.data || typeof baseResume.data !== "object") {
|
} catch (error) {
|
||||||
throw new Error("Reactive Resume base resume is empty or invalid.");
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
prepareMode = baseResume.mode ?? mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
let preparedResumeData: Record<string, unknown>;
|
let preparedResumeData: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
const prepared = await prepareTailoredResumeForPdf({
|
const prepared = await prepareTailoredResumeForPdf({
|
||||||
resumeData: baseResume.data,
|
resumeData: baseResumeData,
|
||||||
mode: baseResume.mode,
|
mode: prepareMode,
|
||||||
tailoredContent,
|
tailoredContent,
|
||||||
jobDescription,
|
jobDescription,
|
||||||
selectedProjectIds,
|
selectedProjectIds,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user