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
This commit is contained in:
Shaheer Sarfaraz 2026-02-25 02:26:15 +00:00 committed by GitHub
parent 70f8afd294
commit 7514aa1b28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 4164 additions and 1247 deletions

View File

@ -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/<jobId>/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

View File

@ -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.

View File

@ -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

View File

@ -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<ValidationResult> {
export async function validateRxresume(input?: {
mode?: "v4" | "v5";
email?: string;
password?: string;
apiKey?: string;
baseUrl?: string;
}): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/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<ResumeProjectCatalogItem[]> {
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;

View File

@ -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<void>;
}
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
const tokens = statusTokens[status] ?? defaultStatusToken;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80",
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", tokens.dot)} />
{tokens.label}
</span>
);
};
const TracerPill: React.FC<{ enabled: boolean }> = ({ enabled }) => (
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80">
<span
className={cn(
"h-1.5 w-1.5 rounded-full opacity-80",
enabled ? "bg-violet-500" : "bg-slate-500",
)}
/>
{enabled ? "Tracer On" : "Tracer Off"}
</span>
);
const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
if (score == null) {
return <span className="text-[10px] text-muted-foreground/60">-</span>;
@ -159,30 +134,26 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
};
const status = getStatus(score);
const tooltipContent = `${score}% match`;
const tooltip = (
<>
{parsedNames.length > 0 && (
<p className="text-xs font-medium space-x-1">
<span className="opacity-70">Matched</span>
<span>{parsedNames.join(", ")}</span>
</p>
)}
<p className="opacity-80 mt-1 text-[10px]">{`${score}% match`}</p>
</>
);
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80 cursor-help">
<span
className={cn("h-1.5 w-1.5 rounded-full opacity-80", status.dot)}
/>
{status.label}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{parsedNames.length > 0 && (
<p className="text-xs font-medium space-x-1">
<span className="opacity-70">Matched</span>
<span>{parsedNames.join(", ")}</span>
</p>
)}
<p className="opacity-80 mt-1 text-[10px]">{tooltipContent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<StatusIndicator
dotColor={status.dot}
label={status.label}
className="cursor-help"
tooltip={tooltip}
tooltipClassName="max-w-xs"
/>
);
};
@ -191,6 +162,8 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
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<JobHeaderProps> = ({
{/* Status and score: single line, subdued */}
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
<div className="flex items-center gap-4">
<StatusPill status={job.status} />
<TracerPill enabled={job.tracerLinksEnabled} />
<StatusIndicator
dotColor={jobStatus.dotColor}
label={jobStatus.label}
/>
<StatusIndicator
dotColor={tracerStatus.dotColor}
label={tracerStatus.label}
/>
{showSponsorInfo && (
<SponsorPill
score={job.sponsorMatchScore}

View File

@ -95,6 +95,7 @@ const settingsResponse = {
llmProvider: { value: "openrouter", default: "openrouter", override: null },
llmApiKeyHint: null,
rxresumeEmail: "",
rxresumeApiKeyHint: null,
rxresumePasswordHint: null,
rxresumeBaseResumeId: null,
},
@ -139,6 +140,13 @@ describe("OnboardingGate", () => {
});
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(<OnboardingGate />);
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();
});

View File

@ -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<ValidationState>({
valid: false,
message: null,
checked: false,
});
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
{
valid: false,
message: null,
checked: false,
},
const [llmValidation, setLlmValidation] = useState<ValidationState>(
EMPTY_VALIDATION_STATE,
);
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
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<ValidationState>({
valid: false,
message: null,
checked: false,
});
useState<ValidationState>(EMPTY_VALIDATION_STATE);
const [currentStep, setCurrentStep] = useState<string | null>(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<ValidationResult & { checked: true; testedAt: number }> => {
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<boolean> => {
const values = getValues();
const apiKeyValue = values.llmApiKey.trim();
@ -395,13 +444,13 @@ export const OnboardingGate: React.FC = () => {
const handleSaveRxresume = async (): Promise<boolean> => {
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 = () => {
</TabsContent>
<TabsContent value="rxresume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">
Link your RxResume account
</p>
<p className="text-xs text-muted-foreground">
Used to export tailored PDFs. Create an account{" "}
<a
className="underline underline-offset-2"
href="https://v4.rxresu.me/auth/register"
target="_blank"
rel="noreferrer"
>
here
</a>{" "}
on RxResume v4 using email/password.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Controller
name="rxresumeEmail"
control={control}
render={({ field }) => (
<SettingsInput
label="Email"
inputProps={{
name: "rxresumeEmail",
value: field.value,
onChange: field.onChange,
}}
placeholder="you@example.com"
current={rxresumeEmailCurrent}
disabled={isSavingEnv}
/>
)}
/>
<Controller
name="rxresumePassword"
control={control}
render={({ field }) => (
<SettingsInput
label="Password"
inputProps={{
name: "rxresumePassword",
value: field.value,
onChange: field.onChange,
}}
type="password"
placeholder="Enter password"
current={rxresumePasswordCurrent}
disabled={isSavingEnv}
/>
)}
/>
</div>
<ReactiveResumeConfigPanel
mode={rxresumeModeCurrent}
onModeChange={(mode) => {
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),
}}
/>
</TabsContent>
<TabsContent value="baseresume" className="space-y-4 pt-6">
@ -743,8 +795,14 @@ export const OnboardingGate: React.FC = () => {
render={({ field }) => (
<BaseResumeSelection
value={field.value}
onValueChange={field.onChange}
onValueChange={(value) => {
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
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={handleRefresh} disabled={isBusy}>
Refresh status
</Button>
<Button onClick={handlePrimaryAction} disabled={isBusy}>
{isBusy ? "Working..." : primaryLabel}
{isBusy
? "Validating..."
: getStepPrimaryLabel({
currentStep,
llmValidated,
rxresumeValidated: rxresumeValidation.valid,
baseResumeValidated: baseResumeValidation.valid,
})}
</Button>
</div>
</div>
<Progress value={progressValue} className="h-2" />
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
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.{" "}
<a
className="font-semibold text-foreground underline underline-offset-2"
href="https://github.com/DaKheera47/job-ops/issues"
target="_blank"
rel="noreferrer"
>
Open an issue
</a>
.
</div>
</div>
</AlertDialogContent>
</AlertDialog>

View File

@ -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 (
<StatusIndicator
label={`${label}: ${statusLabel}`}
dotColor={dotColor}
tooltip={
state.checked && !state.valid && state.message
? state.message
: undefined
}
/>
);
}
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 (
<div className="space-y-4">
{intro ? (
<div>
<p className="text-sm font-semibold">{intro.title}</p>
{intro.description ? (
<p className="text-xs text-muted-foreground">{intro.description}</p>
) : null}
</div>
) : null}
<Tabs value={mode} onValueChange={handleModeChange}>
<TabsList className="grid h-auto w-full grid-cols-2">
<TabsTrigger value="v5" disabled={disabled}>
v5 (API key)
</TabsTrigger>
<TabsTrigger value="v4" disabled={disabled}>
v4 (Email + Password)
</TabsTrigger>
</TabsList>
</Tabs>
{showValidationStatus && selectedValidationStatus ? (
<div className="flex flex-wrap items-center gap-2 text-xs w-full justify-between">
{renderStatusPill(`${mode} status`, selectedValidationStatus)}
</div>
) : null}
{mode === "v5" ? (
<div className="grid gap-4">
<SettingsInput
label="v5 API key"
inputProps={{
name: "rxresumeApiKey",
value: v5.apiKey,
onChange: (event) => v5.onApiKeyChange(event.currentTarget.value),
}}
type="password"
placeholder={v5.placeholder ?? "Enter v5 API key"}
helper={v5.helper}
disabled={disabled}
error={v5.error}
/>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="v4 Email"
inputProps={{
name: "rxresumeEmail",
value: v4.email,
onChange: (event) => v4.onEmailChange(event.currentTarget.value),
}}
placeholder={v4.emailPlaceholder ?? "you@example.com"}
disabled={disabled}
error={v4.emailError}
/>
<SettingsInput
label="v4 Password"
inputProps={{
name: "rxresumePassword",
value: v4.password,
onChange: (event) =>
v4.onPasswordChange(event.currentTarget.value),
}}
type="password"
placeholder={v4.passwordPlaceholder ?? "Enter v4 password"}
disabled={disabled}
error={v4.passwordError}
/>
</div>
)}
{projectSelection ? (
<>
<Separator />
{!canShowProjectSelection ? (
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Connect Reactive Resume and choose a template resume to configure
resume projects.
</div>
) : (
<div className="space-y-4">
<BaseResumeSelection
value={projectSelection.baseResumeId}
onValueChange={projectSelection.onBaseResumeIdChange}
hasRxResumeAccess={hasRxResumeAccess}
rxresumeMode={mode}
disabled={projectSelection.disabled}
/>
{!projectSelection.baseResumeId ? (
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Choose a PDF to configure resume projects.
</div>
) : (
<>
<div className="space-y-2">
<div className="text-sm font-medium">
Max projects to choose
</div>
<Input
type="number"
inputMode="numeric"
min={projectSelection.lockedCount}
max={projectSelection.maxProjectsTotal}
value={projectSelection.value?.maxProjects ?? 0}
onChange={(event) => {
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 ? (
<p className="text-xs text-destructive">
{projectSelection.maxProjectsError}
</p>
) : null}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Project
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Visible in template
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Must Include
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
AI selectable
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">
{project.name}
</div>
{projectMeta ? (
<div className="text-xs text-muted-foreground">
{projectMeta}
</div>
) : null}
</div>
</TableCell>
<TableCell>
{project.isVisibleInBase ? "Yes" : "No"}
</TableCell>
<TableCell>
<Checkbox
checked={locked}
onCheckedChange={() => {
if (!value) return;
projectSelection.onChange(
toggleMustInclude({
settings: value,
projectId: project.id,
checked: !locked,
maxProjectsTotal:
projectSelection.maxProjectsTotal,
}),
);
}}
disabled={
projectSelection.disabled ||
projectSelection.isProjectsLoading ||
!value
}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
onCheckedChange={() => {
if (!value) return;
projectSelection.onChange(
toggleAiSelectable({
settings: value,
projectId: project.id,
checked: !aiSelectable,
}),
);
}}
disabled={
projectSelection.disabled ||
projectSelection.isProjectsLoading ||
locked ||
!value
}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</>
)}
</div>
)}
</>
) : null}
</div>
);
};

View File

@ -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<StatusIndicatorProps> = ({
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 = (
<span
className={cn(
appearance === "badge"
? "inline-flex items-center gap-2 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase tracking-wide"
: STATUS_INDICATOR_BASE_CLASS,
appearance === "badge" ? badgeTokens.badge : undefined,
className,
)}
>
<span
className={cn(
appearance === "badge"
? "h-1.5 w-1.5 rounded-full"
: STATUS_INDICATOR_DOT_CLASS,
animateDot ? "animate-pulse" : undefined,
resolvedDotColor,
dotClassName,
)}
/>
{label}
</span>
);
if (!tooltip) return content;
return (
<TooltipProvider>
<Tooltip delayDuration={tooltipDelayDuration}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side={tooltipSide} className={tooltipClassName}>
{tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
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<StatusIndicatorProps, "appearance"> & { appearance?: "badge" }
> = (props) => <StatusIndicator {...props} appearance="badge" />;
export {
StatusIndicator,
getJobStatusIndicator,
getTracerStatusIndicator,
StatusBadgeIndicator,
};

View File

@ -24,9 +24,6 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
return (
<div className="space-y-2">
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
<span className="text-xs font-medium text-muted-foreground">
Selected Projects
</span>
{tooManyProjects && (
<span className="flex items-center gap-1 text-[10px] text-amber-500 font-medium">
<AlertTriangle className="h-3 w-3" />

View File

@ -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<PageHeaderProps> = ({
);
};
// ============================================================================
// Status Indicator (Pipeline running, Updating, etc.)
// ============================================================================
interface StatusIndicatorProps {
label: string;
variant?: "amber" | "emerald" | "sky";
}
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
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 (
<span
className={cn(
"inline-flex items-center gap-2 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase tracking-wide",
colorMap[variant],
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full animate-pulse",
dotMap[variant],
)}
/>
{label}
</span>
);
};
export const StatusIndicator = StatusBadgeIndicator;
// ============================================================================
// Split Layout (List + Detail panels)

View File

@ -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<RxResumeMode, string | null> = {
v4: null,
v5: null,
};
export function useRxResumeConfigState(settings: RxResumeSettingsLike) {
const storedRxResume = useMemo(
() => getStoredRxResumeCredentialAvailability(settings),
[settings],
);
const [baseResumeIdsByMode, setBaseResumeIdsByMode] =
useState<Record<RxResumeMode, string | null>>(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,
};
}

View File

@ -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<RxResumeMode, string | null>;
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<typeof getStoredRxResumeCredentialAvailability>,
"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<UpdateSettingsInput> => {
const update: Partial<UpdateSettingsInput> = {
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<TSettings> = {
mode: RxResumeMode;
stored: RxResumeStoredCredentialAvailability;
draft: RxResumeCredentialDrafts;
validate: (
payload: { mode: RxResumeMode } & ReturnType<
typeof toRxResumeValidationPayload
>,
) => Promise<ValidationResult>;
persist?: (update: Partial<UpdateSettingsInput>) => Promise<TSettings>;
persistOnSuccess?: boolean;
getPrecheckMessage?: (
failure: Exclude<RxResumeCredentialPrecheckFailure, null>,
) => string;
getValidationErrorMessage?: (error: unknown, mode: RxResumeMode) => string;
getPersistErrorMessage?: (error: unknown, mode: RxResumeMode) => string;
};
export type ValidateAndMaybePersistRxResumeModeResult<TSettings> = {
validation: ValidationResult;
precheckFailure: RxResumeCredentialPrecheckFailure;
updatedSettings: TSettings | null;
};
export const validateAndMaybePersistRxResumeMode = async <TSettings>(
input: ValidateAndMaybePersistRxResumeModeInput<TSettings>,
): Promise<ValidateAndMaybePersistRxResumeModeResult<TSettings>> => {
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,
};
}
};

View File

@ -14,6 +14,7 @@ const render = (ui: Parameters<typeof renderWithQueryClient>[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 () => {

View File

@ -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<AppSettings | null>(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<JobStatus[]>([
"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 = () => {
/>
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
<Accordion
type="multiple"
className="w-full space-y-4"
defaultValue={["model", "feature", "webhooks", "chat"]}
>
<Accordion type="multiple" className="w-full space-y-4">
<ModelSettingsSection
values={model}
isLoading={isLoading}
@ -800,11 +946,22 @@ export const SettingsPage: React.FC = () => {
/>
<ReactiveResumeSection
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
onRxresumeModeChange={(mode) => {
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}

View File

@ -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<BaseResumeSelectionProps> = ({
value,
onValueChange,
hasRxResumeAccess,
rxresumeMode,
disabled = false,
isLoading = false,
}) => {
@ -31,12 +34,16 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
const [fetchError, setFetchError] = useState<string | null>(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<BaseResumeSelectionProps> = ({
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<BaseResumeSelectionProps> = ({
}
}, [hasRxResumeAccess, fetchResumes]);
useEffect(() => {
if (!hasRxResumeAccess) {
setResumes([]);
setFetchError(null);
}
}, [hasRxResumeAccess]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">

View File

@ -52,14 +52,12 @@ describe("EnvironmentSettingsSection", () => {
it("renders values grouped logically and masks private secrets with hints", () => {
render(<EnvironmentSettingsHarness />);
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();
});
});

View File

@ -44,28 +44,6 @@ export const EnvironmentSettingsSection: React.FC<
Service Accounts
</div>
<div className="space-y-4">
<div className="text-sm font-semibold">RxResume</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="Email"
inputProps={register("rxresumeEmail")}
placeholder="you@example.com"
disabled={isLoading || isSaving}
error={errors.rxresumeEmail?.message as string | undefined}
/>
<SettingsInput
label="Password"
inputProps={register("rxresumePassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.rxresumePassword?.message as string | undefined}
current={formatSecretHint(privateValues.rxresumePasswordHint)}
/>
</div>
</div>
<div className="space-y-4">
<div className="text-sm font-semibold">UKVisaJobs</div>
<div className="grid gap-4 md:grid-cols-2">

View File

@ -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<ReactiveResumeSectionProps> = ({
rxResumeBaseResumeIdDraft,
setRxResumeBaseResumeIdDraft,
hasRxResumeAccess,
rxresumeMode,
onRxresumeModeChange,
validationStatuses,
profileProjects,
lockedCount,
maxProjectsTotal,
@ -53,8 +49,25 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
}) => {
const {
control,
setValue,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
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 = <TField extends Path<UpdateSettingsInput>>(
field: TField,
value: PathValue<UpdateSettingsInput, TField>,
) =>
setValue(field, value, {
shouldDirty: true,
shouldTouch: true,
});
return (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
@ -62,196 +75,48 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
<span className="text-base font-semibold">Reactive Resume</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
{!hasRxResumeAccess ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>RxResume Access Missing</AlertTitle>
<AlertDescription>
Configure RxResume credentials in settings (email + password) or
set <code>RXRESUME_API_KEY</code> to enable access.
</AlertDescription>
</Alert>
) : (
<>
<Alert className="bg-green-50 border-green-200 dark:bg-green-900/10 dark:border-green-900/20">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertTitle className="text-green-800 dark:text-green-300">
RxResume Access Ready
</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-400">
Reactive Resume access is active.
</AlertDescription>
</Alert>
<BaseResumeSelection
value={rxResumeBaseResumeIdDraft}
onValueChange={setRxResumeBaseResumeIdDraft}
hasRxResumeAccess={hasRxResumeAccess}
disabled={isLoading || isSaving}
/>
<Separator />
<div className="space-y-4">
{!rxResumeBaseResumeIdDraft ? (
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Choose a PDF to configure resume projects.
</div>
) : (
<>
<div className="space-y-2">
<div className="text-sm font-medium">
Max projects to choose
</div>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={field.value?.maxProjects ?? 0}
onChange={(event) => {
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 && (
<p className="text-xs text-destructive">
{errors.resumeProjects.maxProjects.message}
</p>
)}
</div>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Project
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Visible in template
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
Must Include
</TableHead>
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
AI selectable
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(
field.value?.lockedProjectIds.includes(
project.id,
),
);
const aiSelectable = Boolean(
field.value?.aiSelectableProjectIds.includes(
project.id,
),
);
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">
{project.name || project.id}
</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date]
.filter(Boolean)
.join(" - ")}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{project.isVisibleInBase ? "Yes" : "No"}
</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={
isLoading ||
isSaving ||
isProjectsLoading ||
!field.value
}
onCheckedChange={(checked) => {
if (!field.value) return;
field.onChange(
toggleMustInclude({
settings: field.value,
projectId: project.id,
checked: checked === true,
maxProjectsTotal,
}),
);
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={
locked ||
isLoading ||
isSaving ||
isProjectsLoading ||
!field.value
}
onCheckedChange={(checked) => {
if (!field.value) return;
field.onChange(
toggleAiSelectable({
settings: field.value,
projectId: project.id,
checked: checked === true,
}),
);
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
/>
</>
)}
</div>
</>
)}
</div>
<ReactiveResumeConfigPanel
mode={selectedMode}
onModeChange={(mode) => {
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(),
}}
/>
</AccordionContent>
</AccordionItem>
);

View File

@ -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");
});
});

View File

@ -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<ValidationResponse> {
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<ValidationResponse> {
};
}
// 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<ValidationResponse> {
}
}
async function validateRxresume(
email?: string | null,
password?: string | null,
): Promise<ValidationResponse> {
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<ValidationResponse> {
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);
},
);

View File

@ -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();

View File

@ -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));
}
});

View File

@ -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<string, unknown>).summary === "object"
? "v5"
: "v4",
data,
})),
extractProjectsFromResume: vi.fn((data: unknown) => {
const root = (data ?? {}) as Record<string, unknown>;
const sections = (root.sections ?? {}) as Record<string, unknown>;
const projects = (sections.projects ?? {}) as Record<string, unknown>;
const items = Array.isArray(projects.items) ? projects.items : [];
return {
mode: "v5",
catalog: items.map((item) => {
const project = item as Record<string, unknown>;
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();
});
});

View File

@ -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);
}
},
}),
);

View File

@ -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<any>,
) => {
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 = <T>(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];

View File

@ -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<any>,
) => {
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 = <T>(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",

View File

@ -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<void> {
}
/**
* 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<PdfResult> {
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<string, unknown>) => ({
...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<Record<string, unknown>>;
const skillsWithSchema = newSkills.map(
(newSkill: Record<string, unknown>) => {
// 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<string, unknown>;
try {
let selectedSet: Set<string>;
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<string, unknown>;
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 };
}
}

View File

@ -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.",
);
});

View File

@ -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<ResumeProfile> {
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<ResumeProfile> {
}
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<ResumeProfile> {
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;
}
}

View File

@ -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<unknown> {
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<string, string>;
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<unknown> {
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<RxResumeResponse> {
return (await fetchRxResume(`/resume/${id}`)) as RxResumeResponse;
}
/**
* Import a resume.
*/
export async function importResume(payload: {
name: string;
slug: string;
data: unknown;
}): Promise<string> {
// 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<void> {
await fetchRxResume(`/resume/${id}`, { method: "DELETE" });
}
/**
* Export a resume as PDF. Returns the URL.
*/
export async function exportResumePdf(id: string): Promise<string> {
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;
}[];
}

View File

@ -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<SettingKey, "rxresumeBaseResumeIdV4" | "rxresumeBaseResumeIdV5"> {
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,
),
};
}

View File

@ -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)", () => {

View File

@ -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<string, unknown>;
const MAX_ERROR_SNIPPET = 300;

View File

@ -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<Record<string, string | null>>;
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,
},
]);
});
});

View File

@ -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<string, unknown>;
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<ReturnType<typeof readV4Credentials>>;
type V5Credentials = Awaited<ReturnType<typeof readV5Credentials>>;
function toV4Override(
input?: ResolveModeOptions["v4"],
): Partial<v4.RxResumeCredentials> | 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<string, unknown>;
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<string, unknown> {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new RxResumeRequestError(
"Reactive Resume v5 returned an unexpected resume response shape.",
);
}
return payload as Record<string, unknown>;
}
async function readConfiguredMode(): Promise<RxResumeMode> {
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<RxResumeResolvedMode> {
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<T>(
options: ResolveModeOptions,
handlers: {
v4: (creds: V4Credentials) => Promise<T>;
v5: (creds: V5Credentials) => Promise<T>;
},
): Promise<T> {
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<RxResumeResume[]> {
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<RxResumeResume> {
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<string, unknown> }
| { 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<string, unknown>,
};
}
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<PreparedRxResumePdfPayload> {
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<string> {
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<void> {
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<string> {
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,
};
}
}

View File

@ -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.";
}

View File

@ -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)", () => {

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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<string, unknown>;
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<T>(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<RecordLike> | 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<RecordLike>,
): 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<RecordLike>,
): 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<string>;
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,
);
}

View File

@ -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<RxResumeCredentials>,
): Promise<RxResumeResume> {
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<RxResumeCredentials>,
): Promise<string> {
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;

View File

@ -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),
);
});
});

