From 7514aa1b28d54dcc6423055e7012ae01a83b97b5 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:26:15 +0000 Subject: [PATCH] Add RxResume v4/v5 dual support (#230) * feat(settings): add rxresume mode and v5 api key settings * feat(server): add mode-aware rxresume adapter with auto v5-first selection * refactor(server): route settings profile and pdf generation through rxresume adapter * feat(api): support rxresume v4/v5 in onboarding and settings routes with ok/meta responses * feat(client): add rxresume mode selector and v5 api key setup flow * docs: document rxresume auto mode with v5-first self-hosted setup * test: verify dual-mode rxresume support and ci parity checks * comments * services folder * correct types for v5 * tests and docs fix * Fix RxResume auto fallback and route API consistency * warning for both being set * simpler response * onboarding component improvements, v5 check still not working * fix list resume endpoint... * fix api endpoints to latest v5 docs * don't show the entire project field on v5 * remove auto entirely * formatting * ci green * v5 has a different resume schema * remove redundant check * remove requirement that only one must be specified * consolidate sections * base resume can be v4 or v5 * saving now works * status indicator * actually render some pills * reason for failure * fix apikey verification * dedupe isValidatingMode * reefactoor * simplification? * refactor? * ci passing * remove auto from docs * tailoring is schema dependent * skills object tighter * remove redundant text * fix lint * mode --- docs-site/docs/features/reactive-resume.md | 39 +- .../docs/getting-started/self-hosting.md | 4 + orchestrator/README.md | 6 +- orchestrator/src/client/api/client.ts | 31 +- .../src/client/components/JobHeader.tsx | 87 +-- .../client/components/OnboardingGate.test.tsx | 11 +- .../src/client/components/OnboardingGate.tsx | 378 ++++++----- .../components/ReactiveResumeConfigPanel.tsx | 365 +++++++++++ .../src/client/components/StatusIndicator.tsx | 121 ++++ .../discovered-panel/ProjectSelector.tsx | 3 - orchestrator/src/client/components/layout.tsx | 43 +- .../client/hooks/useRxResumeConfigState.ts | 55 ++ .../src/client/lib/rxresume-config.ts | 245 +++++++ .../src/client/pages/SettingsPage.test.tsx | 48 +- .../src/client/pages/SettingsPage.tsx | 189 +++++- .../components/BaseResumeSelection.tsx | 21 +- .../EnvironmentSettingsSection.test.tsx | 3 +- .../components/EnvironmentSettingsSection.tsx | 22 - .../components/ReactiveResumeSection.tsx | 287 +++----- .../src/server/api/routes/onboarding.test.ts | 41 +- .../src/server/api/routes/onboarding.ts | 98 +-- .../src/server/api/routes/profile.test.ts | 20 +- orchestrator/src/server/api/routes/profile.ts | 63 +- .../src/server/api/routes/settings.test.ts | 124 +++- .../src/server/api/routes/settings.ts | 133 ++-- .../services/pdf-skills-validation.test.ts | 119 +++- .../src/server/services/pdf-tailoring.test.ts | 118 +++- orchestrator/src/server/services/pdf.ts | 322 +++------ .../src/server/services/profile.test.ts | 23 +- orchestrator/src/server/services/profile.ts | 38 +- .../src/server/services/rxresume-v5.ts | 197 ------ .../server/services/rxresume/baseResumeId.ts | 64 ++ .../client.test.ts} | 2 +- .../client.ts} | 6 +- .../server/services/rxresume/index.test.ts | 344 ++++++++++ .../src/server/services/rxresume/index.ts | 612 ++++++++++++++++++ .../server/services/rxresume/schema/index.ts | 34 + .../services/rxresume/schema/v4.test.ts | 2 +- .../src/server/services/rxresume/schema/v4.ts | 8 + .../src/server/services/rxresume/schema/v5.ts | 87 +++ .../src/server/services/rxresume/tailoring.ts | 438 +++++++++++++ .../{rxresume-v4.ts => rxresume/v4.ts} | 17 +- .../src/server/services/rxresume/v5.test.ts | 100 +++ .../src/server/services/rxresume/v5.ts | 263 ++++++++ .../services/settings-update/registry.ts | 29 + orchestrator/src/server/services/settings.ts | 53 +- .../src/server/services/tracer-links.ts | 44 ++ shared/src/settings-registry.test.ts | 15 + shared/src/settings-registry.ts | 29 + shared/src/testing/factories.ts | 4 + shared/src/types/settings.ts | 6 + 51 files changed, 4164 insertions(+), 1247 deletions(-) create mode 100644 orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx create mode 100644 orchestrator/src/client/components/StatusIndicator.tsx create mode 100644 orchestrator/src/client/hooks/useRxResumeConfigState.ts create mode 100644 orchestrator/src/client/lib/rxresume-config.ts delete mode 100644 orchestrator/src/server/services/rxresume-v5.ts create mode 100644 orchestrator/src/server/services/rxresume/baseResumeId.ts rename orchestrator/src/server/services/{rxresume-client.test.ts => rxresume/client.test.ts} (99%) rename orchestrator/src/server/services/{rxresume-client.ts => rxresume/client.ts} (98%) create mode 100644 orchestrator/src/server/services/rxresume/index.test.ts create mode 100644 orchestrator/src/server/services/rxresume/index.ts create mode 100644 orchestrator/src/server/services/rxresume/schema/index.ts rename shared/src/rxresume-schema.test.ts => orchestrator/src/server/services/rxresume/schema/v4.test.ts (98%) rename shared/src/rxresume-schema.ts => orchestrator/src/server/services/rxresume/schema/v4.ts (99%) create mode 100644 orchestrator/src/server/services/rxresume/schema/v5.ts create mode 100644 orchestrator/src/server/services/rxresume/tailoring.ts rename orchestrator/src/server/services/{rxresume-v4.ts => rxresume/v4.ts} (87%) create mode 100644 orchestrator/src/server/services/rxresume/v5.test.ts create mode 100644 orchestrator/src/server/services/rxresume/v5.ts diff --git a/docs-site/docs/features/reactive-resume.md b/docs-site/docs/features/reactive-resume.md index fbded44..73adaa5 100644 --- a/docs-site/docs/features/reactive-resume.md +++ b/docs-site/docs/features/reactive-resume.md @@ -78,24 +78,32 @@ At generation time: Before connecting Reactive Resume to JobOps: -1. Create your account on **RxResume v4** at [v4.rxresu.me/auth/register](https://v4.rxresu.me/auth/register). -2. Use a **native email + password** account (not Google/GitHub/other OAuth login). -3. Generate/store that password so JobOps can use it for API login. +1. Choose a mode in **Settings → Reactive Resume**: + - `v5` (API key) + - `v4` (email/password) +2. For **v5** (recommended for self-hosted/latest), generate an API key and configure `rxresumeApiKey` or `RXRESUME_API_KEY`. +3. For **v4**, create a native email/password account at [v4.rxresu.me/auth/register](https://v4.rxresu.me/auth/register) and configure `rxresumeEmail` + `rxresumePassword`. -JobOps cannot use OAuth-based RxResume logins for this integration. +Important: -### 1) Configure RxResume credentials +- Explicit `v4` and `v5` modes do not silently fall back. +- OAuth-only logins are not supported for the v4 email/password integration. -Configure in Settings: +### 1) Configure Reactive Resume access -- `rxresumeEmail` -- `rxresumePassword` +Configure in **Settings → Reactive Resume**: + +- `rxresumeMode` (`v5` or `v4`) +- `rxresumeApiKey` (for v5) +- `rxresumeEmail` + `rxresumePassword` (for v4) Or via environment variables: +- `RXRESUME_MODE` (`v5` or `v4`) +- `RXRESUME_API_KEY` (for v5) - `RXRESUME_EMAIL` - `RXRESUME_PASSWORD` -- optional `RXRESUME_URL` (defaults to `https://v4.rxresu.me`) +- optional `RXRESUME_URL` (works for both modes; v5 OpenAPI path is added automatically) ### 2) Select base resume @@ -176,7 +184,7 @@ curl -X PATCH "http://localhost:3001/api/settings" \ ``` ```bash -# List available RxResume resumes +# List available Reactive Resume resumes curl "http://localhost:3001/api/settings/rx-resumes" ``` @@ -194,14 +202,17 @@ curl -X POST "http://localhost:3001/api/jobs//generate-pdf" ### RxResume controls are disabled -- Ensure RxResume credentials are configured. +- Ensure the selected mode has credentials configured. +- `v5`: set a valid API key. +- `v4`: set email + password. - Save settings, then refresh resumes in the Reactive Resume section. ### No resumes appear in dropdown -- Confirm credentials are valid for [v4.rxresu.me](https://v4.rxresu.me)/your configured RxResume URL. -- Confirm the RxResume account is a native email/password account (not OAuth-only). -- Confirm the selected RxResume account actually has resumes. +- Confirm the selected mode matches your Reactive Resume deployment. +- For `v5`, confirm `RXRESUME_API_KEY` / `rxresumeApiKey` is valid for your self-hosted instance. +- For `v4`, confirm credentials are valid for [v4.rxresu.me](https://v4.rxresu.me) (or your configured v4 URL) and are not OAuth-only. +- Confirm the selected Reactive Resume account actually has resumes. ### Project list is empty in settings diff --git a/docs-site/docs/getting-started/self-hosting.md b/docs-site/docs/getting-started/self-hosting.md index 7884ab5..13eb7f7 100644 --- a/docs-site/docs/getting-started/self-hosting.md +++ b/docs-site/docs/getting-started/self-hosting.md @@ -124,3 +124,7 @@ docker compose up -d If you self-host Reactive Resume, set: - `RXRESUME_URL=http://rxresume.local.net` +- `RXRESUME_MODE=auto` (recommended) or `v5`/`v4` to force a specific API version +- `RXRESUME_API_KEY=...` (or configure `rxresumeApiKey` in JobOps Settings) + +`auto` mode is the default and prefers v5 when an API key is configured, then falls back to v4 credentials. diff --git a/orchestrator/README.md b/orchestrator/README.md index 35a5b00..97fa40b 100644 --- a/orchestrator/README.md +++ b/orchestrator/README.md @@ -36,7 +36,9 @@ orchestrator/ # The app is self-configuring. You can add keys via the UI Onboarding. ``` - After the server starts, use the onboarding modal to connect OpenRouter, link your v4.rxresu.me account, and select a template resume. + After the server starts, use the onboarding modal to connect your LLM provider, configure Reactive Resume (`v5` or `v4`), and select a template resume. + + `v5` (API key) is recommended for self-hosted/latest Reactive Resume. Use `v4` when connecting to the legacy email/password flow. OpenRouter is the default LLM provider, but LM Studio, Ollama, OpenAI, and Gemini are also supported. @@ -142,5 +144,5 @@ npm start - **Backend:** Express, TypeScript, Drizzle ORM, SQLite - **Frontend:** React, Vite, CSS (custom design system) - **AI:** Configurable LLM provider (OpenRouter default; also supports OpenAI/Gemini/LM Studio/Ollama) -- **PDF Generation:** RxResume v4 API export (configured via Settings) +- **PDF Generation:** Reactive Resume v4/v5 API export (configured via Settings) - **Job Crawling:** Wraps existing TypeScript Crawlee crawler diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index c30811c..880b8bb 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -37,6 +37,7 @@ import type { ProfileStatusResponse, ResumeProfile, ResumeProjectCatalogItem, + RxResumeMode, StageEvent, StageEventMetadata, StageTransitionTarget, @@ -1253,7 +1254,11 @@ export async function getResumeProjectsCatalog(): Promise< try { const settings = await getSettings(); if (settings.rxresumeBaseResumeId) { - return await getRxResumeProjects(settings.rxresumeBaseResumeId); + return await getRxResumeProjects( + settings.rxresumeBaseResumeId, + undefined, + settings.rxresumeMode?.value, + ); } } catch { // fall through to profile-based projects @@ -1287,13 +1292,16 @@ export async function validateLlm(input: { }); } -export async function validateRxresume( - email?: string, - password?: string, -): Promise { +export async function validateRxresume(input?: { + mode?: "v4" | "v5"; + email?: string; + password?: string; + apiKey?: string; + baseUrl?: string; +}): Promise { return fetchApi("/onboarding/validate/rxresume", { method: "POST", - body: JSON.stringify({ email, password }), + body: JSON.stringify(input ?? {}), }); } @@ -1310,9 +1318,12 @@ export async function updateSettings( }); } -export async function getRxResumes(): Promise<{ id: string; name: string }[]> { +export async function getRxResumes( + mode?: RxResumeMode, +): Promise<{ id: string; name: string }[]> { + const query = mode ? `?mode=${encodeURIComponent(mode)}` : ""; const data = await fetchApi<{ resumes: { id: string; name: string }[] }>( - "/settings/rx-resumes", + `/settings/rx-resumes${query}`, ); return data.resumes; } @@ -1320,9 +1331,11 @@ export async function getRxResumes(): Promise<{ id: string; name: string }[]> { export async function getRxResumeProjects( resumeId: string, signal?: AbortSignal, + mode?: RxResumeMode, ): Promise { + const query = mode ? `?mode=${encodeURIComponent(mode)}` : ""; const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>( - `/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`, + `/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects${query}`, { signal }, ); return data.projects; diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index 5a15346..16062c9 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -1,4 +1,4 @@ -import type { Job, JobStatus } from "@shared/types.js"; +import type { Job } from "@shared/types.js"; import { ArrowUpRight, Calendar, @@ -21,9 +21,10 @@ import { import { cn, formatDate, sourceLabel } from "@/lib/utils"; import { useSettings } from "../hooks/useSettings"; import { - defaultStatusToken, - statusTokens, -} from "../pages/orchestrator/constants"; + getJobStatusIndicator, + getTracerStatusIndicator, + StatusIndicator, +} from "./StatusIndicator"; interface JobHeaderProps { job: Job; @@ -31,32 +32,6 @@ interface JobHeaderProps { onCheckSponsor?: () => Promise; } -const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => { - const tokens = statusTokens[status] ?? defaultStatusToken; - return ( - - - {tokens.label} - - ); -}; - -const TracerPill: React.FC<{ enabled: boolean }> = ({ enabled }) => ( - - - {enabled ? "Tracer On" : "Tracer Off"} - -); - const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => { if (score == null) { return -; @@ -159,30 +134,26 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { }; const status = getStatus(score); - const tooltipContent = `${score}% match`; + const tooltip = ( + <> + {parsedNames.length > 0 && ( +

+ Matched + {parsedNames.join(", ")} +

+ )} +

{`${score}% match`}

