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: 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). 1. Choose a mode in **Settings → Reactive Resume**:
2. Use a **native email + password** account (not Google/GitHub/other OAuth login). - `v5` (API key)
3. Generate/store that password so JobOps can use it for API login. - `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` Configure in **Settings → Reactive Resume**:
- `rxresumePassword`
- `rxresumeMode` (`v5` or `v4`)
- `rxresumeApiKey` (for v5)
- `rxresumeEmail` + `rxresumePassword` (for v4)
Or via environment variables: Or via environment variables:
- `RXRESUME_MODE` (`v5` or `v4`)
- `RXRESUME_API_KEY` (for v5)
- `RXRESUME_EMAIL` - `RXRESUME_EMAIL`
- `RXRESUME_PASSWORD` - `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 ### 2) Select base resume
@ -176,7 +184,7 @@ curl -X PATCH "http://localhost:3001/api/settings" \
``` ```
```bash ```bash
# List available RxResume resumes # List available Reactive Resume resumes
curl "http://localhost:3001/api/settings/rx-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 ### 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. - Save settings, then refresh resumes in the Reactive Resume section.
### No resumes appear in dropdown ### No resumes appear in dropdown
- Confirm credentials are valid for [v4.rxresu.me](https://v4.rxresu.me)/your configured RxResume URL. - Confirm the selected mode matches your Reactive Resume deployment.
- Confirm the RxResume account is a native email/password account (not OAuth-only). - For `v5`, confirm `RXRESUME_API_KEY` / `rxresumeApiKey` is valid for your self-hosted instance.
- Confirm the selected RxResume account actually has resumes. - 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 ### Project list is empty in settings

View File

@ -124,3 +124,7 @@ docker compose up -d
If you self-host Reactive Resume, set: If you self-host Reactive Resume, set:
- `RXRESUME_URL=http://rxresume.local.net` - `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. # 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. 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 - **Backend:** Express, TypeScript, Drizzle ORM, SQLite
- **Frontend:** React, Vite, CSS (custom design system) - **Frontend:** React, Vite, CSS (custom design system)
- **AI:** Configurable LLM provider (OpenRouter default; also supports OpenAI/Gemini/LM Studio/Ollama) - **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 - **Job Crawling:** Wraps existing TypeScript Crawlee crawler

View File

