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:
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 2
|
"indentWidth": 2,
|
||||||
|
"lineEnding": "lf"
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"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.
|
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
|
### 2) Select base resume
|
||||||
|
|
||||||
In **Settings → Reactive Resume**:
|
In **Settings → Reactive Resume**:
|
||||||
@@ -139,6 +147,12 @@ High-level flow:
|
|||||||
6. Export PDF.
|
6. Export PDF.
|
||||||
7. Delete temporary resume.
|
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
|
### Per-job tracer links
|
||||||
|
|
||||||
Before generating a PDF, each job can enable/disable 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.
|
- Ensure the selected mode has credentials configured.
|
||||||
- `v5`: set a valid API key.
|
- `v5`: set a valid API key.
|
||||||
- `v4`: set email + password.
|
- `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.
|
- Save settings, then refresh resumes in the Reactive Resume section.
|
||||||
|
|
||||||
### No resumes appear in dropdown
|
### No resumes appear in dropdown
|
||||||
|
|||||||
@@ -111,11 +111,14 @@ Defaults and constraints:
|
|||||||
|
|
||||||
- Configure a shared RxResume URL for cloud or self-hosted deployments
|
- Configure a shared RxResume URL for cloud or self-hosted deployments
|
||||||
- Configure v4 email/password or v5 API key in the same section
|
- 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
|
- Select a template/base resume
|
||||||
- Configure project selection behavior:
|
- Configure project selection behavior:
|
||||||
- Max projects
|
- Max projects
|
||||||
- Must-include projects
|
- Must-include projects
|
||||||
- AI-selectable 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
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- Then refresh available resumes from the Reactive Resume section.
|
||||||
|
|
||||||
### RxResume projects look empty in the RxResume UI
|
### RxResume projects look empty in the RxResume UI
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
} from "@client/pages/settings/resume-projects-state";
|
} from "@client/pages/settings/resume-projects-state";
|
||||||
import type { ResumeProjectsSettingsInput } from "@shared/settings-schema.js";
|
import type { ResumeProjectsSettingsInput } from "@shared/settings-schema.js";
|
||||||
import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types.js";
|
import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types.js";
|
||||||
|
import { AlertCircle, AlertTriangle } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -26,6 +28,7 @@ type VersionValidationState = {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
|
status?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProjectSelectionConfig = {
|
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<
|
export const ReactiveResumeConfigPanel: React.FC<
|
||||||
ReactiveResumeConfigPanelProps
|
ReactiveResumeConfigPanelProps
|
||||||
> = ({
|
> = ({
|
||||||
@@ -126,6 +134,14 @@ export const ReactiveResumeConfigPanel: React.FC<
|
|||||||
projectSelection && hasRxResumeAccess,
|
projectSelection && hasRxResumeAccess,
|
||||||
);
|
);
|
||||||
const selectedValidationStatus = validationStatuses?.[mode];
|
const selectedValidationStatus = validationStatuses?.[mode];
|
||||||
|
const showInlineValidationAlert = Boolean(
|
||||||
|
selectedValidationStatus?.checked &&
|
||||||
|
!selectedValidationStatus.valid &&
|
||||||
|
selectedValidationStatus.message,
|
||||||
|
);
|
||||||
|
const selectedValidationIsWarning =
|
||||||
|
showInlineValidationAlert &&
|
||||||
|
isAvailabilityWarning(selectedValidationStatus);
|
||||||
const handleModeChange = (value: string) =>
|
const handleModeChange = (value: string) =>
|
||||||
onModeChange(value === "v4" ? "v4" : "v5");
|
onModeChange(value === "v4" ? "v4" : "v5");
|
||||||
|
|
||||||
@@ -157,6 +173,30 @@ export const ReactiveResumeConfigPanel: React.FC<
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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" ? (
|
{mode === "v5" ? (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
|
|||||||
@@ -117,12 +117,41 @@ export const getRxResumeMissingCredentialLabels = (input: {
|
|||||||
|
|
||||||
export const toRxResumeValidationPayload = (
|
export const toRxResumeValidationPayload = (
|
||||||
draft: RxResumeCredentialDrafts,
|
draft: RxResumeCredentialDrafts,
|
||||||
) => ({
|
options?: {
|
||||||
email: draft.email || undefined,
|
preserveBlankFields?: Array<keyof RxResumeCredentialDrafts>;
|
||||||
baseUrl: draft.baseUrl || undefined,
|
},
|
||||||
password: draft.password || undefined,
|
) => {
|
||||||
apiKey: draft.apiKey || undefined,
|
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 = (
|
export const buildRxResumeSettingsUpdate = (
|
||||||
mode: RxResumeMode,
|
mode: RxResumeMode,
|
||||||
@@ -149,6 +178,7 @@ type ValidateAndMaybePersistRxResumeModeInput<TSettings> = {
|
|||||||
) => Promise<ValidationResult>;
|
) => Promise<ValidationResult>;
|
||||||
persist?: (update: Partial<UpdateSettingsInput>) => Promise<TSettings>;
|
persist?: (update: Partial<UpdateSettingsInput>) => Promise<TSettings>;
|
||||||
persistOnSuccess?: boolean;
|
persistOnSuccess?: boolean;
|
||||||
|
skipPrecheck?: boolean;
|
||||||
getPrecheckMessage?: (
|
getPrecheckMessage?: (
|
||||||
failure: Exclude<RxResumeCredentialPrecheckFailure, null>,
|
failure: Exclude<RxResumeCredentialPrecheckFailure, null>,
|
||||||
) => string;
|
) => string;
|
||||||
@@ -172,6 +202,7 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
|
|||||||
validate,
|
validate,
|
||||||
persist,
|
persist,
|
||||||
persistOnSuccess = false,
|
persistOnSuccess = false,
|
||||||
|
skipPrecheck = false,
|
||||||
getPrecheckMessage = (failure) => RXRESUME_PRECHECK_MESSAGES[failure],
|
getPrecheckMessage = (failure) => RXRESUME_PRECHECK_MESSAGES[failure],
|
||||||
getValidationErrorMessage = (error) =>
|
getValidationErrorMessage = (error) =>
|
||||||
error instanceof Error ? error.message : "RxResume validation failed",
|
error instanceof Error ? error.message : "RxResume validation failed",
|
||||||
@@ -181,16 +212,19 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
|
|||||||
: "Failed to save RxResume settings",
|
: "Failed to save RxResume settings",
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
const precheckFailure = getRxResumeCredentialPrecheckFailure({
|
const precheckFailure = skipPrecheck
|
||||||
|
? null
|
||||||
|
: getRxResumeCredentialPrecheckFailure({
|
||||||
mode,
|
mode,
|
||||||
stored,
|
stored,
|
||||||
draft,
|
draft,
|
||||||
});
|
});
|
||||||
if (precheckFailure) {
|
if (precheckFailure !== null) {
|
||||||
return {
|
return {
|
||||||
validation: {
|
validation: {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: getPrecheckMessage(precheckFailure),
|
message: getPrecheckMessage(precheckFailure),
|
||||||
|
status: 400,
|
||||||
},
|
},
|
||||||
precheckFailure,
|
precheckFailure,
|
||||||
updatedSettings: null,
|
updatedSettings: null,
|
||||||
@@ -208,6 +242,7 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
|
|||||||
validation: {
|
validation: {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: getValidationErrorMessage(error, mode),
|
message: getValidationErrorMessage(error, mode),
|
||||||
|
status: 0,
|
||||||
},
|
},
|
||||||
precheckFailure: null,
|
precheckFailure: null,
|
||||||
updatedSettings: null,
|
updatedSettings: null,
|
||||||
@@ -219,6 +254,7 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
|
|||||||
validation: {
|
validation: {
|
||||||
valid: validation.valid,
|
valid: validation.valid,
|
||||||
message: validation.valid ? null : (validation.message ?? null),
|
message: validation.valid ? null : (validation.message ?? null),
|
||||||
|
status: validation.valid ? null : (validation.status ?? null),
|
||||||
},
|
},
|
||||||
precheckFailure: null,
|
precheckFailure: null,
|
||||||
updatedSettings: null,
|
updatedSettings: null,
|
||||||
@@ -233,6 +269,7 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
|
|||||||
validation: {
|
validation: {
|
||||||
valid: true,
|
valid: true,
|
||||||
message: null,
|
message: null,
|
||||||
|
status: null,
|
||||||
},
|
},
|
||||||
precheckFailure: null,
|
precheckFailure: null,
|
||||||
updatedSettings,
|
updatedSettings,
|
||||||
@@ -242,6 +279,7 @@ export const validateAndMaybePersistRxResumeMode = async <TSettings>(
|
|||||||
validation: {
|
validation: {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: getPersistErrorMessage(error, mode),
|
message: getPersistErrorMessage(error, mode),
|
||||||
|
status: 0,
|
||||||
},
|
},
|
||||||
precheckFailure: null,
|
precheckFailure: null,
|
||||||
updatedSettings: null,
|
updatedSettings: null,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ vi.mock("../api", () => ({
|
|||||||
getSettings: vi.fn(),
|
getSettings: vi.fn(),
|
||||||
updateSettings: vi.fn(),
|
updateSettings: vi.fn(),
|
||||||
validateRxresume: vi.fn(),
|
validateRxresume: vi.fn(),
|
||||||
|
getRxResumeProjects: vi.fn(),
|
||||||
clearDatabase: vi.fn(),
|
clearDatabase: vi.fn(),
|
||||||
deleteJobsByStatus: vi.fn(),
|
deleteJobsByStatus: vi.fn(),
|
||||||
getTracerReadiness: vi.fn(),
|
getTracerReadiness: vi.fn(),
|
||||||
@@ -72,6 +73,13 @@ const openWritingStyleSection = async () => {
|
|||||||
fireEvent.click(chatTrigger);
|
fireEvent.click(chatTrigger);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openReactiveResumeSection = async () => {
|
||||||
|
const trigger = await screen.findByRole("button", {
|
||||||
|
name: /reactive resume/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
};
|
||||||
|
|
||||||
describe("SettingsPage", () => {
|
describe("SettingsPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -92,6 +100,7 @@ describe("SettingsPage", () => {
|
|||||||
vi.mocked(api.validateRxresume).mockResolvedValue({
|
vi.mocked(api.validateRxresume).mockResolvedValue({
|
||||||
valid: false,
|
valid: false,
|
||||||
message: "Missing credentials",
|
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 () => {
|
it("saves the writing language mode through the settings page", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
vi.mocked(api.updateSettings).mockResolvedValue(
|
vi.mocked(api.updateSettings).mockResolvedValue(
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
|
|||||||
import {
|
import {
|
||||||
coerceRxResumeMode,
|
coerceRxResumeMode,
|
||||||
getRxResumeCredentialDrafts,
|
getRxResumeCredentialDrafts,
|
||||||
|
getRxResumeCredentialPrecheckFailure,
|
||||||
|
isRxResumeAvailabilityValidationFailure,
|
||||||
|
isRxResumeBlockingValidationFailure,
|
||||||
RXRESUME_MODES,
|
RXRESUME_MODES,
|
||||||
RXRESUME_PRECHECK_MESSAGES,
|
RXRESUME_PRECHECK_MESSAGES,
|
||||||
|
toRxResumeValidationPayload,
|
||||||
validateAndMaybePersistRxResumeMode,
|
validateAndMaybePersistRxResumeMode,
|
||||||
} from "@client/lib/rxresume-config";
|
} from "@client/lib/rxresume-config";
|
||||||
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
||||||
@@ -37,6 +41,7 @@ import type {
|
|||||||
ResumeProjectCatalogItem,
|
ResumeProjectCatalogItem,
|
||||||
ResumeProjectsSettings,
|
ResumeProjectsSettings,
|
||||||
RxResumeMode,
|
RxResumeMode,
|
||||||
|
ValidationResult,
|
||||||
} from "@shared/types.js";
|
} from "@shared/types.js";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
@@ -101,13 +106,31 @@ type RxResumeValidationBadgeState = {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
|
status: number | null;
|
||||||
};
|
};
|
||||||
const EMPTY_RXRESUME_VALIDATION_BADGE_STATE: RxResumeValidationBadgeState = {
|
const EMPTY_RXRESUME_VALIDATION_BADGE_STATE: RxResumeValidationBadgeState = {
|
||||||
checked: false,
|
checked: false,
|
||||||
valid: false,
|
valid: false,
|
||||||
message: null,
|
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 = (
|
const normalizeLlmProviderValue = (
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
): LlmProviderValue => (value ? normalizeLlmProvider(value) : null);
|
): LlmProviderValue => (value ? normalizeLlmProvider(value) : null);
|
||||||
@@ -404,6 +427,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
clearErrors,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setError,
|
setError,
|
||||||
@@ -598,6 +622,27 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [refreshReadiness]);
|
}, [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(
|
const validateRxresumeMode = useCallback(
|
||||||
async (
|
async (
|
||||||
mode: RxResumeMode,
|
mode: RxResumeMode,
|
||||||
@@ -614,6 +659,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
validate: api.validateRxresume,
|
validate: api.validateRxresume,
|
||||||
persist: api.updateSettings,
|
persist: api.updateSettings,
|
||||||
persistOnSuccess,
|
persistOnSuccess,
|
||||||
|
skipPrecheck: silent,
|
||||||
getPrecheckMessage: (failure) => RXRESUME_PRECHECK_MESSAGES[failure],
|
getPrecheckMessage: (failure) => RXRESUME_PRECHECK_MESSAGES[failure],
|
||||||
getValidationErrorMessage: (error) =>
|
getValidationErrorMessage: (error) =>
|
||||||
error instanceof Error ? error.message : "RxResume validation failed",
|
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",
|
error instanceof Error ? error.message : "RxResume validation failed",
|
||||||
});
|
});
|
||||||
|
|
||||||
setRxresumeValidationStatuses((current) => ({
|
setRxResumeValidationStatus(mode, result.validation);
|
||||||
...current,
|
|
||||||
[mode]: {
|
|
||||||
checked: true,
|
|
||||||
valid: result.validation.valid,
|
|
||||||
message: result.validation.valid
|
|
||||||
? null
|
|
||||||
: (result.validation.message ?? null),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (result.updatedSettings) {
|
if (result.updatedSettings) {
|
||||||
setSettings(result.updatedSettings);
|
setSettings(result.updatedSettings);
|
||||||
@@ -661,7 +698,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
`Reactive Resume ${mode} validation failed`,
|
`Reactive Resume ${mode} validation failed`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[getValues, queryClient, storedRxResume],
|
[getValues, queryClient, setRxResumeValidationStatus, storedRxResume],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -677,7 +714,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
validateRxresumeMode(mode, { silent: true, persistOnSuccess: false }),
|
validateRxresumeMode(mode, { silent: true, persistOnSuccess: false }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, [settings, rxresumeValidationStatuses, validateRxresumeMode]);
|
}, [rxresumeValidationStatuses, settings, validateRxresumeMode]);
|
||||||
|
|
||||||
const effectiveProfileProjects =
|
const effectiveProfileProjects =
|
||||||
rxResumeProjectsOverride ??
|
rxResumeProjectsOverride ??
|
||||||
@@ -786,7 +823,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
if (value !== undefined) envPayload.webhookSecret = value;
|
if (value !== undefined) envPayload.webhookSecret = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: UpdateSettingsInput = {
|
const payload: Partial<UpdateSettingsInput> = {
|
||||||
model: normalizeString(data.model),
|
model: normalizeString(data.model),
|
||||||
modelScorer: normalizeString(data.modelScorer),
|
modelScorer: normalizeString(data.modelScorer),
|
||||||
modelTailoring: normalizeString(data.modelTailoring),
|
modelTailoring: normalizeString(data.modelTailoring),
|
||||||
@@ -794,8 +831,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
||||||
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
||||||
resumeProjects: resumeProjectsOverride,
|
resumeProjects: resumeProjectsOverride,
|
||||||
rxresumeMode: data.rxresumeMode ?? "v5",
|
...(dirtyFields.rxresumeMode
|
||||||
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
|
? { rxresumeMode: data.rxresumeMode ?? "v5" }
|
||||||
|
: {}),
|
||||||
|
...(dirtyFields.rxresumeBaseResumeId
|
||||||
|
? { rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId) }
|
||||||
|
: {}),
|
||||||
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
||||||
chatStyleTone: normalizeString(data.chatStyleTone),
|
chatStyleTone: normalizeString(data.chatStyleTone),
|
||||||
chatStyleFormality: normalizeString(data.chatStyleFormality),
|
chatStyleFormality: normalizeString(data.chatStyleFormality),
|
||||||
@@ -836,15 +877,88 @@ export const SettingsPage: React.FC = () => {
|
|||||||
...envPayload,
|
...envPayload,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove virtual field because the backend doesn't expect it
|
const shouldValidateRxResumeBeforeSave = Boolean(
|
||||||
// this exists only to toggle the UI
|
dirtyFields.rxresumeMode ||
|
||||||
// need to track it so that the save button is enabled when it changes
|
dirtyFields.rxresumeUrl ||
|
||||||
delete payload.enableBasicAuth;
|
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);
|
const updated = await updateSettingsMutation.mutateAsync(payload);
|
||||||
setSettings(updated);
|
setSettings(updated);
|
||||||
reset(mapSettingsToForm(updated));
|
reset(mapSettingsToForm(updated));
|
||||||
toast.success("Settings saved");
|
toast.success("Settings saved");
|
||||||
|
if (rxResumeSaveWarningMessage) {
|
||||||
|
toast.info(rxResumeSaveWarningMessage);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Failed to save settings";
|
error instanceof Error ? error.message : "Failed to save settings";
|
||||||
@@ -995,6 +1109,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
hasRxResumeAccess={hasRxResumeAccess}
|
hasRxResumeAccess={hasRxResumeAccess}
|
||||||
rxresumeMode={rxresumeMode}
|
rxresumeMode={rxresumeMode}
|
||||||
|
onCredentialFieldEdit={clearRxResumeValidationFeedback}
|
||||||
validationStatuses={rxresumeValidationStatuses}
|
validationStatuses={rxresumeValidationStatuses}
|
||||||
profileProjects={effectiveProfileProjects}
|
profileProjects={effectiveProfileProjects}
|
||||||
lockedCount={lockedCount}
|
lockedCount={lockedCount}
|
||||||
|
|||||||
@@ -21,9 +21,20 @@ type ReactiveResumeSectionProps = {
|
|||||||
hasRxResumeAccess: boolean;
|
hasRxResumeAccess: boolean;
|
||||||
rxresumeMode: RxResumeMode;
|
rxresumeMode: RxResumeMode;
|
||||||
onRxresumeModeChange?: (mode: RxResumeMode) => void;
|
onRxresumeModeChange?: (mode: RxResumeMode) => void;
|
||||||
|
onCredentialFieldEdit?: (mode: RxResumeMode) => void;
|
||||||
validationStatuses?: {
|
validationStatuses?: {
|
||||||
v4: { checked: boolean; valid: boolean; message?: string | null };
|
v4: {
|
||||||
v5: { checked: boolean; valid: boolean; message?: string | null };
|
checked: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
message?: string | null;
|
||||||
|
status?: number | null;
|
||||||
|
};
|
||||||
|
v5: {
|
||||||
|
checked: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
message?: string | null;
|
||||||
|
status?: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
profileProjects: ResumeProjectCatalogItem[];
|
profileProjects: ResumeProjectCatalogItem[];
|
||||||
lockedCount: number;
|
lockedCount: number;
|
||||||
@@ -39,6 +50,7 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
hasRxResumeAccess,
|
hasRxResumeAccess,
|
||||||
rxresumeMode,
|
rxresumeMode,
|
||||||
onRxresumeModeChange,
|
onRxresumeModeChange,
|
||||||
|
onCredentialFieldEdit,
|
||||||
validationStatuses,
|
validationStatuses,
|
||||||
profileProjects,
|
profileProjects,
|
||||||
lockedCount,
|
lockedCount,
|
||||||
@@ -49,6 +61,7 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
|
clearErrors,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useFormContext<UpdateSettingsInput>();
|
} = useFormContext<UpdateSettingsInput>();
|
||||||
@@ -70,6 +83,15 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
shouldTouch: true,
|
shouldTouch: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const clearRxResumeFeedback = (mode: RxResumeMode) => {
|
||||||
|
onCredentialFieldEdit?.(mode);
|
||||||
|
clearErrors(
|
||||||
|
mode === "v5"
|
||||||
|
? ["rxresumeApiKey", "rxresumeUrl"]
|
||||||
|
: ["rxresumeEmail", "rxresumePassword", "rxresumeUrl"],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
|
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
|
||||||
<AccordionTrigger className="hover:no-underline py-4">
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
@@ -88,24 +110,32 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
|||||||
validationStatuses={validationStatuses}
|
validationStatuses={validationStatuses}
|
||||||
shared={{
|
shared={{
|
||||||
baseUrl: rxresumeUrlValue,
|
baseUrl: rxresumeUrlValue,
|
||||||
onBaseUrlChange: (value) =>
|
onBaseUrlChange: (value) => {
|
||||||
setDirtyTouchedValue("rxresumeUrl", value),
|
clearRxResumeFeedback(selectedMode);
|
||||||
|
setDirtyTouchedValue("rxresumeUrl", value);
|
||||||
|
},
|
||||||
baseUrlError: errors.rxresumeUrl?.message as string | undefined,
|
baseUrlError: errors.rxresumeUrl?.message as string | undefined,
|
||||||
}}
|
}}
|
||||||
v5={{
|
v5={{
|
||||||
apiKey: rxresumeApiKeyValue,
|
apiKey: rxresumeApiKeyValue,
|
||||||
onApiKeyChange: (value) =>
|
onApiKeyChange: (value) => {
|
||||||
setDirtyTouchedValue("rxresumeApiKey", value),
|
clearRxResumeFeedback("v5");
|
||||||
|
setDirtyTouchedValue("rxresumeApiKey", value);
|
||||||
|
},
|
||||||
error: errors.rxresumeApiKey?.message as string | undefined,
|
error: errors.rxresumeApiKey?.message as string | undefined,
|
||||||
}}
|
}}
|
||||||
v4={{
|
v4={{
|
||||||
email: rxresumeEmailValue,
|
email: rxresumeEmailValue,
|
||||||
onEmailChange: (value) =>
|
onEmailChange: (value) => {
|
||||||
setDirtyTouchedValue("rxresumeEmail", value),
|
clearRxResumeFeedback("v4");
|
||||||
|
setDirtyTouchedValue("rxresumeEmail", value);
|
||||||
|
},
|
||||||
emailError: errors.rxresumeEmail?.message as string | undefined,
|
emailError: errors.rxresumeEmail?.message as string | undefined,
|
||||||
password: rxresumePasswordValue,
|
password: rxresumePasswordValue,
|
||||||
onPasswordChange: (value) =>
|
onPasswordChange: (value) => {
|
||||||
setDirtyTouchedValue("rxresumePassword", value),
|
clearRxResumeFeedback("v4");
|
||||||
|
setDirtyTouchedValue("rxresumePassword", value);
|
||||||
|
},
|
||||||
passwordError: errors.rxresumePassword?.message as
|
passwordError: errors.rxresumePassword?.message as
|
||||||
| string
|
| string
|
||||||
| undefined,
|
| undefined,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ describe.sequential("Backup API routes", () => {
|
|||||||
|
|
||||||
it("should return error if database does not exist", async () => {
|
it("should return error if database does not exist", async () => {
|
||||||
// Delete the database
|
// Delete the database
|
||||||
|
closeDb();
|
||||||
await fs.promises.unlink(`${tempDir}/jobs.db`);
|
await fs.promises.unlink(`${tempDir}/jobs.db`);
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/backups`, { method: "POST" });
|
const res = await fetch(`${baseUrl}/api/backups`, { method: "POST" });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { notFound } from "@infra/errors";
|
import { badRequest, notFound } from "@infra/errors";
|
||||||
import { fail } from "@infra/http";
|
import { asyncRoute, fail, ok } from "@infra/http";
|
||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
|
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
|
||||||
import {
|
import {
|
||||||
@@ -15,29 +15,29 @@ export const backupRouter = Router();
|
|||||||
/**
|
/**
|
||||||
* GET /api/backups - List all backups with metadata
|
* GET /api/backups - List all backups with metadata
|
||||||
*/
|
*/
|
||||||
backupRouter.get("/", async (_req: Request, res: Response) => {
|
backupRouter.get(
|
||||||
|
"/",
|
||||||
|
asyncRoute(async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const backups = await listBackups();
|
const backups = await listBackups();
|
||||||
const nextScheduled = getNextBackupTime();
|
const nextScheduled = getNextBackupTime();
|
||||||
|
ok(res, { backups, nextScheduled });
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
backups,
|
|
||||||
nextScheduled,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
logger.error("Failed to list backups", {
|
||||||
logger.error("Failed to list backups", error);
|
route: "GET /api/backups",
|
||||||
res.status(500).json({ success: false, error: message });
|
error,
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/backups - Create a manual backup
|
* POST /api/backups - Create a manual backup
|
||||||
*/
|
*/
|
||||||
backupRouter.post("/", async (_req: Request, res: Response) => {
|
backupRouter.post(
|
||||||
|
"/",
|
||||||
|
asyncRoute(async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (isDemoMode()) {
|
if (isDemoMode()) {
|
||||||
return sendDemoBlocked(
|
return sendDemoBlocked(
|
||||||
@@ -55,21 +55,23 @@ backupRouter.post("/", async (_req: Request, res: Response) => {
|
|||||||
throw new Error("Backup was created but not found in list");
|
throw new Error("Backup was created but not found in list");
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
ok(res, backup);
|
||||||
success: true,
|
|
||||||
data: backup,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
logger.error("Failed to create backup", {
|
||||||
logger.error("Failed to create backup", error);
|
route: "POST /api/backups",
|
||||||
res.status(500).json({ success: false, error: message });
|
error,
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/backups/:filename - Delete a specific backup
|
* 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 {
|
try {
|
||||||
if (isDemoMode()) {
|
if (isDemoMode()) {
|
||||||
return sendDemoBlocked(
|
return sendDemoBlocked(
|
||||||
@@ -85,32 +87,29 @@ backupRouter.delete("/:filename", async (req: Request, res: Response) => {
|
|||||||
const { filename } = req.params;
|
const { filename } = req.params;
|
||||||
|
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
res.status(400).json({
|
fail(res, badRequest("Filename is required"));
|
||||||
success: false,
|
|
||||||
error: "Filename is required",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteBackup(filename);
|
await deleteBackup(filename);
|
||||||
|
ok(res, { message: `Backup ${filename} deleted successfully` });
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Backup ${filename} deleted successfully`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
logger.error("Failed to delete backup", {
|
logger.error("Failed to delete backup", {
|
||||||
|
route: "DELETE /api/backups/:filename",
|
||||||
filename: req.params.filename,
|
filename: req.params.filename,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (message.includes("not found")) {
|
if (message.includes("not found")) {
|
||||||
return fail(res, notFound(message));
|
fail(res, notFound(message));
|
||||||
} else if (message.includes("Invalid")) {
|
return;
|
||||||
res.status(400).json({ success: false, error: message });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ success: false, error: message });
|
|
||||||
}
|
}
|
||||||
|
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.ok).toBe(true);
|
||||||
expect(body.data.valid).toBe(false);
|
expect(body.data.valid).toBe(false);
|
||||||
expect(body.data.message).toContain("not configured");
|
expect(body.data.message).toContain("not configured");
|
||||||
|
expect(body.data.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns invalid when only email is provided", async () => {
|
it("returns invalid when only email is provided", async () => {
|
||||||
@@ -389,6 +390,68 @@ describe.sequential("Onboarding API routes", () => {
|
|||||||
expect(body.ok).toBe(true);
|
expect(body.ok).toBe(true);
|
||||||
// Should be invalid because credentials are fake
|
// Should be invalid because credentials are fake
|
||||||
expect(body.data.valid).toBe(false);
|
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 () => {
|
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.ok).toBe(true);
|
||||||
expect(body.data.valid).toBe(true);
|
expect(body.data.valid).toBe(true);
|
||||||
expect(body.data.message).toBeNull();
|
expect(body.data.message).toBeNull();
|
||||||
|
expect(body.data.status).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles whitespace-only credentials", async () => {
|
it("handles whitespace-only credentials", async () => {
|
||||||
@@ -433,6 +497,7 @@ describe.sequential("Onboarding API routes", () => {
|
|||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(body.data.valid).toBe(false);
|
expect(body.data.valid).toBe(false);
|
||||||
expect(body.data.message).toContain("not configured");
|
expect(body.data.message).toContain("not configured");
|
||||||
|
expect(body.data.status).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const onboardingRouter = Router();
|
|||||||
type ValidationResponse = {
|
type ValidationResponse = {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
|
status?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDefaultValidationBaseUrl(
|
function getDefaultValidationBaseUrl(
|
||||||
@@ -140,10 +141,57 @@ async function validateRxresume(options?: {
|
|||||||
baseUrl?: string | null;
|
baseUrl?: string | null;
|
||||||
}): Promise<ValidationResponse> {
|
}): Promise<ValidationResponse> {
|
||||||
const rawMode = options?.mode?.trim();
|
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({
|
const result = await validateRxResumeCredentials({
|
||||||
mode,
|
mode: inferredMode,
|
||||||
v4: {
|
v4: {
|
||||||
email: options?.email ?? undefined,
|
email: options?.email ?? undefined,
|
||||||
password: options?.password ?? 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();
|
const normalizedMessage = result.message.toLowerCase();
|
||||||
|
if (result.status === 400 && normalizedMessage.includes("not configured")) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
status: 400,
|
||||||
|
message: result.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
result.status === 401 ||
|
result.status === 401 ||
|
||||||
normalizedMessage.includes("invalidcredentials")
|
normalizedMessage.includes("invalidcredentials")
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
|
status: result.status,
|
||||||
message:
|
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(
|
onboardingRouter.post(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { startServer, stopServer } from "./test-utils";
|
|||||||
|
|
||||||
// Mock the RxResume adapter service
|
// Mock the RxResume adapter service
|
||||||
vi.mock("@server/services/rxresume", () => ({
|
vi.mock("@server/services/rxresume", () => ({
|
||||||
|
clearRxResumeResumeCache: vi.fn(),
|
||||||
getResume: vi.fn(),
|
getResume: vi.fn(),
|
||||||
RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error {
|
RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { isDemoMode } from "@server/config/demo";
|
|||||||
import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults";
|
import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults";
|
||||||
import { clearProfileCache, getProfile } from "@server/services/profile";
|
import { clearProfileCache, getProfile } from "@server/services/profile";
|
||||||
import { extractProjectsFromProfile } from "@server/services/resumeProjects";
|
import { extractProjectsFromProfile } from "@server/services/resumeProjects";
|
||||||
import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume";
|
import {
|
||||||
|
clearRxResumeResumeCache,
|
||||||
|
getResume,
|
||||||
|
RxResumeAuthConfigError,
|
||||||
|
} from "@server/services/rxresume";
|
||||||
import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
|
import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
|
||||||
import { type Request, type Response, Router } from "express";
|
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) => {
|
profileRouter.post("/refresh", async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
clearProfileCache();
|
clearProfileCache();
|
||||||
|
clearRxResumeResumeCache();
|
||||||
const profile = await getProfile(true);
|
const profile = await getProfile(true);
|
||||||
ok(res, profile);
|
ok(res, profile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import type { Server } from "node:http";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("@server/services/rxresume", () => ({
|
vi.mock("@server/services/rxresume", () => ({
|
||||||
|
clearRxResumeResumeCache: vi.fn(),
|
||||||
listResumes: vi.fn(),
|
listResumes: vi.fn(),
|
||||||
getResume: vi.fn(),
|
getResume: vi.fn(),
|
||||||
|
validateCredentials: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
mode: "v5",
|
||||||
|
})),
|
||||||
validateResumeSchema: vi.fn(async (data: unknown) => ({
|
validateResumeSchema: vi.fn(async (data: unknown) => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
mode:
|
mode:
|
||||||
@@ -55,6 +60,7 @@ vi.mock("@server/services/rxresume", () => ({
|
|||||||
import {
|
import {
|
||||||
extractProjectsFromResume,
|
extractProjectsFromResume,
|
||||||
getResume,
|
getResume,
|
||||||
|
validateCredentials,
|
||||||
} from "@server/services/rxresume";
|
} from "@server/services/rxresume";
|
||||||
import { startServer, stopServer } from "./test-utils";
|
import { startServer, stopServer } from "./test-utils";
|
||||||
|
|
||||||
@@ -65,6 +71,11 @@ describe.sequential("Settings API routes", () => {
|
|||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(validateCredentials).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
mode: "v5",
|
||||||
|
});
|
||||||
({ server, baseUrl, closeDb, tempDir } = await startServer({
|
({ server, baseUrl, closeDb, tempDir } = await startServer({
|
||||||
env: {
|
env: {
|
||||||
LLM_API_KEY: "secret-key",
|
LLM_API_KEY: "secret-key",
|
||||||
@@ -139,6 +150,110 @@ describe.sequential("Settings API routes", () => {
|
|||||||
expect(patchBody.data.llmApiKeyHint).toBe("upda");
|
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 () => {
|
it("validates basic auth requirements", async () => {
|
||||||
const res = await fetch(`${baseUrl}/api/settings`, {
|
const res = await fetch(`${baseUrl}/api/settings`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@@ -3,27 +3,110 @@ import {
|
|||||||
badRequest,
|
badRequest,
|
||||||
serviceUnavailable,
|
serviceUnavailable,
|
||||||
statusToCode,
|
statusToCode,
|
||||||
|
unauthorized,
|
||||||
upstreamError,
|
upstreamError,
|
||||||
} from "@infra/errors";
|
} from "@infra/errors";
|
||||||
import { asyncRoute, fail, ok } from "@infra/http";
|
import { asyncRoute, fail, ok } from "@infra/http";
|
||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
|
import { getRequestId } from "@infra/request-context";
|
||||||
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
|
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
|
||||||
import { setBackupSettings } from "@server/services/backup/index";
|
import { setBackupSettings } from "@server/services/backup/index";
|
||||||
|
import { clearProfileCache } from "@server/services/profile";
|
||||||
import {
|
import {
|
||||||
|
clearRxResumeResumeCache,
|
||||||
extractProjectsFromResume,
|
extractProjectsFromResume,
|
||||||
getResume,
|
getResume,
|
||||||
listResumes,
|
listResumes,
|
||||||
RxResumeAuthConfigError,
|
RxResumeAuthConfigError,
|
||||||
RxResumeRequestError,
|
RxResumeRequestError,
|
||||||
validateResumeSchema,
|
validateResumeSchema,
|
||||||
|
validateCredentials as validateRxResumeCredentials,
|
||||||
} from "@server/services/rxresume";
|
} from "@server/services/rxresume";
|
||||||
import { getEffectiveSettings } from "@server/services/settings";
|
import { getEffectiveSettings } from "@server/services/settings";
|
||||||
import { applySettingsUpdates } from "@server/services/settings-update";
|
import { applySettingsUpdates } from "@server/services/settings-update";
|
||||||
import { updateSettingsSchema } from "@shared/settings-schema";
|
import {
|
||||||
|
type UpdateSettingsInput,
|
||||||
|
updateSettingsSchema,
|
||||||
|
} from "@shared/settings-schema";
|
||||||
import { type Request, type Response, Router } from "express";
|
import { type Request, type Response, Router } from "express";
|
||||||
|
|
||||||
export const settingsRouter = Router();
|
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)
|
* GET /api/settings - Get app settings (effective + defaults)
|
||||||
*/
|
*/
|
||||||
@@ -50,7 +133,49 @@ settingsRouter.patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const input = updateSettingsSchema.parse(req.body);
|
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);
|
const plan = await applySettingsUpdates(input);
|
||||||
|
if (plan.shouldClearRxResumeCaches) {
|
||||||
|
clearRxResumeResumeCache();
|
||||||
|
clearProfileCache();
|
||||||
|
}
|
||||||
|
|
||||||
const data = await getEffectiveSettings();
|
const data = await getEffectiveSettings();
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ vi.mock("@server/services/scorer", () => ({
|
|||||||
|
|
||||||
vi.mock("@server/services/profile", () => ({
|
vi.mock("@server/services/profile", () => ({
|
||||||
getProfile: vi.fn().mockResolvedValue({}),
|
getProfile: vi.fn().mockResolvedValue({}),
|
||||||
|
clearProfileCache: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@server/services/visa-sponsors/index", () => ({
|
vi.mock("@server/services/visa-sponsors/index", () => ({
|
||||||
@@ -81,6 +82,23 @@ vi.mock("@server/services/visa-sponsors/index", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const originalEnv = { ...process.env };
|
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?: {
|
export async function startServer(options?: {
|
||||||
env?: Record<string, string | undefined>;
|
env?: Record<string, string | undefined>;
|
||||||
@@ -93,8 +111,12 @@ export async function startServer(options?: {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), "job-ops-api-test-"));
|
const tempDir = await mkdtemp(join(tmpdir(), "job-ops-api-test-"));
|
||||||
const envOverrides = options?.env ?? {};
|
const envOverrides = options?.env ?? {};
|
||||||
|
const nextEnv = { ...originalEnv };
|
||||||
|
for (const key of isolatedEnvKeys) {
|
||||||
|
delete nextEnv[key];
|
||||||
|
}
|
||||||
process.env = {
|
process.env = {
|
||||||
...originalEnv,
|
...nextEnv,
|
||||||
DATA_DIR: tempDir,
|
DATA_DIR: tempDir,
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
MODEL: "test-model",
|
MODEL: "test-model",
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ if (!existsSync(dataDir)) {
|
|||||||
|
|
||||||
const sqlite = new Database(DB_PATH);
|
const sqlite = new Database(DB_PATH);
|
||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
let isClosed = false;
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
export { schema };
|
export { schema };
|
||||||
|
|
||||||
export function closeDb() {
|
export function closeDb() {
|
||||||
|
if (isClosed) return;
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
|
isClosed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import type { FileHandle } from "node:fs/promises";
|
import type { FileHandle } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { logger } from "@infra/logger";
|
||||||
import { getDataDir } from "@server/config/dataDir";
|
import { getDataDir } from "@server/config/dataDir";
|
||||||
import { createScheduler } from "@server/utils/scheduler";
|
import { createScheduler } from "@server/utils/scheduler";
|
||||||
import type { BackupInfo } from "@shared/types";
|
import type { BackupInfo } from "@shared/types";
|
||||||
@@ -32,46 +33,34 @@ interface BackupSettings {
|
|||||||
maxCount: number;
|
maxCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current settings (updated by setBackupSettings)
|
|
||||||
let currentSettings: BackupSettings = {
|
let currentSettings: BackupSettings = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
hour: 2,
|
hour: 2,
|
||||||
maxCount: 5,
|
maxCount: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create scheduler for automatic backups
|
|
||||||
const scheduler = createScheduler("backup", async () => {
|
const scheduler = createScheduler("backup", async () => {
|
||||||
await createBackup("auto");
|
await createBackup("auto");
|
||||||
await cleanupOldBackups();
|
await cleanupOldBackups();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the path to the database file
|
|
||||||
*/
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
return path.join(getDataDir(), DB_FILENAME);
|
return path.join(getDataDir(), DB_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the data directory path
|
|
||||||
*/
|
|
||||||
function getBackupDir(): string {
|
function getBackupDir(): string {
|
||||||
return getDataDir();
|
return getDataDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate filename for a backup
|
|
||||||
*/
|
|
||||||
function generateBackupFilename(type: "auto" | "manual"): string {
|
function generateBackupFilename(type: "auto" | "manual"): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (type === "auto") {
|
if (type === "auto") {
|
||||||
// Format: jobs_YYYY_MM_DD.db (UTC date to match UTC scheduler)
|
|
||||||
const year = now.getUTCFullYear();
|
const year = now.getUTCFullYear();
|
||||||
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||||
const day = String(now.getUTCDate()).padStart(2, "0");
|
const day = String(now.getUTCDate()).padStart(2, "0");
|
||||||
return `${AUTO_BACKUP_PREFIX}${year}_${month}_${day}.db`;
|
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 year = now.getFullYear();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(now.getDate()).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");
|
const seconds = String(now.getSeconds()).padStart(2, "0");
|
||||||
return `${MANUAL_BACKUP_PREFIX}${year}_${month}_${day}_${hours}_${minutes}_${seconds}.db`;
|
return `${MANUAL_BACKUP_PREFIX}${year}_${month}_${day}_${hours}_${minutes}_${seconds}.db`;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse backup filename to extract creation date
|
|
||||||
*/
|
|
||||||
function parseBackupDate(filename: string): Date | null {
|
function parseBackupDate(filename: string): Date | null {
|
||||||
const autoMatch = filename.match(AUTO_BACKUP_REGEX);
|
const autoMatch = filename.match(AUTO_BACKUP_REGEX);
|
||||||
if (autoMatch) {
|
if (autoMatch) {
|
||||||
@@ -135,20 +120,12 @@ function buildUtcDate(
|
|||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine backup type from filename
|
|
||||||
*/
|
|
||||||
function getBackupType(filename: string): "auto" | "manual" | null {
|
function getBackupType(filename: string): "auto" | "manual" | null {
|
||||||
if (AUTO_BACKUP_PATTERN.test(filename)) return "auto";
|
if (AUTO_BACKUP_PATTERN.test(filename)) return "auto";
|
||||||
if (MANUAL_BACKUP_PATTERN.test(filename)) return "manual";
|
if (MANUAL_BACKUP_PATTERN.test(filename)) return "manual";
|
||||||
return null;
|
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> {
|
export async function createBackup(type: "auto" | "manual"): Promise<string> {
|
||||||
const dbPath = getDbPath();
|
const dbPath = getDbPath();
|
||||||
const backupDir = getBackupDir();
|
const backupDir = getBackupDir();
|
||||||
@@ -157,7 +134,6 @@ export async function createBackup(type: "auto" | "manual"): Promise<string> {
|
|||||||
let backupPath = path.join(backupDir, filename);
|
let backupPath = path.join(backupDir, filename);
|
||||||
let reservedHandle: FileHandle | null = null;
|
let reservedHandle: FileHandle | null = null;
|
||||||
|
|
||||||
// Check if database exists
|
|
||||||
if (!fs.existsSync(dbPath)) {
|
if (!fs.existsSync(dbPath)) {
|
||||||
throw new Error(`Database file not found: ${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") {
|
if (type === "auto") {
|
||||||
reservedHandle = await tryReserve(backupPath);
|
reservedHandle = await tryReserve(backupPath);
|
||||||
if (!reservedHandle) {
|
if (!reservedHandle) {
|
||||||
console.log(
|
logger.info("Automatic backup already exists for current day", {
|
||||||
`ℹ️ [backup] Auto backup already exists for today: ${filename}`,
|
filename,
|
||||||
);
|
type,
|
||||||
|
});
|
||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
} else {
|
} 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();
|
await reservedHandle.close();
|
||||||
|
|
||||||
let sqlite: SqliteDatabase | null = null;
|
let sqlite: SqliteDatabase | null = null;
|
||||||
@@ -218,32 +194,28 @@ export async function createBackup(type: "auto" | "manual"): Promise<string> {
|
|||||||
sqlite?.close();
|
sqlite?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
logger.info("Created database backup", {
|
||||||
`✅ [backup] Created ${type} backup: ${filename} (${(await fs.promises.stat(backupPath)).size} bytes)`,
|
filename,
|
||||||
);
|
type,
|
||||||
|
size: (await fs.promises.stat(backupPath)).size,
|
||||||
|
});
|
||||||
|
|
||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* List all backups with metadata
|
|
||||||
* @returns Array of backup information
|
|
||||||
*/
|
|
||||||
export async function listBackups(): Promise<BackupInfo[]> {
|
export async function listBackups(): Promise<BackupInfo[]> {
|
||||||
const backupDir = getBackupDir();
|
const backupDir = getBackupDir();
|
||||||
|
|
||||||
// Check if directory exists
|
|
||||||
if (!fs.existsSync(backupDir)) {
|
if (!fs.existsSync(backupDir)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read directory and filter backup files
|
|
||||||
const files = await fs.promises.readdir(backupDir);
|
const files = await fs.promises.readdir(backupDir);
|
||||||
const backupFiles = files.filter((file) => {
|
const backupFiles = files.filter(
|
||||||
return AUTO_BACKUP_PATTERN.test(file) || MANUAL_BACKUP_PATTERN.test(file);
|
(file) =>
|
||||||
});
|
AUTO_BACKUP_PATTERN.test(file) || MANUAL_BACKUP_PATTERN.test(file),
|
||||||
|
);
|
||||||
|
|
||||||
// Get metadata for each backup
|
|
||||||
const backups: BackupInfo[] = [];
|
const backups: BackupInfo[] = [];
|
||||||
for (const filename of backupFiles) {
|
for (const filename of backupFiles) {
|
||||||
const filePath = path.join(backupDir, filename);
|
const filePath = path.join(backupDir, filename);
|
||||||
@@ -261,20 +233,14 @@ export async function listBackups(): Promise<BackupInfo[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by creation date (newest first)
|
backups.sort(
|
||||||
backups.sort((a, b) => {
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
);
|
||||||
});
|
|
||||||
|
|
||||||
return backups;
|
return backups;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a specific backup
|
|
||||||
* @param filename - Name of the backup file to delete
|
|
||||||
*/
|
|
||||||
export async function deleteBackup(filename: string): Promise<void> {
|
export async function deleteBackup(filename: string): Promise<void> {
|
||||||
// Validate filename to prevent path traversal
|
|
||||||
if (
|
if (
|
||||||
!AUTO_BACKUP_PATTERN.test(filename) &&
|
!AUTO_BACKUP_PATTERN.test(filename) &&
|
||||||
!MANUAL_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 backupDir = getBackupDir();
|
||||||
const filePath = path.join(backupDir, filename);
|
const filePath = path.join(backupDir, filename);
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
throw new Error(`Backup not found: ${filename}`);
|
throw new Error(`Backup not found: ${filename}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete file
|
|
||||||
await fs.promises.unlink(filePath);
|
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> {
|
export async function cleanupOldBackups(): Promise<void> {
|
||||||
const backups = await listBackups();
|
const backups = await listBackups();
|
||||||
|
|
||||||
// Filter to only automatic backups
|
|
||||||
const autoBackups = backups.filter((b) => b.type === "auto");
|
const autoBackups = backups.filter((b) => b.type === "auto");
|
||||||
|
|
||||||
// Sort by creation date (oldest first for deletion)
|
autoBackups.sort(
|
||||||
autoBackups.sort((a, b) => {
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// Delete oldest backups if we exceed max count
|
|
||||||
const maxCount = currentSettings.maxCount;
|
const maxCount = currentSettings.maxCount;
|
||||||
if (autoBackups.length > maxCount) {
|
if (autoBackups.length > maxCount) {
|
||||||
const toDelete = autoBackups.slice(0, autoBackups.length - maxCount);
|
const toDelete = autoBackups.slice(0, autoBackups.length - maxCount);
|
||||||
@@ -320,79 +275,55 @@ export async function cleanupOldBackups(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await deleteBackup(backup.filename);
|
await deleteBackup(backup.filename);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error("Failed to delete old automatic backup", {
|
||||||
`❌ [backup] Failed to delete old backup ${backup.filename}:`,
|
filename: backup.filename,
|
||||||
error,
|
error,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
logger.info("Cleaned up old automatic backups", {
|
||||||
`🧹 [backup] Cleaned up ${toDelete.length} old automatic backups (max: ${maxCount})`,
|
deletedCount: toDelete.length,
|
||||||
);
|
maxCount,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update backup settings and restart scheduler if needed
|
|
||||||
* @param settings - New backup settings
|
|
||||||
*/
|
|
||||||
export function setBackupSettings(settings: Partial<BackupSettings>): void {
|
export function setBackupSettings(settings: Partial<BackupSettings>): void {
|
||||||
const oldEnabled = currentSettings.enabled;
|
const oldEnabled = currentSettings.enabled;
|
||||||
const oldHour = currentSettings.hour;
|
const oldHour = currentSettings.hour;
|
||||||
|
|
||||||
// Update settings
|
|
||||||
currentSettings = { ...currentSettings, ...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 (currentSettings.enabled) {
|
||||||
if (!oldEnabled || oldHour !== currentSettings.hour) {
|
if (!oldEnabled || oldHour !== currentSettings.hour) {
|
||||||
// Start or restart with new hour
|
|
||||||
scheduler.start(currentSettings.hour);
|
scheduler.start(currentSettings.hour);
|
||||||
}
|
}
|
||||||
} else if (oldEnabled && !currentSettings.enabled) {
|
} else if (oldEnabled && !currentSettings.enabled) {
|
||||||
// Stop scheduler
|
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current backup settings
|
|
||||||
*/
|
|
||||||
export function getBackupSettings(): BackupSettings {
|
export function getBackupSettings(): BackupSettings {
|
||||||
return { ...currentSettings };
|
return { ...currentSettings };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the next scheduled backup time
|
|
||||||
* @returns ISO string of next backup time, or null if disabled
|
|
||||||
*/
|
|
||||||
export function getNextBackupTime(): string | null {
|
export function getNextBackupTime(): string | null {
|
||||||
return scheduler.getNextRun();
|
return scheduler.getNextRun();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if automatic backup scheduler is running
|
|
||||||
*/
|
|
||||||
export function isBackupSchedulerRunning(): boolean {
|
export function isBackupSchedulerRunning(): boolean {
|
||||||
return scheduler.isRunning();
|
return scheduler.isRunning();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the backup scheduler manually (used on server startup)
|
|
||||||
* Only starts if backup is enabled
|
|
||||||
*/
|
|
||||||
export function startBackupScheduler(): void {
|
export function startBackupScheduler(): void {
|
||||||
if (currentSettings.enabled) {
|
if (currentSettings.enabled) {
|
||||||
scheduler.start(currentSettings.hour);
|
scheduler.start(currentSettings.hour);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the backup scheduler
|
|
||||||
*/
|
|
||||||
export function stopBackupScheduler(): void {
|
export function stopBackupScheduler(): void {
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ describe("getProfile", () => {
|
|||||||
await getProfile(true);
|
await getProfile(true);
|
||||||
|
|
||||||
expect(getResume).toHaveBeenCalledTimes(2);
|
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 () => {
|
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", {
|
logger.info("Fetching profile from Reactive Resume", {
|
||||||
resumeId: rxresumeBaseResumeId,
|
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") {
|
if (!resume.data || typeof resume.data !== "object") {
|
||||||
throw new Error("Resume data is empty or invalid");
|
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)
|
// Default token TTL: 50 minutes (JWT tokens typically expire in 1 hour)
|
||||||
const DEFAULT_TOKEN_TTL_MS = 50 * 60 * 1000;
|
const DEFAULT_TOKEN_TTL_MS = 50 * 60 * 1000;
|
||||||
|
|
||||||
export class RxResumeClient {
|
class RxResumeClientImpl {
|
||||||
private readonly tokenTtlMs: number;
|
private readonly tokenTtlMs: number;
|
||||||
|
|
||||||
constructor(
|
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 {
|
function sanitizeResponseSnippet(text: string): string {
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
const compact = normalizeWhitespace(text);
|
const compact = normalizeWhitespace(text);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ vi.mock("./client", () => ({
|
|||||||
import { getSetting } from "@server/repositories/settings";
|
import { getSetting } from "@server/repositories/settings";
|
||||||
import { RxResumeClient } from "./client";
|
import { RxResumeClient } from "./client";
|
||||||
import {
|
import {
|
||||||
|
clearRxResumeResumeCache,
|
||||||
extractProjectsFromResume,
|
extractProjectsFromResume,
|
||||||
getResume as getResumeFromAdapter,
|
getResume as getResumeFromAdapter,
|
||||||
listResumes,
|
listResumes,
|
||||||
@@ -53,6 +54,8 @@ function mockSettings(map: SettingMap): void {
|
|||||||
describe("rxresume adapter", () => {
|
describe("rxresume adapter", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
clearRxResumeResumeCache();
|
||||||
delete process.env.RXRESUME_API_KEY;
|
delete process.env.RXRESUME_API_KEY;
|
||||||
delete process.env.RXRESUME_EMAIL;
|
delete process.env.RXRESUME_EMAIL;
|
||||||
delete process.env.RXRESUME_PASSWORD;
|
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 () => {
|
it("prepares tailored v5 resume payload without relying on v4 fields", async () => {
|
||||||
const v5ResumeData = {
|
const v5ResumeData = {
|
||||||
basics: {
|
basics: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { getSetting } from "@server/repositories/settings";
|
import { getSetting } from "@server/repositories/settings";
|
||||||
import { pickProjectIdsForJob } from "@server/services/projectSelection";
|
import { pickProjectIdsForJob } from "@server/services/projectSelection";
|
||||||
import { resolveResumeProjectsSettings } from "@server/services/resumeProjects";
|
import { resolveResumeProjectsSettings } from "@server/services/resumeProjects";
|
||||||
@@ -71,6 +72,7 @@ export class RxResumeRequestError extends Error {
|
|||||||
|
|
||||||
type ResolveModeOptions = {
|
type ResolveModeOptions = {
|
||||||
mode?: RxResumeMode;
|
mode?: RxResumeMode;
|
||||||
|
forceRefresh?: boolean;
|
||||||
v4?: {
|
v4?: {
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
@@ -81,6 +83,91 @@ type ResolveModeOptions = {
|
|||||||
|
|
||||||
type V4Credentials = Awaited<ReturnType<typeof readV4Credentials>>;
|
type V4Credentials = Awaited<ReturnType<typeof readV4Credentials>>;
|
||||||
type V5Credentials = Awaited<ReturnType<typeof readV5Credentials>>;
|
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(
|
function toV4Override(
|
||||||
input?: ResolveModeOptions["v4"],
|
input?: ResolveModeOptions["v4"],
|
||||||
@@ -177,21 +264,25 @@ async function readV4Credentials(overrides?: ResolveModeOptions["v4"]) {
|
|||||||
getSetting("rxresumePassword"),
|
getSetting("rxresumePassword"),
|
||||||
getSetting("rxresumeUrl"),
|
getSetting("rxresumeUrl"),
|
||||||
]);
|
]);
|
||||||
const email =
|
const email = resolveOverrideValue({
|
||||||
overrides?.email?.trim() ||
|
overrideValue: overrides?.email,
|
||||||
storedEmail?.trim() ||
|
hasOverride: hasOverrideKey(overrides, "email"),
|
||||||
process.env.RXRESUME_EMAIL?.trim() ||
|
storedValue: storedEmail,
|
||||||
"";
|
envValue: process.env.RXRESUME_EMAIL,
|
||||||
const password =
|
});
|
||||||
overrides?.password?.trim() ||
|
const password = resolveOverrideValue({
|
||||||
storedPassword?.trim() ||
|
overrideValue: overrides?.password,
|
||||||
process.env.RXRESUME_PASSWORD?.trim() ||
|
hasOverride: hasOverrideKey(overrides, "password"),
|
||||||
"";
|
storedValue: storedPassword,
|
||||||
const baseUrl =
|
envValue: process.env.RXRESUME_PASSWORD,
|
||||||
overrides?.baseUrl?.trim() ||
|
});
|
||||||
storedBaseUrl?.trim() ||
|
const baseUrl = resolveOverrideValue({
|
||||||
process.env.RXRESUME_URL?.trim() ||
|
overrideValue: overrides?.baseUrl,
|
||||||
"https://v4.rxresu.me";
|
hasOverride: hasOverrideKey(overrides, "baseUrl"),
|
||||||
|
storedValue: storedBaseUrl,
|
||||||
|
envValue: process.env.RXRESUME_URL,
|
||||||
|
fallback: "https://v4.rxresu.me",
|
||||||
|
});
|
||||||
return { email, password, baseUrl, available: Boolean(email && password) };
|
return { email, password, baseUrl, available: Boolean(email && password) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,60 +291,25 @@ async function readV5Credentials(overrides?: ResolveModeOptions["v5"]) {
|
|||||||
getSetting("rxresumeApiKey"),
|
getSetting("rxresumeApiKey"),
|
||||||
getSetting("rxresumeUrl"),
|
getSetting("rxresumeUrl"),
|
||||||
]);
|
]);
|
||||||
const apiKey =
|
const apiKey = resolveOverrideValue({
|
||||||
overrides?.apiKey?.trim() ||
|
overrideValue: overrides?.apiKey,
|
||||||
storedApiKey?.trim() ||
|
hasOverride: hasOverrideKey(overrides, "apiKey"),
|
||||||
process.env.RXRESUME_API_KEY?.trim() ||
|
storedValue: storedApiKey,
|
||||||
"";
|
envValue: process.env.RXRESUME_API_KEY,
|
||||||
const baseUrl =
|
});
|
||||||
overrides?.baseUrl?.trim() ||
|
const baseUrl = resolveOverrideValue({
|
||||||
storedBaseUrl?.trim() ||
|
overrideValue: overrides?.baseUrl,
|
||||||
process.env.RXRESUME_URL?.trim() ||
|
hasOverride: hasOverrideKey(overrides, "baseUrl"),
|
||||||
"https://rxresu.me";
|
storedValue: storedBaseUrl,
|
||||||
|
envValue: process.env.RXRESUME_URL,
|
||||||
|
fallback: "https://rxresu.me",
|
||||||
|
});
|
||||||
return { apiKey, baseUrl, available: Boolean(apiKey) };
|
return { apiKey, baseUrl, available: Boolean(apiKey) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveRxResumeMode(
|
async function resolveOperationContext(
|
||||||
options: ResolveModeOptions = {},
|
options: ResolveModeOptions = {},
|
||||||
): Promise<RxResumeResolvedMode> {
|
): Promise<ResolvedOperationContext> {
|
||||||
const mode = options.mode ?? (await readConfiguredMode());
|
|
||||||
const [v5Creds, v4Creds] = await Promise.all([
|
|
||||||
readV5Credentials(options.v5),
|
|
||||||
readV4Credentials(options.v4),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (mode === "v5") {
|
|
||||||
if (!v5Creds.available) {
|
|
||||||
throw new RxResumeAuthConfigError(
|
|
||||||
"v5",
|
|
||||||
"Reactive Resume v5 API key is not configured. Set RXRESUME_API_KEY or configure rxresumeApiKey in Settings.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return "v5";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "v4") {
|
|
||||||
if (!v4Creds.available) {
|
|
||||||
throw new RxResumeAuthConfigError(
|
|
||||||
"v4",
|
|
||||||
"Reactive Resume v4 credentials are not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD or configure them in Settings.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return "v4";
|
|
||||||
}
|
|
||||||
throw new RxResumeAuthConfigError(
|
|
||||||
mode,
|
|
||||||
"Reactive Resume mode must be set to v4 or v5.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRxResumeOperation<T>(
|
|
||||||
options: ResolveModeOptions,
|
|
||||||
handlers: {
|
|
||||||
v4: (creds: V4Credentials) => Promise<T>;
|
|
||||||
v5: (creds: V5Credentials) => Promise<T>;
|
|
||||||
},
|
|
||||||
): Promise<T> {
|
|
||||||
const requestedMode = options.mode ?? (await readConfiguredMode());
|
const requestedMode = options.mode ?? (await readConfiguredMode());
|
||||||
const [v5Creds, v4Creds] = await Promise.all([
|
const [v5Creds, v4Creds] = await Promise.all([
|
||||||
readV5Credentials(options.v5),
|
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.",
|
"Reactive Resume v5 API key is not configured. Set RXRESUME_API_KEY or configure rxresumeApiKey in Settings.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
return { mode: "v5", creds: v5Creds };
|
||||||
return await handlers.v5(v5Creds);
|
|
||||||
} catch (error) {
|
|
||||||
throw normalizeError(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!v4Creds.available) {
|
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.",
|
"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 {
|
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) {
|
} catch (error) {
|
||||||
throw normalizeError(error);
|
throw normalizeError(error);
|
||||||
}
|
}
|
||||||
@@ -304,33 +413,47 @@ export async function getResume(
|
|||||||
resumeId: string,
|
resumeId: string,
|
||||||
options: ResolveModeOptions = {},
|
options: ResolveModeOptions = {},
|
||||||
): Promise<RxResumeResume> {
|
): Promise<RxResumeResume> {
|
||||||
return runRxResumeOperation(options, {
|
const context = await resolveOperationContext(options);
|
||||||
v5: async (creds) => {
|
const cacheKey = buildResumeCacheKey(resumeId, context);
|
||||||
const resume = normalizeV5ResumeResponse(
|
const now = Date.now();
|
||||||
await v5.getResume(resumeId, {
|
|
||||||
apiKey: creds.apiKey,
|
if (!options.forceRefresh) {
|
||||||
baseUrl: creds.baseUrl,
|
const cached = rxResumeResumeCache.get(cacheKey);
|
||||||
}),
|
if (cached && cached.expiresAt > now) {
|
||||||
) as RxResumeResume;
|
return cloneResume(cached.resume);
|
||||||
return {
|
}
|
||||||
...resume,
|
if (cached) {
|
||||||
mode: "v5",
|
rxResumeResumeCache.delete(cacheKey);
|
||||||
title:
|
}
|
||||||
typeof resume.name === "string" && resume.name.trim()
|
|
||||||
? resume.name
|
const inFlight = inFlightResumeRequests.get(cacheKey);
|
||||||
: (resume.slug ?? resume.id),
|
if (inFlight) {
|
||||||
data: resume.data,
|
return cloneResume(await inFlight);
|
||||||
} as RxResumeResume;
|
}
|
||||||
},
|
}
|
||||||
v4: async (creds) => ({
|
|
||||||
...((await v4.getResume(
|
const generation = rxResumeResumeCacheGeneration;
|
||||||
resumeId,
|
let request: Promise<RxResumeResume>;
|
||||||
toV4Override(creds),
|
request = fetchResumeFromUpstream(resumeId, context)
|
||||||
)) as RxResumeResume),
|
.then((resume) => {
|
||||||
mode: "v4",
|
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(
|
export async function validateResumeSchema(
|
||||||
resumeData: unknown,
|
resumeData: unknown,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ describe("applySettingsUpdates", () => {
|
|||||||
"app-key",
|
"app-key",
|
||||||
);
|
);
|
||||||
expect(plan.shouldRefreshBackupScheduler).toBe(false);
|
expect(plan.shouldRefreshBackupScheduler).toBe(false);
|
||||||
|
expect(plan.shouldClearRxResumeCaches).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks backup scheduler refresh when backup settings are changed", async () => {
|
it("marks backup scheduler refresh when backup settings are changed", async () => {
|
||||||
@@ -84,6 +85,7 @@ describe("applySettingsUpdates", () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(plan.shouldRefreshBackupScheduler).toBe(true);
|
expect(plan.shouldRefreshBackupScheduler).toBe(true);
|
||||||
|
expect(plan.shouldClearRxResumeCaches).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves and persists normalized resumeProjects", async () => {
|
it("resolves and persists normalized resumeProjects", async () => {
|
||||||
@@ -131,4 +133,25 @@ describe("applySettingsUpdates", () => {
|
|||||||
JSON.stringify(normalized),
|
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(
|
shouldRefreshBackupScheduler: deferredSideEffects.has(
|
||||||
"refreshBackupScheduler",
|
"refreshBackupScheduler",
|
||||||
),
|
),
|
||||||
|
shouldClearRxResumeCaches: deferredSideEffects.has("clearRxResumeCaches"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
import { settingsRegistry } from "@shared/settings-registry";
|
import { settingsRegistry } from "@shared/settings-registry";
|
||||||
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
|
|
||||||
export type DeferredSideEffect = "refreshBackupScheduler";
|
export type DeferredSideEffect =
|
||||||
|
| "refreshBackupScheduler"
|
||||||
|
| "clearRxResumeCaches";
|
||||||
|
|
||||||
export type SettingsUpdateAction = {
|
export type SettingsUpdateAction = {
|
||||||
settingKey: SettingKey;
|
settingKey: SettingKey;
|
||||||
@@ -38,6 +40,7 @@ export type SettingUpdateHandler<K extends keyof UpdateSettingsInput> = (args: {
|
|||||||
|
|
||||||
export type SettingsUpdatePlan = {
|
export type SettingsUpdatePlan = {
|
||||||
shouldRefreshBackupScheduler: boolean;
|
shouldRefreshBackupScheduler: boolean;
|
||||||
|
shouldClearRxResumeCaches: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function result(
|
function result(
|
||||||
@@ -69,6 +72,15 @@ export const settingsUpdateRegistry: Partial<{
|
|||||||
[K in keyof UpdateSettingsInput]: SettingUpdateHandler<K>;
|
[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)) {
|
for (const [key, def] of Object.entries(settingsRegistry)) {
|
||||||
if (def.kind === "virtual") continue;
|
if (def.kind === "virtual") continue;
|
||||||
|
|
||||||
@@ -120,6 +132,7 @@ for (const [key, def] of Object.entries(settingsRegistry)) {
|
|||||||
persistAction("rxresumeBaseResumeId", serialized),
|
persistAction("rxresumeBaseResumeId", serialized),
|
||||||
persistAction(modeSpecificKey, serialized),
|
persistAction(modeSpecificKey, serialized),
|
||||||
],
|
],
|
||||||
|
deferred: ["clearRxResumeCaches"],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
@@ -142,10 +155,19 @@ for (const [key, def] of Object.entries(settingsRegistry)) {
|
|||||||
applyEnvValue((def as any).envKey, serialized);
|
applyEnvValue((def as any).envKey, serialized);
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const deferred: DeferredSideEffect[] = [];
|
||||||
|
if (isBackup) {
|
||||||
|
deferred.push("refreshBackupScheduler");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
RXRESUME_CACHE_INVALIDATION_KEYS.has(key as keyof UpdateSettingsInput)
|
||||||
|
) {
|
||||||
|
deferred.push("clearRxResumeCaches");
|
||||||
|
}
|
||||||
|
|
||||||
return result({
|
return result({
|
||||||
actions: [persistAction(targetKey, serialized, sideEffect)],
|
actions: [persistAction(targetKey, serialized, sideEffect)],
|
||||||
deferred: isBackup ? ["refreshBackupScheduler"] : [],
|
deferred,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export interface ProfileStatusResponse {
|
|||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
|
status?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DemoInfoResponse {
|
export interface DemoInfoResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user