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:
parent
4787f4d151
commit
ac0a1281f4
@ -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": [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
const precheckFailure = skipPrecheck
|
||||
? null
|
||||
: getRxResumeCredentialPrecheckFailure({
|
||||
mode,
|
||||
stored,
|
||||
draft,
|
||||
});
|
||||
if (precheckFailure) {
|
||||
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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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,29 +15,29 @@ export const backupRouter = Router();
|
||||
/**
|
||||
* GET /api/backups - List all backups with metadata
|
||||
*/
|
||||
backupRouter.get("/", async (_req: Request, res: Response) => {
|
||||
backupRouter.get(
|
||||
"/",
|
||||
asyncRoute(async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const backups = await listBackups();
|
||||
const nextScheduled = getNextBackupTime();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
backups,
|
||||
nextScheduled,
|
||||
},
|
||||
});
|
||||
ok(res, { 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 });
|
||||
}
|
||||
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) => {
|
||||
backupRouter.post(
|
||||
"/",
|
||||
asyncRoute(async (_req: Request, res: Response) => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
return sendDemoBlocked(
|
||||
@ -55,21 +55,23 @@ backupRouter.post("/", async (_req: Request, res: Response) => {
|
||||
throw new Error("Backup was created but not found in list");
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: backup,
|
||||
});
|
||||
ok(res, 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 });
|
||||
}
|
||||
logger.error("Failed to create backup", {
|
||||
route: "POST /api/backups",
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/backups/:filename - Delete a specific backup
|
||||
*/
|
||||
backupRouter.delete("/:filename", async (req: Request, res: Response) => {
|
||||
backupRouter.delete(
|
||||
"/:filename",
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
return sendDemoBlocked(
|
||||
@ -85,32 +87,29 @@ backupRouter.delete("/:filename", async (req: Request, res: Response) => {
|
||||
const { filename } = req.params;
|
||||
|
||||
if (!filename) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Filename is required",
|
||||
});
|
||||
fail(res, badRequest("Filename is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteBackup(filename);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Backup ${filename} deleted successfully`,
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
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 });
|
||||
fail(res, notFound(message));
|
||||
return;
|
||||
}
|
||||
if (message.includes("Invalid")) {
|
||||
fail(res, badRequest(message));
|
||||
return;
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,46 +33,34 @@ 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");
|
||||
@ -80,11 +69,7 @@ function generateBackupFilename(type: "auto" | "manual"): string {
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,33 +413,47 @@ 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(
|
||||
resumeData: unknown,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -42,5 +42,6 @@ export async function applySettingsUpdates(
|
||||
shouldRefreshBackupScheduler: deferredSideEffects.has(
|
||||
"refreshBackupScheduler",
|
||||
),
|
||||
shouldClearRxResumeCaches: deferredSideEffects.has("clearRxResumeCaches"),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -127,6 +127,7 @@ export interface ProfileStatusResponse {
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
message: string | null;
|
||||
status?: number | null;
|
||||
}
|
||||
|
||||
export interface DemoInfoResponse {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user