@ -37,6 +37,7 @@ import type {
ProfileStatusResponse, ProfileStatusResponse,
ResumeProfile, ResumeProfile,
ResumeProjectCatalogItem, ResumeProjectCatalogItem,
RxResumeMode,
StageEvent, StageEvent,
StageEventMetadata, StageEventMetadata,
StageTransitionTarget, StageTransitionTarget,
@ -1253,7 +1254,11 @@ export async function getResumeProjectsCatalog(): Promise<
try { try {
const settings = await getSettings(); const settings = await getSettings();
if (settings.rxresumeBaseResumeId) { if (settings.rxresumeBaseResumeId) {
return await getRxResumeProjects(settings.rxresumeBaseResumeId); return await getRxResumeProjects(
settings.rxresumeBaseResumeId,
undefined,
settings.rxresumeMode?.value,
);
} }
} catch { } catch {
// fall through to profile-based projects // fall through to profile-based projects
@ -1287,13 +1292,16 @@ export async function validateLlm(input: {
}); });
} }
export async function validateRxresume( export async function validateRxresume(input?: {
email?: string, mode?: "v4" | "v5";
password?: string, email?: string;
): Promise<ValidationResult> { password?: string;
apiKey?: string;
baseUrl?: string;
}): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/onboarding/validate/rxresume", { return fetchApi<ValidationResult>("/onboarding/validate/rxresume", {
method: "POST", 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 }[] }>( const data = await fetchApi<{ resumes: { id: string; name: string }[] }>(
"/settings/rx-resumes", `/settings/rx-resumes${query}`,
); );
return data.resumes; return data.resumes;
} }
@ -1320,9 +1331,11 @@ export async function getRxResumes(): Promise<{ id: string; name: string }[]> {
export async function getRxResumeProjects( export async function getRxResumeProjects(
resumeId: string, resumeId: string,
signal?: AbortSignal, signal?: AbortSignal,
mode?: RxResumeMode,
): Promise<ResumeProjectCatalogItem[]> { ): Promise<ResumeProjectCatalogItem[]> {
const query = mode ? `?mode=${encodeURIComponent(mode)}` : "";
const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>( const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>(
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`, `/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects${query}`,
{ signal }, { signal },
); );
return data.projects; 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 { import {
ArrowUpRight, ArrowUpRight,
Calendar, Calendar,
@ -21,9 +21,10 @@ import {
import { cn, formatDate, sourceLabel } from "@/lib/utils"; import { cn, formatDate, sourceLabel } from "@/lib/utils";
import { useSettings } from "../hooks/useSettings"; import { useSettings } from "../hooks/useSettings";
import { import {
defaultStatusToken, getJobStatusIndicator,
statusTokens, getTracerStatusIndicator,
} from "../pages/orchestrator/constants"; StatusIndicator,
} from "./StatusIndicator";
interface JobHeaderProps { interface JobHeaderProps {
job: Job; job: Job;
@ -31,32 +32,6 @@ interface JobHeaderProps {
onCheckSponsor?: () => Promise<void>; 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 }) => { const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
if (score == null) { if (score == null) {
return <span className="text-[10px] text-muted-foreground/60">-</span>; 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 status = getStatus(score);
const tooltipContent = `${score}% match`; const tooltip = (
<>
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 && ( {parsedNames.length > 0 && (
<p className="text-xs font-medium space-x-1"> <p className="text-xs font-medium space-x-1">
<span className="opacity-70">Matched</span> <span className="opacity-70">Matched</span>
<span>{parsedNames.join(", ")}</span> <span>{parsedNames.join(", ")}</span>
</p> </p>
)} )}
<p className="opacity-80 mt-1 text-[10px]">{tooltipContent}</p> <p className="opacity-80 mt-1 text-[10px]">{`${score}% match`}</p>
</TooltipContent> </>
</Tooltip> );
</TooltipProvider>
return (
<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, className,
onCheckSponsor, onCheckSponsor,
}) => { }) => {
const jobStatus = getJobStatusIndicator(job.status);
const tracerStatus = getTracerStatusIndicator(job.tracerLinksEnabled);
const { showSponsorInfo } = useSettings(); const { showSponsorInfo } = useSettings();
const { pathname } = useLocation(); const { pathname } = useLocation();
const isJobPage = pathname.startsWith("/job/"); const isJobPage = pathname.startsWith("/job/");
@ -267,8 +240,14 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
{/* Status and score: single line, subdued */} {/* 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 justify-between gap-2 py-1 border-y border-border/30">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<StatusPill status={job.status} /> <StatusIndicator
<TracerPill enabled={job.tracerLinksEnabled} /> dotColor={jobStatus.dotColor}
label={jobStatus.label}
/>
<StatusIndicator
dotColor={tracerStatus.dotColor}
label={tracerStatus.label}
/>
{showSponsorInfo && ( {showSponsorInfo && (
<SponsorPill <SponsorPill
score={job.sponsorMatchScore} score={job.sponsorMatchScore}

View File

@ -95,6 +95,7 @@ const settingsResponse = {
llmProvider: { value: "openrouter", default: "openrouter", override: null }, llmProvider: { value: "openrouter", default: "openrouter", override: null },
llmApiKeyHint: null, llmApiKeyHint: null,
rxresumeEmail: "", rxresumeEmail: "",
rxresumeApiKeyHint: null,
rxresumePasswordHint: null, rxresumePasswordHint: null,
rxresumeBaseResumeId: null, rxresumeBaseResumeId: null,
}, },
@ -139,6 +140,13 @@ describe("OnboardingGate", () => {
}); });
it("hides the gate when all validations succeed", async () => { 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({ vi.mocked(api.validateLlm).mockResolvedValue({
valid: true, valid: true,
message: null, message: null,
@ -177,8 +185,9 @@ describe("OnboardingGate", () => {
render(<OnboardingGate />); render(<OnboardingGate />);
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled()); await waitFor(() => expect(api.validateResumeConfig).toHaveBeenCalled());
expect(api.validateLlm).not.toHaveBeenCalled(); expect(api.validateLlm).not.toHaveBeenCalled();
expect(api.validateRxresume).not.toHaveBeenCalled();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument(); expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
}); });

View File

@ -1,17 +1,24 @@
import * as api from "@client/api"; import * as api from "@client/api";
import { ReactiveResumeConfigPanel } from "@client/components/ReactiveResumeConfigPanel";
import { useDemoInfo } from "@client/hooks/useDemoInfo"; import { useDemoInfo } from "@client/hooks/useDemoInfo";
import { useRxResumeConfigState } from "@client/hooks/useRxResumeConfigState";
import { useSettings } from "@client/hooks/useSettings"; 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 { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"; import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import { import {
formatSecretHint,
getLlmProviderConfig, getLlmProviderConfig,
LLM_PROVIDER_LABELS, LLM_PROVIDER_LABELS,
LLM_PROVIDERS, LLM_PROVIDERS,
normalizeLlmProvider, normalizeLlmProvider,
} from "@client/pages/settings/utils"; } from "@client/pages/settings/utils";
import type { UpdateSettingsInput } from "@shared/settings-schema.js"; 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 { Check } from "lucide-react";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useState } 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"; import { cn } from "@/lib/utils";
type ValidationState = ValidationResult & { checked: boolean }; type ValidationState = ValidationResult & { checked: boolean };
type TimestampedValidationState = ValidationState & { testedAt: number | null };
type OnboardingFormData = { type OnboardingFormData = {
llmProvider: string; llmProvider: string;
llmBaseUrl: string; llmBaseUrl: string;
llmApiKey: string; llmApiKey: string;
rxresumeMode: RxResumeMode;
rxresumeEmail: string; rxresumeEmail: string;
rxresumePassword: string; rxresumePassword: string;
rxresumeApiKey: string;
rxresumeBaseResumeId: string | null; 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: { function getStepPrimaryLabel(input: {
currentStep: string | null; currentStep: string | null;
llmValidated: boolean; llmValidated: boolean;
@ -76,29 +97,32 @@ export const OnboardingGate: React.FC = () => {
isLoading: settingsLoading, isLoading: settingsLoading,
refreshSettings, refreshSettings,
} = useSettings(); } = useSettings();
const {
storedRxResume,
getBaseResumeIdForMode,
setBaseResumeIdForMode,
syncBaseResumeIdsForMode,
} = useRxResumeConfigState(settings);
const [isSavingEnv, setIsSavingEnv] = useState(false); const [isSavingEnv, setIsSavingEnv] = useState(false);
const [isValidatingLlm, setIsValidatingLlm] = useState(false); const [isValidatingLlm, setIsValidatingLlm] = useState(false);
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false); const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false); const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
const [llmValidation, setLlmValidation] = useState<ValidationState>({ const [llmValidation, setLlmValidation] = useState<ValidationState>(
valid: false, EMPTY_VALIDATION_STATE,
message: null,
checked: false,
});
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
{
valid: false,
message: null,
checked: false,
},
); );
const [baseResumeValidation, setBaseResumeValidation] = const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
useState<ValidationState>({ EMPTY_VALIDATION_STATE,
valid: false, );
message: null, const [rxresumeVersionValidations, setRxresumeVersionValidations] = useState<{
checked: false, v4: TimestampedValidationState;
v5: TimestampedValidationState;
}>({
v4: EMPTY_TIMESTAMPED_VALIDATION_STATE,
v5: EMPTY_TIMESTAMPED_VALIDATION_STATE,
}); });
const [baseResumeValidation, setBaseResumeValidation] =
useState<ValidationState>(EMPTY_VALIDATION_STATE);
const [currentStep, setCurrentStep] = useState<string | null>(null); const [currentStep, setCurrentStep] = useState<string | null>(null);
const demoInfo = useDemoInfo(); const demoInfo = useDemoInfo();
const demoMode = demoInfo?.demoMode ?? false; const demoMode = demoInfo?.demoMode ?? false;
@ -109,8 +133,10 @@ export const OnboardingGate: React.FC = () => {
llmProvider: "", llmProvider: "",
llmBaseUrl: "", llmBaseUrl: "",
llmApiKey: "", llmApiKey: "",
rxresumeMode: "v5",
rxresumeEmail: "", rxresumeEmail: "",
rxresumePassword: "", rxresumePassword: "",
rxresumeApiKey: "",
rxresumeBaseResumeId: null, rxresumeBaseResumeId: null,
}, },
}); });
@ -149,28 +175,6 @@ export const OnboardingGate: React.FC = () => {
} }
}, [getValues, settings?.llmProvider]); }, [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 () => { const validateBaseResume = useCallback(async () => {
setIsValidatingBaseResume(true); setIsValidatingBaseResume(true);
try { try {
@ -190,6 +194,7 @@ export const OnboardingGate: React.FC = () => {
} }
}, []); }, []);
const rxresumeModeValue = watch("rxresumeMode");
const selectedProvider = normalizeLlmProvider( const selectedProvider = normalizeLlmProvider(
llmProvider || settings?.llmProvider?.value || "openrouter", llmProvider || settings?.llmProvider?.value || "openrouter",
); );
@ -203,8 +208,9 @@ export const OnboardingGate: React.FC = () => {
const llmKeyHint = settings?.llmApiKeyHint ?? null; const llmKeyHint = settings?.llmApiKeyHint ?? null;
const hasLlmKey = Boolean(llmKeyHint); const hasLlmKey = Boolean(llmKeyHint);
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()); const rxresumeModeCurrent = (rxresumeModeValue ||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint); settings?.rxresumeMode?.value ||
"v5") as RxResumeMode;
const hasCheckedValidations = const hasCheckedValidations =
(requiresLlmKey ? llmValidation.checked : true) && (requiresLlmKey ? llmValidation.checked : true) &&
rxresumeValidation.checked && rxresumeValidation.checked &&
@ -216,26 +222,83 @@ export const OnboardingGate: React.FC = () => {
hasCheckedValidations && hasCheckedValidations &&
!(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid); !(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid);
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim() const validateRxresumeVersion = useCallback(
? settings.rxresumeEmail async (
: undefined; version: "v4" | "v5",
const rxresumePasswordCurrent = settings?.rxresumePasswordHint ): Promise<ValidationResult & { checked: true; testedAt: number }> => {
? formatSecretHint(settings?.rxresumePasswordHint) const values = getValues();
: undefined; 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 // Initialize form values from settings
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
const initialMode = getInitialRxResumeMode({
savedMode: (settings.rxresumeMode?.value ??
null) as RxResumeMode | null,
hasV4: storedRxResume.hasV4,
hasV5: storedRxResume.hasV5,
});
const selectedId = syncBaseResumeIdsForMode(initialMode);
reset({ reset({
llmProvider: settings.llmProvider?.value || "", llmProvider: settings.llmProvider?.value || "",
llmBaseUrl: settings.llmBaseUrl?.value || "", llmBaseUrl: settings.llmBaseUrl?.value || "",
llmApiKey: "", llmApiKey: "",
rxresumeMode: initialMode,
rxresumeEmail: "", rxresumeEmail: "",
rxresumePassword: "", 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 // Clear base URL when provider doesn't require it
useEffect(() => { useEffect(() => {
@ -262,7 +325,7 @@ export const OnboardingGate: React.FC = () => {
{ {
id: "rxresume", id: "rxresume",
label: "Connect Reactive Resume", label: "Connect Reactive Resume",
subtitle: "Reactive Resume login", subtitle: "Version + credentials",
complete: rxresumeValidation.valid, complete: rxresumeValidation.valid,
disabled: false, disabled: false,
}, },
@ -334,20 +397,6 @@ export const OnboardingGate: React.FC = () => {
demoMode, 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 handleSaveLlm = async (): Promise<boolean> => {
const values = getValues(); const values = getValues();
const apiKeyValue = values.llmApiKey.trim(); const apiKeyValue = values.llmApiKey.trim();
@ -395,13 +444,13 @@ export const OnboardingGate: React.FC = () => {
const handleSaveRxresume = async (): Promise<boolean> => { const handleSaveRxresume = async (): Promise<boolean> => {
const values = getValues(); const values = getValues();
const emailValue = values.rxresumeEmail.trim(); const modeValue = values.rxresumeMode;
const passwordValue = values.rxresumePassword.trim(); const draftCredentials = getRxResumeCredentialDrafts(values);
const missing: string[] = []; const missing = getRxResumeMissingCredentialLabels({
mode: modeValue,
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email"); stored: storedRxResume,
if (!hasRxresumePassword && !passwordValue) draft: draftCredentials,
missing.push("RxResume password"); });
if (missing.length > 0) { if (missing.length > 0) {
toast.info("Almost there", { toast.info("Almost there", {
@ -411,22 +460,50 @@ export const OnboardingGate: React.FC = () => {
} }
try { try {
const validation = await validateRxresume(); setIsValidatingRxresume(true);
if (!validation.valid) { const result = await validateAndMaybePersistRxResumeMode({
toast.error(validation.message || "RxResume validation failed"); mode: modeValue,
return false; stored: storedRxResume,
} draft: draftCredentials,
validate: api.validateRxresume,
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {}; persist: async (update) => {
if (emailValue) update.rxresumeEmail = emailValue;
if (passwordValue) update.rxresumePassword = passwordValue;
if (Object.keys(update).length > 0) {
setIsSavingEnv(true); setIsSavingEnv(true);
try {
await api.updateSettings(update); await api.updateSettings(update);
await refreshSettings(); await refreshSettings();
setValue("rxresumePassword", ""); } 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;
}
setValue("rxresumePassword", "");
setValue("rxresumeApiKey", "");
toast.success("RxResume connected"); toast.success("RxResume connected");
return true; return true;
@ -438,6 +515,7 @@ export const OnboardingGate: React.FC = () => {
toast.error(message); toast.error(message);
return false; return false;
} finally { } finally {
setIsValidatingRxresume(false);
setIsSavingEnv(false); setIsSavingEnv(false);
} }
}; };
@ -453,6 +531,7 @@ export const OnboardingGate: React.FC = () => {
try { try {
setIsSavingEnv(true); setIsSavingEnv(true);
await api.updateSettings({ await api.updateSettings({
rxresumeMode: values.rxresumeMode,
rxresumeBaseResumeId: values.rxresumeBaseResumeId, rxresumeBaseResumeId: values.rxresumeBaseResumeId,
}); });
const validation = await validateBaseResume(); const validation = await validateBaseResume();
@ -488,12 +567,6 @@ export const OnboardingGate: React.FC = () => {
isValidatingRxresume || isValidatingRxresume ||
isValidatingBaseResume; isValidatingBaseResume;
const canGoBack = stepIndex > 0; const canGoBack = stepIndex > 0;
const primaryLabel = getStepPrimaryLabel({
currentStep,
llmValidated,
rxresumeValidated: rxresumeValidation.valid,
baseResumeValidated: baseResumeValidation.valid,
});
const handlePrimaryAction = async () => { const handlePrimaryAction = async () => {
if (!currentStep) return; if (!currentStep) return;
@ -671,60 +744,39 @@ export const OnboardingGate: React.FC = () => {
</TabsContent> </TabsContent>
<TabsContent value="rxresume" className="space-y-4 pt-6"> <TabsContent value="rxresume" className="space-y-4 pt-6">
<div> <ReactiveResumeConfigPanel
<p className="text-sm font-semibold"> mode={rxresumeModeCurrent}
Link your RxResume account onModeChange={(mode) => {
</p> setValue("rxresumeMode", mode);
<p className="text-xs text-muted-foreground"> setValue(
Used to export tailored PDFs. Create an account{" "} "rxresumeBaseResumeId",
<a getBaseResumeIdForMode(mode),
className="underline underline-offset-2" );
href="https://v4.rxresu.me/auth/register" setRxresumeValidation((previous) => ({
target="_blank" ...EMPTY_VALIDATION_STATE,
rel="noreferrer" checked: previous.checked,
> }));
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} disabled={isSavingEnv}
/> showValidationStatus
)} validationStatuses={rxresumeVersionValidations}
/> intro={{
<Controller title: "Link your RxResume account",
name="rxresumePassword" description:
control={control} "Used to export tailored PDFs. Choose between Reactive Resume version 4 and 5, and provide the credentials.",
render={({ field }) => ( }}
<SettingsInput v5={{
label="Password" apiKey: watch("rxresumeApiKey"),
inputProps={{ onApiKeyChange: (value) => setValue("rxresumeApiKey", value),
name: "rxresumePassword", }}
value: field.value, v4={{
onChange: field.onChange, email: watch("rxresumeEmail"),
onEmailChange: (value) => setValue("rxresumeEmail", value),
password: watch("rxresumePassword"),
onPasswordChange: (value) =>
setValue("rxresumePassword", value),
}} }}
type="password"
placeholder="Enter password"
current={rxresumePasswordCurrent}
disabled={isSavingEnv}
/> />
)}
/>
</div>
</TabsContent> </TabsContent>
<TabsContent value="baseresume" className="space-y-4 pt-6"> <TabsContent value="baseresume" className="space-y-4 pt-6">
@ -743,8 +795,14 @@ export const OnboardingGate: React.FC = () => {
render={({ field }) => ( render={({ field }) => (
<BaseResumeSelection <BaseResumeSelection
value={field.value} value={field.value}
onValueChange={field.onChange} onValueChange={(value) => {
const mode = (getValues("rxresumeMode") ??
"v5") as RxResumeMode;
setBaseResumeIdForMode(mode, value);
field.onChange(value);
}}
hasRxResumeAccess={rxresumeValidation.valid} hasRxResumeAccess={rxresumeValidation.valid}
rxresumeMode={rxresumeModeCurrent}
disabled={isSavingEnv} disabled={isSavingEnv}
/> />
)} )}
@ -761,30 +819,20 @@ export const OnboardingGate: React.FC = () => {
Back Back
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" onClick={handleRefresh} disabled={isBusy}>
Refresh status
</Button>
<Button onClick={handlePrimaryAction} disabled={isBusy}> <Button onClick={handlePrimaryAction} disabled={isBusy}>
{isBusy ? "Working..." : primaryLabel} {isBusy
? "Validating..."
: getStepPrimaryLabel({
currentStep,
llmValidated,
rxresumeValidated: rxresumeValidation.valid,
baseResumeValidated: baseResumeValidation.valid,
})}
</Button> </Button>
</div> </div>
</div> </div>
<Progress value={progressValue} className="h-2" /> <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> </div>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between"> <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 && ( {tooManyProjects && (
<span className="flex items-center gap-1 text-[10px] text-amber-500 font-medium"> <span className="flex items-center gap-1 text-[10px] text-amber-500 font-medium">
<AlertTriangle className="h-3 w-3" /> <AlertTriangle className="h-3 w-3" />

View File

@ -25,6 +25,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useVersionCheck } from "../hooks/useVersionCheck"; import { useVersionCheck } from "../hooks/useVersionCheck";
import { isNavActive, NAV_LINKS } from "./navigation"; import { isNavActive, NAV_LINKS } from "./navigation";
import { StatusBadgeIndicator } from "./StatusIndicator";
// ============================================================================ // ============================================================================
// Page Header // Page Header
@ -165,47 +166,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
); );
}; };
// ============================================================================ export const StatusIndicator = StatusBadgeIndicator;
// 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>
);
};
// ============================================================================ // ============================================================================
// Split Layout (List + Detail panels) // 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", () => ({ vi.mock("../api", () => ({
getSettings: vi.fn(), getSettings: vi.fn(),
updateSettings: vi.fn(), updateSettings: vi.fn(),
validateRxresume: vi.fn(),
clearDatabase: vi.fn(), clearDatabase: vi.fn(),
deleteJobsByStatus: vi.fn(), deleteJobsByStatus: vi.fn(),
getTracerReadiness: 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", () => { describe("SettingsPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -70,6 +76,10 @@ describe("SettingsPage", () => {
lastSuccessAt: Date.now(), lastSuccessAt: Date.now(),
reason: null, reason: null,
}); });
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message: "Missing credentials",
});
}); });
it("saves trimmed model overrides", async () => { it("saves trimmed model overrides", async () => {
@ -84,6 +94,7 @@ describe("SettingsPage", () => {
}); });
renderPage(); renderPage();
await openModelSection();
const modelInput = screen.getByLabelText(/default model/i); const modelInput = screen.getByLabelText(/default model/i);
await waitFor(() => expect(modelInput).toBeEnabled()); await waitFor(() => expect(modelInput).toBeEnabled());
@ -107,6 +118,7 @@ describe("SettingsPage", () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings); vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage(); renderPage();
await openModelSection();
const modelInput = screen.getByLabelText(/default model/i); const modelInput = screen.getByLabelText(/default model/i);
await waitFor(() => expect(modelInput).toBeEnabled()); await waitFor(() => expect(modelInput).toBeEnabled());
@ -166,6 +178,7 @@ describe("SettingsPage", () => {
renderPage(); renderPage();
const saveButton = screen.getByRole("button", { name: /^save$/i }); const saveButton = screen.getByRole("button", { name: /^save$/i });
expect(saveButton).toBeDisabled(); expect(saveButton).toBeDisabled();
await openModelSection();
const modelInput = screen.getByLabelText(/default model/i); const modelInput = screen.getByLabelText(/default model/i);
// Wait for the query to resolve and input to be enabled // Wait for the query to resolve and input to be enabled
@ -207,7 +220,40 @@ describe("SettingsPage", () => {
/show visa sponsor information/i, /show visa sponsor information/i,
); );
fireEvent.click(sponsorCheckbox); 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 () => { it("enables save button when basic auth toggle is changed", async () => {

View File

@ -1,7 +1,15 @@
import * as api from "@client/api"; import * as api from "@client/api";
import { PageHeader } from "@client/components/layout"; import { PageHeader } from "@client/components/layout";
import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation"; import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation";
import { useRxResumeConfigState } from "@client/hooks/useRxResumeConfigState";
import { useTracerReadiness } from "@client/hooks/useTracerReadiness"; 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 { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection"; import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"; import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
@ -28,12 +36,18 @@ import type {
JobStatus, JobStatus,
ResumeProjectCatalogItem, ResumeProjectCatalogItem,
ResumeProjectsSettings, ResumeProjectsSettings,
RxResumeMode,
} from "@shared/types.js"; } from "@shared/types.js";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useState } 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 { toast } from "sonner";
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast"; import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
import { queryKeys } from "@/client/lib/queryKeys"; import { queryKeys } from "@/client/lib/queryKeys";
@ -51,6 +65,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
pipelineWebhookUrl: "", pipelineWebhookUrl: "",
jobCompleteWebhookUrl: "", jobCompleteWebhookUrl: "",
resumeProjects: null, resumeProjects: null,
rxresumeMode: "v5",
rxresumeBaseResumeId: null, rxresumeBaseResumeId: null,
showSponsorInfo: null, showSponsorInfo: null,
chatStyleTone: "", chatStyleTone: "",
@ -59,6 +74,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
chatStyleDoNotUse: "", chatStyleDoNotUse: "",
rxresumeEmail: "", rxresumeEmail: "",
rxresumePassword: "", rxresumePassword: "",
rxresumeApiKey: "",
basicAuthUser: "", basicAuthUser: "",
basicAuthPassword: "", basicAuthPassword: "",
ukvisajobsEmail: "", ukvisajobsEmail: "",
@ -77,6 +93,16 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
}; };
type LlmProviderValue = LlmProviderId | null; 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 = ( const normalizeLlmProviderValue = (
value: string | null | undefined, value: string | null | undefined,
@ -93,6 +119,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
pipelineWebhookUrl: null, pipelineWebhookUrl: null,
jobCompleteWebhookUrl: null, jobCompleteWebhookUrl: null,
resumeProjects: null, resumeProjects: null,
rxresumeMode: null,
rxresumeBaseResumeId: null, rxresumeBaseResumeId: null,
showSponsorInfo: null, showSponsorInfo: null,
chatStyleTone: null, chatStyleTone: null,
@ -101,6 +128,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
chatStyleDoNotUse: null, chatStyleDoNotUse: null,
rxresumeEmail: null, rxresumeEmail: null,
rxresumePassword: null, rxresumePassword: null,
rxresumeApiKey: null,
basicAuthUser: null, basicAuthUser: null,
basicAuthPassword: null, basicAuthPassword: null,
ukvisajobsEmail: null, ukvisajobsEmail: null,
@ -130,6 +158,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
pipelineWebhookUrl: data.pipelineWebhookUrl.override ?? "", pipelineWebhookUrl: data.pipelineWebhookUrl.override ?? "",
jobCompleteWebhookUrl: data.jobCompleteWebhookUrl.override ?? "", jobCompleteWebhookUrl: data.jobCompleteWebhookUrl.override ?? "",
resumeProjects: data.resumeProjects.override, resumeProjects: data.resumeProjects.override,
rxresumeMode: data.rxresumeMode.override ?? data.rxresumeMode.value,
rxresumeBaseResumeId: data.rxresumeBaseResumeId, rxresumeBaseResumeId: data.rxresumeBaseResumeId,
showSponsorInfo: data.showSponsorInfo.override, showSponsorInfo: data.showSponsorInfo.override,
chatStyleTone: data.chatStyleTone.override ?? "", chatStyleTone: data.chatStyleTone.override ?? "",
@ -138,6 +167,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
chatStyleDoNotUse: data.chatStyleDoNotUse.override ?? "", chatStyleDoNotUse: data.chatStyleDoNotUse.override ?? "",
rxresumeEmail: data.rxresumeEmail ?? "", rxresumeEmail: data.rxresumeEmail ?? "",
rxresumePassword: "", rxresumePassword: "",
rxresumeApiKey: "",
basicAuthUser: data.basicAuthUser ?? "", basicAuthUser: data.basicAuthUser ?? "",
basicAuthPassword: "", basicAuthPassword: "",
ukvisajobsEmail: data.ukvisajobsEmail ?? "", ukvisajobsEmail: data.ukvisajobsEmail ?? "",
@ -312,6 +342,13 @@ export const SettingsPage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [settings, setSettings] = useState<AppSettings | null>(null); const [settings, setSettings] = useState<AppSettings | null>(null);
const [isSaving, setIsSaving] = useState(false); 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[]>([ const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
"discovered", "discovered",
]); ]);
@ -348,9 +385,15 @@ export const SettingsPage: React.FC = () => {
setError, setError,
setValue, setValue,
getValues, getValues,
watch, control,
formState: { isDirty, errors, isValid, dirtyFields }, formState: { isDirty, errors, isValid, dirtyFields },
} = methods; } = methods;
const {
storedRxResume,
getBaseResumeIdForMode,
setBaseResumeIdForMode,
syncBaseResumeIdsForMode,
} = useRxResumeConfigState(settings);
const settingsQuery = useQuery({ const settingsQuery = useQuery({
queryKey: queryKeys.settings.current(), queryKey: queryKeys.settings.current(),
@ -367,8 +410,17 @@ export const SettingsPage: React.FC = () => {
const isLoadingBackups = backupsQuery.isLoading; const isLoadingBackups = backupsQuery.isLoading;
useQueryErrorToast(backupsQuery.error, "Failed to load backups"); 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( const hasRxResumeAccess = Boolean(
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint, rxresumeValidationStatuses[selectedRxresumeMode].valid,
); );
useEffect(() => { useEffect(() => {
@ -381,11 +433,12 @@ export const SettingsPage: React.FC = () => {
useEffect(() => { useEffect(() => {
if (!settings) return; if (!settings) return;
const storedId = settings.rxresumeBaseResumeId ?? null; const effectiveMode = coerceRxResumeMode(settings.rxresumeMode?.value);
const storedId = syncBaseResumeIdsForMode(effectiveMode);
setRxResumeBaseResumeIdDraft(storedId); setRxResumeBaseResumeIdDraft(storedId);
setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false }); setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false });
setRxResumeProjectsOverride(null); setRxResumeProjectsOverride(null);
}, [settings, setValue]); }, [settings, setValue, syncBaseResumeIdsForMode]);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@ -407,7 +460,11 @@ export const SettingsPage: React.FC = () => {
setIsFetchingRxResumeProjects(true); setIsFetchingRxResumeProjects(true);
api api
.getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal) .getRxResumeProjects(
rxResumeBaseResumeIdDraft,
controller.signal,
selectedRxresumeMode,
)
.then((projects) => { .then((projects) => {
if (!isMounted) return; if (!isMounted) return;
setRxResumeProjectsOverride(projects); setRxResumeProjectsOverride(projects);
@ -437,7 +494,13 @@ export const SettingsPage: React.FC = () => {
isMounted = false; isMounted = false;
controller.abort(); controller.abort();
}; };
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue]); }, [
rxResumeBaseResumeIdDraft,
hasRxResumeAccess,
selectedRxresumeMode,
getValues,
setValue,
]);
const derived = getDerivedSettings(settings); const derived = getDerivedSettings(settings);
const { const {
@ -511,12 +574,93 @@ export const SettingsPage: React.FC = () => {
} }
}, [refreshReadiness]); }, [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 effectiveMaxProjectsTotal = effectiveProfileProjects.length;
const watchedValues = watch(); const lockedCount = resumeProjectsValue?.lockedProjectIds.length ?? 0;
const lockedCount =
watchedValues.resumeProjects?.lockedProjectIds.length ?? 0;
const canSave = isDirty && isValid; const canSave = isDirty && isValid;
@ -594,6 +738,11 @@ export const SettingsPage: React.FC = () => {
if (value !== undefined) envPayload.rxresumePassword = value; if (value !== undefined) envPayload.rxresumePassword = value;
} }
if (dirtyFields.rxresumeApiKey) {
const value = normalizePrivateInput(data.rxresumeApiKey);
if (value !== undefined) envPayload.rxresumeApiKey = value;
}
if (dirtyFields.ukvisajobsPassword) { if (dirtyFields.ukvisajobsPassword) {
const value = normalizePrivateInput(data.ukvisajobsPassword); const value = normalizePrivateInput(data.ukvisajobsPassword);
if (value !== undefined) envPayload.ukvisajobsPassword = value; if (value !== undefined) envPayload.ukvisajobsPassword = value;
@ -617,6 +766,7 @@ export const SettingsPage: React.FC = () => {
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl), pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl), jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
resumeProjects: resumeProjectsOverride, resumeProjects: resumeProjectsOverride,
rxresumeMode: data.rxresumeMode ?? "v5",
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId), rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default), showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
chatStyleTone: normalizeString(data.chatStyleTone), 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"> <main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
<Accordion <Accordion type="multiple" className="w-full space-y-4">
type="multiple"
className="w-full space-y-4"
defaultValue={["model", "feature", "webhooks", "chat"]}
>
<ModelSettingsSection <ModelSettingsSection
values={model} values={model}
isLoading={isLoading} isLoading={isLoading}
@ -800,11 +946,22 @@ export const SettingsPage: React.FC = () => {
/> />
<ReactiveResumeSection <ReactiveResumeSection
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft} rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
onRxresumeModeChange={(mode) => {
const nextId = getBaseResumeIdForMode(mode);
setRxResumeBaseResumeIdDraft(nextId);
setValue("rxresumeBaseResumeId", nextId, { shouldDirty: true });
setRxResumeProjectsOverride(null);
}}
setRxResumeBaseResumeIdDraft={(value) => { setRxResumeBaseResumeIdDraft={(value) => {
const mode = (getValues("rxresumeMode") ??
rxresumeMode) as RxResumeMode;
setBaseResumeIdForMode(mode, value);
setRxResumeBaseResumeIdDraft(value); setRxResumeBaseResumeIdDraft(value);
setValue("rxresumeBaseResumeId", value, { shouldDirty: true }); setValue("rxresumeBaseResumeId", value, { shouldDirty: true });
}} }}
hasRxResumeAccess={hasRxResumeAccess} hasRxResumeAccess={hasRxResumeAccess}
rxresumeMode={rxresumeMode}
validationStatuses={rxresumeValidationStatuses}
profileProjects={effectiveProfileProjects} profileProjects={effectiveProfileProjects}
lockedCount={lockedCount} lockedCount={lockedCount}
maxProjectsTotal={effectiveMaxProjectsTotal} maxProjectsTotal={effectiveMaxProjectsTotal}

View File

@ -1,4 +1,5 @@
import * as api from "@client/api"; import * as api from "@client/api";
import type { RxResumeMode } from "@shared/types.js";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@ -15,6 +16,7 @@ type BaseResumeSelectionProps = {
value: string | null; value: string | null;
onValueChange: (value: string | null) => void; onValueChange: (value: string | null) => void;
hasRxResumeAccess: boolean; hasRxResumeAccess: boolean;
rxresumeMode?: RxResumeMode;
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
}; };
@ -23,6 +25,7 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
value, value,
onValueChange, onValueChange,
hasRxResumeAccess, hasRxResumeAccess,
rxresumeMode,
disabled = false, disabled = false,
isLoading = false, isLoading = false,
}) => { }) => {
@ -31,12 +34,16 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
const fetchResumes = useCallback(async () => { const fetchResumes = useCallback(async () => {
if (!hasRxResumeAccess) return; if (!hasRxResumeAccess) {
setResumes([]);
setFetchError(null);
return;
}
setIsFetchingResumes(true); setIsFetchingResumes(true);
setFetchError(null); setFetchError(null);
try { try {
const data = await api.getRxResumes(); const data = await api.getRxResumes(rxresumeMode);
setResumes(data); setResumes(data);
// Preselect if only one option is available and no value is currently set // 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); onValueChange(data[0].id);
} }
} catch (error) { } catch (error) {
setResumes([]);
setFetchError( setFetchError(
error instanceof Error ? error.message : "Failed to fetch resumes", error instanceof Error ? error.message : "Failed to fetch resumes",
); );
} finally { } finally {
setIsFetchingResumes(false); setIsFetchingResumes(false);
} }
}, [hasRxResumeAccess, onValueChange, value]); }, [hasRxResumeAccess, onValueChange, rxresumeMode, value]);
useEffect(() => { useEffect(() => {
if (hasRxResumeAccess) { if (hasRxResumeAccess) {
@ -58,6 +66,13 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
} }
}, [hasRxResumeAccess, fetchResumes]); }, [hasRxResumeAccess, fetchResumes]);
useEffect(() => {
if (!hasRxResumeAccess) {
setResumes([]);
setFetchError(null);
}
}, [hasRxResumeAccess]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <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", () => { it("renders values grouped logically and masks private secrets with hints", () => {
render(<EnvironmentSettingsHarness />); render(<EnvironmentSettingsHarness />);
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument();
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument(); expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument();
expect(screen.getByDisplayValue("adzuna-id")).toBeInTheDocument(); expect(screen.getByDisplayValue("adzuna-id")).toBeInTheDocument();
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument(); expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument();
expect(screen.getByText(/adzu\*{8}/)).toBeInTheDocument(); expect(screen.getByText(/adzu\*{8}/)).toBeInTheDocument();
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument(); expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument();
expect(screen.getByText("Not set")).toBeInTheDocument();
// Basic Auth // Basic Auth
expect(screen.getByLabelText("Enable basic authentication")).toBeChecked(); expect(screen.getByLabelText("Enable basic authentication")).toBeChecked();
@ -68,5 +66,6 @@ describe("EnvironmentSettingsSection", () => {
// Sections // Sections
expect(screen.getByText("Service Accounts")).toBeInTheDocument(); expect(screen.getByText("Service Accounts")).toBeInTheDocument();
expect(screen.getByText("Security")).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 Service Accounts
</div> </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="space-y-4">
<div className="text-sm font-semibold">UKVisaJobs</div> <div className="text-sm font-semibold">UKVisaJobs</div>
<div className="grid gap-4 md:grid-cols-2"> <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 { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { ResumeProjectCatalogItem } from "@shared/types.js"; import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types.js";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import type React from "react"; import type React from "react";
import { Controller, useFormContext } from "react-hook-form"; import {
type Path,
type PathValue,
useFormContext,
useWatch,
} from "react-hook-form";
import { import {
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } 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 = { type ReactiveResumeSectionProps = {
rxResumeBaseResumeIdDraft: string | null; rxResumeBaseResumeIdDraft: string | null;
setRxResumeBaseResumeIdDraft: (value: string | null) => void; setRxResumeBaseResumeIdDraft: (value: string | null) => void;
// True when v4 credentials or v5 API key are configured. // True when v4 credentials or v5 API key are configured.
hasRxResumeAccess: boolean; 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[]; profileProjects: ResumeProjectCatalogItem[];
lockedCount: number; lockedCount: number;
maxProjectsTotal: number; maxProjectsTotal: number;
@ -44,6 +37,9 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
rxResumeBaseResumeIdDraft, rxResumeBaseResumeIdDraft,
setRxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft,
hasRxResumeAccess, hasRxResumeAccess,
rxresumeMode,
onRxresumeModeChange,
validationStatuses,
profileProjects, profileProjects,
lockedCount, lockedCount,
maxProjectsTotal, maxProjectsTotal,
@ -53,8 +49,25 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
}) => { }) => {
const { const {
control, control,
setValue,
formState: { errors }, formState: { errors },
} = useFormContext<UpdateSettingsInput>(); } = 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 ( return (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4"> <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> <span className="text-base font-semibold">Reactive Resume</span>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="pb-4"> <AccordionContent className="pb-4">
<div className="space-y-4"> <ReactiveResumeConfigPanel
{!hasRxResumeAccess ? ( mode={selectedMode}
<Alert variant="destructive"> onModeChange={(mode) => {
<AlertCircle className="h-4 w-4" /> onRxresumeModeChange?.(mode);
<AlertTitle>RxResume Access Missing</AlertTitle> setDirtyTouchedValue("rxresumeMode", mode);
<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} disabled={isLoading || isSaving}
/> hasRxResumeAccess={hasRxResumeAccess}
showValidationStatus={Boolean(validationStatuses)}
<Separator /> validationStatuses={validationStatuses}
v5={{
<div className="space-y-4"> apiKey: rxresumeApiKeyValue,
{!rxResumeBaseResumeIdDraft ? ( onApiKeyChange: (value) =>
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground"> setDirtyTouchedValue("rxresumeApiKey", value),
Choose a PDF to configure resume projects. error: errors.rxresumeApiKey?.message as string | undefined,
</div> }}
) : ( v4={{
<> email: rxresumeEmailValue,
<div className="space-y-2"> onEmailChange: (value) =>
<div className="text-sm font-medium"> setDirtyTouchedValue("rxresumeEmail", value),
Max projects to choose emailError: errors.rxresumeEmail?.message as string | undefined,
</div> password: rxresumePasswordValue,
<Controller onPasswordChange: (value) =>
name="resumeProjects" setDirtyTouchedValue("rxresumePassword", value),
control={control} passwordError: errors.rxresumePassword?.message as
render={({ field }) => ( | string
<Input | undefined,
type="number" }}
inputMode="numeric" projectSelection={{
min={lockedCount} baseResumeId: rxResumeBaseResumeIdDraft,
max={maxProjectsTotal} onBaseResumeIdChange: setRxResumeBaseResumeIdDraft,
value={field.value?.maxProjects ?? 0} projects: profileProjects,
onChange={(event) => { value: resumeProjectsValue,
if (!field.value) return; onChange: (next) => setDirtyTouchedValue("resumeProjects", next),
const next = Number(event.target.value);
const clamped = clampInt(
next,
lockedCount, lockedCount,
maxProjectsTotal, maxProjectsTotal,
); isProjectsLoading,
field.onChange({ disabled: isLoading || isSaving,
...field.value, maxProjectsError:
maxProjects: clamped, errors.resumeProjects?.maxProjects?.message?.toString(),
});
}}
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>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
); );

View File

@ -1,5 +1,5 @@
import type { Server } from "node:http"; 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils"; import { startServer, stopServer } from "./test-utils";
@ -224,7 +224,7 @@ describe.sequential("Onboarding API routes", () => {
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.ok).toBe(true); expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false); 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 () => { it("returns invalid when only email is provided", async () => {
@ -237,7 +237,7 @@ describe.sequential("Onboarding API routes", () => {
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false); 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 () => { it("returns invalid when only password is provided", async () => {
@ -250,7 +250,7 @@ describe.sequential("Onboarding API routes", () => {
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false); 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 () => { it("validates invalid credentials against RxResume", async () => {
@ -275,6 +275,37 @@ describe.sequential("Onboarding API routes", () => {
expect(body.data.valid).toBe(false); 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 () => { it("handles whitespace-only credentials", async () => {
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, { const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
method: "POST", method: "POST",
@ -285,7 +316,7 @@ describe.sequential("Onboarding API routes", () => {
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false); 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 { logger } from "@infra/logger";
import { isDemoMode } from "@server/config/demo"; import { isDemoMode } from "@server/config/demo";
import { getSetting } from "@server/repositories/settings"; import { getSetting } from "@server/repositories/settings";
import { LlmService } from "@server/services/llm/service"; import { LlmService } from "@server/services/llm/service";
import { RxResumeClient } from "@server/services/rxresume-client";
import { import {
getResume, getResume,
RxResumeCredentialsError, RxResumeAuthConfigError,
} from "@server/services/rxresume-v4"; validateResumeSchema,
import { resumeDataSchema } from "@shared/rxresume-schema"; validateCredentials as validateRxResumeCredentials,
} from "@server/services/rxresume";
import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
export const onboardingRouter = Router(); 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> { async function validateResumeConfig(): Promise<ValidationResponse> {
try { try {
// Check if rxresumeBaseResumeId is configured // Check if rxresumeBaseResumeId is configured
const rxresumeBaseResumeId = await getSetting("rxresumeBaseResumeId"); const { resumeId: rxresumeBaseResumeId } =
await getConfiguredRxResumeBaseResumeId();
if (!rxresumeBaseResumeId) { if (!rxresumeBaseResumeId) {
return { return {
@ -80,23 +82,17 @@ async function validateResumeConfig(): Promise<ValidationResponse> {
}; };
} }
// Validate against schema const validated = await validateResumeSchema(resume.data);
const result = resumeDataSchema.safeParse(resume.data); if (!validated.ok) {
if (!result.success) { return { valid: false, message: validated.message };
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 };
} }
return { valid: true, message: null }; return { valid: true, message: null };
} catch (error) { } catch (error) {
if (error instanceof RxResumeCredentialsError) { if (error instanceof RxResumeAuthConfigError) {
return { return {
valid: false, valid: false,
message: "RxResume credentials not configured.", message: error.message,
}; };
} }
const message = const message =
@ -112,29 +108,32 @@ async function validateResumeConfig(): Promise<ValidationResponse> {
} }
} }
async function validateRxresume( async function validateRxresume(options?: {
email?: string | null, mode?: string | null;
password?: string | null, email?: string | null;
): Promise<ValidationResponse> { password?: string | null;
const rxEmail = email?.trim() || process.env.RXRESUME_EMAIL || ""; apiKey?: string | null;
const rxPassword = password?.trim() || process.env.RXRESUME_PASSWORD || ""; baseUrl?: string | null;
const rxUrl = process.env.RXRESUME_URL || "https://v4.rxresu.me"; }): Promise<ValidationResponse> {
const rawMode = options?.mode?.trim();
const mode = rawMode === "v4" || rawMode === "v5" ? rawMode : undefined;
if (!rxEmail || !rxPassword) { const result = await validateRxResumeCredentials({
return { valid: false, message: "RxResume credentials are missing." }; 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( if (result.ok) return { valid: true, message: null };
rxEmail,
rxPassword,
rxUrl,
);
if (result.ok) { const normalizedMessage = result.message.toLowerCase();
return { valid: true, message: null };
}
const normalizedMessage = result.message?.toLowerCase() ?? "";
if ( if (
result.status === 401 || result.status === 401 ||
normalizedMessage.includes("invalidcredentials") normalizedMessage.includes("invalidcredentials")
@ -142,13 +141,11 @@ async function validateRxresume(
return { return {
valid: false, valid: false,
message: 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 = return { valid: false, message: result.message };
result.message || `RxResume validation failed (HTTP ${result.status})`;
return { valid: false, message };
} }
onboardingRouter.post( onboardingRouter.post(
@ -213,8 +210,19 @@ onboardingRouter.post(
typeof req.body?.email === "string" ? req.body.email : undefined; typeof req.body?.email === "string" ? req.body.email : undefined;
const password = const password =
typeof req.body?.password === "string" ? req.body.password : undefined; typeof req.body?.password === "string" ? req.body.password : undefined;
const result = await validateRxresume(email, password); const mode = typeof req.body?.mode === "string" ? req.body.mode : undefined;
res.json({ success: true, data: result }); 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(); 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils"; import { startServer, stopServer } from "./test-utils";
// Mock the rxresume-v4 service // Mock the RxResume adapter service
vi.mock("@server/services/rxresume-v4", () => ({ vi.mock("@server/services/rxresume", () => ({
getResume: vi.fn(), getResume: vi.fn(),
listResumes: vi.fn(), RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error {
RxResumeCredentialsError: class RxResumeCredentialsError extends Error {
constructor() { constructor() {
super("RxResume credentials not configured."); super("Reactive Resume credentials not configured.");
this.name = "RxResumeCredentialsError"; this.name = "RxResumeAuthConfigError";
} }
}, },
})); }));
@ -31,10 +30,7 @@ vi.mock("@server/repositories/settings", async (importOriginal) => {
import { getSetting } from "@server/repositories/settings"; import { getSetting } from "@server/repositories/settings";
import { getProfile } from "@server/services/profile"; import { getProfile } from "@server/services/profile";
import { import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume";
getResume,
RxResumeCredentialsError,
} from "@server/services/rxresume-v4";
describe.sequential("Profile API routes", () => { describe.sequential("Profile API routes", () => {
let server: Server; let server: Server;
@ -192,7 +188,9 @@ describe.sequential("Profile API routes", () => {
it("returns exists: false when RxResume credentials are missing", async () => { it("returns exists: false when RxResume credentials are missing", async () => {
vi.mocked(getSetting).mockResolvedValue("test-resume-id"); 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 res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json(); 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 { isDemoMode } from "@server/config/demo";
import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults"; import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults";
import { getSetting } from "@server/repositories/settings";
import { clearProfileCache, getProfile } from "@server/services/profile"; import { clearProfileCache, getProfile } from "@server/services/profile";
import { extractProjectsFromProfile } from "@server/services/resumeProjects"; import { extractProjectsFromProfile } from "@server/services/resumeProjects";
import { import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume";
getResume, import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
RxResumeCredentialsError,
} from "@server/services/rxresume-v4";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
export const profileRouter = Router(); export const profileRouter = Router();
@ -22,10 +21,9 @@ profileRouter.get("/projects", async (_req: Request, res: Response) => {
} }
const profile = await getProfile(); const profile = await getProfile();
const { catalog } = extractProjectsFromProfile(profile); const { catalog } = extractProjectsFromProfile(profile);
res.json({ success: true, data: catalog }); ok(res, catalog);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; fail(res, toAppError(error));
res.status(500).json({ success: false, error: message });
} }
}); });
@ -35,10 +33,9 @@ profileRouter.get("/projects", async (_req: Request, res: Response) => {
profileRouter.get("/", async (_req: Request, res: Response) => { profileRouter.get("/", async (_req: Request, res: Response) => {
try { try {
const profile = await getProfile(); const profile = await getProfile();
res.json({ success: true, data: profile }); ok(res, profile);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; fail(res, toAppError(error));
res.status(500).json({ success: false, error: message });
} }
}); });
@ -47,16 +44,14 @@ profileRouter.get("/", async (_req: Request, res: Response) => {
*/ */
profileRouter.get("/status", async (_req: Request, res: Response) => { profileRouter.get("/status", async (_req: Request, res: Response) => {
try { try {
const rxresumeBaseResumeId = await getSetting("rxresumeBaseResumeId"); const { resumeId: rxresumeBaseResumeId } =
await getConfiguredRxResumeBaseResumeId();
if (!rxresumeBaseResumeId) { if (!rxresumeBaseResumeId) {
res.json({ ok(res, {
success: true,
data: {
exists: false, exists: false,
error: error:
"No base resume selected. Please select a resume from your RxResume account in Settings.", "No base resume selected. Please select a resume from your Reactive Resume account in Settings.",
},
}); });
return; return;
} }
@ -65,46 +60,36 @@ profileRouter.get("/status", async (_req: Request, res: Response) => {
try { try {
const resume = await getResume(rxresumeBaseResumeId); const resume = await getResume(rxresumeBaseResumeId);
if (!resume.data || typeof resume.data !== "object") { if (!resume.data || typeof resume.data !== "object") {
res.json({ ok(res, {
success: true,
data: {
exists: false, exists: false,
error: "Selected resume is empty or invalid.", error: "Selected resume is empty or invalid.",
},
}); });
return; return;
} }
res.json({ success: true, data: { exists: true, error: null } }); ok(res, { exists: true, error: null });
} catch (error) { } catch (error) {
if (error instanceof RxResumeCredentialsError) { if (error instanceof RxResumeAuthConfigError) {
res.json({ ok(res, { exists: false, error: error.message });
success: true,
data: {
exists: false,
error: "RxResume credentials not configured.",
},
});
return; return;
} }
throw error; throw error;
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown 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) => { profileRouter.post("/refresh", async (_req: Request, res: Response) => {
try { try {
clearProfileCache(); clearProfileCache();
const profile = await getProfile(true); const profile = await getProfile(true);
res.json({ success: true, data: profile }); ok(res, profile);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; fail(res, toAppError(error));
res.status(500).json({ success: false, error: message });
} }
}); });

View File

@ -1,5 +1,61 @@
import type { Server } from "node:http"; 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"; import { startServer, stopServer } from "./test-utils";
describe.sequential("Settings API routes", () => { describe.sequential("Settings API routes", () => {
@ -118,4 +174,70 @@ describe.sequential("Settings API routes", () => {
expect(getBody.data.penalizeMissingSalary.value).toBe(true); expect(getBody.data.penalizeMissingSalary.value).toBe(true);
expect(getBody.data.missingSalaryPenalty.value).toBe(20); 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 { logger } from "@infra/logger";
import { isDemoMode, sendDemoBlocked } from "@server/config/demo"; import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
import { setBackupSettings } from "@server/services/backup/index"; import { setBackupSettings } from "@server/services/backup/index";
import { extractProjectsFromProfile } from "@server/services/resumeProjects";
import { import {
extractProjectsFromResume,
getResume, getResume,
listResumes, listResumes,
RxResumeCredentialsError, RxResumeAuthConfigError,
} from "@server/services/rxresume-v4"; RxResumeRequestError,
validateResumeSchema,
} from "@server/services/rxresume";
import { getEffectiveSettings } from "@server/services/settings"; import { getEffectiveSettings } from "@server/services/settings";
import { applySettingsUpdates } from "@server/services/settings-update"; import { applySettingsUpdates } from "@server/services/settings-update";
import { updateSettingsSchema } from "@shared/settings-schema"; 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) => { function failRxResume(res: Response, error: unknown): void {
try { if (error instanceof RxResumeAuthConfigError) {
const resumes = await listResumes(); 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) settingsRouter.get(
res.json({ "/rx-resumes",
success: true, asyncRoute(async (req: Request, res: Response) => {
data: { 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) => ({ resumes: resumes.map((resume) => ({
id: resume.id, id: resume.id,
name: resume.name, name: resume.name,
})), })),
},
}); });
} catch (error) { } catch (error) {
if (error instanceof RxResumeCredentialsError) { failRxResume(res, error);
res.status(400).json({ success: false, error: error.message });
return;
} }
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( settingsRouter.get(
"/rx-resumes/:id/projects", "/rx-resumes/:id/projects",
async (req: Request, res: Response) => { asyncRoute(async (req: Request, res: Response) => {
try { try {
const resumeId = req.params.id; const resumeId = req.params.id;
if (!resumeId) { if (!resumeId) {
res fail(res, badRequest("Resume id is required."));
.status(400)
.json({ success: false, error: "Resume id is required." });
return; return;
} }
const resume = await getResume(resumeId); const modeParam =
const profile = resume.data ?? {}; typeof req.query.mode === "string" ? req.query.mode : undefined;
const { catalog } = extractProjectsFromProfile(profile); 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) { } catch (error) {
if (error instanceof RxResumeCredentialsError) { failRxResume(res, error);
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 });
}
},
); );

View File

@ -5,7 +5,7 @@ import { getProfile } from "./profile";
process.env.DATA_DIR = "/tmp"; process.env.DATA_DIR = "/tmp";
// Define mock data in hoisted block // Define mock data in hoisted block
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => {
const profile = { const profile = {
sections: { sections: {
summary: { content: "Original Summary" }, summary: { content: "Original Summary" },
@ -29,25 +29,16 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
// Capture what's passed to create() // Capture what's passed to create()
let lastCreateData: any = null; let lastCreateData: any = null;
const mockClient = { const mockRxResumeApi = {
create: vi.fn().mockImplementation((data: any) => { importResume: vi.fn().mockImplementation((payload: any) => {
const data = payload?.data;
lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone
return Promise.resolve("mock-resume-id"); return Promise.resolve("mock-resume-id");
}), }),
print: vi.fn().mockResolvedValue("https://example.com/pdf/mock.pdf"), exportResumePdf: vi
delete: vi.fn().mockResolvedValue(undefined),
withAutoRefresh: vi
.fn() .fn()
.mockImplementation( .mockResolvedValue("https://example.com/pdf/mock.pdf"),
async ( deleteResume: vi.fn().mockResolvedValue(undefined),
_email: string,
_password: string,
operation: (token: string) => Promise<any>,
) => {
return operation("mock-token");
},
),
getToken: vi.fn().mockResolvedValue("mock-token"),
getLastCreateData: () => lastCreateData, getLastCreateData: () => lastCreateData,
clearLastCreateData: () => { clearLastCreateData: () => {
lastCreateData = null; lastCreateData = null;
@ -63,7 +54,7 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
access: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined),
unlink: 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/baseResumeId", () => ({
vi.mock("./rxresume-client", () => ({ getConfiguredRxResumeBaseResumeId: vi.fn().mockResolvedValue({
RxResumeClient: vi.fn().mockImplementation(function (this: any) { mode: "v4",
return mockRxResumeClient; 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 // Mock stream pipeline for downloading PDF
vi.mock("stream/promises", () => ({ vi.mock("stream/promises", () => ({
pipeline: vi.fn().mockResolvedValue(undefined), pipeline: vi.fn().mockResolvedValue(undefined),
@ -227,7 +282,7 @@ describe("PDF Service Skills Validation", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(getProfile).mockResolvedValue(mockProfile); vi.mocked(getProfile).mockResolvedValue(mockProfile);
mockRxResumeClient.clearLastCreateData(); mockRxResume.clearLastCreateData();
}); });
it("should add required schema fields (visible, description) to new skills", async () => { 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"); await generatePdf("job-skills-1", tailoredContent, "Job Desc");
expect(mockRxResumeClient.create).toHaveBeenCalled(); expect(mockRxResume.importResume).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData(); const savedResumeJson = mockRxResume.getLastCreateData();
const skillItems = savedResumeJson.sections.skills.items; 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 // No tailoring, pass dummy path to bypass getProfile cache and use readFile mock
await generatePdf("job-no-tailor", {}, "Job Desc", "dummy.json"); await generatePdf("job-no-tailor", {}, "Job Desc", "dummy.json");
expect(mockRxResumeClient.create).toHaveBeenCalled(); expect(mockRxResume.importResume).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData(); const savedResumeJson = mockRxResume.getLastCreateData();
const item = savedResumeJson.sections.skills.items[0]; 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"); await generatePdf("job-cuid2-test", {}, "Job Desc", "dummy.json");
expect(mockRxResumeClient.create).toHaveBeenCalled(); expect(mockRxResume.importResume).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData(); const savedResumeJson = mockRxResume.getLastCreateData();
const skillItems = savedResumeJson.sections.skills.items; 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"); await generatePdf("job-no-skill-prefix", {}, "Job Desc", "dummy.json");
expect(mockRxResumeClient.create).toHaveBeenCalled(); expect(mockRxResume.importResume).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData(); const savedResumeJson = mockRxResume.getLastCreateData();
const skill = savedResumeJson.sections.skills.items[0]; 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"); await generatePdf("job-preserve-id", {}, "Job Desc", "dummy.json");
expect(mockRxResumeClient.create).toHaveBeenCalled(); expect(mockRxResume.importResume).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData(); const savedResumeJson = mockRxResume.getLastCreateData();
const skill = savedResumeJson.sections.skills.items[0]; const skill = savedResumeJson.sections.skills.items[0];

View File

@ -3,7 +3,7 @@ import { generatePdf } from "./pdf";
import * as projectSelection from "./projectSelection"; import * as projectSelection from "./projectSelection";
// Define mock data in hoisted block // Define mock data in hoisted block
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => {
const profile = { const profile = {
sections: { sections: {
summary: { content: "Original Summary" }, summary: { content: "Original Summary" },
@ -22,25 +22,16 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
// Capture what's passed to create() // Capture what's passed to create()
let lastCreateData: any = null; let lastCreateData: any = null;
const mockClient = { const mockRxResumeApi = {
create: vi.fn().mockImplementation((data: any) => { importResume: vi.fn().mockImplementation((payload: any) => {
const data = payload?.data;
lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone
return Promise.resolve("mock-resume-id"); return Promise.resolve("mock-resume-id");
}), }),
print: vi.fn().mockResolvedValue("https://example.com/pdf/mock.pdf"), exportResumePdf: vi
delete: vi.fn().mockResolvedValue(undefined),
withAutoRefresh: vi
.fn() .fn()
.mockImplementation( .mockResolvedValue("https://example.com/pdf/mock.pdf"),
async ( deleteResume: vi.fn().mockResolvedValue(undefined),
_email: string,
_password: string,
operation: (token: string) => Promise<any>,
) => {
return operation("mock-token");
},
),
getToken: vi.fn().mockResolvedValue("mock-token"),
getLastCreateData: () => lastCreateData, getLastCreateData: () => lastCreateData,
clearLastCreateData: () => { clearLastCreateData: () => {
lastCreateData = null; lastCreateData = null;
@ -56,7 +47,7 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
access: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn().mockResolvedValue(undefined), unlink: vi.fn().mockResolvedValue(undefined),
}, },
mockRxResumeClient: mockClient, mockRxResume: mockRxResumeApi,
}; };
}); });
@ -159,13 +150,80 @@ vi.mock("./tracer-links", () => ({
rewriteResumeLinksWithTracer: mockTracerLinks.rewriteResumeLinksWithTracer, rewriteResumeLinksWithTracer: mockTracerLinks.rewriteResumeLinksWithTracer,
})); }));
// Mock the RxResumeClient vi.mock("./rxresume/baseResumeId", () => ({
vi.mock("./rxresume-client", () => ({ getConfiguredRxResumeBaseResumeId: vi.fn().mockResolvedValue({
RxResumeClient: vi.fn().mockImplementation(function (this: any) { mode: "v4",
return mockRxResumeClient; 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 // Mock stream pipeline for downloading PDF
vi.mock("stream/promises", () => ({ vi.mock("stream/promises", () => ({
pipeline: vi.fn().mockResolvedValue(undefined), pipeline: vi.fn().mockResolvedValue(undefined),
@ -225,7 +283,7 @@ describe("PDF Service Tailoring Logic", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
mockRxResumeClient.clearLastCreateData(); mockRxResume.clearLastCreateData();
mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue( mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue(
"https://jobops.example", "https://jobops.example",
); );
@ -247,8 +305,8 @@ describe("PDF Service Tailoring Logic", () => {
expect(projectSelection.pickProjectIdsForJob).not.toHaveBeenCalled(); expect(projectSelection.pickProjectIdsForJob).not.toHaveBeenCalled();
// 2. Verify create data content // 2. Verify create data content
expect(mockRxResumeClient.create).toHaveBeenCalled(); expect(mockRxResume.importResume).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData(); const savedResumeJson = mockRxResume.getLastCreateData();
const projects = savedResumeJson.sections.projects.items; const projects = savedResumeJson.sections.projects.items;
const p1 = projects.find((p: any) => p.id === "p1"); 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 () => { it("should handle comma-separated project IDs correctly", async () => {
await generatePdf("job-2", {}, "desc", "base.json", "p1, p2 "); await generatePdf("job-2", {}, "desc", "base.json", "p1, p2 ");
expect(mockRxResumeClient.create).toHaveBeenCalled(); expect(mockRxResume.importResume).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData(); const savedResumeJson = mockRxResume.getLastCreateData();
const projects = savedResumeJson.sections.projects.items; const projects = savedResumeJson.sections.projects.items;
expect(projects.find((p: any) => p.id === "p1").visible).toBe(true); 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 () => { it("keeps projects section visible when selected project list is explicitly empty", async () => {
await generatePdf("job-empty-projects", {}, "desc", "base.json", ""); await generatePdf("job-empty-projects", {}, "desc", "base.json", "");
expect(mockRxResumeClient.create).toHaveBeenCalled(); expect(mockRxResume.importResume).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData(); const savedResumeJson = mockRxResume.getLastCreateData();
const projects = savedResumeJson.sections.projects.items; const projects = savedResumeJson.sections.projects.items;
expect(projects.find((p: any) => p.id === "p1").visible).toBe(false); 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(projectSelection.pickProjectIdsForJob).toHaveBeenCalled();
expect(mockRxResumeClient.create).toHaveBeenCalled(); expect(mockRxResume.importResume).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData(); const savedResumeJson = mockRxResume.getLastCreateData();
const p1 = savedResumeJson.sections.projects.items.find( const p1 = savedResumeJson.sections.projects.items.find(
(p: any) => p.id === "p1", (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"; import { createWriteStream, existsSync } from "node:fs";
@ -7,20 +7,16 @@ import { access, mkdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { createId } from "@paralleldrive/cuid2"; import { logger } from "@infra/logger";
import { getDataDir } from "../config/dataDir"; import { getDataDir } from "../config/dataDir";
import { getSetting } from "../repositories/settings";
import { getProfile } from "./profile";
import { pickProjectIdsForJob } from "./projectSelection";
import { import {
extractProjectsFromProfile, deleteResume as deleteRemoteResume,
resolveResumeProjectsSettings, exportResumePdf,
} from "./resumeProjects"; getResume as getRxResume,
import { RxResumeClient } from "./rxresume-client"; importResume as importRemoteResume,
import { prepareTailoredResumeForPdf,
resolveTracerPublicBaseUrl, } from "./rxresume";
rewriteResumeLinksWithTracer, import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
} from "./tracer-links";
const OUTPUT_DIR = join(getDataDir(), "pdfs"); const OUTPUT_DIR = join(getDataDir(), "pdfs");
@ -42,36 +38,6 @@ export interface GeneratePdfOptions {
tracerCompanyName?: string | null; 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. * 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: * Flow:
* 1. Prepare resume data with tailored content and project selection * 1. Prepare resume data with tailored content and project selection
* 2. Get auth token (uses cached token or logs in) * 2. Import/create resume on Reactive Resume
* 3. Import/create resume on RxResume * 3. Request print to get PDF URL
* 4. Request print to get PDF URL * 4. Download PDF locally
* 5. Download PDF locally * 5. Delete temporary resume from Reactive Resume
* 6. Delete temporary resume from RxResume
*
* Token refresh is handled automatically on 401 errors.
*/ */
export async function generatePdf( export async function generatePdf(
jobId: string, jobId: string,
tailoredContent: TailoredPdfContent, tailoredContent: TailoredPdfContent,
jobDescription: string, jobDescription: string,
_baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API _baseResumePath?: string, // Deprecated: now always uses configured Reactive Resume base resume
selectedProjectIds?: string | null, selectedProjectIds?: string | null,
options?: GeneratePdfOptions, options?: GeneratePdfOptions,
): Promise<PdfResult> { ): Promise<PdfResult> {
console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`); logger.info("Generating PDF resume", { jobId });
try { try {
// Ensure output directory exists // Ensure output directory exists
@ -124,220 +87,81 @@ export async function generatePdf(
await mkdir(OUTPUT_DIR, { recursive: true }); await mkdir(OUTPUT_DIR, { recursive: true });
} }
// Get credentials and initialize client const { resumeId: baseResumeId } =
const { email, password, baseUrl } = await getCredentials(); await getConfiguredRxResumeBaseResumeId();
const client = new RxResumeClient(baseUrl); if (!baseResumeId) {
// 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) || [],
}),
);
}
// 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;
}
}
// 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
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( throw new Error(
"Tracer links are enabled but no public base URL is available. Set JOBOPS_PUBLIC_BASE_URL.", "Base resume not configured. Please select a base resume from your Reactive Resume account in Settings.",
); );
} }
const baseResume = await getRxResume(baseResumeId);
await rewriteResumeLinksWithTracer({ if (!baseResume.data || typeof baseResume.data !== "object") {
jobId, throw new Error("Reactive Resume base resume is empty or invalid.");
resumeData: baseResume,
publicBaseUrl: tracerBaseUrl,
companyName: options.tracerCompanyName ?? null,
});
} }
// Use withAutoRefresh to handle token caching and 401 retry automatically let preparedResumeData: Record<string, unknown>;
const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);
await client.withAutoRefresh(email, password, async (token) => {
let resumeId: string | null = null;
try { try {
// Create resume on RxResume const prepared = await prepareTailoredResumeForPdf({
console.log(` 📤 Uploading resume to RxResume...`); resumeData: baseResume.data,
resumeId = await client.create(baseResume, token); mode: baseResume.mode,
console.log(` ✅ Resume created with ID: ${resumeId}`); tailoredContent,
jobDescription,
selectedProjectIds,
jobId,
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;
}
// Get PDF URL const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);
console.log(` 🖨️ Requesting PDF generation...`); let resumeId: string | null = null;
const pdfUrl = await client.print(resumeId, token); try {
console.log(` ✅ PDF URL received: ${pdfUrl}`); logger.debug("Uploading temporary resume for PDF generation", { jobId });
resumeId = await importRemoteResume({
data: preparedResumeData,
name: `JobOps Tailored Resume ${jobId}`,
slug: "",
});
// Download PDF logger.debug("Requesting PDF export for temporary resume", {
console.log(` 📥 Downloading PDF...`); jobId,
resumeId,
});
const pdfUrl = await exportResumePdf(resumeId);
logger.debug("Downloading generated PDF", { jobId, resumeId });
await downloadFile(pdfUrl, outputPath); await downloadFile(pdfUrl, outputPath);
console.log(` ✅ PDF saved to: ${outputPath}`); await deleteRemoteResume(resumeId);
// 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; resumeId = null;
} finally { } finally {
// Attempt cleanup if resume was created but not deleted
if (resumeId) { if (resumeId) {
try { try {
console.log(` 🧹 Attempting cleanup of orphaned resume...`); await deleteRemoteResume(resumeId);
await client.delete(resumeId, token); } catch (cleanupError) {
} catch { logger.warn("Failed to cleanup temporary Reactive Resume record", {
console.warn(` ⚠️ Failed to cleanup orphaned resume ${resumeId}`); jobId,
} resumeId,
} error: cleanupError,
}
}); });
}
}
}
console.log(`✅ PDF generated successfully: ${outputPath}`); logger.info("PDF generated successfully", { jobId, outputPath });
return { success: true, pdfPath: outputPath }; return { success: true, pdfPath: outputPath };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown 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 }; return { success: false, error: message };
} }
} }

