Enhance RxResume validation, settings handling, and caching (#287)

* feat: enhance validation handling in ReactiveResumeConfigPanel and rxresume-config

* feat: enhance RxResume validation handling in SettingsPage and ReactiveResumeSection

* feat: enhance RxResume settings handling and cache management

* feat: add save-time validation and caching for Reactive Resume settings

* refactor: improve code formatting and readability across multiple files

* fix: improve condition check in hasOverrideKey function

* feat: enhance RxResume validation and settings handling with precheck options

* refactor: streamline RxResume client initialization and improve backup sorting logic
This commit is contained in:
Ammad Ali 2026-03-19 11:38:04 +00:00 committed by GitHub
parent 4787f4d151
commit ac0a1281f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1364 additions and 345 deletions

View File

@ -2,7 +2,8 @@
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 2
"indentWidth": 2,
"lineEnding": "lf"
},
"files": {
"includes": [

View File

@ -108,6 +108,14 @@ Or via environment variables:
If you leave the URL blank in the dashboard, JobOps uses `RXRESUME_URL` when it is set; if not set, it falls back to the public cloud default for the selected mode.
### Save-time validation
When you save Reactive Resume credentials or the shared URL in Settings:
1. JobOps validates only the credential-bearing Reactive Resume fields for the selected mode.
2. Invalid credentials or other `4xx` configuration failures block the save and show a persistent inline error.
3. Temporary network failures, timeouts, or upstream `5xx` errors show a persistent inline warning, but the save still succeeds.
### 2) Select base resume
In **Settings → Reactive Resume**:
@ -139,6 +147,12 @@ High-level flow:
6. Export PDF.
7. Delete temporary resume.
### Resume-data caching
JobOps caches successful Reactive Resume resume fetches in memory for 5 minutes.
This reduces repeated API calls from settings loads, profile checks, project lookups, and PDF generation while still refreshing often enough for normal editing workflows.
### Per-job tracer links
Before generating a PDF, each job can enable/disable tracer links.
@ -208,6 +222,8 @@ curl -X POST "http://localhost:3001/api/jobs/<jobId>/generate-pdf"
- Ensure the selected mode has credentials configured.
- `v5`: set a valid API key.
- `v4`: set email + password.
- Invalid credentials block save and remain visible as an inline error until you edit the selected mode's credentials or URL.
- Temporary Reactive Resume downtime shows an inline warning, but other settings can still be saved.
- Save settings, then refresh resumes in the Reactive Resume section.
### No resumes appear in dropdown

View File

@ -111,11 +111,14 @@ Defaults and constraints:
- Configure a shared RxResume URL for cloud or self-hosted deployments
- Configure v4 email/password or v5 API key in the same section
- Invalid Reactive Resume credentials or other `4xx` config failures block the save and stay visible as an inline error
- Temporary Reactive Resume downtime shows an inline warning, but the save still succeeds
- Select a template/base resume
- Configure project selection behavior:
- Max projects
- Must-include projects
- AI-selectable projects
- JobOps briefly caches successful Reactive Resume resume data to reduce repeated API calls across settings, profile, and PDF flows
### Tracer Links
@ -215,6 +218,8 @@ curl -X POST "http://localhost:3001/api/backups"
- JobOps resolves the RxResume URL in this order: the value saved in **Settings → Reactive Resume**, then the `RXRESUME_URL` environment variable (if set), and finally the public cloud default.
- Open **Settings → Reactive Resume** and configure the shared RxResume URL if you use a self-hosted instance.
- If you leave the URL blank, JobOps will fall back to `RXRESUME_URL` when it is configured; otherwise it uses the public cloud default.
- Invalid credentials block the save and remain visible inline until you edit the selected mode's credentials or URL.
- Temporary instance downtime shows a warning inline, but does not block unrelated settings updates.
- Then refresh available resumes from the Reactive Resume section.
### RxResume projects look empty in the RxResume UI

View File

@ -6,7 +6,9 @@ import {
} from "@client/pages/settings/resume-projects-state";
import type { ResumeProjectsSettingsInput } from "@shared/settings-schema.js";
import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types.js";
import { AlertCircle, AlertTriangle } from "lucide-react";
import type React from "react";
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";
@ -26,6 +28,7 @@ type VersionValidationState = {
checked: boolean;
valid: boolean;
message?: string | null;
status?: number | null;
};
type ProjectSelectionConfig = {
@ -107,6 +110,11 @@ function renderStatusPill(label: string, state: VersionValidationState) {
);
}
function isAvailabilityWarning(state?: VersionValidationState): boolean {
const status = state?.status ?? null;
return status === 0 || (typeof status === "number" && status >= 500);
}
export const ReactiveResumeConfigPanel: React.FC<
ReactiveResumeConfigPanelProps
> = ({
@ -126,6 +134,14 @@ export const ReactiveResumeConfigPanel: React.FC<
projectSelection && hasRxResumeAccess,
);
const selectedValidationStatus = validationStatuses?.[mode];
const showInlineValidationAlert = Boolean(
selectedValidationStatus?.checked &&
!selectedValidationStatus.valid &&
selectedValidationStatus.message,
);
const selectedValidationIsWarning =
showInlineValidationAlert &&
isAvailabilityWarning(selectedValidationStatus);
const handleModeChange = (value: string) =>
onModeChange(value === "v4" ? "v4" : "v5");
@ -157,6 +173,30 @@ export const ReactiveResumeConfigPanel: React.FC<
</div>
) : null}
{showInlineValidationAlert && selectedValidationStatus?.message ? (
<Alert
variant={selectedValidationIsWarning ? "default" : "destructive"}
className={
selectedValidationIsWarning
? "border-amber-200 bg-amber-50 text-amber-950 [&>svg]:text-amber-700"
: undefined
}
>
{selectedValidationIsWarning ? (
<AlertTriangle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertTitle>
Reactive Resume {mode.toUpperCase()}{" "}
{selectedValidationIsWarning ? "warning" : "error"}
</AlertTitle>
<AlertDescription>
{selectedValidationStatus.message}
</AlertDescription>
</Alert>
) : null}
{mode === "v5" ? (
<div className="grid gap-4">
<SettingsInput

View File

@ -117,12 +117,41 @@ export const getRxResumeMissingCredentialLabels = (input: {
export const toRxResumeValidationPayload = (
draft: RxResumeCredentialDrafts,
) => ({
email: draft.email || undefined,
baseUrl: draft.baseUrl || undefined,
password: draft.password || undefined,
apiKey: draft.apiKey || undefined,
});
options?: {
preserveBlankFields?: Array<keyof RxResumeCredentialDrafts>;
},
) => {
const preserveBlankFields = new Set(options?.preserveBlankFields ?? []);
return {
email: preserveBlankFields.has("email")
? draft.email
: draft.email || undefined,
baseUrl: preserveBlankFields.has("baseUrl")
? draft.baseUrl
: draft.baseUrl || undefined,
password: preserveBlankFields.has("password")
? draft.password
: draft.password || undefined,
apiKey: preserveBlankFields.has("apiKey")
? draft.apiKey
: draft.apiKey || undefined,
};
};
export const isRxResumeBlockingValidationFailure = (
validation: ValidationResult,
): boolean =>
!validation.valid &&
typeof validation.status === "number" &&
validation.status >= 400 &&
validation.status < 500;
export const isRxResumeAvailabilityValidationFailure = (
validation: ValidationResult,
): boolean =>
!validation.valid &&
(validation.status === 0 ||
(typeof validation.status === "number" && validation.status >= 500));
export const buildRxResumeSettingsUpdate = (
mode: RxResumeMode,
@ -149,6 +178,7 @@ type ValidateAndMaybePersistRxResumeModeInput<TSettings> = {
) => Promise<ValidationResult>;
persist?: (update: Partial<UpdateSettingsInput>) => Promise<TSettings>;
persistOnSuccess?: boolean;
skipPrecheck?: boolean;
getPrecheckMessage?: (
failure: Exclude<RxResumeCredentialPrecheckFailure, null>,
) => string;
@ -172,6 +202,7 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
validate,
persist,
persistOnSuccess = false,
skipPrecheck = false,
getPrecheckMessage = (failure) => RXRESUME_PRECHECK_MESSAGES[failure],
getValidationErrorMessage = (error) =>
error instanceof Error ? error.message : "RxResume validation failed",
@ -181,16 +212,19 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
: "Failed to save RxResume settings",
} = input;
const precheckFailure = getRxResumeCredentialPrecheckFailure({
mode,
stored,
draft,
});
if (precheckFailure) {
const precheckFailure = skipPrecheck
? null
: getRxResumeCredentialPrecheckFailure({
mode,
stored,
draft,
});
if (precheckFailure !== null) {
return {
validation: {
valid: false,
message: getPrecheckMessage(precheckFailure),
status: 400,
},
precheckFailure,
updatedSettings: null,
@ -208,6 +242,7 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
validation: {
valid: false,
message: getValidationErrorMessage(error, mode),
status: 0,
},
precheckFailure: null,
updatedSettings: null,
@ -219,6 +254,7 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
validation: {
valid: validation.valid,
message: validation.valid ? null : (validation.message ?? null),
status: validation.valid ? null : (validation.status ?? null),
},
precheckFailure: null,
updatedSettings: null,
@ -233,6 +269,7 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
validation: {
valid: true,
message: null,
status: null,
},
precheckFailure: null,
updatedSettings,
@ -242,6 +279,7 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
validation: {
valid: false,
message: getPersistErrorMessage(error, mode),
status: 0,
},
precheckFailure: null,
updatedSettings: null,

View File

@ -17,6 +17,7 @@ vi.mock("../api", () => ({
getSettings: vi.fn(),
updateSettings: vi.fn(),
validateRxresume: vi.fn(),
getRxResumeProjects: vi.fn(),
clearDatabase: vi.fn(),
deleteJobsByStatus: vi.fn(),
getTracerReadiness: vi.fn(),
@ -72,6 +73,13 @@ const openWritingStyleSection = async () => {
fireEvent.click(chatTrigger);
};
const openReactiveResumeSection = async () => {
const trigger = await screen.findByRole("button", {
name: /reactive resume/i,
});
fireEvent.click(trigger);
};
describe("SettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -92,6 +100,7 @@ describe("SettingsPage", () => {
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message: "Missing credentials",
status: 400,
});
});
@ -308,6 +317,127 @@ describe("SettingsPage", () => {
);
});
it("blocks save and renders an inline alert when the v5 API key is invalid", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage();
await openReactiveResumeSection();
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
vi.mocked(api.validateRxresume).mockClear();
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message:
"Reactive Resume v5 API key is invalid. Update the API key and try again.",
status: 401,
});
fireEvent.change(screen.getByLabelText(/v5 api key/i), {
target: { value: "invalid-v5-key" },
});
const saveButton = screen.getByRole("button", { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
expect(
await screen.findByText(/Reactive Resume v5 API key is invalid/i),
).toBeInTheDocument();
expect(api.updateSettings).not.toHaveBeenCalled();
});
it("allows saving on RxResume availability warnings and keeps the inline warning visible", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue({
...baseSettings,
rxresumeApiKeyHint: "rr-v",
});
renderPage();
await openReactiveResumeSection();
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
vi.mocked(api.validateRxresume).mockClear();
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message:
"JobOps could not verify Reactive Resume because the instance is unavailable right now.",
status: 0,
});
fireEvent.change(screen.getByLabelText(/v5 api key/i), {
target: { value: "rr-v5-warning-key" },
});
const saveButton = screen.getByRole("button", { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
expect(
await screen.findByText(/instance is unavailable right now/i),
).toBeInTheDocument();
expect(toast.success).toHaveBeenCalledWith("Settings saved");
expect(toast.info).toHaveBeenCalledWith(
"Settings saved, but JobOps could not verify Reactive Resume because the instance is unavailable.",
);
});
it("does not run RxResume validation for unrelated settings saves", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue({
...baseSettings,
model: {
value: "new-model",
default: baseSettings.model.default,
override: "new-model",
},
});
renderPage();
await openModelSection();
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
vi.mocked(api.validateRxresume).mockClear();
fireEvent.change(screen.getByLabelText(/default model/i), {
target: { value: "new-model" },
});
const saveButton = screen.getByRole("button", { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
expect(api.validateRxresume).not.toHaveBeenCalled();
});
it("clears the previous RxResume warning when the key or URL changes", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message:
"JobOps could not verify Reactive Resume because the instance is unavailable right now.",
status: 0,
});
renderPage();
await openReactiveResumeSection();
expect(
await screen.findByText(/instance is unavailable right now/i),
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/rxresume url/i), {
target: { value: "https://resume.example.com" },
});
await waitFor(() =>
expect(
screen.queryByText(/instance is unavailable right now/i),
).not.toBeInTheDocument(),
);
});
it("saves the writing language mode through the settings page", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue(

View File

@ -6,8 +6,12 @@ import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
import {
coerceRxResumeMode,
getRxResumeCredentialDrafts,
getRxResumeCredentialPrecheckFailure,
isRxResumeAvailabilityValidationFailure,
isRxResumeBlockingValidationFailure,
RXRESUME_MODES,
RXRESUME_PRECHECK_MESSAGES,
toRxResumeValidationPayload,
validateAndMaybePersistRxResumeMode,
} from "@client/lib/rxresume-config";
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
@ -37,6 +41,7 @@ import type {
ResumeProjectCatalogItem,
ResumeProjectsSettings,
RxResumeMode,
ValidationResult,
} from "@shared/types.js";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings } from "lucide-react";
@ -101,13 +106,31 @@ type RxResumeValidationBadgeState = {
checked: boolean;
valid: boolean;
message: string | null;
status: number | null;
};
const EMPTY_RXRESUME_VALIDATION_BADGE_STATE: RxResumeValidationBadgeState = {
checked: false,
valid: false,
message: null,
status: null,
};
const getRxResumeValidationFieldsForMode = (
mode: RxResumeMode,
): Array<keyof UpdateSettingsInput> =>
mode === "v5"
? ["rxresumeApiKey", "rxresumeUrl"]
: ["rxresumeEmail", "rxresumePassword", "rxresumeUrl"];
const toRxResumeValidationBadgeState = (
validation: ValidationResult,
): RxResumeValidationBadgeState => ({
checked: true,
valid: validation.valid,
message: validation.valid ? null : (validation.message ?? null),
status: validation.valid ? null : (validation.status ?? null),
});
const normalizeLlmProviderValue = (
value: string | null | undefined,
): LlmProviderValue => (value ? normalizeLlmProvider(value) : null);
@ -404,6 +427,7 @@ export const SettingsPage: React.FC = () => {
});
const {
clearErrors,
handleSubmit,
reset,
setError,
@ -598,6 +622,27 @@ export const SettingsPage: React.FC = () => {
}
}, [refreshReadiness]);
const setRxResumeValidationStatus = useCallback(
(mode: RxResumeMode, validation: ValidationResult) => {
setRxresumeValidationStatuses((current) => ({
...current,
[mode]: toRxResumeValidationBadgeState(validation),
}));
},
[],
);
const clearRxResumeValidationFeedback = useCallback(
(mode: RxResumeMode) => {
setRxresumeValidationStatuses((current) => ({
...current,
[mode]: EMPTY_RXRESUME_VALIDATION_BADGE_STATE,
}));
clearErrors(getRxResumeValidationFieldsForMode(mode));
},
[clearErrors],
);
const validateRxresumeMode = useCallback(
async (
mode: RxResumeMode,
@ -614,6 +659,7 @@ export const SettingsPage: React.FC = () => {
validate: api.validateRxresume,
persist: api.updateSettings,
persistOnSuccess,
skipPrecheck: silent,
getPrecheckMessage: (failure) => RXRESUME_PRECHECK_MESSAGES[failure],
getValidationErrorMessage: (error) =>
error instanceof Error ? error.message : "RxResume validation failed",
@ -621,16 +667,7 @@ export const SettingsPage: React.FC = () => {
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),
},
}));
setRxResumeValidationStatus(mode, result.validation);
if (result.updatedSettings) {
setSettings(result.updatedSettings);
@ -661,7 +698,7 @@ export const SettingsPage: React.FC = () => {
`Reactive Resume ${mode} validation failed`,
);
},
[getValues, queryClient, storedRxResume],
[getValues, queryClient, setRxResumeValidationStatus, storedRxResume],
);
useEffect(() => {
@ -677,7 +714,7 @@ export const SettingsPage: React.FC = () => {
validateRxresumeMode(mode, { silent: true, persistOnSuccess: false }),
),
);
}, [settings, rxresumeValidationStatuses, validateRxresumeMode]);
}, [rxresumeValidationStatuses, settings, validateRxresumeMode]);
const effectiveProfileProjects =
rxResumeProjectsOverride ??
@ -786,7 +823,7 @@ export const SettingsPage: React.FC = () => {
if (value !== undefined) envPayload.webhookSecret = value;
}
const payload: UpdateSettingsInput = {
const payload: Partial<UpdateSettingsInput> = {
model: normalizeString(data.model),
modelScorer: normalizeString(data.modelScorer),
modelTailoring: normalizeString(data.modelTailoring),
@ -794,8 +831,12 @@ export const SettingsPage: React.FC = () => {
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
resumeProjects: resumeProjectsOverride,
rxresumeMode: data.rxresumeMode ?? "v5",
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
...(dirtyFields.rxresumeMode
? { rxresumeMode: data.rxresumeMode ?? "v5" }
: {}),
...(dirtyFields.rxresumeBaseResumeId
? { rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId) }
: {}),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
chatStyleTone: normalizeString(data.chatStyleTone),
chatStyleFormality: normalizeString(data.chatStyleFormality),
@ -836,15 +877,88 @@ export const SettingsPage: React.FC = () => {
...envPayload,
};
// Remove virtual field because the backend doesn't expect it
// this exists only to toggle the UI
// need to track it so that the save button is enabled when it changes
delete payload.enableBasicAuth;
const shouldValidateRxResumeBeforeSave = Boolean(
dirtyFields.rxresumeMode ||
dirtyFields.rxresumeUrl ||
dirtyFields.rxresumeApiKey ||
dirtyFields.rxresumeEmail ||
dirtyFields.rxresumePassword,
);
const rxResumeValidationMode = (data.rxresumeMode ??
rxresumeMode) as RxResumeMode;
let rxResumeSaveWarningMessage: string | null = null;
if (shouldValidateRxResumeBeforeSave) {
const validationDraft = getRxResumeCredentialDrafts(data);
const precheckFailure = getRxResumeCredentialPrecheckFailure({
mode: rxResumeValidationMode,
stored: storedRxResume,
draft: validationDraft,
});
if (!precheckFailure) {
const preserveBlankFields = [
...(dirtyFields.rxresumeEmail ? (["email"] as const) : []),
...(dirtyFields.rxresumePassword ? (["password"] as const) : []),
...(dirtyFields.rxresumeApiKey ? (["apiKey"] as const) : []),
...(dirtyFields.rxresumeUrl ? (["baseUrl"] as const) : []),
];
const validation = await api.validateRxresume({
mode: rxResumeValidationMode,
...toRxResumeValidationPayload(validationDraft, {
preserveBlankFields: preserveBlankFields as Array<
keyof ReturnType<typeof getRxResumeCredentialDrafts>
>,
}),
});
setRxResumeValidationStatus(rxResumeValidationMode, validation);
if (isRxResumeBlockingValidationFailure(validation)) {
clearErrors(
getRxResumeValidationFieldsForMode(rxResumeValidationMode),
);
if (rxResumeValidationMode === "v5") {
setError("rxresumeApiKey", {
type: "manual",
message:
validation.message ??
"Reactive Resume v5 API key is invalid.",
});
} else {
setError("rxresumeEmail", {
type: "manual",
message:
validation.message ??
"Reactive Resume v4 email/password is invalid.",
});
setError("rxresumePassword", {
type: "manual",
message:
validation.message ??
"Reactive Resume v4 email/password is invalid.",
});
}
return;
}
clearErrors(
getRxResumeValidationFieldsForMode(rxResumeValidationMode),
);
if (isRxResumeAvailabilityValidationFailure(validation)) {
rxResumeSaveWarningMessage =
"Settings saved, but JobOps could not verify Reactive Resume because the instance is unavailable.";
}
}
}
const updated = await updateSettingsMutation.mutateAsync(payload);
setSettings(updated);
reset(mapSettingsToForm(updated));
toast.success("Settings saved");
if (rxResumeSaveWarningMessage) {
toast.info(rxResumeSaveWarningMessage);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save settings";
@ -995,6 +1109,7 @@ export const SettingsPage: React.FC = () => {
}}
hasRxResumeAccess={hasRxResumeAccess}
rxresumeMode={rxresumeMode}
onCredentialFieldEdit={clearRxResumeValidationFeedback}
validationStatuses={rxresumeValidationStatuses}
profileProjects={effectiveProfileProjects}
lockedCount={lockedCount}

View File

@ -21,9 +21,20 @@ type ReactiveResumeSectionProps = {
hasRxResumeAccess: boolean;
rxresumeMode: RxResumeMode;
onRxresumeModeChange?: (mode: RxResumeMode) => void;
onCredentialFieldEdit?: (mode: RxResumeMode) => void;
validationStatuses?: {
v4: { checked: boolean; valid: boolean; message?: string | null };
v5: { checked: boolean; valid: boolean; message?: string | null };
v4: {
checked: boolean;
valid: boolean;
message?: string | null;
status?: number | null;
};
v5: {
checked: boolean;
valid: boolean;
message?: string | null;
status?: number | null;
};
};
profileProjects: ResumeProjectCatalogItem[];
lockedCount: number;
@ -39,6 +50,7 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
hasRxResumeAccess,
rxresumeMode,
onRxresumeModeChange,
onCredentialFieldEdit,
validationStatuses,
profileProjects,
lockedCount,
@ -49,6 +61,7 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
}) => {
const {
control,
clearErrors,
setValue,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
@ -70,6 +83,15 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
shouldTouch: true,
});
const clearRxResumeFeedback = (mode: RxResumeMode) => {
onCredentialFieldEdit?.(mode);
clearErrors(
mode === "v5"
? ["rxresumeApiKey", "rxresumeUrl"]
: ["rxresumeEmail", "rxresumePassword", "rxresumeUrl"],
);
};
return (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -88,24 +110,32 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
validationStatuses={validationStatuses}
shared={{
baseUrl: rxresumeUrlValue,
onBaseUrlChange: (value) =>
setDirtyTouchedValue("rxresumeUrl", value),
onBaseUrlChange: (value) => {
clearRxResumeFeedback(selectedMode);
setDirtyTouchedValue("rxresumeUrl", value);
},
baseUrlError: errors.rxresumeUrl?.message as string | undefined,
}}
v5={{
apiKey: rxresumeApiKeyValue,
onApiKeyChange: (value) =>
setDirtyTouchedValue("rxresumeApiKey", value),
onApiKeyChange: (value) => {
clearRxResumeFeedback("v5");
setDirtyTouchedValue("rxresumeApiKey", value);
},
error: errors.rxresumeApiKey?.message as string | undefined,
}}
v4={{
email: rxresumeEmailValue,
onEmailChange: (value) =>
setDirtyTouchedValue("rxresumeEmail", value),
onEmailChange: (value) => {
clearRxResumeFeedback("v4");
setDirtyTouchedValue("rxresumeEmail", value);
},
emailError: errors.rxresumeEmail?.message as string | undefined,
password: rxresumePasswordValue,
onPasswordChange: (value) =>
setDirtyTouchedValue("rxresumePassword", value),
onPasswordChange: (value) => {
clearRxResumeFeedback("v4");
setDirtyTouchedValue("rxresumePassword", value);
},
passwordError: errors.rxresumePassword?.message as
| string
| undefined,

View File

@ -61,6 +61,7 @@ describe.sequential("Backup API routes", () => {
it("should return error if database does not exist", async () => {
// Delete the database
closeDb();
await fs.promises.unlink(`${tempDir}/jobs.db`);
const res = await fetch(`${baseUrl}/api/backups`, { method: "POST" });

View File

@ -1,5 +1,5 @@
import { notFound } from "@infra/errors";
import { fail } from "@infra/http";
import { badRequest, notFound } from "@infra/errors";
import { asyncRoute, fail, ok } from "@infra/http";
import { logger } from "@infra/logger";
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
import {
@ -15,102 +15,101 @@ export const backupRouter = Router();
/**
* GET /api/backups - List all backups with metadata
*/
backupRouter.get("/", async (_req: Request, res: Response) => {
try {
const backups = await listBackups();
const nextScheduled = getNextBackupTime();
res.json({
success: true,
data: {
backups,
nextScheduled,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
logger.error("Failed to list backups", error);
res.status(500).json({ success: false, error: message });
}
});
backupRouter.get(
"/",
asyncRoute(async (_req: Request, res: Response) => {
try {
const backups = await listBackups();
const nextScheduled = getNextBackupTime();
ok(res, { backups, nextScheduled });
} catch (error) {
logger.error("Failed to list backups", {
route: "GET /api/backups",
error,
});
throw error;
}
}),
);
/**
* POST /api/backups - Create a manual backup
*/
backupRouter.post("/", async (_req: Request, res: Response) => {
try {
if (isDemoMode()) {
return sendDemoBlocked(
res,
"Manual backup creation is disabled in the public demo.",
{ route: "POST /api/backups" },
);
backupRouter.post(
"/",
asyncRoute(async (_req: Request, res: Response) => {
try {
if (isDemoMode()) {
return sendDemoBlocked(
res,
"Manual backup creation is disabled in the public demo.",
{ route: "POST /api/backups" },
);
}
const filename = await createBackup("manual");
const backups = await listBackups();
const backup = backups.find((b) => b.filename === filename);
if (!backup) {
throw new Error("Backup was created but not found in list");
}
ok(res, backup);
} catch (error) {
logger.error("Failed to create backup", {
route: "POST /api/backups",
error,
});
throw error;
}
const filename = await createBackup("manual");
const backups = await listBackups();
const backup = backups.find((b) => b.filename === filename);
if (!backup) {
throw new Error("Backup was created but not found in list");
}
res.json({
success: true,
data: backup,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
logger.error("Failed to create backup", error);
res.status(500).json({ success: false, error: message });
}
});
}),
);
/**
* DELETE /api/backups/:filename - Delete a specific backup
*/
backupRouter.delete("/:filename", async (req: Request, res: Response) => {
try {
if (isDemoMode()) {
return sendDemoBlocked(
res,
"Deleting backups is disabled in the public demo.",
{
route: "DELETE /api/backups/:filename",
filename: req.params.filename,
},
);
}
backupRouter.delete(
"/:filename",
asyncRoute(async (req: Request, res: Response) => {
try {
if (isDemoMode()) {
return sendDemoBlocked(
res,
"Deleting backups is disabled in the public demo.",
{
route: "DELETE /api/backups/:filename",
filename: req.params.filename,
},
);
}
const { filename } = req.params;
const { filename } = req.params;
if (!filename) {
res.status(400).json({
success: false,
error: "Filename is required",
if (!filename) {
fail(res, badRequest("Filename is required"));
return;
}
await deleteBackup(filename);
ok(res, { message: `Backup ${filename} deleted successfully` });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
logger.error("Failed to delete backup", {
route: "DELETE /api/backups/:filename",
filename: req.params.filename,
error,
});
return;
if (message.includes("not found")) {
fail(res, notFound(message));
return;
}
if (message.includes("Invalid")) {
fail(res, badRequest(message));
return;
}
throw error;
}
await deleteBackup(filename);
res.json({
success: true,
message: `Backup ${filename} deleted successfully`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
logger.error("Failed to delete backup", {
filename: req.params.filename,
error,
});
if (message.includes("not found")) {
return fail(res, notFound(message));
} else if (message.includes("Invalid")) {
res.status(400).json({ success: false, error: message });
} else {
res.status(500).json({ success: false, error: message });
}
}
});
}),
);

View File

@ -341,6 +341,7 @@ describe.sequential("Onboarding API routes", () => {
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("not configured");
expect(body.data.status).toBe(400);
});
it("returns invalid when only email is provided", async () => {
@ -389,6 +390,68 @@ describe.sequential("Onboarding API routes", () => {
expect(body.ok).toBe(true);
// Should be invalid because credentials are fake
expect(body.data.valid).toBe(false);
expect(body.data.status).toBe(401);
expect(body.data.message).toContain("email/password");
});
it("returns a v5 API-key specific warning for invalid v5 credentials", 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: false,
status: 401,
headers: { get: () => "application/json" },
json: async () => ({ message: "Unauthorized" }),
} 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-invalid-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(false);
expect(body.data.status).toBe(401);
expect(body.data.message).toContain("API key");
});
it("returns an availability warning when the Reactive Resume instance is unreachable", async () => {
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (url.includes("/api/openapi/resumes")) {
return Promise.reject(new TypeError("fetch failed"));
}
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(false);
expect(body.data.status).toBe(0);
expect(body.data.message).toContain("http://localhost:3000");
expect(body.data.message).toContain("unavailable");
});
it("validates v5 API key mode against Reactive Resume OpenAPI", async () => {
@ -420,6 +483,7 @@ describe.sequential("Onboarding API routes", () => {
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(true);
expect(body.data.message).toBeNull();
expect(body.data.status).toBeNull();
});
it("handles whitespace-only credentials", async () => {
@ -433,6 +497,7 @@ describe.sequential("Onboarding API routes", () => {
expect(res.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("not configured");
expect(body.data.status).toBe(400);
});
});

View File

@ -17,6 +17,7 @@ export const onboardingRouter = Router();
type ValidationResponse = {
valid: boolean;
message: string | null;
status?: number | null;
};
function getDefaultValidationBaseUrl(
@ -140,10 +141,57 @@ async function validateRxresume(options?: {
baseUrl?: string | null;
}): Promise<ValidationResponse> {
const rawMode = options?.mode?.trim();
const mode = rawMode === "v4" || rawMode === "v5" ? rawMode : undefined;
const explicitMode = rawMode === "v4" || rawMode === "v5" ? rawMode : null;
const requestEmail = options?.email?.trim() ?? "";
const requestPassword = options?.password?.trim() ?? "";
const requestApiKey = options?.apiKey?.trim() ?? "";
const hasExplicitV4Input =
options?.email !== undefined || options?.password !== undefined;
const hasExplicitV5Input = options?.apiKey !== undefined;
const storedModeRaw = (await getSetting("rxresumeMode"))?.trim();
const storedMode =
storedModeRaw === "v4" || storedModeRaw === "v5"
? storedModeRaw
: undefined;
const inferredMode =
explicitMode ??
(hasExplicitV5Input
? "v5"
: hasExplicitV4Input
? "v4"
: storedMode === "v4"
? "v4"
: "v5");
const storedBaseUrl = await getSetting("rxresumeUrl");
const resolvedBaseUrl =
options?.baseUrl !== undefined && options?.baseUrl !== null
? options.baseUrl.trim() ||
process.env.RXRESUME_URL?.trim() ||
(inferredMode === "v4" ? "https://v4.rxresu.me" : "https://rxresu.me")
: storedBaseUrl?.trim() ||
process.env.RXRESUME_URL?.trim() ||
(inferredMode === "v4" ? "https://v4.rxresu.me" : "https://rxresu.me");
if (inferredMode === "v4" && hasExplicitV4Input) {
if (!requestEmail || !requestPassword) {
return {
valid: false,
status: 400,
message: "Reactive Resume v4 credentials are not configured.",
};
}
}
if (inferredMode === "v5" && hasExplicitV5Input && !requestApiKey) {
return {
valid: false,
status: 400,
message: "Reactive Resume v5 API key is not configured.",
};
}
const result = await validateRxResumeCredentials({
mode,
mode: inferredMode,
v4: {
email: options?.email ?? undefined,
password: options?.password ?? undefined,
@ -155,21 +203,52 @@ async function validateRxresume(options?: {
},
});
if (result.ok) return { valid: true, message: null };
if (result.ok) return { valid: true, message: null, status: null };
const normalizedMessage = result.message.toLowerCase();
if (result.status === 400 && normalizedMessage.includes("not configured")) {
return {
valid: false,
status: 400,
message: result.message,
};
}
if (
result.status === 401 ||
normalizedMessage.includes("invalidcredentials")
) {
return {
valid: false,
status: result.status,
message:
"Invalid RxResume credentials. Check your configured Reactive Resume mode credentials and try again.",
inferredMode === "v4"
? "Reactive Resume v4 email/password is invalid. Update the email/password and try again."
: "Reactive Resume v5 API key is invalid. Update the API key and try again.",
};
}
return { valid: false, message: result.message };
if (result.status === 0 || result.status >= 500) {
return {
valid: false,
status: result.status,
message: `JobOps could not verify Reactive Resume because the instance at ${resolvedBaseUrl} is unavailable right now.`,
};
}
if (result.status >= 400 && result.status < 500) {
return {
valid: false,
status: result.status,
message: `Reactive Resume returned HTTP ${result.status} from ${resolvedBaseUrl}. Check the configured URL and selected mode.`,
};
}
return {
valid: false,
message: result.message,
status: result.status,
};
}
onboardingRouter.post(

View File

@ -4,6 +4,7 @@ import { startServer, stopServer } from "./test-utils";
// Mock the RxResume adapter service
vi.mock("@server/services/rxresume", () => ({
clearRxResumeResumeCache: vi.fn(),
getResume: vi.fn(),
RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error {
constructor() {

View File

@ -4,7 +4,11 @@ import { isDemoMode } from "@server/config/demo";
import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults";
import { clearProfileCache, getProfile } from "@server/services/profile";
import { extractProjectsFromProfile } from "@server/services/resumeProjects";
import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume";
import {
clearRxResumeResumeCache,
getResume,
RxResumeAuthConfigError,
} from "@server/services/rxresume";
import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
import { type Request, type Response, Router } from "express";
@ -87,6 +91,7 @@ profileRouter.get("/status", async (_req: Request, res: Response) => {
profileRouter.post("/refresh", async (_req: Request, res: Response) => {
try {
clearProfileCache();
clearRxResumeResumeCache();
const profile = await getProfile(true);
ok(res, profile);
} catch (error) {

View File

@ -2,8 +2,13 @@ import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@server/services/rxresume", () => ({
clearRxResumeResumeCache: vi.fn(),
listResumes: vi.fn(),
getResume: vi.fn(),
validateCredentials: vi.fn(async () => ({
ok: true,
mode: "v5",
})),
validateResumeSchema: vi.fn(async (data: unknown) => ({
ok: true,
mode:
@ -55,6 +60,7 @@ vi.mock("@server/services/rxresume", () => ({
import {
extractProjectsFromResume,
getResume,
validateCredentials,
} from "@server/services/rxresume";
import { startServer, stopServer } from "./test-utils";
@ -65,6 +71,11 @@ describe.sequential("Settings API routes", () => {
let tempDir: string;
beforeEach(async () => {
vi.clearAllMocks();
vi.mocked(validateCredentials).mockResolvedValue({
ok: true,
mode: "v5",
});
({ server, baseUrl, closeDb, tempDir } = await startServer({
env: {
LLM_API_KEY: "secret-key",
@ -139,6 +150,110 @@ describe.sequential("Settings API routes", () => {
expect(patchBody.data.llmApiKeyHint).toBe("upda");
});
it("blocks saving when the configured Reactive Resume v5 API key is invalid", async () => {
vi.mocked(validateCredentials).mockResolvedValue({
ok: false,
mode: "v5",
status: 401,
message:
"Reactive Resume v5 API key is invalid. Update the API key and try again.",
});
const res = await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rxresumeMode: "v5",
rxresumeApiKey: "invalid-key",
}),
});
const body = await res.json();
expect(res.status).toBe(401);
expect(body.ok).toBe(false);
expect(body.error.code).toBe("UNAUTHORIZED");
expect(body.error.message).toContain("API key");
const settingsRes = await fetch(`${baseUrl}/api/settings`);
const settingsBody = await settingsRes.json();
expect(settingsBody.data.rxresumeApiKeyHint).toBeNull();
});
it("blocks saving when Reactive Resume returns another 4xx validation failure", async () => {
vi.mocked(validateCredentials).mockResolvedValue({
ok: false,
mode: "v5",
status: 404,
message:
"Reactive Resume returned HTTP 404 from https://resume.example.com. Check the configured URL and selected mode.",
});
const res = await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rxresumeUrl: "https://resume.example.com",
}),
});
const body = await res.json();
expect(res.status).toBe(404);
expect(body.ok).toBe(false);
expect(body.error.code).toBe("NOT_FOUND");
const settingsRes = await fetch(`${baseUrl}/api/settings`);
const settingsBody = await settingsRes.json();
expect(settingsBody.data.rxresumeUrl).toBe(
"https://env.rxresume.example.com",
);
});
it("allows saving when Reactive Resume is temporarily unavailable", async () => {
vi.mocked(validateCredentials).mockResolvedValue({
ok: false,
mode: "v5",
status: 0,
message:
"JobOps could not verify Reactive Resume because the instance is unavailable.",
});
const res = await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rxresumeMode: "v5",
rxresumeApiKey: "rr-v5-warning-key",
}),
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.ok).toBe(true);
expect(body.data.rxresumeApiKeyHint).toBe("rr-v");
});
it("does not run Reactive Resume validation for unrelated settings saves", async () => {
vi.mocked(validateCredentials).mockResolvedValue({
ok: false,
mode: "v5",
status: 401,
message: "should not run",
});
const res = await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "gpt-4o-mini",
}),
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.ok).toBe(true);
expect(validateCredentials).not.toHaveBeenCalled();
});
it("validates basic auth requirements", async () => {
const res = await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",

View File

@ -3,27 +3,110 @@ import {
badRequest,
serviceUnavailable,
statusToCode,
unauthorized,
upstreamError,
} from "@infra/errors";
import { asyncRoute, fail, ok } from "@infra/http";
import { logger } from "@infra/logger";
import { getRequestId } from "@infra/request-context";
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
import { setBackupSettings } from "@server/services/backup/index";
import { clearProfileCache } from "@server/services/profile";
import {
clearRxResumeResumeCache,
extractProjectsFromResume,
getResume,
listResumes,
RxResumeAuthConfigError,
RxResumeRequestError,
validateResumeSchema,
validateCredentials as validateRxResumeCredentials,
} from "@server/services/rxresume";
import { getEffectiveSettings } from "@server/services/settings";
import { applySettingsUpdates } from "@server/services/settings-update";
import { updateSettingsSchema } from "@shared/settings-schema";
import {
type UpdateSettingsInput,
updateSettingsSchema,
} from "@shared/settings-schema";
import { type Request, type Response, Router } from "express";
export const settingsRouter = Router();
const RXRESUME_SAVE_VALIDATION_KEYS: Array<keyof UpdateSettingsInput> = [
"rxresumeMode",
"rxresumeUrl",
"rxresumeApiKey",
"rxresumeEmail",
"rxresumePassword",
];
function hasInputKey<K extends keyof UpdateSettingsInput>(
input: UpdateSettingsInput,
key: K,
): boolean {
return Object.hasOwn(input, key);
}
function shouldValidateRxResumeOnSave(input: UpdateSettingsInput): boolean {
return RXRESUME_SAVE_VALIDATION_KEYS.some((key) => hasInputKey(input, key));
}
function isMissingRxResumeConfigValidationResult(input: {
status: number;
message: string;
}): boolean {
return input.status === 400 && /not configured/i.test(input.message);
}
function buildRxResumeValidationOptions(
input: UpdateSettingsInput,
): Parameters<typeof validateRxResumeCredentials>[0] {
return {
mode:
input.rxresumeMode === "v4" || input.rxresumeMode === "v5"
? input.rxresumeMode
: undefined,
v4: {
...(hasInputKey(input, "rxresumeEmail")
? { email: input.rxresumeEmail }
: {}),
...(hasInputKey(input, "rxresumePassword")
? { password: input.rxresumePassword }
: {}),
...(hasInputKey(input, "rxresumeUrl")
? { baseUrl: input.rxresumeUrl }
: {}),
},
v5: {
...(hasInputKey(input, "rxresumeApiKey")
? { apiKey: input.rxresumeApiKey }
: {}),
...(hasInputKey(input, "rxresumeUrl")
? { baseUrl: input.rxresumeUrl }
: {}),
},
};
}
function toRxResumeValidationAppError(
status: number,
message: string,
): AppError {
if (status === 401) {
return unauthorized(message);
}
if (status === 400) {
return badRequest(message);
}
return new AppError({
status,
code: statusToCode(status),
message,
});
}
/**
* GET /api/settings - Get app settings (effective + defaults)
*/
@ -50,7 +133,49 @@ settingsRouter.patch(
}
const input = updateSettingsSchema.parse(req.body);
if (shouldValidateRxResumeOnSave(input)) {
const validation = await validateRxResumeCredentials(
buildRxResumeValidationOptions(input),
);
if (!validation.ok) {
const status = validation.status ?? 0;
if (
isMissingRxResumeConfigValidationResult({
status,
message: validation.message,
})
) {
logger.info(
"Skipping save-time Reactive Resume validation because credentials are incomplete",
{
requestId: getRequestId() ?? null,
route: "PATCH /api/settings",
rxresumeMode: validation.mode ?? input.rxresumeMode ?? null,
status,
},
);
} else if (status >= 400 && status < 500) {
fail(res, toRxResumeValidationAppError(status, validation.message));
return;
} else if (status === 0 || status >= 500) {
logger.warn(
"Reactive Resume save-time validation could not verify upstream availability",
{
requestId: getRequestId() ?? null,
route: "PATCH /api/settings",
rxresumeMode: validation.mode ?? input.rxresumeMode ?? null,
status,
},
);
}
}
}
const plan = await applySettingsUpdates(input);
if (plan.shouldClearRxResumeCaches) {
clearRxResumeResumeCache();
clearProfileCache();
}
const data = await getEffectiveSettings();

View File

@ -61,6 +61,7 @@ vi.mock("@server/services/scorer", () => ({
vi.mock("@server/services/profile", () => ({
getProfile: vi.fn().mockResolvedValue({}),
clearProfileCache: vi.fn(),
}));
vi.mock("@server/services/visa-sponsors/index", () => ({
@ -81,6 +82,23 @@ vi.mock("@server/services/visa-sponsors/index", () => ({
}));
const originalEnv = { ...process.env };
const isolatedEnvKeys = [
"RXRESUME_API_KEY",
"RXRESUME_EMAIL",
"RXRESUME_PASSWORD",
"RXRESUME_URL",
"RXRESUME_MODE",
"LLM_API_KEY",
"LLM_PROVIDER",
"LLM_BASE_URL",
"BASIC_AUTH_USER",
"BASIC_AUTH_PASSWORD",
"WEBHOOK_SECRET",
"UKVISAJOBS_EMAIL",
"UKVISAJOBS_PASSWORD",
"ADZUNA_APP_ID",
"ADZUNA_APP_KEY",
] as const;
export async function startServer(options?: {
env?: Record<string, string | undefined>;
@ -93,8 +111,12 @@ export async function startServer(options?: {
vi.resetModules();
const tempDir = await mkdtemp(join(tmpdir(), "job-ops-api-test-"));
const envOverrides = options?.env ?? {};
const nextEnv = { ...originalEnv };
for (const key of isolatedEnvKeys) {
delete nextEnv[key];
}
process.env = {
...originalEnv,
...nextEnv,
DATA_DIR: tempDir,
NODE_ENV: "test",
MODEL: "test-model",

View File

@ -20,11 +20,14 @@ if (!existsSync(dataDir)) {
const sqlite = new Database(DB_PATH);
sqlite.pragma("journal_mode = WAL");
let isClosed = false;
export const db = drizzle(sqlite, { schema });
export { schema };
export function closeDb() {
if (isClosed) return;
sqlite.close();
isClosed = true;
}

View File

@ -8,6 +8,7 @@
import fs from "node:fs";
import type { FileHandle } from "node:fs/promises";
import path from "node:path";
import { logger } from "@infra/logger";
import { getDataDir } from "@server/config/dataDir";
import { createScheduler } from "@server/utils/scheduler";
import type { BackupInfo } from "@shared/types";
@ -32,59 +33,43 @@ interface BackupSettings {
maxCount: number;
}
// Current settings (updated by setBackupSettings)
let currentSettings: BackupSettings = {
enabled: false,
hour: 2,
maxCount: 5,
};
// Create scheduler for automatic backups
const scheduler = createScheduler("backup", async () => {
await createBackup("auto");
await cleanupOldBackups();
});
/**
* Get the path to the database file
*/
function getDbPath(): string {
return path.join(getDataDir(), DB_FILENAME);
}
/**
* Get the data directory path
*/
function getBackupDir(): string {
return getDataDir();
}
/**
* Generate filename for a backup
*/
function generateBackupFilename(type: "auto" | "manual"): string {
const now = new Date();
if (type === "auto") {
// Format: jobs_YYYY_MM_DD.db (UTC date to match UTC scheduler)
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
const day = String(now.getUTCDate()).padStart(2, "0");
return `${AUTO_BACKUP_PREFIX}${year}_${month}_${day}.db`;
} else {
// Format: jobs_manual_YYYY_MM_DD_HH_MM_SS.db (local time for manual backups)
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
return `${MANUAL_BACKUP_PREFIX}${year}_${month}_${day}_${hours}_${minutes}_${seconds}.db`;
}
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
return `${MANUAL_BACKUP_PREFIX}${year}_${month}_${day}_${hours}_${minutes}_${seconds}.db`;
}
/**
* Parse backup filename to extract creation date
*/
function parseBackupDate(filename: string): Date | null {
const autoMatch = filename.match(AUTO_BACKUP_REGEX);
if (autoMatch) {
@ -135,20 +120,12 @@ function buildUtcDate(
return date;
}
/**
* Determine backup type from filename
*/
function getBackupType(filename: string): "auto" | "manual" | null {
if (AUTO_BACKUP_PATTERN.test(filename)) return "auto";
if (MANUAL_BACKUP_PATTERN.test(filename)) return "manual";
return null;
}
/**
* Create a backup of the database
* @param type - 'auto' for scheduled backups, 'manual' for user-triggered
* @returns The filename of the created backup
*/
export async function createBackup(type: "auto" | "manual"): Promise<string> {
const dbPath = getDbPath();
const backupDir = getBackupDir();
@ -157,7 +134,6 @@ export async function createBackup(type: "auto" | "manual"): Promise<string> {
let backupPath = path.join(backupDir, filename);
let reservedHandle: FileHandle | null = null;
// Check if database exists
if (!fs.existsSync(dbPath)) {
throw new Error(`Database file not found: ${dbPath}`);
}
@ -176,9 +152,10 @@ export async function createBackup(type: "auto" | "manual"): Promise<string> {
if (type === "auto") {
reservedHandle = await tryReserve(backupPath);
if (!reservedHandle) {
console.log(
` [backup] Auto backup already exists for today: ${filename}`,
);
logger.info("Automatic backup already exists for current day", {
filename,
type,
});
return filename;
}
} else {
@ -204,7 +181,6 @@ export async function createBackup(type: "auto" | "manual"): Promise<string> {
}
}
// Close the reserved file handle before running SQLite backup
await reservedHandle.close();
let sqlite: SqliteDatabase | null = null;
@ -218,32 +194,28 @@ export async function createBackup(type: "auto" | "manual"): Promise<string> {
sqlite?.close();
}
console.log(
`✅ [backup] Created ${type} backup: ${filename} (${(await fs.promises.stat(backupPath)).size} bytes)`,
);
logger.info("Created database backup", {
filename,
type,
size: (await fs.promises.stat(backupPath)).size,
});
return filename;
}
/**
* List all backups with metadata
* @returns Array of backup information
*/
export async function listBackups(): Promise<BackupInfo[]> {
const backupDir = getBackupDir();
// Check if directory exists
if (!fs.existsSync(backupDir)) {
return [];
}
// Read directory and filter backup files
const files = await fs.promises.readdir(backupDir);
const backupFiles = files.filter((file) => {
return AUTO_BACKUP_PATTERN.test(file) || MANUAL_BACKUP_PATTERN.test(file);
});
const backupFiles = files.filter(
(file) =>
AUTO_BACKUP_PATTERN.test(file) || MANUAL_BACKUP_PATTERN.test(file),
);
// Get metadata for each backup
const backups: BackupInfo[] = [];
for (const filename of backupFiles) {
const filePath = path.join(backupDir, filename);
@ -261,20 +233,14 @@ export async function listBackups(): Promise<BackupInfo[]> {
}
}
// Sort by creation date (newest first)
backups.sort((a, b) => {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
backups.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return backups;
}
/**
* Delete a specific backup
* @param filename - Name of the backup file to delete
*/
export async function deleteBackup(filename: string): Promise<void> {
// Validate filename to prevent path traversal
if (
!AUTO_BACKUP_PATTERN.test(filename) &&
!MANUAL_BACKUP_PATTERN.test(filename)
@ -285,33 +251,22 @@ export async function deleteBackup(filename: string): Promise<void> {
const backupDir = getBackupDir();
const filePath = path.join(backupDir, filename);
// Check if file exists
if (!fs.existsSync(filePath)) {
throw new Error(`Backup not found: ${filename}`);
}
// Delete file
await fs.promises.unlink(filePath);
console.log(`🗑️ [backup] Deleted backup: ${filename}`);
logger.info("Deleted database backup", { filename });
}
/**
* Clean up old automatic backups
* Keeps only the most recent N automatic backups (where N = maxCount)
* Manual backups are never deleted automatically
*/
export async function cleanupOldBackups(): Promise<void> {
const backups = await listBackups();
// Filter to only automatic backups
const autoBackups = backups.filter((b) => b.type === "auto");
// Sort by creation date (oldest first for deletion)
autoBackups.sort((a, b) => {
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
autoBackups.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
// Delete oldest backups if we exceed max count
const maxCount = currentSettings.maxCount;
if (autoBackups.length > maxCount) {
const toDelete = autoBackups.slice(0, autoBackups.length - maxCount);
@ -320,79 +275,55 @@ export async function cleanupOldBackups(): Promise<void> {
try {
await deleteBackup(backup.filename);
} catch (error) {
console.error(
`❌ [backup] Failed to delete old backup ${backup.filename}:`,
logger.error("Failed to delete old automatic backup", {
filename: backup.filename,
error,
);
});
}
}
console.log(
`🧹 [backup] Cleaned up ${toDelete.length} old automatic backups (max: ${maxCount})`,
);
logger.info("Cleaned up old automatic backups", {
deletedCount: toDelete.length,
maxCount,
});
}
}
/**
* Update backup settings and restart scheduler if needed
* @param settings - New backup settings
*/
export function setBackupSettings(settings: Partial<BackupSettings>): void {
const oldEnabled = currentSettings.enabled;
const oldHour = currentSettings.hour;
// Update settings
currentSettings = { ...currentSettings, ...settings };
console.log(`⚙️ [backup] Settings updated:`, currentSettings);
logger.info("Backup settings updated", currentSettings);
// Restart scheduler if settings changed
if (currentSettings.enabled) {
if (!oldEnabled || oldHour !== currentSettings.hour) {
// Start or restart with new hour
scheduler.start(currentSettings.hour);
}
} else if (oldEnabled && !currentSettings.enabled) {
// Stop scheduler
scheduler.stop();
}
}
/**
* Get current backup settings
*/
export function getBackupSettings(): BackupSettings {
return { ...currentSettings };
}
/**
* Get the next scheduled backup time
* @returns ISO string of next backup time, or null if disabled
*/
export function getNextBackupTime(): string | null {
return scheduler.getNextRun();
}
/**
* Check if automatic backup scheduler is running
*/
export function isBackupSchedulerRunning(): boolean {
return scheduler.isRunning();
}
/**
* Start the backup scheduler manually (used on server startup)
* Only starts if backup is enabled
*/
export function startBackupScheduler(): void {
if (currentSettings.enabled) {
scheduler.start(currentSettings.hour);
}
}
/**
* Stop the backup scheduler
*/
export function stopBackupScheduler(): void {
scheduler.stop();
}

View File

@ -78,6 +78,11 @@ describe("getProfile", () => {
await getProfile(true);
expect(getResume).toHaveBeenCalledTimes(2);
expect(vi.mocked(getResume).mock.calls[0]).toEqual(["test-resume-id"]);
expect(vi.mocked(getResume).mock.calls[1]).toEqual([
"test-resume-id",
{ forceRefresh: true },
]);
});
it("should throw user-friendly error on credential issues", async () => {

View File

@ -38,7 +38,9 @@ export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
logger.info("Fetching profile from Reactive Resume", {
resumeId: rxresumeBaseResumeId,
});
const resume = await getResume(rxresumeBaseResumeId);
const resume = forceRefresh
? await getResume(rxresumeBaseResumeId, { forceRefresh: true })
: await getResume(rxresumeBaseResumeId);
if (!resume.data || typeof resume.data !== "object") {
throw new Error("Resume data is empty or invalid");

View File

@ -72,7 +72,7 @@ const tokenCache = new Map<string, CachedToken>();
// Default token TTL: 50 minutes (JWT tokens typically expire in 1 hour)
const DEFAULT_TOKEN_TTL_MS = 50 * 60 * 1000;
export class RxResumeClient {
class RxResumeClientImpl {
private readonly tokenTtlMs: number;
constructor(
@ -456,6 +456,21 @@ export class RxResumeClient {
}
}
type GlobalRxResumeClientClass = typeof globalThis & {
__jobOpsRxResumeClientClass?: typeof RxResumeClientImpl;
};
const globalRxResumeClientClass = globalThis as GlobalRxResumeClientClass;
const rxResumeClientClass =
globalRxResumeClientClass.__jobOpsRxResumeClientClass ?? RxResumeClientImpl;
globalRxResumeClientClass.__jobOpsRxResumeClientClass = rxResumeClientClass;
export const RxResumeClient = rxResumeClientClass;
export type RxResumeClient = InstanceType<typeof RxResumeClientImpl>;
function sanitizeResponseSnippet(text: string): string {
if (!text) return "";
const compact = normalizeWhitespace(text);

View File

@ -31,6 +31,7 @@ vi.mock("./client", () => ({
import { getSetting } from "@server/repositories/settings";
import { RxResumeClient } from "./client";
import {
clearRxResumeResumeCache,
extractProjectsFromResume,
getResume as getResumeFromAdapter,
listResumes,
@ -53,6 +54,8 @@ function mockSettings(map: SettingMap): void {
describe("rxresume adapter", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
clearRxResumeResumeCache();
delete process.env.RXRESUME_API_KEY;
delete process.env.RXRESUME_EMAIL;
delete process.env.RXRESUME_PASSWORD;
@ -222,6 +225,109 @@ describe("rxresume adapter", () => {
});
});
it("caches successful resume fetches", async () => {
mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" });
vi.mocked(v5.getResume).mockResolvedValue({
id: "resume-1",
name: "Resume One",
data: { basics: { name: "Test User" } },
} as any);
const first = await getResumeFromAdapter("resume-1");
const second = await getResumeFromAdapter("resume-1");
expect(v5.getResume).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first).not.toBe(second);
});
it("expires cached resumes after the ttl", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" });
vi.mocked(v5.getResume).mockResolvedValue({
id: "resume-1",
name: "Resume One",
data: { basics: { name: "Test User" } },
} as any);
await getResumeFromAdapter("resume-1");
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
await getResumeFromAdapter("resume-1");
expect(v5.getResume).toHaveBeenCalledTimes(2);
});
it("supports forceRefresh for cached resumes", async () => {
mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" });
vi.mocked(v5.getResume).mockResolvedValue({
id: "resume-1",
name: "Resume One",
data: { basics: { name: "Test User" } },
} as any);
await getResumeFromAdapter("resume-1");
await getResumeFromAdapter("resume-1", { forceRefresh: true });
expect(v5.getResume).toHaveBeenCalledTimes(2);
});
it("clears the centralized resume cache on demand", async () => {
mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" });
vi.mocked(v5.getResume).mockResolvedValue({
id: "resume-1",
name: "Resume One",
data: { basics: { name: "Test User" } },
} as any);
await getResumeFromAdapter("resume-1");
clearRxResumeResumeCache();
await getResumeFromAdapter("resume-1");
expect(v5.getResume).toHaveBeenCalledTimes(2);
});
it("coalesces in-flight resume fetches", async () => {
mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" });
let resolveResume: ((value: Record<string, unknown>) => void) | undefined;
vi.mocked(v5.getResume).mockImplementation(
() =>
new Promise((resolve) => {
resolveResume = resolve;
}) as Promise<any>,
);
const first = getResumeFromAdapter("resume-1");
const second = getResumeFromAdapter("resume-1");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(v5.getResume).toHaveBeenCalledTimes(1);
resolveResume?.({
id: "resume-1",
name: "Resume One",
data: { basics: { name: "Test User" } },
});
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult).toEqual(secondResult);
});
it("creates separate cache entries for different credential fingerprints", async () => {
vi.mocked(v5.getResume).mockResolvedValue({
id: "resume-1",
name: "Resume One",
data: { basics: { name: "Test User" } },
} as any);
mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key-one" });
await getResumeFromAdapter("resume-1");
mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key-two" });
await getResumeFromAdapter("resume-1");
expect(v5.getResume).toHaveBeenCalledTimes(2);
});
it("prepares tailored v5 resume payload without relying on v4 fields", async () => {
const v5ResumeData = {
basics: {

View File

@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { getSetting } from "@server/repositories/settings";
import { pickProjectIdsForJob } from "@server/services/projectSelection";
import { resolveResumeProjectsSettings } from "@server/services/resumeProjects";
@ -71,6 +72,7 @@ export class RxResumeRequestError extends Error {
type ResolveModeOptions = {
mode?: RxResumeMode;
forceRefresh?: boolean;
v4?: {
email?: string | null;
password?: string | null;
@ -81,6 +83,91 @@ type ResolveModeOptions = {
type V4Credentials = Awaited<ReturnType<typeof readV4Credentials>>;
type V5Credentials = Awaited<ReturnType<typeof readV5Credentials>>;
type ResolvedOperationContext =
| { mode: "v4"; creds: V4Credentials }
| { mode: "v5"; creds: V5Credentials };
const RXRESUME_RESUME_CACHE_TTL_MS = 5 * 60 * 1000;
type RxResumeResumeCacheEntry = {
expiresAt: number;
resume: RxResumeResume;
};
const rxResumeResumeCache = new Map<string, RxResumeResumeCacheEntry>();
const inFlightResumeRequests = new Map<string, Promise<RxResumeResume>>();
let rxResumeResumeCacheGeneration = 0;
function hasOverrideKey<T extends object>(
value: T | undefined,
key: PropertyKey,
): boolean {
return value !== undefined && Object.hasOwn(value, key);
}
function resolveOverrideValue(args: {
overrideValue?: string | null;
hasOverride: boolean;
storedValue?: string | null;
envValue?: string | null;
fallback?: string;
}): string {
if (args.hasOverride) {
const trimmed = args.overrideValue?.trim() ?? "";
return trimmed || args.envValue?.trim() || args.fallback || "";
}
return (
args.storedValue?.trim() || args.envValue?.trim() || args.fallback || ""
);
}
function cloneResume(resume: RxResumeResume): RxResumeResume {
return structuredClone(resume) as RxResumeResume;
}
function normalizeBaseUrlForCache(baseUrl: string): string {
const trimmed = baseUrl.trim();
try {
const url = new URL(trimmed);
url.hash = "";
url.search = "";
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
return url.toString().replace(/\/$/, "");
} catch {
return trimmed.replace(/\/+$/, "");
}
}
function buildCredentialFingerprint(context: ResolvedOperationContext): string {
const normalizedCredential =
context.mode === "v5"
? context.creds.apiKey.trim()
: `${context.creds.email.trim().toLowerCase()}:${context.creds.password.trim()}`;
return createHash("sha256")
.update(normalizedCredential)
.digest("hex")
.slice(0, 12);
}
function buildResumeCacheKey(
resumeId: string,
context: ResolvedOperationContext,
): string {
return [
context.mode,
normalizeBaseUrlForCache(context.creds.baseUrl),
resumeId.trim(),
buildCredentialFingerprint(context),
].join("::");
}
export function clearRxResumeResumeCache(): void {
rxResumeResumeCacheGeneration += 1;
rxResumeResumeCache.clear();
inFlightResumeRequests.clear();
}
function toV4Override(
input?: ResolveModeOptions["v4"],
@ -177,21 +264,25 @@ async function readV4Credentials(overrides?: ResolveModeOptions["v4"]) {
getSetting("rxresumePassword"),
getSetting("rxresumeUrl"),
]);
const email =
overrides?.email?.trim() ||
storedEmail?.trim() ||
process.env.RXRESUME_EMAIL?.trim() ||
"";
const password =
overrides?.password?.trim() ||
storedPassword?.trim() ||
process.env.RXRESUME_PASSWORD?.trim() ||
"";
const baseUrl =
overrides?.baseUrl?.trim() ||
storedBaseUrl?.trim() ||
process.env.RXRESUME_URL?.trim() ||
"https://v4.rxresu.me";
const email = resolveOverrideValue({
overrideValue: overrides?.email,
hasOverride: hasOverrideKey(overrides, "email"),
storedValue: storedEmail,
envValue: process.env.RXRESUME_EMAIL,
});
const password = resolveOverrideValue({
overrideValue: overrides?.password,
hasOverride: hasOverrideKey(overrides, "password"),
storedValue: storedPassword,
envValue: process.env.RXRESUME_PASSWORD,
});
const baseUrl = resolveOverrideValue({
overrideValue: overrides?.baseUrl,
hasOverride: hasOverrideKey(overrides, "baseUrl"),
storedValue: storedBaseUrl,
envValue: process.env.RXRESUME_URL,
fallback: "https://v4.rxresu.me",
});
return { email, password, baseUrl, available: Boolean(email && password) };
}
@ -200,60 +291,25 @@ async function readV5Credentials(overrides?: ResolveModeOptions["v5"]) {
getSetting("rxresumeApiKey"),
getSetting("rxresumeUrl"),
]);
const apiKey =
overrides?.apiKey?.trim() ||
storedApiKey?.trim() ||
process.env.RXRESUME_API_KEY?.trim() ||
"";
const baseUrl =
overrides?.baseUrl?.trim() ||
storedBaseUrl?.trim() ||
process.env.RXRESUME_URL?.trim() ||
"https://rxresu.me";
const apiKey = resolveOverrideValue({
overrideValue: overrides?.apiKey,
hasOverride: hasOverrideKey(overrides, "apiKey"),
storedValue: storedApiKey,
envValue: process.env.RXRESUME_API_KEY,
});
const baseUrl = resolveOverrideValue({
overrideValue: overrides?.baseUrl,
hasOverride: hasOverrideKey(overrides, "baseUrl"),
storedValue: storedBaseUrl,
envValue: process.env.RXRESUME_URL,
fallback: "https://rxresu.me",
});
return { apiKey, baseUrl, available: Boolean(apiKey) };
}
export async function resolveRxResumeMode(
async function resolveOperationContext(
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> {
): Promise<ResolvedOperationContext> {
const requestedMode = options.mode ?? (await readConfiguredMode());
const [v5Creds, v4Creds] = await Promise.all([
readV5Credentials(options.v5),
@ -267,11 +323,7 @@ async function runRxResumeOperation<T>(
"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);
}
return { mode: "v5", creds: v5Creds };
}
if (!v4Creds.available) {
@ -280,8 +332,65 @@ async function runRxResumeOperation<T>(
"Reactive Resume v4 credentials are not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD or configure them in Settings.",
);
}
return { mode: "v4", creds: v4Creds };
}
export async function resolveRxResumeMode(
options: ResolveModeOptions = {},
): Promise<RxResumeResolvedMode> {
const context = await resolveOperationContext(options);
return context.mode;
}
async function runRxResumeOperation<T>(
options: ResolveModeOptions,
handlers: {
v4: (creds: V4Credentials) => Promise<T>;
v5: (creds: V5Credentials) => Promise<T>;
},
): Promise<T> {
const context = await resolveOperationContext(options);
try {
return await handlers.v4(v4Creds);
if (context.mode === "v5") {
return await handlers.v5(context.creds);
}
return await handlers.v4(context.creds);
} catch (error) {
throw normalizeError(error);
}
}
async function fetchResumeFromUpstream(
resumeId: string,
context: ResolvedOperationContext,
): Promise<RxResumeResume> {
try {
if (context.mode === "v5") {
const resume = normalizeV5ResumeResponse(
await v5.getResume(resumeId, {
apiKey: context.creds.apiKey,
baseUrl: context.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;
}
return {
...((await v4.getResume(
resumeId,
toV4Override(context.creds),
)) as RxResumeResume),
mode: "v4",
};
} catch (error) {
throw normalizeError(error);
}
@ -304,32 +413,46 @@ 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",
}),
});
const context = await resolveOperationContext(options);
const cacheKey = buildResumeCacheKey(resumeId, context);
const now = Date.now();
if (!options.forceRefresh) {
const cached = rxResumeResumeCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cloneResume(cached.resume);
}
if (cached) {
rxResumeResumeCache.delete(cacheKey);
}
const inFlight = inFlightResumeRequests.get(cacheKey);
if (inFlight) {
return cloneResume(await inFlight);
}
}
const generation = rxResumeResumeCacheGeneration;
let request: Promise<RxResumeResume>;
request = fetchResumeFromUpstream(resumeId, context)
.then((resume) => {
const cachedResume = cloneResume(resume);
if (generation === rxResumeResumeCacheGeneration) {
rxResumeResumeCache.set(cacheKey, {
expiresAt: Date.now() + RXRESUME_RESUME_CACHE_TTL_MS,
resume: cachedResume,
});
}
return cloneResume(cachedResume);
})
.finally(() => {
if (inFlightResumeRequests.get(cacheKey) === request) {
inFlightResumeRequests.delete(cacheKey);
}
});
inFlightResumeRequests.set(cacheKey, request);
return request;
}
export async function validateResumeSchema(

View File

@ -67,6 +67,7 @@ describe("applySettingsUpdates", () => {
"app-key",
);
expect(plan.shouldRefreshBackupScheduler).toBe(false);
expect(plan.shouldClearRxResumeCaches).toBe(false);
});
it("marks backup scheduler refresh when backup settings are changed", async () => {
@ -84,6 +85,7 @@ describe("applySettingsUpdates", () => {
]),
);
expect(plan.shouldRefreshBackupScheduler).toBe(true);
expect(plan.shouldClearRxResumeCaches).toBe(false);
});
it("resolves and persists normalized resumeProjects", async () => {
@ -131,4 +133,25 @@ describe("applySettingsUpdates", () => {
JSON.stringify(normalized),
);
});
it("marks Reactive Resume cache clearing when RxResume settings change", async () => {
const settingsRepo = await import("@server/repositories/settings");
const plan = await applySettingsUpdates({
rxresumeMode: "v4",
rxresumeUrl: "https://resume.example.com",
rxresumeBaseResumeId: "resume-123",
});
expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual(
expect.arrayContaining([
["rxresumeMode", "v4"],
["rxresumeUrl", "https://resume.example.com"],
["rxresumeBaseResumeId", "resume-123"],
["rxresumeBaseResumeIdV4", "resume-123"],
]),
);
expect(plan.shouldClearRxResumeCaches).toBe(true);
expect(plan.shouldRefreshBackupScheduler).toBe(false);
});
});

View File

@ -42,5 +42,6 @@ export async function applySettingsUpdates(
shouldRefreshBackupScheduler: deferredSideEffects.has(
"refreshBackupScheduler",
),
shouldClearRxResumeCaches: deferredSideEffects.has("clearRxResumeCaches"),
};
}

View File

@ -13,7 +13,9 @@ import {
import { settingsRegistry } from "@shared/settings-registry";
import type { UpdateSettingsInput } from "@shared/settings-schema";
export type DeferredSideEffect = "refreshBackupScheduler";
export type DeferredSideEffect =
| "refreshBackupScheduler"
| "clearRxResumeCaches";
export type SettingsUpdateAction = {
settingKey: SettingKey;
@ -38,6 +40,7 @@ export type SettingUpdateHandler<K extends keyof UpdateSettingsInput> = (args: {
export type SettingsUpdatePlan = {
shouldRefreshBackupScheduler: boolean;
shouldClearRxResumeCaches: boolean;
};
function result(
@ -69,6 +72,15 @@ export const settingsUpdateRegistry: Partial<{
[K in keyof UpdateSettingsInput]: SettingUpdateHandler<K>;
}> = {};
const RXRESUME_CACHE_INVALIDATION_KEYS = new Set<keyof UpdateSettingsInput>([
"rxresumeMode",
"rxresumeUrl",
"rxresumeApiKey",
"rxresumeEmail",
"rxresumePassword",
"rxresumeBaseResumeId",
]);
for (const [key, def] of Object.entries(settingsRegistry)) {
if (def.kind === "virtual") continue;
@ -120,6 +132,7 @@ for (const [key, def] of Object.entries(settingsRegistry)) {
persistAction("rxresumeBaseResumeId", serialized),
persistAction(modeSpecificKey, serialized),
],
deferred: ["clearRxResumeCaches"],
});
};
continue;
@ -142,10 +155,19 @@ for (const [key, def] of Object.entries(settingsRegistry)) {
applyEnvValue((def as any).envKey, serialized);
}
: undefined;
const deferred: DeferredSideEffect[] = [];
if (isBackup) {
deferred.push("refreshBackupScheduler");
}
if (
RXRESUME_CACHE_INVALIDATION_KEYS.has(key as keyof UpdateSettingsInput)
) {
deferred.push("clearRxResumeCaches");
}
return result({
actions: [persistAction(targetKey, serialized, sideEffect)],
deferred: isBackup ? ["refreshBackupScheduler"] : [],
deferred,
});
};
}

View File

@ -127,6 +127,7 @@ export interface ProfileStatusResponse {
export interface ValidationResult {
valid: boolean;
message: string | null;
status?: number | null;
}
export interface DemoInfoResponse {