View File

@ -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<string, unknown>;
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<string, unknown>;
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<unknown> {
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<string, string>;
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<unknown> {
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<RxResumeGetByIdResponse> {
const payload = (await fetchRxResume(
`/resumes/${id}`,
{},
config,
)) as RxResumeGetByIdResponse;
if (payload.data !== undefined) {
payload.data = parseV5ResumeData(payload.data) as
| ResumeData
| Record<string, unknown>;
}
return payload;
}
export async function verifyApiKey(
apiKey?: string,
baseUrl?: string,
): Promise<VerifyApiKeyResult> {
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<string> {
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<void> {
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<string> {
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<RxResumeListItem[]> {
return (await fetchRxResume("/resumes", {}, config)) as RxResumeListItem[];
}

View File

@ -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;

View File

@ -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<AppSettings> {
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<string, unknown> = {};
if (rxresumeBaseResumeId) {
@ -38,22 +46,26 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
profile = resume.data as Record<string, unknown>;
}
} 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<AppSettings> {
}
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<AppSettings> {
}
}
// Always expose the effective base resume id for the active RxResume mode.
result.rxresumeBaseResumeId = rxresumeBaseResumeId;
return result as AppSettings;
}

View File

@ -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<string, string> = {
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);
}
}

View File

@ -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");
});
});
});

View File

@ -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",

View File

@ -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,
});

View File

@ -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<boolean>;
missingSalaryPenalty: Resolved<number>;
autoSkipScoreThreshold: Resolved<number | null>;
rxresumeMode: Resolved<RxResumeMode>;
// 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;