+ + ); return ( - - - - - - {status.label} - - - - {parsedNames.length > 0 && ( -

- Matched - {parsedNames.join(", ")} -

- )} -

{tooltipContent}

-
-
-
+ ); }; @@ -191,6 +162,8 @@ export const JobHeader: React.FC = ({ className, onCheckSponsor, }) => { + const jobStatus = getJobStatusIndicator(job.status); + const tracerStatus = getTracerStatusIndicator(job.tracerLinksEnabled); const { showSponsorInfo } = useSettings(); const { pathname } = useLocation(); const isJobPage = pathname.startsWith("/job/"); @@ -267,8 +240,14 @@ export const JobHeader: React.FC = ({ {/* Status and score: single line, subdued */}
- - + + {showSponsorInfo && ( { }); it("hides the gate when all validations succeed", async () => { + vi.mocked(useSettings).mockReturnValue({ + ...settingsResponse, + settings: { + ...settingsResponse.settings, + rxresumeApiKeyHint: "abcd1234", + }, + } as any); vi.mocked(api.validateLlm).mockResolvedValue({ valid: true, message: null, @@ -177,8 +185,9 @@ describe("OnboardingGate", () => { render(); - await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled()); + await waitFor(() => expect(api.validateResumeConfig).toHaveBeenCalled()); expect(api.validateLlm).not.toHaveBeenCalled(); + expect(api.validateRxresume).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument(); }); diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 796aa9f..0aae3b6 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -1,17 +1,24 @@ import * as api from "@client/api"; +import { ReactiveResumeConfigPanel } from "@client/components/ReactiveResumeConfigPanel"; import { useDemoInfo } from "@client/hooks/useDemoInfo"; +import { useRxResumeConfigState } from "@client/hooks/useRxResumeConfigState"; import { useSettings } from "@client/hooks/useSettings"; +import { + getInitialRxResumeMode, + getRxResumeCredentialDrafts, + getRxResumeMissingCredentialLabels, + validateAndMaybePersistRxResumeMode, +} from "@client/lib/rxresume-config"; import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection"; import { SettingsInput } from "@client/pages/settings/components/SettingsInput"; import { - formatSecretHint, getLlmProviderConfig, LLM_PROVIDER_LABELS, LLM_PROVIDERS, normalizeLlmProvider, } from "@client/pages/settings/utils"; import type { UpdateSettingsInput } from "@shared/settings-schema.js"; -import type { ValidationResult } from "@shared/types.js"; +import type { RxResumeMode, ValidationResult } from "@shared/types.js"; import { Check } from "lucide-react"; import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -44,16 +51,30 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; type ValidationState = ValidationResult & { checked: boolean }; +type TimestampedValidationState = ValidationState & { testedAt: number | null }; type OnboardingFormData = { llmProvider: string; llmBaseUrl: string; llmApiKey: string; + rxresumeMode: RxResumeMode; rxresumeEmail: string; rxresumePassword: string; + rxresumeApiKey: string; rxresumeBaseResumeId: string | null; }; +const EMPTY_VALIDATION_STATE: ValidationState = { + valid: false, + message: null, + checked: false, +}; + +const EMPTY_TIMESTAMPED_VALIDATION_STATE: TimestampedValidationState = { + ...EMPTY_VALIDATION_STATE, + testedAt: null, +}; + function getStepPrimaryLabel(input: { currentStep: string | null; llmValidated: boolean; @@ -76,29 +97,32 @@ export const OnboardingGate: React.FC = () => { isLoading: settingsLoading, refreshSettings, } = useSettings(); + const { + storedRxResume, + getBaseResumeIdForMode, + setBaseResumeIdForMode, + syncBaseResumeIdsForMode, + } = useRxResumeConfigState(settings); const [isSavingEnv, setIsSavingEnv] = useState(false); const [isValidatingLlm, setIsValidatingLlm] = useState(false); const [isValidatingRxresume, setIsValidatingRxresume] = useState(false); const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false); - const [llmValidation, setLlmValidation] = useState({ - valid: false, - message: null, - checked: false, - }); - const [rxresumeValidation, setRxresumeValidation] = useState( - { - valid: false, - message: null, - checked: false, - }, + const [llmValidation, setLlmValidation] = useState( + EMPTY_VALIDATION_STATE, ); + const [rxresumeValidation, setRxresumeValidation] = useState( + EMPTY_VALIDATION_STATE, + ); + const [rxresumeVersionValidations, setRxresumeVersionValidations] = useState<{ + v4: TimestampedValidationState; + v5: TimestampedValidationState; + }>({ + v4: EMPTY_TIMESTAMPED_VALIDATION_STATE, + v5: EMPTY_TIMESTAMPED_VALIDATION_STATE, + }); const [baseResumeValidation, setBaseResumeValidation] = - useState({ - valid: false, - message: null, - checked: false, - }); + useState(EMPTY_VALIDATION_STATE); const [currentStep, setCurrentStep] = useState(null); const demoInfo = useDemoInfo(); const demoMode = demoInfo?.demoMode ?? false; @@ -109,8 +133,10 @@ export const OnboardingGate: React.FC = () => { llmProvider: "", llmBaseUrl: "", llmApiKey: "", + rxresumeMode: "v5", rxresumeEmail: "", rxresumePassword: "", + rxresumeApiKey: "", rxresumeBaseResumeId: null, }, }); @@ -149,28 +175,6 @@ export const OnboardingGate: React.FC = () => { } }, [getValues, settings?.llmProvider]); - const validateRxresume = useCallback(async () => { - const values = getValues(); - - setIsValidatingRxresume(true); - try { - const result = await api.validateRxresume( - values.rxresumeEmail.trim() || undefined, - values.rxresumePassword.trim() || undefined, - ); - setRxresumeValidation({ ...result, checked: true }); - return result; - } catch (error) { - const message = - error instanceof Error ? error.message : "RxResume validation failed"; - const result = { valid: false, message }; - setRxresumeValidation({ ...result, checked: true }); - return result; - } finally { - setIsValidatingRxresume(false); - } - }, [getValues]); - const validateBaseResume = useCallback(async () => { setIsValidatingBaseResume(true); try { @@ -190,6 +194,7 @@ export const OnboardingGate: React.FC = () => { } }, []); + const rxresumeModeValue = watch("rxresumeMode"); const selectedProvider = normalizeLlmProvider( llmProvider || settings?.llmProvider?.value || "openrouter", ); @@ -203,8 +208,9 @@ export const OnboardingGate: React.FC = () => { const llmKeyHint = settings?.llmApiKeyHint ?? null; const hasLlmKey = Boolean(llmKeyHint); - const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()); - const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint); + const rxresumeModeCurrent = (rxresumeModeValue || + settings?.rxresumeMode?.value || + "v5") as RxResumeMode; const hasCheckedValidations = (requiresLlmKey ? llmValidation.checked : true) && rxresumeValidation.checked && @@ -216,26 +222,83 @@ export const OnboardingGate: React.FC = () => { hasCheckedValidations && !(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid); - const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim() - ? settings.rxresumeEmail - : undefined; - const rxresumePasswordCurrent = settings?.rxresumePasswordHint - ? formatSecretHint(settings?.rxresumePasswordHint) - : undefined; + const validateRxresumeVersion = useCallback( + async ( + version: "v4" | "v5", + ): Promise => { + const values = getValues(); + const draftCredentials = getRxResumeCredentialDrafts(values); + const testedAt = Date.now(); + const result = await validateAndMaybePersistRxResumeMode({ + mode: version, + stored: storedRxResume, + draft: draftCredentials, + validate: api.validateRxresume, + getPrecheckMessage: (failure) => + failure === "missing-v5-api-key" + ? "v5 API key required. Add a v5 API key, then test again." + : "v4 email and password required. Add both credentials, then test again.", + getValidationErrorMessage: (error, mode) => + error instanceof Error + ? error.message + : `RxResume ${mode} validation failed`, + }); + return { ...result.validation, checked: true, testedAt }; + }, + [getValues, storedRxResume], + ); + + const validateRxresume = useCallback(async () => { + const values = getValues(); + const selectedMode = values.rxresumeMode; + + setIsValidatingRxresume(true); + try { + const versionResult = await validateRxresumeVersion(selectedMode); + setRxresumeVersionValidations((current) => ({ + ...current, + [selectedMode]: versionResult, + })); + + const result: ValidationResult = { + valid: versionResult.valid, + message: versionResult.message, + }; + setRxresumeValidation({ ...result, checked: true }); + return result; + } finally { + setIsValidatingRxresume(false); + } + }, [getValues, validateRxresumeVersion]); // Initialize form values from settings useEffect(() => { if (settings) { + const initialMode = getInitialRxResumeMode({ + savedMode: (settings.rxresumeMode?.value ?? + null) as RxResumeMode | null, + hasV4: storedRxResume.hasV4, + hasV5: storedRxResume.hasV5, + }); + const selectedId = syncBaseResumeIdsForMode(initialMode); reset({ llmProvider: settings.llmProvider?.value || "", llmBaseUrl: settings.llmBaseUrl?.value || "", llmApiKey: "", + rxresumeMode: initialMode, rxresumeEmail: "", rxresumePassword: "", - rxresumeBaseResumeId: settings.rxresumeBaseResumeId || null, + rxresumeApiKey: "", + rxresumeBaseResumeId: selectedId, }); } - }, [settings, reset]); + }, [ + settings, + reset, + storedRxResume.hasV4, + storedRxResume.hasV5, + syncBaseResumeIdsForMode, + ]); // Clear base URL when provider doesn't require it useEffect(() => { @@ -262,7 +325,7 @@ export const OnboardingGate: React.FC = () => { { id: "rxresume", label: "Connect Reactive Resume", - subtitle: "Reactive Resume login", + subtitle: "Version + credentials", complete: rxresumeValidation.valid, disabled: false, }, @@ -334,20 +397,6 @@ export const OnboardingGate: React.FC = () => { demoMode, ]); - const handleRefresh = async () => { - const results = await Promise.allSettled([ - refreshSettings(), - runAllValidations(), - ]); - const failed = results.find((result) => result.status === "rejected"); - if (failed) { - const reason = failed.status === "rejected" ? failed.reason : null; - const message = - reason instanceof Error ? reason.message : "Failed to refresh setup"; - toast.error(message); - } - }; - const handleSaveLlm = async (): Promise => { const values = getValues(); const apiKeyValue = values.llmApiKey.trim(); @@ -395,13 +444,13 @@ export const OnboardingGate: React.FC = () => { const handleSaveRxresume = async (): Promise => { const values = getValues(); - const emailValue = values.rxresumeEmail.trim(); - const passwordValue = values.rxresumePassword.trim(); - const missing: string[] = []; - - if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email"); - if (!hasRxresumePassword && !passwordValue) - missing.push("RxResume password"); + const modeValue = values.rxresumeMode; + const draftCredentials = getRxResumeCredentialDrafts(values); + const missing = getRxResumeMissingCredentialLabels({ + mode: modeValue, + stored: storedRxResume, + draft: draftCredentials, + }); if (missing.length > 0) { toast.info("Almost there", { @@ -411,22 +460,50 @@ export const OnboardingGate: React.FC = () => { } try { - const validation = await validateRxresume(); - if (!validation.valid) { - toast.error(validation.message || "RxResume validation failed"); + setIsValidatingRxresume(true); + const result = await validateAndMaybePersistRxResumeMode({ + mode: modeValue, + stored: storedRxResume, + draft: draftCredentials, + validate: api.validateRxresume, + persist: async (update) => { + setIsSavingEnv(true); + try { + await api.updateSettings(update); + await refreshSettings(); + } finally { + setIsSavingEnv(false); + } + }, + persistOnSuccess: true, + getPrecheckMessage: (failure) => + failure === "missing-v5-api-key" + ? "v5 API key required. Add a v5 API key, then test again." + : "v4 email and password required. Add both credentials, then test again.", + getValidationErrorMessage: (error) => + error instanceof Error ? error.message : "RxResume validation failed", + getPersistErrorMessage: (error) => + error instanceof Error + ? error.message + : "Failed to save RxResume credentials", + }); + + setRxresumeVersionValidations((current) => ({ + ...current, + [modeValue]: { + ...result.validation, + checked: true, + testedAt: Date.now(), + }, + })); + setRxresumeValidation({ ...result.validation, checked: true }); + + if (!result.validation.valid) { + toast.error(result.validation.message || "RxResume validation failed"); return false; } - - const update: { rxresumeEmail?: string; rxresumePassword?: string } = {}; - if (emailValue) update.rxresumeEmail = emailValue; - if (passwordValue) update.rxresumePassword = passwordValue; - - if (Object.keys(update).length > 0) { - setIsSavingEnv(true); - await api.updateSettings(update); - await refreshSettings(); - setValue("rxresumePassword", ""); - } + setValue("rxresumePassword", ""); + setValue("rxresumeApiKey", ""); toast.success("RxResume connected"); return true; @@ -438,6 +515,7 @@ export const OnboardingGate: React.FC = () => { toast.error(message); return false; } finally { + setIsValidatingRxresume(false); setIsSavingEnv(false); } }; @@ -453,6 +531,7 @@ export const OnboardingGate: React.FC = () => { try { setIsSavingEnv(true); await api.updateSettings({ + rxresumeMode: values.rxresumeMode, rxresumeBaseResumeId: values.rxresumeBaseResumeId, }); const validation = await validateBaseResume(); @@ -488,12 +567,6 @@ export const OnboardingGate: React.FC = () => { isValidatingRxresume || isValidatingBaseResume; const canGoBack = stepIndex > 0; - const primaryLabel = getStepPrimaryLabel({ - currentStep, - llmValidated, - rxresumeValidated: rxresumeValidation.valid, - baseResumeValidated: baseResumeValidation.valid, - }); const handlePrimaryAction = async () => { if (!currentStep) return; @@ -671,60 +744,39 @@ export const OnboardingGate: React.FC = () => { -
-

- Link your RxResume account -

-

- Used to export tailored PDFs. Create an account{" "} - - here - {" "} - on RxResume v4 using email/password. -

-
-
- ( - - )} - /> - ( - - )} - /> -
+ { + setValue("rxresumeMode", mode); + setValue( + "rxresumeBaseResumeId", + getBaseResumeIdForMode(mode), + ); + setRxresumeValidation((previous) => ({ + ...EMPTY_VALIDATION_STATE, + checked: previous.checked, + })); + }} + disabled={isSavingEnv} + showValidationStatus + validationStatuses={rxresumeVersionValidations} + intro={{ + title: "Link your RxResume account", + description: + "Used to export tailored PDFs. Choose between Reactive Resume version 4 and 5, and provide the credentials.", + }} + v5={{ + apiKey: watch("rxresumeApiKey"), + onApiKeyChange: (value) => setValue("rxresumeApiKey", value), + }} + v4={{ + email: watch("rxresumeEmail"), + onEmailChange: (value) => setValue("rxresumeEmail", value), + password: watch("rxresumePassword"), + onPasswordChange: (value) => + setValue("rxresumePassword", value), + }} + />
@@ -743,8 +795,14 @@ export const OnboardingGate: React.FC = () => { render={({ field }) => ( { + const mode = (getValues("rxresumeMode") ?? + "v5") as RxResumeMode; + setBaseResumeIdForMode(mode, value); + field.onChange(value); + }} hasRxResumeAccess={rxresumeValidation.valid} + rxresumeMode={rxresumeModeCurrent} disabled={isSavingEnv} /> )} @@ -761,30 +819,20 @@ export const OnboardingGate: React.FC = () => { Back
-
- -
- Friendly heads-up: pipelines can be slow or a little flaky in alpha. - If anything feels off, open a GitHub issue and we will take a look.{" "} - - Open an issue - - . -
diff --git a/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx b/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx new file mode 100644 index 0000000..d5e2a5d --- /dev/null +++ b/orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx @@ -0,0 +1,365 @@ +import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection"; +import { SettingsInput } from "@client/pages/settings/components/SettingsInput"; +import { + toggleAiSelectable, + toggleMustInclude, +} from "@client/pages/settings/resume-projects-state"; +import type { ResumeProjectsSettingsInput } from "@shared/settings-schema.js"; +import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types.js"; +import type React from "react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { clampInt } from "@/lib/utils"; +import { StatusIndicator } from "./StatusIndicator"; + +type VersionValidationState = { + checked: boolean; + valid: boolean; + message?: string | null; +}; + +type ProjectSelectionConfig = { + baseResumeId: string | null; + onBaseResumeIdChange: (value: string | null) => void; + projects: ResumeProjectCatalogItem[]; + value: ResumeProjectsSettingsInput | null | undefined; + onChange: (next: ResumeProjectsSettingsInput) => void; + lockedCount: number; + maxProjectsTotal: number; + isProjectsLoading: boolean; + disabled: boolean; + maxProjectsError?: string; +}; + +type ReactiveResumeConfigPanelProps = { + mode: RxResumeMode; + onModeChange: (mode: RxResumeMode) => void; + disabled?: boolean; + hasRxResumeAccess?: boolean; + showValidationStatus?: boolean; + validationStatuses?: { + v4: VersionValidationState; + v5: VersionValidationState; + }; + intro?: { + title: string; + description?: string; + }; + v5: { + apiKey: string; + onApiKeyChange: (value: string) => void; + error?: string; + helper?: string; + placeholder?: string; + }; + v4: { + email: string; + onEmailChange: (value: string) => void; + emailError?: string; + password: string; + onPasswordChange: (value: string) => void; + passwordError?: string; + emailPlaceholder?: string; + passwordPlaceholder?: string; + }; + projectSelection?: ProjectSelectionConfig; +}; + +function renderStatusPill(label: string, state: VersionValidationState) { + const statusLabel = state.checked + ? state.valid + ? "Connected" + : "Failed" + : "Not tested"; + const dotColor = state.checked + ? state.valid + ? "bg-emerald-500" + : "bg-destructive" + : "bg-muted-foreground"; + + return ( + + ); +} + +export const ReactiveResumeConfigPanel: React.FC< + ReactiveResumeConfigPanelProps +> = ({ + mode, + onModeChange, + disabled = false, + hasRxResumeAccess = false, + showValidationStatus = false, + validationStatuses, + intro, + v5, + v4, + projectSelection, +}) => { + const canShowProjectSelection = Boolean( + projectSelection && hasRxResumeAccess, + ); + const selectedValidationStatus = validationStatuses?.[mode]; + const handleModeChange = (value: string) => + onModeChange(value === "v4" ? "v4" : "v5"); + + return ( +
+ {intro ? ( +
+

{intro.title}

+ {intro.description ? ( +

{intro.description}

+ ) : null} +
+ ) : null} + + + + + v5 (API key) + + + v4 (Email + Password) + + + + + {showValidationStatus && selectedValidationStatus ? ( +
+ {renderStatusPill(`${mode} status`, selectedValidationStatus)} +
+ ) : null} + + {mode === "v5" ? ( +
+ v5.onApiKeyChange(event.currentTarget.value), + }} + type="password" + placeholder={v5.placeholder ?? "Enter v5 API key"} + helper={v5.helper} + disabled={disabled} + error={v5.error} + /> +
+ ) : ( +
+ v4.onEmailChange(event.currentTarget.value), + }} + placeholder={v4.emailPlaceholder ?? "you@example.com"} + disabled={disabled} + error={v4.emailError} + /> + + v4.onPasswordChange(event.currentTarget.value), + }} + type="password" + placeholder={v4.passwordPlaceholder ?? "Enter v4 password"} + disabled={disabled} + error={v4.passwordError} + /> +
+ )} + + {projectSelection ? ( + <> + + + {!canShowProjectSelection ? ( +
+ Connect Reactive Resume and choose a template resume to configure + resume projects. +
+ ) : ( +
+ + + {!projectSelection.baseResumeId ? ( +
+ Choose a PDF to configure resume projects. +
+ ) : ( + <> +
+
+ Max projects to choose +
+ { + if (!projectSelection.value) return; + const next = Number(event.target.value); + const clamped = clampInt( + next, + projectSelection.lockedCount, + projectSelection.maxProjectsTotal, + ); + projectSelection.onChange({ + ...projectSelection.value, + maxProjects: clamped, + }); + }} + disabled={ + projectSelection.disabled || + projectSelection.isProjectsLoading || + !projectSelection.value + } + /> + {projectSelection.maxProjectsError ? ( +

+ {projectSelection.maxProjectsError} +

+ ) : null} +
+ + + + + + Project + + + Visible in template + + + Must Include + + + AI selectable + + + + + + {projectSelection.projects.map((project) => { + const value = projectSelection.value; + const locked = Boolean( + value?.lockedProjectIds.includes(project.id), + ); + const aiSelectable = Boolean( + value?.aiSelectableProjectIds.includes(project.id), + ); + const projectMeta = + mode === "v5" + ? project.date + : [project.description, project.date] + .filter(Boolean) + .join(" - "); + + return ( + + +
+
+ {project.name} +
+ {projectMeta ? ( +
+ {projectMeta} +
+ ) : null} +
+
+ + {project.isVisibleInBase ? "Yes" : "No"} + + + { + if (!value) return; + projectSelection.onChange( + toggleMustInclude({ + settings: value, + projectId: project.id, + checked: !locked, + maxProjectsTotal: + projectSelection.maxProjectsTotal, + }), + ); + }} + disabled={ + projectSelection.disabled || + projectSelection.isProjectsLoading || + !value + } + /> + + + { + if (!value) return; + projectSelection.onChange( + toggleAiSelectable({ + settings: value, + projectId: project.id, + checked: !aiSelectable, + }), + ); + }} + disabled={ + projectSelection.disabled || + projectSelection.isProjectsLoading || + locked || + !value + } + /> + +
+ ); + })} +
+
+ + )} +
+ )} + + ) : null} +
+ ); +}; diff --git a/orchestrator/src/client/components/StatusIndicator.tsx b/orchestrator/src/client/components/StatusIndicator.tsx new file mode 100644 index 0000000..9da9797 --- /dev/null +++ b/orchestrator/src/client/components/StatusIndicator.tsx @@ -0,0 +1,121 @@ +import type { JobStatus } from "@shared/types/jobs"; +import type React from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + defaultStatusToken, + statusTokens, +} from "../pages/orchestrator/constants"; + +const STATUS_INDICATOR_BASE_CLASS = + "inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80"; +const STATUS_INDICATOR_DOT_CLASS = "h-1.5 w-1.5 rounded-full opacity-80"; + +const badgeVariantClasses = { + amber: { + badge: "border-amber-500/30 bg-amber-500/10 text-amber-200", + dot: "bg-amber-400", + }, + emerald: { + badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", + dot: "bg-emerald-400", + }, + sky: { + badge: "border-sky-500/30 bg-sky-500/10 text-sky-200", + dot: "bg-sky-400", + }, +}; + +type StatusIndicatorProps = { + dotColor?: string; + label: React.ReactNode; + className?: string; + dotClassName?: string; + variant?: keyof typeof badgeVariantClasses; + appearance?: "inline" | "badge"; + animateDot?: boolean; + tooltip?: React.ReactNode; + tooltipClassName?: string; + tooltipSide?: "top" | "right" | "bottom" | "left"; + tooltipDelayDuration?: number; +}; + +const StatusIndicator: React.FC = ({ + dotColor, + label, + className, + dotClassName, + variant = "amber", + appearance = "inline", + animateDot = appearance === "badge", + tooltip, + tooltipClassName, + tooltipSide = "top", + tooltipDelayDuration = 0, +}) => { + const badgeTokens = badgeVariantClasses[variant]; + const resolvedDotColor = dotColor ?? badgeTokens.dot; + + const content = ( + + + {label} + + ); + + if (!tooltip) return content; + + return ( + + + {content} + + {tooltip} + + + + ); +}; + +const getJobStatusIndicator = (status: JobStatus) => { + const tokens = statusTokens[status] ?? defaultStatusToken; + return { label: tokens.label, dotColor: tokens.dot }; +}; + +const getTracerStatusIndicator = (enabled: boolean) => ({ + label: enabled ? "Tracer On" : "Tracer Off", + dotColor: enabled ? "bg-violet-500" : "bg-slate-500", +}); + +const StatusBadgeIndicator: React.FC< + Omit & { appearance?: "badge" } +> = (props) => ; + +export { + StatusIndicator, + getJobStatusIndicator, + getTracerStatusIndicator, + StatusBadgeIndicator, +}; diff --git a/orchestrator/src/client/components/discovered-panel/ProjectSelector.tsx b/orchestrator/src/client/components/discovered-panel/ProjectSelector.tsx index b4ee710..722eabc 100644 --- a/orchestrator/src/client/components/discovered-panel/ProjectSelector.tsx +++ b/orchestrator/src/client/components/discovered-panel/ProjectSelector.tsx @@ -24,9 +24,6 @@ export const ProjectSelector: React.FC = ({ return (
- - Selected Projects - {tooManyProjects && ( diff --git a/orchestrator/src/client/components/layout.tsx b/orchestrator/src/client/components/layout.tsx index d95c572..4ddc8cb 100644 --- a/orchestrator/src/client/components/layout.tsx +++ b/orchestrator/src/client/components/layout.tsx @@ -25,6 +25,7 @@ import { import { cn } from "@/lib/utils"; import { useVersionCheck } from "../hooks/useVersionCheck"; import { isNavActive, NAV_LINKS } from "./navigation"; +import { StatusBadgeIndicator } from "./StatusIndicator"; // ============================================================================ // Page Header @@ -165,47 +166,7 @@ export const PageHeader: React.FC = ({ ); }; -// ============================================================================ -// Status Indicator (Pipeline running, Updating, etc.) -// ============================================================================ - -interface StatusIndicatorProps { - label: string; - variant?: "amber" | "emerald" | "sky"; -} - -export const StatusIndicator: React.FC = ({ - label, - variant = "amber", -}) => { - const colorMap = { - amber: "border-amber-500/30 bg-amber-500/10 text-amber-200", - emerald: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", - sky: "border-sky-500/30 bg-sky-500/10 text-sky-200", - }; - const dotMap = { - amber: "bg-amber-400", - emerald: "bg-emerald-400", - sky: "bg-sky-400", - }; - - return ( - - - {label} - - ); -}; +export const StatusIndicator = StatusBadgeIndicator; // ============================================================================ // Split Layout (List + Detail panels) diff --git a/orchestrator/src/client/hooks/useRxResumeConfigState.ts b/orchestrator/src/client/hooks/useRxResumeConfigState.ts new file mode 100644 index 0000000..3e7f617 --- /dev/null +++ b/orchestrator/src/client/hooks/useRxResumeConfigState.ts @@ -0,0 +1,55 @@ +import { + getRxResumeBaseResumeSelection, + getStoredRxResumeCredentialAvailability, + type RxResumeSettingsLike, +} from "@client/lib/rxresume-config"; +import type { RxResumeMode } from "@shared/types.js"; +import { useCallback, useMemo, useState } from "react"; + +const EMPTY_IDS_BY_MODE: Record = { + v4: null, + v5: null, +}; + +export function useRxResumeConfigState(settings: RxResumeSettingsLike) { + const storedRxResume = useMemo( + () => getStoredRxResumeCredentialAvailability(settings), + [settings], + ); + const [baseResumeIdsByMode, setBaseResumeIdsByMode] = + useState>(EMPTY_IDS_BY_MODE); + + const syncBaseResumeIdsForMode = useCallback( + (mode: RxResumeMode) => { + const { idsByMode, selectedId } = getRxResumeBaseResumeSelection( + settings, + mode, + ); + setBaseResumeIdsByMode(idsByMode); + return selectedId; + }, + [settings], + ); + + const getBaseResumeIdForMode = useCallback( + (mode: RxResumeMode) => baseResumeIdsByMode[mode] ?? null, + [baseResumeIdsByMode], + ); + + const setBaseResumeIdForMode = useCallback( + (mode: RxResumeMode, value: string | null) => { + setBaseResumeIdsByMode((prev) => + prev[mode] === value ? prev : { ...prev, [mode]: value }, + ); + }, + [], + ); + + return { + storedRxResume, + baseResumeIdsByMode, + syncBaseResumeIdsForMode, + getBaseResumeIdForMode, + setBaseResumeIdForMode, + }; +} diff --git a/orchestrator/src/client/lib/rxresume-config.ts b/orchestrator/src/client/lib/rxresume-config.ts new file mode 100644 index 0000000..344b670 --- /dev/null +++ b/orchestrator/src/client/lib/rxresume-config.ts @@ -0,0 +1,245 @@ +import type { UpdateSettingsInput } from "@shared/settings-schema.js"; +import type { RxResumeMode, ValidationResult } from "@shared/types.js"; + +export type RxResumeSettingsLike = + | { + rxresumeMode?: { value?: string | null } | null; + rxresumeEmail?: string | null; + rxresumePasswordHint?: string | null; + rxresumeApiKeyHint?: string | null; + rxresumeBaseResumeId?: string | null; + rxresumeBaseResumeIdV4?: string | null; + rxresumeBaseResumeIdV5?: string | null; + } + | null + | undefined; + +export const RXRESUME_MODES = ["v4", "v5"] as const; + +export const RXRESUME_PRECHECK_MESSAGES = { + "missing-v4-email-password": "Add v4 email and password, then test again.", + "missing-v5-api-key": "Add a v5 API key, then test again.", +} as const; + +export const coerceRxResumeMode = ( + value: unknown, + fallback: RxResumeMode = "v5", +): RxResumeMode => (value === "v4" || value === "v5" ? value : fallback); + +export const getStoredRxResumeCredentialAvailability = ( + settings: RxResumeSettingsLike, +) => { + const email = Boolean(settings?.rxresumeEmail?.trim()); + const password = Boolean(settings?.rxresumePasswordHint); + const apiKey = Boolean(settings?.rxresumeApiKeyHint); + return { email, password, apiKey, hasV4: email && password, hasV5: apiKey }; +}; + +export const getInitialRxResumeMode = (input: { + savedMode: RxResumeMode | null | undefined; + hasV4: boolean; + hasV5: boolean; +}): RxResumeMode => + coerceRxResumeMode( + input.savedMode ?? (input.hasV4 && !input.hasV5 ? "v4" : "v5"), + ); + +export const getRxResumeBaseResumeSelection = ( + settings: RxResumeSettingsLike, + mode: RxResumeMode, +) => { + const idsByMode = { + v4: + settings?.rxresumeBaseResumeIdV4 ?? + (mode === "v4" ? (settings?.rxresumeBaseResumeId ?? null) : null), + v5: + settings?.rxresumeBaseResumeIdV5 ?? + (mode === "v5" ? (settings?.rxresumeBaseResumeId ?? null) : null), + } satisfies Record; + return { idsByMode, selectedId: idsByMode[mode] ?? null }; +}; + +export const getRxResumeCredentialDrafts = (input: { + rxresumeEmail?: string | null; + rxresumePassword?: string | null; + rxresumeApiKey?: string | null; +}) => ({ + email: input.rxresumeEmail?.trim() ?? "", + password: input.rxresumePassword?.trim() ?? "", + apiKey: input.rxresumeApiKey?.trim() ?? "", +}); + +export type RxResumeCredentialDrafts = ReturnType< + typeof getRxResumeCredentialDrafts +>; +export type RxResumeStoredCredentialAvailability = Pick< + ReturnType, + "email" | "password" | "apiKey" +>; + +export const getRxResumeCredentialPrecheckFailure = (input: { + mode: RxResumeMode; + stored: RxResumeStoredCredentialAvailability; + draft: RxResumeCredentialDrafts; +}) => { + const hasV4 = + (input.stored.email || Boolean(input.draft.email)) && + (input.stored.password || Boolean(input.draft.password)); + const hasV5 = input.stored.apiKey || Boolean(input.draft.apiKey); + if (input.mode === "v5" && !hasV5) return "missing-v5-api-key" as const; + if (input.mode === "v4" && !hasV4) + return "missing-v4-email-password" as const; + return null; +}; + +export type RxResumeCredentialPrecheckFailure = ReturnType< + typeof getRxResumeCredentialPrecheckFailure +>; + +export const getRxResumeMissingCredentialLabels = (input: { + mode: RxResumeMode; + stored: RxResumeStoredCredentialAvailability; + draft: RxResumeCredentialDrafts; +}) => + input.mode === "v5" + ? input.stored.apiKey || input.draft.apiKey + ? [] + : ["RxResume v5 API key"] + : [ + ...(input.stored.email || input.draft.email ? [] : ["RxResume email"]), + ...(input.stored.password || input.draft.password + ? [] + : ["RxResume password"]), + ]; + +export const toRxResumeValidationPayload = ( + draft: RxResumeCredentialDrafts, +) => ({ + email: draft.email || undefined, + password: draft.password || undefined, + apiKey: draft.apiKey || undefined, +}); + +export const buildRxResumeSettingsUpdate = ( + mode: RxResumeMode, + draft: RxResumeCredentialDrafts, +): Partial => { + const update: Partial = { + rxresumeMode: mode, + }; + if (draft.email) update.rxresumeEmail = draft.email; + if (draft.password) update.rxresumePassword = draft.password; + if (draft.apiKey) update.rxresumeApiKey = draft.apiKey; + return update; +}; + +type ValidateAndMaybePersistRxResumeModeInput = { + mode: RxResumeMode; + stored: RxResumeStoredCredentialAvailability; + draft: RxResumeCredentialDrafts; + validate: ( + payload: { mode: RxResumeMode } & ReturnType< + typeof toRxResumeValidationPayload + >, + ) => Promise; + persist?: (update: Partial) => Promise; + persistOnSuccess?: boolean; + getPrecheckMessage?: ( + failure: Exclude, + ) => string; + getValidationErrorMessage?: (error: unknown, mode: RxResumeMode) => string; + getPersistErrorMessage?: (error: unknown, mode: RxResumeMode) => string; +}; + +export type ValidateAndMaybePersistRxResumeModeResult = { + validation: ValidationResult; + precheckFailure: RxResumeCredentialPrecheckFailure; + updatedSettings: TSettings | null; +}; + +export const validateAndMaybePersistRxResumeMode = async ( + input: ValidateAndMaybePersistRxResumeModeInput, +): Promise> => { + const { + mode, + stored, + draft, + validate, + persist, + persistOnSuccess = false, + getPrecheckMessage = (failure) => RXRESUME_PRECHECK_MESSAGES[failure], + getValidationErrorMessage = (error) => + error instanceof Error ? error.message : "RxResume validation failed", + getPersistErrorMessage = (error) => + error instanceof Error + ? error.message + : "Failed to save RxResume settings", + } = input; + + const precheckFailure = getRxResumeCredentialPrecheckFailure({ + mode, + stored, + draft, + }); + if (precheckFailure) { + return { + validation: { + valid: false, + message: getPrecheckMessage(precheckFailure), + }, + precheckFailure, + updatedSettings: null, + }; + } + + let validation: ValidationResult; + try { + validation = await validate({ + mode, + ...toRxResumeValidationPayload(draft), + }); + } catch (error) { + return { + validation: { + valid: false, + message: getValidationErrorMessage(error, mode), + }, + precheckFailure: null, + updatedSettings: null, + }; + } + + if (!validation.valid || !persistOnSuccess || !persist) { + return { + validation: { + valid: validation.valid, + message: validation.valid ? null : (validation.message ?? null), + }, + precheckFailure: null, + updatedSettings: null, + }; + } + + try { + const updatedSettings = await persist( + buildRxResumeSettingsUpdate(mode, draft), + ); + return { + validation: { + valid: true, + message: null, + }, + precheckFailure: null, + updatedSettings, + }; + } catch (error) { + return { + validation: { + valid: false, + message: getPersistErrorMessage(error, mode), + }, + precheckFailure: null, + updatedSettings: null, + }; + } +}; diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 36e3e71..fbf6524 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -14,6 +14,7 @@ const render = (ui: Parameters[0]) => vi.mock("../api", () => ({ getSettings: vi.fn(), updateSettings: vi.fn(), + validateRxresume: vi.fn(), clearDatabase: vi.fn(), deleteJobsByStatus: vi.fn(), getTracerReadiness: vi.fn(), @@ -57,6 +58,11 @@ const renderPage = () => { ); }; +const openModelSection = async () => { + const modelTrigger = await screen.findByRole("button", { name: /^model$/i }); + fireEvent.click(modelTrigger); +}; + describe("SettingsPage", () => { beforeEach(() => { vi.clearAllMocks(); @@ -70,6 +76,10 @@ describe("SettingsPage", () => { lastSuccessAt: Date.now(), reason: null, }); + vi.mocked(api.validateRxresume).mockResolvedValue({ + valid: false, + message: "Missing credentials", + }); }); it("saves trimmed model overrides", async () => { @@ -84,6 +94,7 @@ describe("SettingsPage", () => { }); renderPage(); + await openModelSection(); const modelInput = screen.getByLabelText(/default model/i); await waitFor(() => expect(modelInput).toBeEnabled()); @@ -107,6 +118,7 @@ describe("SettingsPage", () => { vi.mocked(api.getSettings).mockResolvedValue(baseSettings); renderPage(); + await openModelSection(); const modelInput = screen.getByLabelText(/default model/i); await waitFor(() => expect(modelInput).toBeEnabled()); @@ -166,6 +178,7 @@ describe("SettingsPage", () => { renderPage(); const saveButton = screen.getByRole("button", { name: /^save$/i }); expect(saveButton).toBeDisabled(); + await openModelSection(); const modelInput = screen.getByLabelText(/default model/i); // Wait for the query to resolve and input to be enabled @@ -207,7 +220,40 @@ describe("SettingsPage", () => { /show visa sponsor information/i, ); fireEvent.click(sponsorCheckbox); - expect(saveButton).toBeEnabled(); + await waitFor(() => expect(saveButton).toBeEnabled()); + }); + + it("allows saving when both Reactive Resume v4 and v5 credentials are present", async () => { + const settingsWithBothRxResumeAuth = createAppSettings({ + rxresumeEmail: "resume@example.com", + rxresumePasswordHint: "pass", + rxresumeApiKeyHint: "api_", + }); + vi.mocked(api.getSettings).mockResolvedValue(settingsWithBothRxResumeAuth); + vi.mocked(api.updateSettings).mockResolvedValue( + settingsWithBothRxResumeAuth, + ); + + renderPage(); + + const displayTrigger = await screen.findByRole("button", { + name: /display settings/i, + }); + fireEvent.click(displayTrigger); + const sponsorCheckbox = screen.getByLabelText( + /show visa sponsor information/i, + ); + fireEvent.click(sponsorCheckbox); + + const saveButton = screen.getByRole("button", { name: /^save$/i }); + await waitFor(() => expect(saveButton).toBeEnabled()); + fireEvent.click(saveButton); + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()); + expect(toast.error).not.toHaveBeenCalledWith( + "Choose one Reactive Resume auth method", + expect.anything(), + ); }); it("enables save button when basic auth toggle is changed", async () => { diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 36559f4..10cf3e6 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -1,7 +1,15 @@ import * as api from "@client/api"; import { PageHeader } from "@client/components/layout"; import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation"; +import { useRxResumeConfigState } from "@client/hooks/useRxResumeConfigState"; import { useTracerReadiness } from "@client/hooks/useTracerReadiness"; +import { + coerceRxResumeMode, + getRxResumeCredentialDrafts, + RXRESUME_MODES, + RXRESUME_PRECHECK_MESSAGES, + validateAndMaybePersistRxResumeMode, +} from "@client/lib/rxresume-config"; import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection"; import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection"; import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"; @@ -28,12 +36,18 @@ import type { JobStatus, ResumeProjectCatalogItem, ResumeProjectsSettings, + RxResumeMode, } from "@shared/types.js"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Settings } from "lucide-react"; import type React from "react"; import { useCallback, useEffect, useState } from "react"; -import { FormProvider, type Resolver, useForm } from "react-hook-form"; +import { + FormProvider, + type Resolver, + useForm, + useWatch, +} from "react-hook-form"; import { toast } from "sonner"; import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast"; import { queryKeys } from "@/client/lib/queryKeys"; @@ -51,6 +65,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { pipelineWebhookUrl: "", jobCompleteWebhookUrl: "", resumeProjects: null, + rxresumeMode: "v5", rxresumeBaseResumeId: null, showSponsorInfo: null, chatStyleTone: "", @@ -59,6 +74,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { chatStyleDoNotUse: "", rxresumeEmail: "", rxresumePassword: "", + rxresumeApiKey: "", basicAuthUser: "", basicAuthPassword: "", ukvisajobsEmail: "", @@ -77,6 +93,16 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { }; type LlmProviderValue = LlmProviderId | null; +type RxResumeValidationBadgeState = { + checked: boolean; + valid: boolean; + message: string | null; +}; +const EMPTY_RXRESUME_VALIDATION_BADGE_STATE: RxResumeValidationBadgeState = { + checked: false, + valid: false, + message: null, +}; const normalizeLlmProviderValue = ( value: string | null | undefined, @@ -93,6 +119,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { pipelineWebhookUrl: null, jobCompleteWebhookUrl: null, resumeProjects: null, + rxresumeMode: null, rxresumeBaseResumeId: null, showSponsorInfo: null, chatStyleTone: null, @@ -101,6 +128,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { chatStyleDoNotUse: null, rxresumeEmail: null, rxresumePassword: null, + rxresumeApiKey: null, basicAuthUser: null, basicAuthPassword: null, ukvisajobsEmail: null, @@ -130,6 +158,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ pipelineWebhookUrl: data.pipelineWebhookUrl.override ?? "", jobCompleteWebhookUrl: data.jobCompleteWebhookUrl.override ?? "", resumeProjects: data.resumeProjects.override, + rxresumeMode: data.rxresumeMode.override ?? data.rxresumeMode.value, rxresumeBaseResumeId: data.rxresumeBaseResumeId, showSponsorInfo: data.showSponsorInfo.override, chatStyleTone: data.chatStyleTone.override ?? "", @@ -138,6 +167,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ chatStyleDoNotUse: data.chatStyleDoNotUse.override ?? "", rxresumeEmail: data.rxresumeEmail ?? "", rxresumePassword: "", + rxresumeApiKey: "", basicAuthUser: data.basicAuthUser ?? "", basicAuthPassword: "", ukvisajobsEmail: data.ukvisajobsEmail ?? "", @@ -312,6 +342,13 @@ export const SettingsPage: React.FC = () => { const queryClient = useQueryClient(); const [settings, setSettings] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [rxresumeValidationStatuses, setRxresumeValidationStatuses] = useState<{ + v4: RxResumeValidationBadgeState; + v5: RxResumeValidationBadgeState; + }>({ + v4: EMPTY_RXRESUME_VALIDATION_BADGE_STATE, + v5: EMPTY_RXRESUME_VALIDATION_BADGE_STATE, + }); const [statusesToClear, setStatusesToClear] = useState([ "discovered", ]); @@ -348,9 +385,15 @@ export const SettingsPage: React.FC = () => { setError, setValue, getValues, - watch, + control, formState: { isDirty, errors, isValid, dirtyFields }, } = methods; + const { + storedRxResume, + getBaseResumeIdForMode, + setBaseResumeIdForMode, + syncBaseResumeIdsForMode, + } = useRxResumeConfigState(settings); const settingsQuery = useQuery({ queryKey: queryKeys.settings.current(), @@ -367,8 +410,17 @@ export const SettingsPage: React.FC = () => { const isLoadingBackups = backupsQuery.isLoading; useQueryErrorToast(backupsQuery.error, "Failed to load backups"); + const rxresumeMode = (settings?.rxresumeMode?.value ?? "v5") as RxResumeMode; + const selectedRxresumeMode = (useWatch({ + control, + name: "rxresumeMode", + }) ?? rxresumeMode) as RxResumeMode; + const resumeProjectsValue = useWatch({ + control, + name: "resumeProjects", + }); const hasRxResumeAccess = Boolean( - settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint, + rxresumeValidationStatuses[selectedRxresumeMode].valid, ); useEffect(() => { @@ -381,11 +433,12 @@ export const SettingsPage: React.FC = () => { useEffect(() => { if (!settings) return; - const storedId = settings.rxresumeBaseResumeId ?? null; + const effectiveMode = coerceRxResumeMode(settings.rxresumeMode?.value); + const storedId = syncBaseResumeIdsForMode(effectiveMode); setRxResumeBaseResumeIdDraft(storedId); setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false }); setRxResumeProjectsOverride(null); - }, [settings, setValue]); + }, [settings, setValue, syncBaseResumeIdsForMode]); useEffect(() => { let isMounted = true; @@ -407,7 +460,11 @@ export const SettingsPage: React.FC = () => { setIsFetchingRxResumeProjects(true); api - .getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal) + .getRxResumeProjects( + rxResumeBaseResumeIdDraft, + controller.signal, + selectedRxresumeMode, + ) .then((projects) => { if (!isMounted) return; setRxResumeProjectsOverride(projects); @@ -437,7 +494,13 @@ export const SettingsPage: React.FC = () => { isMounted = false; controller.abort(); }; - }, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue]); + }, [ + rxResumeBaseResumeIdDraft, + hasRxResumeAccess, + selectedRxresumeMode, + getValues, + setValue, + ]); const derived = getDerivedSettings(settings); const { @@ -511,12 +574,93 @@ export const SettingsPage: React.FC = () => { } }, [refreshReadiness]); - const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects; + const validateRxresumeMode = useCallback( + async ( + mode: RxResumeMode, + options?: { silent?: boolean; persistOnSuccess?: boolean }, + ) => { + const { silent = false, persistOnSuccess = true } = options ?? {}; + const notify = !silent; + const values = getValues(); + const draftCredentials = getRxResumeCredentialDrafts(values); + const result = await validateAndMaybePersistRxResumeMode({ + mode, + stored: storedRxResume, + draft: draftCredentials, + validate: api.validateRxresume, + persist: api.updateSettings, + persistOnSuccess, + getPrecheckMessage: (failure) => RXRESUME_PRECHECK_MESSAGES[failure], + getValidationErrorMessage: (error) => + error instanceof Error ? error.message : "RxResume validation failed", + getPersistErrorMessage: (error) => + error instanceof Error ? error.message : "RxResume validation failed", + }); + + setRxresumeValidationStatuses((current) => ({ + ...current, + [mode]: { + checked: true, + valid: result.validation.valid, + message: result.validation.valid + ? null + : (result.validation.message ?? null), + }, + })); + + if (result.updatedSettings) { + setSettings(result.updatedSettings); + queryClient.setQueryData( + queryKeys.settings.current(), + result.updatedSettings, + ); + if (notify) { + toast.success(`Reactive Resume ${mode} validation passed`); + } + return; + } + + if (!notify || result.validation.valid) { + return; + } + + if (result.precheckFailure) { + toast.info( + result.validation.message ?? + RXRESUME_PRECHECK_MESSAGES[result.precheckFailure], + ); + return; + } + + toast.error( + result.validation.message || + `Reactive Resume ${mode} validation failed`, + ); + }, + [getValues, queryClient, storedRxResume], + ); + + useEffect(() => { + if (!settings) return; + + const modesToCheck = RXRESUME_MODES.filter( + (mode) => !rxresumeValidationStatuses[mode].checked, + ); + if (modesToCheck.length === 0) return; + + void Promise.all( + modesToCheck.map((mode) => + validateRxresumeMode(mode, { silent: true, persistOnSuccess: false }), + ), + ); + }, [settings, rxresumeValidationStatuses, validateRxresumeMode]); + + const effectiveProfileProjects = + rxResumeProjectsOverride ?? + (selectedRxresumeMode === rxresumeMode ? profileProjects : []); const effectiveMaxProjectsTotal = effectiveProfileProjects.length; - const watchedValues = watch(); - const lockedCount = - watchedValues.resumeProjects?.lockedProjectIds.length ?? 0; + const lockedCount = resumeProjectsValue?.lockedProjectIds.length ?? 0; const canSave = isDirty && isValid; @@ -594,6 +738,11 @@ export const SettingsPage: React.FC = () => { if (value !== undefined) envPayload.rxresumePassword = value; } + if (dirtyFields.rxresumeApiKey) { + const value = normalizePrivateInput(data.rxresumeApiKey); + if (value !== undefined) envPayload.rxresumeApiKey = value; + } + if (dirtyFields.ukvisajobsPassword) { const value = normalizePrivateInput(data.ukvisajobsPassword); if (value !== undefined) envPayload.ukvisajobsPassword = value; @@ -617,6 +766,7 @@ export const SettingsPage: React.FC = () => { pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl), jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl), resumeProjects: resumeProjectsOverride, + rxresumeMode: data.rxresumeMode ?? "v5", rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId), showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default), chatStyleTone: normalizeString(data.chatStyleTone), @@ -781,11 +931,7 @@ export const SettingsPage: React.FC = () => { />
- + { /> { + const nextId = getBaseResumeIdForMode(mode); + setRxResumeBaseResumeIdDraft(nextId); + setValue("rxresumeBaseResumeId", nextId, { shouldDirty: true }); + setRxResumeProjectsOverride(null); + }} setRxResumeBaseResumeIdDraft={(value) => { + const mode = (getValues("rxresumeMode") ?? + rxresumeMode) as RxResumeMode; + setBaseResumeIdForMode(mode, value); setRxResumeBaseResumeIdDraft(value); setValue("rxresumeBaseResumeId", value, { shouldDirty: true }); }} hasRxResumeAccess={hasRxResumeAccess} + rxresumeMode={rxresumeMode} + validationStatuses={rxresumeValidationStatuses} profileProjects={effectiveProfileProjects} lockedCount={lockedCount} maxProjectsTotal={effectiveMaxProjectsTotal} diff --git a/orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx b/orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx index c4796d1..1eaf579 100644 --- a/orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx +++ b/orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx @@ -1,4 +1,5 @@ import * as api from "@client/api"; +import type { RxResumeMode } from "@shared/types.js"; import { RefreshCw } from "lucide-react"; import type React from "react"; import { useCallback, useEffect, useState } from "react"; @@ -15,6 +16,7 @@ type BaseResumeSelectionProps = { value: string | null; onValueChange: (value: string | null) => void; hasRxResumeAccess: boolean; + rxresumeMode?: RxResumeMode; disabled?: boolean; isLoading?: boolean; }; @@ -23,6 +25,7 @@ export const BaseResumeSelection: React.FC = ({ value, onValueChange, hasRxResumeAccess, + rxresumeMode, disabled = false, isLoading = false, }) => { @@ -31,12 +34,16 @@ export const BaseResumeSelection: React.FC = ({ const [fetchError, setFetchError] = useState(null); const fetchResumes = useCallback(async () => { - if (!hasRxResumeAccess) return; + if (!hasRxResumeAccess) { + setResumes([]); + setFetchError(null); + return; + } setIsFetchingResumes(true); setFetchError(null); try { - const data = await api.getRxResumes(); + const data = await api.getRxResumes(rxresumeMode); setResumes(data); // Preselect if only one option is available and no value is currently set @@ -44,13 +51,14 @@ export const BaseResumeSelection: React.FC = ({ onValueChange(data[0].id); } } catch (error) { + setResumes([]); setFetchError( error instanceof Error ? error.message : "Failed to fetch resumes", ); } finally { setIsFetchingResumes(false); } - }, [hasRxResumeAccess, onValueChange, value]); + }, [hasRxResumeAccess, onValueChange, rxresumeMode, value]); useEffect(() => { if (hasRxResumeAccess) { @@ -58,6 +66,13 @@ export const BaseResumeSelection: React.FC = ({ } }, [hasRxResumeAccess, fetchResumes]); + useEffect(() => { + if (!hasRxResumeAccess) { + setResumes([]); + setFetchError(null); + } + }, [hasRxResumeAccess]); + return (
diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx index b5f2287..93531a1 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx @@ -52,14 +52,12 @@ describe("EnvironmentSettingsSection", () => { it("renders values grouped logically and masks private secrets with hints", () => { render(); - expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument(); expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument(); expect(screen.getByDisplayValue("adzuna-id")).toBeInTheDocument(); expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument(); expect(screen.getByText(/adzu\*{8}/)).toBeInTheDocument(); expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument(); - expect(screen.getByText("Not set")).toBeInTheDocument(); // Basic Auth expect(screen.getByLabelText("Enable basic authentication")).toBeChecked(); @@ -68,5 +66,6 @@ describe("EnvironmentSettingsSection", () => { // Sections expect(screen.getByText("Service Accounts")).toBeInTheDocument(); expect(screen.getByText("Security")).toBeInTheDocument(); + expect(screen.queryByText("RxResume")).not.toBeInTheDocument(); }); }); diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx index 7a699ee..5ad5f5b 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx @@ -44,28 +44,6 @@ export const EnvironmentSettingsSection: React.FC< Service Accounts
-
-
RxResume
-
- - -
-
-
UKVisaJobs
diff --git a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx index cd439b0..400e6c8 100644 --- a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx @@ -1,37 +1,30 @@ +import { ReactiveResumeConfigPanel } from "@client/components/ReactiveResumeConfigPanel"; import type { UpdateSettingsInput } from "@shared/settings-schema.js"; -import type { ResumeProjectCatalogItem } from "@shared/types.js"; -import { AlertCircle, CheckCircle2 } from "lucide-react"; +import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types.js"; import type React from "react"; -import { Controller, useFormContext } from "react-hook-form"; +import { + type Path, + type PathValue, + useFormContext, + useWatch, +} from "react-hook-form"; import { AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { clampInt } from "@/lib/utils"; -import { - toggleAiSelectable, - toggleMustInclude, -} from "../resume-projects-state"; -import { BaseResumeSelection } from "./BaseResumeSelection"; type ReactiveResumeSectionProps = { rxResumeBaseResumeIdDraft: string | null; setRxResumeBaseResumeIdDraft: (value: string | null) => void; // True when v4 credentials or v5 API key are configured. hasRxResumeAccess: boolean; + rxresumeMode: RxResumeMode; + onRxresumeModeChange?: (mode: RxResumeMode) => void; + validationStatuses?: { + v4: { checked: boolean; valid: boolean; message?: string | null }; + v5: { checked: boolean; valid: boolean; message?: string | null }; + }; profileProjects: ResumeProjectCatalogItem[]; lockedCount: number; maxProjectsTotal: number; @@ -44,6 +37,9 @@ export const ReactiveResumeSection: React.FC = ({ rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft, hasRxResumeAccess, + rxresumeMode, + onRxresumeModeChange, + validationStatuses, profileProjects, lockedCount, maxProjectsTotal, @@ -53,8 +49,25 @@ export const ReactiveResumeSection: React.FC = ({ }) => { const { control, + setValue, formState: { errors }, } = useFormContext(); + const selectedMode = + useWatch({ control, name: "rxresumeMode" }) ?? rxresumeMode ?? "v5"; + const rxresumeApiKeyValue = + useWatch({ control, name: "rxresumeApiKey" }) ?? ""; + const rxresumeEmailValue = useWatch({ control, name: "rxresumeEmail" }) ?? ""; + const rxresumePasswordValue = + useWatch({ control, name: "rxresumePassword" }) ?? ""; + const resumeProjectsValue = useWatch({ control, name: "resumeProjects" }); + const setDirtyTouchedValue = >( + field: TField, + value: PathValue, + ) => + setValue(field, value, { + shouldDirty: true, + shouldTouch: true, + }); return ( @@ -62,196 +75,48 @@ export const ReactiveResumeSection: React.FC = ({ Reactive Resume -
- {!hasRxResumeAccess ? ( - - - RxResume Access Missing - - Configure RxResume credentials in settings (email + password) or - set RXRESUME_API_KEY to enable access. - - - ) : ( - <> - - - - RxResume Access Ready - - - Reactive Resume access is active. - - - - - - - -
- {!rxResumeBaseResumeIdDraft ? ( -
- Choose a PDF to configure resume projects. -
- ) : ( - <> -
-
- Max projects to choose -
- ( - { - if (!field.value) return; - const next = Number(event.target.value); - const clamped = clampInt( - next, - lockedCount, - maxProjectsTotal, - ); - field.onChange({ - ...field.value, - maxProjects: clamped, - }); - }} - disabled={ - isLoading || - isSaving || - isProjectsLoading || - !field.value - } - /> - )} - /> - {errors.resumeProjects?.maxProjects && ( -

- {errors.resumeProjects.maxProjects.message} -

- )} -
- - ( - - - - - Project - - - Visible in template - - - Must Include - - - AI selectable - - - - - - {profileProjects.map((project) => { - const locked = Boolean( - field.value?.lockedProjectIds.includes( - project.id, - ), - ); - const aiSelectable = Boolean( - field.value?.aiSelectableProjectIds.includes( - project.id, - ), - ); - - return ( - - -
-
- {project.name || project.id} -
-
- {[project.description, project.date] - .filter(Boolean) - .join(" - ")} -
-
-
- - {project.isVisibleInBase ? "Yes" : "No"} - - - { - if (!field.value) return; - field.onChange( - toggleMustInclude({ - settings: field.value, - projectId: project.id, - checked: checked === true, - maxProjectsTotal, - }), - ); - }} - /> - - - { - if (!field.value) return; - field.onChange( - toggleAiSelectable({ - settings: field.value, - projectId: project.id, - checked: checked === true, - }), - ); - }} - /> - -
- ); - })} -
-
- )} - /> - - )} -
- - )} -
+ { + onRxresumeModeChange?.(mode); + setDirtyTouchedValue("rxresumeMode", mode); + }} + disabled={isLoading || isSaving} + hasRxResumeAccess={hasRxResumeAccess} + showValidationStatus={Boolean(validationStatuses)} + validationStatuses={validationStatuses} + v5={{ + apiKey: rxresumeApiKeyValue, + onApiKeyChange: (value) => + setDirtyTouchedValue("rxresumeApiKey", value), + error: errors.rxresumeApiKey?.message as string | undefined, + }} + v4={{ + email: rxresumeEmailValue, + onEmailChange: (value) => + setDirtyTouchedValue("rxresumeEmail", value), + emailError: errors.rxresumeEmail?.message as string | undefined, + password: rxresumePasswordValue, + onPasswordChange: (value) => + setDirtyTouchedValue("rxresumePassword", value), + passwordError: errors.rxresumePassword?.message as + | string + | undefined, + }} + projectSelection={{ + baseResumeId: rxResumeBaseResumeIdDraft, + onBaseResumeIdChange: setRxResumeBaseResumeIdDraft, + projects: profileProjects, + value: resumeProjectsValue, + onChange: (next) => setDirtyTouchedValue("resumeProjects", next), + lockedCount, + maxProjectsTotal, + isProjectsLoading, + disabled: isLoading || isSaving, + maxProjectsError: + errors.resumeProjects?.maxProjects?.message?.toString(), + }} + />
); diff --git a/orchestrator/src/server/api/routes/onboarding.test.ts b/orchestrator/src/server/api/routes/onboarding.test.ts index 6ff3f05..83f2409 100644 --- a/orchestrator/src/server/api/routes/onboarding.test.ts +++ b/orchestrator/src/server/api/routes/onboarding.test.ts @@ -1,5 +1,5 @@ import type { Server } from "node:http"; -import { RxResumeClient } from "@server/services/rxresume-client"; +import { RxResumeClient } from "@server/services/rxresume/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { startServer, stopServer } from "./test-utils"; @@ -224,7 +224,7 @@ describe.sequential("Onboarding API routes", () => { expect(res.ok).toBe(true); expect(body.ok).toBe(true); expect(body.data.valid).toBe(false); - expect(body.data.message).toContain("missing"); + expect(body.data.message).toContain("not configured"); }); it("returns invalid when only email is provided", async () => { @@ -237,7 +237,7 @@ describe.sequential("Onboarding API routes", () => { expect(res.ok).toBe(true); expect(body.data.valid).toBe(false); - expect(body.data.message).toContain("missing"); + expect(body.data.message).toContain("not configured"); }); it("returns invalid when only password is provided", async () => { @@ -250,7 +250,7 @@ describe.sequential("Onboarding API routes", () => { expect(res.ok).toBe(true); expect(body.data.valid).toBe(false); - expect(body.data.message).toContain("missing"); + expect(body.data.message).toContain("not configured"); }); it("validates invalid credentials against RxResume", async () => { @@ -275,6 +275,37 @@ describe.sequential("Onboarding API routes", () => { expect(body.data.valid).toBe(false); }); + it("validates v5 API key mode against Reactive Resume OpenAPI", async () => { + global.fetch = vi.fn((input, init) => { + const url = typeof input === "string" ? input : input.url; + if (url.includes("/api/openapi/resumes")) { + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => "application/json" }, + json: async () => [], + } as unknown as Response); + } + return originalFetch(input, init); + }); + + const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode: "v5", + apiKey: "rr-v5-test-key", + baseUrl: "http://localhost:3000", + }), + }); + const body = await res.json(); + + expect(res.ok).toBe(true); + expect(body.ok).toBe(true); + expect(body.data.valid).toBe(true); + expect(body.data.message).toBeNull(); + }); + it("handles whitespace-only credentials", async () => { const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, { method: "POST", @@ -285,7 +316,7 @@ describe.sequential("Onboarding API routes", () => { expect(res.ok).toBe(true); expect(body.data.valid).toBe(false); - expect(body.data.message).toContain("missing"); + expect(body.data.message).toContain("not configured"); }); }); diff --git a/orchestrator/src/server/api/routes/onboarding.ts b/orchestrator/src/server/api/routes/onboarding.ts index aa1e61f..4dcb957 100644 --- a/orchestrator/src/server/api/routes/onboarding.ts +++ b/orchestrator/src/server/api/routes/onboarding.ts @@ -1,14 +1,15 @@ -import { okWithMeta } from "@infra/http"; +import { ok, okWithMeta } from "@infra/http"; import { logger } from "@infra/logger"; import { isDemoMode } from "@server/config/demo"; import { getSetting } from "@server/repositories/settings"; import { LlmService } from "@server/services/llm/service"; -import { RxResumeClient } from "@server/services/rxresume-client"; import { getResume, - RxResumeCredentialsError, -} from "@server/services/rxresume-v4"; -import { resumeDataSchema } from "@shared/rxresume-schema"; + RxResumeAuthConfigError, + validateResumeSchema, + validateCredentials as validateRxResumeCredentials, +} from "@server/services/rxresume"; +import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId"; import { type Request, type Response, Router } from "express"; export const onboardingRouter = Router(); @@ -54,12 +55,13 @@ async function validateLlm(options: { } /** - * Validate that a base resume is configured and accessible via RxResume v4 API. + * Validate that a base resume is configured and accessible via Reactive Resume. */ async function validateResumeConfig(): Promise { try { // Check if rxresumeBaseResumeId is configured - const rxresumeBaseResumeId = await getSetting("rxresumeBaseResumeId"); + const { resumeId: rxresumeBaseResumeId } = + await getConfiguredRxResumeBaseResumeId(); if (!rxresumeBaseResumeId) { return { @@ -80,23 +82,17 @@ async function validateResumeConfig(): Promise { }; } - // Validate against schema - const result = resumeDataSchema.safeParse(resume.data); - if (!result.success) { - const issue = result.error.issues[0]; - const path = issue?.path?.join(".") || ""; - const baseMessage = - issue?.message ?? "Resume does not match the expected schema."; - const details = path ? `Field "${path}": ${baseMessage}` : baseMessage; - return { valid: false, message: details }; + const validated = await validateResumeSchema(resume.data); + if (!validated.ok) { + return { valid: false, message: validated.message }; } return { valid: true, message: null }; } catch (error) { - if (error instanceof RxResumeCredentialsError) { + if (error instanceof RxResumeAuthConfigError) { return { valid: false, - message: "RxResume credentials not configured.", + message: error.message, }; } const message = @@ -112,29 +108,32 @@ async function validateResumeConfig(): Promise { } } -async function validateRxresume( - email?: string | null, - password?: string | null, -): Promise { - const rxEmail = email?.trim() || process.env.RXRESUME_EMAIL || ""; - const rxPassword = password?.trim() || process.env.RXRESUME_PASSWORD || ""; - const rxUrl = process.env.RXRESUME_URL || "https://v4.rxresu.me"; +async function validateRxresume(options?: { + mode?: string | null; + email?: string | null; + password?: string | null; + apiKey?: string | null; + baseUrl?: string | null; +}): Promise { + const rawMode = options?.mode?.trim(); + const mode = rawMode === "v4" || rawMode === "v5" ? rawMode : undefined; - if (!rxEmail || !rxPassword) { - return { valid: false, message: "RxResume credentials are missing." }; - } + const result = await validateRxResumeCredentials({ + mode, + v4: { + email: options?.email ?? undefined, + password: options?.password ?? undefined, + baseUrl: options?.baseUrl ?? undefined, + }, + v5: { + apiKey: options?.apiKey ?? undefined, + baseUrl: options?.baseUrl ?? undefined, + }, + }); - const result = await RxResumeClient.verifyCredentials( - rxEmail, - rxPassword, - rxUrl, - ); + if (result.ok) return { valid: true, message: null }; - if (result.ok) { - return { valid: true, message: null }; - } - - const normalizedMessage = result.message?.toLowerCase() ?? ""; + const normalizedMessage = result.message.toLowerCase(); if ( result.status === 401 || normalizedMessage.includes("invalidcredentials") @@ -142,13 +141,11 @@ async function validateRxresume( return { valid: false, message: - "Invalid RxResume credentials. Check your email and password and try again.", + "Invalid RxResume credentials. Check your configured Reactive Resume mode credentials and try again.", }; } - const message = - result.message || `RxResume validation failed (HTTP ${result.status})`; - return { valid: false, message }; + return { valid: false, message: result.message }; } onboardingRouter.post( @@ -213,8 +210,19 @@ onboardingRouter.post( typeof req.body?.email === "string" ? req.body.email : undefined; const password = typeof req.body?.password === "string" ? req.body.password : undefined; - const result = await validateRxresume(email, password); - res.json({ success: true, data: result }); + const mode = typeof req.body?.mode === "string" ? req.body.mode : undefined; + const apiKey = + typeof req.body?.apiKey === "string" ? req.body.apiKey : undefined; + const baseUrl = + typeof req.body?.baseUrl === "string" ? req.body.baseUrl : undefined; + const result = await validateRxresume({ + mode, + email, + password, + apiKey, + baseUrl, + }); + ok(res, result); }, ); @@ -233,6 +241,6 @@ onboardingRouter.get( } const result = await validateResumeConfig(); - res.json({ success: true, data: result }); + ok(res, result); }, ); diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts index 604ea08..93e697c 100644 --- a/orchestrator/src/server/api/routes/profile.test.ts +++ b/orchestrator/src/server/api/routes/profile.test.ts @@ -2,14 +2,13 @@ import type { Server } from "node:http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { startServer, stopServer } from "./test-utils"; -// Mock the rxresume-v4 service -vi.mock("@server/services/rxresume-v4", () => ({ +// Mock the RxResume adapter service +vi.mock("@server/services/rxresume", () => ({ getResume: vi.fn(), - listResumes: vi.fn(), - RxResumeCredentialsError: class RxResumeCredentialsError extends Error { + RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error { constructor() { - super("RxResume credentials not configured."); - this.name = "RxResumeCredentialsError"; + super("Reactive Resume credentials not configured."); + this.name = "RxResumeAuthConfigError"; } }, })); @@ -31,10 +30,7 @@ vi.mock("@server/repositories/settings", async (importOriginal) => { import { getSetting } from "@server/repositories/settings"; import { getProfile } from "@server/services/profile"; -import { - getResume, - RxResumeCredentialsError, -} from "@server/services/rxresume-v4"; +import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume"; describe.sequential("Profile API routes", () => { let server: Server; @@ -192,7 +188,9 @@ describe.sequential("Profile API routes", () => { it("returns exists: false when RxResume credentials are missing", async () => { vi.mocked(getSetting).mockResolvedValue("test-resume-id"); - vi.mocked(getResume).mockRejectedValue(new RxResumeCredentialsError()); + vi.mocked(getResume).mockRejectedValue( + new (RxResumeAuthConfigError as unknown as new () => Error)(), + ); const res = await fetch(`${baseUrl}/api/profile/status`); const body = await res.json(); diff --git a/orchestrator/src/server/api/routes/profile.ts b/orchestrator/src/server/api/routes/profile.ts index 28263a5..10f478d 100644 --- a/orchestrator/src/server/api/routes/profile.ts +++ b/orchestrator/src/server/api/routes/profile.ts @@ -1,12 +1,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 { getSetting } from "@server/repositories/settings"; import { clearProfileCache, getProfile } from "@server/services/profile"; import { extractProjectsFromProfile } from "@server/services/resumeProjects"; -import { - getResume, - RxResumeCredentialsError, -} from "@server/services/rxresume-v4"; +import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume"; +import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId"; import { type Request, type Response, Router } from "express"; export const profileRouter = Router(); @@ -22,10 +21,9 @@ profileRouter.get("/projects", async (_req: Request, res: Response) => { } const profile = await getProfile(); const { catalog } = extractProjectsFromProfile(profile); - res.json({ success: true, data: catalog }); + ok(res, catalog); } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); + fail(res, toAppError(error)); } }); @@ -35,10 +33,9 @@ profileRouter.get("/projects", async (_req: Request, res: Response) => { profileRouter.get("/", async (_req: Request, res: Response) => { try { const profile = await getProfile(); - res.json({ success: true, data: profile }); + ok(res, profile); } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); + fail(res, toAppError(error)); } }); @@ -47,16 +44,14 @@ profileRouter.get("/", async (_req: Request, res: Response) => { */ profileRouter.get("/status", async (_req: Request, res: Response) => { try { - const rxresumeBaseResumeId = await getSetting("rxresumeBaseResumeId"); + const { resumeId: rxresumeBaseResumeId } = + await getConfiguredRxResumeBaseResumeId(); if (!rxresumeBaseResumeId) { - res.json({ - success: true, - data: { - exists: false, - error: - "No base resume selected. Please select a resume from your RxResume account in Settings.", - }, + ok(res, { + exists: false, + error: + "No base resume selected. Please select a resume from your Reactive Resume account in Settings.", }); return; } @@ -65,46 +60,36 @@ profileRouter.get("/status", async (_req: Request, res: Response) => { try { const resume = await getResume(rxresumeBaseResumeId); if (!resume.data || typeof resume.data !== "object") { - res.json({ - success: true, - data: { - exists: false, - error: "Selected resume is empty or invalid.", - }, + ok(res, { + exists: false, + error: "Selected resume is empty or invalid.", }); return; } - res.json({ success: true, data: { exists: true, error: null } }); + ok(res, { exists: true, error: null }); } catch (error) { - if (error instanceof RxResumeCredentialsError) { - res.json({ - success: true, - data: { - exists: false, - error: "RxResume credentials not configured.", - }, - }); + if (error instanceof RxResumeAuthConfigError) { + ok(res, { exists: false, error: error.message }); return; } throw error; } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - res.json({ success: true, data: { exists: false, error: message } }); + ok(res, { exists: false, error: message }); } }); /** - * POST /api/profile/refresh - Clear profile cache and refetch from RxResume v4 API + * POST /api/profile/refresh - Clear profile cache and refetch from Reactive Resume */ profileRouter.post("/refresh", async (_req: Request, res: Response) => { try { clearProfileCache(); const profile = await getProfile(true); - res.json({ success: true, data: profile }); + ok(res, profile); } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); + fail(res, toAppError(error)); } }); diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index b7723c9..76feaaf 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -1,5 +1,61 @@ import type { Server } from "node:http"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@server/services/rxresume", () => ({ + listResumes: vi.fn(), + getResume: vi.fn(), + validateResumeSchema: vi.fn(async (data: unknown) => ({ + ok: true, + mode: + data && + typeof data === "object" && + typeof (data as Record).summary === "object" + ? "v5" + : "v4", + data, + })), + extractProjectsFromResume: vi.fn((data: unknown) => { + const root = (data ?? {}) as Record; + const sections = (root.sections ?? {}) as Record; + const projects = (sections.projects ?? {}) as Record; + const items = Array.isArray(projects.items) ? projects.items : []; + return { + mode: "v5", + catalog: items.map((item) => { + const project = item as Record; + return { + id: String(project.id ?? ""), + name: String(project.name ?? ""), + description: String(project.description ?? ""), + date: String(project.period ?? ""), + isVisibleInBase: !project.hidden, + }; + }), + }; + }), + RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error { + constructor(message = "Reactive Resume auth config missing") { + super(message); + this.name = "RxResumeAuthConfigError"; + } + }, + RxResumeRequestError: class RxResumeRequestError extends Error { + status: number | null; + constructor( + message = "Reactive Resume request failed", + status: number | null = null, + ) { + super(message); + this.name = "RxResumeRequestError"; + this.status = status; + } + }, +})); + +import { + extractProjectsFromResume, + getResume, +} from "@server/services/rxresume"; import { startServer, stopServer } from "./test-utils"; describe.sequential("Settings API routes", () => { @@ -118,4 +174,70 @@ describe.sequential("Settings API routes", () => { expect(getBody.data.penalizeMissingSalary.value).toBe(true); expect(getBody.data.missingSalaryPenalty.value).toBe(20); }); + + it("preserves upstream 404 from Reactive Resume project lookup", async () => { + const { RxResumeRequestError } = await import("@server/services/rxresume"); + vi.mocked(getResume).mockRejectedValue( + new RxResumeRequestError( + "Reactive Resume API error (404): Resume not found", + 404, + ), + ); + + const res = await fetch( + `${baseUrl}/api/settings/rx-resumes/missing/projects`, + ); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("NOT_FOUND"); + expect(body.error.message).toContain("404"); + }); + + it("returns project catalog for v5-shaped Reactive Resume payloads", async () => { + vi.mocked(getResume).mockResolvedValue({ + id: "resume-v5", + name: "Resume v5", + mode: "v5", + data: { + sections: { + projects: { + title: "Projects", + columns: 1, + hidden: false, + items: [ + { + id: "p1", + hidden: false, + name: "JobOps", + period: "2024", + website: { url: "https://example.com", label: "Example" }, + description: "Project description", + }, + ], + }, + }, + summary: {}, + }, + } as any); + + const res = await fetch( + `${baseUrl}/api/settings/rx-resumes/resume-v5/projects?mode=v5`, + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.data.projects).toEqual([ + { + id: "p1", + name: "JobOps", + description: "Project description", + date: "2024", + isVisibleInBase: true, + }, + ]); + expect(extractProjectsFromResume).toHaveBeenCalled(); + }); }); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 27edb18..7e63d69 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -1,12 +1,22 @@ +import { + AppError, + badRequest, + serviceUnavailable, + statusToCode, + upstreamError, +} from "@infra/errors"; +import { asyncRoute, fail, ok } from "@infra/http"; import { logger } from "@infra/logger"; import { isDemoMode, sendDemoBlocked } from "@server/config/demo"; import { setBackupSettings } from "@server/services/backup/index"; -import { extractProjectsFromProfile } from "@server/services/resumeProjects"; import { + extractProjectsFromResume, getResume, listResumes, - RxResumeCredentialsError, -} from "@server/services/rxresume-v4"; + RxResumeAuthConfigError, + RxResumeRequestError, + validateResumeSchema, +} from "@server/services/rxresume"; import { getEffectiveSettings } from "@server/services/settings"; import { applySettingsUpdates } from "@server/services/settings-update"; import { updateSettingsSchema } from "@shared/settings-schema"; @@ -60,61 +70,106 @@ settingsRouter.patch("/", async (req: Request, res: Response) => { }); /** - * GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume v4 API + * GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume (v4/v5 adapter) */ -settingsRouter.get("/rx-resumes", async (_req: Request, res: Response) => { - try { - const resumes = await listResumes(); +function failRxResume(res: Response, error: unknown): void { + if (error instanceof RxResumeAuthConfigError) { + fail(res, badRequest(error.message)); + return; + } + if (error instanceof RxResumeRequestError) { + if (error.status === 401) { + fail( + res, + badRequest( + "Reactive Resume authentication failed. Check your configured mode credentials.", + ), + ); + return; + } + if (error.status && error.status >= 500) { + fail(res, upstreamError(error.message)); + return; + } + if (error.status && error.status >= 400 && error.status < 500) { + fail( + res, + new AppError({ + status: error.status, + code: statusToCode(error.status), + message: error.message, + }), + ); + return; + } + if (error.status === 0) { + fail( + res, + serviceUnavailable( + "Reactive Resume is unavailable. Check the URL and try again.", + ), + ); + return; + } + } + const message = error instanceof Error ? error.message : "Unknown error"; + logger.error("Reactive Resume route request failed", { message, error }); + fail(res, upstreamError(message)); +} - // Map to expected format (id, name) - res.json({ - success: true, - data: { +settingsRouter.get( + "/rx-resumes", + asyncRoute(async (req: Request, res: Response) => { + try { + const modeParam = + typeof req.query.mode === "string" ? req.query.mode : undefined; + const mode = + modeParam === "v4" || modeParam === "v5" ? modeParam : undefined; + const resumes = await listResumes({ mode }); + + ok(res, { resumes: resumes.map((resume) => ({ id: resume.id, name: resume.name, })), - }, - }); - } catch (error) { - if (error instanceof RxResumeCredentialsError) { - res.status(400).json({ success: false, error: error.message }); - return; + }); + } catch (error) { + failRxResume(res, error); } - const message = error instanceof Error ? error.message : "Unknown error"; - logger.error("Failed to fetch Reactive Resumes", { message }); - res.status(500).json({ success: false, error: message }); - } -}); + }), +); /** - * GET /api/settings/rx-resumes/:id/projects - Fetch project catalog from RxResume v4 + * GET /api/settings/rx-resumes/:id/projects - Fetch project catalog from Reactive Resume (v4/v5 adapter) */ settingsRouter.get( "/rx-resumes/:id/projects", - async (req: Request, res: Response) => { + asyncRoute(async (req: Request, res: Response) => { try { const resumeId = req.params.id; if (!resumeId) { - res - .status(400) - .json({ success: false, error: "Resume id is required." }); + fail(res, badRequest("Resume id is required.")); return; } - const resume = await getResume(resumeId); - const profile = resume.data ?? {}; - const { catalog } = extractProjectsFromProfile(profile); + const modeParam = + typeof req.query.mode === "string" ? req.query.mode : undefined; + const mode = + modeParam === "v4" || modeParam === "v5" ? modeParam : undefined; - res.json({ success: true, data: { projects: catalog } }); + const resume = await getResume(resumeId, { mode }); + const validated = await validateResumeSchema(resume.data ?? {}, { mode }); + if (!validated.ok) { + fail(res, badRequest(validated.message)); + return; + } + const { catalog } = extractProjectsFromResume(resume.data ?? {}, { + mode: validated.mode, + }); + + ok(res, { projects: catalog }); } catch (error) { - if (error instanceof RxResumeCredentialsError) { - res.status(400).json({ success: false, error: error.message }); - return; - } - const message = error instanceof Error ? error.message : "Unknown error"; - logger.error("Failed to fetch RxResume projects", { message }); - res.status(500).json({ success: false, error: message }); + failRxResume(res, error); } - }, + }), ); diff --git a/orchestrator/src/server/services/pdf-skills-validation.test.ts b/orchestrator/src/server/services/pdf-skills-validation.test.ts index 3f799f4..550d36b 100644 --- a/orchestrator/src/server/services/pdf-skills-validation.test.ts +++ b/orchestrator/src/server/services/pdf-skills-validation.test.ts @@ -5,7 +5,7 @@ import { getProfile } from "./profile"; process.env.DATA_DIR = "/tmp"; // Define mock data in hoisted block -const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { +const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => { const profile = { sections: { summary: { content: "Original Summary" }, @@ -29,25 +29,16 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { // Capture what's passed to create() let lastCreateData: any = null; - const mockClient = { - create: vi.fn().mockImplementation((data: any) => { + const mockRxResumeApi = { + importResume: vi.fn().mockImplementation((payload: any) => { + const data = payload?.data; lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone return Promise.resolve("mock-resume-id"); }), - print: vi.fn().mockResolvedValue("https://example.com/pdf/mock.pdf"), - delete: vi.fn().mockResolvedValue(undefined), - withAutoRefresh: vi + exportResumePdf: vi .fn() - .mockImplementation( - async ( - _email: string, - _password: string, - operation: (token: string) => Promise, - ) => { - return operation("mock-token"); - }, - ), - getToken: vi.fn().mockResolvedValue("mock-token"), + .mockResolvedValue("https://example.com/pdf/mock.pdf"), + deleteResume: vi.fn().mockResolvedValue(undefined), getLastCreateData: () => lastCreateData, clearLastCreateData: () => { lastCreateData = null; @@ -63,7 +54,7 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { access: vi.fn().mockResolvedValue(undefined), unlink: vi.fn().mockResolvedValue(undefined), }, - mockRxResumeClient: mockClient, + mockRxResume: mockRxResumeApi, }; }); @@ -161,13 +152,77 @@ vi.mock("./resumeProjects", () => ({ }), })); -// Mock the RxResumeClient -vi.mock("./rxresume-client", () => ({ - RxResumeClient: vi.fn().mockImplementation(function (this: any) { - return mockRxResumeClient; +vi.mock("./rxresume/baseResumeId", () => ({ + getConfiguredRxResumeBaseResumeId: vi.fn().mockResolvedValue({ + mode: "v4", + resumeId: "base-resume-id", }), })); +vi.mock("./rxresume", async () => { + const clone = (value: T): T => JSON.parse(JSON.stringify(value)); + const { createId } = await import("@paralleldrive/cuid2"); + const profileModule = await import("./profile"); + return { + getResume: vi.fn().mockImplementation(async () => ({ + id: "base-resume-id", + name: "Base Resume", + mode: "v4", + data: await profileModule.getProfile(), + })), + prepareTailoredResumeForPdf: vi + .fn() + .mockImplementation(async (args: any) => { + const data = clone(args.resumeData); + if ( + data.sections?.skills?.items && + Array.isArray(data.sections.skills.items) + ) { + data.sections.skills.items = data.sections.skills.items.map( + (skill: any) => ({ + ...skill, + id: skill.id || createId(), + visible: skill.visible ?? true, + description: skill.description ?? "", + level: skill.level ?? 1, + keywords: skill.keywords || [], + }), + ); + } + + if (args.tailoredContent?.skills && data.sections?.skills) { + const existingSkills = data.sections.skills.items || []; + data.sections.skills.items = args.tailoredContent.skills.map( + (newSkill: any) => { + const existing = existingSkills.find( + (s: any) => s.name === newSkill.name, + ); + return { + id: newSkill.id || existing?.id || createId(), + visible: newSkill.visible ?? existing?.visible ?? true, + name: newSkill.name || existing?.name || "", + description: + newSkill.description ?? existing?.description ?? "", + level: newSkill.level ?? existing?.level ?? 0, + keywords: newSkill.keywords || existing?.keywords || [], + }; + }, + ); + } + + return { + mode: "v4", + data, + projectCatalog: [], + selectedProjectIds: [], + }; + }), + importResume: mockRxResume.importResume, + exportResumePdf: mockRxResume.exportResumePdf, + deleteResume: mockRxResume.deleteResume, + }; +}); + // Mock stream pipeline for downloading PDF vi.mock("stream/promises", () => ({ pipeline: vi.fn().mockResolvedValue(undefined), @@ -227,7 +282,7 @@ describe("PDF Service Skills Validation", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getProfile).mockResolvedValue(mockProfile); - mockRxResumeClient.clearLastCreateData(); + mockRxResume.clearLastCreateData(); }); it("should add required schema fields (visible, description) to new skills", async () => { @@ -241,8 +296,8 @@ describe("PDF Service Skills Validation", () => { await generatePdf("job-skills-1", tailoredContent, "Job Desc"); - expect(mockRxResumeClient.create).toHaveBeenCalled(); - const savedResumeJson = mockRxResumeClient.getLastCreateData(); + expect(mockRxResume.importResume).toHaveBeenCalled(); + const savedResumeJson = mockRxResume.getLastCreateData(); const skillItems = savedResumeJson.sections.skills.items; @@ -297,8 +352,8 @@ describe("PDF Service Skills Validation", () => { // No tailoring, pass dummy path to bypass getProfile cache and use readFile mock await generatePdf("job-no-tailor", {}, "Job Desc", "dummy.json"); - expect(mockRxResumeClient.create).toHaveBeenCalled(); - const savedResumeJson = mockRxResumeClient.getLastCreateData(); + expect(mockRxResume.importResume).toHaveBeenCalled(); + const savedResumeJson = mockRxResume.getLastCreateData(); const item = savedResumeJson.sections.skills.items[0]; @@ -349,8 +404,8 @@ describe("PDF Service Skills Validation", () => { await generatePdf("job-cuid2-test", {}, "Job Desc", "dummy.json"); - expect(mockRxResumeClient.create).toHaveBeenCalled(); - const savedResumeJson = mockRxResumeClient.getLastCreateData(); + expect(mockRxResume.importResume).toHaveBeenCalled(); + const savedResumeJson = mockRxResume.getLastCreateData(); const skillItems = savedResumeJson.sections.skills.items; @@ -394,8 +449,8 @@ describe("PDF Service Skills Validation", () => { await generatePdf("job-no-skill-prefix", {}, "Job Desc", "dummy.json"); - expect(mockRxResumeClient.create).toHaveBeenCalled(); - const savedResumeJson = mockRxResumeClient.getLastCreateData(); + expect(mockRxResume.importResume).toHaveBeenCalled(); + const savedResumeJson = mockRxResume.getLastCreateData(); const skill = savedResumeJson.sections.skills.items[0]; @@ -430,8 +485,8 @@ describe("PDF Service Skills Validation", () => { await generatePdf("job-preserve-id", {}, "Job Desc", "dummy.json"); - expect(mockRxResumeClient.create).toHaveBeenCalled(); - const savedResumeJson = mockRxResumeClient.getLastCreateData(); + expect(mockRxResume.importResume).toHaveBeenCalled(); + const savedResumeJson = mockRxResume.getLastCreateData(); const skill = savedResumeJson.sections.skills.items[0]; diff --git a/orchestrator/src/server/services/pdf-tailoring.test.ts b/orchestrator/src/server/services/pdf-tailoring.test.ts index 2ecf038..9b4e88f 100644 --- a/orchestrator/src/server/services/pdf-tailoring.test.ts +++ b/orchestrator/src/server/services/pdf-tailoring.test.ts @@ -3,7 +3,7 @@ import { generatePdf } from "./pdf"; import * as projectSelection from "./projectSelection"; // Define mock data in hoisted block -const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { +const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => { const profile = { sections: { summary: { content: "Original Summary" }, @@ -22,25 +22,16 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { // Capture what's passed to create() let lastCreateData: any = null; - const mockClient = { - create: vi.fn().mockImplementation((data: any) => { + const mockRxResumeApi = { + importResume: vi.fn().mockImplementation((payload: any) => { + const data = payload?.data; lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone return Promise.resolve("mock-resume-id"); }), - print: vi.fn().mockResolvedValue("https://example.com/pdf/mock.pdf"), - delete: vi.fn().mockResolvedValue(undefined), - withAutoRefresh: vi + exportResumePdf: vi .fn() - .mockImplementation( - async ( - _email: string, - _password: string, - operation: (token: string) => Promise, - ) => { - return operation("mock-token"); - }, - ), - getToken: vi.fn().mockResolvedValue("mock-token"), + .mockResolvedValue("https://example.com/pdf/mock.pdf"), + deleteResume: vi.fn().mockResolvedValue(undefined), getLastCreateData: () => lastCreateData, clearLastCreateData: () => { lastCreateData = null; @@ -56,7 +47,7 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { access: vi.fn().mockResolvedValue(undefined), unlink: vi.fn().mockResolvedValue(undefined), }, - mockRxResumeClient: mockClient, + mockRxResume: mockRxResumeApi, }; }); @@ -159,13 +150,80 @@ vi.mock("./tracer-links", () => ({ rewriteResumeLinksWithTracer: mockTracerLinks.rewriteResumeLinksWithTracer, })); -// Mock the RxResumeClient -vi.mock("./rxresume-client", () => ({ - RxResumeClient: vi.fn().mockImplementation(function (this: any) { - return mockRxResumeClient; +vi.mock("./rxresume/baseResumeId", () => ({ + getConfiguredRxResumeBaseResumeId: vi.fn().mockResolvedValue({ + mode: "v4", + resumeId: "base-resume-id", }), })); +vi.mock("./rxresume", async () => { + const clone = (value: T): T => JSON.parse(JSON.stringify(value)); + const projectSelectionModule = await import("./projectSelection"); + return { + getResume: vi.fn().mockResolvedValue({ + id: "base-resume-id", + name: "Base Resume", + mode: "v4", + data: mockProfile, + }), + prepareTailoredResumeForPdf: vi + .fn() + .mockImplementation(async (args: any) => { + const data = clone(args.resumeData); + if (args.tailedContent?.summary || args.tailoredContent?.summary) { + const summary = args.tailoredContent?.summary; + if (data.sections?.summary) data.sections.summary.content = summary; + } + if (args.tailoredContent?.headline && data.basics) { + data.basics.headline = args.tailoredContent.headline; + } + + let selected = (args.selectedProjectIds as string | null | undefined) + ?.split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (!selected) { + selected = await projectSelectionModule.pickProjectIdsForJob({ + jobDescription: args.jobDescription, + eligibleProjects: [ + { id: "p1", name: "Project 1" }, + { id: "p2", name: "Project 2" }, + ], + desiredCount: 3, + } as any); + } + const selectedSet = new Set(selected); + for (const item of data.sections?.projects?.items ?? []) { + item.visible = selectedSet.has(item.id); + } + if (data.sections?.projects) data.sections.projects.visible = true; + + if (args.tracerLinks?.enabled) { + mockTracerLinks.resolveTracerPublicBaseUrl({ + requestOrigin: args.tracerLinks.requestOrigin, + }); + await mockTracerLinks.rewriteResumeLinksWithTracer({ + jobId: args.jobId, + resumeData: data, + publicBaseUrl: "https://jobops.example", + companyName: args.tracerLinks.companyName ?? null, + }); + } + + return { + mode: "v4", + data, + projectCatalog: [], + selectedProjectIds: [...selectedSet], + }; + }), + importResume: mockRxResume.importResume, + exportResumePdf: mockRxResume.exportResumePdf, + deleteResume: mockRxResume.deleteResume, + }; +}); + // Mock stream pipeline for downloading PDF vi.mock("stream/promises", () => ({ pipeline: vi.fn().mockResolvedValue(undefined), @@ -225,7 +283,7 @@ describe("PDF Service Tailoring Logic", () => { beforeEach(() => { vi.clearAllMocks(); mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); - mockRxResumeClient.clearLastCreateData(); + mockRxResume.clearLastCreateData(); mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue( "https://jobops.example", ); @@ -247,8 +305,8 @@ describe("PDF Service Tailoring Logic", () => { expect(projectSelection.pickProjectIdsForJob).not.toHaveBeenCalled(); // 2. Verify create data content - expect(mockRxResumeClient.create).toHaveBeenCalled(); - const savedResumeJson = mockRxResumeClient.getLastCreateData(); + expect(mockRxResume.importResume).toHaveBeenCalled(); + const savedResumeJson = mockRxResume.getLastCreateData(); const projects = savedResumeJson.sections.projects.items; const p1 = projects.find((p: any) => p.id === "p1"); @@ -265,8 +323,8 @@ describe("PDF Service Tailoring Logic", () => { it("should handle comma-separated project IDs correctly", async () => { await generatePdf("job-2", {}, "desc", "base.json", "p1, p2 "); - expect(mockRxResumeClient.create).toHaveBeenCalled(); - const savedResumeJson = mockRxResumeClient.getLastCreateData(); + expect(mockRxResume.importResume).toHaveBeenCalled(); + const savedResumeJson = mockRxResume.getLastCreateData(); const projects = savedResumeJson.sections.projects.items; expect(projects.find((p: any) => p.id === "p1").visible).toBe(true); @@ -276,8 +334,8 @@ describe("PDF Service Tailoring Logic", () => { it("keeps projects section visible when selected project list is explicitly empty", async () => { await generatePdf("job-empty-projects", {}, "desc", "base.json", ""); - expect(mockRxResumeClient.create).toHaveBeenCalled(); - const savedResumeJson = mockRxResumeClient.getLastCreateData(); + expect(mockRxResume.importResume).toHaveBeenCalled(); + const savedResumeJson = mockRxResume.getLastCreateData(); const projects = savedResumeJson.sections.projects.items; expect(projects.find((p: any) => p.id === "p1").visible).toBe(false); @@ -293,8 +351,8 @@ describe("PDF Service Tailoring Logic", () => { expect(projectSelection.pickProjectIdsForJob).toHaveBeenCalled(); - expect(mockRxResumeClient.create).toHaveBeenCalled(); - const savedResumeJson = mockRxResumeClient.getLastCreateData(); + expect(mockRxResume.importResume).toHaveBeenCalled(); + const savedResumeJson = mockRxResume.getLastCreateData(); const p1 = savedResumeJson.sections.projects.items.find( (p: any) => p.id === "p1", diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index bbb0823..725e08d 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -1,5 +1,5 @@ /** - * Service for generating PDF resumes using RxResume v4 API. + * Service for generating PDF resumes using Reactive Resume. */ import { createWriteStream, existsSync } from "node:fs"; @@ -7,20 +7,16 @@ import { access, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { createId } from "@paralleldrive/cuid2"; +import { logger } from "@infra/logger"; import { getDataDir } from "../config/dataDir"; -import { getSetting } from "../repositories/settings"; -import { getProfile } from "./profile"; -import { pickProjectIdsForJob } from "./projectSelection"; import { - extractProjectsFromProfile, - resolveResumeProjectsSettings, -} from "./resumeProjects"; -import { RxResumeClient } from "./rxresume-client"; -import { - resolveTracerPublicBaseUrl, - rewriteResumeLinksWithTracer, -} from "./tracer-links"; + deleteResume as deleteRemoteResume, + exportResumePdf, + getResume as getRxResume, + importResume as importRemoteResume, + prepareTailoredResumeForPdf, +} from "./rxresume"; +import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId"; const OUTPUT_DIR = join(getDataDir(), "pdfs"); @@ -42,36 +38,6 @@ export interface GeneratePdfOptions { tracerCompanyName?: string | null; } -/** - * Get RxResume credentials from environment variables or database settings. - */ -async function getCredentials(): Promise<{ - email: string; - password: string; - baseUrl: string; -}> { - // First check environment variables - let email = process.env.RXRESUME_EMAIL || ""; - let password = process.env.RXRESUME_PASSWORD || ""; - const baseUrl = process.env.RXRESUME_URL || "https://v4.rxresu.me"; - - // Fall back to database settings if env vars are not set - if (!email) { - email = (await getSetting("rxresumeEmail")) || ""; - } - if (!password) { - password = (await getSetting("rxresumePassword")) || ""; - } - - if (!email || !password) { - throw new Error( - "RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD environment variables or configure them in settings.", - ); - } - - return { email, password, baseUrl }; -} - /** * Download a file from a URL and save it to a local path. */ @@ -96,27 +62,24 @@ async function downloadFile(url: string, outputPath: string): Promise { } /** - * Generate a tailored PDF resume for a job using the RxResume v4 API. + * Generate a tailored PDF resume for a job using Reactive Resume. * * Flow: * 1. Prepare resume data with tailored content and project selection - * 2. Get auth token (uses cached token or logs in) - * 3. Import/create resume on RxResume - * 4. Request print to get PDF URL - * 5. Download PDF locally - * 6. Delete temporary resume from RxResume - * - * Token refresh is handled automatically on 401 errors. + * 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 */ export async function generatePdf( jobId: string, tailoredContent: TailoredPdfContent, jobDescription: string, - _baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API + _baseResumePath?: string, // Deprecated: now always uses configured Reactive Resume base resume selectedProjectIds?: string | null, options?: GeneratePdfOptions, ): Promise { - console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`); + logger.info("Generating PDF resume", { jobId }); try { // Ensure output directory exists @@ -124,220 +87,81 @@ export async function generatePdf( await mkdir(OUTPUT_DIR, { recursive: true }); } - // Get credentials and initialize client - const { email, password, baseUrl } = await getCredentials(); - const client = new RxResumeClient(baseUrl); - - // Read base resume from profile (fetches from v4 API if configured, force fetch) - const baseResume = JSON.parse(JSON.stringify(await getProfile(true))); - - // Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords) - // This fixes issues where the base JSON uses a shorthand format (missing required fields) - if ( - baseResume.sections?.skills?.items && - Array.isArray(baseResume.sections.skills.items) - ) { - baseResume.sections.skills.items = baseResume.sections.skills.items.map( - (skill: Record) => ({ - ...skill, - id: (skill.id as string) || createId(), - visible: (skill.visible as boolean | undefined) ?? true, - // Zod schema requires string, default to empty string if missing - description: (skill.description as string | undefined) ?? "", - level: (skill.level as number | undefined) ?? 1, - keywords: (skill.keywords as string[] | undefined) || [], - }), + 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.", ); } - - // Inject tailored summary - if (tailoredContent.summary) { - if (baseResume.sections?.summary) { - baseResume.sections.summary.content = tailoredContent.summary; - } else if (baseResume.basics?.summary) { - baseResume.basics.summary = tailoredContent.summary; - } + const baseResume = await getRxResume(baseResumeId); + if (!baseResume.data || typeof baseResume.data !== "object") { + throw new Error("Reactive Resume base resume is empty or invalid."); } - // Inject tailored headline - if (tailoredContent.headline) { - if (baseResume.basics) { - baseResume.basics.headline = tailoredContent.headline; - baseResume.basics.label = tailoredContent.headline; - } - } - - // Inject tailored skills - if (tailoredContent.skills) { - const newSkills = Array.isArray(tailoredContent.skills) - ? tailoredContent.skills - : typeof tailoredContent.skills === "string" - ? JSON.parse(tailoredContent.skills) - : null; - - if (newSkills && baseResume.sections?.skills) { - // Ensure each skill item has required schema fields - const existingSkills = (baseResume.sections.skills.items || - []) as Array>; - const skillsWithSchema = newSkills.map( - (newSkill: Record) => { - // Try to find matching existing skill to preserve id and other fields - const existing = existingSkills.find( - (s) => s.name === newSkill.name, - ); - - return { - id: - (newSkill.id as string) || - (existing?.id as string) || - createId(), - visible: - newSkill.visible !== undefined - ? (newSkill.visible as boolean) - : ((existing?.visible as boolean | undefined) ?? true), - name: - (newSkill.name as string) || (existing?.name as string) || "", - description: - newSkill.description !== undefined - ? (newSkill.description as string) - : (existing?.description as string) || "", - level: - newSkill.level !== undefined - ? (newSkill.level as number) - : ((existing?.level as number | undefined) ?? 0), - keywords: - (newSkill.keywords as string[]) || - (existing?.keywords as string[]) || - [], - }; - }, - ); - - baseResume.sections.skills.items = skillsWithSchema; - } - } - - // Select projects and set visibility + let preparedResumeData: Record; try { - let selectedSet: Set; - - if (selectedProjectIds !== null && selectedProjectIds !== undefined) { - selectedSet = new Set( - selectedProjectIds - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - ); - } else { - const { catalog, selectionItems } = - extractProjectsFromProfile(baseResume); - const overrideResumeProjectsRaw = await getSetting("resumeProjects"); - const { resumeProjects } = resolveResumeProjectsSettings({ - catalog, - overrideRaw: overrideResumeProjectsRaw, - }); - - const locked = resumeProjects.lockedProjectIds; - const desiredCount = Math.max( - 0, - resumeProjects.maxProjects - locked.length, - ); - const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds); - const eligibleProjects = selectionItems.filter((p) => - eligibleSet.has(p.id), - ); - - const picked = await pickProjectIdsForJob({ - jobDescription, - eligibleProjects, - desiredCount, - }); - - selectedSet = new Set([...locked, ...picked]); - } - - const projectsSection = baseResume.sections?.projects; - const projectItems = projectsSection?.items; - if (Array.isArray(projectItems)) { - for (const item of projectItems) { - if (!item || typeof item !== "object") continue; - const typedItem = item as Record; - const id = typeof typedItem.id === "string" ? typedItem.id : ""; - if (!id) continue; - typedItem.visible = selectedSet.has(id); - } - projectsSection.visible = true; - } - } catch (err) { - console.warn( - ` ⚠️ Project visibility step failed for job ${jobId}:`, - err, - ); - } - - if (options?.tracerLinksEnabled) { - const tracerBaseUrl = resolveTracerPublicBaseUrl({ - requestOrigin: options.requestOrigin, - }); - if (!tracerBaseUrl) { - throw new Error( - "Tracer links are enabled but no public base URL is available. Set JOBOPS_PUBLIC_BASE_URL.", - ); - } - - await rewriteResumeLinksWithTracer({ + const prepared = await prepareTailoredResumeForPdf({ + resumeData: baseResume.data, + mode: baseResume.mode, + tailoredContent, + jobDescription, + selectedProjectIds, jobId, - resumeData: baseResume, - publicBaseUrl: tracerBaseUrl, - companyName: options.tracerCompanyName ?? null, + tracerLinks: { + enabled: Boolean(options?.tracerLinksEnabled), + requestOrigin: options?.requestOrigin ?? null, + companyName: options?.tracerCompanyName ?? null, + }, }); + preparedResumeData = prepared.data; + } catch (err) { + logger.warn("Resume tailoring step failed during PDF generation", { + jobId, + error: err, + }); + throw err; } - // Use withAutoRefresh to handle token caching and 401 retry automatically const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`); + let resumeId: string | null = null; + try { + logger.debug("Uploading temporary resume for PDF generation", { jobId }); + resumeId = await importRemoteResume({ + data: preparedResumeData, + name: `JobOps Tailored Resume ${jobId}`, + slug: "", + }); - await client.withAutoRefresh(email, password, async (token) => { - let resumeId: string | null = null; + logger.debug("Requesting PDF export for temporary resume", { + jobId, + resumeId, + }); + const pdfUrl = await exportResumePdf(resumeId); - try { - // Create resume on RxResume - console.log(` 📤 Uploading resume to RxResume...`); - resumeId = await client.create(baseResume, token); - console.log(` ✅ Resume created with ID: ${resumeId}`); - - // Get PDF URL - console.log(` 🖨️ Requesting PDF generation...`); - const pdfUrl = await client.print(resumeId, token); - console.log(` ✅ PDF URL received: ${pdfUrl}`); - - // Download PDF - console.log(` 📥 Downloading PDF...`); - await downloadFile(pdfUrl, outputPath); - console.log(` ✅ PDF saved to: ${outputPath}`); - - // Cleanup: delete temporary resume from RxResume - console.log(` 🧹 Cleaning up temporary resume...`); - await client.delete(resumeId, token); - console.log(` ✅ Temporary resume deleted from RxResume`); - resumeId = null; - } finally { - // Attempt cleanup if resume was created but not deleted - if (resumeId) { - try { - console.log(` 🧹 Attempting cleanup of orphaned resume...`); - await client.delete(resumeId, token); - } catch { - console.warn(` ⚠️ Failed to cleanup orphaned resume ${resumeId}`); - } + logger.debug("Downloading generated PDF", { jobId, resumeId }); + await downloadFile(pdfUrl, outputPath); + await deleteRemoteResume(resumeId); + resumeId = null; + } finally { + if (resumeId) { + try { + await deleteRemoteResume(resumeId); + } catch (cleanupError) { + logger.warn("Failed to cleanup temporary Reactive Resume record", { + jobId, + resumeId, + error: cleanupError, + }); } } - }); + } - console.log(`✅ PDF generated successfully: ${outputPath}`); + logger.info("PDF generated successfully", { jobId, outputPath }); return { success: true, pdfPath: outputPath }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - console.error(`❌ PDF generation failed: ${message}`); + logger.error("PDF generation failed", { jobId, error }); return { success: false, error: message }; } } diff --git a/orchestrator/src/server/services/profile.test.ts b/orchestrator/src/server/services/profile.test.ts index 7be9111..d7b5b30 100644 --- a/orchestrator/src/server/services/profile.test.ts +++ b/orchestrator/src/server/services/profile.test.ts @@ -6,18 +6,18 @@ vi.mock("../repositories/settings", () => ({ getSetting: vi.fn(), })); -vi.mock("./rxresume-v4", () => ({ +vi.mock("./rxresume", () => ({ getResume: vi.fn(), - RxResumeCredentialsError: class RxResumeCredentialsError extends Error { + RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error { constructor() { - super("RxResume credentials not configured."); - this.name = "RxResumeCredentialsError"; + super("Reactive Resume credentials not configured."); + this.name = "RxResumeAuthConfigError"; } }, })); import { getSetting } from "../repositories/settings"; -import { getResume, RxResumeCredentialsError } from "./rxresume-v4"; +import { getResume, RxResumeAuthConfigError } from "./rxresume"; describe("getProfile", () => { beforeEach(() => { @@ -33,7 +33,7 @@ describe("getProfile", () => { ); }); - it("should fetch profile from RxResume v4 API when configured", async () => { + it("should fetch profile from Reactive Resume when configured", async () => { const mockResumeData = { basics: { name: "Test User" } }; vi.mocked(getSetting).mockResolvedValue("test-resume-id"); vi.mocked(getResume).mockResolvedValue({ @@ -43,6 +43,7 @@ describe("getProfile", () => { const profile = await getProfile(); + expect(getSetting).toHaveBeenCalledWith("rxresumeMode"); expect(getSetting).toHaveBeenCalledWith("rxresumeBaseResumeId"); expect(getResume).toHaveBeenCalledWith("test-resume-id"); expect(profile).toEqual(mockResumeData); @@ -59,8 +60,8 @@ describe("getProfile", () => { await getProfile(); await getProfile(); - // getSetting is called each time to check resumeId - expect(getSetting).toHaveBeenCalledTimes(2); + // The helper reads mode + legacy/per-mode resume-id settings each call. + expect(getSetting).toHaveBeenCalledTimes(8); // But getResume should only be called once due to caching expect(getResume).toHaveBeenCalledTimes(1); }); @@ -81,10 +82,12 @@ describe("getProfile", () => { it("should throw user-friendly error on credential issues", async () => { vi.mocked(getSetting).mockResolvedValue("test-resume-id"); - vi.mocked(getResume).mockRejectedValue(new RxResumeCredentialsError()); + vi.mocked(getResume).mockRejectedValue( + new (RxResumeAuthConfigError as unknown as new () => Error)(), + ); await expect(getProfile()).rejects.toThrow( - "RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.", + "Reactive Resume credentials not configured.", ); }); diff --git a/orchestrator/src/server/services/profile.ts b/orchestrator/src/server/services/profile.ts index 5ef436a..24e7048 100644 --- a/orchestrator/src/server/services/profile.ts +++ b/orchestrator/src/server/services/profile.ts @@ -1,19 +1,13 @@ -/** - * Profile service - fetches resume data from RxResume v4 API. - * - * The rxresumeBaseResumeId setting is REQUIRED for the app to function. - * There is no local file fallback. - */ - +import { logger } from "@infra/logger"; import type { ResumeProfile } from "@shared/types"; -import { getSetting } from "../repositories/settings"; -import { getResume, RxResumeCredentialsError } from "./rxresume-v4"; +import { getResume, RxResumeAuthConfigError } from "./rxresume"; +import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId"; let cachedProfile: ResumeProfile | null = null; let cachedResumeId: string | null = null; /** - * Get the base resume profile from RxResume v4 API. + * Get the base resume profile from RxResume. * * Requires rxresumeBaseResumeId to be configured in settings. * Results are cached until clearProfileCache() is called. @@ -22,7 +16,8 @@ let cachedResumeId: string | null = null; * @throws Error if rxresumeBaseResumeId is not configured or API call fails. */ export async function getProfile(forceRefresh = false): Promise { - const rxresumeBaseResumeId = await getSetting("rxresumeBaseResumeId"); + const { resumeId: rxresumeBaseResumeId } = + await getConfiguredRxResumeBaseResumeId(); if (!rxresumeBaseResumeId) { throw new Error( @@ -40,9 +35,9 @@ export async function getProfile(forceRefresh = false): Promise { } try { - console.log( - `📋 Fetching profile from RxResume v4 API (resume: ${rxresumeBaseResumeId})...`, - ); + logger.info("Fetching profile from Reactive Resume", { + resumeId: rxresumeBaseResumeId, + }); const resume = await getResume(rxresumeBaseResumeId); if (!resume.data || typeof resume.data !== "object") { @@ -51,15 +46,18 @@ export async function getProfile(forceRefresh = false): Promise { cachedProfile = resume.data as unknown as ResumeProfile; cachedResumeId = rxresumeBaseResumeId; - console.log(`✅ Profile loaded from RxResume v4 API`); + logger.info("Profile loaded from Reactive Resume", { + resumeId: rxresumeBaseResumeId, + }); return cachedProfile; } catch (error) { - if (error instanceof RxResumeCredentialsError) { - throw new Error( - "RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.", - ); + if (error instanceof RxResumeAuthConfigError) { + throw new Error(error.message); } - console.error(`❌ Failed to load profile from RxResume v4 API:`, error); + logger.error("Failed to load profile from Reactive Resume", { + resumeId: rxresumeBaseResumeId, + error, + }); throw error; } } diff --git a/orchestrator/src/server/services/rxresume-v5.ts b/orchestrator/src/server/services/rxresume-v5.ts deleted file mode 100644 index 95ad188..0000000 --- a/orchestrator/src/server/services/rxresume-v5.ts +++ /dev/null @@ -1,197 +0,0 @@ -// rxresume-v5.ts -// Future-facing v5/OpenAPI implementation that uses API keys. -// - Kept alongside v4 files so we can swap imports when v5 is ready. -// - Uses RXRESUME_API_KEY and /api/openapi endpoints. -// -// NOTE: Not currently wired in; keep for migration. - -import { resumeDataSchema } from "@shared/rxresume-schema"; - -export interface RxResumeResponse { - id: string; - name: string; - slug: string; - data: unknown; - [key: string]: unknown; -} - -/** - * Temporary helper to execute a fetch request with multiple API keys if in development. - * THIS FUNCTION IS TEMPORARY AND WILL BE REMOVED. - */ - -// Cache for last working key index (temporary, part of dev-only logic) -let lastWorkingKeyIndex = 0; - -async function executeWithKeyRetries( - url: string, - options: RequestInit, -): Promise { - const rawApiKey = process.env.RXRESUME_API_KEY; - if (!rawApiKey) { - throw new Error("RXRESUME_API_KEY not configured in environment"); - } - - const isDev = process.env.NODE_ENV !== "production"; - const apiKeys = - isDev && rawApiKey.includes(",") - ? rawApiKey.split(",").map((k) => k.trim()) - : [rawApiKey]; - - // Start from the last working key index - for (let attempt = 0; attempt < apiKeys.length; attempt++) { - const i = (lastWorkingKeyIndex + attempt) % apiKeys.length; - const apiKey = apiKeys[i]; - const headers = { - "x-api-key": apiKey, - ...(options.body ? { "Content-Type": "application/json" } : {}), - ...(options.headers || {}), - } as Record; - - const response = await fetch(url, { - ...options, - headers, - }); - - if (!response.ok) { - const errorData = (await response - .json() - .catch(() => ({ message: response.statusText }))) as { - message?: string; - }; - const errorMsg = `Reactive Resume API error (${response.status}): ${errorData.message || response.statusText}`; - - // ONLY retry/rotation on 401 Unauthorized - if ( - response.status === 401 && - apiKeys.length > 1 && - attempt < apiKeys.length - 1 - ) { - console.warn( - `[RxResume SDK] Key index ${i} was Unauthorized, trying next key...`, - ); - continue; - } - - throw new Error(errorMsg); - } - - // Success! Cache this key index for future requests - lastWorkingKeyIndex = i; - - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return response.json(); - } - return response.text(); - } - - // Unmissable error block if all keys fail - if (apiKeys.length > 1) { - console.error(` -################################################################################ -# # -# ❌ ALL REACTIVE RESUME API KEYS FAILED (${apiKeys.length} keys attempted) # -# Please check your .env configuration. # -# # -################################################################################ -`); - } - - throw new Error("All Reactive Resume API keys failed."); -} - -/** - * Generic fetch helper for Reactive Resume API - */ -export async function fetchRxResume( - path: string, - options: RequestInit = {}, -): Promise { - const baseUrl = process.env.RXRESUME_URL || "https://rxresu.me"; - let cleanBaseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; - - // Handle cases where the base URL already includes /api or /api/openapi - if (cleanBaseUrl.endsWith("/api/openapi")) { - cleanBaseUrl = cleanBaseUrl.slice(0, -12); - } else if (cleanBaseUrl.endsWith("/api")) { - cleanBaseUrl = cleanBaseUrl.slice(0, -4); - } - - const url = `${cleanBaseUrl}/api/openapi${path}`; - return executeWithKeyRetries(url, options); -} - -/** - * Fetch a resume by its ID. - */ -export async function getResume(id: string): Promise { - return (await fetchRxResume(`/resume/${id}`)) as RxResumeResponse; -} - -/** - * Import a resume. - */ -export async function importResume(payload: { - name: string; - slug: string; - data: unknown; -}): Promise { - // Validate data against schema before sending - try { - payload.data = resumeDataSchema.parse(payload.data); - } catch (error) { - console.error("❌ Resume data validation failed:", error); - throw error; - } - - // DEBUG: Save payload to file for debugging (temporary) - try { - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - const debugDir = path.join(process.cwd(), "debug"); - await fs.mkdir(debugDir, { recursive: true }); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const filename = path.join(debugDir, `rxresume-import-${timestamp}.json`); - await fs.writeFile(filename, JSON.stringify(payload, null, 2), "utf-8"); - console.log(`📝 DEBUG: Saved import payload to ${filename}`); - } catch (debugErr) { - console.warn("⚠️ Could not save debug file:", debugErr); - } - - const result = (await fetchRxResume("/resume/import", { - method: "POST", - body: JSON.stringify(payload), - })) as { id: string } | string; - - // Reactive Resume returns the full resume object on import in v4+, or just ID in v5. - return typeof result === "string" ? result : result.id; -} - -/** - * Delete a resume. - */ -export async function deleteResume(id: string): Promise { - await fetchRxResume(`/resume/${id}`, { method: "DELETE" }); -} - -/** - * Export a resume as PDF. Returns the URL. - */ -export async function exportResumePdf(id: string): Promise { - const result = (await fetchRxResume(`/printer/resume/${id}/pdf`)) as { - url: string; - }; - return result.url; -} - -/** - * List all resumes. - * According to official OpenAPI spec, the endpoint is /resume/list - */ -export async function listResumes(): Promise<{ id: string; name: string }[]> { - return (await fetchRxResume("/resume/list")) as { - id: string; - name: string; - }[]; -} diff --git a/orchestrator/src/server/services/rxresume/baseResumeId.ts b/orchestrator/src/server/services/rxresume/baseResumeId.ts new file mode 100644 index 0000000..11639fd --- /dev/null +++ b/orchestrator/src/server/services/rxresume/baseResumeId.ts @@ -0,0 +1,64 @@ +import type { SettingKey } from "@server/repositories/settings"; +import { getSetting } from "@server/repositories/settings"; +import type { RxResumeMode } from "@shared/types"; + +type BaseResumeIdSettings = Partial< + Record< + | "rxresumeMode" + | "rxresumeBaseResumeId" + | "rxresumeBaseResumeIdV4" + | "rxresumeBaseResumeIdV5", + string | null + > +>; + +export function normalizeRxResumeMode( + raw: string | null | undefined, +): RxResumeMode { + return raw === "v4" ? "v4" : "v5"; +} + +export function getRxResumeBaseResumeIdKey( + mode: RxResumeMode, +): Extract { + return mode === "v4" ? "rxresumeBaseResumeIdV4" : "rxresumeBaseResumeIdV5"; +} + +export function resolveRxResumeBaseResumeIdForMode( + settings: BaseResumeIdSettings, + explicitMode?: RxResumeMode, +): string | null { + const mode = explicitMode ?? normalizeRxResumeMode(settings.rxresumeMode); + const modeSpecific = + mode === "v4" + ? settings.rxresumeBaseResumeIdV4 + : settings.rxresumeBaseResumeIdV5; + return modeSpecific?.trim() || settings.rxresumeBaseResumeId?.trim() || null; +} + +export async function getConfiguredRxResumeBaseResumeId(): Promise<{ + mode: RxResumeMode; + resumeId: string | null; +}> { + const [modeRaw, legacyId, v4Id, v5Id] = await Promise.all([ + getSetting("rxresumeMode"), + getSetting("rxresumeBaseResumeId"), + getSetting("rxresumeBaseResumeIdV4"), + getSetting("rxresumeBaseResumeIdV5"), + ]); + const mode = normalizeRxResumeMode( + modeRaw ?? process.env.RXRESUME_MODE ?? null, + ); + return { + mode, + resumeId: resolveRxResumeBaseResumeIdForMode( + { + rxresumeMode: modeRaw, + rxresumeBaseResumeId: legacyId, + rxresumeBaseResumeIdV4: v4Id, + rxresumeBaseResumeIdV5: v5Id, + }, + mode, + ), + }; +} diff --git a/orchestrator/src/server/services/rxresume-client.test.ts b/orchestrator/src/server/services/rxresume/client.test.ts similarity index 99% rename from orchestrator/src/server/services/rxresume-client.test.ts rename to orchestrator/src/server/services/rxresume/client.test.ts index 4494eaa..0ebc348 100644 --- a/orchestrator/src/server/services/rxresume-client.test.ts +++ b/orchestrator/src/server/services/rxresume/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { RxResumeClient } from "./rxresume-client"; +import { RxResumeClient } from "./client"; describe("RxResumeClient", () => { describe("verifyCredentials (static)", () => { diff --git a/orchestrator/src/server/services/rxresume-client.ts b/orchestrator/src/server/services/rxresume/client.ts similarity index 98% rename from orchestrator/src/server/services/rxresume-client.ts rename to orchestrator/src/server/services/rxresume/client.ts index 40b559f..e40c8f7 100644 --- a/orchestrator/src/server/services/rxresume-client.ts +++ b/orchestrator/src/server/services/rxresume/client.ts @@ -1,11 +1,11 @@ -// rxresume-client.ts +// rxresume/client.ts // Low-level HTTP client for the RxResume v4 API. // - Handles login, token caching, and cookie-based auth. -// - Used by rxresume-v4.ts to provide a higher-level service surface. +// - Used by rxresume/v4.ts to provide a higher-level service surface. // - The v5 client should be a drop-in replacement in the future. -import type { ResumeData } from "@shared/rxresume-schema"; import { normalizeWhitespace } from "@shared/utils/string"; +import type { ResumeData } from "./schema/v4"; type AnyObj = Record; const MAX_ERROR_SNIPPET = 300; diff --git a/orchestrator/src/server/services/rxresume/index.test.ts b/orchestrator/src/server/services/rxresume/index.test.ts new file mode 100644 index 0000000..38b2e9b --- /dev/null +++ b/orchestrator/src/server/services/rxresume/index.test.ts @@ -0,0 +1,344 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@server/repositories/settings", () => ({ + getSetting: vi.fn(), +})); + +vi.mock("./v4", () => ({ + listResumes: vi.fn(), + getResume: vi.fn(), + importResume: vi.fn(), + deleteResume: vi.fn(), + exportResumePdf: vi.fn(), + RxResumeCredentialsError: class RxResumeCredentialsError extends Error {}, +})); + +vi.mock("./v5", () => ({ + listResumes: vi.fn(), + getResume: vi.fn(), + importResume: vi.fn(), + deleteResume: vi.fn(), + exportResumePdf: vi.fn(), + verifyApiKey: vi.fn(), +})); + +vi.mock("./client", () => ({ + RxResumeClient: { + verifyCredentials: vi.fn(), + }, +})); + +import { getSetting } from "@server/repositories/settings"; +import { RxResumeClient } from "./client"; +import { + extractProjectsFromResume, + getResume as getResumeFromAdapter, + listResumes, + prepareTailoredResumeForPdf, + RxResumeAuthConfigError, + resolveRxResumeMode, + validateCredentials, +} from "./index"; +import * as v4 from "./v4"; +import * as v5 from "./v5"; + +type SettingMap = Partial>; + +function mockSettings(map: SettingMap): void { + vi.mocked(getSetting).mockImplementation( + async (key: string) => map[key] ?? null, + ); +} + +describe("rxresume adapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.RXRESUME_API_KEY; + delete process.env.RXRESUME_EMAIL; + delete process.env.RXRESUME_PASSWORD; + delete process.env.RXRESUME_MODE; + mockSettings({}); + }); + + it("throws targeted error when explicit v5 is selected without api key", async () => { + mockSettings({ rxresumeMode: "v5" }); + + await expect(resolveRxResumeMode()).rejects.toBeInstanceOf( + RxResumeAuthConfigError, + ); + await expect(resolveRxResumeMode()).rejects.toThrow(/v5 API key/i); + }); + + it("routes listResumes through v5 and normalizes title when v5 is selected", async () => { + mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" }); + vi.mocked(v5.listResumes).mockResolvedValue([ + { + id: "r1", + name: "Resume One", + slug: "resume-one", + tags: [], + isPublic: false, + isLocked: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "r2", + name: "Resume Two", + slug: "resume-two", + tags: [], + isPublic: false, + isLocked: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ]); + + const result = await listResumes(); + + expect(v5.listResumes).toHaveBeenCalledWith({ + apiKey: "v5-key", + baseUrl: "https://rxresu.me", + }); + expect(v4.listResumes).not.toHaveBeenCalled(); + expect(result).toMatchObject([ + { id: "r1", name: "Resume One", title: "Resume One" }, + { id: "r2", name: "Resume Two", title: "Resume Two" }, + ]); + }); + it("does not fall back to v4 at runtime when explicit v5 fails", async () => { + mockSettings({ + rxresumeMode: "v5", + rxresumeApiKey: "stale-v5-key", + rxresumeEmail: "user@example.com", + rxresumePassword: "pw", + }); + vi.mocked(v5.listResumes).mockRejectedValue( + new Error("Reactive Resume API error (401): Unauthorized"), + ); + + await expect(listResumes()).rejects.toThrow(/401/i); + expect(v5.listResumes).toHaveBeenCalledTimes(1); + expect(v4.listResumes).not.toHaveBeenCalled(); + }); + + it("does not fall back to v4 getResume when explicit v5 fails", async () => { + mockSettings({ + rxresumeMode: "v5", + rxresumeApiKey: "v5-key", + rxresumeEmail: "user@example.com", + rxresumePassword: "pw", + }); + vi.mocked(v5.getResume).mockRejectedValue( + new Error("Reactive Resume API error (404): Resume not found"), + ); + + await expect(getResumeFromAdapter("legacy-1")).rejects.toThrow(/404/i); + expect(v5.getResume).toHaveBeenCalledTimes(1); + expect(v4.getResume).not.toHaveBeenCalled(); + }); + + it("validates explicit v4 credentials", async () => { + mockSettings({ + rxresumeMode: "v4", + rxresumeEmail: "user@example.com", + rxresumePassword: "pw", + }); + vi.mocked(RxResumeClient.verifyCredentials).mockResolvedValue({ ok: true }); + + const result = await validateCredentials(); + + expect(RxResumeClient.verifyCredentials).toHaveBeenCalledWith( + "user@example.com", + "pw", + "https://v4.rxresu.me", + ); + expect(v5.verifyApiKey).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: true, mode: "v4" }); + }); + + it("does not fall back to v4 validation when explicit v5 validation fails", async () => { + mockSettings({ + rxresumeMode: "v5", + rxresumeApiKey: "stale-v5-key", + rxresumeEmail: "user@example.com", + rxresumePassword: "pw", + }); + vi.mocked(v5.verifyApiKey).mockResolvedValue({ + ok: false, + status: 401, + message: "Reactive Resume API error (401): Unauthorized", + }); + vi.mocked(RxResumeClient.verifyCredentials).mockResolvedValue({ ok: true }); + + const result = await validateCredentials(); + + expect(v5.verifyApiKey).toHaveBeenCalledTimes(1); + expect(RxResumeClient.verifyCredentials).not.toHaveBeenCalled(); + expect(result).toEqual({ + ok: false, + mode: "v5", + status: 401, + message: "Reactive Resume API error (401): Unauthorized", + }); + }); + + it("prepares tailored v5 resume payload without relying on v4 fields", async () => { + const v5ResumeData = { + basics: { + name: "Test User", + headline: "Old headline", + email: "test@example.com", + phone: "", + location: "", + website: { url: "https://example.com", label: "Portfolio" }, + customFields: [], + }, + picture: {}, + summary: { + title: "Summary", + columns: 1, + hidden: false, + content: "Old summary", + }, + sections: { + projects: { + title: "Projects", + columns: 1, + hidden: false, + items: [ + { + id: "p1", + hidden: false, + name: "Visible project", + period: "2024", + website: { url: "https://p1.example.com", label: "P1" }, + description: "Alpha", + }, + { + id: "p2", + hidden: false, + name: "Hidden project", + period: "2023", + website: { url: "https://p2.example.com", label: "P2" }, + description: "Beta", + }, + ], + }, + skills: { + title: "Skills", + columns: 1, + hidden: false, + items: [ + { + id: "skill1", + hidden: false, + icon: "", + name: "Existing", + proficiency: "", + level: 0, + keywords: ["x"], + }, + ], + }, + }, + customSections: [], + metadata: {}, + }; + + const prepared = await prepareTailoredResumeForPdf({ + mode: "v5", + resumeData: v5ResumeData, + tailoredContent: { + headline: "New headline", + summary: "New summary", + skills: [{ name: "Frontend", keywords: ["React", "TS"] }], + }, + jobDescription: "Test JD", + selectedProjectIds: "p1", + }); + + expect(prepared.mode).toBe("v5"); + expect(prepared.selectedProjectIds).toEqual(["p1"]); + expect(prepared.projectCatalog).toMatchObject([ + { id: "p1", date: "2024", isVisibleInBase: true }, + { id: "p2", date: "2023", isVisibleInBase: true }, + ]); + + const data = prepared.data as any; + expect(data.basics.headline).toBe("New headline"); + expect(data.summary.content).toBe("New summary"); + expect(data.sections.projects.hidden).toBe(false); + expect(data.sections.projects.items[0].hidden).toBe(false); + expect(data.sections.projects.items[1].hidden).toBe(true); + expect(data.sections.skills.items[0].name).toBe("Frontend"); + expect(data.sections.skills.items[0].keywords).toEqual(["React", "TS"]); + }); + + it("extracts project catalog from v5 payloads", () => { + const result = extractProjectsFromResume({ + basics: { + name: "", + headline: "", + email: "", + phone: "", + location: "", + website: { url: "", label: "" }, + customFields: [], + }, + picture: {}, + summary: { + title: "Summary", + columns: 1, + hidden: false, + content: "", + }, + sections: { + projects: { + title: "Projects", + columns: 1, + hidden: false, + items: [ + { + id: "proj-1", + hidden: true, + name: "API", + period: "2025", + website: { url: "https://example.com", label: "Site" }, + description: "Built API", + }, + ], + }, + skills: { + title: "Skills", + columns: 1, + hidden: false, + items: [ + { + id: "skill-1", + hidden: false, + icon: "", + name: "Frontend", + proficiency: "", + level: 0, + keywords: ["React"], + }, + ], + }, + }, + customSections: [], + metadata: {}, + }); + + expect(result.mode).toBe("v5"); + expect(result.catalog).toEqual([ + { + id: "proj-1", + name: "API", + description: "Built API", + date: "2025", + isVisibleInBase: false, + }, + ]); + }); +}); diff --git a/orchestrator/src/server/services/rxresume/index.ts b/orchestrator/src/server/services/rxresume/index.ts new file mode 100644 index 0000000..8fb5e27 --- /dev/null +++ b/orchestrator/src/server/services/rxresume/index.ts @@ -0,0 +1,612 @@ +import { getSetting } from "@server/repositories/settings"; +import { pickProjectIdsForJob } from "@server/services/projectSelection"; +import { resolveResumeProjectsSettings } from "@server/services/resumeProjects"; +import { + resolveTracerPublicBaseUrl, + rewriteResumeLinksWithTracer, +} from "@server/services/tracer-links"; +import { settingsRegistry } from "@shared/settings-registry"; +import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types"; +import { RxResumeClient } from "./client"; +import { + getResumeSchemaValidationMessage, + safeParseResumeDataForMode, +} from "./schema"; +import { + applyProjectVisibility, + applyTailoredChunks, + cloneResumeData, + extractProjectsFromResume as extractProjectsFromResumeByMode, + inferRxResumeModeFromData, + type TailoredSkillsInput, + validateAndParseResumeDataForMode, +} from "./tailoring"; +import * as v4 from "./v4"; +import * as v5 from "./v5"; + +export type RxResumeResolvedMode = "v4" | "v5"; + +export type RxResumeResume = { + id: string; + name: string; + title?: string; + slug?: string; + mode?: RxResumeResolvedMode; + data?: unknown; + [key: string]: unknown; +}; + +export type RxResumeImportPayload = { + name?: string; + slug?: string; + data: unknown; +}; + +export type PreparedRxResumePdfPayload = { + mode: RxResumeResolvedMode; + data: Record; + projectCatalog: ResumeProjectCatalogItem[]; + selectedProjectIds: string[]; +}; + +export class RxResumeAuthConfigError extends Error { + constructor( + public readonly mode: RxResumeMode | RxResumeResolvedMode, + message: string, + ) { + super(message); + this.name = "RxResumeAuthConfigError"; + } +} + +export class RxResumeRequestError extends Error { + constructor( + message: string, + public readonly status: number | null = null, + ) { + super(message); + this.name = "RxResumeRequestError"; + } +} + +type ResolveModeOptions = { + mode?: RxResumeMode; + v4?: { + email?: string | null; + password?: string | null; + baseUrl?: string | null; + }; + v5?: { apiKey?: string | null; baseUrl?: string | null }; +}; + +type V4Credentials = Awaited>; +type V5Credentials = Awaited>; + +function toV4Override( + input?: ResolveModeOptions["v4"], +): Partial | undefined { + if (!input) return undefined; + return { + ...(typeof input.email === "string" ? { email: input.email } : {}), + ...(typeof input.password === "string" ? { password: input.password } : {}), + ...(typeof input.baseUrl === "string" ? { baseUrl: input.baseUrl } : {}), + }; +} + +function normalizeMode(raw: string | null | undefined): RxResumeMode { + const parsed = settingsRegistry.rxresumeMode.parse(raw ?? undefined); + return parsed ?? "v5"; +} + +function normalizeError(error: unknown): Error { + if ( + error instanceof RxResumeAuthConfigError || + error instanceof RxResumeRequestError + ) { + return error; + } + if (error instanceof v4.RxResumeCredentialsError) { + return new RxResumeAuthConfigError( + "v4", + "Reactive Resume v4 credentials are not configured.", + ); + } + if (error instanceof Error) { + const match = /Reactive Resume API error \((\d+)\)/i.exec(error.message); + const isNetworkLikeFailure = + error.name === "AbortError" || + (error instanceof TypeError && + /fetch failed|network/i.test(error.message || "")); + return new RxResumeRequestError( + error.message, + match ? Number(match[1]) : isNetworkLikeFailure ? 0 : null, + ); + } + return new RxResumeRequestError("Reactive Resume request failed."); +} + +function normalizeV5ResumeListResponse(payload: unknown): RxResumeResume[] { + if (!Array.isArray(payload)) { + throw new RxResumeRequestError( + "Reactive Resume v5 returned an unexpected resume list response shape.", + ); + } + + return payload.map((resume) => { + if (!resume || typeof resume !== "object") { + throw new RxResumeRequestError( + "Reactive Resume v5 returned an invalid resume list item.", + ); + } + const item = resume as Record; + const id = typeof item.id === "string" ? item.id : String(item.id ?? ""); + const name = + typeof item.name === "string" && item.name.trim() + ? item.name + : typeof item.title === "string" && item.title.trim() + ? item.title + : id; + + return { + ...item, + id, + name, + title: name, + } as RxResumeResume; + }); +} + +function normalizeV5ResumeResponse(payload: unknown): Record { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new RxResumeRequestError( + "Reactive Resume v5 returned an unexpected resume response shape.", + ); + } + + return payload as Record; +} + +async function readConfiguredMode(): Promise { + const [storedMode] = await Promise.all([getSetting("rxresumeMode")]); + return normalizeMode(storedMode ?? process.env.RXRESUME_MODE ?? null); +} + +async function readV4Credentials(overrides?: ResolveModeOptions["v4"]) { + const [storedEmail, storedPassword] = await Promise.all([ + getSetting("rxresumeEmail"), + getSetting("rxresumePassword"), + ]); + const email = + overrides?.email?.trim() || + process.env.RXRESUME_EMAIL?.trim() || + storedEmail?.trim() || + ""; + const password = + overrides?.password?.trim() || + process.env.RXRESUME_PASSWORD?.trim() || + storedPassword?.trim() || + ""; + const baseUrl = + overrides?.baseUrl?.trim() || + process.env.RXRESUME_URL?.trim() || + "https://v4.rxresu.me"; + return { email, password, baseUrl, available: Boolean(email && password) }; +} + +async function readV5Credentials(overrides?: ResolveModeOptions["v5"]) { + const [storedApiKey] = await Promise.all([getSetting("rxresumeApiKey")]); + const apiKey = + overrides?.apiKey?.trim() || + process.env.RXRESUME_API_KEY?.trim() || + storedApiKey?.trim() || + ""; + const baseUrl = + overrides?.baseUrl?.trim() || + process.env.RXRESUME_URL?.trim() || + "https://rxresu.me"; + return { apiKey, baseUrl, available: Boolean(apiKey) }; +} + +export async function resolveRxResumeMode( + options: ResolveModeOptions = {}, +): Promise { + const mode = options.mode ?? (await readConfiguredMode()); + const [v5Creds, v4Creds] = await Promise.all([ + readV5Credentials(options.v5), + readV4Credentials(options.v4), + ]); + + if (mode === "v5") { + if (!v5Creds.available) { + throw new RxResumeAuthConfigError( + "v5", + "Reactive Resume v5 API key is not configured. Set RXRESUME_API_KEY or configure rxresumeApiKey in Settings.", + ); + } + return "v5"; + } + + if (mode === "v4") { + if (!v4Creds.available) { + throw new RxResumeAuthConfigError( + "v4", + "Reactive Resume v4 credentials are not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD or configure them in Settings.", + ); + } + return "v4"; + } + throw new RxResumeAuthConfigError( + mode, + "Reactive Resume mode must be set to v4 or v5.", + ); +} + +async function runRxResumeOperation( + options: ResolveModeOptions, + handlers: { + v4: (creds: V4Credentials) => Promise; + v5: (creds: V5Credentials) => Promise; + }, +): Promise { + const requestedMode = options.mode ?? (await readConfiguredMode()); + const [v5Creds, v4Creds] = await Promise.all([ + readV5Credentials(options.v5), + readV4Credentials(options.v4), + ]); + + if (requestedMode === "v5") { + if (!v5Creds.available) { + throw new RxResumeAuthConfigError( + "v5", + "Reactive Resume v5 API key is not configured. Set RXRESUME_API_KEY or configure rxresumeApiKey in Settings.", + ); + } + try { + return await handlers.v5(v5Creds); + } catch (error) { + throw normalizeError(error); + } + } + + if (!v4Creds.available) { + throw new RxResumeAuthConfigError( + "v4", + "Reactive Resume v4 credentials are not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD or configure them in Settings.", + ); + } + try { + return await handlers.v4(v4Creds); + } catch (error) { + throw normalizeError(error); + } +} + +export async function listResumes( + options: ResolveModeOptions = {}, +): Promise { + return runRxResumeOperation(options, { + v5: async (creds) => + normalizeV5ResumeListResponse( + await v5.listResumes({ apiKey: creds.apiKey, baseUrl: creds.baseUrl }), + ), + v4: async (creds) => + (await v4.listResumes(toV4Override(creds))) as RxResumeResume[], + }); +} + +export async function getResume( + resumeId: string, + options: ResolveModeOptions = {}, +): Promise { + return runRxResumeOperation(options, { + v5: async (creds) => { + const resume = normalizeV5ResumeResponse( + await v5.getResume(resumeId, { + apiKey: creds.apiKey, + baseUrl: creds.baseUrl, + }), + ) as RxResumeResume; + return { + ...resume, + mode: "v5", + title: + typeof resume.name === "string" && resume.name.trim() + ? resume.name + : (resume.slug ?? resume.id), + data: resume.data, + } as RxResumeResume; + }, + v4: async (creds) => ({ + ...((await v4.getResume( + resumeId, + toV4Override(creds), + )) as RxResumeResume), + mode: "v4", + }), + }); +} + +export async function validateResumeSchema( + resumeData: unknown, + options: ResolveModeOptions = {}, +): Promise< + | { ok: true; mode: RxResumeResolvedMode; data: Record } + | { ok: false; mode: RxResumeResolvedMode; message: string } +> { + const mode = await resolveRxResumeMode(options); + const result = safeParseResumeDataForMode(mode, resumeData); + if (!result.success) { + return { + ok: false, + mode, + message: getResumeSchemaValidationMessage(result.error), + }; + } + + if ( + !result.data || + typeof result.data !== "object" || + Array.isArray(result.data) + ) { + return { + ok: false, + mode, + message: + "Resume schema validation failed: root payload must be an object.", + }; + } + + return { + ok: true, + mode, + data: result.data as Record, + }; +} + +function parseSelectedProjectIds(selectedProjectIds?: string | null): string[] { + if (selectedProjectIds === null || selectedProjectIds === undefined) + return []; + return selectedProjectIds + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +export function extractProjectsFromResume( + resumeData: unknown, + options: { mode?: RxResumeMode } = {}, +): { mode: RxResumeResolvedMode; catalog: ResumeProjectCatalogItem[] } { + const mode = (options.mode ?? + inferRxResumeModeFromData(resumeData) ?? + "v5") as RxResumeResolvedMode; + const parsed = validateAndParseResumeDataForMode(mode, resumeData); + if (!parsed.ok) { + throw new Error(parsed.message); + } + const { catalog } = extractProjectsFromResumeByMode(mode, parsed.data); + return { mode, catalog }; +} + +export async function prepareTailoredResumeForPdf(args: { + resumeData: unknown; + mode?: RxResumeMode; + tailoredContent: { + summary?: string | null; + headline?: string | null; + skills?: TailoredSkillsInput; + }; + jobDescription: string; + selectedProjectIds?: string | null; + tracerLinks?: { + enabled: boolean; + requestOrigin?: string | null; + companyName?: string | null; + }; + forceVisibleProjectsSection?: boolean; + jobId?: string; +}): Promise { + const mode = (args.mode ?? + (await readConfiguredMode())) as RxResumeResolvedMode; + const parsed = validateAndParseResumeDataForMode(mode, args.resumeData); + if (!parsed.ok) { + throw new Error(parsed.message); + } + + const workingCopy = cloneResumeData(parsed.data); + applyTailoredChunks({ + mode, + resumeData: workingCopy, + tailoredContent: args.tailoredContent, + }); + + const { catalog, selectionItems } = extractProjectsFromResumeByMode( + mode, + workingCopy, + ); + + let selectedIds = parseSelectedProjectIds(args.selectedProjectIds); + + if ( + args.selectedProjectIds === null || + args.selectedProjectIds === undefined + ) { + const overrideResumeProjectsRaw = await getSetting("resumeProjects"); + const { resumeProjects } = resolveResumeProjectsSettings({ + catalog, + overrideRaw: overrideResumeProjectsRaw, + }); + + const locked = resumeProjects.lockedProjectIds; + const desiredCount = Math.max( + 0, + resumeProjects.maxProjects - locked.length, + ); + const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds); + const eligibleProjects = selectionItems.filter((p) => + eligibleSet.has(p.id), + ); + const picked = await pickProjectIdsForJob({ + jobDescription: args.jobDescription, + eligibleProjects, + desiredCount, + }); + selectedIds = [...locked, ...picked]; + } + + applyProjectVisibility({ + mode, + resumeData: workingCopy, + selectedProjectIds: new Set(selectedIds), + forceVisibleProjectsSection: args.forceVisibleProjectsSection, + }); + + if (args.tracerLinks?.enabled) { + const tracerBaseUrl = resolveTracerPublicBaseUrl({ + requestOrigin: args.tracerLinks.requestOrigin, + }); + if (!tracerBaseUrl) { + throw new Error( + "Tracer links are enabled but no public base URL is available. Set JOBOPS_PUBLIC_BASE_URL.", + ); + } + if (!args.jobId) { + throw new Error( + "Tracer links are enabled but jobId was not provided for resume tailoring.", + ); + } + + await rewriteResumeLinksWithTracer({ + jobId: args.jobId, + resumeData: workingCopy, + publicBaseUrl: tracerBaseUrl, + companyName: args.tracerLinks.companyName ?? null, + }); + } + + return { + mode, + data: workingCopy, + projectCatalog: catalog, + selectedProjectIds: selectedIds, + }; +} + +export async function importResume( + payload: RxResumeImportPayload, + options: ResolveModeOptions = {}, +): Promise { + return runRxResumeOperation(options, { + v5: async (creds) => + await v5.importResume( + { + name: payload.name?.trim() || "JobOps Tailored Resume", + slug: payload.slug?.trim() || "", + data: payload.data, + }, + { + apiKey: creds.apiKey, + baseUrl: creds.baseUrl, + }, + ), + v4: async (creds) => + await v4.importResume( + payload as v4.RxResumeImportPayload, + toV4Override(creds), + ), + }); +} + +export async function deleteResume( + resumeId: string, + options: ResolveModeOptions = {}, +): Promise { + await runRxResumeOperation(options, { + v5: async (creds) => { + await v5.deleteResume(resumeId, { + apiKey: creds.apiKey, + baseUrl: creds.baseUrl, + }); + }, + v4: async (creds) => await v4.deleteResume(resumeId, toV4Override(creds)), + }); +} + +export async function exportResumePdf( + resumeId: string, + options: ResolveModeOptions = {}, +): Promise { + return runRxResumeOperation(options, { + v5: async (creds) => + await v5.exportResumePdf(resumeId, { + apiKey: creds.apiKey, + baseUrl: creds.baseUrl, + }), + v4: async (creds) => + await v4.exportResumePdf(resumeId, toV4Override(creds)), + }); +} + +export async function validateCredentials( + options: ResolveModeOptions = {}, +): Promise< + | { ok: true; mode: RxResumeResolvedMode } + | { ok: false; mode?: RxResumeMode; status: number; message: string } +> { + const requestedMode = options.mode ?? (await readConfiguredMode()); + const [v5Creds, v4Creds] = await Promise.all([ + readV5Credentials(options.v5), + readV4Credentials(options.v4), + ]); + + const validateV4 = async () => { + const result = await RxResumeClient.verifyCredentials( + v4Creds.email, + v4Creds.password, + v4Creds.baseUrl, + ); + if (result.ok) return { ok: true as const, mode: "v4" as const }; + return { + ok: false as const, + mode: requestedMode, + status: result.status, + message: result.message || "Reactive Resume v4 validation failed.", + }; + }; + + const validateV5 = async () => { + const result = await v5.verifyApiKey(v5Creds.apiKey, v5Creds.baseUrl); + if (result.ok) return { ok: true as const, mode: "v5" as const }; + return { + ok: false as const, + mode: requestedMode, + status: result.status, + message: result.message || "Reactive Resume v5 validation failed.", + }; + }; + + try { + const mode = await resolveRxResumeMode(options); + if (mode === "v5") { + return await validateV5(); + } + return await validateV4(); + } catch (error) { + const normalized = normalizeError(error); + if (normalized instanceof RxResumeAuthConfigError) { + return { + ok: false, + mode: requestedMode, + status: 400, + message: normalized.message, + }; + } + const status = + normalized instanceof RxResumeRequestError ? (normalized.status ?? 0) : 0; + return { + ok: false, + mode: requestedMode, + status, + message: normalized.message, + }; + } +} diff --git a/orchestrator/src/server/services/rxresume/schema/index.ts b/orchestrator/src/server/services/rxresume/schema/index.ts new file mode 100644 index 0000000..d8a34a9 --- /dev/null +++ b/orchestrator/src/server/services/rxresume/schema/index.ts @@ -0,0 +1,34 @@ +import { ZodError } from "zod"; +import type { RxResumeResolvedMode } from "../index"; +import { parseV4ResumeData, safeParseV4ResumeData } from "./v4"; +import { parseV5ResumeData, safeParseV5ResumeData } from "./v5"; + +export function parseResumeDataForMode( + mode: RxResumeResolvedMode, + data: unknown, +) { + return mode === "v5" ? parseV5ResumeData(data) : parseV4ResumeData(data); +} + +export function safeParseResumeDataForMode( + mode: RxResumeResolvedMode, + data: unknown, +) { + return mode === "v5" + ? safeParseV5ResumeData(data) + : safeParseV4ResumeData(data); +} + +export function getResumeSchemaValidationMessage(error: unknown): string { + if (error instanceof ZodError) { + const issue = error.issues[0]; + if (!issue) return "Resume schema validation failed."; + const path = issue.path.map(String).join("."); + return path + ? `Resume schema validation failed at "${path}": ${issue.message}` + : `Resume schema validation failed: ${issue.message}`; + } + return error instanceof Error + ? error.message + : "Resume schema validation failed."; +} diff --git a/shared/src/rxresume-schema.test.ts b/orchestrator/src/server/services/rxresume/schema/v4.test.ts similarity index 98% rename from shared/src/rxresume-schema.test.ts rename to orchestrator/src/server/services/rxresume/schema/v4.test.ts index 2d81268..87e9f65 100644 --- a/shared/src/rxresume-schema.test.ts +++ b/orchestrator/src/server/services/rxresume/schema/v4.test.ts @@ -1,6 +1,6 @@ import { createId } from "@paralleldrive/cuid2"; import { describe, expect, it } from "vitest"; -import { idSchema, resumeDataSchema, skillSchema } from "./rxresume-schema"; +import { idSchema, resumeDataSchema, skillSchema } from "./v4"; describe("RxResume Schema Validation", () => { describe("idSchema (CUID2)", () => { diff --git a/shared/src/rxresume-schema.ts b/orchestrator/src/server/services/rxresume/schema/v4.ts similarity index 99% rename from shared/src/rxresume-schema.ts rename to orchestrator/src/server/services/rxresume/schema/v4.ts index d01588d..55e1e30 100644 --- a/shared/src/rxresume-schema.ts +++ b/orchestrator/src/server/services/rxresume/schema/v4.ts @@ -955,3 +955,11 @@ export const sampleResume: ResumeData = { notes: "", }, }; + +export function parseV4ResumeData(data: unknown) { + return resumeDataSchema.parse(data); +} + +export function safeParseV4ResumeData(data: unknown) { + return resumeDataSchema.safeParse(data); +} diff --git a/orchestrator/src/server/services/rxresume/schema/v5.ts b/orchestrator/src/server/services/rxresume/schema/v5.ts new file mode 100644 index 0000000..b4f28eb --- /dev/null +++ b/orchestrator/src/server/services/rxresume/schema/v5.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +const looseObject = z.object({}).passthrough(); + +const v5UrlSchema = z + .object({ + url: z.string(), + label: z.string(), + }) + .passthrough(); + +const v5ProjectItemSchema = z + .object({ + id: z.string(), + hidden: z.boolean(), + name: z.string(), + period: z.string(), + website: v5UrlSchema, + description: z.string(), + }) + .passthrough(); + +const v5SectionBaseSchema = z + .object({ + title: z.string(), + columns: z.number(), + hidden: z.boolean(), + }) + .passthrough(); + +const v5ProjectsSectionSchema = v5SectionBaseSchema.extend({ + items: z.array(v5ProjectItemSchema), +}); + +const v5SummarySectionSchema = v5SectionBaseSchema.extend({ + content: z.string(), +}); + +const v5SkillItemSchema = z + .object({ + id: z.string(), + hidden: z.boolean(), + icon: z.string(), + name: z.string(), + proficiency: z.string(), + level: z.number(), + keywords: z.array(z.string()), + }) + .passthrough(); + +const v5SkillsSectionSchema = v5SectionBaseSchema.extend({ + items: z.array(v5SkillItemSchema), +}); + +export const v5ResumeDataSchema = z + .object({ + picture: looseObject, + basics: z + .object({ + name: z.string(), + headline: z.string(), + email: z.string(), + phone: z.string(), + location: z.string(), + website: v5UrlSchema, + customFields: z.array(looseObject), + }) + .passthrough(), + summary: v5SummarySectionSchema, + sections: z + .object({ + projects: v5ProjectsSectionSchema, + skills: v5SkillsSectionSchema, + }) + .passthrough(), + customSections: z.array(looseObject), + metadata: looseObject, + }) + .passthrough(); + +export function parseV5ResumeData(data: unknown) { + return v5ResumeDataSchema.parse(data); +} + +export function safeParseV5ResumeData(data: unknown) { + return v5ResumeDataSchema.safeParse(data); +} diff --git a/orchestrator/src/server/services/rxresume/tailoring.ts b/orchestrator/src/server/services/rxresume/tailoring.ts new file mode 100644 index 0000000..5676578 --- /dev/null +++ b/orchestrator/src/server/services/rxresume/tailoring.ts @@ -0,0 +1,438 @@ +import { createId } from "@paralleldrive/cuid2"; +import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types"; +import { stripHtmlTags } from "@shared/utils/string"; +import { + getResumeSchemaValidationMessage, + safeParseResumeDataForMode, +} from "./schema"; + +type RecordLike = Record; + +export type TailoredSkillsInput = + | Array<{ name: string; keywords: string[] }> + | string + | null + | undefined; + +export type TailorChunkInput = { + headline?: string | null; + summary?: string | null; + skills?: TailoredSkillsInput; +}; + +export type ResumeProjectSelectionItem = ResumeProjectCatalogItem & { + summaryText: string; +}; + +export function cloneResumeData(data: T): T { + return JSON.parse(JSON.stringify(data)) as T; +} + +export function validateAndParseResumeDataForMode( + mode: RxResumeMode, + data: unknown, +): + | { ok: true; mode: RxResumeMode; data: RecordLike } + | { ok: false; mode: RxResumeMode; message: string } { + const result = safeParseResumeDataForMode(mode, data); + if (!result.success) { + return { + ok: false, + mode, + message: getResumeSchemaValidationMessage(result.error), + }; + } + if ( + !result.data || + typeof result.data !== "object" || + Array.isArray(result.data) + ) { + return { + ok: false, + mode, + message: + "Resume schema validation failed: root payload must be an object.", + }; + } + return { ok: true, mode, data: result.data as RecordLike }; +} + +export function inferRxResumeModeFromData(data: unknown): RxResumeMode | null { + const v5 = safeParseResumeDataForMode("v5", data); + if (v5.success) return "v5"; + const v4 = safeParseResumeDataForMode("v4", data); + if (v4.success) return "v4"; + return null; +} + +function asRecord(value: unknown): RecordLike | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as RecordLike) + : null; +} + +function asArray(value: unknown): unknown[] | null { + return Array.isArray(value) ? value : null; +} + +function parseTailoredSkills( + skills: TailoredSkillsInput, +): Array | null { + if (!skills) return null; + const parsed = Array.isArray(skills) + ? skills + : typeof skills === "string" + ? (JSON.parse(skills) as unknown) + : null; + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (item) => item && typeof item === "object", + ) as RecordLike[]; +} + +export function applyTailoredHeadline( + mode: RxResumeMode, + resumeData: RecordLike, + headline?: string | null, +): void { + if (!headline) return; + const basics = asRecord(resumeData.basics); + if (!basics) return; + basics.headline = headline; + // Preserve current behavior for legacy consumers/templates that use label. + basics.label = headline; + if (mode === "v5") return; +} + +export function applyTailoredSummary( + mode: RxResumeMode, + resumeData: RecordLike, + summary?: string | null, +): void { + if (!summary) return; + if (mode === "v5") { + const topSummary = asRecord(resumeData.summary); + if (topSummary) { + if ( + typeof topSummary.content === "string" || + topSummary.content === undefined + ) { + topSummary.content = summary; + return; + } + if ( + typeof topSummary.value === "string" || + topSummary.value === undefined + ) { + topSummary.value = summary; + return; + } + } + } + + const sections = asRecord(resumeData.sections); + const summarySection = asRecord(sections?.summary); + if (summarySection) { + summarySection.content = summary; + return; + } + + const basics = asRecord(resumeData.basics); + if (basics) basics.summary = summary; +} + +function sanitizeV4SkillsSection(resumeData: RecordLike): void { + const sections = asRecord(resumeData.sections); + const skillsSection = asRecord(sections?.skills); + const items = asArray(skillsSection?.items); + if (!skillsSection || !items) return; + + skillsSection.items = items.map((raw) => { + const skill = asRecord(raw) ?? {}; + return { + ...skill, + id: typeof skill.id === "string" && skill.id ? skill.id : createId(), + visible: typeof skill.visible === "boolean" ? skill.visible : true, + description: + typeof skill.description === "string" ? skill.description : "", + level: typeof skill.level === "number" ? skill.level : 1, + keywords: Array.isArray(skill.keywords) + ? skill.keywords.filter((k) => typeof k === "string") + : [], + }; + }); +} + +function applyTailoredSkillsV4( + resumeData: RecordLike, + skills: Array, +): void { + sanitizeV4SkillsSection(resumeData); + const sections = asRecord(resumeData.sections); + const skillsSection = asRecord(sections?.skills); + if (!skillsSection) return; + const existingItems = asArray(skillsSection.items) ?? []; + const existing = existingItems + .map((item) => asRecord(item)) + .filter((item): item is RecordLike => Boolean(item)); + + skillsSection.items = skills.map((newSkill) => { + const match = existing.find((item) => item.name === newSkill.name); + return { + id: + (typeof newSkill.id === "string" && newSkill.id) || + (match && typeof match.id === "string" ? match.id : "") || + createId(), + visible: + typeof newSkill.visible === "boolean" + ? newSkill.visible + : typeof match?.visible === "boolean" + ? match.visible + : true, + name: + (typeof newSkill.name === "string" ? newSkill.name : "") || + (typeof match?.name === "string" ? match.name : ""), + description: + typeof newSkill.description === "string" + ? newSkill.description + : typeof match?.description === "string" + ? match.description + : "", + level: + typeof newSkill.level === "number" + ? newSkill.level + : typeof match?.level === "number" + ? match.level + : 0, + keywords: Array.isArray(newSkill.keywords) + ? newSkill.keywords.filter((k) => typeof k === "string") + : Array.isArray(match?.keywords) + ? match.keywords.filter((k) => typeof k === "string") + : [], + }; + }); +} + +function applyTailoredSkillsV5( + resumeData: RecordLike, + skills: Array, +): void { + const sections = asRecord(resumeData.sections); + const skillsSection = asRecord(sections?.skills); + const existingItems = asArray(skillsSection?.items); + if (!skillsSection || !existingItems) return; + const existing = existingItems + .map((item) => asRecord(item)) + .filter((item): item is RecordLike => Boolean(item)); + + const template = existing[0] ?? null; + if (!template) return; + + skillsSection.items = skills.map((newSkill) => { + const match = + existing.find((item) => item.name === newSkill.name) ?? template; + const next: RecordLike = { ...match }; + + if ("id" in next) { + next.id = + (typeof newSkill.id === "string" && newSkill.id) || + (typeof match.id === "string" ? match.id : "") || + createId(); + } + if ("name" in next) { + next.name = + (typeof newSkill.name === "string" ? newSkill.name : "") || + (typeof match.name === "string" ? match.name : ""); + } + if ("keywords" in next) { + next.keywords = Array.isArray(newSkill.keywords) + ? newSkill.keywords.filter((k) => typeof k === "string") + : Array.isArray(match.keywords) + ? match.keywords.filter((k) => typeof k === "string") + : []; + } + + // Only patch optional fields when the instance already uses them. + if ("description" in next) { + next.description = + typeof newSkill.description === "string" + ? newSkill.description + : typeof match.description === "string" + ? match.description + : ""; + } + if ("proficiency" in next) { + next.proficiency = + typeof newSkill.proficiency === "string" + ? newSkill.proficiency + : typeof newSkill.description === "string" + ? newSkill.description + : typeof match.proficiency === "string" + ? match.proficiency + : ""; + } + if ("level" in next) { + next.level = + typeof newSkill.level === "number" + ? newSkill.level + : typeof match.level === "number" + ? match.level + : next.level; + } + if ("hidden" in next) { + next.hidden = + typeof newSkill.hidden === "boolean" + ? newSkill.hidden + : typeof match.hidden === "boolean" + ? match.hidden + : next.hidden; + } + if ("visible" in next) { + next.visible = + typeof newSkill.visible === "boolean" + ? newSkill.visible + : typeof match.visible === "boolean" + ? match.visible + : next.visible; + } + + return next; + }); +} + +export function applyTailoredSkills( + mode: RxResumeMode, + resumeData: RecordLike, + tailoredSkills?: TailoredSkillsInput, +): void { + const parsed = parseTailoredSkills(tailoredSkills); + if (!parsed) { + if (mode === "v4") sanitizeV4SkillsSection(resumeData); + return; + } + if (mode === "v4") { + applyTailoredSkillsV4(resumeData, parsed); + return; + } + applyTailoredSkillsV5(resumeData, parsed); +} + +export function extractProjectsFromResume( + mode: RxResumeMode, + resumeData: RecordLike, +): { + catalog: ResumeProjectCatalogItem[]; + selectionItems: ResumeProjectSelectionItem[]; +} { + const sections = asRecord(resumeData.sections); + const projectsSection = asRecord(sections?.projects); + const items = asArray(projectsSection?.items); + if (!items) return { catalog: [], selectionItems: [] }; + + const catalog: ResumeProjectCatalogItem[] = []; + const selectionItems: ResumeProjectSelectionItem[] = []; + + for (const raw of items) { + const item = asRecord(raw); + if (!item) continue; + const id = typeof item.id === "string" ? item.id : ""; + if (!id) continue; + + const name = typeof item.name === "string" ? item.name : id; + const description = + typeof item.description === "string" ? item.description : ""; + const date = + mode === "v5" + ? typeof item.period === "string" + ? item.period + : "" + : typeof item.date === "string" + ? item.date + : ""; + + const isVisibleInBase = + mode === "v5" + ? !(typeof item.hidden === "boolean" ? item.hidden : false) + : Boolean(item.visible); + + const summaryRaw = + mode === "v5" + ? description + : typeof item.summary === "string" + ? item.summary + : ""; + + const base: ResumeProjectCatalogItem = { + id, + name, + description, + date, + isVisibleInBase, + }; + catalog.push(base); + selectionItems.push({ + ...base, + summaryText: stripHtmlTags(summaryRaw), + }); + } + + return { catalog, selectionItems }; +} + +export function applyProjectVisibility(args: { + mode: RxResumeMode; + resumeData: RecordLike; + selectedProjectIds: ReadonlySet; + forceVisibleProjectsSection?: boolean; +}): void { + const sections = asRecord(args.resumeData.sections); + const projectsSection = asRecord(sections?.projects); + const items = asArray(projectsSection?.items); + if (!projectsSection || !items) return; + + for (const raw of items) { + const item = asRecord(raw); + if (!item) continue; + const id = typeof item.id === "string" ? item.id : ""; + if (!id) continue; + if (args.mode === "v5") { + if ("hidden" in item) { + item.hidden = !args.selectedProjectIds.has(id); + } else if ("visible" in item) { + item.visible = args.selectedProjectIds.has(id); + } + } else { + item.visible = args.selectedProjectIds.has(id); + } + } + + if (args.forceVisibleProjectsSection !== false) { + if (args.mode === "v5") { + if ("hidden" in projectsSection) { + projectsSection.hidden = false; + } else if ("visible" in projectsSection) { + projectsSection.visible = true; + } + } else { + projectsSection.visible = true; + } + } +} + +export function applyTailoredChunks(args: { + mode: RxResumeMode; + resumeData: RecordLike; + tailoredContent: TailorChunkInput; +}): void { + applyTailoredSkills(args.mode, args.resumeData, args.tailoredContent.skills); + applyTailoredSummary( + args.mode, + args.resumeData, + args.tailoredContent.summary, + ); + applyTailoredHeadline( + args.mode, + args.resumeData, + args.tailoredContent.headline, + ); +} diff --git a/orchestrator/src/server/services/rxresume-v4.ts b/orchestrator/src/server/services/rxresume/v4.ts similarity index 87% rename from orchestrator/src/server/services/rxresume-v4.ts rename to orchestrator/src/server/services/rxresume/v4.ts index 77061bc..b64574a 100644 --- a/orchestrator/src/server/services/rxresume-v4.ts +++ b/orchestrator/src/server/services/rxresume/v4.ts @@ -1,13 +1,12 @@ -// rxresume-v4.ts +// rxresume/v4.ts // Service wrapper around the v4 client that mirrors the v5 helper API. // - Pulls credentials from env/settings. // - Validates resume payloads. // - Keeps the rest of the app v5-ready (swap imports later). -import type { ResumeData } from "@shared/rxresume-schema"; -import { resumeDataSchema } from "@shared/rxresume-schema"; -import { getSetting } from "../repositories/settings"; -import { RxResumeClient, type RxResumeResume } from "./rxresume-client"; +import { getSetting } from "@server/repositories/settings"; +import { RxResumeClient, type RxResumeResume } from "./client"; +import { parseV4ResumeData, type ResumeData } from "./schema/v4"; export type RxResumeCredentials = { email: string; @@ -78,16 +77,20 @@ export async function getResume( resumeId: string, override?: Partial, ): Promise { - return withRxResumeClient(override, (client, token) => + const resume = await withRxResumeClient(override, (client, token) => client.get(resumeId, token), ); + if (resume.data) { + resume.data = parseV4ResumeData(resume.data) as ResumeData; + } + return resume; } export async function importResume( payload: RxResumeImportPayload, override?: Partial, ): Promise { - const data = resumeDataSchema.parse(payload.data); + const data = parseV4ResumeData(payload.data) as ResumeData; const title = payload.name?.trim() || undefined; const slug = payload.slug?.trim() || undefined; diff --git a/orchestrator/src/server/services/rxresume/v5.test.ts b/orchestrator/src/server/services/rxresume/v5.test.ts new file mode 100644 index 0000000..c33c5f5 --- /dev/null +++ b/orchestrator/src/server/services/rxresume/v5.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sampleResume } from "./schema/v4"; +import { + deleteResume, + exportResumePdf, + fetchRxResume, + getResume, + importResume, + listResumes, +} from "./v5"; + +function jsonResponse(data: unknown, ok = true, status = 200) { + return { + ok, + status, + statusText: ok ? "OK" : "Error", + headers: { + get: (name: string) => + name.toLowerCase() === "content-type" ? "application/json" : null, + }, + json: async () => data, + text: async () => JSON.stringify(data), + }; +} + +describe("rxresume v5 endpoints", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("normalizes base URL and calls /api/openapi", async () => { + const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ ok: true })); + vi.stubGlobal("fetch", mockFetch); + vi.stubEnv("RXRESUME_API_KEY", "test-key"); + + await fetchRxResume("/resumes", {}, { baseUrl: "https://rxresu.me/api" }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://rxresu.me/api/openapi/resumes", + expect.objectContaining({ + headers: expect.objectContaining({ "x-api-key": "test-key" }), + }), + ); + }); + + it("uses v5 get/list/import/delete/pdf endpoints", async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(jsonResponse([])) + .mockResolvedValueOnce( + jsonResponse({ id: "resume-123", name: "Resume", slug: "resume" }), + ) + .mockResolvedValueOnce(jsonResponse({ id: "imported-123" })) + .mockResolvedValueOnce(jsonResponse({ ok: true })) + .mockResolvedValueOnce( + jsonResponse({ url: "https://rxresu.me/storage/resume-123.pdf" }), + ); + vi.stubGlobal("fetch", mockFetch); + vi.stubEnv("RXRESUME_API_KEY", "test-key"); + + await listResumes({ baseUrl: "https://rxresu.me" }); + await getResume("resume-123", { baseUrl: "https://rxresu.me" }); + await importResume( + { data: sampleResume, name: "Imported Resume" }, + { baseUrl: "https://rxresu.me" }, + ); + await deleteResume("resume-123", { baseUrl: "https://rxresu.me" }); + await exportResumePdf("resume-123", { baseUrl: "https://rxresu.me" }); + + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + "https://rxresu.me/api/openapi/resumes", + expect.any(Object), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + "https://rxresu.me/api/openapi/resumes/resume-123", + expect.any(Object), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + "https://rxresu.me/api/openapi/resumes/import", + expect.objectContaining({ method: "POST" }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 4, + "https://rxresu.me/api/openapi/resumes/resume-123", + expect.objectContaining({ + method: "DELETE", + body: JSON.stringify({}), + }), + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 5, + "https://rxresu.me/api/openapi/resumes/resume-123/pdf", + expect.any(Object), + ); + }); +}); diff --git a/orchestrator/src/server/services/rxresume/v5.ts b/orchestrator/src/server/services/rxresume/v5.ts new file mode 100644 index 0000000..126423a --- /dev/null +++ b/orchestrator/src/server/services/rxresume/v5.ts @@ -0,0 +1,263 @@ +// rxresume/v5.ts +// Reactive Resume v5/OpenAPI implementation (API key auth). +import { parseV4ResumeData, type ResumeData } from "./schema/v4"; +import { parseV5ResumeData } from "./schema/v5"; + +type RxResumeApiConfig = { baseUrl?: string; apiKey?: string }; + +export type RxResumeListItem = { + id: string; + name: string; + slug: string; + tags: string[]; + isPublic: boolean; + isLocked: boolean; + createdAt: string; + updatedAt: string; + [key: string]: unknown; +}; + +export type RxResumeGetByIdResponse = { + id: string; + name: string; + slug: string; + tags: string[]; + data: ResumeData | Record; + isPublic: boolean; + isLocked: boolean; + hasPassword: boolean; + [key: string]: unknown; +}; + +export type RxResumeImportRequest = { + data: ResumeData | unknown; + // Not part of the documented v5 import schema, but accepted by some installs. + name?: string; + slug?: string; +}; + +export type RxResumeExportPdfResponse = { + url: string; +}; + +export type VerifyApiKeyResult = + | { ok: true } + | { ok: false; status: number; message?: string; details?: unknown }; + +const MAX_ERROR_SNIPPET = 300; + +function cleanBaseUrl(baseUrl: string): string { + let normalized = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + if (normalized.endsWith("/api/openapi")) { + normalized = normalized.slice(0, -12); + } else if (normalized.endsWith("/api")) { + normalized = normalized.slice(0, -4); + } + return normalized; +} + +function extractErrorMessage(data: unknown, fallback: string): string { + if (typeof data === "string") return data.slice(0, MAX_ERROR_SNIPPET); + if (data && typeof data === "object") { + const maybe = data as Record; + for (const key of ["message", "error", "statusMessage"]) { + const value = maybe[key]; + if (typeof value === "string" && value.trim()) { + return value.trim().slice(0, MAX_ERROR_SNIPPET); + } + } + } + return fallback.slice(0, MAX_ERROR_SNIPPET); +} + +async function executeWithKeyRetries( + url: string, + options: RequestInit, + apiKeyOverride?: string, +): Promise { + const rawApiKey = apiKeyOverride ?? process.env.RXRESUME_API_KEY; + if (!rawApiKey) { + throw new Error("RXRESUME_API_KEY not configured in environment"); + } + + const apiKeys = rawApiKey + .split(",") + .map((k) => k.trim()) + .filter(Boolean); + if (apiKeys.length === 0) { + throw new Error("RXRESUME_API_KEY not configured in environment"); + } + + for (let attempt = 0; attempt < apiKeys.length; attempt++) { + const apiKey = apiKeys[attempt]; + const headers = { + "x-api-key": apiKey, + ...(options.body ? { "Content-Type": "application/json" } : {}), + ...(options.headers || {}), + } as Record; + + const response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + const errorBody = await response + .json() + .catch(async () => await response.text().catch(() => null)); + const errorMsg = extractErrorMessage(errorBody, response.statusText); + + if ( + response.status === 401 && + apiKeys.length > 1 && + attempt < apiKeys.length - 1 + ) { + continue; + } + + throw new Error( + `Reactive Resume API error (${response.status}): ${errorMsg}`, + ); + } + + const contentType = response.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return response.json(); + } + return response.text(); + } + + throw new Error("All Reactive Resume API keys failed."); +} + +/** + * Generic fetch helper for Reactive Resume API + */ +export async function fetchRxResume( + path: string, + options: RequestInit = {}, + config?: RxResumeApiConfig, +): Promise { + const baseUrl = + config?.baseUrl ?? process.env.RXRESUME_URL ?? "https://rxresu.me"; + const url = `${cleanBaseUrl(baseUrl)}/api/openapi${path}`; + return executeWithKeyRetries(url, options, config?.apiKey); +} + +/** + * Fetch a resume by its ID. + */ +export async function getResume( + id: string, + config?: RxResumeApiConfig, +): Promise { + const payload = (await fetchRxResume( + `/resumes/${id}`, + {}, + config, + )) as RxResumeGetByIdResponse; + if (payload.data !== undefined) { + payload.data = parseV5ResumeData(payload.data) as + | ResumeData + | Record; + } + return payload; +} + +export async function verifyApiKey( + apiKey?: string, + baseUrl?: string, +): Promise { + try { + const payload = await fetchRxResume("/resumes", {}, { apiKey, baseUrl }); + if (!Array.isArray(payload)) { + return { + ok: false, + status: 0, + message: extractErrorMessage( + payload, + "Reactive Resume v5 validation failed: unexpected response payload.", + ), + details: payload, + }; + } + return { ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Network error"; + const match = /error\s*\((\d+)\)/i.exec(message); + return { + ok: false, + status: match ? Number(match[1]) : 0, + message, + details: error, + }; + } +} + +/** + * Import a resume. + */ +export async function importResume( + payload: RxResumeImportRequest, + config?: RxResumeApiConfig, +): Promise { + try { + payload.data = parseV5ResumeData(payload.data); + } catch { + // JobOps still generates the legacy/internal resume shape for tailoring. + // Accept it for v5 imports until the write path is upgraded to a native v5 schema. + payload.data = parseV4ResumeData(payload.data); + } + + const result = (await fetchRxResume( + "/resumes/import", + { + method: "POST", + body: JSON.stringify(payload), + }, + config, + )) as { id: string } | string; + + // Reactive Resume returns the full resume object on import in v4+, or just ID in v5. + return typeof result === "string" ? result : result.id; +} + +/** + * Delete a resume. + */ +export async function deleteResume( + id: string, + config?: RxResumeApiConfig, +): Promise { + await fetchRxResume( + `/resumes/${id}`, + { method: "DELETE", body: JSON.stringify({}) }, + config, + ); +} + +/** + * Export a resume as PDF. Returns the URL. + */ +export async function exportResumePdf( + id: string, + config?: RxResumeApiConfig, +): Promise { + const result = (await fetchRxResume( + `/resumes/${id}/pdf`, + {}, + config, + )) as RxResumeExportPdfResponse; + return result.url; +} + +/** + * List all resumes. + * According to official OpenAPI spec, the endpoint is /resumes + */ +export async function listResumes(config?: { + baseUrl?: string; + apiKey?: string; +}): Promise { + return (await fetchRxResume("/resumes", {}, config)) as RxResumeListItem[]; +} diff --git a/orchestrator/src/server/services/settings-update/registry.ts b/orchestrator/src/server/services/settings-update/registry.ts index 108e8cd..c08414f 100644 --- a/orchestrator/src/server/services/settings-update/registry.ts +++ b/orchestrator/src/server/services/settings-update/registry.ts @@ -6,6 +6,10 @@ import { extractProjectsFromProfile, normalizeResumeProjectsSettings, } from "@server/services/resumeProjects"; +import { + getRxResumeBaseResumeIdKey, + normalizeRxResumeMode, +} from "@server/services/rxresume/baseResumeId"; import { settingsRegistry } from "@shared/settings-registry"; import type { UpdateSettingsInput } from "@shared/settings-schema"; @@ -96,6 +100,31 @@ for (const [key, def] of Object.entries(settingsRegistry)) { continue; } + if (key === "rxresumeBaseResumeId") { + settingsUpdateRegistry.rxresumeBaseResumeId = async ({ + value, + context, + }) => { + const serialized = normalizeEnvInput(value as string | null | undefined); + const mode = normalizeRxResumeMode( + context.input.rxresumeMode ?? + (await settingsRepo.getSetting("rxresumeMode")) ?? + process.env.RXRESUME_MODE ?? + null, + ); + const modeSpecificKey = getRxResumeBaseResumeIdKey(mode); + + return result({ + actions: [ + // Keep the legacy/current key in sync for compatibility and fallback. + persistAction("rxresumeBaseResumeId", serialized), + persistAction(modeSpecificKey, serialized), + ], + }); + }; + continue; + } + // Generic handler for all others settingsUpdateRegistry[key as keyof UpdateSettingsInput] = ({ value }) => { let serialized: string | null; diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index fa214e2..74cd3f5 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -1,13 +1,16 @@ +import { logger } from "@infra/logger"; import * as settingsRepo from "@server/repositories/settings"; import { settingsRegistry } from "@shared/settings-registry"; import type { AppSettings } from "@shared/types"; import { getEnvSettingsData } from "./envSettings"; import { getProfile } from "./profile"; +import { resolveResumeProjectsSettings } from "./resumeProjects"; import { - extractProjectsFromProfile, - resolveResumeProjectsSettings, -} from "./resumeProjects"; -import { getResume, RxResumeCredentialsError } from "./rxresume-v4"; + extractProjectsFromResume, + getResume, + RxResumeAuthConfigError, +} from "./rxresume"; +import { resolveRxResumeBaseResumeIdForMode } from "./rxresume/baseResumeId"; function resolveDefaultLlmBaseUrl(provider: string): string { const normalized = provider.trim().toLowerCase(); @@ -28,7 +31,12 @@ function resolveDefaultLlmBaseUrl(provider: string): string { export async function getEffectiveSettings(): Promise { const overrides = await settingsRepo.getAllSettings(); - const rxresumeBaseResumeId = overrides.rxresumeBaseResumeId ?? null; + const rxresumeBaseResumeId = resolveRxResumeBaseResumeIdForMode({ + rxresumeMode: overrides.rxresumeMode ?? process.env.RXRESUME_MODE ?? null, + rxresumeBaseResumeId: overrides.rxresumeBaseResumeId ?? null, + rxresumeBaseResumeIdV4: overrides.rxresumeBaseResumeIdV4 ?? null, + rxresumeBaseResumeIdV5: overrides.rxresumeBaseResumeIdV5 ?? null, + }); let profile: Record = {}; if (rxresumeBaseResumeId) { @@ -38,22 +46,26 @@ export async function getEffectiveSettings(): Promise { profile = resume.data as Record; } } catch (error) { - if (error instanceof RxResumeCredentialsError) { - console.warn( - "RxResume credentials missing while loading base resume from settings.", + if (error instanceof RxResumeAuthConfigError) { + logger.warn( + "Reactive Resume credentials missing during settings load", + { + resumeId: rxresumeBaseResumeId, + error, + }, ); } else { - console.warn( - "Failed to load RxResume base resume for settings:", + logger.warn("Failed to load Reactive Resume base resume for settings", { + resumeId: rxresumeBaseResumeId, error, - ); + }); } } } if (Object.keys(profile).length === 0) { profile = await getProfile().catch((error) => { - console.warn("Failed to load base resume profile for settings:", error); + logger.warn("Failed to load base resume profile for settings", { error }); return {}; }); } @@ -90,7 +102,19 @@ export async function getEffectiveSettings(): Promise { } if (key === "resumeProjects") { - const { catalog } = extractProjectsFromProfile(profile); + let catalog: AppSettings["profileProjects"] = []; + if (Object.keys(profile).length > 0) { + try { + catalog = extractProjectsFromResume(profile).catalog; + } catch (error) { + logger.warn( + "Failed to extract projects from Reactive Resume data", + { + error, + }, + ); + } + } const resolved = resolveResumeProjectsSettings({ catalog, overrideRaw: rawOverride ?? null, @@ -128,5 +152,8 @@ export async function getEffectiveSettings(): Promise { } } + // Always expose the effective base resume id for the active RxResume mode. + result.rxresumeBaseResumeId = rxresumeBaseResumeId; + return result as AppSettings; } diff --git a/orchestrator/src/server/services/tracer-links.ts b/orchestrator/src/server/services/tracer-links.ts index 3a8457b..f80a194 100644 --- a/orchestrator/src/server/services/tracer-links.ts +++ b/orchestrator/src/server/services/tracer-links.ts @@ -198,6 +198,24 @@ function deriveSourceLabel(sourcePath: string, linkNode: LinkNode): string { return nth ? `${baseLabel} Link ${nth}` : `${baseLabel} Link`; } + const v5SectionMatch = sourcePath.match( + /^sections\.([a-z]+)\.items\[(\d+)\]\.website\.url$/, + ); + if (v5SectionMatch) { + const section = v5SectionMatch[1]; + const index = Number(v5SectionMatch[2]); + const nth = Number.isFinite(index) ? index + 1 : null; + const sectionLabels: Record = { + projects: "Project", + experience: "Experience", + education: "Education", + }; + const baseLabel = sectionLabels[section] ?? "Resume"; + return nth ? `${baseLabel} Link ${nth}` : `${baseLabel} Link`; + } + + if (sourcePath === "basics.website.url") return "Portfolio"; + return "Resume Link"; } @@ -252,6 +270,32 @@ function collectUrlTargets( continue; } + if (key === "website" && isRecord(value)) { + const linkValue = value as { url?: unknown; label?: unknown }; + const rawHref = + typeof linkValue.url === "string" ? linkValue.url.trim() : ""; + if (rawHref && isHttpUrl(rawHref)) { + const sourcePath = `${nextPath}.url`; + targets.push({ + sourcePath, + sourceLabel: deriveSourceLabel(sourcePath, { + label: linkValue.label, + href: rawHref, + }), + destinationUrl: rawHref, + applyTracerUrl: (url: string) => { + const currentLabel = + typeof linkValue.label === "string" ? linkValue.label.trim() : ""; + linkValue.url = url; + if (!currentLabel || currentLabel === rawHref) { + linkValue.label = url; + } + }, + }); + } + continue; + } + collectUrlTargets(value, nextPath, targets); } } diff --git a/shared/src/settings-registry.test.ts b/shared/src/settings-registry.test.ts index 615cd60..641a2f9 100644 --- a/shared/src/settings-registry.test.ts +++ b/shared/src/settings-registry.test.ts @@ -102,4 +102,19 @@ describe("settingsRegistry helpers", () => { expect(settingsRegistry.resumeProjects.serialize(null)).toBeNull(); }); }); + + describe("RxResume settings", () => { + it("parses rxresumeMode enum values and rejects invalid values", () => { + expect(settingsRegistry.rxresumeMode.parse("v4")).toBe("v4"); + expect(settingsRegistry.rxresumeMode.parse("v5")).toBe("v5"); + expect(settingsRegistry.rxresumeMode.parse("")).toBeNull(); + expect(settingsRegistry.rxresumeMode.parse("latest")).toBeNull(); + expect(settingsRegistry.rxresumeMode.serialize("v5")).toBe("v5"); + expect(settingsRegistry.rxresumeMode.serialize(null)).toBeNull(); + }); + + it("has env-backed v5 api key secret setting", () => { + expect(settingsRegistry.rxresumeApiKey.envKey).toBe("RXRESUME_API_KEY"); + }); + }); }); diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts index 0c8e79e..d03c2f1 100644 --- a/shared/src/settings-registry.ts +++ b/shared/src/settings-registry.ts @@ -136,6 +136,22 @@ export const settingsRegistry = { return value ? JSON.stringify(value) : null; }, }, + rxresumeMode: { + kind: "typed" as const, + schema: z.enum(["v4", "v5"]), + default: (): "v4" | "v5" => + (typeof process !== "undefined" + ? process.env.RXRESUME_MODE + : undefined) === "v4" + ? "v4" + : "v5", + parse: (raw: string | undefined): "v4" | "v5" | null => { + if (!raw) return null; + return raw === "v4" || raw === "v5" ? raw : null; + }, + serialize: (value: "v4" | "v5" | null | undefined): string | null => + value ?? null, + }, ukvisajobsMaxJobs: { kind: "typed" as const, schema: z.number().int().min(1).max(1000), @@ -359,6 +375,14 @@ export const settingsRegistry = { kind: "string" as const, schema: z.string().trim().max(200), }, + rxresumeBaseResumeIdV4: { + kind: "string" as const, + schema: z.string().trim().max(200), + }, + rxresumeBaseResumeIdV5: { + kind: "string" as const, + schema: z.string().trim().max(200), + }, rxresumeEmail: { kind: "string" as const, envKey: "RXRESUME_EMAIL", @@ -391,6 +415,11 @@ export const settingsRegistry = { envKey: "RXRESUME_PASSWORD", schema: z.string().trim().max(2000), }, + rxresumeApiKey: { + kind: "secret" as const, + envKey: "RXRESUME_API_KEY", + schema: z.string().trim().max(2000), + }, ukvisajobsPassword: { kind: "secret" as const, envKey: "UKVISAJOBS_PASSWORD", diff --git a/shared/src/testing/factories.ts b/shared/src/testing/factories.ts index 2a9fd5f..43441ea 100644 --- a/shared/src/testing/factories.ts +++ b/shared/src/testing/factories.ts @@ -148,6 +148,8 @@ export const createAppSettings = ( override: null, }, rxresumeBaseResumeId: null, + rxresumeBaseResumeIdV4: null, + rxresumeBaseResumeIdV5: null, ukvisajobsMaxJobs: { value: 50, default: 50, override: null }, adzunaMaxJobsPerTerm: { value: 50, default: 50, override: null }, gradcrackerMaxJobsPerTerm: { value: 50, default: 50, override: null }, @@ -182,6 +184,7 @@ export const createAppSettings = ( chatStyleConstraints: { value: "", default: "", override: null }, chatStyleDoNotUse: { value: "", default: "", override: null }, llmApiKeyHint: null, + rxresumeApiKeyHint: null, rxresumeEmail: null, rxresumePasswordHint: null, basicAuthUser: null, @@ -198,5 +201,6 @@ export const createAppSettings = ( penalizeMissingSalary: { value: false, default: false, override: null }, missingSalaryPenalty: { value: 10, default: 10, override: null }, autoSkipScoreThreshold: { value: null, default: null, override: null }, + rxresumeMode: { value: "v5", default: "v5", override: null }, ...overrides, }); diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts index 709810e..d1888a8 100644 --- a/shared/src/types/settings.ts +++ b/shared/src/types/settings.ts @@ -12,6 +12,8 @@ export interface ResumeProjectsSettings { aiSelectableProjectIds: string[]; } +export type RxResumeMode = "v4" | "v5"; + export interface ResumeProfile { basics?: { name?: string; @@ -138,6 +140,7 @@ export interface AppSettings { penalizeMissingSalary: Resolved; missingSalaryPenalty: Resolved; autoSkipScoreThreshold: Resolved; + rxresumeMode: Resolved; // Model variants (no own default, fallback to model.value): modelScorer: ModelResolved; @@ -146,6 +149,8 @@ export interface AppSettings { // Simple strings: rxresumeBaseResumeId: string | null; + rxresumeBaseResumeIdV4: string | null; + rxresumeBaseResumeIdV5: string | null; rxresumeEmail: string | null; ukvisajobsEmail: string | null; adzunaAppId: string | null; @@ -153,6 +158,7 @@ export interface AppSettings { // Secret hints: llmApiKeyHint: string | null; + rxresumeApiKeyHint: string | null; rxresumePasswordHint: string | null; ukvisajobsPasswordHint: string | null; adzunaAppKeyHint: string | null;