Add RxResume v4/v5 dual support (#230)
* feat(settings): add rxresume mode and v5 api key settings * feat(server): add mode-aware rxresume adapter with auto v5-first selection * refactor(server): route settings profile and pdf generation through rxresume adapter * feat(api): support rxresume v4/v5 in onboarding and settings routes with ok/meta responses * feat(client): add rxresume mode selector and v5 api key setup flow * docs: document rxresume auto mode with v5-first self-hosted setup * test: verify dual-mode rxresume support and ci parity checks * comments * services folder * correct types for v5 * tests and docs fix * Fix RxResume auto fallback and route API consistency * warning for both being set * simpler response * onboarding component improvements, v5 check still not working * fix list resume endpoint... * fix api endpoints to latest v5 docs * don't show the entire project field on v5 * remove auto entirely * formatting * ci green * v5 has a different resume schema * remove redundant check * remove requirement that only one must be specified * consolidate sections * base resume can be v4 or v5 * saving now works * status indicator * actually render some pills * reason for failure * fix apikey verification * dedupe isValidatingMode * reefactoor * simplification? * refactor? * ci passing * remove auto from docs * tailoring is schema dependent * skills object tighter * remove redundant text * fix lint * mode
This commit is contained in:
parent
70f8afd294
commit
7514aa1b28
@ -78,24 +78,32 @@ At generation time:
|
||||
|
||||
Before connecting Reactive Resume to JobOps:
|
||||
|
||||
1. Create your account on **RxResume v4** at [v4.rxresu.me/auth/register](https://v4.rxresu.me/auth/register).
|
||||
2. Use a **native email + password** account (not Google/GitHub/other OAuth login).
|
||||
3. Generate/store that password so JobOps can use it for API login.
|
||||
1. Choose a mode in **Settings → Reactive Resume**:
|
||||
- `v5` (API key)
|
||||
- `v4` (email/password)
|
||||
2. For **v5** (recommended for self-hosted/latest), generate an API key and configure `rxresumeApiKey` or `RXRESUME_API_KEY`.
|
||||
3. For **v4**, create a native email/password account at [v4.rxresu.me/auth/register](https://v4.rxresu.me/auth/register) and configure `rxresumeEmail` + `rxresumePassword`.
|
||||
|
||||
JobOps cannot use OAuth-based RxResume logins for this integration.
|
||||
Important:
|
||||
|
||||
### 1) Configure RxResume credentials
|
||||
- Explicit `v4` and `v5` modes do not silently fall back.
|
||||
- OAuth-only logins are not supported for the v4 email/password integration.
|
||||
|
||||
Configure in Settings:
|
||||
### 1) Configure Reactive Resume access
|
||||
|
||||
- `rxresumeEmail`
|
||||
- `rxresumePassword`
|
||||
Configure in **Settings → Reactive Resume**:
|
||||
|
||||
- `rxresumeMode` (`v5` or `v4`)
|
||||
- `rxresumeApiKey` (for v5)
|
||||
- `rxresumeEmail` + `rxresumePassword` (for v4)
|
||||
|
||||
Or via environment variables:
|
||||
|
||||
- `RXRESUME_MODE` (`v5` or `v4`)
|
||||
- `RXRESUME_API_KEY` (for v5)
|
||||
- `RXRESUME_EMAIL`
|
||||
- `RXRESUME_PASSWORD`
|
||||
- optional `RXRESUME_URL` (defaults to `https://v4.rxresu.me`)
|
||||
- optional `RXRESUME_URL` (works for both modes; v5 OpenAPI path is added automatically)
|
||||
|
||||
### 2) Select base resume
|
||||
|
||||
@ -176,7 +184,7 @@ curl -X PATCH "http://localhost:3001/api/settings" \
|
||||
```
|
||||
|
||||
```bash
|
||||
# List available RxResume resumes
|
||||
# List available Reactive Resume resumes
|
||||
curl "http://localhost:3001/api/settings/rx-resumes"
|
||||
```
|
||||
|
||||
@ -194,14 +202,17 @@ curl -X POST "http://localhost:3001/api/jobs/<jobId>/generate-pdf"
|
||||
|
||||
### RxResume controls are disabled
|
||||
|
||||
- Ensure RxResume credentials are configured.
|
||||
- Ensure the selected mode has credentials configured.
|
||||
- `v5`: set a valid API key.
|
||||
- `v4`: set email + password.
|
||||
- Save settings, then refresh resumes in the Reactive Resume section.
|
||||
|
||||
### No resumes appear in dropdown
|
||||
|
||||
- Confirm credentials are valid for [v4.rxresu.me](https://v4.rxresu.me)/your configured RxResume URL.
|
||||
- Confirm the RxResume account is a native email/password account (not OAuth-only).
|
||||
- Confirm the selected RxResume account actually has resumes.
|
||||
- Confirm the selected mode matches your Reactive Resume deployment.
|
||||
- For `v5`, confirm `RXRESUME_API_KEY` / `rxresumeApiKey` is valid for your self-hosted instance.
|
||||
- For `v4`, confirm credentials are valid for [v4.rxresu.me](https://v4.rxresu.me) (or your configured v4 URL) and are not OAuth-only.
|
||||
- Confirm the selected Reactive Resume account actually has resumes.
|
||||
|
||||
### Project list is empty in settings
|
||||
|
||||
|
||||
@ -124,3 +124,7 @@ docker compose up -d
|
||||
If you self-host Reactive Resume, set:
|
||||
|
||||
- `RXRESUME_URL=http://rxresume.local.net`
|
||||
- `RXRESUME_MODE=auto` (recommended) or `v5`/`v4` to force a specific API version
|
||||
- `RXRESUME_API_KEY=...` (or configure `rxresumeApiKey` in JobOps Settings)
|
||||
|
||||
`auto` mode is the default and prefers v5 when an API key is configured, then falls back to v4 credentials.
|
||||
|
||||
@ -36,7 +36,9 @@ orchestrator/
|
||||
# The app is self-configuring. You can add keys via the UI Onboarding.
|
||||
```
|
||||
|
||||
After the server starts, use the onboarding modal to connect OpenRouter, link your v4.rxresu.me account, and select a template resume.
|
||||
After the server starts, use the onboarding modal to connect your LLM provider, configure Reactive Resume (`v5` or `v4`), and select a template resume.
|
||||
|
||||
`v5` (API key) is recommended for self-hosted/latest Reactive Resume. Use `v4` when connecting to the legacy email/password flow.
|
||||
|
||||
OpenRouter is the default LLM provider, but LM Studio, Ollama, OpenAI, and Gemini are also supported.
|
||||
|
||||
@ -142,5 +144,5 @@ npm start
|
||||
- **Backend:** Express, TypeScript, Drizzle ORM, SQLite
|
||||
- **Frontend:** React, Vite, CSS (custom design system)
|
||||
- **AI:** Configurable LLM provider (OpenRouter default; also supports OpenAI/Gemini/LM Studio/Ollama)
|
||||
- **PDF Generation:** RxResume v4 API export (configured via Settings)
|
||||
- **PDF Generation:** Reactive Resume v4/v5 API export (configured via Settings)
|
||||
- **Job Crawling:** Wraps existing TypeScript Crawlee crawler
|
||||
|
||||
@ -37,6 +37,7 @@ import type {
|
||||
ProfileStatusResponse,
|
||||
ResumeProfile,
|
||||
ResumeProjectCatalogItem,
|
||||
RxResumeMode,
|
||||
StageEvent,
|
||||
StageEventMetadata,
|
||||
StageTransitionTarget,
|
||||
@ -1253,7 +1254,11 @@ export async function getResumeProjectsCatalog(): Promise<
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.rxresumeBaseResumeId) {
|
||||
return await getRxResumeProjects(settings.rxresumeBaseResumeId);
|
||||
return await getRxResumeProjects(
|
||||
settings.rxresumeBaseResumeId,
|
||||
undefined,
|
||||
settings.rxresumeMode?.value,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// fall through to profile-based projects
|
||||
@ -1287,13 +1292,16 @@ export async function validateLlm(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateRxresume(
|
||||
email?: string,
|
||||
password?: string,
|
||||
): Promise<ValidationResult> {
|
||||
export async function validateRxresume(input?: {
|
||||
mode?: "v4" | "v5";
|
||||
email?: string;
|
||||
password?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
}): Promise<ValidationResult> {
|
||||
return fetchApi<ValidationResult>("/onboarding/validate/rxresume", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify(input ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
@ -1310,9 +1318,12 @@ export async function updateSettings(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRxResumes(): Promise<{ id: string; name: string }[]> {
|
||||
export async function getRxResumes(
|
||||
mode?: RxResumeMode,
|
||||
): Promise<{ id: string; name: string }[]> {
|
||||
const query = mode ? `?mode=${encodeURIComponent(mode)}` : "";
|
||||
const data = await fetchApi<{ resumes: { id: string; name: string }[] }>(
|
||||
"/settings/rx-resumes",
|
||||
`/settings/rx-resumes${query}`,
|
||||
);
|
||||
return data.resumes;
|
||||
}
|
||||
@ -1320,9 +1331,11 @@ export async function getRxResumes(): Promise<{ id: string; name: string }[]> {
|
||||
export async function getRxResumeProjects(
|
||||
resumeId: string,
|
||||
signal?: AbortSignal,
|
||||
mode?: RxResumeMode,
|
||||
): Promise<ResumeProjectCatalogItem[]> {
|
||||
const query = mode ? `?mode=${encodeURIComponent(mode)}` : "";
|
||||
const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>(
|
||||
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`,
|
||||
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects${query}`,
|
||||
{ signal },
|
||||
);
|
||||
return data.projects;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Job, JobStatus } from "@shared/types.js";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Calendar,
|
||||
@ -21,9 +21,10 @@ import {
|
||||
import { cn, formatDate, sourceLabel } from "@/lib/utils";
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
import {
|
||||
defaultStatusToken,
|
||||
statusTokens,
|
||||
} from "../pages/orchestrator/constants";
|
||||
getJobStatusIndicator,
|
||||
getTracerStatusIndicator,
|
||||
StatusIndicator,
|
||||
} from "./StatusIndicator";
|
||||
|
||||
interface JobHeaderProps {
|
||||
job: Job;
|
||||
@ -31,32 +32,6 @@ interface JobHeaderProps {
|
||||
onCheckSponsor?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
|
||||
const tokens = statusTokens[status] ?? defaultStatusToken;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80",
|
||||
)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", tokens.dot)} />
|
||||
{tokens.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const TracerPill: React.FC<{ enabled: boolean }> = ({ enabled }) => (
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80">
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full opacity-80",
|
||||
enabled ? "bg-violet-500" : "bg-slate-500",
|
||||
)}
|
||||
/>
|
||||
{enabled ? "Tracer On" : "Tracer Off"}
|
||||
</span>
|
||||
);
|
||||
|
||||
const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
|
||||
if (score == null) {
|
||||
return <span className="text-[10px] text-muted-foreground/60">-</span>;
|
||||
@ -159,30 +134,26 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
|
||||
};
|
||||
|
||||
const status = getStatus(score);
|
||||
const tooltipContent = `${score}% match`;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80 cursor-help">
|
||||
<span
|
||||
className={cn("h-1.5 w-1.5 rounded-full opacity-80", status.dot)}
|
||||
/>
|
||||
{status.label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
const tooltip = (
|
||||
<>
|
||||
{parsedNames.length > 0 && (
|
||||
<p className="text-xs font-medium space-x-1">
|
||||
<span className="opacity-70">Matched</span>
|
||||
<span>{parsedNames.join(", ")}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="opacity-80 mt-1 text-[10px]">{tooltipContent}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<p className="opacity-80 mt-1 text-[10px]">{`${score}% match`}</p>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<StatusIndicator
|
||||
dotColor={status.dot}
|
||||
label={status.label}
|
||||
className="cursor-help"
|
||||
tooltip={tooltip}
|
||||
tooltipClassName="max-w-xs"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -191,6 +162,8 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
|
||||
className,
|
||||
onCheckSponsor,
|
||||
}) => {
|
||||
const jobStatus = getJobStatusIndicator(job.status);
|
||||
const tracerStatus = getTracerStatusIndicator(job.tracerLinksEnabled);
|
||||
const { showSponsorInfo } = useSettings();
|
||||
const { pathname } = useLocation();
|
||||
const isJobPage = pathname.startsWith("/job/");
|
||||
@ -267,8 +240,14 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
|
||||
{/* Status and score: single line, subdued */}
|
||||
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusPill status={job.status} />
|
||||
<TracerPill enabled={job.tracerLinksEnabled} />
|
||||
<StatusIndicator
|
||||
dotColor={jobStatus.dotColor}
|
||||
label={jobStatus.label}
|
||||
/>
|
||||
<StatusIndicator
|
||||
dotColor={tracerStatus.dotColor}
|
||||
label={tracerStatus.label}
|
||||
/>
|
||||
{showSponsorInfo && (
|
||||
<SponsorPill
|
||||
score={job.sponsorMatchScore}
|
||||
|
||||
@ -95,6 +95,7 @@ const settingsResponse = {
|
||||
llmProvider: { value: "openrouter", default: "openrouter", override: null },
|
||||
llmApiKeyHint: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumeApiKeyHint: null,
|
||||
rxresumePasswordHint: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
},
|
||||
@ -139,6 +140,13 @@ describe("OnboardingGate", () => {
|
||||
});
|
||||
|
||||
it("hides the gate when all validations succeed", async () => {
|
||||
vi.mocked(useSettings).mockReturnValue({
|
||||
...settingsResponse,
|
||||
settings: {
|
||||
...settingsResponse.settings,
|
||||
rxresumeApiKeyHint: "abcd1234",
|
||||
},
|
||||
} as any);
|
||||
vi.mocked(api.validateLlm).mockResolvedValue({
|
||||
valid: true,
|
||||
message: null,
|
||||
@ -177,8 +185,9 @@ describe("OnboardingGate", () => {
|
||||
|
||||
render(<OnboardingGate />);
|
||||
|
||||
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
|
||||
await waitFor(() => expect(api.validateResumeConfig).toHaveBeenCalled());
|
||||
expect(api.validateLlm).not.toHaveBeenCalled();
|
||||
expect(api.validateRxresume).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -1,17 +1,24 @@
|
||||
import * as api from "@client/api";
|
||||
import { ReactiveResumeConfigPanel } from "@client/components/ReactiveResumeConfigPanel";
|
||||
import { useDemoInfo } from "@client/hooks/useDemoInfo";
|
||||
import { useRxResumeConfigState } from "@client/hooks/useRxResumeConfigState";
|
||||
import { useSettings } from "@client/hooks/useSettings";
|
||||
import {
|
||||
getInitialRxResumeMode,
|
||||
getRxResumeCredentialDrafts,
|
||||
getRxResumeMissingCredentialLabels,
|
||||
validateAndMaybePersistRxResumeMode,
|
||||
} from "@client/lib/rxresume-config";
|
||||
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||
import {
|
||||
formatSecretHint,
|
||||
getLlmProviderConfig,
|
||||
LLM_PROVIDER_LABELS,
|
||||
LLM_PROVIDERS,
|
||||
normalizeLlmProvider,
|
||||
} from "@client/pages/settings/utils";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type { ValidationResult } from "@shared/types.js";
|
||||
import type { RxResumeMode, ValidationResult } from "@shared/types.js";
|
||||
import { Check } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@ -44,16 +51,30 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ValidationState = ValidationResult & { checked: boolean };
|
||||
type TimestampedValidationState = ValidationState & { testedAt: number | null };
|
||||
|
||||
type OnboardingFormData = {
|
||||
llmProvider: string;
|
||||
llmBaseUrl: string;
|
||||
llmApiKey: string;
|
||||
rxresumeMode: RxResumeMode;
|
||||
rxresumeEmail: string;
|
||||
rxresumePassword: string;
|
||||
rxresumeApiKey: string;
|
||||
rxresumeBaseResumeId: string | null;
|
||||
};
|
||||
|
||||
const EMPTY_VALIDATION_STATE: ValidationState = {
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
};
|
||||
|
||||
const EMPTY_TIMESTAMPED_VALIDATION_STATE: TimestampedValidationState = {
|
||||
...EMPTY_VALIDATION_STATE,
|
||||
testedAt: null,
|
||||
};
|
||||
|
||||
function getStepPrimaryLabel(input: {
|
||||
currentStep: string | null;
|
||||
llmValidated: boolean;
|
||||
@ -76,29 +97,32 @@ export const OnboardingGate: React.FC = () => {
|
||||
isLoading: settingsLoading,
|
||||
refreshSettings,
|
||||
} = useSettings();
|
||||
const {
|
||||
storedRxResume,
|
||||
getBaseResumeIdForMode,
|
||||
setBaseResumeIdForMode,
|
||||
syncBaseResumeIdsForMode,
|
||||
} = useRxResumeConfigState(settings);
|
||||
|
||||
const [isSavingEnv, setIsSavingEnv] = useState(false);
|
||||
const [isValidatingLlm, setIsValidatingLlm] = useState(false);
|
||||
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
|
||||
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
|
||||
const [llmValidation, setLlmValidation] = useState<ValidationState>({
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
});
|
||||
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
|
||||
{
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
},
|
||||
const [llmValidation, setLlmValidation] = useState<ValidationState>(
|
||||
EMPTY_VALIDATION_STATE,
|
||||
);
|
||||
const [baseResumeValidation, setBaseResumeValidation] =
|
||||
useState<ValidationState>({
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
|
||||
EMPTY_VALIDATION_STATE,
|
||||
);
|
||||
const [rxresumeVersionValidations, setRxresumeVersionValidations] = useState<{
|
||||
v4: TimestampedValidationState;
|
||||
v5: TimestampedValidationState;
|
||||
}>({
|
||||
v4: EMPTY_TIMESTAMPED_VALIDATION_STATE,
|
||||
v5: EMPTY_TIMESTAMPED_VALIDATION_STATE,
|
||||
});
|
||||
const [baseResumeValidation, setBaseResumeValidation] =
|
||||
useState<ValidationState>(EMPTY_VALIDATION_STATE);
|
||||
const [currentStep, setCurrentStep] = useState<string | null>(null);
|
||||
const demoInfo = useDemoInfo();
|
||||
const demoMode = demoInfo?.demoMode ?? false;
|
||||
@ -109,8 +133,10 @@ export const OnboardingGate: React.FC = () => {
|
||||
llmProvider: "",
|
||||
llmBaseUrl: "",
|
||||
llmApiKey: "",
|
||||
rxresumeMode: "v5",
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
rxresumeBaseResumeId: null,
|
||||
},
|
||||
});
|
||||
@ -149,28 +175,6 @@ export const OnboardingGate: React.FC = () => {
|
||||
}
|
||||
}, [getValues, settings?.llmProvider]);
|
||||
|
||||
const validateRxresume = useCallback(async () => {
|
||||
const values = getValues();
|
||||
|
||||
setIsValidatingRxresume(true);
|
||||
try {
|
||||
const result = await api.validateRxresume(
|
||||
values.rxresumeEmail.trim() || undefined,
|
||||
values.rxresumePassword.trim() || undefined,
|
||||
);
|
||||
setRxresumeValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "RxResume validation failed";
|
||||
const result = { valid: false, message };
|
||||
setRxresumeValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} finally {
|
||||
setIsValidatingRxresume(false);
|
||||
}
|
||||
}, [getValues]);
|
||||
|
||||
const validateBaseResume = useCallback(async () => {
|
||||
setIsValidatingBaseResume(true);
|
||||
try {
|
||||
@ -190,6 +194,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rxresumeModeValue = watch("rxresumeMode");
|
||||
const selectedProvider = normalizeLlmProvider(
|
||||
llmProvider || settings?.llmProvider?.value || "openrouter",
|
||||
);
|
||||
@ -203,8 +208,9 @@ export const OnboardingGate: React.FC = () => {
|
||||
|
||||
const llmKeyHint = settings?.llmApiKeyHint ?? null;
|
||||
const hasLlmKey = Boolean(llmKeyHint);
|
||||
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
|
||||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
|
||||
const rxresumeModeCurrent = (rxresumeModeValue ||
|
||||
settings?.rxresumeMode?.value ||
|
||||
"v5") as RxResumeMode;
|
||||
const hasCheckedValidations =
|
||||
(requiresLlmKey ? llmValidation.checked : true) &&
|
||||
rxresumeValidation.checked &&
|
||||
@ -216,26 +222,83 @@ export const OnboardingGate: React.FC = () => {
|
||||
hasCheckedValidations &&
|
||||
!(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid);
|
||||
|
||||
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
|
||||
? settings.rxresumeEmail
|
||||
: undefined;
|
||||
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
|
||||
? formatSecretHint(settings?.rxresumePasswordHint)
|
||||
: undefined;
|
||||
const validateRxresumeVersion = useCallback(
|
||||
async (
|
||||
version: "v4" | "v5",
|
||||
): Promise<ValidationResult & { checked: true; testedAt: number }> => {
|
||||
const values = getValues();
|
||||
const draftCredentials = getRxResumeCredentialDrafts(values);
|
||||
const testedAt = Date.now();
|
||||
const result = await validateAndMaybePersistRxResumeMode({
|
||||
mode: version,
|
||||
stored: storedRxResume,
|
||||
draft: draftCredentials,
|
||||
validate: api.validateRxresume,
|
||||
getPrecheckMessage: (failure) =>
|
||||
failure === "missing-v5-api-key"
|
||||
? "v5 API key required. Add a v5 API key, then test again."
|
||||
: "v4 email and password required. Add both credentials, then test again.",
|
||||
getValidationErrorMessage: (error, mode) =>
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `RxResume ${mode} validation failed`,
|
||||
});
|
||||
return { ...result.validation, checked: true, testedAt };
|
||||
},
|
||||
[getValues, storedRxResume],
|
||||
);
|
||||
|
||||
const validateRxresume = useCallback(async () => {
|
||||
const values = getValues();
|
||||
const selectedMode = values.rxresumeMode;
|
||||
|
||||
setIsValidatingRxresume(true);
|
||||
try {
|
||||
const versionResult = await validateRxresumeVersion(selectedMode);
|
||||
setRxresumeVersionValidations((current) => ({
|
||||
...current,
|
||||
[selectedMode]: versionResult,
|
||||
}));
|
||||
|
||||
const result: ValidationResult = {
|
||||
valid: versionResult.valid,
|
||||
message: versionResult.message,
|
||||
};
|
||||
setRxresumeValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} finally {
|
||||
setIsValidatingRxresume(false);
|
||||
}
|
||||
}, [getValues, validateRxresumeVersion]);
|
||||
|
||||
// Initialize form values from settings
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
const initialMode = getInitialRxResumeMode({
|
||||
savedMode: (settings.rxresumeMode?.value ??
|
||||
null) as RxResumeMode | null,
|
||||
hasV4: storedRxResume.hasV4,
|
||||
hasV5: storedRxResume.hasV5,
|
||||
});
|
||||
const selectedId = syncBaseResumeIdsForMode(initialMode);
|
||||
reset({
|
||||
llmProvider: settings.llmProvider?.value || "",
|
||||
llmBaseUrl: settings.llmBaseUrl?.value || "",
|
||||
llmApiKey: "",
|
||||
rxresumeMode: initialMode,
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
rxresumeBaseResumeId: settings.rxresumeBaseResumeId || null,
|
||||
rxresumeApiKey: "",
|
||||
rxresumeBaseResumeId: selectedId,
|
||||
});
|
||||
}
|
||||
}, [settings, reset]);
|
||||
}, [
|
||||
settings,
|
||||
reset,
|
||||
storedRxResume.hasV4,
|
||||
storedRxResume.hasV5,
|
||||
syncBaseResumeIdsForMode,
|
||||
]);
|
||||
|
||||
// Clear base URL when provider doesn't require it
|
||||
useEffect(() => {
|
||||
@ -262,7 +325,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
{
|
||||
id: "rxresume",
|
||||
label: "Connect Reactive Resume",
|
||||
subtitle: "Reactive Resume login",
|
||||
subtitle: "Version + credentials",
|
||||
complete: rxresumeValidation.valid,
|
||||
disabled: false,
|
||||
},
|
||||
@ -334,20 +397,6 @@ export const OnboardingGate: React.FC = () => {
|
||||
demoMode,
|
||||
]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const results = await Promise.allSettled([
|
||||
refreshSettings(),
|
||||
runAllValidations(),
|
||||
]);
|
||||
const failed = results.find((result) => result.status === "rejected");
|
||||
if (failed) {
|
||||
const reason = failed.status === "rejected" ? failed.reason : null;
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : "Failed to refresh setup";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveLlm = async (): Promise<boolean> => {
|
||||
const values = getValues();
|
||||
const apiKeyValue = values.llmApiKey.trim();
|
||||
@ -395,13 +444,13 @@ export const OnboardingGate: React.FC = () => {
|
||||
|
||||
const handleSaveRxresume = async (): Promise<boolean> => {
|
||||
const values = getValues();
|
||||
const emailValue = values.rxresumeEmail.trim();
|
||||
const passwordValue = values.rxresumePassword.trim();
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email");
|
||||
if (!hasRxresumePassword && !passwordValue)
|
||||
missing.push("RxResume password");
|
||||
const modeValue = values.rxresumeMode;
|
||||
const draftCredentials = getRxResumeCredentialDrafts(values);
|
||||
const missing = getRxResumeMissingCredentialLabels({
|
||||
mode: modeValue,
|
||||
stored: storedRxResume,
|
||||
draft: draftCredentials,
|
||||
});
|
||||
|
||||
if (missing.length > 0) {
|
||||
toast.info("Almost there", {
|
||||
@ -411,22 +460,50 @@ export const OnboardingGate: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = await validateRxresume();
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.message || "RxResume validation failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {};
|
||||
if (emailValue) update.rxresumeEmail = emailValue;
|
||||
if (passwordValue) update.rxresumePassword = passwordValue;
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
setIsValidatingRxresume(true);
|
||||
const result = await validateAndMaybePersistRxResumeMode({
|
||||
mode: modeValue,
|
||||
stored: storedRxResume,
|
||||
draft: draftCredentials,
|
||||
validate: api.validateRxresume,
|
||||
persist: async (update) => {
|
||||
setIsSavingEnv(true);
|
||||
try {
|
||||
await api.updateSettings(update);
|
||||
await refreshSettings();
|
||||
setValue("rxresumePassword", "");
|
||||
} finally {
|
||||
setIsSavingEnv(false);
|
||||
}
|
||||
},
|
||||
persistOnSuccess: true,
|
||||
getPrecheckMessage: (failure) =>
|
||||
failure === "missing-v5-api-key"
|
||||
? "v5 API key required. Add a v5 API key, then test again."
|
||||
: "v4 email and password required. Add both credentials, then test again.",
|
||||
getValidationErrorMessage: (error) =>
|
||||
error instanceof Error ? error.message : "RxResume validation failed",
|
||||
getPersistErrorMessage: (error) =>
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to save RxResume credentials",
|
||||
});
|
||||
|
||||
setRxresumeVersionValidations((current) => ({
|
||||
...current,
|
||||
[modeValue]: {
|
||||
...result.validation,
|
||||
checked: true,
|
||||
testedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
setRxresumeValidation({ ...result.validation, checked: true });
|
||||
|
||||
if (!result.validation.valid) {
|
||||
toast.error(result.validation.message || "RxResume validation failed");
|
||||
return false;
|
||||
}
|
||||
setValue("rxresumePassword", "");
|
||||
setValue("rxresumeApiKey", "");
|
||||
|
||||
toast.success("RxResume connected");
|
||||
return true;
|
||||
@ -438,6 +515,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
toast.error(message);
|
||||
return false;
|
||||
} finally {
|
||||
setIsValidatingRxresume(false);
|
||||
setIsSavingEnv(false);
|
||||
}
|
||||
};
|
||||
@ -453,6 +531,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
try {
|
||||
setIsSavingEnv(true);
|
||||
await api.updateSettings({
|
||||
rxresumeMode: values.rxresumeMode,
|
||||
rxresumeBaseResumeId: values.rxresumeBaseResumeId,
|
||||
});
|
||||
const validation = await validateBaseResume();
|
||||
@ -488,12 +567,6 @@ export const OnboardingGate: React.FC = () => {
|
||||
isValidatingRxresume ||
|
||||
isValidatingBaseResume;
|
||||
const canGoBack = stepIndex > 0;
|
||||
const primaryLabel = getStepPrimaryLabel({
|
||||
currentStep,
|
||||
llmValidated,
|
||||
rxresumeValidated: rxresumeValidation.valid,
|
||||
baseResumeValidated: baseResumeValidation.valid,
|
||||
});
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
if (!currentStep) return;
|
||||
@ -671,60 +744,39 @@ export const OnboardingGate: React.FC = () => {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rxresume" className="space-y-4 pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
Link your RxResume account
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used to export tailored PDFs. Create an account{" "}
|
||||
<a
|
||||
className="underline underline-offset-2"
|
||||
href="https://v4.rxresu.me/auth/register"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here
|
||||
</a>{" "}
|
||||
on RxResume v4 using email/password.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Controller
|
||||
name="rxresumeEmail"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Email"
|
||||
inputProps={{
|
||||
name: "rxresumeEmail",
|
||||
value: field.value,
|
||||
onChange: field.onChange,
|
||||
<ReactiveResumeConfigPanel
|
||||
mode={rxresumeModeCurrent}
|
||||
onModeChange={(mode) => {
|
||||
setValue("rxresumeMode", mode);
|
||||
setValue(
|
||||
"rxresumeBaseResumeId",
|
||||
getBaseResumeIdForMode(mode),
|
||||
);
|
||||
setRxresumeValidation((previous) => ({
|
||||
...EMPTY_VALIDATION_STATE,
|
||||
checked: previous.checked,
|
||||
}));
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
current={rxresumeEmailCurrent}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="rxresumePassword"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={{
|
||||
name: "rxresumePassword",
|
||||
value: field.value,
|
||||
onChange: field.onChange,
|
||||
showValidationStatus
|
||||
validationStatuses={rxresumeVersionValidations}
|
||||
intro={{
|
||||
title: "Link your RxResume account",
|
||||
description:
|
||||
"Used to export tailored PDFs. Choose between Reactive Resume version 4 and 5, and provide the credentials.",
|
||||
}}
|
||||
v5={{
|
||||
apiKey: watch("rxresumeApiKey"),
|
||||
onApiKeyChange: (value) => setValue("rxresumeApiKey", value),
|
||||
}}
|
||||
v4={{
|
||||
email: watch("rxresumeEmail"),
|
||||
onEmailChange: (value) => setValue("rxresumeEmail", value),
|
||||
password: watch("rxresumePassword"),
|
||||
onPasswordChange: (value) =>
|
||||
setValue("rxresumePassword", value),
|
||||
}}
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
current={rxresumePasswordCurrent}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="baseresume" className="space-y-4 pt-6">
|
||||
@ -743,8 +795,14 @@ export const OnboardingGate: React.FC = () => {
|
||||
render={({ field }) => (
|
||||
<BaseResumeSelection
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(value) => {
|
||||
const mode = (getValues("rxresumeMode") ??
|
||||
"v5") as RxResumeMode;
|
||||
setBaseResumeIdForMode(mode, value);
|
||||
field.onChange(value);
|
||||
}}
|
||||
hasRxResumeAccess={rxresumeValidation.valid}
|
||||
rxresumeMode={rxresumeModeCurrent}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
)}
|
||||
@ -761,30 +819,20 @@ export const OnboardingGate: React.FC = () => {
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={handleRefresh} disabled={isBusy}>
|
||||
Refresh status
|
||||
</Button>
|
||||
<Button onClick={handlePrimaryAction} disabled={isBusy}>
|
||||
{isBusy ? "Working..." : primaryLabel}
|
||||
{isBusy
|
||||
? "Validating..."
|
||||
: getStepPrimaryLabel({
|
||||
currentStep,
|
||||
llmValidated,
|
||||
rxresumeValidated: rxresumeValidation.valid,
|
||||
baseResumeValidated: baseResumeValidation.valid,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={progressValue} className="h-2" />
|
||||
|
||||
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
Friendly heads-up: pipelines can be slow or a little flaky in alpha.
|
||||
If anything feels off, open a GitHub issue and we will take a look.{" "}
|
||||
<a
|
||||
className="font-semibold text-foreground underline underline-offset-2"
|
||||
href="https://github.com/DaKheera47/job-ops/issues"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open an issue
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
365
orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx
Normal file
365
orchestrator/src/client/components/ReactiveResumeConfigPanel.tsx
Normal file
@ -0,0 +1,365 @@
|
||||
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||
import {
|
||||
toggleAiSelectable,
|
||||
toggleMustInclude,
|
||||
} from "@client/pages/settings/resume-projects-state";
|
||||
import type { ResumeProjectsSettingsInput } from "@shared/settings-schema.js";
|
||||
import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types.js";
|
||||
import type React from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { clampInt } from "@/lib/utils";
|
||||
import { StatusIndicator } from "./StatusIndicator";
|
||||
|
||||
type VersionValidationState = {
|
||||
checked: boolean;
|
||||
valid: boolean;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
type ProjectSelectionConfig = {
|
||||
baseResumeId: string | null;
|
||||
onBaseResumeIdChange: (value: string | null) => void;
|
||||
projects: ResumeProjectCatalogItem[];
|
||||
value: ResumeProjectsSettingsInput | null | undefined;
|
||||
onChange: (next: ResumeProjectsSettingsInput) => void;
|
||||
lockedCount: number;
|
||||
maxProjectsTotal: number;
|
||||
isProjectsLoading: boolean;
|
||||
disabled: boolean;
|
||||
maxProjectsError?: string;
|
||||
};
|
||||
|
||||
type ReactiveResumeConfigPanelProps = {
|
||||
mode: RxResumeMode;
|
||||
onModeChange: (mode: RxResumeMode) => void;
|
||||
disabled?: boolean;
|
||||
hasRxResumeAccess?: boolean;
|
||||
showValidationStatus?: boolean;
|
||||
validationStatuses?: {
|
||||
v4: VersionValidationState;
|
||||
v5: VersionValidationState;
|
||||
};
|
||||
intro?: {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
v5: {
|
||||
apiKey: string;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
error?: string;
|
||||
helper?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
v4: {
|
||||
email: string;
|
||||
onEmailChange: (value: string) => void;
|
||||
emailError?: string;
|
||||
password: string;
|
||||
onPasswordChange: (value: string) => void;
|
||||
passwordError?: string;
|
||||
emailPlaceholder?: string;
|
||||
passwordPlaceholder?: string;
|
||||
};
|
||||
projectSelection?: ProjectSelectionConfig;
|
||||
};
|
||||
|
||||
function renderStatusPill(label: string, state: VersionValidationState) {
|
||||
const statusLabel = state.checked
|
||||
? state.valid
|
||||
? "Connected"
|
||||
: "Failed"
|
||||
: "Not tested";
|
||||
const dotColor = state.checked
|
||||
? state.valid
|
||||
? "bg-emerald-500"
|
||||
: "bg-destructive"
|
||||
: "bg-muted-foreground";
|
||||
|
||||
return (
|
||||
<StatusIndicator
|
||||
label={`${label}: ${statusLabel}`}
|
||||
dotColor={dotColor}
|
||||
tooltip={
|
||||
state.checked && !state.valid && state.message
|
||||
? state.message
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const ReactiveResumeConfigPanel: React.FC<
|
||||
ReactiveResumeConfigPanelProps
|
||||
> = ({
|
||||
mode,
|
||||
onModeChange,
|
||||
disabled = false,
|
||||
hasRxResumeAccess = false,
|
||||
showValidationStatus = false,
|
||||
validationStatuses,
|
||||
intro,
|
||||
v5,
|
||||
v4,
|
||||
projectSelection,
|
||||
}) => {
|
||||
const canShowProjectSelection = Boolean(
|
||||
projectSelection && hasRxResumeAccess,
|
||||
);
|
||||
const selectedValidationStatus = validationStatuses?.[mode];
|
||||
const handleModeChange = (value: string) =>
|
||||
onModeChange(value === "v4" ? "v4" : "v5");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{intro ? (
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{intro.title}</p>
|
||||
{intro.description ? (
|
||||
<p className="text-xs text-muted-foreground">{intro.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Tabs value={mode} onValueChange={handleModeChange}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2">
|
||||
<TabsTrigger value="v5" disabled={disabled}>
|
||||
v5 (API key)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="v4" disabled={disabled}>
|
||||
v4 (Email + Password)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{showValidationStatus && selectedValidationStatus ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs w-full justify-between">
|
||||
{renderStatusPill(`${mode} status`, selectedValidationStatus)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "v5" ? (
|
||||
<div className="grid gap-4">
|
||||
<SettingsInput
|
||||
label="v5 API key"
|
||||
inputProps={{
|
||||
name: "rxresumeApiKey",
|
||||
value: v5.apiKey,
|
||||
onChange: (event) => v5.onApiKeyChange(event.currentTarget.value),
|
||||
}}
|
||||
type="password"
|
||||
placeholder={v5.placeholder ?? "Enter v5 API key"}
|
||||
helper={v5.helper}
|
||||
disabled={disabled}
|
||||
error={v5.error}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="v4 Email"
|
||||
inputProps={{
|
||||
name: "rxresumeEmail",
|
||||
value: v4.email,
|
||||
onChange: (event) => v4.onEmailChange(event.currentTarget.value),
|
||||
}}
|
||||
placeholder={v4.emailPlaceholder ?? "you@example.com"}
|
||||
disabled={disabled}
|
||||
error={v4.emailError}
|
||||
/>
|
||||
<SettingsInput
|
||||
label="v4 Password"
|
||||
inputProps={{
|
||||
name: "rxresumePassword",
|
||||
value: v4.password,
|
||||
onChange: (event) =>
|
||||
v4.onPasswordChange(event.currentTarget.value),
|
||||
}}
|
||||
type="password"
|
||||
placeholder={v4.passwordPlaceholder ?? "Enter v4 password"}
|
||||
disabled={disabled}
|
||||
error={v4.passwordError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectSelection ? (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{!canShowProjectSelection ? (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
Connect Reactive Resume and choose a template resume to configure
|
||||
resume projects.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<BaseResumeSelection
|
||||
value={projectSelection.baseResumeId}
|
||||
onValueChange={projectSelection.onBaseResumeIdChange}
|
||||
hasRxResumeAccess={hasRxResumeAccess}
|
||||
rxresumeMode={mode}
|
||||
disabled={projectSelection.disabled}
|
||||
/>
|
||||
|
||||
{!projectSelection.baseResumeId ? (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
Choose a PDF to configure resume projects.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
Max projects to choose
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={projectSelection.lockedCount}
|
||||
max={projectSelection.maxProjectsTotal}
|
||||
value={projectSelection.value?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!projectSelection.value) return;
|
||||
const next = Number(event.target.value);
|
||||
const clamped = clampInt(
|
||||
next,
|
||||
projectSelection.lockedCount,
|
||||
projectSelection.maxProjectsTotal,
|
||||
);
|
||||
projectSelection.onChange({
|
||||
...projectSelection.value,
|
||||
maxProjects: clamped,
|
||||
});
|
||||
}}
|
||||
disabled={
|
||||
projectSelection.disabled ||
|
||||
projectSelection.isProjectsLoading ||
|
||||
!projectSelection.value
|
||||
}
|
||||
/>
|
||||
{projectSelection.maxProjectsError ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{projectSelection.maxProjectsError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||
Project
|
||||
</TableHead>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||
Visible in template
|
||||
</TableHead>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||
Must Include
|
||||
</TableHead>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||
AI selectable
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{projectSelection.projects.map((project) => {
|
||||
const value = projectSelection.value;
|
||||
const locked = Boolean(
|
||||
value?.lockedProjectIds.includes(project.id),
|
||||
);
|
||||
const aiSelectable = Boolean(
|
||||
value?.aiSelectableProjectIds.includes(project.id),
|
||||
);
|
||||
const projectMeta =
|
||||
mode === "v5"
|
||||
? project.date
|
||||
: [project.description, project.date]
|
||||
.filter(Boolean)
|
||||
.join(" - ");
|
||||
|
||||
return (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">
|
||||
{project.name}
|
||||
</div>
|
||||
{projectMeta ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{projectMeta}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.isVisibleInBase ? "Yes" : "No"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
onCheckedChange={() => {
|
||||
if (!value) return;
|
||||
projectSelection.onChange(
|
||||
toggleMustInclude({
|
||||
settings: value,
|
||||
projectId: project.id,
|
||||
checked: !locked,
|
||||
maxProjectsTotal:
|
||||
projectSelection.maxProjectsTotal,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
projectSelection.disabled ||
|
||||
projectSelection.isProjectsLoading ||
|
||||
!value
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
onCheckedChange={() => {
|
||||
if (!value) return;
|
||||
projectSelection.onChange(
|
||||
toggleAiSelectable({
|
||||
settings: value,
|
||||
projectId: project.id,
|
||||
checked: !aiSelectable,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
projectSelection.disabled ||
|
||||
projectSelection.isProjectsLoading ||
|
||||
locked ||
|
||||
!value
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
121
orchestrator/src/client/components/StatusIndicator.tsx
Normal file
121
orchestrator/src/client/components/StatusIndicator.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import type { JobStatus } from "@shared/types/jobs";
|
||||
import type React from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
defaultStatusToken,
|
||||
statusTokens,
|
||||
} from "../pages/orchestrator/constants";
|
||||
|
||||
const STATUS_INDICATOR_BASE_CLASS =
|
||||
"inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80";
|
||||
const STATUS_INDICATOR_DOT_CLASS = "h-1.5 w-1.5 rounded-full opacity-80";
|
||||
|
||||
const badgeVariantClasses = {
|
||||
amber: {
|
||||
badge: "border-amber-500/30 bg-amber-500/10 text-amber-200",
|
||||
dot: "bg-amber-400",
|
||||
},
|
||||
emerald: {
|
||||
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
sky: {
|
||||
badge: "border-sky-500/30 bg-sky-500/10 text-sky-200",
|
||||
dot: "bg-sky-400",
|
||||
},
|
||||
};
|
||||
|
||||
type StatusIndicatorProps = {
|
||||
dotColor?: string;
|
||||
label: React.ReactNode;
|
||||
className?: string;
|
||||
dotClassName?: string;
|
||||
variant?: keyof typeof badgeVariantClasses;
|
||||
appearance?: "inline" | "badge";
|
||||
animateDot?: boolean;
|
||||
tooltip?: React.ReactNode;
|
||||
tooltipClassName?: string;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
tooltipDelayDuration?: number;
|
||||
};
|
||||
|
||||
const StatusIndicator: React.FC<StatusIndicatorProps> = ({
|
||||
dotColor,
|
||||
label,
|
||||
className,
|
||||
dotClassName,
|
||||
variant = "amber",
|
||||
appearance = "inline",
|
||||
animateDot = appearance === "badge",
|
||||
tooltip,
|
||||
tooltipClassName,
|
||||
tooltipSide = "top",
|
||||
tooltipDelayDuration = 0,
|
||||
}) => {
|
||||
const badgeTokens = badgeVariantClasses[variant];
|
||||
const resolvedDotColor = dotColor ?? badgeTokens.dot;
|
||||
|
||||
const content = (
|
||||
<span
|
||||
className={cn(
|
||||
appearance === "badge"
|
||||
? "inline-flex items-center gap-2 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase tracking-wide"
|
||||
: STATUS_INDICATOR_BASE_CLASS,
|
||||
appearance === "badge" ? badgeTokens.badge : undefined,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
appearance === "badge"
|
||||
? "h-1.5 w-1.5 rounded-full"
|
||||
: STATUS_INDICATOR_DOT_CLASS,
|
||||
animateDot ? "animate-pulse" : undefined,
|
||||
resolvedDotColor,
|
||||
dotClassName,
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!tooltip) return content;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={tooltipDelayDuration}>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide} className={tooltipClassName}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const getJobStatusIndicator = (status: JobStatus) => {
|
||||
const tokens = statusTokens[status] ?? defaultStatusToken;
|
||||
return { label: tokens.label, dotColor: tokens.dot };
|
||||
};
|
||||
|
||||
const getTracerStatusIndicator = (enabled: boolean) => ({
|
||||
label: enabled ? "Tracer On" : "Tracer Off",
|
||||
dotColor: enabled ? "bg-violet-500" : "bg-slate-500",
|
||||
});
|
||||
|
||||
const StatusBadgeIndicator: React.FC<
|
||||
Omit<StatusIndicatorProps, "appearance"> & { appearance?: "badge" }
|
||||
> = (props) => <StatusIndicator {...props} appearance="badge" />;
|
||||
|
||||
export {
|
||||
StatusIndicator,
|
||||
getJobStatusIndicator,
|
||||
getTracerStatusIndicator,
|
||||
StatusBadgeIndicator,
|
||||
};
|
||||
@ -24,9 +24,6 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Selected Projects
|
||||
</span>
|
||||
{tooManyProjects && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-500 font-medium">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useVersionCheck } from "../hooks/useVersionCheck";
|
||||
import { isNavActive, NAV_LINKS } from "./navigation";
|
||||
import { StatusBadgeIndicator } from "./StatusIndicator";
|
||||
|
||||
// ============================================================================
|
||||
// Page Header
|
||||
@ -165,47 +166,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Status Indicator (Pipeline running, Updating, etc.)
|
||||
// ============================================================================
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
label: string;
|
||||
variant?: "amber" | "emerald" | "sky";
|
||||
}
|
||||
|
||||
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
|
||||
label,
|
||||
variant = "amber",
|
||||
}) => {
|
||||
const colorMap = {
|
||||
amber: "border-amber-500/30 bg-amber-500/10 text-amber-200",
|
||||
emerald: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
sky: "border-sky-500/30 bg-sky-500/10 text-sky-200",
|
||||
};
|
||||
const dotMap = {
|
||||
amber: "bg-amber-400",
|
||||
emerald: "bg-emerald-400",
|
||||
sky: "bg-sky-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase tracking-wide",
|
||||
colorMap[variant],
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full animate-pulse",
|
||||
dotMap[variant],
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
export const StatusIndicator = StatusBadgeIndicator;
|
||||
|
||||
// ============================================================================
|
||||
// Split Layout (List + Detail panels)
|
||||
|
||||
55
orchestrator/src/client/hooks/useRxResumeConfigState.ts
Normal file
55
orchestrator/src/client/hooks/useRxResumeConfigState.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
getRxResumeBaseResumeSelection,
|
||||
getStoredRxResumeCredentialAvailability,
|
||||
type RxResumeSettingsLike,
|
||||
} from "@client/lib/rxresume-config";
|
||||
import type { RxResumeMode } from "@shared/types.js";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
const EMPTY_IDS_BY_MODE: Record<RxResumeMode, string | null> = {
|
||||
v4: null,
|
||||
v5: null,
|
||||
};
|
||||
|
||||
export function useRxResumeConfigState(settings: RxResumeSettingsLike) {
|
||||
const storedRxResume = useMemo(
|
||||
() => getStoredRxResumeCredentialAvailability(settings),
|
||||
[settings],
|
||||
);
|
||||
const [baseResumeIdsByMode, setBaseResumeIdsByMode] =
|
||||
useState<Record<RxResumeMode, string | null>>(EMPTY_IDS_BY_MODE);
|
||||
|
||||
const syncBaseResumeIdsForMode = useCallback(
|
||||
(mode: RxResumeMode) => {
|
||||
const { idsByMode, selectedId } = getRxResumeBaseResumeSelection(
|
||||
settings,
|
||||
mode,
|
||||
);
|
||||
setBaseResumeIdsByMode(idsByMode);
|
||||
return selectedId;
|
||||
},
|
||||
[settings],
|
||||
);
|
||||
|
||||
const getBaseResumeIdForMode = useCallback(
|
||||
(mode: RxResumeMode) => baseResumeIdsByMode[mode] ?? null,
|
||||
[baseResumeIdsByMode],
|
||||
);
|
||||
|
||||
const setBaseResumeIdForMode = useCallback(
|
||||
(mode: RxResumeMode, value: string | null) => {
|
||||
setBaseResumeIdsByMode((prev) =>
|
||||
prev[mode] === value ? prev : { ...prev, [mode]: value },
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
storedRxResume,
|
||||
baseResumeIdsByMode,
|
||||
syncBaseResumeIdsForMode,
|
||||
getBaseResumeIdForMode,
|
||||
setBaseResumeIdForMode,
|
||||
};
|
||||
}
|
||||
245
orchestrator/src/client/lib/rxresume-config.ts
Normal file
245
orchestrator/src/client/lib/rxresume-config.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type { RxResumeMode, ValidationResult } from "@shared/types.js";
|
||||
|
||||
export type RxResumeSettingsLike =
|
||||
| {
|
||||
rxresumeMode?: { value?: string | null } | null;
|
||||
rxresumeEmail?: string | null;
|
||||
rxresumePasswordHint?: string | null;
|
||||
rxresumeApiKeyHint?: string | null;
|
||||
rxresumeBaseResumeId?: string | null;
|
||||
rxresumeBaseResumeIdV4?: string | null;
|
||||
rxresumeBaseResumeIdV5?: string | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export const RXRESUME_MODES = ["v4", "v5"] as const;
|
||||
|
||||
export const RXRESUME_PRECHECK_MESSAGES = {
|
||||
"missing-v4-email-password": "Add v4 email and password, then test again.",
|
||||
"missing-v5-api-key": "Add a v5 API key, then test again.",
|
||||
} as const;
|
||||
|
||||
export const coerceRxResumeMode = (
|
||||
value: unknown,
|
||||
fallback: RxResumeMode = "v5",
|
||||
): RxResumeMode => (value === "v4" || value === "v5" ? value : fallback);
|
||||
|
||||
export const getStoredRxResumeCredentialAvailability = (
|
||||
settings: RxResumeSettingsLike,
|
||||
) => {
|
||||
const email = Boolean(settings?.rxresumeEmail?.trim());
|
||||
const password = Boolean(settings?.rxresumePasswordHint);
|
||||
const apiKey = Boolean(settings?.rxresumeApiKeyHint);
|
||||
return { email, password, apiKey, hasV4: email && password, hasV5: apiKey };
|
||||
};
|
||||
|
||||
export const getInitialRxResumeMode = (input: {
|
||||
savedMode: RxResumeMode | null | undefined;
|
||||
hasV4: boolean;
|
||||
hasV5: boolean;
|
||||
}): RxResumeMode =>
|
||||
coerceRxResumeMode(
|
||||
input.savedMode ?? (input.hasV4 && !input.hasV5 ? "v4" : "v5"),
|
||||
);
|
||||
|
||||
export const getRxResumeBaseResumeSelection = (
|
||||
settings: RxResumeSettingsLike,
|
||||
mode: RxResumeMode,
|
||||
) => {
|
||||
const idsByMode = {
|
||||
v4:
|
||||
settings?.rxresumeBaseResumeIdV4 ??
|
||||
(mode === "v4" ? (settings?.rxresumeBaseResumeId ?? null) : null),
|
||||
v5:
|
||||
settings?.rxresumeBaseResumeIdV5 ??
|
||||
(mode === "v5" ? (settings?.rxresumeBaseResumeId ?? null) : null),
|
||||
} satisfies Record<RxResumeMode, string | null>;
|
||||
return { idsByMode, selectedId: idsByMode[mode] ?? null };
|
||||
};
|
||||
|
||||
export const getRxResumeCredentialDrafts = (input: {
|
||||
rxresumeEmail?: string | null;
|
||||
rxresumePassword?: string | null;
|
||||
rxresumeApiKey?: string | null;
|
||||
}) => ({
|
||||
email: input.rxresumeEmail?.trim() ?? "",
|
||||
password: input.rxresumePassword?.trim() ?? "",
|
||||
apiKey: input.rxresumeApiKey?.trim() ?? "",
|
||||
});
|
||||
|
||||
export type RxResumeCredentialDrafts = ReturnType<
|
||||
typeof getRxResumeCredentialDrafts
|
||||
>;
|
||||
export type RxResumeStoredCredentialAvailability = Pick<
|
||||
ReturnType<typeof getStoredRxResumeCredentialAvailability>,
|
||||
"email" | "password" | "apiKey"
|
||||
>;
|
||||
|
||||
export const getRxResumeCredentialPrecheckFailure = (input: {
|
||||
mode: RxResumeMode;
|
||||
stored: RxResumeStoredCredentialAvailability;
|
||||
draft: RxResumeCredentialDrafts;
|
||||
}) => {
|
||||
const hasV4 =
|
||||
(input.stored.email || Boolean(input.draft.email)) &&
|
||||
(input.stored.password || Boolean(input.draft.password));
|
||||
const hasV5 = input.stored.apiKey || Boolean(input.draft.apiKey);
|
||||
if (input.mode === "v5" && !hasV5) return "missing-v5-api-key" as const;
|
||||
if (input.mode === "v4" && !hasV4)
|
||||
return "missing-v4-email-password" as const;
|
||||
return null;
|
||||
};
|
||||
|
||||
export type RxResumeCredentialPrecheckFailure = ReturnType<
|
||||
typeof getRxResumeCredentialPrecheckFailure
|
||||
>;
|
||||
|
||||
export const getRxResumeMissingCredentialLabels = (input: {
|
||||
mode: RxResumeMode;
|
||||
stored: RxResumeStoredCredentialAvailability;
|
||||
draft: RxResumeCredentialDrafts;
|
||||
}) =>
|
||||
input.mode === "v5"
|
||||
? input.stored.apiKey || input.draft.apiKey
|
||||
? []
|
||||
: ["RxResume v5 API key"]
|
||||
: [
|
||||
...(input.stored.email || input.draft.email ? [] : ["RxResume email"]),
|
||||
...(input.stored.password || input.draft.password
|
||||
? []
|
||||
: ["RxResume password"]),
|
||||
];
|
||||
|
||||
export const toRxResumeValidationPayload = (
|
||||
draft: RxResumeCredentialDrafts,
|
||||
) => ({
|
||||
email: draft.email || undefined,
|
||||
password: draft.password || undefined,
|
||||
apiKey: draft.apiKey || undefined,
|
||||
});
|
||||
|
||||
export const buildRxResumeSettingsUpdate = (
|
||||
mode: RxResumeMode,
|
||||
draft: RxResumeCredentialDrafts,
|
||||
): Partial<UpdateSettingsInput> => {
|
||||
const update: Partial<UpdateSettingsInput> = {
|
||||
rxresumeMode: mode,
|
||||
};
|
||||
if (draft.email) update.rxresumeEmail = draft.email;
|
||||
if (draft.password) update.rxresumePassword = draft.password;
|
||||
if (draft.apiKey) update.rxresumeApiKey = draft.apiKey;
|
||||
return update;
|
||||
};
|
||||
|
||||
type ValidateAndMaybePersistRxResumeModeInput<TSettings> = {
|
||||
mode: RxResumeMode;
|
||||
stored: RxResumeStoredCredentialAvailability;
|
||||
draft: RxResumeCredentialDrafts;
|
||||
validate: (
|
||||
payload: { mode: RxResumeMode } & ReturnType<
|
||||
typeof toRxResumeValidationPayload
|
||||
>,
|
||||
) => Promise<ValidationResult>;
|
||||
persist?: (update: Partial<UpdateSettingsInput>) => Promise<TSettings>;
|
||||
persistOnSuccess?: boolean;
|
||||
getPrecheckMessage?: (
|
||||
failure: Exclude<RxResumeCredentialPrecheckFailure, null>,
|
||||
) => string;
|
||||
getValidationErrorMessage?: (error: unknown, mode: RxResumeMode) => string;
|
||||
getPersistErrorMessage?: (error: unknown, mode: RxResumeMode) => string;
|
||||
};
|
||||
|
||||
export type ValidateAndMaybePersistRxResumeModeResult<TSettings> = {
|
||||
validation: ValidationResult;
|
||||
precheckFailure: RxResumeCredentialPrecheckFailure;
|
||||
updatedSettings: TSettings | null;
|
||||
};
|
||||
|
||||
export const validateAndMaybePersistRxResumeMode = async <TSettings>(
|
||||
input: ValidateAndMaybePersistRxResumeModeInput<TSettings>,
|
||||
): Promise<ValidateAndMaybePersistRxResumeModeResult<TSettings>> => {
|
||||
const {
|
||||
mode,
|
||||
stored,
|
||||
draft,
|
||||
validate,
|
||||
persist,
|
||||
persistOnSuccess = false,
|
||||
getPrecheckMessage = (failure) => RXRESUME_PRECHECK_MESSAGES[failure],
|
||||
getValidationErrorMessage = (error) =>
|
||||
error instanceof Error ? error.message : "RxResume validation failed",
|
||||
getPersistErrorMessage = (error) =>
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to save RxResume settings",
|
||||
} = input;
|
||||
|
||||
const precheckFailure = getRxResumeCredentialPrecheckFailure({
|
||||
mode,
|
||||
stored,
|
||||
draft,
|
||||
});
|
||||
if (precheckFailure) {
|
||||
return {
|
||||
validation: {
|
||||
valid: false,
|
||||
message: getPrecheckMessage(precheckFailure),
|
||||
},
|
||||
precheckFailure,
|
||||
updatedSettings: null,
|
||||
};
|
||||
}
|
||||
|
||||
let validation: ValidationResult;
|
||||
try {
|
||||
validation = await validate({
|
||||
mode,
|
||||
...toRxResumeValidationPayload(draft),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
validation: {
|
||||
valid: false,
|
||||
message: getValidationErrorMessage(error, mode),
|
||||
},
|
||||
precheckFailure: null,
|
||||
updatedSettings: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!validation.valid || !persistOnSuccess || !persist) {
|
||||
return {
|
||||
validation: {
|
||||
valid: validation.valid,
|
||||
message: validation.valid ? null : (validation.message ?? null),
|
||||
},
|
||||
precheckFailure: null,
|
||||
updatedSettings: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedSettings = await persist(
|
||||
buildRxResumeSettingsUpdate(mode, draft),
|
||||
);
|
||||
return {
|
||||
validation: {
|
||||
valid: true,
|
||||
message: null,
|
||||
},
|
||||
precheckFailure: null,
|
||||
updatedSettings,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
validation: {
|
||||
valid: false,
|
||||
message: getPersistErrorMessage(error, mode),
|
||||
},
|
||||
precheckFailure: null,
|
||||
updatedSettings: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -14,6 +14,7 @@ const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
vi.mock("../api", () => ({
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
validateRxresume: vi.fn(),
|
||||
clearDatabase: vi.fn(),
|
||||
deleteJobsByStatus: vi.fn(),
|
||||
getTracerReadiness: vi.fn(),
|
||||
@ -57,6 +58,11 @@ const renderPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const openModelSection = async () => {
|
||||
const modelTrigger = await screen.findByRole("button", { name: /^model$/i });
|
||||
fireEvent.click(modelTrigger);
|
||||
};
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@ -70,6 +76,10 @@ describe("SettingsPage", () => {
|
||||
lastSuccessAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
vi.mocked(api.validateRxresume).mockResolvedValue({
|
||||
valid: false,
|
||||
message: "Missing credentials",
|
||||
});
|
||||
});
|
||||
|
||||
it("saves trimmed model overrides", async () => {
|
||||
@ -84,6 +94,7 @@ describe("SettingsPage", () => {
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await openModelSection();
|
||||
|
||||
const modelInput = screen.getByLabelText(/default model/i);
|
||||
await waitFor(() => expect(modelInput).toBeEnabled());
|
||||
@ -107,6 +118,7 @@ describe("SettingsPage", () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
|
||||
renderPage();
|
||||
await openModelSection();
|
||||
|
||||
const modelInput = screen.getByLabelText(/default model/i);
|
||||
await waitFor(() => expect(modelInput).toBeEnabled());
|
||||
@ -166,6 +178,7 @@ describe("SettingsPage", () => {
|
||||
renderPage();
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
await openModelSection();
|
||||
|
||||
const modelInput = screen.getByLabelText(/default model/i);
|
||||
// Wait for the query to resolve and input to be enabled
|
||||
@ -207,7 +220,40 @@ describe("SettingsPage", () => {
|
||||
/show visa sponsor information/i,
|
||||
);
|
||||
fireEvent.click(sponsorCheckbox);
|
||||
expect(saveButton).toBeEnabled();
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
});
|
||||
|
||||
it("allows saving when both Reactive Resume v4 and v5 credentials are present", async () => {
|
||||
const settingsWithBothRxResumeAuth = createAppSettings({
|
||||
rxresumeEmail: "resume@example.com",
|
||||
rxresumePasswordHint: "pass",
|
||||
rxresumeApiKeyHint: "api_",
|
||||
});
|
||||
vi.mocked(api.getSettings).mockResolvedValue(settingsWithBothRxResumeAuth);
|
||||
vi.mocked(api.updateSettings).mockResolvedValue(
|
||||
settingsWithBothRxResumeAuth,
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
const displayTrigger = await screen.findByRole("button", {
|
||||
name: /display settings/i,
|
||||
});
|
||||
fireEvent.click(displayTrigger);
|
||||
const sponsorCheckbox = screen.getByLabelText(
|
||||
/show visa sponsor information/i,
|
||||
);
|
||||
fireEvent.click(sponsorCheckbox);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
|
||||
expect(toast.error).not.toHaveBeenCalledWith(
|
||||
"Choose one Reactive Resume auth method",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("enables save button when basic auth toggle is changed", async () => {
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
import * as api from "@client/api";
|
||||
import { PageHeader } from "@client/components/layout";
|
||||
import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation";
|
||||
import { useRxResumeConfigState } from "@client/hooks/useRxResumeConfigState";
|
||||
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
|
||||
import {
|
||||
coerceRxResumeMode,
|
||||
getRxResumeCredentialDrafts,
|
||||
RXRESUME_MODES,
|
||||
RXRESUME_PRECHECK_MESSAGES,
|
||||
validateAndMaybePersistRxResumeMode,
|
||||
} from "@client/lib/rxresume-config";
|
||||
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
||||
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
|
||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
||||
@ -28,12 +36,18 @@ import type {
|
||||
JobStatus,
|
||||
ResumeProjectCatalogItem,
|
||||
ResumeProjectsSettings,
|
||||
RxResumeMode,
|
||||
} from "@shared/types.js";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Settings } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
||||
import {
|
||||
FormProvider,
|
||||
type Resolver,
|
||||
useForm,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||
import { queryKeys } from "@/client/lib/queryKeys";
|
||||
@ -51,6 +65,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
pipelineWebhookUrl: "",
|
||||
jobCompleteWebhookUrl: "",
|
||||
resumeProjects: null,
|
||||
rxresumeMode: "v5",
|
||||
rxresumeBaseResumeId: null,
|
||||
showSponsorInfo: null,
|
||||
chatStyleTone: "",
|
||||
@ -59,6 +74,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
chatStyleDoNotUse: "",
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
basicAuthUser: "",
|
||||
basicAuthPassword: "",
|
||||
ukvisajobsEmail: "",
|
||||
@ -77,6 +93,16 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
};
|
||||
|
||||
type LlmProviderValue = LlmProviderId | null;
|
||||
type RxResumeValidationBadgeState = {
|
||||
checked: boolean;
|
||||
valid: boolean;
|
||||
message: string | null;
|
||||
};
|
||||
const EMPTY_RXRESUME_VALIDATION_BADGE_STATE: RxResumeValidationBadgeState = {
|
||||
checked: false,
|
||||
valid: false,
|
||||
message: null,
|
||||
};
|
||||
|
||||
const normalizeLlmProviderValue = (
|
||||
value: string | null | undefined,
|
||||
@ -93,6 +119,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
pipelineWebhookUrl: null,
|
||||
jobCompleteWebhookUrl: null,
|
||||
resumeProjects: null,
|
||||
rxresumeMode: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
showSponsorInfo: null,
|
||||
chatStyleTone: null,
|
||||
@ -101,6 +128,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
chatStyleDoNotUse: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumePassword: null,
|
||||
rxresumeApiKey: null,
|
||||
basicAuthUser: null,
|
||||
basicAuthPassword: null,
|
||||
ukvisajobsEmail: null,
|
||||
@ -130,6 +158,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
pipelineWebhookUrl: data.pipelineWebhookUrl.override ?? "",
|
||||
jobCompleteWebhookUrl: data.jobCompleteWebhookUrl.override ?? "",
|
||||
resumeProjects: data.resumeProjects.override,
|
||||
rxresumeMode: data.rxresumeMode.override ?? data.rxresumeMode.value,
|
||||
rxresumeBaseResumeId: data.rxresumeBaseResumeId,
|
||||
showSponsorInfo: data.showSponsorInfo.override,
|
||||
chatStyleTone: data.chatStyleTone.override ?? "",
|
||||
@ -138,6 +167,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
chatStyleDoNotUse: data.chatStyleDoNotUse.override ?? "",
|
||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
basicAuthUser: data.basicAuthUser ?? "",
|
||||
basicAuthPassword: "",
|
||||
ukvisajobsEmail: data.ukvisajobsEmail ?? "",
|
||||
@ -312,6 +342,13 @@ export const SettingsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [rxresumeValidationStatuses, setRxresumeValidationStatuses] = useState<{
|
||||
v4: RxResumeValidationBadgeState;
|
||||
v5: RxResumeValidationBadgeState;
|
||||
}>({
|
||||
v4: EMPTY_RXRESUME_VALIDATION_BADGE_STATE,
|
||||
v5: EMPTY_RXRESUME_VALIDATION_BADGE_STATE,
|
||||
});
|
||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
|
||||
"discovered",
|
||||
]);
|
||||
@ -348,9 +385,15 @@ export const SettingsPage: React.FC = () => {
|
||||
setError,
|
||||
setValue,
|
||||
getValues,
|
||||
watch,
|
||||
control,
|
||||
formState: { isDirty, errors, isValid, dirtyFields },
|
||||
} = methods;
|
||||
const {
|
||||
storedRxResume,
|
||||
getBaseResumeIdForMode,
|
||||
setBaseResumeIdForMode,
|
||||
syncBaseResumeIdsForMode,
|
||||
} = useRxResumeConfigState(settings);
|
||||
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: queryKeys.settings.current(),
|
||||
@ -367,8 +410,17 @@ export const SettingsPage: React.FC = () => {
|
||||
const isLoadingBackups = backupsQuery.isLoading;
|
||||
useQueryErrorToast(backupsQuery.error, "Failed to load backups");
|
||||
|
||||
const rxresumeMode = (settings?.rxresumeMode?.value ?? "v5") as RxResumeMode;
|
||||
const selectedRxresumeMode = (useWatch({
|
||||
control,
|
||||
name: "rxresumeMode",
|
||||
}) ?? rxresumeMode) as RxResumeMode;
|
||||
const resumeProjectsValue = useWatch({
|
||||
control,
|
||||
name: "resumeProjects",
|
||||
});
|
||||
const hasRxResumeAccess = Boolean(
|
||||
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint,
|
||||
rxresumeValidationStatuses[selectedRxresumeMode].valid,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -381,11 +433,12 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
const storedId = settings.rxresumeBaseResumeId ?? null;
|
||||
const effectiveMode = coerceRxResumeMode(settings.rxresumeMode?.value);
|
||||
const storedId = syncBaseResumeIdsForMode(effectiveMode);
|
||||
setRxResumeBaseResumeIdDraft(storedId);
|
||||
setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false });
|
||||
setRxResumeProjectsOverride(null);
|
||||
}, [settings, setValue]);
|
||||
}, [settings, setValue, syncBaseResumeIdsForMode]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@ -407,7 +460,11 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
setIsFetchingRxResumeProjects(true);
|
||||
api
|
||||
.getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal)
|
||||
.getRxResumeProjects(
|
||||
rxResumeBaseResumeIdDraft,
|
||||
controller.signal,
|
||||
selectedRxresumeMode,
|
||||
)
|
||||
.then((projects) => {
|
||||
if (!isMounted) return;
|
||||
setRxResumeProjectsOverride(projects);
|
||||
@ -437,7 +494,13 @@ export const SettingsPage: React.FC = () => {
|
||||
isMounted = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue]);
|
||||
}, [
|
||||
rxResumeBaseResumeIdDraft,
|
||||
hasRxResumeAccess,
|
||||
selectedRxresumeMode,
|
||||
getValues,
|
||||
setValue,
|
||||
]);
|
||||
|
||||
const derived = getDerivedSettings(settings);
|
||||
const {
|
||||
@ -511,12 +574,93 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [refreshReadiness]);
|
||||
|
||||
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
|
||||
const validateRxresumeMode = useCallback(
|
||||
async (
|
||||
mode: RxResumeMode,
|
||||
options?: { silent?: boolean; persistOnSuccess?: boolean },
|
||||
) => {
|
||||
const { silent = false, persistOnSuccess = true } = options ?? {};
|
||||
const notify = !silent;
|
||||
const values = getValues();
|
||||
const draftCredentials = getRxResumeCredentialDrafts(values);
|
||||
const result = await validateAndMaybePersistRxResumeMode({
|
||||
mode,
|
||||
stored: storedRxResume,
|
||||
draft: draftCredentials,
|
||||
validate: api.validateRxresume,
|
||||
persist: api.updateSettings,
|
||||
persistOnSuccess,
|
||||
getPrecheckMessage: (failure) => RXRESUME_PRECHECK_MESSAGES[failure],
|
||||
getValidationErrorMessage: (error) =>
|
||||
error instanceof Error ? error.message : "RxResume validation failed",
|
||||
getPersistErrorMessage: (error) =>
|
||||
error instanceof Error ? error.message : "RxResume validation failed",
|
||||
});
|
||||
|
||||
setRxresumeValidationStatuses((current) => ({
|
||||
...current,
|
||||
[mode]: {
|
||||
checked: true,
|
||||
valid: result.validation.valid,
|
||||
message: result.validation.valid
|
||||
? null
|
||||
: (result.validation.message ?? null),
|
||||
},
|
||||
}));
|
||||
|
||||
if (result.updatedSettings) {
|
||||
setSettings(result.updatedSettings);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.settings.current(),
|
||||
result.updatedSettings,
|
||||
);
|
||||
if (notify) {
|
||||
toast.success(`Reactive Resume ${mode} validation passed`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!notify || result.validation.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.precheckFailure) {
|
||||
toast.info(
|
||||
result.validation.message ??
|
||||
RXRESUME_PRECHECK_MESSAGES[result.precheckFailure],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(
|
||||
result.validation.message ||
|
||||
`Reactive Resume ${mode} validation failed`,
|
||||
);
|
||||
},
|
||||
[getValues, queryClient, storedRxResume],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
|
||||
const modesToCheck = RXRESUME_MODES.filter(
|
||||
(mode) => !rxresumeValidationStatuses[mode].checked,
|
||||
);
|
||||
if (modesToCheck.length === 0) return;
|
||||
|
||||
void Promise.all(
|
||||
modesToCheck.map((mode) =>
|
||||
validateRxresumeMode(mode, { silent: true, persistOnSuccess: false }),
|
||||
),
|
||||
);
|
||||
}, [settings, rxresumeValidationStatuses, validateRxresumeMode]);
|
||||
|
||||
const effectiveProfileProjects =
|
||||
rxResumeProjectsOverride ??
|
||||
(selectedRxresumeMode === rxresumeMode ? profileProjects : []);
|
||||
const effectiveMaxProjectsTotal = effectiveProfileProjects.length;
|
||||
|
||||
const watchedValues = watch();
|
||||
const lockedCount =
|
||||
watchedValues.resumeProjects?.lockedProjectIds.length ?? 0;
|
||||
const lockedCount = resumeProjectsValue?.lockedProjectIds.length ?? 0;
|
||||
|
||||
const canSave = isDirty && isValid;
|
||||
|
||||
@ -594,6 +738,11 @@ export const SettingsPage: React.FC = () => {
|
||||
if (value !== undefined) envPayload.rxresumePassword = value;
|
||||
}
|
||||
|
||||
if (dirtyFields.rxresumeApiKey) {
|
||||
const value = normalizePrivateInput(data.rxresumeApiKey);
|
||||
if (value !== undefined) envPayload.rxresumeApiKey = value;
|
||||
}
|
||||
|
||||
if (dirtyFields.ukvisajobsPassword) {
|
||||
const value = normalizePrivateInput(data.ukvisajobsPassword);
|
||||
if (value !== undefined) envPayload.ukvisajobsPassword = value;
|
||||
@ -617,6 +766,7 @@ export const SettingsPage: React.FC = () => {
|
||||
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
||||
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
||||
resumeProjects: resumeProjectsOverride,
|
||||
rxresumeMode: data.rxresumeMode ?? "v5",
|
||||
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
|
||||
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
||||
chatStyleTone: normalizeString(data.chatStyleTone),
|
||||
@ -781,11 +931,7 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
|
||||
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full space-y-4"
|
||||
defaultValue={["model", "feature", "webhooks", "chat"]}
|
||||
>
|
||||
<Accordion type="multiple" className="w-full space-y-4">
|
||||
<ModelSettingsSection
|
||||
values={model}
|
||||
isLoading={isLoading}
|
||||
@ -800,11 +946,22 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
<ReactiveResumeSection
|
||||
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
|
||||
onRxresumeModeChange={(mode) => {
|
||||
const nextId = getBaseResumeIdForMode(mode);
|
||||
setRxResumeBaseResumeIdDraft(nextId);
|
||||
setValue("rxresumeBaseResumeId", nextId, { shouldDirty: true });
|
||||
setRxResumeProjectsOverride(null);
|
||||
}}
|
||||
setRxResumeBaseResumeIdDraft={(value) => {
|
||||
const mode = (getValues("rxresumeMode") ??
|
||||
rxresumeMode) as RxResumeMode;
|
||||
setBaseResumeIdForMode(mode, value);
|
||||
setRxResumeBaseResumeIdDraft(value);
|
||||
setValue("rxresumeBaseResumeId", value, { shouldDirty: true });
|
||||
}}
|
||||
hasRxResumeAccess={hasRxResumeAccess}
|
||||
rxresumeMode={rxresumeMode}
|
||||
validationStatuses={rxresumeValidationStatuses}
|
||||
profileProjects={effectiveProfileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={effectiveMaxProjectsTotal}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import * as api from "@client/api";
|
||||
import type { RxResumeMode } from "@shared/types.js";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@ -15,6 +16,7 @@ type BaseResumeSelectionProps = {
|
||||
value: string | null;
|
||||
onValueChange: (value: string | null) => void;
|
||||
hasRxResumeAccess: boolean;
|
||||
rxresumeMode?: RxResumeMode;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
@ -23,6 +25,7 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
hasRxResumeAccess,
|
||||
rxresumeMode,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
@ -31,12 +34,16 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
|
||||
const fetchResumes = useCallback(async () => {
|
||||
if (!hasRxResumeAccess) return;
|
||||
if (!hasRxResumeAccess) {
|
||||
setResumes([]);
|
||||
setFetchError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetchingResumes(true);
|
||||
setFetchError(null);
|
||||
try {
|
||||
const data = await api.getRxResumes();
|
||||
const data = await api.getRxResumes(rxresumeMode);
|
||||
setResumes(data);
|
||||
|
||||
// Preselect if only one option is available and no value is currently set
|
||||
@ -44,13 +51,14 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
|
||||
onValueChange(data[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
setResumes([]);
|
||||
setFetchError(
|
||||
error instanceof Error ? error.message : "Failed to fetch resumes",
|
||||
);
|
||||
} finally {
|
||||
setIsFetchingResumes(false);
|
||||
}
|
||||
}, [hasRxResumeAccess, onValueChange, value]);
|
||||
}, [hasRxResumeAccess, onValueChange, rxresumeMode, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRxResumeAccess) {
|
||||
@ -58,6 +66,13 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
|
||||
}
|
||||
}, [hasRxResumeAccess, fetchResumes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRxResumeAccess) {
|
||||
setResumes([]);
|
||||
setFetchError(null);
|
||||
}
|
||||
}, [hasRxResumeAccess]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@ -52,14 +52,12 @@ describe("EnvironmentSettingsSection", () => {
|
||||
it("renders values grouped logically and masks private secrets with hints", () => {
|
||||
render(<EnvironmentSettingsHarness />);
|
||||
|
||||
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("adzuna-id")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/adzu\*{8}/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Not set")).toBeInTheDocument();
|
||||
|
||||
// Basic Auth
|
||||
expect(screen.getByLabelText("Enable basic authentication")).toBeChecked();
|
||||
@ -68,5 +66,6 @@ describe("EnvironmentSettingsSection", () => {
|
||||
// Sections
|
||||
expect(screen.getByText("Service Accounts")).toBeInTheDocument();
|
||||
expect(screen.getByText("Security")).toBeInTheDocument();
|
||||
expect(screen.queryByText("RxResume")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -44,28 +44,6 @@ export const EnvironmentSettingsSection: React.FC<
|
||||
Service Accounts
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold">RxResume</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="Email"
|
||||
inputProps={register("rxresumeEmail")}
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.rxresumeEmail?.message as string | undefined}
|
||||
/>
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={register("rxresumePassword")}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.rxresumePassword?.message as string | undefined}
|
||||
current={formatSecretHint(privateValues.rxresumePasswordHint)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold">UKVisaJobs</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
|
||||
@ -1,37 +1,30 @@
|
||||
import { ReactiveResumeConfigPanel } from "@client/components/ReactiveResumeConfigPanel";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type { ResumeProjectCatalogItem } from "@shared/types.js";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types.js";
|
||||
import type React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import {
|
||||
type Path,
|
||||
type PathValue,
|
||||
useFormContext,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { clampInt } from "@/lib/utils";
|
||||
import {
|
||||
toggleAiSelectable,
|
||||
toggleMustInclude,
|
||||
} from "../resume-projects-state";
|
||||
import { BaseResumeSelection } from "./BaseResumeSelection";
|
||||
|
||||
type ReactiveResumeSectionProps = {
|
||||
rxResumeBaseResumeIdDraft: string | null;
|
||||
setRxResumeBaseResumeIdDraft: (value: string | null) => void;
|
||||
// True when v4 credentials or v5 API key are configured.
|
||||
hasRxResumeAccess: boolean;
|
||||
rxresumeMode: RxResumeMode;
|
||||
onRxresumeModeChange?: (mode: RxResumeMode) => void;
|
||||
validationStatuses?: {
|
||||
v4: { checked: boolean; valid: boolean; message?: string | null };
|
||||
v5: { checked: boolean; valid: boolean; message?: string | null };
|
||||
};
|
||||
profileProjects: ResumeProjectCatalogItem[];
|
||||
lockedCount: number;
|
||||
maxProjectsTotal: number;
|
||||
@ -44,6 +37,9 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
rxResumeBaseResumeIdDraft,
|
||||
setRxResumeBaseResumeIdDraft,
|
||||
hasRxResumeAccess,
|
||||
rxresumeMode,
|
||||
onRxresumeModeChange,
|
||||
validationStatuses,
|
||||
profileProjects,
|
||||
lockedCount,
|
||||
maxProjectsTotal,
|
||||
@ -53,8 +49,25 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
}) => {
|
||||
const {
|
||||
control,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useFormContext<UpdateSettingsInput>();
|
||||
const selectedMode =
|
||||
useWatch({ control, name: "rxresumeMode" }) ?? rxresumeMode ?? "v5";
|
||||
const rxresumeApiKeyValue =
|
||||
useWatch({ control, name: "rxresumeApiKey" }) ?? "";
|
||||
const rxresumeEmailValue = useWatch({ control, name: "rxresumeEmail" }) ?? "";
|
||||
const rxresumePasswordValue =
|
||||
useWatch({ control, name: "rxresumePassword" }) ?? "";
|
||||
const resumeProjectsValue = useWatch({ control, name: "resumeProjects" });
|
||||
const setDirtyTouchedValue = <TField extends Path<UpdateSettingsInput>>(
|
||||
field: TField,
|
||||
value: PathValue<UpdateSettingsInput, TField>,
|
||||
) =>
|
||||
setValue(field, value, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
|
||||
@ -62,196 +75,48 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
<span className="text-base font-semibold">Reactive Resume</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
{!hasRxResumeAccess ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>RxResume Access Missing</AlertTitle>
|
||||
<AlertDescription>
|
||||
Configure RxResume credentials in settings (email + password) or
|
||||
set <code>RXRESUME_API_KEY</code> to enable access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Alert className="bg-green-50 border-green-200 dark:bg-green-900/10 dark:border-green-900/20">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<AlertTitle className="text-green-800 dark:text-green-300">
|
||||
RxResume Access Ready
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-green-700 dark:text-green-400">
|
||||
Reactive Resume access is active.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<BaseResumeSelection
|
||||
value={rxResumeBaseResumeIdDraft}
|
||||
onValueChange={setRxResumeBaseResumeIdDraft}
|
||||
hasRxResumeAccess={hasRxResumeAccess}
|
||||
<ReactiveResumeConfigPanel
|
||||
mode={selectedMode}
|
||||
onModeChange={(mode) => {
|
||||
onRxresumeModeChange?.(mode);
|
||||
setDirtyTouchedValue("rxresumeMode", mode);
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
{!rxResumeBaseResumeIdDraft ? (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
Choose a PDF to configure resume projects.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
Max projects to choose
|
||||
</div>
|
||||
<Controller
|
||||
name="resumeProjects"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={field.value?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!field.value) return;
|
||||
const next = Number(event.target.value);
|
||||
const clamped = clampInt(
|
||||
next,
|
||||
hasRxResumeAccess={hasRxResumeAccess}
|
||||
showValidationStatus={Boolean(validationStatuses)}
|
||||
validationStatuses={validationStatuses}
|
||||
v5={{
|
||||
apiKey: rxresumeApiKeyValue,
|
||||
onApiKeyChange: (value) =>
|
||||
setDirtyTouchedValue("rxresumeApiKey", value),
|
||||
error: errors.rxresumeApiKey?.message as string | undefined,
|
||||
}}
|
||||
v4={{
|
||||
email: rxresumeEmailValue,
|
||||
onEmailChange: (value) =>
|
||||
setDirtyTouchedValue("rxresumeEmail", value),
|
||||
emailError: errors.rxresumeEmail?.message as string | undefined,
|
||||
password: rxresumePasswordValue,
|
||||
onPasswordChange: (value) =>
|
||||
setDirtyTouchedValue("rxresumePassword", value),
|
||||
passwordError: errors.rxresumePassword?.message as
|
||||
| string
|
||||
| undefined,
|
||||
}}
|
||||
projectSelection={{
|
||||
baseResumeId: rxResumeBaseResumeIdDraft,
|
||||
onBaseResumeIdChange: setRxResumeBaseResumeIdDraft,
|
||||
projects: profileProjects,
|
||||
value: resumeProjectsValue,
|
||||
onChange: (next) => setDirtyTouchedValue("resumeProjects", next),
|
||||
lockedCount,
|
||||
maxProjectsTotal,
|
||||
);
|
||||
field.onChange({
|
||||
...field.value,
|
||||
maxProjects: clamped,
|
||||
});
|
||||
}}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isSaving ||
|
||||
isProjectsLoading ||
|
||||
!field.value
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.resumeProjects?.maxProjects && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.resumeProjects.maxProjects.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="resumeProjects"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||
Project
|
||||
</TableHead>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||
Visible in template
|
||||
</TableHead>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||
Must Include
|
||||
</TableHead>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||
AI selectable
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(
|
||||
field.value?.lockedProjectIds.includes(
|
||||
project.id,
|
||||
),
|
||||
);
|
||||
const aiSelectable = Boolean(
|
||||
field.value?.aiSelectableProjectIds.includes(
|
||||
project.id,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">
|
||||
{project.name || project.id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[project.description, project.date]
|
||||
.filter(Boolean)
|
||||
.join(" - ")}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{project.isVisibleInBase ? "Yes" : "No"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isSaving ||
|
||||
isProjectsLoading ||
|
||||
!field.value
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!field.value) return;
|
||||
field.onChange(
|
||||
toggleMustInclude({
|
||||
settings: field.value,
|
||||
projectId: project.id,
|
||||
checked: checked === true,
|
||||
maxProjectsTotal,
|
||||
}),
|
||||
);
|
||||
isProjectsLoading,
|
||||
disabled: isLoading || isSaving,
|
||||
maxProjectsError:
|
||||
errors.resumeProjects?.maxProjects?.message?.toString(),
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={
|
||||
locked ||
|
||||
isLoading ||
|
||||
isSaving ||
|
||||
isProjectsLoading ||
|
||||
!field.value
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!field.value) return;
|
||||
field.onChange(
|
||||
toggleAiSelectable({
|
||||
settings: field.value,
|
||||
projectId: project.id,
|
||||
checked: checked === true,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Server } from "node:http";
|
||||
import { RxResumeClient } from "@server/services/rxresume-client";
|
||||
import { RxResumeClient } from "@server/services/rxresume/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startServer, stopServer } from "./test-utils";
|
||||
|
||||
@ -224,7 +224,7 @@ describe.sequential("Onboarding API routes", () => {
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain("missing");
|
||||
expect(body.data.message).toContain("not configured");
|
||||
});
|
||||
|
||||
it("returns invalid when only email is provided", async () => {
|
||||
@ -237,7 +237,7 @@ describe.sequential("Onboarding API routes", () => {
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain("missing");
|
||||
expect(body.data.message).toContain("not configured");
|
||||
});
|
||||
|
||||
it("returns invalid when only password is provided", async () => {
|
||||
@ -250,7 +250,7 @@ describe.sequential("Onboarding API routes", () => {
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain("missing");
|
||||
expect(body.data.message).toContain("not configured");
|
||||
});
|
||||
|
||||
it("validates invalid credentials against RxResume", async () => {
|
||||
@ -275,6 +275,37 @@ describe.sequential("Onboarding API routes", () => {
|
||||
expect(body.data.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("validates v5 API key mode against Reactive Resume OpenAPI", async () => {
|
||||
global.fetch = vi.fn((input, init) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
if (url.includes("/api/openapi/resumes")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => "application/json" },
|
||||
json: async () => [],
|
||||
} as unknown as Response);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "v5",
|
||||
apiKey: "rr-v5-test-key",
|
||||
baseUrl: "http://localhost:3000",
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(true);
|
||||
expect(body.data.message).toBeNull();
|
||||
});
|
||||
|
||||
it("handles whitespace-only credentials", async () => {
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
||||
method: "POST",
|
||||
@ -285,7 +316,7 @@ describe.sequential("Onboarding API routes", () => {
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain("missing");
|
||||
expect(body.data.message).toContain("not configured");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import { okWithMeta } from "@infra/http";
|
||||
import { ok, okWithMeta } from "@infra/http";
|
||||
import { logger } from "@infra/logger";
|
||||
import { isDemoMode } from "@server/config/demo";
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { LlmService } from "@server/services/llm/service";
|
||||
import { RxResumeClient } from "@server/services/rxresume-client";
|
||||
import {
|
||||
getResume,
|
||||
RxResumeCredentialsError,
|
||||
} from "@server/services/rxresume-v4";
|
||||
import { resumeDataSchema } from "@shared/rxresume-schema";
|
||||
RxResumeAuthConfigError,
|
||||
validateResumeSchema,
|
||||
validateCredentials as validateRxResumeCredentials,
|
||||
} from "@server/services/rxresume";
|
||||
import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
|
||||
import { type Request, type Response, Router } from "express";
|
||||
|
||||
export const onboardingRouter = Router();
|
||||
@ -54,12 +55,13 @@ async function validateLlm(options: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a base resume is configured and accessible via RxResume v4 API.
|
||||
* Validate that a base resume is configured and accessible via Reactive Resume.
|
||||
*/
|
||||
async function validateResumeConfig(): Promise<ValidationResponse> {
|
||||
try {
|
||||
// Check if rxresumeBaseResumeId is configured
|
||||
const rxresumeBaseResumeId = await getSetting("rxresumeBaseResumeId");
|
||||
const { resumeId: rxresumeBaseResumeId } =
|
||||
await getConfiguredRxResumeBaseResumeId();
|
||||
|
||||
if (!rxresumeBaseResumeId) {
|
||||
return {
|
||||
@ -80,23 +82,17 @@ async function validateResumeConfig(): Promise<ValidationResponse> {
|
||||
};
|
||||
}
|
||||
|
||||
// Validate against schema
|
||||
const result = resumeDataSchema.safeParse(resume.data);
|
||||
if (!result.success) {
|
||||
const issue = result.error.issues[0];
|
||||
const path = issue?.path?.join(".") || "";
|
||||
const baseMessage =
|
||||
issue?.message ?? "Resume does not match the expected schema.";
|
||||
const details = path ? `Field "${path}": ${baseMessage}` : baseMessage;
|
||||
return { valid: false, message: details };
|
||||
const validated = await validateResumeSchema(resume.data);
|
||||
if (!validated.ok) {
|
||||
return { valid: false, message: validated.message };
|
||||
}
|
||||
|
||||
return { valid: true, message: null };
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
if (error instanceof RxResumeAuthConfigError) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "RxResume credentials not configured.",
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
const message =
|
||||
@ -112,29 +108,32 @@ async function validateResumeConfig(): Promise<ValidationResponse> {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateRxresume(
|
||||
email?: string | null,
|
||||
password?: string | null,
|
||||
): Promise<ValidationResponse> {
|
||||
const rxEmail = email?.trim() || process.env.RXRESUME_EMAIL || "";
|
||||
const rxPassword = password?.trim() || process.env.RXRESUME_PASSWORD || "";
|
||||
const rxUrl = process.env.RXRESUME_URL || "https://v4.rxresu.me";
|
||||
async function validateRxresume(options?: {
|
||||
mode?: string | null;
|
||||
email?: string | null;
|
||||
password?: string | null;
|
||||
apiKey?: string | null;
|
||||
baseUrl?: string | null;
|
||||
}): Promise<ValidationResponse> {
|
||||
const rawMode = options?.mode?.trim();
|
||||
const mode = rawMode === "v4" || rawMode === "v5" ? rawMode : undefined;
|
||||
|
||||
if (!rxEmail || !rxPassword) {
|
||||
return { valid: false, message: "RxResume credentials are missing." };
|
||||
}
|
||||
const result = await validateRxResumeCredentials({
|
||||
mode,
|
||||
v4: {
|
||||
email: options?.email ?? undefined,
|
||||
password: options?.password ?? undefined,
|
||||
baseUrl: options?.baseUrl ?? undefined,
|
||||
},
|
||||
v5: {
|
||||
apiKey: options?.apiKey ?? undefined,
|
||||
baseUrl: options?.baseUrl ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
rxEmail,
|
||||
rxPassword,
|
||||
rxUrl,
|
||||
);
|
||||
if (result.ok) return { valid: true, message: null };
|
||||
|
||||
if (result.ok) {
|
||||
return { valid: true, message: null };
|
||||
}
|
||||
|
||||
const normalizedMessage = result.message?.toLowerCase() ?? "";
|
||||
const normalizedMessage = result.message.toLowerCase();
|
||||
if (
|
||||
result.status === 401 ||
|
||||
normalizedMessage.includes("invalidcredentials")
|
||||
@ -142,13 +141,11 @@ async function validateRxresume(
|
||||
return {
|
||||
valid: false,
|
||||
message:
|
||||
"Invalid RxResume credentials. Check your email and password and try again.",
|
||||
"Invalid RxResume credentials. Check your configured Reactive Resume mode credentials and try again.",
|
||||
};
|
||||
}
|
||||
|
||||
const message =
|
||||
result.message || `RxResume validation failed (HTTP ${result.status})`;
|
||||
return { valid: false, message };
|
||||
return { valid: false, message: result.message };
|
||||
}
|
||||
|
||||
onboardingRouter.post(
|
||||
@ -213,8 +210,19 @@ onboardingRouter.post(
|
||||
typeof req.body?.email === "string" ? req.body.email : undefined;
|
||||
const password =
|
||||
typeof req.body?.password === "string" ? req.body.password : undefined;
|
||||
const result = await validateRxresume(email, password);
|
||||
res.json({ success: true, data: result });
|
||||
const mode = typeof req.body?.mode === "string" ? req.body.mode : undefined;
|
||||
const apiKey =
|
||||
typeof req.body?.apiKey === "string" ? req.body.apiKey : undefined;
|
||||
const baseUrl =
|
||||
typeof req.body?.baseUrl === "string" ? req.body.baseUrl : undefined;
|
||||
const result = await validateRxresume({
|
||||
mode,
|
||||
email,
|
||||
password,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
});
|
||||
ok(res, result);
|
||||
},
|
||||
);
|
||||
|
||||
@ -233,6 +241,6 @@ onboardingRouter.get(
|
||||
}
|
||||
|
||||
const result = await validateResumeConfig();
|
||||
res.json({ success: true, data: result });
|
||||
ok(res, result);
|
||||
},
|
||||
);
|
||||
|
||||
@ -2,14 +2,13 @@ import type { Server } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startServer, stopServer } from "./test-utils";
|
||||
|
||||
// Mock the rxresume-v4 service
|
||||
vi.mock("@server/services/rxresume-v4", () => ({
|
||||
// Mock the RxResume adapter service
|
||||
vi.mock("@server/services/rxresume", () => ({
|
||||
getResume: vi.fn(),
|
||||
listResumes: vi.fn(),
|
||||
RxResumeCredentialsError: class RxResumeCredentialsError extends Error {
|
||||
RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error {
|
||||
constructor() {
|
||||
super("RxResume credentials not configured.");
|
||||
this.name = "RxResumeCredentialsError";
|
||||
super("Reactive Resume credentials not configured.");
|
||||
this.name = "RxResumeAuthConfigError";
|
||||
}
|
||||
},
|
||||
}));
|
||||
@ -31,10 +30,7 @@ vi.mock("@server/repositories/settings", async (importOriginal) => {
|
||||
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { getProfile } from "@server/services/profile";
|
||||
import {
|
||||
getResume,
|
||||
RxResumeCredentialsError,
|
||||
} from "@server/services/rxresume-v4";
|
||||
import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume";
|
||||
|
||||
describe.sequential("Profile API routes", () => {
|
||||
let server: Server;
|
||||
@ -192,7 +188,9 @@ describe.sequential("Profile API routes", () => {
|
||||
|
||||
it("returns exists: false when RxResume credentials are missing", async () => {
|
||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
||||
vi.mocked(getResume).mockRejectedValue(new RxResumeCredentialsError());
|
||||
vi.mocked(getResume).mockRejectedValue(
|
||||
new (RxResumeAuthConfigError as unknown as new () => Error)(),
|
||||
);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/profile/status`);
|
||||
const body = await res.json();
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import { toAppError } from "@infra/errors";
|
||||
import { fail, ok } from "@infra/http";
|
||||
import { isDemoMode } from "@server/config/demo";
|
||||
import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults";
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { clearProfileCache, getProfile } from "@server/services/profile";
|
||||
import { extractProjectsFromProfile } from "@server/services/resumeProjects";
|
||||
import {
|
||||
getResume,
|
||||
RxResumeCredentialsError,
|
||||
} from "@server/services/rxresume-v4";
|
||||
import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume";
|
||||
import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
|
||||
import { type Request, type Response, Router } from "express";
|
||||
|
||||
export const profileRouter = Router();
|
||||
@ -22,10 +21,9 @@ profileRouter.get("/projects", async (_req: Request, res: Response) => {
|
||||
}
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
res.json({ success: true, data: catalog });
|
||||
ok(res, catalog);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
fail(res, toAppError(error));
|
||||
}
|
||||
});
|
||||
|
||||
@ -35,10 +33,9 @@ profileRouter.get("/projects", async (_req: Request, res: Response) => {
|
||||
profileRouter.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const profile = await getProfile();
|
||||
res.json({ success: true, data: profile });
|
||||
ok(res, profile);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
fail(res, toAppError(error));
|
||||
}
|
||||
});
|
||||
|
||||
@ -47,16 +44,14 @@ profileRouter.get("/", async (_req: Request, res: Response) => {
|
||||
*/
|
||||
profileRouter.get("/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const rxresumeBaseResumeId = await getSetting("rxresumeBaseResumeId");
|
||||
const { resumeId: rxresumeBaseResumeId } =
|
||||
await getConfiguredRxResumeBaseResumeId();
|
||||
|
||||
if (!rxresumeBaseResumeId) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
ok(res, {
|
||||
exists: false,
|
||||
error:
|
||||
"No base resume selected. Please select a resume from your RxResume account in Settings.",
|
||||
},
|
||||
"No base resume selected. Please select a resume from your Reactive Resume account in Settings.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -65,46 +60,36 @@ profileRouter.get("/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const resume = await getResume(rxresumeBaseResumeId);
|
||||
if (!resume.data || typeof resume.data !== "object") {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
ok(res, {
|
||||
exists: false,
|
||||
error: "Selected resume is empty or invalid.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { exists: true, error: null } });
|
||||
ok(res, { exists: true, error: null });
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
exists: false,
|
||||
error: "RxResume credentials not configured.",
|
||||
},
|
||||
});
|
||||
if (error instanceof RxResumeAuthConfigError) {
|
||||
ok(res, { exists: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.json({ success: true, data: { exists: false, error: message } });
|
||||
ok(res, { exists: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/profile/refresh - Clear profile cache and refetch from RxResume v4 API
|
||||
* POST /api/profile/refresh - Clear profile cache and refetch from Reactive Resume
|
||||
*/
|
||||
profileRouter.post("/refresh", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
clearProfileCache();
|
||||
const profile = await getProfile(true);
|
||||
res.json({ success: true, data: profile });
|
||||
ok(res, profile);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
fail(res, toAppError(error));
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,5 +1,61 @@
|
||||
import type { Server } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@server/services/rxresume", () => ({
|
||||
listResumes: vi.fn(),
|
||||
getResume: vi.fn(),
|
||||
validateResumeSchema: vi.fn(async (data: unknown) => ({
|
||||
ok: true,
|
||||
mode:
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
typeof (data as Record<string, unknown>).summary === "object"
|
||||
? "v5"
|
||||
: "v4",
|
||||
data,
|
||||
})),
|
||||
extractProjectsFromResume: vi.fn((data: unknown) => {
|
||||
const root = (data ?? {}) as Record<string, unknown>;
|
||||
const sections = (root.sections ?? {}) as Record<string, unknown>;
|
||||
const projects = (sections.projects ?? {}) as Record<string, unknown>;
|
||||
const items = Array.isArray(projects.items) ? projects.items : [];
|
||||
return {
|
||||
mode: "v5",
|
||||
catalog: items.map((item) => {
|
||||
const project = item as Record<string, unknown>;
|
||||
return {
|
||||
id: String(project.id ?? ""),
|
||||
name: String(project.name ?? ""),
|
||||
description: String(project.description ?? ""),
|
||||
date: String(project.period ?? ""),
|
||||
isVisibleInBase: !project.hidden,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error {
|
||||
constructor(message = "Reactive Resume auth config missing") {
|
||||
super(message);
|
||||
this.name = "RxResumeAuthConfigError";
|
||||
}
|
||||
},
|
||||
RxResumeRequestError: class RxResumeRequestError extends Error {
|
||||
status: number | null;
|
||||
constructor(
|
||||
message = "Reactive Resume request failed",
|
||||
status: number | null = null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RxResumeRequestError";
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
extractProjectsFromResume,
|
||||
getResume,
|
||||
} from "@server/services/rxresume";
|
||||
import { startServer, stopServer } from "./test-utils";
|
||||
|
||||
describe.sequential("Settings API routes", () => {
|
||||
@ -118,4 +174,70 @@ describe.sequential("Settings API routes", () => {
|
||||
expect(getBody.data.penalizeMissingSalary.value).toBe(true);
|
||||
expect(getBody.data.missingSalaryPenalty.value).toBe(20);
|
||||
});
|
||||
|
||||
it("preserves upstream 404 from Reactive Resume project lookup", async () => {
|
||||
const { RxResumeRequestError } = await import("@server/services/rxresume");
|
||||
vi.mocked(getResume).mockRejectedValue(
|
||||
new RxResumeRequestError(
|
||||
"Reactive Resume API error (404): Resume not found",
|
||||
404,
|
||||
),
|
||||
);
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/settings/rx-resumes/missing/projects`,
|
||||
);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error.code).toBe("NOT_FOUND");
|
||||
expect(body.error.message).toContain("404");
|
||||
});
|
||||
|
||||
it("returns project catalog for v5-shaped Reactive Resume payloads", async () => {
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
id: "resume-v5",
|
||||
name: "Resume v5",
|
||||
mode: "v5",
|
||||
data: {
|
||||
sections: {
|
||||
projects: {
|
||||
title: "Projects",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "p1",
|
||||
hidden: false,
|
||||
name: "JobOps",
|
||||
period: "2024",
|
||||
website: { url: "https://example.com", label: "Example" },
|
||||
description: "Project description",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
summary: {},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/settings/rx-resumes/resume-v5/projects?mode=v5`,
|
||||
);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.projects).toEqual([
|
||||
{
|
||||
id: "p1",
|
||||
name: "JobOps",
|
||||
description: "Project description",
|
||||
date: "2024",
|
||||
isVisibleInBase: true,
|
||||
},
|
||||
]);
|
||||
expect(extractProjectsFromResume).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,12 +1,22 @@
|
||||
import {
|
||||
AppError,
|
||||
badRequest,
|
||||
serviceUnavailable,
|
||||
statusToCode,
|
||||
upstreamError,
|
||||
} from "@infra/errors";
|
||||
import { asyncRoute, fail, ok } from "@infra/http";
|
||||
import { logger } from "@infra/logger";
|
||||
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
|
||||
import { setBackupSettings } from "@server/services/backup/index";
|
||||
import { extractProjectsFromProfile } from "@server/services/resumeProjects";
|
||||
import {
|
||||
extractProjectsFromResume,
|
||||
getResume,
|
||||
listResumes,
|
||||
RxResumeCredentialsError,
|
||||
} from "@server/services/rxresume-v4";
|
||||
RxResumeAuthConfigError,
|
||||
RxResumeRequestError,
|
||||
validateResumeSchema,
|
||||
} from "@server/services/rxresume";
|
||||
import { getEffectiveSettings } from "@server/services/settings";
|
||||
import { applySettingsUpdates } from "@server/services/settings-update";
|
||||
import { updateSettingsSchema } from "@shared/settings-schema";
|
||||
@ -60,61 +70,106 @@ settingsRouter.patch("/", async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume v4 API
|
||||
* GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume (v4/v5 adapter)
|
||||
*/
|
||||
settingsRouter.get("/rx-resumes", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const resumes = await listResumes();
|
||||
function failRxResume(res: Response, error: unknown): void {
|
||||
if (error instanceof RxResumeAuthConfigError) {
|
||||
fail(res, badRequest(error.message));
|
||||
return;
|
||||
}
|
||||
if (error instanceof RxResumeRequestError) {
|
||||
if (error.status === 401) {
|
||||
fail(
|
||||
res,
|
||||
badRequest(
|
||||
"Reactive Resume authentication failed. Check your configured mode credentials.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (error.status && error.status >= 500) {
|
||||
fail(res, upstreamError(error.message));
|
||||
return;
|
||||
}
|
||||
if (error.status && error.status >= 400 && error.status < 500) {
|
||||
fail(
|
||||
res,
|
||||
new AppError({
|
||||
status: error.status,
|
||||
code: statusToCode(error.status),
|
||||
message: error.message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (error.status === 0) {
|
||||
fail(
|
||||
res,
|
||||
serviceUnavailable(
|
||||
"Reactive Resume is unavailable. Check the URL and try again.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("Reactive Resume route request failed", { message, error });
|
||||
fail(res, upstreamError(message));
|
||||
}
|
||||
|
||||
// Map to expected format (id, name)
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
settingsRouter.get(
|
||||
"/rx-resumes",
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const modeParam =
|
||||
typeof req.query.mode === "string" ? req.query.mode : undefined;
|
||||
const mode =
|
||||
modeParam === "v4" || modeParam === "v5" ? modeParam : undefined;
|
||||
const resumes = await listResumes({ mode });
|
||||
|
||||
ok(res, {
|
||||
resumes: resumes.map((resume) => ({
|
||||
id: resume.id,
|
||||
name: resume.name,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
res.status(400).json({ success: false, error: error.message });
|
||||
return;
|
||||
failRxResume(res, error);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("Failed to fetch Reactive Resumes", { message });
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/settings/rx-resumes/:id/projects - Fetch project catalog from RxResume v4
|
||||
* GET /api/settings/rx-resumes/:id/projects - Fetch project catalog from Reactive Resume (v4/v5 adapter)
|
||||
*/
|
||||
settingsRouter.get(
|
||||
"/rx-resumes/:id/projects",
|
||||
async (req: Request, res: Response) => {
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const resumeId = req.params.id;
|
||||
if (!resumeId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Resume id is required." });
|
||||
fail(res, badRequest("Resume id is required."));
|
||||
return;
|
||||
}
|
||||
|
||||
const resume = await getResume(resumeId);
|
||||
const profile = resume.data ?? {};
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const modeParam =
|
||||
typeof req.query.mode === "string" ? req.query.mode : undefined;
|
||||
const mode =
|
||||
modeParam === "v4" || modeParam === "v5" ? modeParam : undefined;
|
||||
|
||||
res.json({ success: true, data: { projects: catalog } });
|
||||
const resume = await getResume(resumeId, { mode });
|
||||
const validated = await validateResumeSchema(resume.data ?? {}, { mode });
|
||||
if (!validated.ok) {
|
||||
fail(res, badRequest(validated.message));
|
||||
return;
|
||||
}
|
||||
const { catalog } = extractProjectsFromResume(resume.data ?? {}, {
|
||||
mode: validated.mode,
|
||||
});
|
||||
|
||||
ok(res, { projects: catalog });
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
res.status(400).json({ success: false, error: error.message });
|
||||
return;
|
||||
failRxResume(res, error);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("Failed to fetch RxResume projects", { message });
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@ -5,7 +5,7 @@ import { getProfile } from "./profile";
|
||||
process.env.DATA_DIR = "/tmp";
|
||||
|
||||
// Define mock data in hoisted block
|
||||
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
|
||||
const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => {
|
||||
const profile = {
|
||||
sections: {
|
||||
summary: { content: "Original Summary" },
|
||||
@ -29,25 +29,16 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
|
||||
// Capture what's passed to create()
|
||||
let lastCreateData: any = null;
|
||||
|
||||
const mockClient = {
|
||||
create: vi.fn().mockImplementation((data: any) => {
|
||||
const mockRxResumeApi = {
|
||||
importResume: vi.fn().mockImplementation((payload: any) => {
|
||||
const data = payload?.data;
|
||||
lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone
|
||||
return Promise.resolve("mock-resume-id");
|
||||
}),
|
||||
print: vi.fn().mockResolvedValue("https://example.com/pdf/mock.pdf"),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
withAutoRefresh: vi
|
||||
exportResumePdf: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (
|
||||
_email: string,
|
||||
_password: string,
|
||||
operation: (token: string) => Promise<any>,
|
||||
) => {
|
||||
return operation("mock-token");
|
||||
},
|
||||
),
|
||||
getToken: vi.fn().mockResolvedValue("mock-token"),
|
||||
.mockResolvedValue("https://example.com/pdf/mock.pdf"),
|
||||
deleteResume: vi.fn().mockResolvedValue(undefined),
|
||||
getLastCreateData: () => lastCreateData,
|
||||
clearLastCreateData: () => {
|
||||
lastCreateData = null;
|
||||
@ -63,7 +54,7 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
mockRxResumeClient: mockClient,
|
||||
mockRxResume: mockRxResumeApi,
|
||||
};
|
||||
});
|
||||
|
||||
@ -161,13 +152,77 @@ vi.mock("./resumeProjects", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the RxResumeClient
|
||||
vi.mock("./rxresume-client", () => ({
|
||||
RxResumeClient: vi.fn().mockImplementation(function (this: any) {
|
||||
return mockRxResumeClient;
|
||||
vi.mock("./rxresume/baseResumeId", () => ({
|
||||
getConfiguredRxResumeBaseResumeId: vi.fn().mockResolvedValue({
|
||||
mode: "v4",
|
||||
resumeId: "base-resume-id",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./rxresume", async () => {
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const { createId } = await import("@paralleldrive/cuid2");
|
||||
const profileModule = await import("./profile");
|
||||
return {
|
||||
getResume: vi.fn().mockImplementation(async () => ({
|
||||
id: "base-resume-id",
|
||||
name: "Base Resume",
|
||||
mode: "v4",
|
||||
data: await profileModule.getProfile(),
|
||||
})),
|
||||
prepareTailoredResumeForPdf: vi
|
||||
.fn()
|
||||
.mockImplementation(async (args: any) => {
|
||||
const data = clone(args.resumeData);
|
||||
if (
|
||||
data.sections?.skills?.items &&
|
||||
Array.isArray(data.sections.skills.items)
|
||||
) {
|
||||
data.sections.skills.items = data.sections.skills.items.map(
|
||||
(skill: any) => ({
|
||||
...skill,
|
||||
id: skill.id || createId(),
|
||||
visible: skill.visible ?? true,
|
||||
description: skill.description ?? "",
|
||||
level: skill.level ?? 1,
|
||||
keywords: skill.keywords || [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (args.tailoredContent?.skills && data.sections?.skills) {
|
||||
const existingSkills = data.sections.skills.items || [];
|
||||
data.sections.skills.items = args.tailoredContent.skills.map(
|
||||
(newSkill: any) => {
|
||||
const existing = existingSkills.find(
|
||||
(s: any) => s.name === newSkill.name,
|
||||
);
|
||||
return {
|
||||
id: newSkill.id || existing?.id || createId(),
|
||||
visible: newSkill.visible ?? existing?.visible ?? true,
|
||||
name: newSkill.name || existing?.name || "",
|
||||
description:
|
||||
newSkill.description ?? existing?.description ?? "",
|
||||
level: newSkill.level ?? existing?.level ?? 0,
|
||||
keywords: newSkill.keywords || existing?.keywords || [],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "v4",
|
||||
data,
|
||||
projectCatalog: [],
|
||||
selectedProjectIds: [],
|
||||
};
|
||||
}),
|
||||
importResume: mockRxResume.importResume,
|
||||
exportResumePdf: mockRxResume.exportResumePdf,
|
||||
deleteResume: mockRxResume.deleteResume,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock stream pipeline for downloading PDF
|
||||
vi.mock("stream/promises", () => ({
|
||||
pipeline: vi.fn().mockResolvedValue(undefined),
|
||||
@ -227,7 +282,7 @@ describe("PDF Service Skills Validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getProfile).mockResolvedValue(mockProfile);
|
||||
mockRxResumeClient.clearLastCreateData();
|
||||
mockRxResume.clearLastCreateData();
|
||||
});
|
||||
|
||||
it("should add required schema fields (visible, description) to new skills", async () => {
|
||||
@ -241,8 +296,8 @@ describe("PDF Service Skills Validation", () => {
|
||||
|
||||
await generatePdf("job-skills-1", tailoredContent, "Job Desc");
|
||||
|
||||
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
||||
expect(mockRxResume.importResume).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResume.getLastCreateData();
|
||||
|
||||
const skillItems = savedResumeJson.sections.skills.items;
|
||||
|
||||
@ -297,8 +352,8 @@ describe("PDF Service Skills Validation", () => {
|
||||
// No tailoring, pass dummy path to bypass getProfile cache and use readFile mock
|
||||
await generatePdf("job-no-tailor", {}, "Job Desc", "dummy.json");
|
||||
|
||||
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
||||
expect(mockRxResume.importResume).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResume.getLastCreateData();
|
||||
|
||||
const item = savedResumeJson.sections.skills.items[0];
|
||||
|
||||
@ -349,8 +404,8 @@ describe("PDF Service Skills Validation", () => {
|
||||
|
||||
await generatePdf("job-cuid2-test", {}, "Job Desc", "dummy.json");
|
||||
|
||||
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
||||
expect(mockRxResume.importResume).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResume.getLastCreateData();
|
||||
|
||||
const skillItems = savedResumeJson.sections.skills.items;
|
||||
|
||||
@ -394,8 +449,8 @@ describe("PDF Service Skills Validation", () => {
|
||||
|
||||
await generatePdf("job-no-skill-prefix", {}, "Job Desc", "dummy.json");
|
||||
|
||||
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
||||
expect(mockRxResume.importResume).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResume.getLastCreateData();
|
||||
|
||||
const skill = savedResumeJson.sections.skills.items[0];
|
||||
|
||||
@ -430,8 +485,8 @@ describe("PDF Service Skills Validation", () => {
|
||||
|
||||
await generatePdf("job-preserve-id", {}, "Job Desc", "dummy.json");
|
||||
|
||||
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
||||
expect(mockRxResume.importResume).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResume.getLastCreateData();
|
||||
|
||||
const skill = savedResumeJson.sections.skills.items[0];
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { generatePdf } from "./pdf";
|
||||
import * as projectSelection from "./projectSelection";
|
||||
|
||||
// Define mock data in hoisted block
|
||||
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
|
||||
const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => {
|
||||
const profile = {
|
||||
sections: {
|
||||
summary: { content: "Original Summary" },
|
||||
@ -22,25 +22,16 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
|
||||
// Capture what's passed to create()
|
||||
let lastCreateData: any = null;
|
||||
|
||||
const mockClient = {
|
||||
create: vi.fn().mockImplementation((data: any) => {
|
||||
const mockRxResumeApi = {
|
||||
importResume: vi.fn().mockImplementation((payload: any) => {
|
||||
const data = payload?.data;
|
||||
lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone
|
||||
return Promise.resolve("mock-resume-id");
|
||||
}),
|
||||
print: vi.fn().mockResolvedValue("https://example.com/pdf/mock.pdf"),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
withAutoRefresh: vi
|
||||
exportResumePdf: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (
|
||||
_email: string,
|
||||
_password: string,
|
||||
operation: (token: string) => Promise<any>,
|
||||
) => {
|
||||
return operation("mock-token");
|
||||
},
|
||||
),
|
||||
getToken: vi.fn().mockResolvedValue("mock-token"),
|
||||
.mockResolvedValue("https://example.com/pdf/mock.pdf"),
|
||||
deleteResume: vi.fn().mockResolvedValue(undefined),
|
||||
getLastCreateData: () => lastCreateData,
|
||||
clearLastCreateData: () => {
|
||||
lastCreateData = null;
|
||||
@ -56,7 +47,7 @@ const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
mockRxResumeClient: mockClient,
|
||||
mockRxResume: mockRxResumeApi,
|
||||
};
|
||||
});
|
||||
|
||||
@ -159,13 +150,80 @@ vi.mock("./tracer-links", () => ({
|
||||
rewriteResumeLinksWithTracer: mockTracerLinks.rewriteResumeLinksWithTracer,
|
||||
}));
|
||||
|
||||
// Mock the RxResumeClient
|
||||
vi.mock("./rxresume-client", () => ({
|
||||
RxResumeClient: vi.fn().mockImplementation(function (this: any) {
|
||||
return mockRxResumeClient;
|
||||
vi.mock("./rxresume/baseResumeId", () => ({
|
||||
getConfiguredRxResumeBaseResumeId: vi.fn().mockResolvedValue({
|
||||
mode: "v4",
|
||||
resumeId: "base-resume-id",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./rxresume", async () => {
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const projectSelectionModule = await import("./projectSelection");
|
||||
return {
|
||||
getResume: vi.fn().mockResolvedValue({
|
||||
id: "base-resume-id",
|
||||
name: "Base Resume",
|
||||
mode: "v4",
|
||||
data: mockProfile,
|
||||
}),
|
||||
prepareTailoredResumeForPdf: vi
|
||||
.fn()
|
||||
.mockImplementation(async (args: any) => {
|
||||
const data = clone(args.resumeData);
|
||||
if (args.tailedContent?.summary || args.tailoredContent?.summary) {
|
||||
const summary = args.tailoredContent?.summary;
|
||||
if (data.sections?.summary) data.sections.summary.content = summary;
|
||||
}
|
||||
if (args.tailoredContent?.headline && data.basics) {
|
||||
data.basics.headline = args.tailoredContent.headline;
|
||||
}
|
||||
|
||||
let selected = (args.selectedProjectIds as string | null | undefined)
|
||||
?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!selected) {
|
||||
selected = await projectSelectionModule.pickProjectIdsForJob({
|
||||
jobDescription: args.jobDescription,
|
||||
eligibleProjects: [
|
||||
{ id: "p1", name: "Project 1" },
|
||||
{ id: "p2", name: "Project 2" },
|
||||
],
|
||||
desiredCount: 3,
|
||||
} as any);
|
||||
}
|
||||
const selectedSet = new Set(selected);
|
||||
for (const item of data.sections?.projects?.items ?? []) {
|
||||
item.visible = selectedSet.has(item.id);
|
||||
}
|
||||
if (data.sections?.projects) data.sections.projects.visible = true;
|
||||
|
||||
if (args.tracerLinks?.enabled) {
|
||||
mockTracerLinks.resolveTracerPublicBaseUrl({
|
||||
requestOrigin: args.tracerLinks.requestOrigin,
|
||||
});
|
||||
await mockTracerLinks.rewriteResumeLinksWithTracer({
|
||||
jobId: args.jobId,
|
||||
resumeData: data,
|
||||
publicBaseUrl: "https://jobops.example",
|
||||
companyName: args.tracerLinks.companyName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "v4",
|
||||
data,
|
||||
projectCatalog: [],
|
||||
selectedProjectIds: [...selectedSet],
|
||||
};
|
||||
}),
|
||||
importResume: mockRxResume.importResume,
|
||||
exportResumePdf: mockRxResume.exportResumePdf,
|
||||
deleteResume: mockRxResume.deleteResume,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock stream pipeline for downloading PDF
|
||||
vi.mock("stream/promises", () => ({
|
||||
pipeline: vi.fn().mockResolvedValue(undefined),
|
||||
@ -225,7 +283,7 @@ describe("PDF Service Tailoring Logic", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
||||
mockRxResumeClient.clearLastCreateData();
|
||||
mockRxResume.clearLastCreateData();
|
||||
mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue(
|
||||
"https://jobops.example",
|
||||
);
|
||||
@ -247,8 +305,8 @@ describe("PDF Service Tailoring Logic", () => {
|
||||
expect(projectSelection.pickProjectIdsForJob).not.toHaveBeenCalled();
|
||||
|
||||
// 2. Verify create data content
|
||||
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
||||
expect(mockRxResume.importResume).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResume.getLastCreateData();
|
||||
|
||||
const projects = savedResumeJson.sections.projects.items;
|
||||
const p1 = projects.find((p: any) => p.id === "p1");
|
||||
@ -265,8 +323,8 @@ describe("PDF Service Tailoring Logic", () => {
|
||||
it("should handle comma-separated project IDs correctly", async () => {
|
||||
await generatePdf("job-2", {}, "desc", "base.json", "p1, p2 ");
|
||||
|
||||
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
||||
expect(mockRxResume.importResume).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResume.getLastCreateData();
|
||||
const projects = savedResumeJson.sections.projects.items;
|
||||
|
||||
expect(projects.find((p: any) => p.id === "p1").visible).toBe(true);
|
||||
@ -276,8 +334,8 @@ describe("PDF Service Tailoring Logic", () => {
|
||||
it("keeps projects section visible when selected project list is explicitly empty", async () => {
|
||||
await generatePdf("job-empty-projects", {}, "desc", "base.json", "");
|
||||
|
||||
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
||||
expect(mockRxResume.importResume).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResume.getLastCreateData();
|
||||
const projects = savedResumeJson.sections.projects.items;
|
||||
|
||||
expect(projects.find((p: any) => p.id === "p1").visible).toBe(false);
|
||||
@ -293,8 +351,8 @@ describe("PDF Service Tailoring Logic", () => {
|
||||
|
||||
expect(projectSelection.pickProjectIdsForJob).toHaveBeenCalled();
|
||||
|
||||
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
||||
expect(mockRxResume.importResume).toHaveBeenCalled();
|
||||
const savedResumeJson = mockRxResume.getLastCreateData();
|
||||
|
||||
const p1 = savedResumeJson.sections.projects.items.find(
|
||||
(p: any) => p.id === "p1",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Service for generating PDF resumes using RxResume v4 API.
|
||||
* Service for generating PDF resumes using Reactive Resume.
|
||||
*/
|
||||
|
||||
import { createWriteStream, existsSync } from "node:fs";
|
||||
@ -7,20 +7,16 @@ import { access, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { logger } from "@infra/logger";
|
||||
import { getDataDir } from "../config/dataDir";
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { getProfile } from "./profile";
|
||||
import { pickProjectIdsForJob } from "./projectSelection";
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
resolveResumeProjectsSettings,
|
||||
} from "./resumeProjects";
|
||||
import { RxResumeClient } from "./rxresume-client";
|
||||
import {
|
||||
resolveTracerPublicBaseUrl,
|
||||
rewriteResumeLinksWithTracer,
|
||||
} from "./tracer-links";
|
||||
deleteResume as deleteRemoteResume,
|
||||
exportResumePdf,
|
||||
getResume as getRxResume,
|
||||
importResume as importRemoteResume,
|
||||
prepareTailoredResumeForPdf,
|
||||
} from "./rxresume";
|
||||
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
|
||||
|
||||
const OUTPUT_DIR = join(getDataDir(), "pdfs");
|
||||
|
||||
@ -42,36 +38,6 @@ export interface GeneratePdfOptions {
|
||||
tracerCompanyName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RxResume credentials from environment variables or database settings.
|
||||
*/
|
||||
async function getCredentials(): Promise<{
|
||||
email: string;
|
||||
password: string;
|
||||
baseUrl: string;
|
||||
}> {
|
||||
// First check environment variables
|
||||
let email = process.env.RXRESUME_EMAIL || "";
|
||||
let password = process.env.RXRESUME_PASSWORD || "";
|
||||
const baseUrl = process.env.RXRESUME_URL || "https://v4.rxresu.me";
|
||||
|
||||
// Fall back to database settings if env vars are not set
|
||||
if (!email) {
|
||||
email = (await getSetting("rxresumeEmail")) || "";
|
||||
}
|
||||
if (!password) {
|
||||
password = (await getSetting("rxresumePassword")) || "";
|
||||
}
|
||||
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
"RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD environment variables or configure them in settings.",
|
||||
);
|
||||
}
|
||||
|
||||
return { email, password, baseUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from a URL and save it to a local path.
|
||||
*/
|
||||
@ -96,27 +62,24 @@ async function downloadFile(url: string, outputPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a tailored PDF resume for a job using the RxResume v4 API.
|
||||
* Generate a tailored PDF resume for a job using Reactive Resume.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Prepare resume data with tailored content and project selection
|
||||
* 2. Get auth token (uses cached token or logs in)
|
||||
* 3. Import/create resume on RxResume
|
||||
* 4. Request print to get PDF URL
|
||||
* 5. Download PDF locally
|
||||
* 6. Delete temporary resume from RxResume
|
||||
*
|
||||
* Token refresh is handled automatically on 401 errors.
|
||||
* 2. Import/create resume on Reactive Resume
|
||||
* 3. Request print to get PDF URL
|
||||
* 4. Download PDF locally
|
||||
* 5. Delete temporary resume from Reactive Resume
|
||||
*/
|
||||
export async function generatePdf(
|
||||
jobId: string,
|
||||
tailoredContent: TailoredPdfContent,
|
||||
jobDescription: string,
|
||||
_baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API
|
||||
_baseResumePath?: string, // Deprecated: now always uses configured Reactive Resume base resume
|
||||
selectedProjectIds?: string | null,
|
||||
options?: GeneratePdfOptions,
|
||||
): Promise<PdfResult> {
|
||||
console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`);
|
||||
logger.info("Generating PDF resume", { jobId });
|
||||
|
||||
try {
|
||||
// Ensure output directory exists
|
||||
@ -124,220 +87,81 @@ export async function generatePdf(
|
||||
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Get credentials and initialize client
|
||||
const { email, password, baseUrl } = await getCredentials();
|
||||
const client = new RxResumeClient(baseUrl);
|
||||
|
||||
// Read base resume from profile (fetches from v4 API if configured, force fetch)
|
||||
const baseResume = JSON.parse(JSON.stringify(await getProfile(true)));
|
||||
|
||||
// Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords)
|
||||
// This fixes issues where the base JSON uses a shorthand format (missing required fields)
|
||||
if (
|
||||
baseResume.sections?.skills?.items &&
|
||||
Array.isArray(baseResume.sections.skills.items)
|
||||
) {
|
||||
baseResume.sections.skills.items = baseResume.sections.skills.items.map(
|
||||
(skill: Record<string, unknown>) => ({
|
||||
...skill,
|
||||
id: (skill.id as string) || createId(),
|
||||
visible: (skill.visible as boolean | undefined) ?? true,
|
||||
// Zod schema requires string, default to empty string if missing
|
||||
description: (skill.description as string | undefined) ?? "",
|
||||
level: (skill.level as number | undefined) ?? 1,
|
||||
keywords: (skill.keywords as string[] | undefined) || [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Inject tailored summary
|
||||
if (tailoredContent.summary) {
|
||||
if (baseResume.sections?.summary) {
|
||||
baseResume.sections.summary.content = tailoredContent.summary;
|
||||
} else if (baseResume.basics?.summary) {
|
||||
baseResume.basics.summary = tailoredContent.summary;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject tailored headline
|
||||
if (tailoredContent.headline) {
|
||||
if (baseResume.basics) {
|
||||
baseResume.basics.headline = tailoredContent.headline;
|
||||
baseResume.basics.label = tailoredContent.headline;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject tailored skills
|
||||
if (tailoredContent.skills) {
|
||||
const newSkills = Array.isArray(tailoredContent.skills)
|
||||
? tailoredContent.skills
|
||||
: typeof tailoredContent.skills === "string"
|
||||
? JSON.parse(tailoredContent.skills)
|
||||
: null;
|
||||
|
||||
if (newSkills && baseResume.sections?.skills) {
|
||||
// Ensure each skill item has required schema fields
|
||||
const existingSkills = (baseResume.sections.skills.items ||
|
||||
[]) as Array<Record<string, unknown>>;
|
||||
const skillsWithSchema = newSkills.map(
|
||||
(newSkill: Record<string, unknown>) => {
|
||||
// Try to find matching existing skill to preserve id and other fields
|
||||
const existing = existingSkills.find(
|
||||
(s) => s.name === newSkill.name,
|
||||
);
|
||||
|
||||
return {
|
||||
id:
|
||||
(newSkill.id as string) ||
|
||||
(existing?.id as string) ||
|
||||
createId(),
|
||||
visible:
|
||||
newSkill.visible !== undefined
|
||||
? (newSkill.visible as boolean)
|
||||
: ((existing?.visible as boolean | undefined) ?? true),
|
||||
name:
|
||||
(newSkill.name as string) || (existing?.name as string) || "",
|
||||
description:
|
||||
newSkill.description !== undefined
|
||||
? (newSkill.description as string)
|
||||
: (existing?.description as string) || "",
|
||||
level:
|
||||
newSkill.level !== undefined
|
||||
? (newSkill.level as number)
|
||||
: ((existing?.level as number | undefined) ?? 0),
|
||||
keywords:
|
||||
(newSkill.keywords as string[]) ||
|
||||
(existing?.keywords as string[]) ||
|
||||
[],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
baseResume.sections.skills.items = skillsWithSchema;
|
||||
}
|
||||
}
|
||||
|
||||
// Select projects and set visibility
|
||||
try {
|
||||
let selectedSet: Set<string>;
|
||||
|
||||
if (selectedProjectIds !== null && selectedProjectIds !== undefined) {
|
||||
selectedSet = new Set(
|
||||
selectedProjectIds
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
} else {
|
||||
const { catalog, selectionItems } =
|
||||
extractProjectsFromProfile(baseResume);
|
||||
const overrideResumeProjectsRaw = await getSetting("resumeProjects");
|
||||
const { resumeProjects } = resolveResumeProjectsSettings({
|
||||
catalog,
|
||||
overrideRaw: overrideResumeProjectsRaw,
|
||||
});
|
||||
|
||||
const locked = resumeProjects.lockedProjectIds;
|
||||
const desiredCount = Math.max(
|
||||
0,
|
||||
resumeProjects.maxProjects - locked.length,
|
||||
);
|
||||
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
|
||||
const eligibleProjects = selectionItems.filter((p) =>
|
||||
eligibleSet.has(p.id),
|
||||
);
|
||||
|
||||
const picked = await pickProjectIdsForJob({
|
||||
jobDescription,
|
||||
eligibleProjects,
|
||||
desiredCount,
|
||||
});
|
||||
|
||||
selectedSet = new Set([...locked, ...picked]);
|
||||
}
|
||||
|
||||
const projectsSection = baseResume.sections?.projects;
|
||||
const projectItems = projectsSection?.items;
|
||||
if (Array.isArray(projectItems)) {
|
||||
for (const item of projectItems) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const typedItem = item as Record<string, unknown>;
|
||||
const id = typeof typedItem.id === "string" ? typedItem.id : "";
|
||||
if (!id) continue;
|
||||
typedItem.visible = selectedSet.has(id);
|
||||
}
|
||||
projectsSection.visible = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
` ⚠️ Project visibility step failed for job ${jobId}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.tracerLinksEnabled) {
|
||||
const tracerBaseUrl = resolveTracerPublicBaseUrl({
|
||||
requestOrigin: options.requestOrigin,
|
||||
});
|
||||
if (!tracerBaseUrl) {
|
||||
const { resumeId: baseResumeId } =
|
||||
await getConfiguredRxResumeBaseResumeId();
|
||||
if (!baseResumeId) {
|
||||
throw new Error(
|
||||
"Tracer links are enabled but no public base URL is available. Set JOBOPS_PUBLIC_BASE_URL.",
|
||||
"Base resume not configured. Please select a base resume from your Reactive Resume account in Settings.",
|
||||
);
|
||||
}
|
||||
|
||||
await rewriteResumeLinksWithTracer({
|
||||
jobId,
|
||||
resumeData: baseResume,
|
||||
publicBaseUrl: tracerBaseUrl,
|
||||
companyName: options.tracerCompanyName ?? null,
|
||||
});
|
||||
const baseResume = await getRxResume(baseResumeId);
|
||||
if (!baseResume.data || typeof baseResume.data !== "object") {
|
||||
throw new Error("Reactive Resume base resume is empty or invalid.");
|
||||
}
|
||||
|
||||
// Use withAutoRefresh to handle token caching and 401 retry automatically
|
||||
const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);
|
||||
|
||||
await client.withAutoRefresh(email, password, async (token) => {
|
||||
let resumeId: string | null = null;
|
||||
|
||||
let preparedResumeData: Record<string, unknown>;
|
||||
try {
|
||||
// Create resume on RxResume
|
||||
console.log(` 📤 Uploading resume to RxResume...`);
|
||||
resumeId = await client.create(baseResume, token);
|
||||
console.log(` ✅ Resume created with ID: ${resumeId}`);
|
||||
const prepared = await prepareTailoredResumeForPdf({
|
||||
resumeData: baseResume.data,
|
||||
mode: baseResume.mode,
|
||||
tailoredContent,
|
||||
jobDescription,
|
||||
selectedProjectIds,
|
||||
jobId,
|
||||
tracerLinks: {
|
||||
enabled: Boolean(options?.tracerLinksEnabled),
|
||||
requestOrigin: options?.requestOrigin ?? null,
|
||||
companyName: options?.tracerCompanyName ?? null,
|
||||
},
|
||||
});
|
||||
preparedResumeData = prepared.data;
|
||||
} catch (err) {
|
||||
logger.warn("Resume tailoring step failed during PDF generation", {
|
||||
jobId,
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Get PDF URL
|
||||
console.log(` 🖨️ Requesting PDF generation...`);
|
||||
const pdfUrl = await client.print(resumeId, token);
|
||||
console.log(` ✅ PDF URL received: ${pdfUrl}`);
|
||||
const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);
|
||||
let resumeId: string | null = null;
|
||||
try {
|
||||
logger.debug("Uploading temporary resume for PDF generation", { jobId });
|
||||
resumeId = await importRemoteResume({
|
||||
data: preparedResumeData,
|
||||
name: `JobOps Tailored Resume ${jobId}`,
|
||||
slug: "",
|
||||
});
|
||||
|
||||
// Download PDF
|
||||
console.log(` 📥 Downloading PDF...`);
|
||||
logger.debug("Requesting PDF export for temporary resume", {
|
||||
jobId,
|
||||
resumeId,
|
||||
});
|
||||
const pdfUrl = await exportResumePdf(resumeId);
|
||||
|
||||
logger.debug("Downloading generated PDF", { jobId, resumeId });
|
||||
await downloadFile(pdfUrl, outputPath);
|
||||
console.log(` ✅ PDF saved to: ${outputPath}`);
|
||||
|
||||
// Cleanup: delete temporary resume from RxResume
|
||||
console.log(` 🧹 Cleaning up temporary resume...`);
|
||||
await client.delete(resumeId, token);
|
||||
console.log(` ✅ Temporary resume deleted from RxResume`);
|
||||
await deleteRemoteResume(resumeId);
|
||||
resumeId = null;
|
||||
} finally {
|
||||
// Attempt cleanup if resume was created but not deleted
|
||||
if (resumeId) {
|
||||
try {
|
||||
console.log(` 🧹 Attempting cleanup of orphaned resume...`);
|
||||
await client.delete(resumeId, token);
|
||||
} catch {
|
||||
console.warn(` ⚠️ Failed to cleanup orphaned resume ${resumeId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await deleteRemoteResume(resumeId);
|
||||
} catch (cleanupError) {
|
||||
logger.warn("Failed to cleanup temporary Reactive Resume record", {
|
||||
jobId,
|
||||
resumeId,
|
||||
error: cleanupError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ PDF generated successfully: ${outputPath}`);
|
||||
logger.info("PDF generated successfully", { jobId, outputPath });
|
||||
return { success: true, pdfPath: outputPath };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(`❌ PDF generation failed: ${message}`);
|
||||
logger.error("PDF generation failed", { jobId, error });
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,18 +6,18 @@ vi.mock("../repositories/settings", () => ({
|
||||
getSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./rxresume-v4", () => ({
|
||||
vi.mock("./rxresume", () => ({
|
||||
getResume: vi.fn(),
|
||||
RxResumeCredentialsError: class RxResumeCredentialsError extends Error {
|
||||
RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error {
|
||||
constructor() {
|
||||
super("RxResume credentials not configured.");
|
||||
this.name = "RxResumeCredentialsError";
|
||||
super("Reactive Resume credentials not configured.");
|
||||
this.name = "RxResumeAuthConfigError";
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { getResume, RxResumeCredentialsError } from "./rxresume-v4";
|
||||
import { getResume, RxResumeAuthConfigError } from "./rxresume";
|
||||
|
||||
describe("getProfile", () => {
|
||||
beforeEach(() => {
|
||||
@ -33,7 +33,7 @@ describe("getProfile", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should fetch profile from RxResume v4 API when configured", async () => {
|
||||
it("should fetch profile from Reactive Resume when configured", async () => {
|
||||
const mockResumeData = { basics: { name: "Test User" } };
|
||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
@ -43,6 +43,7 @@ describe("getProfile", () => {
|
||||
|
||||
const profile = await getProfile();
|
||||
|
||||
expect(getSetting).toHaveBeenCalledWith("rxresumeMode");
|
||||
expect(getSetting).toHaveBeenCalledWith("rxresumeBaseResumeId");
|
||||
expect(getResume).toHaveBeenCalledWith("test-resume-id");
|
||||
expect(profile).toEqual(mockResumeData);
|
||||
@ -59,8 +60,8 @@ describe("getProfile", () => {
|
||||
await getProfile();
|
||||
await getProfile();
|
||||
|
||||
// getSetting is called each time to check resumeId
|
||||
expect(getSetting).toHaveBeenCalledTimes(2);
|
||||
// The helper reads mode + legacy/per-mode resume-id settings each call.
|
||||
expect(getSetting).toHaveBeenCalledTimes(8);
|
||||
// But getResume should only be called once due to caching
|
||||
expect(getResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@ -81,10 +82,12 @@ describe("getProfile", () => {
|
||||
|
||||
it("should throw user-friendly error on credential issues", async () => {
|
||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
||||
vi.mocked(getResume).mockRejectedValue(new RxResumeCredentialsError());
|
||||
vi.mocked(getResume).mockRejectedValue(
|
||||
new (RxResumeAuthConfigError as unknown as new () => Error)(),
|
||||
);
|
||||
|
||||
await expect(getProfile()).rejects.toThrow(
|
||||
"RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.",
|
||||
"Reactive Resume credentials not configured.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -1,19 +1,13 @@
|
||||
/**
|
||||
* Profile service - fetches resume data from RxResume v4 API.
|
||||
*
|
||||
* The rxresumeBaseResumeId setting is REQUIRED for the app to function.
|
||||
* There is no local file fallback.
|
||||
*/
|
||||
|
||||
import { logger } from "@infra/logger";
|
||||
import type { ResumeProfile } from "@shared/types";
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { getResume, RxResumeCredentialsError } from "./rxresume-v4";
|
||||
import { getResume, RxResumeAuthConfigError } from "./rxresume";
|
||||
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
|
||||
|
||||
let cachedProfile: ResumeProfile | null = null;
|
||||
let cachedResumeId: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the base resume profile from RxResume v4 API.
|
||||
* Get the base resume profile from RxResume.
|
||||
*
|
||||
* Requires rxresumeBaseResumeId to be configured in settings.
|
||||
* Results are cached until clearProfileCache() is called.
|
||||
@ -22,7 +16,8 @@ let cachedResumeId: string | null = null;
|
||||
* @throws Error if rxresumeBaseResumeId is not configured or API call fails.
|
||||
*/
|
||||
export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
|
||||
const rxresumeBaseResumeId = await getSetting("rxresumeBaseResumeId");
|
||||
const { resumeId: rxresumeBaseResumeId } =
|
||||
await getConfiguredRxResumeBaseResumeId();
|
||||
|
||||
if (!rxresumeBaseResumeId) {
|
||||
throw new Error(
|
||||
@ -40,9 +35,9 @@ export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`📋 Fetching profile from RxResume v4 API (resume: ${rxresumeBaseResumeId})...`,
|
||||
);
|
||||
logger.info("Fetching profile from Reactive Resume", {
|
||||
resumeId: rxresumeBaseResumeId,
|
||||
});
|
||||
const resume = await getResume(rxresumeBaseResumeId);
|
||||
|
||||
if (!resume.data || typeof resume.data !== "object") {
|
||||
@ -51,15 +46,18 @@ export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
|
||||
|
||||
cachedProfile = resume.data as unknown as ResumeProfile;
|
||||
cachedResumeId = rxresumeBaseResumeId;
|
||||
console.log(`✅ Profile loaded from RxResume v4 API`);
|
||||
logger.info("Profile loaded from Reactive Resume", {
|
||||
resumeId: rxresumeBaseResumeId,
|
||||
});
|
||||
return cachedProfile;
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
throw new Error(
|
||||
"RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.",
|
||||
);
|
||||
if (error instanceof RxResumeAuthConfigError) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
console.error(`❌ Failed to load profile from RxResume v4 API:`, error);
|
||||
logger.error("Failed to load profile from Reactive Resume", {
|
||||
resumeId: rxresumeBaseResumeId,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,197 +0,0 @@
|
||||
// rxresume-v5.ts
|
||||
// Future-facing v5/OpenAPI implementation that uses API keys.
|
||||
// - Kept alongside v4 files so we can swap imports when v5 is ready.
|
||||
// - Uses RXRESUME_API_KEY and /api/openapi endpoints.
|
||||
//
|
||||
// NOTE: Not currently wired in; keep for migration.
|
||||
|
||||
import { resumeDataSchema } from "@shared/rxresume-schema";
|
||||
|
||||
export interface RxResumeResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
data: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary helper to execute a fetch request with multiple API keys if in development.
|
||||
* THIS FUNCTION IS TEMPORARY AND WILL BE REMOVED.
|
||||
*/
|
||||
|
||||
// Cache for last working key index (temporary, part of dev-only logic)
|
||||
let lastWorkingKeyIndex = 0;
|
||||
|
||||
async function executeWithKeyRetries(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
): Promise<unknown> {
|
||||
const rawApiKey = process.env.RXRESUME_API_KEY;
|
||||
if (!rawApiKey) {
|
||||
throw new Error("RXRESUME_API_KEY not configured in environment");
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const apiKeys =
|
||||
isDev && rawApiKey.includes(",")
|
||||
? rawApiKey.split(",").map((k) => k.trim())
|
||||
: [rawApiKey];
|
||||
|
||||
// Start from the last working key index
|
||||
for (let attempt = 0; attempt < apiKeys.length; attempt++) {
|
||||
const i = (lastWorkingKeyIndex + attempt) % apiKeys.length;
|
||||
const apiKey = apiKeys[i];
|
||||
const headers = {
|
||||
"x-api-key": apiKey,
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.headers || {}),
|
||||
} as Record<string, string>;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response
|
||||
.json()
|
||||
.catch(() => ({ message: response.statusText }))) as {
|
||||
message?: string;
|
||||
};
|
||||
const errorMsg = `Reactive Resume API error (${response.status}): ${errorData.message || response.statusText}`;
|
||||
|
||||
// ONLY retry/rotation on 401 Unauthorized
|
||||
if (
|
||||
response.status === 401 &&
|
||||
apiKeys.length > 1 &&
|
||||
attempt < apiKeys.length - 1
|
||||
) {
|
||||
console.warn(
|
||||
`[RxResume SDK] Key index ${i} was Unauthorized, trying next key...`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Success! Cache this key index for future requests
|
||||
lastWorkingKeyIndex = i;
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
return response.json();
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// Unmissable error block if all keys fail
|
||||
if (apiKeys.length > 1) {
|
||||
console.error(`
|
||||
################################################################################
|
||||
# #
|
||||
# ❌ ALL REACTIVE RESUME API KEYS FAILED (${apiKeys.length} keys attempted) #
|
||||
# Please check your .env configuration. #
|
||||
# #
|
||||
################################################################################
|
||||
`);
|
||||
}
|
||||
|
||||
throw new Error("All Reactive Resume API keys failed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fetch helper for Reactive Resume API
|
||||
*/
|
||||
export async function fetchRxResume(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<unknown> {
|
||||
const baseUrl = process.env.RXRESUME_URL || "https://rxresu.me";
|
||||
let cleanBaseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
|
||||
// Handle cases where the base URL already includes /api or /api/openapi
|
||||
if (cleanBaseUrl.endsWith("/api/openapi")) {
|
||||
cleanBaseUrl = cleanBaseUrl.slice(0, -12);
|
||||
} else if (cleanBaseUrl.endsWith("/api")) {
|
||||
cleanBaseUrl = cleanBaseUrl.slice(0, -4);
|
||||
}
|
||||
|
||||
const url = `${cleanBaseUrl}/api/openapi${path}`;
|
||||
return executeWithKeyRetries(url, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a resume by its ID.
|
||||
*/
|
||||
export async function getResume(id: string): Promise<RxResumeResponse> {
|
||||
return (await fetchRxResume(`/resume/${id}`)) as RxResumeResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a resume.
|
||||
*/
|
||||
export async function importResume(payload: {
|
||||
name: string;
|
||||
slug: string;
|
||||
data: unknown;
|
||||
}): Promise<string> {
|
||||
// Validate data against schema before sending
|
||||
try {
|
||||
payload.data = resumeDataSchema.parse(payload.data);
|
||||
} catch (error) {
|
||||
console.error("❌ Resume data validation failed:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// DEBUG: Save payload to file for debugging (temporary)
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const debugDir = path.join(process.cwd(), "debug");
|
||||
await fs.mkdir(debugDir, { recursive: true });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = path.join(debugDir, `rxresume-import-${timestamp}.json`);
|
||||
await fs.writeFile(filename, JSON.stringify(payload, null, 2), "utf-8");
|
||||
console.log(`📝 DEBUG: Saved import payload to ${filename}`);
|
||||
} catch (debugErr) {
|
||||
console.warn("⚠️ Could not save debug file:", debugErr);
|
||||
}
|
||||
|
||||
const result = (await fetchRxResume("/resume/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})) as { id: string } | string;
|
||||
|
||||
// Reactive Resume returns the full resume object on import in v4+, or just ID in v5.
|
||||
return typeof result === "string" ? result : result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a resume.
|
||||
*/
|
||||
export async function deleteResume(id: string): Promise<void> {
|
||||
await fetchRxResume(`/resume/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a resume as PDF. Returns the URL.
|
||||
*/
|
||||
export async function exportResumePdf(id: string): Promise<string> {
|
||||
const result = (await fetchRxResume(`/printer/resume/${id}/pdf`)) as {
|
||||
url: string;
|
||||
};
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all resumes.
|
||||
* According to official OpenAPI spec, the endpoint is /resume/list
|
||||
*/
|
||||
export async function listResumes(): Promise<{ id: string; name: string }[]> {
|
||||
return (await fetchRxResume("/resume/list")) as {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
}
|
||||
64
orchestrator/src/server/services/rxresume/baseResumeId.ts
Normal file
64
orchestrator/src/server/services/rxresume/baseResumeId.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import type { SettingKey } from "@server/repositories/settings";
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import type { RxResumeMode } from "@shared/types";
|
||||
|
||||
type BaseResumeIdSettings = Partial<
|
||||
Record<
|
||||
| "rxresumeMode"
|
||||
| "rxresumeBaseResumeId"
|
||||
| "rxresumeBaseResumeIdV4"
|
||||
| "rxresumeBaseResumeIdV5",
|
||||
string | null
|
||||
>
|
||||
>;
|
||||
|
||||
export function normalizeRxResumeMode(
|
||||
raw: string | null | undefined,
|
||||
): RxResumeMode {
|
||||
return raw === "v4" ? "v4" : "v5";
|
||||
}
|
||||
|
||||
export function getRxResumeBaseResumeIdKey(
|
||||
mode: RxResumeMode,
|
||||
): Extract<SettingKey, "rxresumeBaseResumeIdV4" | "rxresumeBaseResumeIdV5"> {
|
||||
return mode === "v4" ? "rxresumeBaseResumeIdV4" : "rxresumeBaseResumeIdV5";
|
||||
}
|
||||
|
||||
export function resolveRxResumeBaseResumeIdForMode(
|
||||
settings: BaseResumeIdSettings,
|
||||
explicitMode?: RxResumeMode,
|
||||
): string | null {
|
||||
const mode = explicitMode ?? normalizeRxResumeMode(settings.rxresumeMode);
|
||||
const modeSpecific =
|
||||
mode === "v4"
|
||||
? settings.rxresumeBaseResumeIdV4
|
||||
: settings.rxresumeBaseResumeIdV5;
|
||||
return modeSpecific?.trim() || settings.rxresumeBaseResumeId?.trim() || null;
|
||||
}
|
||||
|
||||
export async function getConfiguredRxResumeBaseResumeId(): Promise<{
|
||||
mode: RxResumeMode;
|
||||
resumeId: string | null;
|
||||
}> {
|
||||
const [modeRaw, legacyId, v4Id, v5Id] = await Promise.all([
|
||||
getSetting("rxresumeMode"),
|
||||
getSetting("rxresumeBaseResumeId"),
|
||||
getSetting("rxresumeBaseResumeIdV4"),
|
||||
getSetting("rxresumeBaseResumeIdV5"),
|
||||
]);
|
||||
const mode = normalizeRxResumeMode(
|
||||
modeRaw ?? process.env.RXRESUME_MODE ?? null,
|
||||
);
|
||||
return {
|
||||
mode,
|
||||
resumeId: resolveRxResumeBaseResumeIdForMode(
|
||||
{
|
||||
rxresumeMode: modeRaw,
|
||||
rxresumeBaseResumeId: legacyId,
|
||||
rxresumeBaseResumeIdV4: v4Id,
|
||||
rxresumeBaseResumeIdV5: v5Id,
|
||||
},
|
||||
mode,
|
||||
),
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RxResumeClient } from "./rxresume-client";
|
||||
import { RxResumeClient } from "./client";
|
||||
|
||||
describe("RxResumeClient", () => {
|
||||
describe("verifyCredentials (static)", () => {
|
||||
@ -1,11 +1,11 @@
|
||||
// rxresume-client.ts
|
||||
// rxresume/client.ts
|
||||
// Low-level HTTP client for the RxResume v4 API.
|
||||
// - Handles login, token caching, and cookie-based auth.
|
||||
// - Used by rxresume-v4.ts to provide a higher-level service surface.
|
||||
// - Used by rxresume/v4.ts to provide a higher-level service surface.
|
||||
// - The v5 client should be a drop-in replacement in the future.
|
||||
|
||||
import type { ResumeData } from "@shared/rxresume-schema";
|
||||
import { normalizeWhitespace } from "@shared/utils/string";
|
||||
import type { ResumeData } from "./schema/v4";
|
||||
|
||||
type AnyObj = Record<string, unknown>;
|
||||
const MAX_ERROR_SNIPPET = 300;
|
||||
344
orchestrator/src/server/services/rxresume/index.test.ts
Normal file
344
orchestrator/src/server/services/rxresume/index.test.ts
Normal file
@ -0,0 +1,344 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@server/repositories/settings", () => ({
|
||||
getSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./v4", () => ({
|
||||
listResumes: vi.fn(),
|
||||
getResume: vi.fn(),
|
||||
importResume: vi.fn(),
|
||||
deleteResume: vi.fn(),
|
||||
exportResumePdf: vi.fn(),
|
||||
RxResumeCredentialsError: class RxResumeCredentialsError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("./v5", () => ({
|
||||
listResumes: vi.fn(),
|
||||
getResume: vi.fn(),
|
||||
importResume: vi.fn(),
|
||||
deleteResume: vi.fn(),
|
||||
exportResumePdf: vi.fn(),
|
||||
verifyApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
RxResumeClient: {
|
||||
verifyCredentials: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { RxResumeClient } from "./client";
|
||||
import {
|
||||
extractProjectsFromResume,
|
||||
getResume as getResumeFromAdapter,
|
||||
listResumes,
|
||||
prepareTailoredResumeForPdf,
|
||||
RxResumeAuthConfigError,
|
||||
resolveRxResumeMode,
|
||||
validateCredentials,
|
||||
} from "./index";
|
||||
import * as v4 from "./v4";
|
||||
import * as v5 from "./v5";
|
||||
|
||||
type SettingMap = Partial<Record<string, string | null>>;
|
||||
|
||||
function mockSettings(map: SettingMap): void {
|
||||
vi.mocked(getSetting).mockImplementation(
|
||||
async (key: string) => map[key] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
describe("rxresume adapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.RXRESUME_API_KEY;
|
||||
delete process.env.RXRESUME_EMAIL;
|
||||
delete process.env.RXRESUME_PASSWORD;
|
||||
delete process.env.RXRESUME_MODE;
|
||||
mockSettings({});
|
||||
});
|
||||
|
||||
it("throws targeted error when explicit v5 is selected without api key", async () => {
|
||||
mockSettings({ rxresumeMode: "v5" });
|
||||
|
||||
await expect(resolveRxResumeMode()).rejects.toBeInstanceOf(
|
||||
RxResumeAuthConfigError,
|
||||
);
|
||||
await expect(resolveRxResumeMode()).rejects.toThrow(/v5 API key/i);
|
||||
});
|
||||
|
||||
it("routes listResumes through v5 and normalizes title when v5 is selected", async () => {
|
||||
mockSettings({ rxresumeMode: "v5", rxresumeApiKey: "v5-key" });
|
||||
vi.mocked(v5.listResumes).mockResolvedValue([
|
||||
{
|
||||
id: "r1",
|
||||
name: "Resume One",
|
||||
slug: "resume-one",
|
||||
tags: [],
|
||||
isPublic: false,
|
||||
isLocked: false,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
name: "Resume Two",
|
||||
slug: "resume-two",
|
||||
tags: [],
|
||||
isPublic: false,
|
||||
isLocked: false,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await listResumes();
|
||||
|
||||
expect(v5.listResumes).toHaveBeenCalledWith({
|
||||
apiKey: "v5-key",
|
||||
baseUrl: "https://rxresu.me",
|
||||
});
|
||||
expect(v4.listResumes).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject([
|
||||
{ id: "r1", name: "Resume One", title: "Resume One" },
|
||||
{ id: "r2", name: "Resume Two", title: "Resume Two" },
|
||||
]);
|
||||
});
|
||||
it("does not fall back to v4 at runtime when explicit v5 fails", async () => {
|
||||
mockSettings({
|
||||
rxresumeMode: "v5",
|
||||
rxresumeApiKey: "stale-v5-key",
|
||||
rxresumeEmail: "user@example.com",
|
||||
rxresumePassword: "pw",
|
||||
});
|
||||
vi.mocked(v5.listResumes).mockRejectedValue(
|
||||
new Error("Reactive Resume API error (401): Unauthorized"),
|
||||
);
|
||||
|
||||
await expect(listResumes()).rejects.toThrow(/401/i);
|
||||
expect(v5.listResumes).toHaveBeenCalledTimes(1);
|
||||
expect(v4.listResumes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to v4 getResume when explicit v5 fails", async () => {
|
||||
mockSettings({
|
||||
rxresumeMode: "v5",
|
||||
rxresumeApiKey: "v5-key",
|
||||
rxresumeEmail: "user@example.com",
|
||||
rxresumePassword: "pw",
|
||||
});
|
||||
vi.mocked(v5.getResume).mockRejectedValue(
|
||||
new Error("Reactive Resume API error (404): Resume not found"),
|
||||
);
|
||||
|
||||
await expect(getResumeFromAdapter("legacy-1")).rejects.toThrow(/404/i);
|
||||
expect(v5.getResume).toHaveBeenCalledTimes(1);
|
||||
expect(v4.getResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates explicit v4 credentials", async () => {
|
||||
mockSettings({
|
||||
rxresumeMode: "v4",
|
||||
rxresumeEmail: "user@example.com",
|
||||
rxresumePassword: "pw",
|
||||
});
|
||||
vi.mocked(RxResumeClient.verifyCredentials).mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await validateCredentials();
|
||||
|
||||
expect(RxResumeClient.verifyCredentials).toHaveBeenCalledWith(
|
||||
"user@example.com",
|
||||
"pw",
|
||||
"https://v4.rxresu.me",
|
||||
);
|
||||
expect(v5.verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ ok: true, mode: "v4" });
|
||||
});
|
||||
|
||||
it("does not fall back to v4 validation when explicit v5 validation fails", async () => {
|
||||
mockSettings({
|
||||
rxresumeMode: "v5",
|
||||
rxresumeApiKey: "stale-v5-key",
|
||||
rxresumeEmail: "user@example.com",
|
||||
rxresumePassword: "pw",
|
||||
});
|
||||
vi.mocked(v5.verifyApiKey).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
message: "Reactive Resume API error (401): Unauthorized",
|
||||
});
|
||||
vi.mocked(RxResumeClient.verifyCredentials).mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await validateCredentials();
|
||||
|
||||
expect(v5.verifyApiKey).toHaveBeenCalledTimes(1);
|
||||
expect(RxResumeClient.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
mode: "v5",
|
||||
status: 401,
|
||||
message: "Reactive Resume API error (401): Unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
it("prepares tailored v5 resume payload without relying on v4 fields", async () => {
|
||||
const v5ResumeData = {
|
||||
basics: {
|
||||
name: "Test User",
|
||||
headline: "Old headline",
|
||||
email: "test@example.com",
|
||||
phone: "",
|
||||
location: "",
|
||||
website: { url: "https://example.com", label: "Portfolio" },
|
||||
customFields: [],
|
||||
},
|
||||
picture: {},
|
||||
summary: {
|
||||
title: "Summary",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
content: "Old summary",
|
||||
},
|
||||
sections: {
|
||||
projects: {
|
||||
title: "Projects",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "p1",
|
||||
hidden: false,
|
||||
name: "Visible project",
|
||||
period: "2024",
|
||||
website: { url: "https://p1.example.com", label: "P1" },
|
||||
description: "Alpha",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
hidden: false,
|
||||
name: "Hidden project",
|
||||
period: "2023",
|
||||
website: { url: "https://p2.example.com", label: "P2" },
|
||||
description: "Beta",
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
title: "Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "skill1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "Existing",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: ["x"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
customSections: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const prepared = await prepareTailoredResumeForPdf({
|
||||
mode: "v5",
|
||||
resumeData: v5ResumeData,
|
||||
tailoredContent: {
|
||||
headline: "New headline",
|
||||
summary: "New summary",
|
||||
skills: [{ name: "Frontend", keywords: ["React", "TS"] }],
|
||||
},
|
||||
jobDescription: "Test JD",
|
||||
selectedProjectIds: "p1",
|
||||
});
|
||||
|
||||
expect(prepared.mode).toBe("v5");
|
||||
expect(prepared.selectedProjectIds).toEqual(["p1"]);
|
||||
expect(prepared.projectCatalog).toMatchObject([
|
||||
{ id: "p1", date: "2024", isVisibleInBase: true },
|
||||
{ id: "p2", date: "2023", isVisibleInBase: true },
|
||||
]);
|
||||
|
||||
const data = prepared.data as any;
|
||||
expect(data.basics.headline).toBe("New headline");
|
||||
expect(data.summary.content).toBe("New summary");
|
||||
expect(data.sections.projects.hidden).toBe(false);
|
||||
expect(data.sections.projects.items[0].hidden).toBe(false);
|
||||
expect(data.sections.projects.items[1].hidden).toBe(true);
|
||||
expect(data.sections.skills.items[0].name).toBe("Frontend");
|
||||
expect(data.sections.skills.items[0].keywords).toEqual(["React", "TS"]);
|
||||
});
|
||||
|
||||
it("extracts project catalog from v5 payloads", () => {
|
||||
const result = extractProjectsFromResume({
|
||||
basics: {
|
||||
name: "",
|
||||
headline: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
location: "",
|
||||
website: { url: "", label: "" },
|
||||
customFields: [],
|
||||
},
|
||||
picture: {},
|
||||
summary: {
|
||||
title: "Summary",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
content: "",
|
||||
},
|
||||
sections: {
|
||||
projects: {
|
||||
title: "Projects",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "proj-1",
|
||||
hidden: true,
|
||||
name: "API",
|
||||
period: "2025",
|
||||
website: { url: "https://example.com", label: "Site" },
|
||||
description: "Built API",
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
title: "Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "Frontend",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: ["React"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
customSections: [],
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
expect(result.mode).toBe("v5");
|
||||
expect(result.catalog).toEqual([
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "API",
|
||||
description: "Built API",
|
||||
date: "2025",
|
||||
isVisibleInBase: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
612
orchestrator/src/server/services/rxresume/index.ts
Normal file
612
orchestrator/src/server/services/rxresume/index.ts
Normal file
@ -0,0 +1,612 @@
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { pickProjectIdsForJob } from "@server/services/projectSelection";
|
||||
import { resolveResumeProjectsSettings } from "@server/services/resumeProjects";
|
||||
import {
|
||||
resolveTracerPublicBaseUrl,
|
||||
rewriteResumeLinksWithTracer,
|
||||
} from "@server/services/tracer-links";
|
||||
import { settingsRegistry } from "@shared/settings-registry";
|
||||
import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types";
|
||||
import { RxResumeClient } from "./client";
|
||||
import {
|
||||
getResumeSchemaValidationMessage,
|
||||
safeParseResumeDataForMode,
|
||||
} from "./schema";
|
||||
import {
|
||||
applyProjectVisibility,
|
||||
applyTailoredChunks,
|
||||
cloneResumeData,
|
||||
extractProjectsFromResume as extractProjectsFromResumeByMode,
|
||||
inferRxResumeModeFromData,
|
||||
type TailoredSkillsInput,
|
||||
validateAndParseResumeDataForMode,
|
||||
} from "./tailoring";
|
||||
import * as v4 from "./v4";
|
||||
import * as v5 from "./v5";
|
||||
|
||||
export type RxResumeResolvedMode = "v4" | "v5";
|
||||
|
||||
export type RxResumeResume = {
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
slug?: string;
|
||||
mode?: RxResumeResolvedMode;
|
||||
data?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type RxResumeImportPayload = {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
export type PreparedRxResumePdfPayload = {
|
||||
mode: RxResumeResolvedMode;
|
||||
data: Record<string, unknown>;
|
||||
projectCatalog: ResumeProjectCatalogItem[];
|
||||
selectedProjectIds: string[];
|
||||
};
|
||||
|
||||
export class RxResumeAuthConfigError extends Error {
|
||||
constructor(
|
||||
public readonly mode: RxResumeMode | RxResumeResolvedMode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RxResumeAuthConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export class RxResumeRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number | null = null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RxResumeRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
type ResolveModeOptions = {
|
||||
mode?: RxResumeMode;
|
||||
v4?: {
|
||||
email?: string | null;
|
||||
password?: string | null;
|
||||
baseUrl?: string | null;
|
||||
};
|
||||
v5?: { apiKey?: string | null; baseUrl?: string | null };
|
||||
};
|
||||
|
||||
type V4Credentials = Awaited<ReturnType<typeof readV4Credentials>>;
|
||||
type V5Credentials = Awaited<ReturnType<typeof readV5Credentials>>;
|
||||
|
||||
function toV4Override(
|
||||
input?: ResolveModeOptions["v4"],
|
||||
): Partial<v4.RxResumeCredentials> | undefined {
|
||||
if (!input) return undefined;
|
||||
return {
|
||||
...(typeof input.email === "string" ? { email: input.email } : {}),
|
||||
...(typeof input.password === "string" ? { password: input.password } : {}),
|
||||
...(typeof input.baseUrl === "string" ? { baseUrl: input.baseUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMode(raw: string | null | undefined): RxResumeMode {
|
||||
const parsed = settingsRegistry.rxresumeMode.parse(raw ?? undefined);
|
||||
return parsed ?? "v5";
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof RxResumeAuthConfigError ||
|
||||
error instanceof RxResumeRequestError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof v4.RxResumeCredentialsError) {
|
||||
return new RxResumeAuthConfigError(
|
||||
"v4",
|
||||
"Reactive Resume v4 credentials are not configured.",
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const match = /Reactive Resume API error \((\d+)\)/i.exec(error.message);
|
||||
const isNetworkLikeFailure =
|
||||
error.name === "AbortError" ||
|
||||
(error instanceof TypeError &&
|
||||
/fetch failed|network/i.test(error.message || ""));
|
||||
return new RxResumeRequestError(
|
||||
error.message,
|
||||
match ? Number(match[1]) : isNetworkLikeFailure ? 0 : null,
|
||||
);
|
||||
}
|
||||
return new RxResumeRequestError("Reactive Resume request failed.");
|
||||
}
|
||||
|
||||
function normalizeV5ResumeListResponse(payload: unknown): RxResumeResume[] {
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new RxResumeRequestError(
|
||||
"Reactive Resume v5 returned an unexpected resume list response shape.",
|
||||
);
|
||||
}
|
||||
|
||||
return payload.map((resume) => {
|
||||
if (!resume || typeof resume !== "object") {
|
||||
throw new RxResumeRequestError(
|
||||
"Reactive Resume v5 returned an invalid resume list item.",
|
||||
);
|
||||
}
|
||||
const item = resume as Record<string, unknown>;
|
||||
const id = typeof item.id === "string" ? item.id : String(item.id ?? "");
|
||||
const name =
|
||||
typeof item.name === "string" && item.name.trim()
|
||||
? item.name
|
||||
: typeof item.title === "string" && item.title.trim()
|
||||
? item.title
|
||||
: id;
|
||||
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
name,
|
||||
title: name,
|
||||
} as RxResumeResume;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeV5ResumeResponse(payload: unknown): Record<string, unknown> {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
throw new RxResumeRequestError(
|
||||
"Reactive Resume v5 returned an unexpected resume response shape.",
|
||||
);
|
||||
}
|
||||
|
||||
return payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function readConfiguredMode(): Promise<RxResumeMode> {
|
||||
const [storedMode] = await Promise.all([getSetting("rxresumeMode")]);
|
||||
return normalizeMode(storedMode ?? process.env.RXRESUME_MODE ?? null);
|
||||
}
|
||||
|
||||
async function readV4Credentials(overrides?: ResolveModeOptions["v4"]) {
|
||||
const [storedEmail, storedPassword] = await Promise.all([
|
||||
getSetting("rxresumeEmail"),
|
||||
getSetting("rxresumePassword"),
|
||||
]);
|
||||
const email =
|
||||
overrides?.email?.trim() ||
|
||||
process.env.RXRESUME_EMAIL?.trim() ||
|
||||
storedEmail?.trim() ||
|
||||
"";
|
||||
const password =
|
||||
overrides?.password?.trim() ||
|
||||
process.env.RXRESUME_PASSWORD?.trim() ||
|
||||
storedPassword?.trim() ||
|
||||
"";
|
||||
const baseUrl =
|
||||
overrides?.baseUrl?.trim() ||
|
||||
process.env.RXRESUME_URL?.trim() ||
|
||||
"https://v4.rxresu.me";
|
||||
return { email, password, baseUrl, available: Boolean(email && password) };
|
||||
}
|
||||
|
||||
async function readV5Credentials(overrides?: ResolveModeOptions["v5"]) {
|
||||
const [storedApiKey] = await Promise.all([getSetting("rxresumeApiKey")]);
|
||||
const apiKey =
|
||||
overrides?.apiKey?.trim() ||
|
||||
process.env.RXRESUME_API_KEY?.trim() ||
|
||||
storedApiKey?.trim() ||
|
||||
"";
|
||||
const baseUrl =
|
||||
overrides?.baseUrl?.trim() ||
|
||||
process.env.RXRESUME_URL?.trim() ||
|
||||
"https://rxresu.me";
|
||||
return { apiKey, baseUrl, available: Boolean(apiKey) };
|
||||
}
|
||||
|
||||
export async function resolveRxResumeMode(
|
||||
options: ResolveModeOptions = {},
|
||||
): Promise<RxResumeResolvedMode> {
|
||||
const mode = options.mode ?? (await readConfiguredMode());
|
||||
const [v5Creds, v4Creds] = await Promise.all([
|
||||
readV5Credentials(options.v5),
|
||||
readV4Credentials(options.v4),
|
||||
]);
|
||||
|
||||
if (mode === "v5") {
|
||||
if (!v5Creds.available) {
|
||||
throw new RxResumeAuthConfigError(
|
||||
"v5",
|
||||
"Reactive Resume v5 API key is not configured. Set RXRESUME_API_KEY or configure rxresumeApiKey in Settings.",
|
||||
);
|
||||
}
|
||||
return "v5";
|
||||
}
|
||||
|
||||
if (mode === "v4") {
|
||||
if (!v4Creds.available) {
|
||||
throw new RxResumeAuthConfigError(
|
||||
"v4",
|
||||
"Reactive Resume v4 credentials are not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD or configure them in Settings.",
|
||||
);
|
||||
}
|
||||
return "v4";
|
||||
}
|
||||
throw new RxResumeAuthConfigError(
|
||||
mode,
|
||||
"Reactive Resume mode must be set to v4 or v5.",
|
||||
);
|
||||
}
|
||||
|
||||
async function runRxResumeOperation<T>(
|
||||
options: ResolveModeOptions,
|
||||
handlers: {
|
||||
v4: (creds: V4Credentials) => Promise<T>;
|
||||
v5: (creds: V5Credentials) => Promise<T>;
|
||||
},
|
||||
): Promise<T> {
|
||||
const requestedMode = options.mode ?? (await readConfiguredMode());
|
||||
const [v5Creds, v4Creds] = await Promise.all([
|
||||
readV5Credentials(options.v5),
|
||||
readV4Credentials(options.v4),
|
||||
]);
|
||||
|
||||
if (requestedMode === "v5") {
|
||||
if (!v5Creds.available) {
|
||||
throw new RxResumeAuthConfigError(
|
||||
"v5",
|
||||
"Reactive Resume v5 API key is not configured. Set RXRESUME_API_KEY or configure rxresumeApiKey in Settings.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await handlers.v5(v5Creds);
|
||||
} catch (error) {
|
||||
throw normalizeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!v4Creds.available) {
|
||||
throw new RxResumeAuthConfigError(
|
||||
"v4",
|
||||
"Reactive Resume v4 credentials are not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD or configure them in Settings.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await handlers.v4(v4Creds);
|
||||
} catch (error) {
|
||||
throw normalizeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listResumes(
|
||||
options: ResolveModeOptions = {},
|
||||
): Promise<RxResumeResume[]> {
|
||||
return runRxResumeOperation(options, {
|
||||
v5: async (creds) =>
|
||||
normalizeV5ResumeListResponse(
|
||||
await v5.listResumes({ apiKey: creds.apiKey, baseUrl: creds.baseUrl }),
|
||||
),
|
||||
v4: async (creds) =>
|
||||
(await v4.listResumes(toV4Override(creds))) as RxResumeResume[],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getResume(
|
||||
resumeId: string,
|
||||
options: ResolveModeOptions = {},
|
||||
): Promise<RxResumeResume> {
|
||||
return runRxResumeOperation(options, {
|
||||
v5: async (creds) => {
|
||||
const resume = normalizeV5ResumeResponse(
|
||||
await v5.getResume(resumeId, {
|
||||
apiKey: creds.apiKey,
|
||||
baseUrl: creds.baseUrl,
|
||||
}),
|
||||
) as RxResumeResume;
|
||||
return {
|
||||
...resume,
|
||||
mode: "v5",
|
||||
title:
|
||||
typeof resume.name === "string" && resume.name.trim()
|
||||
? resume.name
|
||||
: (resume.slug ?? resume.id),
|
||||
data: resume.data,
|
||||
} as RxResumeResume;
|
||||
},
|
||||
v4: async (creds) => ({
|
||||
...((await v4.getResume(
|
||||
resumeId,
|
||||
toV4Override(creds),
|
||||
)) as RxResumeResume),
|
||||
mode: "v4",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateResumeSchema(
|
||||
resumeData: unknown,
|
||||
options: ResolveModeOptions = {},
|
||||
): Promise<
|
||||
| { ok: true; mode: RxResumeResolvedMode; data: Record<string, unknown> }
|
||||
| { ok: false; mode: RxResumeResolvedMode; message: string }
|
||||
> {
|
||||
const mode = await resolveRxResumeMode(options);
|
||||
const result = safeParseResumeDataForMode(mode, resumeData);
|
||||
if (!result.success) {
|
||||
return {
|
||||
ok: false,
|
||||
mode,
|
||||
message: getResumeSchemaValidationMessage(result.error),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!result.data ||
|
||||
typeof result.data !== "object" ||
|
||||
Array.isArray(result.data)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
mode,
|
||||
message:
|
||||
"Resume schema validation failed: root payload must be an object.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
mode,
|
||||
data: result.data as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSelectedProjectIds(selectedProjectIds?: string | null): string[] {
|
||||
if (selectedProjectIds === null || selectedProjectIds === undefined)
|
||||
return [];
|
||||
return selectedProjectIds
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function extractProjectsFromResume(
|
||||
resumeData: unknown,
|
||||
options: { mode?: RxResumeMode } = {},
|
||||
): { mode: RxResumeResolvedMode; catalog: ResumeProjectCatalogItem[] } {
|
||||
const mode = (options.mode ??
|
||||
inferRxResumeModeFromData(resumeData) ??
|
||||
"v5") as RxResumeResolvedMode;
|
||||
const parsed = validateAndParseResumeDataForMode(mode, resumeData);
|
||||
if (!parsed.ok) {
|
||||
throw new Error(parsed.message);
|
||||
}
|
||||
const { catalog } = extractProjectsFromResumeByMode(mode, parsed.data);
|
||||
return { mode, catalog };
|
||||
}
|
||||
|
||||
export async function prepareTailoredResumeForPdf(args: {
|
||||
resumeData: unknown;
|
||||
mode?: RxResumeMode;
|
||||
tailoredContent: {
|
||||
summary?: string | null;
|
||||
headline?: string | null;
|
||||
skills?: TailoredSkillsInput;
|
||||
};
|
||||
jobDescription: string;
|
||||
selectedProjectIds?: string | null;
|
||||
tracerLinks?: {
|
||||
enabled: boolean;
|
||||
requestOrigin?: string | null;
|
||||
companyName?: string | null;
|
||||
};
|
||||
forceVisibleProjectsSection?: boolean;
|
||||
jobId?: string;
|
||||
}): Promise<PreparedRxResumePdfPayload> {
|
||||
const mode = (args.mode ??
|
||||
(await readConfiguredMode())) as RxResumeResolvedMode;
|
||||
const parsed = validateAndParseResumeDataForMode(mode, args.resumeData);
|
||||
if (!parsed.ok) {
|
||||
throw new Error(parsed.message);
|
||||
}
|
||||
|
||||
const workingCopy = cloneResumeData(parsed.data);
|
||||
applyTailoredChunks({
|
||||
mode,
|
||||
resumeData: workingCopy,
|
||||
tailoredContent: args.tailoredContent,
|
||||
});
|
||||
|
||||
const { catalog, selectionItems } = extractProjectsFromResumeByMode(
|
||||
mode,
|
||||
workingCopy,
|
||||
);
|
||||
|
||||
let selectedIds = parseSelectedProjectIds(args.selectedProjectIds);
|
||||
|
||||
if (
|
||||
args.selectedProjectIds === null ||
|
||||
args.selectedProjectIds === undefined
|
||||
) {
|
||||
const overrideResumeProjectsRaw = await getSetting("resumeProjects");
|
||||
const { resumeProjects } = resolveResumeProjectsSettings({
|
||||
catalog,
|
||||
overrideRaw: overrideResumeProjectsRaw,
|
||||
});
|
||||
|
||||
const locked = resumeProjects.lockedProjectIds;
|
||||
const desiredCount = Math.max(
|
||||
0,
|
||||
resumeProjects.maxProjects - locked.length,
|
||||
);
|
||||
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
|
||||
const eligibleProjects = selectionItems.filter((p) =>
|
||||
eligibleSet.has(p.id),
|
||||
);
|
||||
const picked = await pickProjectIdsForJob({
|
||||
jobDescription: args.jobDescription,
|
||||
eligibleProjects,
|
||||
desiredCount,
|
||||
});
|
||||
selectedIds = [...locked, ...picked];
|
||||
}
|
||||
|
||||
applyProjectVisibility({
|
||||
mode,
|
||||
resumeData: workingCopy,
|
||||
selectedProjectIds: new Set(selectedIds),
|
||||
forceVisibleProjectsSection: args.forceVisibleProjectsSection,
|
||||
});
|
||||
|
||||
if (args.tracerLinks?.enabled) {
|
||||
const tracerBaseUrl = resolveTracerPublicBaseUrl({
|
||||
requestOrigin: args.tracerLinks.requestOrigin,
|
||||
});
|
||||
if (!tracerBaseUrl) {
|
||||
throw new Error(
|
||||
"Tracer links are enabled but no public base URL is available. Set JOBOPS_PUBLIC_BASE_URL.",
|
||||
);
|
||||
}
|
||||
if (!args.jobId) {
|
||||
throw new Error(
|
||||
"Tracer links are enabled but jobId was not provided for resume tailoring.",
|
||||
);
|
||||
}
|
||||
|
||||
await rewriteResumeLinksWithTracer({
|
||||
jobId: args.jobId,
|
||||
resumeData: workingCopy,
|
||||
publicBaseUrl: tracerBaseUrl,
|
||||
companyName: args.tracerLinks.companyName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
data: workingCopy,
|
||||
projectCatalog: catalog,
|
||||
selectedProjectIds: selectedIds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importResume(
|
||||
payload: RxResumeImportPayload,
|
||||
options: ResolveModeOptions = {},
|
||||
): Promise<string> {
|
||||
return runRxResumeOperation(options, {
|
||||
v5: async (creds) =>
|
||||
await v5.importResume(
|
||||
{
|
||||
name: payload.name?.trim() || "JobOps Tailored Resume",
|
||||
slug: payload.slug?.trim() || "",
|
||||
data: payload.data,
|
||||
},
|
||||
{
|
||||
apiKey: creds.apiKey,
|
||||
baseUrl: creds.baseUrl,
|
||||
},
|
||||
),
|
||||
v4: async (creds) =>
|
||||
await v4.importResume(
|
||||
payload as v4.RxResumeImportPayload,
|
||||
toV4Override(creds),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteResume(
|
||||
resumeId: string,
|
||||
options: ResolveModeOptions = {},
|
||||
): Promise<void> {
|
||||
await runRxResumeOperation(options, {
|
||||
v5: async (creds) => {
|
||||
await v5.deleteResume(resumeId, {
|
||||
apiKey: creds.apiKey,
|
||||
baseUrl: creds.baseUrl,
|
||||
});
|
||||
},
|
||||
v4: async (creds) => await v4.deleteResume(resumeId, toV4Override(creds)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportResumePdf(
|
||||
resumeId: string,
|
||||
options: ResolveModeOptions = {},
|
||||
): Promise<string> {
|
||||
return runRxResumeOperation(options, {
|
||||
v5: async (creds) =>
|
||||
await v5.exportResumePdf(resumeId, {
|
||||
apiKey: creds.apiKey,
|
||||
baseUrl: creds.baseUrl,
|
||||
}),
|
||||
v4: async (creds) =>
|
||||
await v4.exportResumePdf(resumeId, toV4Override(creds)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateCredentials(
|
||||
options: ResolveModeOptions = {},
|
||||
): Promise<
|
||||
| { ok: true; mode: RxResumeResolvedMode }
|
||||
| { ok: false; mode?: RxResumeMode; status: number; message: string }
|
||||
> {
|
||||
const requestedMode = options.mode ?? (await readConfiguredMode());
|
||||
const [v5Creds, v4Creds] = await Promise.all([
|
||||
readV5Credentials(options.v5),
|
||||
readV4Credentials(options.v4),
|
||||
]);
|
||||
|
||||
const validateV4 = async () => {
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
v4Creds.email,
|
||||
v4Creds.password,
|
||||
v4Creds.baseUrl,
|
||||
);
|
||||
if (result.ok) return { ok: true as const, mode: "v4" as const };
|
||||
return {
|
||||
ok: false as const,
|
||||
mode: requestedMode,
|
||||
status: result.status,
|
||||
message: result.message || "Reactive Resume v4 validation failed.",
|
||||
};
|
||||
};
|
||||
|
||||
const validateV5 = async () => {
|
||||
const result = await v5.verifyApiKey(v5Creds.apiKey, v5Creds.baseUrl);
|
||||
if (result.ok) return { ok: true as const, mode: "v5" as const };
|
||||
return {
|
||||
ok: false as const,
|
||||
mode: requestedMode,
|
||||
status: result.status,
|
||||
message: result.message || "Reactive Resume v5 validation failed.",
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const mode = await resolveRxResumeMode(options);
|
||||
if (mode === "v5") {
|
||||
return await validateV5();
|
||||
}
|
||||
return await validateV4();
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error);
|
||||
if (normalized instanceof RxResumeAuthConfigError) {
|
||||
return {
|
||||
ok: false,
|
||||
mode: requestedMode,
|
||||
status: 400,
|
||||
message: normalized.message,
|
||||
};
|
||||
}
|
||||
const status =
|
||||
normalized instanceof RxResumeRequestError ? (normalized.status ?? 0) : 0;
|
||||
return {
|
||||
ok: false,
|
||||
mode: requestedMode,
|
||||
status,
|
||||
message: normalized.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
34
orchestrator/src/server/services/rxresume/schema/index.ts
Normal file
34
orchestrator/src/server/services/rxresume/schema/index.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { ZodError } from "zod";
|
||||
import type { RxResumeResolvedMode } from "../index";
|
||||
import { parseV4ResumeData, safeParseV4ResumeData } from "./v4";
|
||||
import { parseV5ResumeData, safeParseV5ResumeData } from "./v5";
|
||||
|
||||
export function parseResumeDataForMode(
|
||||
mode: RxResumeResolvedMode,
|
||||
data: unknown,
|
||||
) {
|
||||
return mode === "v5" ? parseV5ResumeData(data) : parseV4ResumeData(data);
|
||||
}
|
||||
|
||||
export function safeParseResumeDataForMode(
|
||||
mode: RxResumeResolvedMode,
|
||||
data: unknown,
|
||||
) {
|
||||
return mode === "v5"
|
||||
? safeParseV5ResumeData(data)
|
||||
: safeParseV4ResumeData(data);
|
||||
}
|
||||
|
||||
export function getResumeSchemaValidationMessage(error: unknown): string {
|
||||
if (error instanceof ZodError) {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) return "Resume schema validation failed.";
|
||||
const path = issue.path.map(String).join(".");
|
||||
return path
|
||||
? `Resume schema validation failed at "${path}": ${issue.message}`
|
||||
: `Resume schema validation failed: ${issue.message}`;
|
||||
}
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: "Resume schema validation failed.";
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { idSchema, resumeDataSchema, skillSchema } from "./rxresume-schema";
|
||||
import { idSchema, resumeDataSchema, skillSchema } from "./v4";
|
||||
|
||||
describe("RxResume Schema Validation", () => {
|
||||
describe("idSchema (CUID2)", () => {
|
||||
@ -955,3 +955,11 @@ export const sampleResume: ResumeData = {
|
||||
notes: "",
|
||||
},
|
||||
};
|
||||
|
||||
export function parseV4ResumeData(data: unknown) {
|
||||
return resumeDataSchema.parse(data);
|
||||
}
|
||||
|
||||
export function safeParseV4ResumeData(data: unknown) {
|
||||
return resumeDataSchema.safeParse(data);
|
||||
}
|
||||
87
orchestrator/src/server/services/rxresume/schema/v5.ts
Normal file
87
orchestrator/src/server/services/rxresume/schema/v5.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const looseObject = z.object({}).passthrough();
|
||||
|
||||
const v5UrlSchema = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
label: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const v5ProjectItemSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
hidden: z.boolean(),
|
||||
name: z.string(),
|
||||
period: z.string(),
|
||||
website: v5UrlSchema,
|
||||
description: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const v5SectionBaseSchema = z
|
||||
.object({
|
||||
title: z.string(),
|
||||
columns: z.number(),
|
||||
hidden: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const v5ProjectsSectionSchema = v5SectionBaseSchema.extend({
|
||||
items: z.array(v5ProjectItemSchema),
|
||||
});
|
||||
|
||||
const v5SummarySectionSchema = v5SectionBaseSchema.extend({
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
const v5SkillItemSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
hidden: z.boolean(),
|
||||
icon: z.string(),
|
||||
name: z.string(),
|
||||
proficiency: z.string(),
|
||||
level: z.number(),
|
||||
keywords: z.array(z.string()),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const v5SkillsSectionSchema = v5SectionBaseSchema.extend({
|
||||
items: z.array(v5SkillItemSchema),
|
||||
});
|
||||
|
||||
export const v5ResumeDataSchema = z
|
||||
.object({
|
||||
picture: looseObject,
|
||||
basics: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
headline: z.string(),
|
||||
email: z.string(),
|
||||
phone: z.string(),
|
||||
location: z.string(),
|
||||
website: v5UrlSchema,
|
||||
customFields: z.array(looseObject),
|
||||
})
|
||||
.passthrough(),
|
||||
summary: v5SummarySectionSchema,
|
||||
sections: z
|
||||
.object({
|
||||
projects: v5ProjectsSectionSchema,
|
||||
skills: v5SkillsSectionSchema,
|
||||
})
|
||||
.passthrough(),
|
||||
customSections: z.array(looseObject),
|
||||
metadata: looseObject,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export function parseV5ResumeData(data: unknown) {
|
||||
return v5ResumeDataSchema.parse(data);
|
||||
}
|
||||
|
||||
export function safeParseV5ResumeData(data: unknown) {
|
||||
return v5ResumeDataSchema.safeParse(data);
|
||||
}
|
||||
438
orchestrator/src/server/services/rxresume/tailoring.ts
Normal file
438
orchestrator/src/server/services/rxresume/tailoring.ts
Normal file
@ -0,0 +1,438 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types";
|
||||
import { stripHtmlTags } from "@shared/utils/string";
|
||||
import {
|
||||
getResumeSchemaValidationMessage,
|
||||
safeParseResumeDataForMode,
|
||||
} from "./schema";
|
||||
|
||||
type RecordLike = Record<string, unknown>;
|
||||
|
||||
export type TailoredSkillsInput =
|
||||
| Array<{ name: string; keywords: string[] }>
|
||||
| string
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type TailorChunkInput = {
|
||||
headline?: string | null;
|
||||
summary?: string | null;
|
||||
skills?: TailoredSkillsInput;
|
||||
};
|
||||
|
||||
export type ResumeProjectSelectionItem = ResumeProjectCatalogItem & {
|
||||
summaryText: string;
|
||||
};
|
||||
|
||||
export function cloneResumeData<T>(data: T): T {
|
||||
return JSON.parse(JSON.stringify(data)) as T;
|
||||
}
|
||||
|
||||
export function validateAndParseResumeDataForMode(
|
||||
mode: RxResumeMode,
|
||||
data: unknown,
|
||||
):
|
||||
| { ok: true; mode: RxResumeMode; data: RecordLike }
|
||||
| { ok: false; mode: RxResumeMode; message: string } {
|
||||
const result = safeParseResumeDataForMode(mode, data);
|
||||
if (!result.success) {
|
||||
return {
|
||||
ok: false,
|
||||
mode,
|
||||
message: getResumeSchemaValidationMessage(result.error),
|
||||
};
|
||||
}
|
||||
if (
|
||||
!result.data ||
|
||||
typeof result.data !== "object" ||
|
||||
Array.isArray(result.data)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
mode,
|
||||
message:
|
||||
"Resume schema validation failed: root payload must be an object.",
|
||||
};
|
||||
}
|
||||
return { ok: true, mode, data: result.data as RecordLike };
|
||||
}
|
||||
|
||||
export function inferRxResumeModeFromData(data: unknown): RxResumeMode | null {
|
||||
const v5 = safeParseResumeDataForMode("v5", data);
|
||||
if (v5.success) return "v5";
|
||||
const v4 = safeParseResumeDataForMode("v4", data);
|
||||
if (v4.success) return "v4";
|
||||
return null;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): RecordLike | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as RecordLike)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] | null {
|
||||
return Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseTailoredSkills(
|
||||
skills: TailoredSkillsInput,
|
||||
): Array<RecordLike> | null {
|
||||
if (!skills) return null;
|
||||
const parsed = Array.isArray(skills)
|
||||
? skills
|
||||
: typeof skills === "string"
|
||||
? (JSON.parse(skills) as unknown)
|
||||
: null;
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter(
|
||||
(item) => item && typeof item === "object",
|
||||
) as RecordLike[];
|
||||
}
|
||||
|
||||
export function applyTailoredHeadline(
|
||||
mode: RxResumeMode,
|
||||
resumeData: RecordLike,
|
||||
headline?: string | null,
|
||||
): void {
|
||||
if (!headline) return;
|
||||
const basics = asRecord(resumeData.basics);
|
||||
if (!basics) return;
|
||||
basics.headline = headline;
|
||||
// Preserve current behavior for legacy consumers/templates that use label.
|
||||
basics.label = headline;
|
||||
if (mode === "v5") return;
|
||||
}
|
||||
|
||||
export function applyTailoredSummary(
|
||||
mode: RxResumeMode,
|
||||
resumeData: RecordLike,
|
||||
summary?: string | null,
|
||||
): void {
|
||||
if (!summary) return;
|
||||
if (mode === "v5") {
|
||||
const topSummary = asRecord(resumeData.summary);
|
||||
if (topSummary) {
|
||||
if (
|
||||
typeof topSummary.content === "string" ||
|
||||
topSummary.content === undefined
|
||||
) {
|
||||
topSummary.content = summary;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof topSummary.value === "string" ||
|
||||
topSummary.value === undefined
|
||||
) {
|
||||
topSummary.value = summary;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sections = asRecord(resumeData.sections);
|
||||
const summarySection = asRecord(sections?.summary);
|
||||
if (summarySection) {
|
||||
summarySection.content = summary;
|
||||
return;
|
||||
}
|
||||
|
||||
const basics = asRecord(resumeData.basics);
|
||||
if (basics) basics.summary = summary;
|
||||
}
|
||||
|
||||
function sanitizeV4SkillsSection(resumeData: RecordLike): void {
|
||||
const sections = asRecord(resumeData.sections);
|
||||
const skillsSection = asRecord(sections?.skills);
|
||||
const items = asArray(skillsSection?.items);
|
||||
if (!skillsSection || !items) return;
|
||||
|
||||
skillsSection.items = items.map((raw) => {
|
||||
const skill = asRecord(raw) ?? {};
|
||||
return {
|
||||
...skill,
|
||||
id: typeof skill.id === "string" && skill.id ? skill.id : createId(),
|
||||
visible: typeof skill.visible === "boolean" ? skill.visible : true,
|
||||
description:
|
||||
typeof skill.description === "string" ? skill.description : "",
|
||||
level: typeof skill.level === "number" ? skill.level : 1,
|
||||
keywords: Array.isArray(skill.keywords)
|
||||
? skill.keywords.filter((k) => typeof k === "string")
|
||||
: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function applyTailoredSkillsV4(
|
||||
resumeData: RecordLike,
|
||||
skills: Array<RecordLike>,
|
||||
): void {
|
||||
sanitizeV4SkillsSection(resumeData);
|
||||
const sections = asRecord(resumeData.sections);
|
||||
const skillsSection = asRecord(sections?.skills);
|
||||
if (!skillsSection) return;
|
||||
const existingItems = asArray(skillsSection.items) ?? [];
|
||||
const existing = existingItems
|
||||
.map((item) => asRecord(item))
|
||||
.filter((item): item is RecordLike => Boolean(item));
|
||||
|
||||
skillsSection.items = skills.map((newSkill) => {
|
||||
const match = existing.find((item) => item.name === newSkill.name);
|
||||
return {
|
||||
id:
|
||||
(typeof newSkill.id === "string" && newSkill.id) ||
|
||||
(match && typeof match.id === "string" ? match.id : "") ||
|
||||
createId(),
|
||||
visible:
|
||||
typeof newSkill.visible === "boolean"
|
||||
? newSkill.visible
|
||||
: typeof match?.visible === "boolean"
|
||||
? match.visible
|
||||
: true,
|
||||
name:
|
||||
(typeof newSkill.name === "string" ? newSkill.name : "") ||
|
||||
(typeof match?.name === "string" ? match.name : ""),
|
||||
description:
|
||||
typeof newSkill.description === "string"
|
||||
? newSkill.description
|
||||
: typeof match?.description === "string"
|
||||
? match.description
|
||||
: "",
|
||||
level:
|
||||
typeof newSkill.level === "number"
|
||||
? newSkill.level
|
||||
: typeof match?.level === "number"
|
||||
? match.level
|
||||
: 0,
|
||||
keywords: Array.isArray(newSkill.keywords)
|
||||
? newSkill.keywords.filter((k) => typeof k === "string")
|
||||
: Array.isArray(match?.keywords)
|
||||
? match.keywords.filter((k) => typeof k === "string")
|
||||
: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function applyTailoredSkillsV5(
|
||||
resumeData: RecordLike,
|
||||
skills: Array<RecordLike>,
|
||||
): void {
|
||||
const sections = asRecord(resumeData.sections);
|
||||
const skillsSection = asRecord(sections?.skills);
|
||||
const existingItems = asArray(skillsSection?.items);
|
||||
if (!skillsSection || !existingItems) return;
|
||||
const existing = existingItems
|
||||
.map((item) => asRecord(item))
|
||||
.filter((item): item is RecordLike => Boolean(item));
|
||||
|
||||
const template = existing[0] ?? null;
|
||||
if (!template) return;
|
||||
|
||||
skillsSection.items = skills.map((newSkill) => {
|
||||
const match =
|
||||
existing.find((item) => item.name === newSkill.name) ?? template;
|
||||
const next: RecordLike = { ...match };
|
||||
|
||||
if ("id" in next) {
|
||||
next.id =
|
||||
(typeof newSkill.id === "string" && newSkill.id) ||
|
||||
(typeof match.id === "string" ? match.id : "") ||
|
||||
createId();
|
||||
}
|
||||
if ("name" in next) {
|
||||
next.name =
|
||||
(typeof newSkill.name === "string" ? newSkill.name : "") ||
|
||||
(typeof match.name === "string" ? match.name : "");
|
||||
}
|
||||
if ("keywords" in next) {
|
||||
next.keywords = Array.isArray(newSkill.keywords)
|
||||
? newSkill.keywords.filter((k) => typeof k === "string")
|
||||
: Array.isArray(match.keywords)
|
||||
? match.keywords.filter((k) => typeof k === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
// Only patch optional fields when the instance already uses them.
|
||||
if ("description" in next) {
|
||||
next.description =
|
||||
typeof newSkill.description === "string"
|
||||
? newSkill.description
|
||||
: typeof match.description === "string"
|
||||
? match.description
|
||||
: "";
|
||||
}
|
||||
if ("proficiency" in next) {
|
||||
next.proficiency =
|
||||
typeof newSkill.proficiency === "string"
|
||||
? newSkill.proficiency
|
||||
: typeof newSkill.description === "string"
|
||||
? newSkill.description
|
||||
: typeof match.proficiency === "string"
|
||||
? match.proficiency
|
||||
: "";
|
||||
}
|
||||
if ("level" in next) {
|
||||
next.level =
|
||||
typeof newSkill.level === "number"
|
||||
? newSkill.level
|
||||
: typeof match.level === "number"
|
||||
? match.level
|
||||
: next.level;
|
||||
}
|
||||
if ("hidden" in next) {
|
||||
next.hidden =
|
||||
typeof newSkill.hidden === "boolean"
|
||||
? newSkill.hidden
|
||||
: typeof match.hidden === "boolean"
|
||||
? match.hidden
|
||||
: next.hidden;
|
||||
}
|
||||
if ("visible" in next) {
|
||||
next.visible =
|
||||
typeof newSkill.visible === "boolean"
|
||||
? newSkill.visible
|
||||
: typeof match.visible === "boolean"
|
||||
? match.visible
|
||||
: next.visible;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function applyTailoredSkills(
|
||||
mode: RxResumeMode,
|
||||
resumeData: RecordLike,
|
||||
tailoredSkills?: TailoredSkillsInput,
|
||||
): void {
|
||||
const parsed = parseTailoredSkills(tailoredSkills);
|
||||
if (!parsed) {
|
||||
if (mode === "v4") sanitizeV4SkillsSection(resumeData);
|
||||
return;
|
||||
}
|
||||
if (mode === "v4") {
|
||||
applyTailoredSkillsV4(resumeData, parsed);
|
||||
return;
|
||||
}
|
||||
applyTailoredSkillsV5(resumeData, parsed);
|
||||
}
|
||||
|
||||
export function extractProjectsFromResume(
|
||||
mode: RxResumeMode,
|
||||
resumeData: RecordLike,
|
||||
): {
|
||||
catalog: ResumeProjectCatalogItem[];
|
||||
selectionItems: ResumeProjectSelectionItem[];
|
||||
} {
|
||||
const sections = asRecord(resumeData.sections);
|
||||
const projectsSection = asRecord(sections?.projects);
|
||||
const items = asArray(projectsSection?.items);
|
||||
if (!items) return { catalog: [], selectionItems: [] };
|
||||
|
||||
const catalog: ResumeProjectCatalogItem[] = [];
|
||||
const selectionItems: ResumeProjectSelectionItem[] = [];
|
||||
|
||||
for (const raw of items) {
|
||||
const item = asRecord(raw);
|
||||
if (!item) continue;
|
||||
const id = typeof item.id === "string" ? item.id : "";
|
||||
if (!id) continue;
|
||||
|
||||
const name = typeof item.name === "string" ? item.name : id;
|
||||
const description =
|
||||
typeof item.description === "string" ? item.description : "";
|
||||
const date =
|
||||
mode === "v5"
|
||||
? typeof item.period === "string"
|
||||
? item.period
|
||||
: ""
|
||||
: typeof item.date === "string"
|
||||
? item.date
|
||||
: "";
|
||||
|
||||
const isVisibleInBase =
|
||||
mode === "v5"
|
||||
? !(typeof item.hidden === "boolean" ? item.hidden : false)
|
||||
: Boolean(item.visible);
|
||||
|
||||
const summaryRaw =
|
||||
mode === "v5"
|
||||
? description
|
||||
: typeof item.summary === "string"
|
||||
? item.summary
|
||||
: "";
|
||||
|
||||
const base: ResumeProjectCatalogItem = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
date,
|
||||
isVisibleInBase,
|
||||
};
|
||||
catalog.push(base);
|
||||
selectionItems.push({
|
||||
...base,
|
||||
summaryText: stripHtmlTags(summaryRaw),
|
||||
});
|
||||
}
|
||||
|
||||
return { catalog, selectionItems };
|
||||
}
|
||||
|
||||
export function applyProjectVisibility(args: {
|
||||
mode: RxResumeMode;
|
||||
resumeData: RecordLike;
|
||||
selectedProjectIds: ReadonlySet<string>;
|
||||
forceVisibleProjectsSection?: boolean;
|
||||
}): void {
|
||||
const sections = asRecord(args.resumeData.sections);
|
||||
const projectsSection = asRecord(sections?.projects);
|
||||
const items = asArray(projectsSection?.items);
|
||||
if (!projectsSection || !items) return;
|
||||
|
||||
for (const raw of items) {
|
||||
const item = asRecord(raw);
|
||||
if (!item) continue;
|
||||
const id = typeof item.id === "string" ? item.id : "";
|
||||
if (!id) continue;
|
||||
if (args.mode === "v5") {
|
||||
if ("hidden" in item) {
|
||||
item.hidden = !args.selectedProjectIds.has(id);
|
||||
} else if ("visible" in item) {
|
||||
item.visible = args.selectedProjectIds.has(id);
|
||||
}
|
||||
} else {
|
||||
item.visible = args.selectedProjectIds.has(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.forceVisibleProjectsSection !== false) {
|
||||
if (args.mode === "v5") {
|
||||
if ("hidden" in projectsSection) {
|
||||
projectsSection.hidden = false;
|
||||
} else if ("visible" in projectsSection) {
|
||||
projectsSection.visible = true;
|
||||
}
|
||||
} else {
|
||||
projectsSection.visible = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTailoredChunks(args: {
|
||||
mode: RxResumeMode;
|
||||
resumeData: RecordLike;
|
||||
tailoredContent: TailorChunkInput;
|
||||
}): void {
|
||||
applyTailoredSkills(args.mode, args.resumeData, args.tailoredContent.skills);
|
||||
applyTailoredSummary(
|
||||
args.mode,
|
||||
args.resumeData,
|
||||
args.tailoredContent.summary,
|
||||
);
|
||||
applyTailoredHeadline(
|
||||
args.mode,
|
||||
args.resumeData,
|
||||
args.tailoredContent.headline,
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,12 @@
|
||||
// rxresume-v4.ts
|
||||
// rxresume/v4.ts
|
||||
// Service wrapper around the v4 client that mirrors the v5 helper API.
|
||||
// - Pulls credentials from env/settings.
|
||||
// - Validates resume payloads.
|
||||
// - Keeps the rest of the app v5-ready (swap imports later).
|
||||
|
||||
import type { ResumeData } from "@shared/rxresume-schema";
|
||||
import { resumeDataSchema } from "@shared/rxresume-schema";
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { RxResumeClient, type RxResumeResume } from "./rxresume-client";
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { RxResumeClient, type RxResumeResume } from "./client";
|
||||
import { parseV4ResumeData, type ResumeData } from "./schema/v4";
|
||||
|
||||
export type RxResumeCredentials = {
|
||||
email: string;
|
||||
@ -78,16 +77,20 @@ export async function getResume(
|
||||
resumeId: string,
|
||||
override?: Partial<RxResumeCredentials>,
|
||||
): Promise<RxResumeResume> {
|
||||
return withRxResumeClient(override, (client, token) =>
|
||||
const resume = await withRxResumeClient(override, (client, token) =>
|
||||
client.get(resumeId, token),
|
||||
);
|
||||
if (resume.data) {
|
||||
resume.data = parseV4ResumeData(resume.data) as ResumeData;
|
||||
}
|
||||
return resume;
|
||||
}
|
||||
|
||||
export async function importResume(
|
||||
payload: RxResumeImportPayload,
|
||||
override?: Partial<RxResumeCredentials>,
|
||||
): Promise<string> {
|
||||
const data = resumeDataSchema.parse(payload.data);
|
||||
const data = parseV4ResumeData(payload.data) as ResumeData;
|
||||
const title = payload.name?.trim() || undefined;
|
||||
const slug = payload.slug?.trim() || undefined;
|
||||
|
||||
100
orchestrator/src/server/services/rxresume/v5.test.ts
Normal file
100
orchestrator/src/server/services/rxresume/v5.test.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { sampleResume } from "./schema/v4";
|
||||
import {
|
||||
deleteResume,
|
||||
exportResumePdf,
|
||||
fetchRxResume,
|
||||
getResume,
|
||||
importResume,
|
||||
listResumes,
|
||||
} from "./v5";
|
||||
|
||||
function jsonResponse(data: unknown, ok = true, status = 200) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? "OK" : "Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
};
|
||||
}
|
||||
|
||||
describe("rxresume v5 endpoints", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("normalizes base URL and calls /api/openapi", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ ok: true }));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
vi.stubEnv("RXRESUME_API_KEY", "test-key");
|
||||
|
||||
await fetchRxResume("/resumes", {}, { baseUrl: "https://rxresu.me/api" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://rxresu.me/api/openapi/resumes",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ "x-api-key": "test-key" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses v5 get/list/import/delete/pdf endpoints", async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([]))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ id: "resume-123", name: "Resume", slug: "resume" }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "imported-123" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ url: "https://rxresu.me/storage/resume-123.pdf" }),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
vi.stubEnv("RXRESUME_API_KEY", "test-key");
|
||||
|
||||
await listResumes({ baseUrl: "https://rxresu.me" });
|
||||
await getResume("resume-123", { baseUrl: "https://rxresu.me" });
|
||||
await importResume(
|
||||
{ data: sampleResume, name: "Imported Resume" },
|
||||
{ baseUrl: "https://rxresu.me" },
|
||||
);
|
||||
await deleteResume("resume-123", { baseUrl: "https://rxresu.me" });
|
||||
await exportResumePdf("resume-123", { baseUrl: "https://rxresu.me" });
|
||||
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://rxresu.me/api/openapi/resumes",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://rxresu.me/api/openapi/resumes/resume-123",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"https://rxresu.me/api/openapi/resumes/import",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"https://rxresu.me/api/openapi/resumes/resume-123",
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
"https://rxresu.me/api/openapi/resumes/resume-123/pdf",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
263
orchestrator/src/server/services/rxresume/v5.ts
Normal file
263
orchestrator/src/server/services/rxresume/v5.ts
Normal file
@ -0,0 +1,263 @@
|
||||
// rxresume/v5.ts
|
||||
// Reactive Resume v5/OpenAPI implementation (API key auth).
|
||||
import { parseV4ResumeData, type ResumeData } from "./schema/v4";
|
||||
import { parseV5ResumeData } from "./schema/v5";
|
||||
|
||||
type RxResumeApiConfig = { baseUrl?: string; apiKey?: string };
|
||||
|
||||
export type RxResumeListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
tags: string[];
|
||||
isPublic: boolean;
|
||||
isLocked: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type RxResumeGetByIdResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
tags: string[];
|
||||
data: ResumeData | Record<string, unknown>;
|
||||
isPublic: boolean;
|
||||
isLocked: boolean;
|
||||
hasPassword: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type RxResumeImportRequest = {
|
||||
data: ResumeData | unknown;
|
||||
// Not part of the documented v5 import schema, but accepted by some installs.
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export type RxResumeExportPdfResponse = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type VerifyApiKeyResult =
|
||||
| { ok: true }
|
||||
| { ok: false; status: number; message?: string; details?: unknown };
|
||||
|
||||
const MAX_ERROR_SNIPPET = 300;
|
||||
|
||||
function cleanBaseUrl(baseUrl: string): string {
|
||||
let normalized = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
if (normalized.endsWith("/api/openapi")) {
|
||||
normalized = normalized.slice(0, -12);
|
||||
} else if (normalized.endsWith("/api")) {
|
||||
normalized = normalized.slice(0, -4);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractErrorMessage(data: unknown, fallback: string): string {
|
||||
if (typeof data === "string") return data.slice(0, MAX_ERROR_SNIPPET);
|
||||
if (data && typeof data === "object") {
|
||||
const maybe = data as Record<string, unknown>;
|
||||
for (const key of ["message", "error", "statusMessage"]) {
|
||||
const value = maybe[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim().slice(0, MAX_ERROR_SNIPPET);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback.slice(0, MAX_ERROR_SNIPPET);
|
||||
}
|
||||
|
||||
async function executeWithKeyRetries(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
apiKeyOverride?: string,
|
||||
): Promise<unknown> {
|
||||
const rawApiKey = apiKeyOverride ?? process.env.RXRESUME_API_KEY;
|
||||
if (!rawApiKey) {
|
||||
throw new Error("RXRESUME_API_KEY not configured in environment");
|
||||
}
|
||||
|
||||
const apiKeys = rawApiKey
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
if (apiKeys.length === 0) {
|
||||
throw new Error("RXRESUME_API_KEY not configured in environment");
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < apiKeys.length; attempt++) {
|
||||
const apiKey = apiKeys[attempt];
|
||||
const headers = {
|
||||
"x-api-key": apiKey,
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.headers || {}),
|
||||
} as Record<string, string>;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response
|
||||
.json()
|
||||
.catch(async () => await response.text().catch(() => null));
|
||||
const errorMsg = extractErrorMessage(errorBody, response.statusText);
|
||||
|
||||
if (
|
||||
response.status === 401 &&
|
||||
apiKeys.length > 1 &&
|
||||
attempt < apiKeys.length - 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Reactive Resume API error (${response.status}): ${errorMsg}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
return response.json();
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
throw new Error("All Reactive Resume API keys failed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fetch helper for Reactive Resume API
|
||||
*/
|
||||
export async function fetchRxResume(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
config?: RxResumeApiConfig,
|
||||
): Promise<unknown> {
|
||||
const baseUrl =
|
||||
config?.baseUrl ?? process.env.RXRESUME_URL ?? "https://rxresu.me";
|
||||
const url = `${cleanBaseUrl(baseUrl)}/api/openapi${path}`;
|
||||
return executeWithKeyRetries(url, options, config?.apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a resume by its ID.
|
||||
*/
|
||||
export async function getResume(
|
||||
id: string,
|
||||
config?: RxResumeApiConfig,
|
||||
): Promise<RxResumeGetByIdResponse> {
|
||||
const payload = (await fetchRxResume(
|
||||
`/resumes/${id}`,
|
||||
{},
|
||||
config,
|
||||
)) as RxResumeGetByIdResponse;
|
||||
if (payload.data !== undefined) {
|
||||
payload.data = parseV5ResumeData(payload.data) as
|
||||
| ResumeData
|
||||
| Record<string, unknown>;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function verifyApiKey(
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
): Promise<VerifyApiKeyResult> {
|
||||
try {
|
||||
const payload = await fetchRxResume("/resumes", {}, { apiKey, baseUrl });
|
||||
if (!Array.isArray(payload)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
message: extractErrorMessage(
|
||||
payload,
|
||||
"Reactive Resume v5 validation failed: unexpected response payload.",
|
||||
),
|
||||
details: payload,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Network error";
|
||||
const match = /error\s*\((\d+)\)/i.exec(message);
|
||||
return {
|
||||
ok: false,
|
||||
status: match ? Number(match[1]) : 0,
|
||||
message,
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a resume.
|
||||
*/
|
||||
export async function importResume(
|
||||
payload: RxResumeImportRequest,
|
||||
config?: RxResumeApiConfig,
|
||||
): Promise<string> {
|
||||
try {
|
||||
payload.data = parseV5ResumeData(payload.data);
|
||||
} catch {
|
||||
// JobOps still generates the legacy/internal resume shape for tailoring.
|
||||
// Accept it for v5 imports until the write path is upgraded to a native v5 schema.
|
||||
payload.data = parseV4ResumeData(payload.data);
|
||||
}
|
||||
|
||||
const result = (await fetchRxResume(
|
||||
"/resumes/import",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
config,
|
||||
)) as { id: string } | string;
|
||||
|
||||
// Reactive Resume returns the full resume object on import in v4+, or just ID in v5.
|
||||
return typeof result === "string" ? result : result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a resume.
|
||||
*/
|
||||
export async function deleteResume(
|
||||
id: string,
|
||||
config?: RxResumeApiConfig,
|
||||
): Promise<void> {
|
||||
await fetchRxResume(
|
||||
`/resumes/${id}`,
|
||||
{ method: "DELETE", body: JSON.stringify({}) },
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a resume as PDF. Returns the URL.
|
||||
*/
|
||||
export async function exportResumePdf(
|
||||
id: string,
|
||||
config?: RxResumeApiConfig,
|
||||
): Promise<string> {
|
||||
const result = (await fetchRxResume(
|
||||
`/resumes/${id}/pdf`,
|
||||
{},
|
||||
config,
|
||||
)) as RxResumeExportPdfResponse;
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all resumes.
|
||||
* According to official OpenAPI spec, the endpoint is /resumes
|
||||
*/
|
||||
export async function listResumes(config?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
}): Promise<RxResumeListItem[]> {
|
||||
return (await fetchRxResume("/resumes", {}, config)) as RxResumeListItem[];
|
||||
}
|
||||
@ -6,6 +6,10 @@ import {
|
||||
extractProjectsFromProfile,
|
||||
normalizeResumeProjectsSettings,
|
||||
} from "@server/services/resumeProjects";
|
||||
import {
|
||||
getRxResumeBaseResumeIdKey,
|
||||
normalizeRxResumeMode,
|
||||
} from "@server/services/rxresume/baseResumeId";
|
||||
import { settingsRegistry } from "@shared/settings-registry";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||
|
||||
@ -96,6 +100,31 @@ for (const [key, def] of Object.entries(settingsRegistry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "rxresumeBaseResumeId") {
|
||||
settingsUpdateRegistry.rxresumeBaseResumeId = async ({
|
||||
value,
|
||||
context,
|
||||
}) => {
|
||||
const serialized = normalizeEnvInput(value as string | null | undefined);
|
||||
const mode = normalizeRxResumeMode(
|
||||
context.input.rxresumeMode ??
|
||||
(await settingsRepo.getSetting("rxresumeMode")) ??
|
||||
process.env.RXRESUME_MODE ??
|
||||
null,
|
||||
);
|
||||
const modeSpecificKey = getRxResumeBaseResumeIdKey(mode);
|
||||
|
||||
return result({
|
||||
actions: [
|
||||
// Keep the legacy/current key in sync for compatibility and fallback.
|
||||
persistAction("rxresumeBaseResumeId", serialized),
|
||||
persistAction(modeSpecificKey, serialized),
|
||||
],
|
||||
});
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generic handler for all others
|
||||
settingsUpdateRegistry[key as keyof UpdateSettingsInput] = ({ value }) => {
|
||||
let serialized: string | null;
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import * as settingsRepo from "@server/repositories/settings";
|
||||
import { settingsRegistry } from "@shared/settings-registry";
|
||||
import type { AppSettings } from "@shared/types";
|
||||
import { getEnvSettingsData } from "./envSettings";
|
||||
import { getProfile } from "./profile";
|
||||
import { resolveResumeProjectsSettings } from "./resumeProjects";
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
resolveResumeProjectsSettings,
|
||||
} from "./resumeProjects";
|
||||
import { getResume, RxResumeCredentialsError } from "./rxresume-v4";
|
||||
extractProjectsFromResume,
|
||||
getResume,
|
||||
RxResumeAuthConfigError,
|
||||
} from "./rxresume";
|
||||
import { resolveRxResumeBaseResumeIdForMode } from "./rxresume/baseResumeId";
|
||||
|
||||
function resolveDefaultLlmBaseUrl(provider: string): string {
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
@ -28,7 +31,12 @@ function resolveDefaultLlmBaseUrl(provider: string): string {
|
||||
export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
const overrides = await settingsRepo.getAllSettings();
|
||||
|
||||
const rxresumeBaseResumeId = overrides.rxresumeBaseResumeId ?? null;
|
||||
const rxresumeBaseResumeId = resolveRxResumeBaseResumeIdForMode({
|
||||
rxresumeMode: overrides.rxresumeMode ?? process.env.RXRESUME_MODE ?? null,
|
||||
rxresumeBaseResumeId: overrides.rxresumeBaseResumeId ?? null,
|
||||
rxresumeBaseResumeIdV4: overrides.rxresumeBaseResumeIdV4 ?? null,
|
||||
rxresumeBaseResumeIdV5: overrides.rxresumeBaseResumeIdV5 ?? null,
|
||||
});
|
||||
let profile: Record<string, unknown> = {};
|
||||
|
||||
if (rxresumeBaseResumeId) {
|
||||
@ -38,22 +46,26 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
profile = resume.data as Record<string, unknown>;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
console.warn(
|
||||
"RxResume credentials missing while loading base resume from settings.",
|
||||
if (error instanceof RxResumeAuthConfigError) {
|
||||
logger.warn(
|
||||
"Reactive Resume credentials missing during settings load",
|
||||
{
|
||||
resumeId: rxresumeBaseResumeId,
|
||||
error,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"Failed to load RxResume base resume for settings:",
|
||||
logger.warn("Failed to load Reactive Resume base resume for settings", {
|
||||
resumeId: rxresumeBaseResumeId,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(profile).length === 0) {
|
||||
profile = await getProfile().catch((error) => {
|
||||
console.warn("Failed to load base resume profile for settings:", error);
|
||||
logger.warn("Failed to load base resume profile for settings", { error });
|
||||
return {};
|
||||
});
|
||||
}
|
||||
@ -90,7 +102,19 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
}
|
||||
|
||||
if (key === "resumeProjects") {
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
let catalog: AppSettings["profileProjects"] = [];
|
||||
if (Object.keys(profile).length > 0) {
|
||||
try {
|
||||
catalog = extractProjectsFromResume(profile).catalog;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
"Failed to extract projects from Reactive Resume data",
|
||||
{
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
const resolved = resolveResumeProjectsSettings({
|
||||
catalog,
|
||||
overrideRaw: rawOverride ?? null,
|
||||
@ -128,5 +152,8 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
// Always expose the effective base resume id for the active RxResume mode.
|
||||
result.rxresumeBaseResumeId = rxresumeBaseResumeId;
|
||||
|
||||
return result as AppSettings;
|
||||
}
|
||||
|
||||
@ -198,6 +198,24 @@ function deriveSourceLabel(sourcePath: string, linkNode: LinkNode): string {
|
||||
return nth ? `${baseLabel} Link ${nth}` : `${baseLabel} Link`;
|
||||
}
|
||||
|
||||
const v5SectionMatch = sourcePath.match(
|
||||
/^sections\.([a-z]+)\.items\[(\d+)\]\.website\.url$/,
|
||||
);
|
||||
if (v5SectionMatch) {
|
||||
const section = v5SectionMatch[1];
|
||||
const index = Number(v5SectionMatch[2]);
|
||||
const nth = Number.isFinite(index) ? index + 1 : null;
|
||||
const sectionLabels: Record<string, string> = {
|
||||
projects: "Project",
|
||||
experience: "Experience",
|
||||
education: "Education",
|
||||
};
|
||||
const baseLabel = sectionLabels[section] ?? "Resume";
|
||||
return nth ? `${baseLabel} Link ${nth}` : `${baseLabel} Link`;
|
||||
}
|
||||
|
||||
if (sourcePath === "basics.website.url") return "Portfolio";
|
||||
|
||||
return "Resume Link";
|
||||
}
|
||||
|
||||
@ -252,6 +270,32 @@ function collectUrlTargets(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "website" && isRecord(value)) {
|
||||
const linkValue = value as { url?: unknown; label?: unknown };
|
||||
const rawHref =
|
||||
typeof linkValue.url === "string" ? linkValue.url.trim() : "";
|
||||
if (rawHref && isHttpUrl(rawHref)) {
|
||||
const sourcePath = `${nextPath}.url`;
|
||||
targets.push({
|
||||
sourcePath,
|
||||
sourceLabel: deriveSourceLabel(sourcePath, {
|
||||
label: linkValue.label,
|
||||
href: rawHref,
|
||||
}),
|
||||
destinationUrl: rawHref,
|
||||
applyTracerUrl: (url: string) => {
|
||||
const currentLabel =
|
||||
typeof linkValue.label === "string" ? linkValue.label.trim() : "";
|
||||
linkValue.url = url;
|
||||
if (!currentLabel || currentLabel === rawHref) {
|
||||
linkValue.label = url;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
collectUrlTargets(value, nextPath, targets);
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,4 +102,19 @@ describe("settingsRegistry helpers", () => {
|
||||
expect(settingsRegistry.resumeProjects.serialize(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RxResume settings", () => {
|
||||
it("parses rxresumeMode enum values and rejects invalid values", () => {
|
||||
expect(settingsRegistry.rxresumeMode.parse("v4")).toBe("v4");
|
||||
expect(settingsRegistry.rxresumeMode.parse("v5")).toBe("v5");
|
||||
expect(settingsRegistry.rxresumeMode.parse("")).toBeNull();
|
||||
expect(settingsRegistry.rxresumeMode.parse("latest")).toBeNull();
|
||||
expect(settingsRegistry.rxresumeMode.serialize("v5")).toBe("v5");
|
||||
expect(settingsRegistry.rxresumeMode.serialize(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("has env-backed v5 api key secret setting", () => {
|
||||
expect(settingsRegistry.rxresumeApiKey.envKey).toBe("RXRESUME_API_KEY");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -136,6 +136,22 @@ export const settingsRegistry = {
|
||||
return value ? JSON.stringify(value) : null;
|
||||
},
|
||||
},
|
||||
rxresumeMode: {
|
||||
kind: "typed" as const,
|
||||
schema: z.enum(["v4", "v5"]),
|
||||
default: (): "v4" | "v5" =>
|
||||
(typeof process !== "undefined"
|
||||
? process.env.RXRESUME_MODE
|
||||
: undefined) === "v4"
|
||||
? "v4"
|
||||
: "v5",
|
||||
parse: (raw: string | undefined): "v4" | "v5" | null => {
|
||||
if (!raw) return null;
|
||||
return raw === "v4" || raw === "v5" ? raw : null;
|
||||
},
|
||||
serialize: (value: "v4" | "v5" | null | undefined): string | null =>
|
||||
value ?? null,
|
||||
},
|
||||
ukvisajobsMaxJobs: {
|
||||
kind: "typed" as const,
|
||||
schema: z.number().int().min(1).max(1000),
|
||||
@ -359,6 +375,14 @@ export const settingsRegistry = {
|
||||
kind: "string" as const,
|
||||
schema: z.string().trim().max(200),
|
||||
},
|
||||
rxresumeBaseResumeIdV4: {
|
||||
kind: "string" as const,
|
||||
schema: z.string().trim().max(200),
|
||||
},
|
||||
rxresumeBaseResumeIdV5: {
|
||||
kind: "string" as const,
|
||||
schema: z.string().trim().max(200),
|
||||
},
|
||||
rxresumeEmail: {
|
||||
kind: "string" as const,
|
||||
envKey: "RXRESUME_EMAIL",
|
||||
@ -391,6 +415,11 @@ export const settingsRegistry = {
|
||||
envKey: "RXRESUME_PASSWORD",
|
||||
schema: z.string().trim().max(2000),
|
||||
},
|
||||
rxresumeApiKey: {
|
||||
kind: "secret" as const,
|
||||
envKey: "RXRESUME_API_KEY",
|
||||
schema: z.string().trim().max(2000),
|
||||
},
|
||||
ukvisajobsPassword: {
|
||||
kind: "secret" as const,
|
||||
envKey: "UKVISAJOBS_PASSWORD",
|
||||
|
||||
@ -148,6 +148,8 @@ export const createAppSettings = (
|
||||
override: null,
|
||||
},
|
||||
rxresumeBaseResumeId: null,
|
||||
rxresumeBaseResumeIdV4: null,
|
||||
rxresumeBaseResumeIdV5: null,
|
||||
ukvisajobsMaxJobs: { value: 50, default: 50, override: null },
|
||||
adzunaMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
||||
gradcrackerMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
||||
@ -182,6 +184,7 @@ export const createAppSettings = (
|
||||
chatStyleConstraints: { value: "", default: "", override: null },
|
||||
chatStyleDoNotUse: { value: "", default: "", override: null },
|
||||
llmApiKeyHint: null,
|
||||
rxresumeApiKeyHint: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumePasswordHint: null,
|
||||
basicAuthUser: null,
|
||||
@ -198,5 +201,6 @@ export const createAppSettings = (
|
||||
penalizeMissingSalary: { value: false, default: false, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
autoSkipScoreThreshold: { value: null, default: null, override: null },
|
||||
rxresumeMode: { value: "v5", default: "v5", override: null },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@ -12,6 +12,8 @@ export interface ResumeProjectsSettings {
|
||||
aiSelectableProjectIds: string[];
|
||||
}
|
||||
|
||||
export type RxResumeMode = "v4" | "v5";
|
||||
|
||||
export interface ResumeProfile {
|
||||
basics?: {
|
||||
name?: string;
|
||||
@ -138,6 +140,7 @@ export interface AppSettings {
|
||||
penalizeMissingSalary: Resolved<boolean>;
|
||||
missingSalaryPenalty: Resolved<number>;
|
||||
autoSkipScoreThreshold: Resolved<number | null>;
|
||||
rxresumeMode: Resolved<RxResumeMode>;
|
||||
|
||||
// Model variants (no own default, fallback to model.value):
|
||||
modelScorer: ModelResolved;
|
||||
@ -146,6 +149,8 @@ export interface AppSettings {
|
||||
|
||||
// Simple strings:
|
||||
rxresumeBaseResumeId: string | null;
|
||||
rxresumeBaseResumeIdV4: string | null;
|
||||
rxresumeBaseResumeIdV5: string | null;
|
||||
rxresumeEmail: string | null;
|
||||
ukvisajobsEmail: string | null;
|
||||
adzunaAppId: string | null;
|
||||
@ -153,6 +158,7 @@ export interface AppSettings {
|
||||
|
||||
// Secret hints:
|
||||
llmApiKeyHint: string | null;
|
||||
rxresumeApiKeyHint: string | null;
|
||||
rxresumePasswordHint: string | null;
|
||||
ukvisajobsPasswordHint: string | null;
|
||||
adzunaAppKeyHint: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user