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