View File

@ -6,18 +6,18 @@ vi.mock("../repositories/settings", () => ({
getSetting: vi.fn(), getSetting: vi.fn(),
})); }));
vi.mock("./rxresume-v4", () => ({ vi.mock("./rxresume", () => ({
getResume: vi.fn(), getResume: vi.fn(),
RxResumeCredentialsError: class RxResumeCredentialsError extends Error { RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error {
constructor() { constructor() {
super("RxResume credentials not configured."); super("Reactive Resume credentials not configured.");
this.name = "RxResumeCredentialsError"; this.name = "RxResumeAuthConfigError";
} }
}, },
})); }));
import { getSetting } from "../repositories/settings"; import { getSetting } from "../repositories/settings";
import { getResume, RxResumeCredentialsError } from "./rxresume-v4"; import { getResume, RxResumeAuthConfigError } from "./rxresume";
describe("getProfile", () => { describe("getProfile", () => {
beforeEach(() => { 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" } }; const mockResumeData = { basics: { name: "Test User" } };
vi.mocked(getSetting).mockResolvedValue("test-resume-id"); vi.mocked(getSetting).mockResolvedValue("test-resume-id");
vi.mocked(getResume).mockResolvedValue({ vi.mocked(getResume).mockResolvedValue({
@ -43,6 +43,7 @@ describe("getProfile", () => {
const profile = await getProfile(); const profile = await getProfile();
expect(getSetting).toHaveBeenCalledWith("rxresumeMode");
expect(getSetting).toHaveBeenCalledWith("rxresumeBaseResumeId"); expect(getSetting).toHaveBeenCalledWith("rxresumeBaseResumeId");
expect(getResume).toHaveBeenCalledWith("test-resume-id"); expect(getResume).toHaveBeenCalledWith("test-resume-id");
expect(profile).toEqual(mockResumeData); expect(profile).toEqual(mockResumeData);
@ -59,8 +60,8 @@ describe("getProfile", () => {
await getProfile(); await getProfile();
await getProfile(); await getProfile();
// getSetting is called each time to check resumeId // The helper reads mode + legacy/per-mode resume-id settings each call.
expect(getSetting).toHaveBeenCalledTimes(2); expect(getSetting).toHaveBeenCalledTimes(8);
// But getResume should only be called once due to caching // But getResume should only be called once due to caching
expect(getResume).toHaveBeenCalledTimes(1); expect(getResume).toHaveBeenCalledTimes(1);
}); });
@ -81,10 +82,12 @@ describe("getProfile", () => {
it("should throw user-friendly error on credential issues", async () => { it("should throw user-friendly error on credential issues", async () => {
vi.mocked(getSetting).mockResolvedValue("test-resume-id"); 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( 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 @@
/** import { logger } from "@infra/logger";
* 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 type { ResumeProfile } from "@shared/types"; import type { ResumeProfile } from "@shared/types";
import { getSetting } from "../repositories/settings"; import { getResume, RxResumeAuthConfigError } from "./rxresume";
import { getResume, RxResumeCredentialsError } from "./rxresume-v4"; import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
let cachedProfile: ResumeProfile | null = null; let cachedProfile: ResumeProfile | null = null;
let cachedResumeId: string | 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. * Requires rxresumeBaseResumeId to be configured in settings.
* Results are cached until clearProfileCache() is called. * 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. * @throws Error if rxresumeBaseResumeId is not configured or API call fails.
*/ */
export async function getProfile(forceRefresh = false): Promise<ResumeProfile> { export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
const rxresumeBaseResumeId = await getSetting("rxresumeBaseResumeId"); const { resumeId: rxresumeBaseResumeId } =
await getConfiguredRxResumeBaseResumeId();
if (!rxresumeBaseResumeId) { if (!rxresumeBaseResumeId) {
throw new Error( throw new Error(
@ -40,9 +35,9 @@ export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
} }
try { try {
console.log( logger.info("Fetching profile from Reactive Resume", {
`📋 Fetching profile from RxResume v4 API (resume: ${rxresumeBaseResumeId})...`, resumeId: rxresumeBaseResumeId,
); });
const resume = await getResume(rxresumeBaseResumeId); const resume = await getResume(rxresumeBaseResumeId);
if (!resume.data || typeof resume.data !== "object") { 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; cachedProfile = resume.data as unknown as ResumeProfile;
cachedResumeId = rxresumeBaseResumeId; cachedResumeId = rxresumeBaseResumeId;
console.log(`✅ Profile loaded from RxResume v4 API`); logger.info("Profile loaded from Reactive Resume", {
resumeId: rxresumeBaseResumeId,
});
return cachedProfile; return cachedProfile;
} catch (error) { } catch (error) {
if (error instanceof RxResumeCredentialsError) { if (error instanceof RxResumeAuthConfigError) {
throw new Error( throw new Error(error.message);
"RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.",
);
} }
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; 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RxResumeClient } from "./rxresume-client"; import { RxResumeClient } from "./client";
describe("RxResumeClient", () => { describe("RxResumeClient", () => {
describe("verifyCredentials (static)", () => { describe("verifyCredentials (static)", () => {

View File

@ -1,11 +1,11 @@
// rxresume-client.ts // rxresume/client.ts
// Low-level HTTP client for the RxResume v4 API. // Low-level HTTP client for the RxResume v4 API.
// - Handles login, token caching, and cookie-based auth. // - 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. // - 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 { normalizeWhitespace } from "@shared/utils/string";
import type { ResumeData } from "./schema/v4";
type AnyObj = Record<string, unknown>; type AnyObj = Record<string, unknown>;
const MAX_ERROR_SNIPPET = 300; 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 { createId } from "@paralleldrive/cuid2";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { idSchema, resumeDataSchema, skillSchema } from "./rxresume-schema"; import { idSchema, resumeDataSchema, skillSchema } from "./v4";
describe("RxResume Schema Validation", () => { describe("RxResume Schema Validation", () => {
describe("idSchema (CUID2)", () => { describe("idSchema (CUID2)", () => {

View File

@ -955,3 +955,11 @@ export const sampleResume: ResumeData = {
notes: "", 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. // Service wrapper around the v4 client that mirrors the v5 helper API.
// - Pulls credentials from env/settings. // - Pulls credentials from env/settings.
// - Validates resume payloads. // - Validates resume payloads.
// - Keeps the rest of the app v5-ready (swap imports later). // - Keeps the rest of the app v5-ready (swap imports later).
import type { ResumeData } from "@shared/rxresume-schema"; import { getSetting } from "@server/repositories/settings";
import { resumeDataSchema } from "@shared/rxresume-schema"; import { RxResumeClient, type RxResumeResume } from "./client";
import { getSetting } from "../repositories/settings"; import { parseV4ResumeData, type ResumeData } from "./schema/v4";
import { RxResumeClient, type RxResumeResume } from "./rxresume-client";
export type RxResumeCredentials = { export type RxResumeCredentials = {
email: string; email: string;
@ -78,16 +77,20 @@ export async function getResume(
resumeId: string, resumeId: string,
override?: Partial<RxResumeCredentials>, override?: Partial<RxResumeCredentials>,
): Promise<RxResumeResume> { ): Promise<RxResumeResume> {
return withRxResumeClient(override, (client, token) => const resume = await withRxResumeClient(override, (client, token) =>
client.get(resumeId, token), client.get(resumeId, token),
); );
if (resume.data) {
resume.data = parseV4ResumeData(resume.data) as ResumeData;
}
return resume;
} }
export async function importResume( export async function importResume(
payload: RxResumeImportPayload, payload: RxResumeImportPayload,
override?: Partial<RxResumeCredentials>, override?: Partial<RxResumeCredentials>,
): Promise<string> { ): Promise<string> {
const data = resumeDataSchema.parse(payload.data); const data = parseV4ResumeData(payload.data) as ResumeData;
const title = payload.name?.trim() || undefined; const title = payload.name?.trim() || undefined;
const slug = payload.slug?.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, extractProjectsFromProfile,
normalizeResumeProjectsSettings, normalizeResumeProjectsSettings,
} from "@server/services/resumeProjects"; } from "@server/services/resumeProjects";
import {
getRxResumeBaseResumeIdKey,
normalizeRxResumeMode,
} from "@server/services/rxresume/baseResumeId";
import { settingsRegistry } from "@shared/settings-registry"; import { settingsRegistry } from "@shared/settings-registry";
import type { UpdateSettingsInput } from "@shared/settings-schema"; import type { UpdateSettingsInput } from "@shared/settings-schema";
@ -96,6 +100,31 @@ for (const [key, def] of Object.entries(settingsRegistry)) {
continue; 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 // Generic handler for all others
settingsUpdateRegistry[key as keyof UpdateSettingsInput] = ({ value }) => { settingsUpdateRegistry[key as keyof UpdateSettingsInput] = ({ value }) => {
let serialized: string | null; let serialized: string | null;

View File

@ -1,13 +1,16 @@
import { logger } from "@infra/logger";
import * as settingsRepo from "@server/repositories/settings"; import * as settingsRepo from "@server/repositories/settings";
import { settingsRegistry } from "@shared/settings-registry"; import { settingsRegistry } from "@shared/settings-registry";
import type { AppSettings } from "@shared/types"; import type { AppSettings } from "@shared/types";
import { getEnvSettingsData } from "./envSettings"; import { getEnvSettingsData } from "./envSettings";
import { getProfile } from "./profile"; import { getProfile } from "./profile";
import { resolveResumeProjectsSettings } from "./resumeProjects";
import { import {
extractProjectsFromProfile, extractProjectsFromResume,
resolveResumeProjectsSettings, getResume,
} from "./resumeProjects"; RxResumeAuthConfigError,
import { getResume, RxResumeCredentialsError } from "./rxresume-v4"; } from "./rxresume";
import { resolveRxResumeBaseResumeIdForMode } from "./rxresume/baseResumeId";
function resolveDefaultLlmBaseUrl(provider: string): string { function resolveDefaultLlmBaseUrl(provider: string): string {
const normalized = provider.trim().toLowerCase(); const normalized = provider.trim().toLowerCase();
@ -28,7 +31,12 @@ function resolveDefaultLlmBaseUrl(provider: string): string {
export async function getEffectiveSettings(): Promise<AppSettings> { export async function getEffectiveSettings(): Promise<AppSettings> {
const overrides = await settingsRepo.getAllSettings(); 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> = {}; let profile: Record<string, unknown> = {};
if (rxresumeBaseResumeId) { if (rxresumeBaseResumeId) {
@ -38,22 +46,26 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
profile = resume.data as Record<string, unknown>; profile = resume.data as Record<string, unknown>;
} }
} catch (error) { } catch (error) {
if (error instanceof RxResumeCredentialsError) { if (error instanceof RxResumeAuthConfigError) {
console.warn( logger.warn(
"RxResume credentials missing while loading base resume from settings.", "Reactive Resume credentials missing during settings load",
{
resumeId: rxresumeBaseResumeId,
error,
},
); );
} else { } else {
console.warn( logger.warn("Failed to load Reactive Resume base resume for settings", {
"Failed to load RxResume base resume for settings:", resumeId: rxresumeBaseResumeId,
error, error,
); });
} }
} }
} }
if (Object.keys(profile).length === 0) { if (Object.keys(profile).length === 0) {
profile = await getProfile().catch((error) => { 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 {}; return {};
}); });
} }
@ -90,7 +102,19 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
} }
if (key === "resumeProjects") { 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({ const resolved = resolveResumeProjectsSettings({
catalog, catalog,
overrideRaw: rawOverride ?? null, 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; return result as AppSettings;
} }

View File

@ -198,6 +198,24 @@ function deriveSourceLabel(sourcePath: string, linkNode: LinkNode): string {
return nth ? `${baseLabel} Link ${nth}` : `${baseLabel} Link`; 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"; return "Resume Link";
} }
@ -252,6 +270,32 @@ function collectUrlTargets(
continue; 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); collectUrlTargets(value, nextPath, targets);
} }
} }

View File

@ -102,4 +102,19 @@ describe("settingsRegistry helpers", () => {
expect(settingsRegistry.resumeProjects.serialize(null)).toBeNull(); 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; 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: { ukvisajobsMaxJobs: {
kind: "typed" as const, kind: "typed" as const,
schema: z.number().int().min(1).max(1000), schema: z.number().int().min(1).max(1000),
@ -359,6 +375,14 @@ export const settingsRegistry = {
kind: "string" as const, kind: "string" as const,
schema: z.string().trim().max(200), 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: { rxresumeEmail: {
kind: "string" as const, kind: "string" as const,
envKey: "RXRESUME_EMAIL", envKey: "RXRESUME_EMAIL",
@ -391,6 +415,11 @@ export const settingsRegistry = {
envKey: "RXRESUME_PASSWORD", envKey: "RXRESUME_PASSWORD",
schema: z.string().trim().max(2000), schema: z.string().trim().max(2000),
}, },
rxresumeApiKey: {
kind: "secret" as const,
envKey: "RXRESUME_API_KEY",
schema: z.string().trim().max(2000),
},
ukvisajobsPassword: { ukvisajobsPassword: {
kind: "secret" as const, kind: "secret" as const,
envKey: "UKVISAJOBS_PASSWORD", envKey: "UKVISAJOBS_PASSWORD",

View File

@ -148,6 +148,8 @@ export const createAppSettings = (
override: null, override: null,
}, },
rxresumeBaseResumeId: null, rxresumeBaseResumeId: null,
rxresumeBaseResumeIdV4: null,
rxresumeBaseResumeIdV5: null,
ukvisajobsMaxJobs: { value: 50, default: 50, override: null }, ukvisajobsMaxJobs: { value: 50, default: 50, override: null },
adzunaMaxJobsPerTerm: { value: 50, default: 50, override: null }, adzunaMaxJobsPerTerm: { value: 50, default: 50, override: null },
gradcrackerMaxJobsPerTerm: { 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 }, chatStyleConstraints: { value: "", default: "", override: null },
chatStyleDoNotUse: { value: "", default: "", override: null }, chatStyleDoNotUse: { value: "", default: "", override: null },
llmApiKeyHint: null, llmApiKeyHint: null,
rxresumeApiKeyHint: null,
rxresumeEmail: null, rxresumeEmail: null,
rxresumePasswordHint: null, rxresumePasswordHint: null,
basicAuthUser: null, basicAuthUser: null,
@ -198,5 +201,6 @@ export const createAppSettings = (
penalizeMissingSalary: { value: false, default: false, override: null }, penalizeMissingSalary: { value: false, default: false, override: null },
missingSalaryPenalty: { value: 10, default: 10, override: null }, missingSalaryPenalty: { value: 10, default: 10, override: null },
autoSkipScoreThreshold: { value: null, default: null, override: null }, autoSkipScoreThreshold: { value: null, default: null, override: null },
rxresumeMode: { value: "v5", default: "v5", override: null },
...overrides, ...overrides,
}); });

View File

@ -12,6 +12,8 @@ export interface ResumeProjectsSettings {
aiSelectableProjectIds: string[]; aiSelectableProjectIds: string[];
} }
export type RxResumeMode = "v4" | "v5";
export interface ResumeProfile { export interface ResumeProfile {
basics?: { basics?: {
name?: string; name?: string;
@ -138,6 +140,7 @@ export interface AppSettings {
penalizeMissingSalary: Resolved<boolean>; penalizeMissingSalary: Resolved<boolean>;
missingSalaryPenalty: Resolved<number>; missingSalaryPenalty: Resolved<number>;
autoSkipScoreThreshold: Resolved<number | null>; autoSkipScoreThreshold: Resolved<number | null>;
rxresumeMode: Resolved<RxResumeMode>;
// Model variants (no own default, fallback to model.value): // Model variants (no own default, fallback to model.value):
modelScorer: ModelResolved; modelScorer: ModelResolved;
@ -146,6 +149,8 @@ export interface AppSettings {
// Simple strings: // Simple strings:
rxresumeBaseResumeId: string | null; rxresumeBaseResumeId: string | null;
rxresumeBaseResumeIdV4: string | null;
rxresumeBaseResumeIdV5: string | null;
rxresumeEmail: string | null; rxresumeEmail: string | null;
ukvisajobsEmail: string | null; ukvisajobsEmail: string | null;
adzunaAppId: string | null; adzunaAppId: string | null;
@ -153,6 +158,7 @@ export interface AppSettings {
// Secret hints: // Secret hints:
llmApiKeyHint: string | null; llmApiKeyHint: string | null;
rxresumeApiKeyHint: string | null;
rxresumePasswordHint: string | null; rxresumePasswordHint: string | null;
ukvisajobsPasswordHint: string | null; ukvisajobsPasswordHint: string | null;
adzunaAppKeyHint: string | null; adzunaAppKeyHint: string | null;