feat: keyword sets, sponsorship signals, and extractor/profile fixes
CI / skip-ci-check (push) Successful in 10s
CI / secret-scan (push) Successful in 14s
CI / docker-ci (push) Successful in 17s

Add per-profile keyword sets, job source settings, sponsorship signal pills,
and several ATS extractors. Fix Hiring Cafe discovery via Next.js SSR search,
profile activate for comma-separated basicAuthUser aliases, and resume path
backfill migrations. Update settings and hiring-cafe docs; localhost compose
overlay for loopback-only deploys.
This commit is contained in:
2026-06-11 10:23:39 -04:00
parent ecf1829ed8
commit 84e6835b11
117 changed files with 7123 additions and 3029 deletions
+47 -53
View File
@@ -25,6 +25,7 @@ import type {
JobsListResponse,
JobsRevisionResponse,
JobTracerLinksResponse,
KeywordSet,
ManualJobDraft,
ManualJobFetchResponse,
ManualJobInferenceResponse,
@@ -39,7 +40,6 @@ import type {
ProfileStatusResponse,
ResumeProfile,
ResumeProjectCatalogItem,
RxResumeMode,
SearchProfile,
StageEvent,
StageEventMetadata,
@@ -393,9 +393,14 @@ export async function activateSearchProfileForBasicAuthUser(
return;
}
if (!listPayload.ok || !Array.isArray(listPayload.data)) return;
const match = listPayload.data.find(
(row) => (row.data?.basicAuthUser ?? "").trim() === username.trim(),
);
const normalizedUsername = username.trim().toLowerCase();
const match = listPayload.data.find((row) => {
const aliases = (row.data?.basicAuthUser ?? "")
.split(",")
.map((part) => part.trim().toLowerCase())
.filter(Boolean);
return aliases.includes(normalizedUsername);
});
if (!match) return;
await fetch(`${API_BASE}/profiles/${match.id}/activate`, {
method: "POST",
@@ -1533,19 +1538,6 @@ export async function getProfileProjects(): Promise<
export async function getResumeProjectsCatalog(): Promise<
ResumeProjectCatalogItem[]
> {
try {
const settings = await getSettings();
if (settings.rxresumeBaseResumeId) {
return await getRxResumeProjects(
settings.rxresumeBaseResumeId,
undefined,
settings.rxresumeMode?.value,
);
}
} catch {
// fall through to profile-based projects
}
return getProfileProjects();
}
@@ -1586,19 +1578,6 @@ export async function getLlmModels(input?: {
return data.models;
}
export async function validateRxresume(input?: {
mode?: "v4" | "v5";
email?: string;
password?: string;
apiKey?: string;
baseUrl?: string;
}): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/onboarding/validate/rxresume", {
method: "POST",
body: JSON.stringify(input ?? {}),
});
}
export async function validateResumeConfig(): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/onboarding/validate/resume");
}
@@ -1612,29 +1591,6 @@ export async function updateSettings(
});
}
export async function getRxResumes(
mode?: RxResumeMode,
): Promise<{ id: string; name: string }[]> {
const query = mode ? `?mode=${encodeURIComponent(mode)}` : "";
const data = await fetchApi<{ resumes: { id: string; name: string }[] }>(
`/settings/rx-resumes${query}`,
);
return data.resumes;
}
export async function getRxResumeProjects(
resumeId: string,
signal?: AbortSignal,
mode?: RxResumeMode,
): Promise<ResumeProjectCatalogItem[]> {
const query = mode ? `?mode=${encodeURIComponent(mode)}` : "";
const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>(
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects${query}`,
{ signal },
);
return data.projects;
}
// Database API
export async function clearDatabase(): Promise<{
message: string;
@@ -1790,3 +1746,41 @@ export async function generateProfileFromResume(): Promise<JobSearchProfile> {
method: "POST",
});
}
// Keyword sets API
export async function listKeywordSets(): Promise<KeywordSet[]> {
return fetchApi<KeywordSet[]>("/keyword-sets");
}
export async function createKeywordSet(input: {
name: string;
terms?: string[];
}): Promise<KeywordSet> {
return fetchApi<KeywordSet>("/keyword-sets", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateKeywordSet(
id: string,
input: { name?: string; terms?: string[] },
): Promise<KeywordSet> {
return fetchApi<KeywordSet>(`/keyword-sets/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export async function deleteKeywordSet(id: string): Promise<void> {
await fetchApi<void>(`/keyword-sets/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
export async function activateKeywordSet(id: string): Promise<KeywordSet> {
return fetchApi<KeywordSet>(
`/keyword-sets/${encodeURIComponent(id)}/activate`,
{ method: "POST" },
);
}
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/tooltip";
import { cn, formatDate, sourceLabel } from "@/lib/utils";
import { useSettings } from "../hooks/useSettings";
import { SponsorshipSignalsPills } from "./SponsorshipSignalsPills";
import {
getJobStatusIndicator,
getTracerStatusIndicator,
@@ -255,6 +256,10 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
onCheck={onCheckSponsor}
/>
)}
<SponsorshipSignalsPills
sponsorshipSignals={job.sponsorshipSignals}
size="xs"
/>
</div>
<ScoreMeter score={job.suitabilityScore} />
</div>
@@ -1,6 +1,6 @@
import * as api from "@client/api";
import { useSettings } from "@client/hooks/useSettings";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
@@ -12,8 +12,6 @@ const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
vi.mock("@client/api", () => ({
getDemoInfo: vi.fn(),
validateLlm: vi.fn(),
validateRxresume: vi.fn(),
validateResumeConfig: vi.fn(),
updateSettings: vi.fn(),
}));
@@ -36,10 +34,6 @@ vi.mock("@client/pages/settings/components/SettingsInput", () => ({
),
}));
vi.mock("@client/pages/settings/components/BaseResumeSelection", () => ({
BaseResumeSelection: () => <div>Base resume selection</div>,
}));
vi.mock("@/components/ui/alert-dialog", () => ({
AlertDialog: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
@@ -58,19 +52,6 @@ vi.mock("@/components/ui/alert-dialog", () => ({
),
}));
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TabsContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
TabsList: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
TabsTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
}));
vi.mock("@/components/ui/select", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
@@ -105,11 +86,6 @@ const settingsResponse = {
settings: {
llmProvider: { value: "openrouter", default: "openrouter", override: null },
llmApiKeyHint: null,
rxresumeEmail: "",
rxresumeUrl: "",
rxresumeApiKeyHint: null,
rxresumePasswordHint: null,
rxresumeBaseResumeId: null,
localResumeProfilePath: null,
localResumeFileConfigured: false,
},
@@ -131,19 +107,11 @@ describe("OnboardingGate", () => {
vi.mocked(useSettings).mockReturnValue(settingsResponse as any);
});
it("renders the gate once validations complete and any fail", async () => {
it("renders the gate when LLM validation fails", async () => {
vi.mocked(api.validateLlm).mockResolvedValue({
valid: false,
message: "Invalid",
});
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: true,
message: null,
});
vi.mocked(api.validateResumeConfig).mockResolvedValue({
valid: true,
message: null,
});
render(<OnboardingGate />);
@@ -151,28 +119,17 @@ describe("OnboardingGate", () => {
await waitFor(() => {
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
});
expect(
screen.queryByLabelText("Local resume path"),
).not.toBeInTheDocument();
expect(screen.queryByText("Resume file")).not.toBeInTheDocument();
});
it("hides the gate when all validations succeed", async () => {
vi.mocked(useSettings).mockReturnValue({
...settingsResponse,
settings: {
...settingsResponse.settings,
rxresumeApiKeyHint: "abcd1234",
},
} as any);
it("hides the gate when LLM validation succeeds", async () => {
vi.mocked(api.validateLlm).mockResolvedValue({
valid: true,
message: null,
});
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: true,
message: null,
});
vi.mocked(api.validateResumeConfig).mockResolvedValue({
valid: true,
message: null,
});
render(<OnboardingGate />);
@@ -180,13 +137,12 @@ describe("OnboardingGate", () => {
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
});
it("hides the gate for Ollama when local resume file is configured on the server", async () => {
it("hides the gate for providers without API keys", async () => {
vi.mocked(useSettings).mockReturnValue({
...settingsResponse,
settings: {
...settingsResponse.settings,
llmProvider: { value: "ollama", default: "ollama", override: null },
localResumeFileConfigured: true,
},
} as any);
@@ -195,79 +151,6 @@ describe("OnboardingGate", () => {
await waitFor(() => {
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
});
expect(api.validateRxresume).not.toHaveBeenCalled();
expect(api.validateResumeConfig).not.toHaveBeenCalled();
});
it("skips LLM key validation for providers without API keys", async () => {
vi.mocked(useSettings).mockReturnValue({
...settingsResponse,
settings: {
...settingsResponse.settings,
llmProvider: { value: "ollama", default: "ollama", override: null },
},
} as any);
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message: "Missing",
});
vi.mocked(api.validateResumeConfig).mockResolvedValue({
valid: true,
message: null,
});
render(<OnboardingGate />);
await waitFor(() => expect(api.validateResumeConfig).toHaveBeenCalled());
expect(api.validateLlm).not.toHaveBeenCalled();
expect(api.validateRxresume).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
});
expect(screen.queryByText("LLM API key")).not.toBeInTheDocument();
});
it("renders the RxResume URL field and includes it in validation", async () => {
vi.mocked(useSettings).mockReturnValue({
...settingsResponse,
settings: {
...settingsResponse.settings,
rxresumeUrl: "https://resume.example.com",
rxresumeApiKeyHint: "abcd1234",
},
} as any);
vi.mocked(api.validateLlm).mockResolvedValue({
valid: false,
message: "Invalid",
});
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: true,
message: null,
});
vi.mocked(api.validateResumeConfig).mockResolvedValue({
valid: true,
message: null,
});
render(<OnboardingGate />);
await waitFor(() =>
expect(screen.getByLabelText("RxResume URL")).toBeInTheDocument(),
);
await waitFor(() =>
expect(api.validateRxresume).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: "https://resume.example.com",
}),
),
);
fireEvent.change(screen.getByLabelText("RxResume URL"), {
target: { value: "https://self-hosted.example.com" },
});
expect(
screen.getByDisplayValue("https://self-hosted.example.com"),
).toBeInTheDocument();
});
});
@@ -1,15 +1,6 @@
import * as api from "@client/api";
import { ReactiveResumeConfigPanel } from "@client/components/ReactiveResumeConfigPanel";
import { useDemoInfo } from "@client/hooks/useDemoInfo";
import { useRxResumeConfigState } from "@client/hooks/useRxResumeConfigState";
import { useSettings } from "@client/hooks/useSettings";
import {
getInitialRxResumeMode,
getRxResumeCredentialDrafts,
getRxResumeMissingCredentialLabels,
validateAndMaybePersistRxResumeMode,
} from "@client/lib/rxresume-config";
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import {
getLlmProviderConfig,
@@ -19,10 +10,10 @@ import {
} from "@client/pages/settings/utils";
import { getDefaultModelForProvider } from "@shared/settings-registry";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { RxResumeMode, ValidationResult } from "@shared/types.js";
import type { ValidationResult } from "@shared/types.js";
import { Check } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
@@ -48,22 +39,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
type ValidationState = ValidationResult & { checked: boolean };
type TimestampedValidationState = ValidationState & { testedAt: number | null };
type OnboardingFormData = {
llmProvider: string;
llmBaseUrl: string;
llmApiKey: string;
rxresumeMode: RxResumeMode;
rxresumeEmail: string;
rxresumeUrl: string;
rxresumePassword: string;
rxresumeApiKey: string;
rxresumeBaseResumeId: string | null;
};
const EMPTY_VALIDATION_STATE: ValidationState = {
@@ -72,27 +55,6 @@ const EMPTY_VALIDATION_STATE: ValidationState = {
checked: false,
};
const EMPTY_TIMESTAMPED_VALIDATION_STATE: TimestampedValidationState = {
...EMPTY_VALIDATION_STATE,
testedAt: null,
};
function getStepPrimaryLabel(input: {
currentStep: string | null;
llmValidated: boolean;
rxresumeValidated: boolean;
baseResumeValidated: boolean;
}): string {
const toLabel = (isValidated: boolean): string =>
isValidated ? "Revalidate" : "Validate";
if (input.currentStep === "llm") return toLabel(input.llmValidated);
if (input.currentStep === "rxresume") return toLabel(input.rxresumeValidated);
if (input.currentStep === "baseresume")
return toLabel(input.baseResumeValidated);
return "Validate";
}
export const OnboardingGate: React.FC = () => {
const {
settings,
@@ -100,41 +62,11 @@ export const OnboardingGate: React.FC = () => {
refreshSettings,
} = useSettings();
/** Skip RxResume onboarding when Vite flag is set, server reports a local resume file, or Settings has a local path. */
const skipRxResumeOnboarding = useMemo(
() =>
import.meta.env.VITE_SKIP_RXRESUME_ONBOARDING === "true" ||
Boolean(settings?.localResumeFileConfigured) ||
Boolean(settings?.localResumeProfilePath?.trim()),
[settings?.localResumeFileConfigured, settings?.localResumeProfilePath],
);
const {
storedRxResume,
getBaseResumeIdForMode,
setBaseResumeIdForMode,
syncBaseResumeIdsForMode,
} = useRxResumeConfigState(settings);
const [isSavingEnv, setIsSavingEnv] = useState(false);
const [isValidatingLlm, setIsValidatingLlm] = useState(false);
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
const [llmValidation, setLlmValidation] = useState<ValidationState>(
EMPTY_VALIDATION_STATE,
);
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
EMPTY_VALIDATION_STATE,
);
const [rxresumeVersionValidations, setRxresumeVersionValidations] = useState<{
v4: TimestampedValidationState;
v5: TimestampedValidationState;
}>({
v4: EMPTY_TIMESTAMPED_VALIDATION_STATE,
v5: EMPTY_TIMESTAMPED_VALIDATION_STATE,
});
const [baseResumeValidation, setBaseResumeValidation] =
useState<ValidationState>(EMPTY_VALIDATION_STATE);
const [currentStep, setCurrentStep] = useState<string | null>(null);
const demoInfo = useDemoInfo();
const demoMode = demoInfo?.demoMode ?? false;
@@ -144,12 +76,6 @@ export const OnboardingGate: React.FC = () => {
llmProvider: "",
llmBaseUrl: "",
llmApiKey: "",
rxresumeMode: "v5",
rxresumeEmail: "",
rxresumeUrl: "",
rxresumePassword: "",
rxresumeApiKey: "",
rxresumeBaseResumeId: null,
},
});
@@ -187,26 +113,6 @@ export const OnboardingGate: React.FC = () => {
}
}, [getValues, settings?.llmProvider]);
const validateBaseResume = useCallback(async () => {
setIsValidatingBaseResume(true);
try {
const result = await api.validateResumeConfig();
setBaseResumeValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error
? error.message
: "Base resume validation failed";
const result = { valid: false, message };
setBaseResumeValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingBaseResume(false);
}
}, []);
const rxresumeModeValue = watch("rxresumeMode");
const selectedProvider = normalizeLlmProvider(
llmProvider || settings?.llmProvider?.value || "openrouter",
);
@@ -220,234 +126,68 @@ export const OnboardingGate: React.FC = () => {
const llmKeyHint = settings?.llmApiKeyHint ?? null;
const hasLlmKey = Boolean(llmKeyHint);
const rxresumeModeCurrent = (rxresumeModeValue ||
settings?.rxresumeMode?.value ||
"v5") as RxResumeMode;
const hasCheckedValidations =
(requiresLlmKey ? llmValidation.checked : true) &&
(skipRxResumeOnboarding
? true
: rxresumeValidation.checked && baseResumeValidation.checked);
const llmValidated = requiresLlmKey ? llmValidation.valid : true;
const hasCheckedValidations = requiresLlmKey ? llmValidation.checked : true;
const shouldOpen =
!demoMode &&
Boolean(settings && !settingsLoading) &&
hasCheckedValidations &&
!(
llmValidated &&
(skipRxResumeOnboarding
? true
: rxresumeValidation.valid && baseResumeValidation.valid)
);
!llmValidated;
const validateRxresumeVersion = useCallback(
async (
version: "v4" | "v5",
): Promise<ValidationResult & { checked: true; testedAt: number }> => {
const values = getValues();
const draftCredentials = getRxResumeCredentialDrafts(values);
const testedAt = Date.now();
const result = await validateAndMaybePersistRxResumeMode({
mode: version,
stored: storedRxResume,
draft: draftCredentials,
validate: api.validateRxresume,
getPrecheckMessage: (failure) =>
failure === "missing-v5-api-key"
? "v5 API key required. Add a v5 API key, then test again."
: "v4 email and password required. Add both credentials, then test again.",
getValidationErrorMessage: (error, mode) =>
error instanceof Error
? error.message
: `RxResume ${mode} validation failed`,
});
return { ...result.validation, checked: true, testedAt };
},
[getValues, storedRxResume],
);
const validateRxresume = useCallback(async () => {
const values = getValues();
const selectedMode = values.rxresumeMode;
setIsValidatingRxresume(true);
try {
const versionResult = await validateRxresumeVersion(selectedMode);
setRxresumeVersionValidations((current) => ({
...current,
[selectedMode]: versionResult,
}));
const result: ValidationResult = {
valid: versionResult.valid,
message: versionResult.message,
};
setRxresumeValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingRxresume(false);
}
}, [getValues, validateRxresumeVersion]);
// Initialize form values from settings
useEffect(() => {
if (settings) {
const initialMode = getInitialRxResumeMode({
savedMode: (settings.rxresumeMode?.value ??
null) as RxResumeMode | null,
hasV4: storedRxResume.hasV4,
hasV5: storedRxResume.hasV5,
});
const selectedId = syncBaseResumeIdsForMode(initialMode);
reset({
llmProvider: settings.llmProvider?.value || "",
llmBaseUrl: settings.llmBaseUrl?.value || "",
llmApiKey: "",
rxresumeMode: initialMode,
rxresumeEmail: "",
rxresumeUrl: settings.rxresumeUrl ?? "",
rxresumePassword: "",
rxresumeApiKey: "",
rxresumeBaseResumeId: selectedId,
});
}
}, [
settings,
reset,
storedRxResume.hasV4,
storedRxResume.hasV5,
syncBaseResumeIdsForMode,
]);
}, [settings, reset]);
// Clear base URL when provider doesn't require it
useEffect(() => {
if (!showBaseUrl) {
setValue("llmBaseUrl", "");
}
}, [showBaseUrl, setValue]);
// Reset LLM validation when provider changes
useEffect(() => {
if (!selectedProvider) return;
setLlmValidation({ valid: false, message: null, checked: false });
}, [selectedProvider]);
const steps = useMemo(
() =>
skipRxResumeOnboarding
? [
{
id: "llm",
label: "LLM Provider",
subtitle: "Provider + credentials",
complete: llmValidated,
disabled: false,
},
]
: [
{
id: "llm",
label: "LLM Provider",
subtitle: "Provider + credentials",
complete: llmValidated,
disabled: false,
},
{
id: "rxresume",
label: "Connect Reactive Resume",
subtitle: "Version + credentials",
complete: rxresumeValidation.valid,
disabled: false,
},
{
id: "baseresume",
label: "Select Template Resume",
subtitle: "Template selection",
complete: baseResumeValidation.valid,
disabled: !rxresumeValidation.valid,
},
],
[
skipRxResumeOnboarding,
llmValidated,
rxresumeValidation.valid,
baseResumeValidation.valid,
],
);
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id;
useEffect(() => {
if (!shouldOpen) return;
if (!currentStep && defaultStep) {
setCurrentStep(defaultStep);
}
}, [currentStep, defaultStep, shouldOpen]);
const runAllValidations = useCallback(async () => {
const runLlmValidation = useCallback(async () => {
if (!settings) return;
const validations: Promise<ValidationResult>[] = [];
if (requiresLlmKey) {
validations.push(validateLlm());
await validateLlm();
} else {
setLlmValidation({ valid: true, message: null, checked: true });
}
if (!skipRxResumeOnboarding) {
validations.push(validateRxresume(), validateBaseResume());
} else {
setRxresumeValidation({ valid: true, message: null, checked: true });
setBaseResumeValidation({ valid: true, message: null, checked: true });
}
}, [settings, requiresLlmKey, validateLlm]);
const results = await Promise.allSettled(validations);
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 : "Validation checks failed";
toast.error(message);
}
}, [
settings,
requiresLlmKey,
skipRxResumeOnboarding,
validateLlm,
validateRxresume,
validateBaseResume,
]);
// Run validations on mount when needed
useEffect(() => {
if (demoMode) return;
if (!settings || settingsLoading) return;
const needsValidation =
(requiresLlmKey ? !llmValidation.checked : false) ||
(skipRxResumeOnboarding
? false
: !rxresumeValidation.checked || !baseResumeValidation.checked);
if (!needsValidation) return;
void runAllValidations();
if (requiresLlmKey ? llmValidation.checked : true) return;
void runLlmValidation();
}, [
settings,
settingsLoading,
requiresLlmKey,
llmValidation.checked,
rxresumeValidation.checked,
baseResumeValidation.checked,
runAllValidations,
runLlmValidation,
demoMode,
skipRxResumeOnboarding,
]);
const handleSaveLlm = async (): Promise<boolean> => {
const handleSaveLlm = async () => {
const values = getValues();
const apiKeyValue = values.llmApiKey.trim();
const baseUrlValue = values.llmBaseUrl.trim();
if (requiresLlmKey && !apiKeyValue && !hasLlmKey) {
toast.info("Add your LLM API key to continue");
return false;
return;
}
try {
@@ -457,7 +197,7 @@ export const OnboardingGate: React.FC = () => {
if (!validation.valid) {
toast.error(validation.message || "LLM validation failed");
return false;
return;
}
const update: Partial<UpdateSettingsInput> = {
@@ -484,165 +224,19 @@ export const OnboardingGate: React.FC = () => {
? `Default for ${providerConfig.label}: ${defaultModel}.`
: "Select the model manually in Settings > Model.",
});
return true;
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save LLM settings";
toast.error(message);
return false;
} finally {
setIsSavingEnv(false);
}
};
const handleSaveRxresume = async (): Promise<boolean> => {
const values = getValues();
const modeValue = values.rxresumeMode;
const draftCredentials = getRxResumeCredentialDrafts(values);
const missing = getRxResumeMissingCredentialLabels({
mode: modeValue,
stored: storedRxResume,
draft: draftCredentials,
});
const isBusy = isSavingEnv || settingsLoading || isValidatingLlm;
const progressValue = llmValidated ? 100 : 0;
if (missing.length > 0) {
toast.info("Almost there", {
description: `Missing: ${missing.join(", ")}`,
});
return false;
}
try {
setIsValidatingRxresume(true);
const result = await validateAndMaybePersistRxResumeMode({
mode: modeValue,
stored: storedRxResume,
draft: draftCredentials,
validate: api.validateRxresume,
persist: async (update) => {
setIsSavingEnv(true);
try {
await api.updateSettings(update);
await refreshSettings();
} finally {
setIsSavingEnv(false);
}
},
persistOnSuccess: true,
getPrecheckMessage: (failure) =>
failure === "missing-v5-api-key"
? "v5 API key required. Add a v5 API key, then test again."
: "v4 email and password required. Add both credentials, then test again.",
getValidationErrorMessage: (error) =>
error instanceof Error ? error.message : "RxResume validation failed",
getPersistErrorMessage: (error) =>
error instanceof Error
? error.message
: "Failed to save RxResume credentials",
});
setRxresumeVersionValidations((current) => ({
...current,
[modeValue]: {
...result.validation,
checked: true,
testedAt: Date.now(),
},
}));
setRxresumeValidation({ ...result.validation, checked: true });
if (!result.validation.valid) {
toast.error(result.validation.message || "RxResume validation failed");
return false;
}
setValue("rxresumePassword", "");
setValue("rxresumeApiKey", "");
toast.success("RxResume connected");
return true;
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to save RxResume credentials";
toast.error(message);
return false;
} finally {
setIsValidatingRxresume(false);
setIsSavingEnv(false);
}
};
const handleSaveBaseResume = async (): Promise<boolean> => {
const values = getValues();
if (!values.rxresumeBaseResumeId) {
toast.info("Select a base resume to continue");
return false;
}
try {
setIsSavingEnv(true);
await api.updateSettings({
rxresumeMode: values.rxresumeMode,
rxresumeBaseResumeId: values.rxresumeBaseResumeId,
});
const validation = await validateBaseResume();
if (!validation.valid) {
toast.error(validation.message || "Base resume validation failed");
return false;
}
await refreshSettings();
toast.success("Base resume set");
return true;
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save base resume";
toast.error(message);
return false;
} finally {
setIsSavingEnv(false);
}
};
const resolvedStepIndex = currentStep
? steps.findIndex((step) => step.id === currentStep)
: 0;
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0;
const completedSteps = steps.filter((step) => step.complete).length;
const progressValue =
steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0;
const isBusy =
isSavingEnv ||
settingsLoading ||
isValidatingLlm ||
isValidatingRxresume ||
isValidatingBaseResume;
const canGoBack = stepIndex > 0;
const handlePrimaryAction = async () => {
if (!currentStep) return;
if (currentStep === "llm") {
await handleSaveLlm();
return;
}
if (currentStep === "rxresume") {
await handleSaveRxresume();
return;
}
if (currentStep === "baseresume") {
await handleSaveBaseResume();
return;
}
};
const handleBack = () => {
if (!canGoBack) return;
setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep);
};
if (!shouldOpen || !currentStep) return null;
if (!shouldOpen) return null;
return (
<AlertDialog open>
@@ -654,241 +248,134 @@ export const OnboardingGate: React.FC = () => {
<AlertDialogHeader>
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
<AlertDialogDescription>
Let's get your workspace ready. Add your keys and resume once,
then the pipeline can run end-to-end.
Connect your LLM provider to run job scoring, summaries, and
tailoring. You can add a resume file later in Settings if you need
PDF export or resume-based scoring.
</AlertDialogDescription>
</AlertDialogHeader>
<Tabs value={currentStep} onValueChange={setCurrentStep}>
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
{steps.map((step, index) => {
const isActive = step.id === currentStep;
const isComplete = step.complete;
return (
<FieldLabel
key={step.id}
className={cn(
"w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none",
step.disabled && "opacity-50 cursor-not-allowed",
)}
>
<TabsTrigger
value={step.id}
disabled={step.disabled}
className={cn(
"w-full rounded-md hover:bg-muted/60 border-b-2 border-transparent px-3 py-4 text-left shadow-none",
isActive
? "border-primary !bg-muted/60 text-foreground"
: "text-muted-foreground",
)}
>
<Field orientation="horizontal" className="items-start">
<FieldContent>
<FieldTitle>{step.label}</FieldTitle>
<FieldDescription>{step.subtitle}</FieldDescription>
</FieldContent>
<span
className={cn(
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
isComplete
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
{isComplete ? (
<Check className="h-3.5 w-3.5" />
) : (
index + 1
)}
</span>
</Field>
</TabsTrigger>
</FieldLabel>
);
})}
</TabsList>
<TabsContent value="llm" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Connect LLM provider</p>
<p className="text-xs text-muted-foreground">
Used for job scoring, summaries, and tailoring.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="llmProvider" className="text-sm font-medium">
Provider
</label>
<Controller
name="llmProvider"
control={control}
render={({ field }) => (
<Select
value={selectedProvider}
onValueChange={(value) => {
field.onChange(value);
}}
disabled={isSavingEnv}
>
<SelectTrigger id="llmProvider">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map((provider) => (
<SelectItem key={provider} value={provider}>
{LLM_PROVIDER_LABELS[provider]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
{providerConfig.providerHint}
</p>
</div>
{showBaseUrl && (
<Controller
name="llmBaseUrl"
control={control}
render={({ field }) => (
<SettingsInput
label="LLM base URL"
inputProps={{
name: "llmBaseUrl",
value: field.value,
onChange: field.onChange,
}}
placeholder={providerConfig.baseUrlPlaceholder}
helper={providerConfig.baseUrlHelper}
current={settings?.llmBaseUrl?.value || "—"}
disabled={isSavingEnv}
/>
)}
/>
)}
{showApiKey && (
<Controller
name="llmApiKey"
control={control}
render={({ field }) => (
<SettingsInput
label="LLM API key"
inputProps={{
name: "llmApiKey",
value: field.value,
onChange: field.onChange,
}}
type="password"
placeholder="Enter key"
helper={
llmKeyHint
? `${providerConfig.keyHelper}. Leave blank to use the saved key.`
: providerConfig.keyHelper
}
disabled={isSavingEnv}
/>
)}
/>
)}
</div>
</TabsContent>
<TabsContent value="rxresume" className="space-y-4 pt-6">
<ReactiveResumeConfigPanel
mode={rxresumeModeCurrent}
onModeChange={(mode) => {
setValue("rxresumeMode", mode);
setValue(
"rxresumeBaseResumeId",
getBaseResumeIdForMode(mode),
);
setRxresumeValidation((previous) => ({
...EMPTY_VALIDATION_STATE,
checked: previous.checked,
}));
}}
disabled={isSavingEnv}
showValidationStatus
validationStatuses={rxresumeVersionValidations}
intro={{
title: "Link your RxResume account",
description:
"Used to export tailored PDFs. Choose between Reactive Resume version 4 and 5, and provide the credentials.",
}}
v5={{
apiKey: watch("rxresumeApiKey"),
onApiKeyChange: (value) => setValue("rxresumeApiKey", value),
}}
shared={{
baseUrl: watch("rxresumeUrl"),
onBaseUrlChange: (value) => setValue("rxresumeUrl", value),
}}
v4={{
email: watch("rxresumeEmail"),
onEmailChange: (value) => setValue("rxresumeEmail", value),
password: watch("rxresumePassword"),
onPasswordChange: (value) =>
setValue("rxresumePassword", value),
}}
/>
</TabsContent>
<TabsContent value="baseresume" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">
Select your template resume
</p>
<p className="text-xs text-muted-foreground">
Choose the resume you want to use as a template. The selected
resume will be used as a template for tailoring.
</p>
</div>
<Controller
name="rxresumeBaseResumeId"
control={control}
render={({ field }) => (
<BaseResumeSelection
value={field.value}
onValueChange={(value) => {
const mode = (getValues("rxresumeMode") ??
"v5") as RxResumeMode;
setBaseResumeIdForMode(mode, value);
field.onChange(value);
}}
hasRxResumeAccess={rxresumeValidation.valid}
rxresumeMode={rxresumeModeCurrent}
disabled={isSavingEnv}
/>
)}
/>
</TabsContent>
</Tabs>
<div className="flex items-center justify-between">
<Button
variant="outline"
onClick={handleBack}
disabled={!canGoBack || isBusy}
>
Back
</Button>
<div className="flex items-center gap-2">
<Button onClick={handlePrimaryAction} disabled={isBusy}>
{isBusy
? "Validating..."
: getStepPrimaryLabel({
currentStep,
llmValidated,
rxresumeValidated: rxresumeValidation.valid,
baseResumeValidated: baseResumeValidation.valid,
})}
</Button>
<FieldLabel className="w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none">
<div className="w-full rounded-md border-b-2 border-primary bg-muted/60 px-3 py-4 text-left">
<Field orientation="horizontal" className="items-start">
<FieldContent>
<FieldTitle>LLM Provider</FieldTitle>
<FieldDescription>Provider + credentials</FieldDescription>
</FieldContent>
<span
className={cn(
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
llmValidated
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
{llmValidated ? <Check className="h-3.5 w-3.5" /> : "1"}
</span>
</Field>
</div>
</FieldLabel>
<div className="space-y-4 pt-2">
<div>
<p className="text-sm font-semibold">Connect LLM provider</p>
<p className="text-xs text-muted-foreground">
Used for job scoring, summaries, and tailoring.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="llmProvider" className="text-sm font-medium">
Provider
</label>
<Controller
name="llmProvider"
control={control}
render={({ field }) => (
<Select
value={selectedProvider}
onValueChange={(value) => {
field.onChange(value);
}}
disabled={isSavingEnv}
>
<SelectTrigger id="llmProvider">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map((provider) => (
<SelectItem key={provider} value={provider}>
{LLM_PROVIDER_LABELS[provider]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
{providerConfig.providerHint}
</p>
</div>
{showBaseUrl && (
<Controller
name="llmBaseUrl"
control={control}
render={({ field }) => (
<SettingsInput
label="LLM base URL"
inputProps={{
name: "llmBaseUrl",
value: field.value,
onChange: field.onChange,
}}
placeholder={providerConfig.baseUrlPlaceholder}
helper={providerConfig.baseUrlHelper}
current={settings?.llmBaseUrl?.value || "—"}
disabled={isSavingEnv}
/>
)}
/>
)}
{showApiKey && (
<Controller
name="llmApiKey"
control={control}
render={({ field }) => (
<SettingsInput
label="LLM API key"
inputProps={{
name: "llmApiKey",
value: field.value,
onChange: field.onChange,
}}
type="password"
placeholder="Enter key"
helper={
llmKeyHint
? `${providerConfig.keyHelper}. Leave blank to use the saved key.`
: providerConfig.keyHelper
}
disabled={isSavingEnv}
/>
)}
/>
)}
</div>
{llmValidation.checked && !llmValidation.valid ? (
<p className="text-xs text-destructive">
{llmValidation.message ?? "LLM validation failed."}
</p>
) : null}
</div>
<div className="flex items-center justify-end">
<Button onClick={handleSaveLlm} disabled={isBusy}>
{isBusy
? "Validating..."
: llmValidated
? "Revalidate"
: "Connect"}
</Button>
</div>
<Progress value={progressValue} className="h-2" />
</div>
</AlertDialogContent>
@@ -1,453 +0,0 @@
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 { AlertCircle, AlertTriangle } from "lucide-react";
import type React from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
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;
status?: number | 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;
};
shared: {
baseUrl: string;
onBaseUrlChange: (value: string) => void;
baseUrlError?: string;
baseUrlHelper?: string;
baseUrlPlaceholder?: 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
}
/>
);
}
function isAvailabilityWarning(state?: VersionValidationState): boolean {
const status = state?.status ?? null;
return status === 0 || (typeof status === "number" && status >= 500);
}
export const ReactiveResumeConfigPanel: React.FC<
ReactiveResumeConfigPanelProps
> = ({
mode,
onModeChange,
disabled = false,
hasRxResumeAccess = false,
showValidationStatus = false,
validationStatuses,
intro,
shared,
v5,
v4,
projectSelection,
}) => {
const canShowProjectSelection = Boolean(
projectSelection && hasRxResumeAccess,
);
const selectedValidationStatus = validationStatuses?.[mode];
const showInlineValidationAlert = Boolean(
selectedValidationStatus?.checked &&
!selectedValidationStatus.valid &&
selectedValidationStatus.message,
);
const selectedValidationIsWarning =
showInlineValidationAlert &&
isAvailabilityWarning(selectedValidationStatus);
const handleModeChange = (value: string) =>
onModeChange(value === "v4" ? "v4" : "v5");
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}
{showInlineValidationAlert && selectedValidationStatus?.message ? (
<Alert
variant={selectedValidationIsWarning ? "default" : "destructive"}
className={
selectedValidationIsWarning
? "border-amber-200 bg-amber-50 text-amber-950 [&>svg]:text-amber-700"
: undefined
}
>
{selectedValidationIsWarning ? (
<AlertTriangle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertTitle>
Reactive Resume {mode.toUpperCase()}{" "}
{selectedValidationIsWarning ? "warning" : "error"}
</AlertTitle>
<AlertDescription>
{selectedValidationStatus.message}
</AlertDescription>
</Alert>
) : null}
{mode === "v5" ? (
<div className="grid gap-4">
<SettingsInput
label="RxResume URL"
inputProps={{
name: "rxresumeUrl",
value: shared.baseUrl,
onChange: (event) =>
shared.onBaseUrlChange(event.currentTarget.value),
}}
type="url"
placeholder={
shared.baseUrlPlaceholder ?? "https://resume.example.com"
}
helper={
shared.baseUrlHelper ??
"Leave blank to use the default for the selected mode (or the RXRESUME_URL environment override, if set)."
}
disabled={disabled}
error={shared.baseUrlError}
/>
<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">
<div className="md:col-span-2">
<SettingsInput
label="RxResume URL"
inputProps={{
name: "rxresumeUrl",
value: shared.baseUrl,
onChange: (event) =>
shared.onBaseUrlChange(event.currentTarget.value),
}}
type="url"
placeholder={
shared.baseUrlPlaceholder ?? "https://resume.example.com"
}
helper={
shared.baseUrlHelper ??
"Leave blank to use the public cloud default for the selected mode."
}
disabled={disabled}
error={shared.baseUrlError}
/>
</div>
<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>
);
};
@@ -0,0 +1,74 @@
import {
parseSponsorshipSignals,
SPONSORSHIP_GREEN_FLAG_IDS,
SPONSORSHIP_RED_FLAG_IDS,
SPONSORSHIP_SIGNAL_META,
SPONSORSHIP_YELLOW_FLAG_IDS,
type SponsorshipFlagTier,
} from "@shared/sponsorship-signals";
import type React from "react";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
const flagTierClass: Record<SponsorshipFlagTier, string> = {
green: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
yellow: "border-amber-500/40 bg-amber-500/10 text-amber-200",
red: "border-rose-500/40 bg-rose-500/10 text-rose-200",
};
interface SponsorshipSignalsPillsProps {
sponsorshipSignals: string | null | undefined;
className?: string;
size?: "xs" | "sm";
}
export const SponsorshipSignalsPills: React.FC<
SponsorshipSignalsPillsProps
> = ({ sponsorshipSignals, className, size = "sm" }) => {
const signals = parseSponsorshipSignals(sponsorshipSignals);
if (signals.length === 0) return null;
const ordered = [
...SPONSORSHIP_RED_FLAG_IDS.filter((id) => signals.includes(id)),
...SPONSORSHIP_YELLOW_FLAG_IDS.filter((id) => signals.includes(id)),
...SPONSORSHIP_GREEN_FLAG_IDS.filter((id) => signals.includes(id)),
];
return (
<TooltipProvider>
<div className={cn("flex flex-wrap items-center gap-1", className)}>
{ordered.map((signal) => {
const meta = SPONSORSHIP_SIGNAL_META[signal];
return (
<Tooltip key={signal} delayDuration={0}>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={cn(
"font-medium",
size === "xs" ? "px-1 py-0 text-[10px]" : "text-xs",
flagTierClass[meta.flagTier],
)}
>
{meta.shortLabel}
</Badge>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs font-medium">{meta.label}</p>
<p className="text-xs text-muted-foreground">
{meta.description}
</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
);
};
@@ -1,55 +0,0 @@
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,
};
}
@@ -1,288 +0,0 @@
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;
rxresumeUrl?: 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;
rxresumeUrl?: string | null;
rxresumePassword?: string | null;
rxresumeApiKey?: string | null;
}) => ({
email: input.rxresumeEmail?.trim() ?? "",
baseUrl: input.rxresumeUrl?.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,
options?: {
preserveBlankFields?: Array<keyof RxResumeCredentialDrafts>;
},
) => {
const preserveBlankFields = new Set(options?.preserveBlankFields ?? []);
return {
email: preserveBlankFields.has("email")
? draft.email
: draft.email || undefined,
baseUrl: preserveBlankFields.has("baseUrl")
? draft.baseUrl
: draft.baseUrl || undefined,
password: preserveBlankFields.has("password")
? draft.password
: draft.password || undefined,
apiKey: preserveBlankFields.has("apiKey")
? draft.apiKey
: draft.apiKey || undefined,
};
};
export const isRxResumeBlockingValidationFailure = (
validation: ValidationResult,
): boolean =>
!validation.valid &&
typeof validation.status === "number" &&
validation.status >= 400 &&
validation.status < 500;
export const isRxResumeAvailabilityValidationFailure = (
validation: ValidationResult,
): boolean =>
!validation.valid &&
(validation.status === 0 ||
(typeof validation.status === "number" && validation.status >= 500));
export const buildRxResumeSettingsUpdate = (
mode: RxResumeMode,
draft: RxResumeCredentialDrafts,
): Partial<UpdateSettingsInput> => {
const update: Partial<UpdateSettingsInput> = {
rxresumeMode: mode,
rxresumeUrl: draft.baseUrl || null,
};
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;
skipPrecheck?: 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,
skipPrecheck = 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 = skipPrecheck
? null
: getRxResumeCredentialPrecheckFailure({
mode,
stored,
draft,
});
if (precheckFailure !== null) {
return {
validation: {
valid: false,
message: getPrecheckMessage(precheckFailure),
status: 400,
},
precheckFailure,
updatedSettings: null,
};
}
let validation: ValidationResult;
try {
validation = await validate({
mode,
...toRxResumeValidationPayload(draft),
});
} catch (error) {
return {
validation: {
valid: false,
message: getValidationErrorMessage(error, mode),
status: 0,
},
precheckFailure: null,
updatedSettings: null,
};
}
if (!validation.valid || !persistOnSuccess || !persist) {
return {
validation: {
valid: validation.valid,
message: validation.valid ? null : (validation.message ?? null),
status: validation.valid ? null : (validation.status ?? null),
},
precheckFailure: null,
updatedSettings: null,
};
}
try {
const updatedSettings = await persist(
buildRxResumeSettingsUpdate(mode, draft),
);
return {
validation: {
valid: true,
message: null,
status: null,
},
precheckFailure: null,
updatedSettings,
};
} catch (error) {
return {
validation: {
valid: false,
message: getPersistErrorMessage(error, mode),
status: 0,
},
precheckFailure: null,
updatedSettings: null,
};
}
};
@@ -39,6 +39,7 @@ const makeJob = (overrides: Partial<JobListItem>): JobListItem => ({
closedAt: null,
suitabilityScore: null,
sponsorMatchScore: null,
sponsorshipSignals: null,
jobType: null,
jobFunction: null,
salaryMinAmount: null,
@@ -60,6 +60,7 @@ let mockAutomaticRunValues: AutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
activeKeywordSetId: null,
runBudget: 150,
country: "united kingdom",
cityLocations: [],
@@ -407,6 +408,7 @@ describe("OrchestratorPage", () => {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
activeKeywordSetId: null,
runBudget: 150,
country: "united kingdom",
cityLocations: [],
@@ -759,17 +761,21 @@ describe("OrchestratorPage", () => {
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).toHaveBeenCalledWith({
searchTerms: ["backend"],
workplaceTypes: ["remote", "hybrid", "onsite"],
jobspyResultsWanted: 150,
gradcrackerMaxJobsPerTerm: 150,
ukvisajobsMaxJobs: 150,
adzunaMaxJobsPerTerm: 150,
startupjobsMaxJobsPerTerm: 150,
jobspyCountryIndeed: "united kingdom",
searchCities: "United Kingdom",
});
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
searchTerms: ["backend"],
workplaceTypes: ["remote", "hybrid", "onsite"],
jobspyResultsWanted: 150,
gradcrackerMaxJobsPerTerm: 150,
ukvisajobsMaxJobs: 150,
adzunaMaxJobsPerTerm: 150,
startupjobsMaxJobsPerTerm: 150,
workingnomadsMaxJobsPerTerm: 150,
testdevjobsMaxPages: 10,
jobspyCountryIndeed: "united kingdom",
searchCities: "United Kingdom",
}),
);
});
expect(api.runPipeline).toHaveBeenCalledWith({
topN: 12,
@@ -790,6 +796,7 @@ describe("OrchestratorPage", () => {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
activeKeywordSetId: null,
runBudget: 150,
country: "united kingdom",
cityLocations: ["London", "Manchester"],
@@ -825,6 +832,7 @@ describe("OrchestratorPage", () => {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
activeKeywordSetId: null,
runBudget: 150,
country: "united kingdom",
cityLocations: ["Leeds", "Manchester"],
@@ -860,6 +868,7 @@ describe("OrchestratorPage", () => {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
activeKeywordSetId: null,
runBudget: 150,
country: "united kingdom",
cityLocations: ["Leeds", "Manchester"],
@@ -967,6 +976,7 @@ describe("OrchestratorPage", () => {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
activeKeywordSetId: null,
runBudget: 150,
country: "united states",
cityLocations: [],
@@ -47,6 +47,10 @@ export const OrchestratorPage: React.FC = () => {
setCountrySelection,
sponsorFilter,
setSponsorFilter,
sponsorshipSignalsFilter,
setSponsorshipSignalsFilter,
hideSponsorBlockers,
setHideSponsorBlockers,
workplaceFilter,
setWorkplaceFilter,
salaryFilter,
@@ -205,6 +209,8 @@ export const OrchestratorPage: React.FC = () => {
countriesFilter,
countriesExcludeFilter,
sponsorFilter,
sponsorshipSignalsFilter,
hideSponsorBlockers,
workplaceFilter,
salaryFilter,
sort,
@@ -387,6 +393,8 @@ export const OrchestratorPage: React.FC = () => {
"countries",
"countriesExclude",
"sponsor",
"sponsorSignal",
"hideSponsorBlockers",
"salaryMode",
"salaryMin",
"salaryMax",
@@ -525,6 +533,10 @@ export const OrchestratorPage: React.FC = () => {
}
sponsorFilter={sponsorFilter}
onSponsorFilterChange={setSponsorFilter}
sponsorshipSignalsFilter={sponsorshipSignalsFilter}
onSponsorshipSignalsFilterChange={setSponsorshipSignalsFilter}
hideSponsorBlockers={hideSponsorBlockers}
onHideSponsorBlockersChange={setHideSponsorBlockers}
workplaceFilter={workplaceFilter}
onWorkplaceFilterChange={setWorkplaceFilter}
salaryFilter={salaryFilter}
@@ -17,8 +17,6 @@ vi.mock("../api", () => ({
getSettings: vi.fn(),
getLlmModels: vi.fn().mockResolvedValue([]),
updateSettings: vi.fn(),
validateRxresume: vi.fn(),
getRxResumeProjects: vi.fn(),
clearDatabase: vi.fn(),
deleteJobsByStatus: vi.fn(),
getTracerReadiness: vi.fn(),
@@ -74,13 +72,6 @@ const openWritingStyleSection = async () => {
fireEvent.click(chatTrigger);
};
const openReactiveResumeSection = async () => {
const trigger = await screen.findByRole("button", {
name: /reactive resume/i,
});
fireEvent.click(trigger);
};
describe("SettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -98,11 +89,6 @@ describe("SettingsPage", () => {
lastSuccessAt: Date.now(),
reason: null,
});
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message: "Missing credentials",
status: 400,
});
});
afterAll(() => {
@@ -297,192 +283,6 @@ describe("SettingsPage", () => {
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("saves a shared RxResume URL from the Reactive Resume section", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue({
...baseSettings,
rxresumeUrl: "https://resume.example.com",
});
renderPage();
const reactiveResumeTrigger = await screen.findByRole("button", {
name: /reactive resume/i,
});
fireEvent.click(reactiveResumeTrigger);
const urlInput = screen.getByLabelText(/rxresume url/i);
await waitFor(() => expect(urlInput).toBeEnabled());
fireEvent.change(urlInput, {
target: { value: "https://resume.example.com" },
});
const saveButton = screen.getByRole("button", { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
rxresumeUrl: "https://resume.example.com",
}),
);
});
it("blocks save and renders an inline alert when the v5 API key is invalid", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage();
await openReactiveResumeSection();
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
vi.mocked(api.validateRxresume).mockClear();
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message:
"Reactive Resume v5 API key is invalid. Update the API key and try again.",
status: 401,
});
fireEvent.change(screen.getByLabelText(/v5 api key/i), {
target: { value: "invalid-v5-key" },
});
const saveButton = screen.getByRole("button", { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
expect(
await screen.findByText(/Reactive Resume v5 API key is invalid/i),
).toBeInTheDocument();
expect(api.updateSettings).not.toHaveBeenCalled();
});
it("allows saving on RxResume availability warnings and keeps the inline warning visible", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue({
...baseSettings,
rxresumeApiKeyHint: "rr-v",
});
renderPage();
await openReactiveResumeSection();
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
vi.mocked(api.validateRxresume).mockClear();
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message:
"JobOps could not verify Reactive Resume because the instance is unavailable right now.",
status: 0,
});
fireEvent.change(screen.getByLabelText(/v5 api key/i), {
target: { value: "rr-v5-warning-key" },
});
const saveButton = screen.getByRole("button", { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
expect(
await screen.findByText(/instance is unavailable right now/i),
).toBeInTheDocument();
expect(toast.success).toHaveBeenCalledWith("Settings saved");
expect(toast.info).toHaveBeenCalledWith(
"Settings saved, but JobOps could not verify Reactive Resume because the instance is unavailable.",
);
});
it("does not run RxResume validation for unrelated settings saves", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue({
...baseSettings,
model: {
value: "new-model",
default: baseSettings.model.default,
override: "new-model",
},
});
renderPage();
await openModelSection();
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
vi.mocked(api.validateRxresume).mockClear();
fireEvent.change(screen.getByLabelText(/default model/i), {
target: { value: "new-model" },
});
const saveButton = screen.getByRole("button", { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
expect(api.validateRxresume).not.toHaveBeenCalled();
});
it("clears the previous RxResume warning when the key or URL changes", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message:
"JobOps could not verify Reactive Resume because the instance is unavailable right now.",
status: 0,
});
renderPage();
await openReactiveResumeSection();
expect(
await screen.findByText(/instance is unavailable right now/i),
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/rxresume url/i), {
target: { value: "https://resume.example.com" },
});
await waitFor(() =>
expect(
screen.queryByText(/instance is unavailable right now/i),
).not.toBeInTheDocument(),
);
});
it("saves the writing language mode through the settings page", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue(
+185 -370
View File
@@ -1,27 +1,17 @@
import * as api from "@client/api";
import { PageHeader } from "@client/components/layout";
import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation";
import { useRxResumeConfigState } from "@client/hooks/useRxResumeConfigState";
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
import {
coerceRxResumeMode,
getRxResumeCredentialDrafts,
getRxResumeCredentialPrecheckFailure,
isRxResumeAvailabilityValidationFailure,
isRxResumeBlockingValidationFailure,
RXRESUME_MODES,
RXRESUME_PRECHECK_MESSAGES,
toRxResumeValidationPayload,
validateAndMaybePersistRxResumeMode,
} from "@client/lib/rxresume-config";
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
import { JobSearchProfileSection } from "@client/pages/settings/components/JobSearchProfileSection";
import { JobSourcesSettingsSection } from "@client/pages/settings/components/JobSourcesSettingsSection";
import { KeywordSetsSettingsSection } from "@client/pages/settings/components/KeywordSetsSettingsSection";
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
import { ResumeSettingsSection } from "@client/pages/settings/components/ResumeSettingsSection";
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
import { TracerLinksSettingsSection } from "@client/pages/settings/components/TracerLinksSettingsSection";
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
@@ -42,8 +32,6 @@ import type {
JobStatus,
ResumeProjectCatalogItem,
ResumeProjectsSettings,
RxResumeMode,
ValidationResult,
} from "@shared/types.js";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings } from "lucide-react";
@@ -105,37 +93,20 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
blockedCompanyKeywords: [],
blockedCountries: [],
scoringInstructions: "",
ashbyCompanies: [],
greenhouseCompanies: [],
leverCompanies: [],
smartrecruitersCompanies: [],
teamtailorCompanies: [],
huntflowTenants: [],
factorialTenants: [],
workdayTenants: [],
careersPageUrls: [],
icimsTenants: [],
elutaRssLocations: [],
};
type LlmProviderValue = LlmProviderId | null;
type RxResumeValidationBadgeState = {
checked: boolean;
valid: boolean;
message: string | null;
status: number | null;
};
const EMPTY_RXRESUME_VALIDATION_BADGE_STATE: RxResumeValidationBadgeState = {
checked: false,
valid: false,
message: null,
status: null,
};
const getRxResumeValidationFieldsForMode = (
mode: RxResumeMode,
): Array<keyof UpdateSettingsInput> =>
mode === "v5"
? ["rxresumeApiKey", "rxresumeUrl"]
: ["rxresumeEmail", "rxresumePassword", "rxresumeUrl"];
const toRxResumeValidationBadgeState = (
validation: ValidationResult,
): RxResumeValidationBadgeState => ({
checked: true,
valid: validation.valid,
message: validation.valid ? null : (validation.message ?? null),
status: validation.valid ? null : (validation.status ?? null),
});
const normalizeLlmProviderValue = (
value: string | null | undefined,
@@ -233,6 +204,17 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
blockedCompanyKeywords: data.blockedCompanyKeywords.override ?? [],
blockedCountries: data.blockedCountries.override ?? [],
scoringInstructions: data.scoringInstructions.override ?? "",
ashbyCompanies: data.ashbyCompanies.value,
greenhouseCompanies: data.greenhouseCompanies.value,
leverCompanies: data.leverCompanies.value,
smartrecruitersCompanies: data.smartrecruitersCompanies.value,
teamtailorCompanies: data.teamtailorCompanies.value,
huntflowTenants: data.huntflowTenants.value,
factorialTenants: data.factorialTenants.value,
workdayTenants: data.workdayTenants.value,
careersPageUrls: data.careersPageUrls.value,
icimsTenants: data.icimsTenants.value,
elutaRssLocations: data.elutaRssLocations.value,
});
const normalizeString = (value: string | null | undefined) => {
@@ -348,13 +330,11 @@ const getDerivedSettings = (settings: AppSettings | null) => {
},
envSettings: {
readable: {
rxresumeEmail: settings?.rxresumeEmail ?? "",
ukvisajobsEmail: settings?.ukvisajobsEmail ?? "",
adzunaAppId: settings?.adzunaAppId ?? "",
basicAuthUser: settings?.basicAuthUser ?? "",
},
private: {
rxresumePasswordHint: settings?.rxresumePasswordHint ?? null,
ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null,
adzunaAppKeyHint: settings?.adzunaAppKeyHint ?? null,
basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null,
@@ -411,6 +391,52 @@ const getDerivedSettings = (settings: AppSettings | null) => {
default: settings?.scoringInstructions?.default ?? "",
},
},
jobSources: {
ashbyCompanies: {
effective: settings?.ashbyCompanies?.value ?? [],
default: settings?.ashbyCompanies?.default ?? [],
},
greenhouseCompanies: {
effective: settings?.greenhouseCompanies?.value ?? [],
default: settings?.greenhouseCompanies?.default ?? [],
},
leverCompanies: {
effective: settings?.leverCompanies?.value ?? [],
default: settings?.leverCompanies?.default ?? [],
},
smartrecruitersCompanies: {
effective: settings?.smartrecruitersCompanies?.value ?? [],
default: settings?.smartrecruitersCompanies?.default ?? [],
},
teamtailorCompanies: {
effective: settings?.teamtailorCompanies?.value ?? [],
default: settings?.teamtailorCompanies?.default ?? [],
},
huntflowTenants: {
effective: settings?.huntflowTenants?.value ?? [],
default: settings?.huntflowTenants?.default ?? [],
},
factorialTenants: {
effective: settings?.factorialTenants?.value ?? [],
default: settings?.factorialTenants?.default ?? [],
},
workdayTenants: {
effective: settings?.workdayTenants?.value ?? [],
default: settings?.workdayTenants?.default ?? [],
},
careersPageUrls: {
effective: settings?.careersPageUrls?.value ?? [],
default: settings?.careersPageUrls?.default ?? [],
},
icimsTenants: {
effective: settings?.icimsTenants?.value ?? [],
default: settings?.icimsTenants?.default ?? [],
},
elutaRssLocations: {
effective: settings?.elutaRssLocations?.value ?? [],
default: settings?.elutaRssLocations?.default ?? [],
},
},
};
};
@@ -418,25 +444,9 @@ export const SettingsPage: React.FC = () => {
const queryClient = useQueryClient();
const [settings, setSettings] = useState<AppSettings | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [rxresumeValidationStatuses, setRxresumeValidationStatuses] = useState<{
v4: RxResumeValidationBadgeState;
v5: RxResumeValidationBadgeState;
}>({
v4: EMPTY_RXRESUME_VALIDATION_BADGE_STATE,
v5: EMPTY_RXRESUME_VALIDATION_BADGE_STATE,
});
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
"discovered",
]);
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<
string | null
>(null);
const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState<
ResumeProjectCatalogItem[] | null
>(null);
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] =
useState(false);
// Backup state
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
const [isDeletingBackup, setIsDeletingBackup] = useState(false);
@@ -461,17 +471,9 @@ export const SettingsPage: React.FC = () => {
reset,
setError,
setValue,
getValues,
control,
formState: { isDirty, errors, isValid, dirtyFields },
} = methods;
const {
storedRxResume,
getBaseResumeIdForMode,
setBaseResumeIdForMode,
syncBaseResumeIdsForMode,
} = useRxResumeConfigState(settings);
const settingsQuery = useQuery({
queryKey: queryKeys.settings.current(),
queryFn: api.getSettings,
@@ -487,18 +489,10 @@ export const SettingsPage: React.FC = () => {
const isLoadingBackups = backupsQuery.isLoading;
useQueryErrorToast(backupsQuery.error, "Failed to load backups");
const rxresumeMode = (settings?.rxresumeMode?.value ?? "v5") as RxResumeMode;
const selectedRxresumeMode = (useWatch({
control,
name: "rxresumeMode",
}) ?? rxresumeMode) as RxResumeMode;
const resumeProjectsValue = useWatch({
control,
name: "resumeProjects",
});
const hasRxResumeAccess = Boolean(
rxresumeValidationStatuses[selectedRxresumeMode].valid,
);
useEffect(() => {
if (!settingsQuery.data) return;
@@ -508,77 +502,6 @@ export const SettingsPage: React.FC = () => {
useQueryErrorToast(settingsQuery.error, "Failed to load settings");
useEffect(() => {
if (!settings) return;
const effectiveMode = coerceRxResumeMode(settings.rxresumeMode?.value);
const storedId = syncBaseResumeIdsForMode(effectiveMode);
setRxResumeBaseResumeIdDraft(storedId);
setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false });
setRxResumeProjectsOverride(null);
}, [settings, setValue, syncBaseResumeIdsForMode]);
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
if (!rxResumeBaseResumeIdDraft) {
setRxResumeProjectsOverride(null);
return () => {
isMounted = false;
controller.abort();
};
}
if (!hasRxResumeAccess)
return () => {
isMounted = false;
controller.abort();
};
setIsFetchingRxResumeProjects(true);
api
.getRxResumeProjects(
rxResumeBaseResumeIdDraft,
controller.signal,
selectedRxresumeMode,
)
.then((projects) => {
if (!isMounted) return;
setRxResumeProjectsOverride(projects);
const normalized = normalizeResumeProjectsForCatalog(
projects,
getValues("resumeProjects") ?? null,
);
if (normalized) {
setValue("resumeProjects", normalized, { shouldDirty: true });
}
})
.catch((error) => {
if (!isMounted || error.name === "AbortError") return;
const message =
error instanceof Error
? error.message
: "Failed to load RxResume projects";
toast.error(message);
setRxResumeProjectsOverride(null);
})
.finally(() => {
if (!isMounted) return;
setIsFetchingRxResumeProjects(false);
});
return () => {
isMounted = false;
controller.abort();
};
}, [
rxResumeBaseResumeIdDraft,
hasRxResumeAccess,
selectedRxresumeMode,
getValues,
setValue,
]);
const derived = getDerivedSettings(settings);
const {
model,
@@ -592,6 +515,7 @@ export const SettingsPage: React.FC = () => {
backup,
scoring,
jobSearchProfile,
jobSources,
} = derived;
const handleCreateBackup = async () => {
@@ -652,105 +576,7 @@ export const SettingsPage: React.FC = () => {
}
}, [refreshReadiness]);
const setRxResumeValidationStatus = useCallback(
(mode: RxResumeMode, validation: ValidationResult) => {
setRxresumeValidationStatuses((current) => ({
...current,
[mode]: toRxResumeValidationBadgeState(validation),
}));
},
[],
);
const clearRxResumeValidationFeedback = useCallback(
(mode: RxResumeMode) => {
setRxresumeValidationStatuses((current) => ({
...current,
[mode]: EMPTY_RXRESUME_VALIDATION_BADGE_STATE,
}));
clearErrors(getRxResumeValidationFieldsForMode(mode));
},
[clearErrors],
);
const validateRxresumeMode = useCallback(
async (
mode: RxResumeMode,
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,
skipPrecheck: silent,
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",
});
setRxResumeValidationStatus(mode, result.validation);
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, setRxResumeValidationStatus, 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 }),
),
);
}, [rxresumeValidationStatuses, settings, validateRxresumeMode]);
const effectiveProfileProjects =
rxResumeProjectsOverride ??
(selectedRxresumeMode === rxresumeMode ? profileProjects : []);
const effectiveMaxProjectsTotal = effectiveProfileProjects.length;
const effectiveMaxProjectsTotal = profileProjects.length;
const lockedCount = resumeProjectsValue?.lockedProjectIds.length ?? 0;
const canSave = isDirty && isValid;
@@ -781,14 +607,6 @@ export const SettingsPage: React.FC = () => {
const envPayload: Partial<UpdateSettingsInput> = {};
if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) {
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail);
}
if (dirtyFields.rxresumeUrl) {
envPayload.rxresumeUrl = normalizeString(data.rxresumeUrl);
}
if (dirtyFields.localResumeProfilePath) {
envPayload.localResumeProfilePath = normalizeString(
data.localResumeProfilePath,
@@ -834,16 +652,6 @@ export const SettingsPage: React.FC = () => {
if (value !== undefined) envPayload.llmApiKey = value;
}
if (dirtyFields.rxresumePassword) {
const value = normalizePrivateInput(data.rxresumePassword);
if (value !== undefined) envPayload.rxresumePassword = value;
}
if (dirtyFields.rxresumeApiKey) {
const value = normalizePrivateInput(data.rxresumeApiKey);
if (value !== undefined) envPayload.rxresumeApiKey = value;
}
if (dirtyFields.ukvisajobsPassword) {
const value = normalizePrivateInput(data.ukvisajobsPassword);
if (value !== undefined) envPayload.ukvisajobsPassword = value;
@@ -890,12 +698,6 @@ export const SettingsPage: React.FC = () => {
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
resumeProjects: resumeProjectsOverride,
...(dirtyFields.rxresumeMode
? { rxresumeMode: data.rxresumeMode ?? "v5" }
: {}),
...(dirtyFields.rxresumeBaseResumeId
? { rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId) }
: {}),
showSponsorInfo: nullIfSame(
data.showSponsorInfo,
display.showSponsorInfo.default,
@@ -951,84 +753,107 @@ export const SettingsPage: React.FC = () => {
normalizeString(data.scoringInstructions),
scoring.scoringInstructions.default,
),
ashbyCompanies: (() => {
const normalized = normalizeStringArray(data.ashbyCompanies);
return stringArraysEqual(
normalized,
jobSources.ashbyCompanies.default,
)
? null
: normalized;
})(),
greenhouseCompanies: (() => {
const normalized = normalizeStringArray(data.greenhouseCompanies);
return stringArraysEqual(
normalized,
jobSources.greenhouseCompanies.default,
)
? null
: normalized;
})(),
leverCompanies: (() => {
const normalized = normalizeStringArray(data.leverCompanies);
return stringArraysEqual(
normalized,
jobSources.leverCompanies.default,
)
? null
: normalized;
})(),
smartrecruitersCompanies: (() => {
const normalized = normalizeStringArray(
data.smartrecruitersCompanies,
);
return stringArraysEqual(
normalized,
jobSources.smartrecruitersCompanies.default,
)
? null
: normalized;
})(),
teamtailorCompanies: (() => {
const normalized = normalizeStringArray(data.teamtailorCompanies);
return stringArraysEqual(
normalized,
jobSources.teamtailorCompanies.default,
)
? null
: normalized;
})(),
huntflowTenants: (() => {
const normalized = normalizeStringArray(data.huntflowTenants);
return stringArraysEqual(
normalized,
jobSources.huntflowTenants.default,
)
? null
: normalized;
})(),
factorialTenants: (() => {
const normalized = normalizeStringArray(data.factorialTenants);
return stringArraysEqual(
normalized,
jobSources.factorialTenants.default,
)
? null
: normalized;
})(),
workdayTenants: (() => {
const normalized = normalizeStringArray(data.workdayTenants);
return stringArraysEqual(
normalized,
jobSources.workdayTenants.default,
)
? null
: normalized;
})(),
careersPageUrls: (() => {
const normalized = normalizeStringArray(data.careersPageUrls);
return stringArraysEqual(
normalized,
jobSources.careersPageUrls.default,
)
? null
: normalized;
})(),
icimsTenants: (() => {
const normalized = normalizeStringArray(data.icimsTenants);
return stringArraysEqual(normalized, jobSources.icimsTenants.default)
? null
: normalized;
})(),
elutaRssLocations: (() => {
const normalized = normalizeStringArray(data.elutaRssLocations);
return stringArraysEqual(
normalized,
jobSources.elutaRssLocations.default,
)
? null
: normalized;
})(),
...envPayload,
};
const shouldValidateRxResumeBeforeSave = Boolean(
dirtyFields.rxresumeMode ||
dirtyFields.rxresumeUrl ||
dirtyFields.rxresumeApiKey ||
dirtyFields.rxresumeEmail ||
dirtyFields.rxresumePassword,
);
const rxResumeValidationMode = (data.rxresumeMode ??
rxresumeMode) as RxResumeMode;
let rxResumeSaveWarningMessage: string | null = null;
if (shouldValidateRxResumeBeforeSave) {
const validationDraft = getRxResumeCredentialDrafts(data);
const precheckFailure = getRxResumeCredentialPrecheckFailure({
mode: rxResumeValidationMode,
stored: storedRxResume,
draft: validationDraft,
});
if (!precheckFailure) {
const preserveBlankFields = [
...(dirtyFields.rxresumeEmail ? (["email"] as const) : []),
...(dirtyFields.rxresumePassword ? (["password"] as const) : []),
...(dirtyFields.rxresumeApiKey ? (["apiKey"] as const) : []),
...(dirtyFields.rxresumeUrl ? (["baseUrl"] as const) : []),
];
const validation = await api.validateRxresume({
mode: rxResumeValidationMode,
...toRxResumeValidationPayload(validationDraft, {
preserveBlankFields: preserveBlankFields as Array<
keyof ReturnType<typeof getRxResumeCredentialDrafts>
>,
}),
});
setRxResumeValidationStatus(rxResumeValidationMode, validation);
if (isRxResumeBlockingValidationFailure(validation)) {
clearErrors(
getRxResumeValidationFieldsForMode(rxResumeValidationMode),
);
if (rxResumeValidationMode === "v5") {
setError("rxresumeApiKey", {
type: "manual",
message:
validation.message ??
"Reactive Resume v5 API key is invalid.",
});
} else {
setError("rxresumeEmail", {
type: "manual",
message:
validation.message ??
"Reactive Resume v4 email/password is invalid.",
});
setError("rxresumePassword", {
type: "manual",
message:
validation.message ??
"Reactive Resume v4 email/password is invalid.",
});
}
return;
}
clearErrors(
getRxResumeValidationFieldsForMode(rxResumeValidationMode),
);
if (isRxResumeAvailabilityValidationFailure(validation)) {
rxResumeSaveWarningMessage =
"Settings saved, but JobOps could not verify Reactive Resume because the instance is unavailable.";
}
}
}
const updated = await updateSettingsMutation.mutateAsync(payload);
if (
@@ -1048,9 +873,6 @@ export const SettingsPage: React.FC = () => {
setSettings(updated);
reset(mapSettingsToForm(updated));
toast.success("Settings saved");
if (rxResumeSaveWarningMessage) {
toast.info(rxResumeSaveWarningMessage);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save settings";
@@ -1184,29 +1006,13 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<ReactiveResumeSection
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
onRxresumeModeChange={(mode) => {
const nextId = getBaseResumeIdForMode(mode);
setRxResumeBaseResumeIdDraft(nextId);
setValue("rxresumeBaseResumeId", nextId, { shouldDirty: true });
setRxResumeProjectsOverride(null);
}}
setRxResumeBaseResumeIdDraft={(value) => {
const mode = (getValues("rxresumeMode") ??
rxresumeMode) as RxResumeMode;
setBaseResumeIdForMode(mode, value);
setRxResumeBaseResumeIdDraft(value);
setValue("rxresumeBaseResumeId", value, { shouldDirty: true });
}}
hasRxResumeAccess={hasRxResumeAccess}
rxresumeMode={rxresumeMode}
onCredentialFieldEdit={clearRxResumeValidationFeedback}
validationStatuses={rxresumeValidationStatuses}
profileProjects={effectiveProfileProjects}
<ResumeSettingsSection
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={effectiveMaxProjectsTotal}
isProjectsLoading={isFetchingRxResumeProjects}
localResumeFileConfigured={Boolean(
settings?.localResumeFileConfigured,
)}
isLoading={isLoading}
isSaving={isSaving}
/>
@@ -1232,6 +1038,15 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<KeywordSetsSettingsSection
isLoading={isLoading}
isSaving={isSaving}
/>
<JobSourcesSettingsSection
values={jobSources}
isLoading={isLoading}
isSaving={isSaving}
/>
<ScoringSettingsSection
values={scoring}
isLoading={isLoading}
@@ -114,6 +114,7 @@ beforeEach(() => {
status: "applied",
suitabilityScore: null,
sponsorMatchScore: null,
sponsorshipSignals: null,
jobType: null,
jobFunction: null,
salaryMinAmount: null,
@@ -137,6 +138,7 @@ beforeEach(() => {
status: "applied",
suitabilityScore: null,
sponsorMatchScore: null,
sponsorshipSignals: null,
jobType: null,
jobFunction: null,
salaryMinAmount: null,
@@ -25,6 +25,10 @@ vi.mock("@/lib/user-location", () => ({
getDetectedCountryKey: getDetectedCountryKeyMock,
}));
vi.mock("./KeywordSetPicker", () => ({
KeywordSetPicker: () => null,
}));
describe("AutomaticRunTab", () => {
beforeEach(() => {
getDetectedCountryKeyMock.mockReset();
@@ -5,7 +5,7 @@ import {
normalizeCountryKey,
SUPPORTED_COUNTRY_KEYS,
} from "@shared/location-support.js";
import type { AppSettings, JobSource } from "@shared/types";
import type { AppSettings, JobSource, KeywordSet } from "@shared/types";
import { Loader2, Sparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -45,6 +45,7 @@ import {
WORKPLACE_TYPE_OPTIONS,
type WorkplaceType,
} from "./automatic-run";
import { KeywordSetPicker } from "./KeywordSetPicker";
import { TokenizedInput } from "./TokenizedInput";
interface AutomaticRunTabProps {
@@ -62,7 +63,8 @@ const DEFAULT_VALUES: AutomaticRunValues = {
topN: 10,
minSuitabilityScore: 50,
searchTerms: ["web developer"],
runBudget: 200,
activeKeywordSetId: null,
runBudget: 500,
country: "united kingdom",
cityLocations: [],
workplaceTypes: ["remote", "hybrid", "onsite"],
@@ -163,6 +165,9 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
}) => {
const [isSaving, setIsSaving] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [activeKeywordSetId, setActiveKeywordSetId] = useState<string | null>(
null,
);
const { watch, reset, setValue } = useForm<AutomaticRunFormValues>({
defaultValues: {
topN: String(DEFAULT_VALUES.topN),
@@ -187,6 +192,25 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
const searchTerms = watch("searchTerms");
const searchTermDraft = watch("searchTermDraft");
const handleActiveKeywordSetChange = useCallback(
(set: KeywordSet) => {
setActiveKeywordSetId(set.id);
const terms = set.terms.map((t) => t.trim()).filter(Boolean);
if (terms.length > 0) {
setValue("searchTerms", terms, { shouldDirty: false });
return;
}
const profileRoles =
settings?.jobSearchProfile?.value?.targetRoles
?.map((t) => t.trim())
.filter((t): t is string => Boolean(t)) ?? [];
if (profileRoles.length > 0) {
setValue("searchTerms", profileRoles, { shouldDirty: false });
}
},
[setValue, settings?.jobSearchProfile?.value?.targetRoles],
);
useEffect(() => {
if (!open) return;
const memory = loadAutomaticRunMemory();
@@ -274,6 +298,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
cityLocations,
workplaceTypes: normalizeWorkplaceTypes(workplaceTypes),
searchTerms,
activeKeywordSetId,
};
}, [
topNInput,
@@ -283,6 +308,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
cityLocations,
workplaceTypes,
searchTerms,
activeKeywordSetId,
]);
const workplaceTypeSelectionInvalid = workplaceTypes.length === 0;
@@ -320,13 +346,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
const filtered = pipelineSources.filter((source) =>
isSourceAvailableForRun(source),
);
const missing = compatibleEnabledSources.filter(
(source) => !filtered.includes(source),
);
if (missing.length > 0) {
onSetPipelineSources([...filtered, ...missing]);
return;
}
if (filtered.length === pipelineSources.length) return;
if (filtered.length > 0) {
onSetPipelineSources(filtered);
return;
}
if (compatibleEnabledSources.length > 0) {
onSetPipelineSources([compatibleEnabledSources[0]]);
onSetPipelineSources([...compatibleEnabledSources]);
}
}, [
compatibleEnabledSources,
@@ -335,14 +368,19 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
pipelineSources,
]);
const mergedDiscoveryTerms = useMemo(
() =>
mergeDiscoverySearchTerms(
values.searchTerms,
settings?.jobSearchProfile?.value?.targetRoles,
),
[values.searchTerms, settings?.jobSearchProfile?.value?.targetRoles],
);
const mergedDiscoveryTerms = useMemo(() => {
if (values.activeKeywordSetId) {
return values.searchTerms.map((t) => t.trim()).filter(Boolean);
}
return mergeDiscoverySearchTerms(
values.searchTerms,
settings?.jobSearchProfile?.value?.targetRoles,
);
}, [
values.searchTerms,
values.activeKeywordSetId,
settings?.jobSearchProfile?.value?.targetRoles,
]);
const estimate = useMemo(
() =>
@@ -600,7 +638,11 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
<CardHeader className="pb-3">
<CardTitle>Search terms</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<KeywordSetPicker
disabled={isSaving || isPipelineRunning}
onActiveSetChange={handleActiveKeywordSetChange}
/>
<TokenizedInput
id="search-terms-input"
values={searchTerms}
@@ -619,10 +661,37 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
<Card>
<CardHeader className="pb-3">
<CardTitle>
Sources ({compatiblePipelineSources.length}/
{compatibleEnabledSources.length})
</CardTitle>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>
Sources ({compatiblePipelineSources.length}/
{compatibleEnabledSources.length})
</CardTitle>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={isSaving || isPipelineRunning}
onClick={() =>
onSetPipelineSources([...compatibleEnabledSources])
}
>
Select all
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={isSaving || isPipelineRunning}
onClick={() => {
if (compatibleEnabledSources.length === 0) return;
onSetPipelineSources([compatibleEnabledSources[0]]);
}}
>
Clear
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<TooltipProvider>
@@ -37,7 +37,7 @@ describe("JobCommandBar", () => {
expect(
screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
),
).toBeInTheDocument();
});
@@ -52,7 +52,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@disc" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -70,7 +70,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Enter" });
@@ -91,7 +91,7 @@ describe("JobCommandBar", () => {
expect(dialog.className).not.toContain("border-sky-500/50");
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@disc" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -118,7 +118,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@ready" } });
@@ -141,7 +141,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@" } });
@@ -163,7 +163,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@prog" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -192,7 +192,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
fireEvent.change(
screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
),
{
target: { value: "Globex" },
@@ -220,7 +220,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@disc" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -257,7 +257,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@disc" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -280,7 +280,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -302,7 +302,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -323,7 +323,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -342,7 +342,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -353,7 +353,7 @@ describe("JobCommandBar", () => {
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
expect(
screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
),
).toBeInTheDocument();
});
@@ -368,7 +368,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -390,7 +390,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
fireEvent.change(input, { target: { value: "@all" } });
fireEvent.keyDown(input, { key: "Tab" });
@@ -423,7 +423,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
fireEvent.change(
screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
),
{
target: { value: "Globex" },
@@ -472,7 +472,7 @@ describe("JobCommandBar", () => {
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
"Search jobs by title, company, location, or sponsorship signals...",
);
const lockTokens = ["@ready", "@disc", "@applied", "@skip", "@exp"];
@@ -169,10 +169,11 @@ export const JobCommandBar: React.FC<JobCommandBarProps> = ({
>
<DialogTitle className="sr-only">Job Search</DialogTitle>
<DialogDescription className="sr-only">
Search jobs across all states by job title or company name.
Search jobs across all states by title, company, location, or
sponsorship signals such as TN or visa sponsorship.
</DialogDescription>
<CommandInput
placeholder="Search jobs by job title or company name..."
placeholder="Search jobs by title, company, location, or sponsorship signals..."
value={query}
onValueChange={setQuery}
onKeyDown={handleInputKeyDown}
@@ -1,3 +1,4 @@
import { sponsorshipSignalSearchHaystack } from "@shared/sponsorship-signals";
import type { JobListItem, JobStatus } from "@shared/types.js";
import type { FilterTab } from "./constants";
@@ -144,12 +145,23 @@ export const computeJobMatchScore = (
job.location ?? "",
normalizedQuery,
);
const sponsorshipScore = computeFieldMatchScore(
sponsorshipSignalSearchHaystack(job.sponsorshipSignals),
normalizedQuery,
);
// Prefer title/company matches over location when scores tie.
// Only apply bias when a field actually matched.
const titleRankedScore = titleScore > 0 ? titleScore + 8 : 0;
const employerRankedScore = employerScore > 0 ? employerScore + 12 : 0;
return Math.max(titleRankedScore, employerRankedScore, locationScore);
const sponsorshipRankedScore =
sponsorshipScore > 0 ? sponsorshipScore + 4 : 0;
return Math.max(
titleRankedScore,
employerRankedScore,
locationScore,
sponsorshipRankedScore,
);
};
export const groupJobsForCommandBar = (
@@ -1,3 +1,4 @@
import { SponsorshipSignalsPills } from "@client/components/SponsorshipSignalsPills";
import type { JobListItem } from "@shared/types.js";
import { cn } from "@/lib/utils";
import { defaultStatusToken, statusTokens } from "./constants";
@@ -77,6 +78,11 @@ export const JobRowContent = ({
Found {formatDiscoveredAt(job.discoveredAt)}
</div>
)}
<SponsorshipSignalsPills
sponsorshipSignals={job.sponsorshipSignals}
size="xs"
className="mt-1"
/>
</div>
{hasScore && (
@@ -0,0 +1,253 @@
import * as api from "@client/api";
import type { KeywordSet } from "@shared/types";
import { Loader2, Plus, Trash2 } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
type KeywordSetPickerProps = {
disabled?: boolean;
onActiveSetChange: (set: KeywordSet) => void;
};
export const KeywordSetPicker: React.FC<KeywordSetPickerProps> = ({
disabled = false,
onActiveSetChange,
}) => {
const [sets, setSets] = useState<KeywordSet[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [newSetName, setNewSetName] = useState("");
const [showCreate, setShowCreate] = useState(false);
const onActiveSetChangeRef = useRef(onActiveSetChange);
const lastNotifiedIdRef = useRef<string | null>(null);
onActiveSetChangeRef.current = onActiveSetChange;
const notifyActiveSet = useCallback((set: KeywordSet) => {
if (lastNotifiedIdRef.current === set.id) return;
lastNotifiedIdRef.current = set.id;
onActiveSetChangeRef.current(set);
}, []);
const loadSets = useCallback(async () => {
setLoading(true);
try {
const list = await api.listKeywordSets();
setSets(list);
const active = list.find((s) => s.isActive) ?? list[0];
if (active) notifyActiveSet(active);
} catch {
toast.error("Failed to load keyword sets");
} finally {
setLoading(false);
}
}, [notifyActiveSet]);
useEffect(() => {
void loadSets();
}, [loadSets]);
const handleSelect = useCallback(
async (setId: string) => {
if (disabled || loading) return;
const current = sets.find((s) => s.id === setId);
if (current?.isActive) return;
setLoading(true);
try {
const activated = await api.activateKeywordSet(setId);
setSets((prev) =>
prev.map((s) => ({ ...s, isActive: s.id === activated.id })),
);
notifyActiveSet(activated);
} catch {
toast.error("Failed to switch keyword set");
} finally {
setLoading(false);
}
},
[disabled, loading, notifyActiveSet, sets],
);
const handleCreate = useCallback(async () => {
const name = newSetName.trim();
if (!name) {
toast.error("Enter a name for the keyword set");
return;
}
setCreating(true);
try {
const created = await api.createKeywordSet({ name, terms: [] });
const activated = await api.activateKeywordSet(created.id);
setSets((prev) => [
...prev.map((s) => ({ ...s, isActive: false })),
{ ...activated, isActive: true },
]);
notifyActiveSet(activated);
setNewSetName("");
setShowCreate(false);
toast.success(`Keyword set "${activated.name}" created`);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to create keyword set";
toast.error(message);
} finally {
setCreating(false);
}
}, [newSetName, notifyActiveSet]);
const handleDelete = useCallback(
async (setId: string) => {
if (disabled || loading) return;
setLoading(true);
try {
await api.deleteKeywordSet(setId);
lastNotifiedIdRef.current = null;
const list = await api.listKeywordSets();
setSets(list);
const active = list.find((s) => s.isActive) ?? list[0];
if (active) notifyActiveSet(active);
toast.success("Keyword set deleted");
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to delete keyword set";
toast.error(message);
} finally {
setLoading(false);
}
},
[disabled, loading, notifyActiveSet],
);
const activeSet = sets.find((s) => s.isActive) ?? sets[0];
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium shrink-0">Active set</span>
{activeSet ? (
<Select
value={activeSet.id}
onValueChange={handleSelect}
disabled={disabled || loading}
>
<SelectTrigger
className="w-[min(100%,240px)]"
aria-label="Keyword set"
>
<SelectValue placeholder="Keyword set" />
</SelectTrigger>
<SelectContent>
{sets.map((set) => (
<SelectItem key={set.id} value={set.id}>
{set.name}
{set.isActive ? " (active)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-sm text-muted-foreground">
{loading ? "Loading sets…" : "No keyword sets"}
</span>
)}
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || creating}
onClick={() => setShowCreate((v) => !v)}
>
<Plus className="h-4 w-4 mr-1" />
New set
</Button>
{activeSet && sets.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled || loading}
onClick={() => void handleDelete(activeSet.id)}
title={`Delete "${activeSet.name}"`}
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</div>
{showCreate ? (
<div className="flex flex-wrap items-center gap-2">
<Input
value={newSetName}
onChange={(e) => setNewSetName(e.target.value)}
placeholder="e.g. SDET, Caseware, Lead"
className="max-w-xs"
disabled={disabled || creating}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleCreate();
}
}}
/>
<Button
type="button"
size="sm"
disabled={disabled || creating}
onClick={() => void handleCreate()}
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create"}
</Button>
</div>
) : null}
{sets.length > 0 ? (
<div className="flex flex-wrap gap-1.5 pt-1">
{sets.map((set) => {
const isActive = set.id === activeSet?.id;
return (
<button
key={set.id}
type="button"
disabled={disabled || loading || isActive}
onClick={() => void handleSelect(set.id)}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
isActive
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
)}
>
{set.name}
<span className="ml-1 text-muted-foreground">
({set.terms.length})
</span>
</button>
);
})}
</div>
) : !loading ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
No keyword sets loaded. Use &quot;New set&quot; or check that the API
is running.
</p>
) : null}
<p className="text-xs text-muted-foreground">
Switch between named lists for different searches. Terms below belong to
the active set.
</p>
</div>
);
};
@@ -56,6 +56,10 @@ const renderFilters = (overrides?: Partial<FiltersProps>) => {
onApplySettingsCompanySkipListChange: vi.fn(),
sponsorFilter: "all" as SponsorFilter,
onSponsorFilterChange: vi.fn(),
sponsorshipSignalsFilter: [],
onSponsorshipSignalsFilterChange: vi.fn(),
hideSponsorBlockers: false,
onHideSponsorBlockersChange: vi.fn(),
workplaceFilter: "all" as WorkplaceFilter,
onWorkplaceFilterChange: vi.fn(),
salaryFilter: {
@@ -1,6 +1,13 @@
import { KbdHint } from "@client/components/KbdHint";
import { getDisplayKey, SHORTCUTS } from "@client/lib/shortcut-map";
import { formatCountryLabel } from "@shared/location-support";
import {
SPONSORSHIP_GREEN_FLAG_IDS,
SPONSORSHIP_RED_FLAG_IDS,
SPONSORSHIP_SIGNAL_META,
SPONSORSHIP_YELLOW_FLAG_IDS,
type SponsorshipSignalId,
} from "@shared/sponsorship-signals";
import type { JobSource } from "@shared/types.js";
import { Filter, Search } from "lucide-react";
import type React from "react";
@@ -64,6 +71,10 @@ interface OrchestratorFiltersProps {
onApplySettingsCompanySkipListChange: (value: boolean) => void;
sponsorFilter: SponsorFilter;
onSponsorFilterChange: (value: SponsorFilter) => void;
sponsorshipSignalsFilter: SponsorshipSignalId[];
onSponsorshipSignalsFilterChange: (values: SponsorshipSignalId[]) => void;
hideSponsorBlockers: boolean;
onHideSponsorBlockersChange: (value: boolean) => void;
workplaceFilter: WorkplaceFilter;
onWorkplaceFilterChange: (value: WorkplaceFilter) => void;
salaryFilter: SalaryFilter;
@@ -174,6 +185,10 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
onApplySettingsCompanySkipListChange,
sponsorFilter,
onSponsorFilterChange,
sponsorshipSignalsFilter,
onSponsorshipSignalsFilterChange,
hideSponsorBlockers,
onHideSponsorBlockersChange,
workplaceFilter,
onWorkplaceFilterChange,
salaryFilter,
@@ -239,6 +254,8 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
) +
Number(!applySettingsCompanySkipList) +
Number(sponsorFilter !== "all") +
Number(sponsorshipSignalsFilter.length > 0) +
Number(hideSponsorBlockers) +
Number(workplaceFilter !== "all") +
Number(
(typeof salaryFilter.min === "number" && salaryFilter.min > 0) ||
@@ -255,6 +272,8 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
employerExcludeFilter.length,
applySettingsCompanySkipList,
sponsorFilter,
sponsorshipSignalsFilter.length,
hideSponsorBlockers,
workplaceFilter,
salaryFilter.min,
salaryFilter.max,
@@ -553,22 +572,160 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
<CardHeader className="pb-3">
<CardTitle>Sponsor status</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{sponsorOptions.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={
sponsorFilter === option.value
? "default"
: "outline"
}
onClick={() => onSponsorFilterChange(option.value)}
>
{option.label}
</Button>
))}
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{sponsorOptions.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={
sponsorFilter === option.value
? "default"
: "outline"
}
onClick={() => onSponsorFilterChange(option.value)}
>
{option.label}
</Button>
))}
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<Checkbox
id="hide-sponsor-blockers"
checked={hideSponsorBlockers}
onCheckedChange={(checked) => {
onHideSponsorBlockersChange(checked === true);
}}
/>
<div className="space-y-1">
<label
htmlFor="hide-sponsor-blockers"
className="text-sm font-medium leading-none cursor-pointer"
>
Hide red-flag roles
</label>
<p className="text-xs text-muted-foreground">
Hides citizenship/GC/EAD-only, clearance, and
unrestricted-work-auth posts. Yellow no-sponsor
listings stay visible so you can ask about TN.
</p>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-emerald-200/90">
Green flags (TN-friendly)
</p>
<p className="text-xs text-muted-foreground">
Show jobs matching any selected signal. Search{" "}
<span className="font-mono">TN</span>,{" "}
<span className="font-mono">sponsor</span>, or{" "}
<span className="font-mono">
authorized to work
</span>{" "}
in the job search bar too.
</p>
<div className="flex flex-wrap gap-2">
{SPONSORSHIP_GREEN_FLAG_IDS.map((signalId) => {
const selected =
sponsorshipSignalsFilter.includes(signalId);
const meta = SPONSORSHIP_SIGNAL_META[signalId];
return (
<Button
key={signalId}
type="button"
size="sm"
variant={selected ? "default" : "outline"}
onClick={() => {
const next = selected
? sponsorshipSignalsFilter.filter(
(value) => value !== signalId,
)
: [...sponsorshipSignalsFilter, signalId];
onSponsorshipSignalsFilterChange(next);
}}
>
{meta.label}
</Button>
);
})}
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-amber-200/90">
Yellow flags (verify TN)
</p>
<p className="text-xs text-muted-foreground">
Says no sponsorship ask if TN support letter is
available.
</p>
<div className="flex flex-wrap gap-2">
{SPONSORSHIP_YELLOW_FLAG_IDS.map((signalId) => {
const selected =
sponsorshipSignalsFilter.includes(signalId);
const meta = SPONSORSHIP_SIGNAL_META[signalId];
return (
<Button
key={signalId}
type="button"
size="sm"
variant={selected ? "secondary" : "outline"}
className={
selected
? "border-amber-500/40 bg-amber-500/20 text-amber-100"
: undefined
}
onClick={() => {
const next = selected
? sponsorshipSignalsFilter.filter(
(value) => value !== signalId,
)
: [...sponsorshipSignalsFilter, signalId];
onSponsorshipSignalsFilterChange(next);
}}
>
{meta.label}
</Button>
);
})}
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-rose-200/90">
Red flags (hard skip)
</p>
<p className="text-xs text-muted-foreground">
Citizenship/GC/EAD only, clearance, or unrestricted
work authorization usually not TN-viable.
</p>
<div className="flex flex-wrap gap-2">
{SPONSORSHIP_RED_FLAG_IDS.map((signalId) => {
const selected =
sponsorshipSignalsFilter.includes(signalId);
const meta = SPONSORSHIP_SIGNAL_META[signalId];
return (
<Button
key={signalId}
type="button"
size="sm"
variant={selected ? "destructive" : "outline"}
onClick={() => {
const next = selected
? sponsorshipSignalsFilter.filter(
(value) => value !== signalId,
)
: [...sponsorshipSignalsFilter, signalId];
onSponsorshipSignalsFilterChange(next);
}}
>
{meta.label}
</Button>
);
})}
</div>
</div>
</div>
</CardContent>
</Card>
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
AUTOMATIC_PRESETS,
buildMaxCoverageDiscoveryLimits,
calculateAutomaticEstimate,
deriveExtractorLimits,
mergeDiscoverySearchTerms,
@@ -26,6 +27,7 @@ describe("automatic-run utilities", () => {
topN: 10,
minSuitabilityScore: 50,
searchTerms: ["backend", "platform"],
activeKeywordSetId: null,
runBudget: 100,
country: "united kingdom",
cityLocations: [],
@@ -71,6 +73,7 @@ describe("automatic-run utilities", () => {
topN: 10,
minSuitabilityScore: 50,
searchTerms: [],
activeKeywordSetId: null,
runBudget: 750,
country: "united kingdom",
cityLocations: [],
@@ -112,6 +115,7 @@ describe("automatic-run utilities", () => {
topN: 10,
minSuitabilityScore: 50,
searchTerms: ["backend", "platform"],
activeKeywordSetId: null,
runBudget: 120,
country: "united kingdom",
cityLocations: [],
@@ -130,6 +134,7 @@ describe("automatic-run utilities", () => {
topN: 10,
minSuitabilityScore: 50,
searchTerms: ["backend", "platform"],
activeKeywordSetId: null,
runBudget: 120,
country: "united kingdom",
cityLocations: [],
@@ -148,6 +153,7 @@ describe("automatic-run utilities", () => {
topN: 10,
minSuitabilityScore: 50,
searchTerms: ["backend", "platform"],
activeKeywordSetId: null,
runBudget: 120,
country: "united kingdom",
cityLocations: [],
@@ -159,4 +165,16 @@ describe("automatic-run utilities", () => {
expect(estimate.discovered.cap).toBeGreaterThan(0);
expect(estimate.discovered.cap).toBeLessThanOrEqual(120);
});
it("raises discovery caps for max coverage runs", () => {
const limits = buildMaxCoverageDiscoveryLimits({
budget: 500,
searchTerms: ["qa engineer"],
sources: ["workingnomads", "testdevjobs", "indeed"],
});
expect(limits.workingnomadsMaxJobsPerTerm).toBeGreaterThanOrEqual(100);
expect(limits.testdevjobsMaxPages).toBe(10);
expect(limits.jobspyResultsWanted).toBeGreaterThanOrEqual(100);
});
});
@@ -2,6 +2,7 @@ import {
parseSearchCitiesSetting,
serializeSearchCitiesSetting,
} from "@shared/search-cities.js";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { JobSource } from "@shared/types";
/**
@@ -39,6 +40,7 @@ export interface AutomaticRunValues {
topN: number;
minSuitabilityScore: number;
searchTerms: string[];
activeKeywordSetId: string | null;
runBudget: number;
country: string;
cityLocations: string[];
@@ -80,7 +82,7 @@ export const AUTOMATIC_PRESETS: Record<
detailed: {
topN: 20,
minSuitabilityScore: 35,
runBudget: 750,
runBudget: 1000,
},
};
@@ -163,6 +165,50 @@ export function deriveExtractorLimits(args: {
};
}
/** Raise per-source caps before a pipeline run for maximum discovery coverage. */
export function buildMaxCoverageDiscoveryLimits(args: {
budget: number;
searchTerms: string[];
sources: JobSource[];
}): Partial<UpdateSettingsInput> {
const limits = deriveExtractorLimits(args);
const perTermCap = Math.max(100, limits.jobspyResultsWanted);
const boardCap = Math.max(150, perTermCap);
return {
jobspyResultsWanted: perTermCap,
gradcrackerMaxJobsPerTerm: boardCap,
ukvisajobsMaxJobs: boardCap,
adzunaMaxJobsPerTerm: boardCap,
startupjobsMaxJobsPerTerm: boardCap,
usajobsMaxJobsPerTerm: boardCap,
jobicyMaxJobsPerTerm: boardCap,
themuseMaxJobsPerTerm: boardCap,
joobleMaxJobsPerTerm: boardCap,
careerjetMaxJobsPerTerm: boardCap,
reedMaxJobsPerTerm: boardCap,
remoteokMaxJobsPerTerm: boardCap,
remotiveMaxJobsPerTerm: boardCap,
arbeitnowMaxJobsPerTerm: boardCap,
himalayasMaxJobsPerTerm: boardCap,
weworkremotelyMaxJobsPerTerm: boardCap,
workingnomadsMaxJobsPerTerm: boardCap,
fourdayweekMaxJobsPerTerm: boardCap,
testdevjobsMaxJobsPerTerm: boardCap,
testdevjobsMaxPages: 10,
builtinMaxJobsPerTerm: boardCap,
builtinMaxPagesPerTerm: 10,
wellfoundMaxJobsPerTerm: Math.min(100, boardCap),
qajobsboardMaxJobsPerTerm: boardCap,
arcMaxJobsPerPath: boardCap,
smartrecruitersMaxJobsPerCompany: boardCap,
elutaMaxJobsPerTerm: boardCap,
bctenetMaxJobsPerTerm: 400,
icimsMaxJobsPerTenant: 250,
icimsMaxPagesPerSearch: 10,
};
}
export function parseSearchTermsInput(input: string): string[] {
return input
.split(/[\n,]/g)
@@ -3,8 +3,17 @@ import {
EXTRACTOR_SOURCE_METADATA,
PIPELINE_EXTRACTOR_SOURCE_IDS,
} from "@shared/extractors";
import {
SPONSORSHIP_SIGNAL_IDS,
type SponsorshipSignalId,
} from "@shared/sponsorship-signals";
import type { JobSource, JobStatus } from "@shared/types";
export type { SponsorshipSignalId };
export const sponsorshipSignalFilterOptions = [
...SPONSORSHIP_SIGNAL_IDS,
] as const;
export const DEFAULT_PIPELINE_SOURCES: JobSource[] = [
"gradcracker",
"indeed",
@@ -1,3 +1,4 @@
import { serializeSponsorshipSignals } from "@shared/sponsorship-signals";
import { createJob } from "@shared/testing/factories";
import type { Job } from "@shared/types";
import { renderHook } from "@testing-library/react";
@@ -36,6 +37,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{
@@ -67,6 +70,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{
@@ -99,6 +104,8 @@ describe("useFilteredJobs", () => {
[],
[],
"confirmed",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{
@@ -111,6 +118,90 @@ describe("useFilteredJobs", () => {
expect(result.current.map((job) => job.id)).toEqual(["confirmed"]);
});
it("filters by sponsorship text signals", () => {
const jobs: Job[] = [
{
...baseJob,
id: "tn",
sponsorshipSignals: serializeSponsorshipSignals(["tn_eligible"]),
},
{
...baseJob,
id: "sponsor",
sponsorshipSignals: serializeSponsorshipSignals(["will_sponsor"]),
},
{ ...baseJob, id: "none", sponsorshipSignals: null },
];
const { result } = renderHook(() =>
useFilteredJobs(
jobs,
"all",
[],
[],
[],
[],
"all",
["tn_eligible", "will_sponsor"],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
),
);
expect(result.current.map((job) => job.id).sort()).toEqual([
"sponsor",
"tn",
]);
});
it("hides red-flag jobs but keeps yellow no-sponsor listings when enabled", () => {
const jobs: Job[] = [
{
...baseJob,
id: "red",
sponsorshipSignals: serializeSponsorshipSignals([
"citizenship_required",
]),
},
{
...baseJob,
id: "yellow",
sponsorshipSignals: serializeSponsorshipSignals([
"no_sponsorship_stated",
]),
},
{
...baseJob,
id: "tn",
sponsorshipSignals: serializeSponsorshipSignals(["tn_eligible"]),
},
];
const { result } = renderHook(() =>
useFilteredJobs(
jobs,
"all",
[],
[],
[],
[],
"all",
[],
true,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
),
);
expect(result.current.map((job) => job.id).sort()).toEqual([
"tn",
"yellow",
]);
});
it("filters by salary range using structured and text salary fields", () => {
const jobs: Job[] = [
{ ...baseJob, id: "structured", salaryMinAmount: 70000 },
@@ -128,6 +219,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "between", min: 60000, max: 80000 },
{
@@ -160,6 +253,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{
@@ -193,6 +288,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"remote",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -209,6 +306,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"not_remote",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -225,6 +324,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"unknown",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -249,6 +350,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -288,6 +391,8 @@ describe("useFilteredJobs", () => {
["united kingdom"],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -312,6 +417,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -359,6 +466,8 @@ describe("useFilteredJobs", () => {
[],
["united kingdom"],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -381,6 +490,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -412,6 +523,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -443,6 +556,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -466,6 +581,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -497,6 +614,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -520,6 +639,8 @@ describe("useFilteredJobs", () => {
[],
[],
"all",
[],
false,
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
@@ -1,6 +1,11 @@
import type { DuplicateDismissReason } from "@client/lib/job-dedup";
import { jobMatchesAllowedCountry } from "@shared/blocked-countries";
import { textMatchesKeyword } from "@shared/keyword-match";
import type { SponsorshipSignalId } from "@shared/sponsorship-signals";
import {
jobHasAnySponsorshipSignal,
jobHasRedSponsorshipFlag,
} from "@shared/sponsorship-signals";
import type { JobListItem, JobSource } from "@shared/types";
import { useMemo } from "react";
import type {
@@ -57,6 +62,8 @@ export const useFilteredJobs = (
countriesFilter: string[],
countriesExcludeFilter: string[],
sponsorFilter: SponsorFilter,
sponsorshipSignalsFilter: SponsorshipSignalId[],
hideSponsorBlockers: boolean,
workplaceFilter: WorkplaceFilter,
salaryFilter: SalaryFilter,
sort: JobSort,
@@ -154,6 +161,21 @@ export const useFilteredJobs = (
);
}
if (sponsorshipSignalsFilter.length > 0) {
filtered = filtered.filter((job) =>
jobHasAnySponsorshipSignal(
job.sponsorshipSignals,
sponsorshipSignalsFilter,
),
);
}
if (hideSponsorBlockers) {
filtered = filtered.filter(
(job) => !jobHasRedSponsorshipFlag(job.sponsorshipSignals),
);
}
if (workplaceFilter !== "all") {
filtered = filtered.filter((job) => {
if (workplaceFilter === "remote") return job.isRemote === true;
@@ -252,6 +274,8 @@ export const useFilteredJobs = (
countriesFilter,
countriesExcludeFilter,
sponsorFilter,
sponsorshipSignalsFilter,
hideSponsorBlockers,
workplaceFilter,
salaryFilter,
sort,
@@ -635,6 +635,7 @@ describe("useOrchestratorData", () => {
status: "discovered",
suitabilityScore: null,
sponsorMatchScore: null,
sponsorshipSignals: null,
jobType: null,
jobFunction: null,
salaryMinAmount: null,
@@ -3,6 +3,7 @@ import {
normalizeCountryKey,
SUPPORTED_COUNTRY_KEYS,
} from "@shared/location-support";
import type { SponsorshipSignalId } from "@shared/sponsorship-signals";
import type { JobSource } from "@shared/types";
import { useCallback, useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
@@ -13,7 +14,7 @@ import type {
SponsorFilter,
WorkplaceFilter,
} from "./constants";
import { DEFAULT_SORT } from "./constants";
import { DEFAULT_SORT, sponsorshipSignalFilterOptions } from "./constants";
const allowedSponsorFilters: SponsorFilter[] = [
"all",
@@ -43,6 +44,10 @@ const allowedWorkplaceFilters: WorkplaceFilter[] = [
"unknown",
];
const allowedSponsorshipSignals = new Set<string>(
sponsorshipSignalFilterOptions,
);
const allowedJobSources = new Set<string>(EXTRACTOR_SOURCE_IDS);
const allowedCountryKeys = new Set(SUPPORTED_COUNTRY_KEYS);
@@ -214,6 +219,58 @@ export const useOrchestratorFilters = () => {
: "all";
}, [searchParams]);
const sponsorshipSignalsFilter = useMemo((): SponsorshipSignalId[] => {
const raw = searchParams.getAll("sponsorSignal");
const out: SponsorshipSignalId[] = [];
const seen = new Set<string>();
for (const value of raw) {
const trimmed = value.trim();
if (!trimmed || !allowedSponsorshipSignals.has(trimmed)) continue;
if (seen.has(trimmed)) continue;
seen.add(trimmed);
out.push(trimmed as SponsorshipSignalId);
}
return out;
}, [searchParams]);
const hideSponsorBlockers = useMemo(
() => searchParams.get("hideSponsorBlockers") === "1",
[searchParams],
);
const setHideSponsorBlockers = useCallback(
(value: boolean) => {
setSearchParams(
(prev) => {
if (value) prev.set("hideSponsorBlockers", "1");
else prev.delete("hideSponsorBlockers");
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
const setSponsorshipSignalsFilter = useCallback(
(values: SponsorshipSignalId[]) => {
setSearchParams(
(prev) => {
prev.delete("sponsorSignal");
const unique = [
...new Set(
values.filter((value) => allowedSponsorshipSignals.has(value)),
),
];
for (const value of unique) prev.append("sponsorSignal", value);
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
const setSponsorFilter = useCallback(
(value: SponsorFilter) => {
setSearchParams(
@@ -438,6 +495,8 @@ export const useOrchestratorFilters = () => {
prev.delete("countries");
prev.delete("countriesExclude");
prev.delete("sponsor");
prev.delete("sponsorSignal");
prev.delete("hideSponsorBlockers");
prev.delete("workplace");
prev.delete("salaryMode");
prev.delete("salaryMin");
@@ -465,6 +524,10 @@ export const useOrchestratorFilters = () => {
setCountrySelection,
sponsorFilter,
setSponsorFilter,
sponsorshipSignalsFilter,
setSponsorshipSignalsFilter,
hideSponsorBlockers,
setHideSponsorBlockers,
workplaceFilter,
setWorkplaceFilter,
salaryFilter,
@@ -10,7 +10,7 @@ import { toast } from "sonner";
import { trackProductEvent } from "@/lib/analytics";
import type { AutomaticRunValues } from "./automatic-run";
import {
deriveExtractorLimits,
buildMaxCoverageDiscoveryLimits,
mergeDiscoverySearchTerms,
serializeCityLocationsSetting,
} from "./automatic-run";
@@ -169,10 +169,12 @@ export function usePipelineControls(
return;
}
const mergedSearchTerms = mergeDiscoverySearchTerms(
values.searchTerms,
settings?.jobSearchProfile?.value?.targetRoles,
);
const mergedSearchTerms = values.activeKeywordSetId
? values.searchTerms.map((t) => t.trim()).filter(Boolean)
: mergeDiscoverySearchTerms(
values.searchTerms,
settings?.jobSearchProfile?.value?.targetRoles,
);
if (mergedSearchTerms.length === 0) {
toast.error(
"Add at least one search term, or set target roles on your job search profile.",
@@ -180,11 +182,6 @@ export function usePipelineControls(
return;
}
const limits = deriveExtractorLimits({
budget: values.runBudget,
searchTerms: mergedSearchTerms,
sources: compatibleSources,
});
const hasJobSpySite = compatibleSources.some(
(source) =>
source === "indeed" ||
@@ -202,14 +199,19 @@ export function usePipelineControls(
serializedCities
? serializedCities
: formatCountryLabel(values.country);
if (values.activeKeywordSetId) {
await api.updateKeywordSet(values.activeKeywordSetId, {
terms: values.searchTerms,
});
}
await api.updateSettings({
searchTerms: values.searchTerms,
workplaceTypes: values.workplaceTypes,
jobspyResultsWanted: limits.jobspyResultsWanted,
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
adzunaMaxJobsPerTerm: limits.adzunaMaxJobsPerTerm,
startupjobsMaxJobsPerTerm: limits.startupjobsMaxJobsPerTerm,
...buildMaxCoverageDiscoveryLimits({
budget: values.runBudget,
searchTerms: mergedSearchTerms,
sources: compatibleSources,
}),
jobspyCountryIndeed: values.country,
searchCities,
});
@@ -67,7 +67,36 @@ describe("usePipelineSources", () => {
expect(result.current.pipelineSources).toEqual(["gradcracker"]);
});
it("falls back to the first enabled source", () => {
it("merges newly enabled sources into stored selection", () => {
ensureStorage().setItem(
PIPELINE_SOURCES_STORAGE_KEY,
JSON.stringify(["gradcracker"]),
);
const enabledSources = [
"gradcracker",
"linkedin",
"workingnomads",
] as const;
const { result } = renderHook(() => usePipelineSources(enabledSources));
expect(result.current.pipelineSources).toEqual([
"gradcracker",
"linkedin",
"workingnomads",
]);
});
it("selects all enabled sources when storage is empty", () => {
const enabledSources = ["gradcracker", "linkedin"] as const;
const { result } = renderHook(() => usePipelineSources(enabledSources));
expect(result.current.pipelineSources).toEqual(["gradcracker", "linkedin"]);
});
it("falls back to all enabled sources when stored sources are unavailable", () => {
ensureStorage().setItem(
PIPELINE_SOURCES_STORAGE_KEY,
JSON.stringify(["ukvisajobs"]),
@@ -77,7 +106,7 @@ describe("usePipelineSources", () => {
const { result } = renderHook(() => usePipelineSources(enabledSources));
expect(result.current.pipelineSources).toEqual(["gradcracker"]);
expect(result.current.pipelineSources).toEqual(["gradcracker", "linkedin"]);
});
it("ignores toggles for disabled sources", () => {
@@ -11,12 +11,14 @@ const resolveAllowedSources = (enabledSources?: readonly JobSource[]) =>
? (enabledSources as JobSource[])
: DEFAULT_PIPELINE_SOURCES;
const normalizeSources = (
const mergeAllowedSources = (
sources: JobSource[],
allowedSources: JobSource[],
) => {
const filtered = sources.filter((value) => allowedSources.includes(value));
return filtered.length > 0 ? filtered : allowedSources.slice(0, 1);
): JobSource[] => {
const kept = sources.filter((value) => allowedSources.includes(value));
const missing = allowedSources.filter((value) => !kept.includes(value));
const merged = [...kept, ...missing];
return merged.length > 0 ? merged : [...allowedSources];
};
const sourcesMatch = (left: JobSource[], right: JobSource[]) =>
@@ -31,22 +33,22 @@ export const usePipelineSources = (enabledSources?: readonly JobSource[]) => {
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
try {
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
if (!raw) return normalizeSources(allowedSources, allowedSources);
if (!raw) return mergeAllowedSources([], allowedSources);
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed))
return normalizeSources(allowedSources, allowedSources);
return mergeAllowedSources([], allowedSources);
const next = parsed.filter((value): value is JobSource =>
orderedSources.includes(value as JobSource),
);
return normalizeSources(next, allowedSources);
return mergeAllowedSources(next, allowedSources);
} catch {
return normalizeSources(allowedSources, allowedSources);
return mergeAllowedSources([], allowedSources);
}
});
useEffect(() => {
setPipelineSources((current) => {
const normalized = normalizeSources(current, allowedSources);
const normalized = mergeAllowedSources(current, allowedSources);
return sourcesMatch(current, normalized) ? current : normalized;
});
}, [allowedSources]);
@@ -27,6 +27,37 @@ describe("orchestrator utils", () => {
expect(enabled).toContain("themuse");
});
it("enables new public job boards without credentials", () => {
const enabled = getEnabledSources(createAppSettings());
for (const source of [
"workingnomads",
"testdevjobs",
"wellfound",
"builtin",
] as const) {
expect(enabled).toContain(source);
}
});
it("enables ATS sources when company lists are configured", () => {
const enabled = getEnabledSources(
createAppSettings({
teamtailorCompanies: {
value: ["testgorilla"],
default: [],
override: null,
},
careersPageUrls: {
value: ["https://sentry.io/careers/"],
default: [],
override: null,
},
}),
);
expect(enabled).toContain("teamtailor");
expect(enabled).toContain("careerspages");
});
it("counts processing jobs in ready and discovered tabs", () => {
const jobs = [
createJob({ id: "ready", status: "ready", closedAt: null }),
@@ -212,6 +212,12 @@ export const getEnabledSources = (
const hasWorkdayTenants = (settings.workdayTenants?.value ?? []).length > 0;
const hasSmartrecruitersCompanies =
(settings.smartrecruitersCompanies?.value ?? []).length > 0;
const hasTeamtailorCompanies =
(settings.teamtailorCompanies?.value ?? []).length > 0;
const hasHuntflowTenants = (settings.huntflowTenants?.value ?? []).length > 0;
const hasFactorialTenants =
(settings.factorialTenants?.value ?? []).length > 0;
const hasCareersPageUrls = (settings.careersPageUrls?.value ?? []).length > 0;
const hasElutaRssLocations =
(settings.elutaRssLocations?.value ?? []).length > 0;
const hasIcimsTenants = (settings.icimsTenants?.value ?? []).length > 0;
@@ -281,6 +287,22 @@ export const getEnabledSources = (
if (hasSmartrecruitersCompanies) enabled.push(source);
continue;
}
if (source === "teamtailor") {
if (hasTeamtailorCompanies) enabled.push(source);
continue;
}
if (source === "huntflow") {
if (hasHuntflowTenants) enabled.push(source);
continue;
}
if (source === "factorial") {
if (hasFactorialTenants) enabled.push(source);
continue;
}
if (source === "careerspages") {
if (hasCareersPageUrls) enabled.push(source);
continue;
}
if (source === "icims") {
if (hasIcimsTenants) enabled.push(source);
continue;
@@ -307,7 +329,11 @@ export const getEnabledSources = (
source === "arbeitnow" ||
source === "himalayas" ||
source === "weworkremotely" ||
source === "workingnomads" ||
source === "fourdayweek" ||
source === "testdevjobs" ||
source === "wellfound" ||
source === "builtin" ||
source === "qajobsboard" ||
source === "arcdev"
) {
@@ -1,137 +0,0 @@
import * as api from "@client/api";
import type { RxResumeMode } from "@shared/types.js";
import { RefreshCw } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type BaseResumeSelectionProps = {
value: string | null;
onValueChange: (value: string | null) => void;
hasRxResumeAccess: boolean;
rxresumeMode?: RxResumeMode;
disabled?: boolean;
isLoading?: boolean;
};
export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
value,
onValueChange,
hasRxResumeAccess,
rxresumeMode,
disabled = false,
isLoading = false,
}) => {
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]);
const [isFetchingResumes, setIsFetchingResumes] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const fetchResumes = useCallback(async () => {
if (!hasRxResumeAccess) {
setResumes([]);
setFetchError(null);
return;
}
setIsFetchingResumes(true);
setFetchError(null);
try {
const data = await api.getRxResumes(rxresumeMode);
setResumes(data);
// Preselect if only one option is available and no value is currently set
if (data.length === 1 && !value) {
onValueChange(data[0].id);
}
} catch (error) {
setResumes([]);
setFetchError(
error instanceof Error ? error.message : "Failed to fetch resumes",
);
} finally {
setIsFetchingResumes(false);
}
}, [hasRxResumeAccess, onValueChange, rxresumeMode, value]);
useEffect(() => {
if (hasRxResumeAccess) {
fetchResumes();
}
}, [hasRxResumeAccess, fetchResumes]);
useEffect(() => {
if (!hasRxResumeAccess) {
setResumes([]);
setFetchError(null);
}
}, [hasRxResumeAccess]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Template Resume</div>
<Button
variant="ghost"
size="sm"
onClick={fetchResumes}
disabled={isFetchingResumes || isLoading || disabled}
className="h-8 px-2"
>
<RefreshCw
className={`h-3 w-3 mr-1 ${isFetchingResumes ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
<Select
value={value || ""}
onValueChange={(val: string) => onValueChange(val || null)}
disabled={disabled || isLoading || isFetchingResumes}
>
<SelectTrigger>
<SelectValue
placeholder={
resumes.length > 0
? "Select a template resume..."
: "No resumes found"
}
/>
</SelectTrigger>
<SelectContent>
{resumes.map((resume) => (
<SelectItem key={resume.id} value={resume.id}>
{resume.name}
</SelectItem>
))}
</SelectContent>
</Select>
{resumes.length === 0 && !isFetchingResumes && !fetchError && (
<div className="text-xs text-amber-600 dark:text-amber-400 mt-2">
No resumes found in your account. Please create a resume on the{" "}
<a
href="https://rxresu.me"
target="_blank"
rel="noreferrer"
className="font-semibold underline underline-offset-2"
>
Reactive Resume website
</a>{" "}
first.
</div>
)}
{fetchError && (
<div className="text-xs text-destructive mt-1">{fetchError}</div>
)}
</div>
);
};
@@ -7,10 +7,8 @@ import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection";
const EnvironmentSettingsHarness = () => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
rxresumeEmail: "resume@example.com",
ukvisajobsEmail: "visa@example.com",
basicAuthUser: "admin",
rxresumePassword: "",
ukvisajobsPassword: "",
adzunaAppId: "adzuna-id",
adzunaAppKey: "",
@@ -26,13 +24,11 @@ const EnvironmentSettingsHarness = () => {
<EnvironmentSettingsSection
values={{
readable: {
rxresumeEmail: "resume@example.com",
ukvisajobsEmail: "visa@example.com",
adzunaAppId: "adzuna-id",
basicAuthUser: "admin",
},
private: {
rxresumePasswordHint: null,
ukvisajobsPasswordHint: "pass",
adzunaAppKeyHint: "adzu",
basicAuthPasswordHint: "abcd",
@@ -66,8 +62,6 @@ describe("EnvironmentSettingsSection", () => {
// Sections
expect(screen.getByText("Service Accounts")).toBeInTheDocument();
expect(screen.getByText("Security")).toBeInTheDocument();
expect(screen.queryByText("RxResume")).not.toBeInTheDocument();
expect(
screen.getByRole("button", {
name: /sign out \/ switch user/i,
@@ -0,0 +1,57 @@
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import { render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { Accordion } from "@/components/ui/accordion";
import { JobSourcesSettingsSection } from "./JobSourcesSettingsSection";
const Harness = () => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
ashbyCompanies: [],
greenhouseCompanies: [],
leverCompanies: [],
teamtailorCompanies: ["testgorilla"],
careersPageUrls: [],
},
});
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["job-sources"]}>
<JobSourcesSettingsSection
values={{
ashbyCompanies: { effective: [], default: [] },
greenhouseCompanies: { effective: [], default: [] },
leverCompanies: { effective: [], default: [] },
smartrecruitersCompanies: { effective: [], default: [] },
teamtailorCompanies: {
effective: ["testgorilla"],
default: [],
},
huntflowTenants: { effective: [], default: [] },
factorialTenants: { effective: [], default: [] },
workdayTenants: { effective: [], default: [] },
careersPageUrls: { effective: [], default: [] },
icimsTenants: { effective: [], default: [] },
elutaRssLocations: { effective: [], default: [] },
}}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
);
};
describe("JobSourcesSettingsSection", () => {
it("renders ATS and career page fields", () => {
render(<Harness />);
expect(screen.getByText("Job sources & ATS")).toBeInTheDocument();
expect(screen.getByText("Teamtailor subdomains")).toBeInTheDocument();
expect(
screen.getByText("Career page URLs (auto-detect ATS)"),
).toBeInTheDocument();
expect(screen.getByText("testgorilla")).toBeInTheDocument();
});
});
@@ -0,0 +1,237 @@
import { parseSearchTermsInput } from "@client/pages/orchestrator/automatic-run";
import { TokenizedInput } from "@client/pages/orchestrator/TokenizedInput";
import type { JobSourcesValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type React from "react";
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Label } from "@/components/ui/label";
type JobSourcesSettingsSectionProps = {
values: JobSourcesValues;
isLoading: boolean;
isSaving: boolean;
};
type TokenizedFieldProps = {
id: string;
label: string;
helperText: string;
name: keyof Pick<
UpdateSettingsInput,
| "ashbyCompanies"
| "greenhouseCompanies"
| "leverCompanies"
| "smartrecruitersCompanies"
| "teamtailorCompanies"
| "huntflowTenants"
| "factorialTenants"
| "workdayTenants"
| "careersPageUrls"
| "icimsTenants"
| "elutaRssLocations"
>;
disabled: boolean;
defaultValues: string[];
draft: string;
onDraftChange: (value: string) => void;
};
function TokenizedCompaniesField({
id,
label,
helperText,
name,
disabled,
defaultValues,
draft,
onDraftChange,
}: TokenizedFieldProps) {
const { control } = useFormContext<UpdateSettingsInput>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<TokenizedInput
id={id}
values={field.value ?? defaultValues}
draft={draft}
parseInput={parseSearchTermsInput}
onDraftChange={onDraftChange}
onValuesChange={field.onChange}
placeholder="Type and press Enter"
helperText={helperText}
removeLabelPrefix="Remove"
disabled={disabled}
/>
</div>
)}
/>
);
}
export const JobSourcesSettingsSection: React.FC<
JobSourcesSettingsSectionProps
> = ({ values, isLoading, isSaving }) => {
const disabled = isLoading || isSaving;
const [ashbyDraft, setAshbyDraft] = useState("");
const [greenhouseDraft, setGreenhouseDraft] = useState("");
const [leverDraft, setLeverDraft] = useState("");
const [smartrecruitersDraft, setSmartrecruitersDraft] = useState("");
const [teamtailorDraft, setTeamtailorDraft] = useState("");
const [huntflowDraft, setHuntflowDraft] = useState("");
const [factorialDraft, setFactorialDraft] = useState("");
const [workdayDraft, setWorkdayDraft] = useState("");
const [careersPagesDraft, setCareersPagesDraft] = useState("");
const [icimsDraft, setIcimsDraft] = useState("");
const [elutaDraft, setElutaDraft] = useState("");
return (
<AccordionItem value="job-sources" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Job sources & ATS</span>
</AccordionTrigger>
<AccordionContent className="pb-4 space-y-6">
<p className="text-sm text-muted-foreground">
Configure company slugs, tenant IDs, and career-page URLs so ATS and
direct employer sources appear in the pipeline run dialog. Public job
boards (Working Nomads, TestDevJobs, Wellfound, etc.) need no setup
here.
</p>
<div className="space-y-4">
<div className="text-sm font-semibold">
Ashby / Greenhouse / Lever
</div>
<TokenizedCompaniesField
id="ashby-companies"
label="Ashby company slugs"
name="ashbyCompanies"
defaultValues={values.ashbyCompanies.default}
disabled={disabled}
draft={ashbyDraft}
onDraftChange={setAshbyDraft}
helperText="Board slug from jobs.ashbyhq.com/{slug}, e.g. ramp, linear."
/>
<TokenizedCompaniesField
id="greenhouse-companies"
label="Greenhouse company slugs"
name="greenhouseCompanies"
defaultValues={values.greenhouseCompanies.default}
disabled={disabled}
draft={greenhouseDraft}
onDraftChange={setGreenhouseDraft}
helperText="Board slug from boards.greenhouse.io/{slug}, e.g. stripe."
/>
<TokenizedCompaniesField
id="lever-companies"
label="Lever company slugs"
name="leverCompanies"
defaultValues={values.leverCompanies.default}
disabled={disabled}
draft={leverDraft}
onDraftChange={setLeverDraft}
helperText="Board slug from jobs.lever.co/{slug}, e.g. netflix."
/>
</div>
<div className="space-y-4">
<div className="text-sm font-semibold">Other ATS platforms</div>
<TokenizedCompaniesField
id="smartrecruiters-companies"
label="SmartRecruiters companies"
name="smartrecruitersCompanies"
defaultValues={values.smartrecruitersCompanies.default}
disabled={disabled}
draft={smartrecruitersDraft}
onDraftChange={setSmartrecruitersDraft}
helperText="API path segment from jobs.smartrecruiters.com/{company}/..."
/>
<TokenizedCompaniesField
id="teamtailor-companies"
label="Teamtailor subdomains"
name="teamtailorCompanies"
defaultValues={values.teamtailorCompanies.default}
disabled={disabled}
draft={teamtailorDraft}
onDraftChange={setTeamtailorDraft}
helperText="Subdomain from {slug}.teamtailor.com, e.g. testgorilla."
/>
<TokenizedCompaniesField
id="huntflow-tenants"
label="Huntflow tenants"
name="huntflowTenants"
defaultValues={values.huntflowTenants.default}
disabled={disabled}
draft={huntflowDraft}
onDraftChange={setHuntflowDraft}
helperText="Subdomain from {tenant}.huntflow.io, e.g. apicworld."
/>
<TokenizedCompaniesField
id="factorial-tenants"
label="Factorial tenants"
name="factorialTenants"
defaultValues={values.factorialTenants.default}
disabled={disabled}
draft={factorialDraft}
onDraftChange={setFactorialDraft}
helperText="Subdomain from {tenant}.factorialhr.com, e.g. yourbourse."
/>
<TokenizedCompaniesField
id="icims-tenants"
label="iCIMS tenant hosts"
name="icimsTenants"
defaultValues={values.icimsTenants.default}
disabled={disabled}
draft={icimsDraft}
onDraftChange={setIcimsDraft}
helperText="Hostnames such as careers-example.icims.com."
/>
</div>
<div className="space-y-4">
<div className="text-sm font-semibold">Workday & career pages</div>
<TokenizedCompaniesField
id="workday-tenants"
label="Workday career sites"
name="workdayTenants"
defaultValues={values.workdayTenants.default}
disabled={disabled}
draft={workdayDraft}
onDraftChange={setWorkdayDraft}
helperText="Full Workday career URLs or JSON tenant objects."
/>
<TokenizedCompaniesField
id="careers-page-urls"
label="Career page URLs (auto-detect ATS)"
name="careersPageUrls"
defaultValues={values.careersPageUrls.default}
disabled={disabled}
draft={careersPagesDraft}
onDraftChange={setCareersPagesDraft}
helperText="Company career homepages, e.g. https://sentry.io/careers/ — resolves Ashby, Greenhouse, Lever, etc."
/>
<TokenizedCompaniesField
id="eluta-locations"
label="Eluta RSS locations (Canada)"
name="elutaRssLocations"
defaultValues={values.elutaRssLocations.default}
disabled={disabled}
draft={elutaDraft}
onDraftChange={setElutaDraft}
helperText="Location strings for eluta.ca RSS, e.g. Toronto, ON."
/>
</div>
</AccordionContent>
</AccordionItem>
);
};
@@ -0,0 +1,73 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Accordion } from "@/components/ui/accordion";
import { KeywordSetsSettingsSection } from "./KeywordSetsSettingsSection";
vi.mock("@client/api", () => ({
listKeywordSets: vi.fn(),
updateKeywordSet: vi.fn(),
activateKeywordSet: vi.fn(),
createKeywordSet: vi.fn(),
deleteKeywordSet: vi.fn(),
}));
vi.mock("@client/pages/orchestrator/KeywordSetPicker", () => ({
KeywordSetPicker: ({
onActiveSetChange,
}: {
onActiveSetChange: (set: {
id: string;
name: string;
terms: string[];
isActive: boolean;
}) => void;
}) => (
<button
type="button"
onClick={() =>
onActiveSetChange({
id: "set-sdet",
name: "SDET",
terms: ["SDET", "playwright"],
isActive: true,
})
}
>
Pick SDET
</button>
),
}));
import * as api from "@client/api";
describe("KeywordSetsSettingsSection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.listKeywordSets).mockResolvedValue([
{
id: "set-sdet",
name: "SDET",
terms: ["SDET", "playwright"],
isActive: true,
createdAt: "",
updatedAt: "",
},
]);
});
it("renders the section and shows terms after a set is selected", async () => {
render(
<Accordion type="multiple" defaultValue={["keyword-sets"]}>
<KeywordSetsSettingsSection isLoading={false} isSaving={false} />
</Accordion>,
);
expect(screen.getByText("Search keyword sets")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Pick SDET" }));
await waitFor(() => {
expect(screen.getByText("SDET")).toBeInTheDocument();
});
expect(screen.getByText("playwright")).toBeInTheDocument();
});
});
@@ -0,0 +1,102 @@
import * as api from "@client/api";
import { parseSearchTermsInput } from "@client/pages/orchestrator/automatic-run";
import { KeywordSetPicker } from "@client/pages/orchestrator/KeywordSetPicker";
import { TokenizedInput } from "@client/pages/orchestrator/TokenizedInput";
import type { KeywordSet } from "@shared/types";
import type React from "react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
type KeywordSetsSettingsSectionProps = {
isLoading: boolean;
isSaving: boolean;
};
export const KeywordSetsSettingsSection: React.FC<
KeywordSetsSettingsSectionProps
> = ({ isLoading, isSaving }) => {
const [activeSet, setActiveSet] = useState<KeywordSet | null>(null);
const [terms, setTerms] = useState<string[]>([]);
const [termDraft, setTermDraft] = useState("");
const [isPersisting, setIsPersisting] = useState(false);
const handleActiveSetChange = useCallback((set: KeywordSet) => {
setActiveSet(set);
setTerms(set.terms.map((t) => t.trim()).filter(Boolean));
setTermDraft("");
}, []);
const handleSave = async () => {
if (!activeSet) {
toast.error("Select a keyword set first");
return;
}
const normalized = terms.map((t) => t.trim()).filter(Boolean);
if (normalized.length === 0) {
toast.error("Add at least one search term");
return;
}
setIsPersisting(true);
try {
const updated = await api.updateKeywordSet(activeSet.id, {
terms: normalized,
});
setActiveSet(updated);
setTerms(updated.terms);
toast.success(`Saved "${updated.name}" keyword set`);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save keyword set";
toast.error(message);
} finally {
setIsPersisting(false);
}
};
const busy = isLoading || isSaving || isPersisting;
return (
<AccordionItem value="keyword-sets" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Search keyword sets</span>
</AccordionTrigger>
<AccordionContent className="pb-4 space-y-4">
<p className="text-sm text-muted-foreground">
Named term lists used when you run the automatic pipeline (e.g. SDET
vs Caseware vs lead roles). The active set is used for discovery.
</p>
<KeywordSetPicker
disabled={busy}
onActiveSetChange={handleActiveSetChange}
/>
<TokenizedInput
id="settings-keyword-set-terms"
values={terms}
draft={termDraft}
parseInput={parseSearchTermsInput}
onDraftChange={setTermDraft}
onValuesChange={setTerms}
placeholder="Type a term and press Enter"
helperText="Terms for the active set above. Save when you are done editing."
removeLabelPrefix="Remove"
disabled={busy || !activeSet}
/>
<div className="flex justify-end">
<Button
type="button"
onClick={() => void handleSave()}
disabled={busy || !activeSet}
>
{isPersisting ? "Saving…" : "Save keyword set"}
</Button>
</div>
</AccordionContent>
</AccordionItem>
);
};
@@ -244,7 +244,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({
{!selectedId && profiles.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
Create a profile to get started. "Generate from resume" will analyse
your Reactive Resume and fill in the fields automatically.
your local resume file and fill in the fields automatically.
</p>
)}
</div>
@@ -1,188 +0,0 @@
import { ReactiveResumeConfigPanel } from "@client/components/ReactiveResumeConfigPanel";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { ResumeProjectCatalogItem, RxResumeMode } from "@shared/types.js";
import type React from "react";
import {
type Path,
type PathValue,
useFormContext,
useWatch,
} from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type ReactiveResumeSectionProps = {
rxResumeBaseResumeIdDraft: string | null;
setRxResumeBaseResumeIdDraft: (value: string | null) => void;
// True when v4 credentials or v5 API key are configured.
hasRxResumeAccess: boolean;
rxresumeMode: RxResumeMode;
onRxresumeModeChange?: (mode: RxResumeMode) => void;
onCredentialFieldEdit?: (mode: RxResumeMode) => void;
validationStatuses?: {
v4: {
checked: boolean;
valid: boolean;
message?: string | null;
status?: number | null;
};
v5: {
checked: boolean;
valid: boolean;
message?: string | null;
status?: number | null;
};
};
profileProjects: ResumeProjectCatalogItem[];
lockedCount: number;
maxProjectsTotal: number;
isProjectsLoading: boolean;
isLoading: boolean;
isSaving: boolean;
};
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
rxResumeBaseResumeIdDraft,
setRxResumeBaseResumeIdDraft,
hasRxResumeAccess,
rxresumeMode,
onRxresumeModeChange,
onCredentialFieldEdit,
validationStatuses,
profileProjects,
lockedCount,
maxProjectsTotal,
isProjectsLoading,
isLoading,
isSaving,
}) => {
const {
control,
clearErrors,
setValue,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
const selectedMode =
useWatch({ control, name: "rxresumeMode" }) ?? rxresumeMode ?? "v5";
const rxresumeApiKeyValue =
useWatch({ control, name: "rxresumeApiKey" }) ?? "";
const rxresumeEmailValue = useWatch({ control, name: "rxresumeEmail" }) ?? "";
const rxresumeUrlValue = useWatch({ control, name: "rxresumeUrl" }) ?? "";
const rxresumePasswordValue =
useWatch({ control, name: "rxresumePassword" }) ?? "";
const localResumeProfilePathValue =
useWatch({ control, name: "localResumeProfilePath" }) ?? "";
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,
});
const clearRxResumeFeedback = (mode: RxResumeMode) => {
onCredentialFieldEdit?.(mode);
clearErrors(
mode === "v5"
? ["rxresumeApiKey", "rxresumeUrl"]
: ["rxresumeEmail", "rxresumePassword", "rxresumeUrl"],
);
};
return (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Reactive Resume</span>
</AccordionTrigger>
<AccordionContent className="pb-4 space-y-6">
<div className="space-y-2 rounded-md border border-dashed p-3">
<Label htmlFor="local-resume-profile-path">
Local resume JSON (no API)
</Label>
<Input
id="local-resume-profile-path"
type="text"
autoComplete="off"
placeholder="/absolute/or/relative/path/to/resume.json"
value={localResumeProfilePathValue}
disabled={isLoading || isSaving}
onChange={(e) =>
setDirtyTouchedValue("localResumeProfilePath", e.target.value)
}
/>
<p className="text-muted-foreground text-xs">
Reactive Resume export JSON on this machine. When set, it overrides
the RxResume API for scoring, cover letters, and PDF tailoring.
Relative paths use the server process working directory. You can
instead set{" "}
<span className="font-mono">JOBOPS_LOCAL_RESUME_PATH</span> in{" "}
<span className="font-mono">.env</span> (wins over this field).
</p>
</div>
<ReactiveResumeConfigPanel
mode={selectedMode}
onModeChange={(mode) => {
onRxresumeModeChange?.(mode);
setDirtyTouchedValue("rxresumeMode", mode);
}}
disabled={isLoading || isSaving}
hasRxResumeAccess={hasRxResumeAccess}
showValidationStatus={Boolean(validationStatuses)}
validationStatuses={validationStatuses}
shared={{
baseUrl: rxresumeUrlValue,
onBaseUrlChange: (value) => {
clearRxResumeFeedback(selectedMode);
setDirtyTouchedValue("rxresumeUrl", value);
},
baseUrlError: errors.rxresumeUrl?.message as string | undefined,
}}
v5={{
apiKey: rxresumeApiKeyValue,
onApiKeyChange: (value) => {
clearRxResumeFeedback("v5");
setDirtyTouchedValue("rxresumeApiKey", value);
},
error: errors.rxresumeApiKey?.message as string | undefined,
}}
v4={{
email: rxresumeEmailValue,
onEmailChange: (value) => {
clearRxResumeFeedback("v4");
setDirtyTouchedValue("rxresumeEmail", value);
},
emailError: errors.rxresumeEmail?.message as string | undefined,
password: rxresumePasswordValue,
onPasswordChange: (value) => {
clearRxResumeFeedback("v4");
setDirtyTouchedValue("rxresumePassword", value);
},
passwordError: errors.rxresumePassword?.message as
| string
| undefined,
}}
projectSelection={{
baseResumeId: rxResumeBaseResumeIdDraft,
onBaseResumeIdChange: setRxResumeBaseResumeIdDraft,
projects: profileProjects,
value: resumeProjectsValue,
onChange: (next) => setDirtyTouchedValue("resumeProjects", next),
lockedCount,
maxProjectsTotal,
isProjectsLoading,
disabled: isLoading || isSaving,
maxProjectsError:
errors.resumeProjects?.maxProjects?.message?.toString(),
}}
/>
</AccordionContent>
</AccordionItem>
);
};
@@ -0,0 +1,224 @@
import {
toggleAiSelectable,
toggleMustInclude,
} from "@client/pages/settings/resume-projects-state";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { ResumeProjectCatalogItem } from "@shared/types.js";
import type React from "react";
import {
type Path,
type PathValue,
useFormContext,
useWatch,
} from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { clampInt } from "@/lib/utils";
type ResumeSettingsSectionProps = {
profileProjects: ResumeProjectCatalogItem[];
lockedCount: number;
maxProjectsTotal: number;
localResumeFileConfigured: boolean;
isLoading: boolean;
isSaving: boolean;
};
export const ResumeSettingsSection: React.FC<ResumeSettingsSectionProps> = ({
profileProjects,
lockedCount,
maxProjectsTotal,
localResumeFileConfigured,
isLoading,
isSaving,
}) => {
const {
control,
setValue,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
const localResumeProfilePathValue =
useWatch({ control, name: "localResumeProfilePath" }) ?? "";
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,
});
const hasResumeSource =
localResumeFileConfigured || Boolean(localResumeProfilePathValue.trim());
return (
<AccordionItem value="resume" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Resume</span>
</AccordionTrigger>
<AccordionContent className="pb-4 space-y-6">
<div className="space-y-2 rounded-md border border-dashed p-3">
<Label htmlFor="local-resume-profile-path">Local resume JSON</Label>
<Input
id="local-resume-profile-path"
type="text"
autoComplete="off"
placeholder="/absolute/or/relative/path/to/resume.json"
value={localResumeProfilePathValue}
disabled={isLoading || isSaving || localResumeFileConfigured}
onChange={(e) =>
setDirtyTouchedValue("localResumeProfilePath", e.target.value)
}
/>
<p className="text-muted-foreground text-xs">
Path to a resume export JSON on the server machine. Used for
scoring, cover letters, tailoring, and PDF generation. Relative
paths resolve from the orchestrator working directory.{" "}
<span className="font-mono">JOBOPS_LOCAL_RESUME_PATH</span> in{" "}
<span className="font-mono">.env</span> overrides this field when
set.
</p>
{localResumeFileConfigured ? (
<p className="text-xs text-emerald-600 dark:text-emerald-400">
A local resume file is configured via environment variable.
</p>
) : null}
</div>
{!hasResumeSource ? (
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Set a local resume JSON path to configure project selection and run
tailoring.
</div>
) : profileProjects.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Save a valid resume path, then reload settings to see projects from
your resume file.
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max projects to choose</div>
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={resumeProjectsValue?.maxProjects ?? 0}
onChange={(event) => {
if (!resumeProjectsValue) return;
const next = Number(event.target.value);
const clamped = clampInt(next, lockedCount, maxProjectsTotal);
setDirtyTouchedValue("resumeProjects", {
...resumeProjectsValue,
maxProjects: clamped,
});
}}
disabled={isLoading || isSaving || !resumeProjectsValue}
/>
{errors.resumeProjects?.maxProjects?.message ? (
<p className="text-xs text-destructive">
{errors.resumeProjects.maxProjects.message.toString()}
</p>
) : null}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">Project</TableHead>
<TableHead className="text-xs">In resume</TableHead>
<TableHead className="text-xs">Must include</TableHead>
<TableHead className="text-xs">AI selectable</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const value = resumeProjectsValue;
const locked = Boolean(
value?.lockedProjectIds.includes(project.id),
);
const aiSelectable = Boolean(
value?.aiSelectableProjectIds.includes(project.id),
);
const projectMeta = [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;
setDirtyTouchedValue(
"resumeProjects",
toggleMustInclude({
settings: value,
projectId: project.id,
checked: !locked,
maxProjectsTotal,
}),
);
}}
disabled={isLoading || isSaving || !value}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
onCheckedChange={() => {
if (!value) return;
setDirtyTouchedValue(
"resumeProjects",
toggleAiSelectable({
settings: value,
projectId: project.id,
checked: !aiSelectable,
}),
);
}}
disabled={isLoading || isSaving || locked || !value}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</AccordionContent>
</AccordionItem>
);
};
@@ -235,7 +235,7 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
setValue("blockedCompanyKeywords", value, { shouldDirty: true })
}
placeholder='e.g. "recruitment", "staffing"'
helperText="Maintained here and saved with Settings. Each token matches employer or job title (hyphens ignored, so co-op matches coop). Matching jobs are dropped during discovery. See docs: /docs/features/company-skip-list"
helperText="Maintained here and saved with Settings. Each token matches employer or job title as a whole word (co-op, co op, and coop are equivalent). Matching jobs are dropped during discovery. See docs: /docs/features/company-skip-list"
removeLabelPrefix="Remove blocked keyword"
disabled={isLoading || isSaving}
/>
@@ -33,13 +33,11 @@ export type ChatValues = {
export type EnvSettingsValues = {
readable: {
rxresumeEmail: string;
ukvisajobsEmail: string;
adzunaAppId: string;
basicAuthUser: string;
};
private: {
rxresumePasswordHint: string | null;
ukvisajobsPasswordHint: string | null;
adzunaAppKeyHint: string | null;
basicAuthPasswordHint: string | null;
@@ -62,3 +60,17 @@ export type ScoringValues = {
blockedCountries: EffectiveDefault<string[]>;
scoringInstructions: EffectiveDefault<string>;
};
export type JobSourcesValues = {
ashbyCompanies: EffectiveDefault<string[]>;
greenhouseCompanies: EffectiveDefault<string[]>;
leverCompanies: EffectiveDefault<string[]>;
smartrecruitersCompanies: EffectiveDefault<string[]>;
teamtailorCompanies: EffectiveDefault<string[]>;
huntflowTenants: EffectiveDefault<string[]>;
factorialTenants: EffectiveDefault<string[]>;
workdayTenants: EffectiveDefault<string[]>;
careersPageUrls: EffectiveDefault<string[]>;
icimsTenants: EffectiveDefault<string[]>;
elutaRssLocations: EffectiveDefault<string[]>;
};
+2
View File
@@ -9,6 +9,7 @@ import { databaseRouter } from "./routes/database";
import { demoRouter } from "./routes/demo";
import { ghostwriterRouter } from "./routes/ghostwriter";
import { jobsRouter } from "./routes/jobs";
import { keywordSetsRouter } from "./routes/keyword-sets";
import { manualJobsRouter } from "./routes/manual-jobs";
import { onboardingRouter } from "./routes/onboarding";
import { pipelineRouter } from "./routes/pipeline";
@@ -35,6 +36,7 @@ apiRouter.use("/manual-jobs", manualJobsRouter);
apiRouter.use("/webhook", webhookRouter);
apiRouter.use("/profile", profileRouter);
apiRouter.use("/profiles", profilesRouter);
apiRouter.use("/keyword-sets", keywordSetsRouter);
apiRouter.use("/database", databaseRouter);
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
apiRouter.use("/onboarding", onboardingRouter);
+26 -1
View File
@@ -37,6 +37,10 @@ import { scoreJobSuitability } from "@server/services/scorer";
import { getTracerReadiness } from "@server/services/tracer-links";
import * as visaSponsors from "@server/services/visa-sponsors/index";
import { asyncPool } from "@server/utils/async-pool";
import {
detectSponsorshipSignals,
serializeSponsorshipSignals,
} from "@shared/sponsorship-signals";
import {
APPLICATION_OUTCOMES,
APPLICATION_STAGES,
@@ -1175,7 +1179,7 @@ jobsRouter.patch("/:id", async (req: Request, res: Response) => {
}
}
const job = await jobsRepo.updateJob(
let job = await jobsRepo.updateJob(
req.params.id,
input,
currentJob.ownerProfileId,
@@ -1196,6 +1200,27 @@ jobsRouter.patch("/:id", async (req: Request, res: Response) => {
return fail(res, err);
}
const shouldRefreshSponsorshipSignals =
input.jobDescription !== undefined || input.location !== undefined;
if (shouldRefreshSponsorshipSignals) {
const sponsorshipSignals = serializeSponsorshipSignals(
detectSponsorshipSignals({
jobDescription: job.jobDescription,
location: job.location,
companyAddresses: job.companyAddresses,
companyDescription: job.companyDescription,
}),
);
if (sponsorshipSignals !== job.sponsorshipSignals) {
job =
(await jobsRepo.updateJob(
req.params.id,
{ sponsorshipSignals },
currentJob.ownerProfileId,
)) ?? job;
}
}
logger.info("Job updated", {
route: "PATCH /api/jobs/:id",
jobId: req.params.id,
@@ -0,0 +1,127 @@
import { badRequest } from "@infra/errors";
import { asyncRoute, fail, ok } from "@infra/http";
import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import { getJobOwnerProfileId } from "@server/infra/request-context";
import * as keywordSetsRepo from "@server/repositories/keyword-sets";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
export const keywordSetsRouter = Router();
const termsSchema = z
.array(z.string().trim().min(1).max(200))
.max(100)
.optional();
const createSchema = z.object({
name: z.string().trim().min(1).max(100),
terms: termsSchema,
});
const updateSchema = z.object({
name: z.string().trim().min(1).max(100).optional(),
terms: termsSchema,
});
function resolveOwnerProfileId(): string {
const fromContext = getJobOwnerProfileId();
if (fromContext && fromContext !== "__unmapped__") {
return fromContext;
}
return DEFAULT_JOB_OWNER_PROFILE_ID;
}
function handleRepoError(res: Response, error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (/maximum of/i.test(message) || /cannot delete/i.test(message)) {
return fail(res, badRequest(message));
}
throw error;
}
keywordSetsRouter.get(
"/",
asyncRoute(async (_req: Request, res: Response) => {
const ownerProfileId = resolveOwnerProfileId();
const sets = await keywordSetsRepo.listKeywordSets(ownerProfileId);
return ok(res, sets);
}),
);
keywordSetsRouter.post(
"/",
asyncRoute(async (req: Request, res: Response) => {
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) {
throw badRequest("Invalid keyword set payload", {
issues: parsed.error.issues,
});
}
const ownerProfileId = resolveOwnerProfileId();
try {
const created = await keywordSetsRepo.createKeywordSet(
ownerProfileId,
parsed.data,
);
return ok(res, created);
} catch (error) {
return handleRepoError(res, error);
}
}),
);
keywordSetsRouter.patch(
"/:id",
asyncRoute(async (req: Request, res: Response) => {
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) {
throw badRequest("Invalid keyword set payload", {
issues: parsed.error.issues,
});
}
const ownerProfileId = resolveOwnerProfileId();
const updated = await keywordSetsRepo.updateKeywordSet(
req.params.id,
ownerProfileId,
parsed.data,
);
if (!updated) {
return fail(res, badRequest("Keyword set not found"));
}
return ok(res, updated);
}),
);
keywordSetsRouter.delete(
"/:id",
asyncRoute(async (req: Request, res: Response) => {
const ownerProfileId = resolveOwnerProfileId();
try {
const deleted = await keywordSetsRepo.deleteKeywordSet(
req.params.id,
ownerProfileId,
);
if (!deleted) {
return fail(res, badRequest("Keyword set not found"));
}
return ok(res, { deleted: true });
} catch (error) {
return handleRepoError(res, error);
}
}),
);
keywordSetsRouter.post(
"/:id/activate",
asyncRoute(async (req: Request, res: Response) => {
const ownerProfileId = resolveOwnerProfileId();
const activated = await keywordSetsRepo.activateKeywordSet(
req.params.id,
ownerProfileId,
);
if (!activated) {
return fail(res, badRequest("Keyword set not found"));
}
return ok(res, activated);
}),
);
@@ -2,18 +2,6 @@ import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils";
// Mock the RxResume adapter service
vi.mock("@server/services/rxresume", () => ({
clearRxResumeResumeCache: vi.fn(),
getResume: vi.fn(),
RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error {
constructor() {
super("Reactive Resume credentials not configured.");
this.name = "RxResumeAuthConfigError";
}
},
}));
// Mock the profile service
vi.mock("@server/services/profile", () => ({
getProfile: vi.fn(),
@@ -21,21 +9,10 @@ vi.mock("@server/services/profile", () => ({
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
}));
// Mock the settings repository
vi.mock("@server/repositories/settings", async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>;
return {
...original,
getSetting: vi.fn(),
};
});
import { getSetting } from "@server/repositories/settings";
import {
getProfile,
resolveLocalResumeFilePath,
} from "@server/services/profile";
import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume";
describe.sequential("Profile API routes", () => {
let server: Server;
@@ -181,8 +158,8 @@ describe.sequential("Profile API routes", () => {
expect(body.data.error).toBeNull();
});
it("returns exists: false when rxresumeBaseResumeId is not configured", async () => {
vi.mocked(getSetting).mockResolvedValue(null);
it("returns exists: false when no local resume path is configured", async () => {
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(null);
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
@@ -190,29 +167,15 @@ describe.sequential("Profile API routes", () => {
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.exists).toBe(false);
expect(body.data.error).toContain("No base resume selected");
expect(body.data.error).toContain("No resume file configured");
});
it("returns exists: true when resume is accessible", async () => {
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
vi.mocked(getResume).mockResolvedValue({
id: "test-resume-id",
data: { basics: { name: "Test" } },
} as any);
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.exists).toBe(true);
expect(body.data.error).toBeNull();
});
it("returns exists: false when RxResume credentials are missing", async () => {
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
vi.mocked(getResume).mockRejectedValue(
new (RxResumeAuthConfigError as unknown as new () => Error)(),
it("returns exists: false when the local resume file cannot be loaded", async () => {
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(
"/data/resumes/missing.json",
);
vi.mocked(getProfile).mockRejectedValue(
new Error("Cannot read local resume file"),
);
const res = await fetch(`${baseUrl}/api/profile/status`);
@@ -221,23 +184,7 @@ describe.sequential("Profile API routes", () => {
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.exists).toBe(false);
expect(body.data.error).toContain("credentials not configured");
});
it("returns exists: false when resume data is empty", async () => {
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
vi.mocked(getResume).mockResolvedValue({
id: "test-resume-id",
data: null,
} as any);
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.exists).toBe(false);
expect(body.data.error).toContain("empty or invalid");
expect(body.data.error).toContain("Cannot read local resume file");
});
});
+6 -45
View File
@@ -8,12 +8,6 @@ import {
resolveLocalResumeFilePath,
} from "@server/services/profile";
import { extractProjectsFromProfile } from "@server/services/resumeProjects";
import {
clearRxResumeResumeCache,
getResume,
RxResumeAuthConfigError,
} from "@server/services/rxresume";
import { getConfiguredRxResumeBaseResumeId } from "@server/services/rxresume/baseResumeId";
import { type Request, type Response, Router } from "express";
export const profileRouter = Router();
@@ -53,53 +47,21 @@ profileRouter.get("/", async (_req: Request, res: Response) => {
profileRouter.get("/status", async (_req: Request, res: Response) => {
try {
const localPath = await resolveLocalResumeFilePath();
if (localPath) {
try {
await getProfile();
ok(res, { exists: true, error: null });
return;
} catch (error) {
if (error instanceof RxResumeAuthConfigError) {
ok(res, { exists: false, error: error.message });
return;
}
const message =
error instanceof Error ? error.message : "Unknown error";
ok(res, { exists: false, error: message });
return;
}
}
const { resumeId: rxresumeBaseResumeId } =
await getConfiguredRxResumeBaseResumeId();
if (!rxresumeBaseResumeId) {
if (!localPath) {
ok(res, {
exists: false,
error:
"No base resume selected. Please select a resume from your Reactive Resume account in Settings.",
"No resume file configured. Set JOBOPS_LOCAL_RESUME_PATH or a local resume path in Settings.",
});
return;
}
// Verify the resume is accessible
try {
const resume = await getResume(rxresumeBaseResumeId);
if (!resume.data || typeof resume.data !== "object") {
ok(res, {
exists: false,
error: "Selected resume is empty or invalid.",
});
return;
}
await getProfile();
ok(res, { exists: true, error: null });
} catch (error) {
if (error instanceof RxResumeAuthConfigError) {
ok(res, { exists: false, error: error.message });
return;
}
throw error;
const message = error instanceof Error ? error.message : "Unknown error";
ok(res, { exists: false, error: message });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
@@ -108,12 +70,11 @@ profileRouter.get("/status", async (_req: Request, res: Response) => {
});
/**
* POST /api/profile/refresh - Clear profile cache and refetch from Reactive Resume
* POST /api/profile/refresh - Clear profile cache and refetch from disk
*/
profileRouter.post("/refresh", async (_req: Request, res: Response) => {
try {
clearProfileCache();
clearRxResumeResumeCache();
const profile = await getProfile(true);
ok(res, profile);
} catch (error) {
+7 -12
View File
@@ -15,16 +15,6 @@ import { type Request, type Response, Router } from "express";
export const profilesRouter = Router();
function profileMatchesBasicAuthUser(
profile: SearchProfile,
username: string | null,
): boolean {
if (!username?.trim()) return false;
const u = username.trim().toLowerCase();
const bu = (profile.data.basicAuthUser ?? "").trim().toLowerCase();
return Boolean(bu) && bu === u;
}
function assertProfileVisibleToRequest(
req: Request,
profile: SearchProfile,
@@ -32,7 +22,12 @@ function assertProfileVisibleToRequest(
if (!isBasicAuthEnabled()) return true;
const username = parseBasicAuthUsername(req.headers.authorization);
if (!username?.trim()) return "unauthorized";
return profileMatchesBasicAuthUser(profile, username) ? true : "forbidden";
return profilesRepo.profileMatchesBasicAuthUser(
profile.data.basicAuthUser,
username,
)
? true
: "forbidden";
}
profilesRouter.get(
@@ -206,7 +201,7 @@ profilesRouter.post(
Array.isArray(resumeProfile)
) {
throw badRequest(
"No resume profile available. Configure Reactive Resume in Settings first.",
"No resume profile available. Configure a local resume JSON path in Settings first.",
);
}
const generated = await generateProfileFromResume(
@@ -267,17 +267,25 @@ export const DEMO_SOURCE_BASE_URLS: Record<JobSource, string> = {
arbeitnow: "https://www.arbeitnow.com",
himalayas: "https://himalayas.app",
weworkremotely: "https://weworkremotely.com",
workingnomads: "https://www.workingnomads.com",
testdevjobs: "https://testdevjobs.com",
wellfound: "https://wellfound.com",
builtin: "https://builtin.com",
fourdayweek: "https://4dayweek.io",
ashby: "https://jobs.ashbyhq.com",
lever: "https://jobs.lever.co",
greenhouse: "https://boards.greenhouse.io",
workday: "https://workday.com",
smartrecruiters: "https://www.smartrecruiters.com",
teamtailor: "https://teamtailor.com",
huntflow: "https://huntflow.io",
factorial: "https://factorialhr.com",
icims: "https://www.icims.com",
bctenet: "https://www.bctechnology.com",
eluta: "https://www.eluta.ca",
qajobsboard: "https://www.qajobsboard.com",
arcdev: "https://arc.dev",
careerspages: "https://example.com",
manual: "https://example.com",
};
+116 -1
View File
@@ -788,6 +788,30 @@ const migrations = [
`ALTER TABLE jobs ADD COLUMN content_fingerprint TEXT`,
`CREATE INDEX IF NOT EXISTS idx_jobs_owner_profile_content_fingerprint ON jobs(owner_profile_id, content_fingerprint)`,
`CREATE TABLE IF NOT EXISTS keyword_sets (
id TEXT PRIMARY KEY,
owner_profile_id TEXT NOT NULL DEFAULT '__default__',
name TEXT NOT NULL,
terms TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(owner_profile_id, name)
)`,
`CREATE INDEX IF NOT EXISTS idx_keyword_sets_owner_active ON keyword_sets(owner_profile_id, is_active)`,
`INSERT INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
SELECT
'default-keyword-set',
'__default__',
'Default',
COALESCE((SELECT value FROM settings WHERE key = 'searchTerms'), '["web developer"]'),
1,
datetime('now'),
datetime('now')
WHERE NOT EXISTS (
SELECT 1 FROM keyword_sets WHERE owner_profile_id = '__default__'
)`,
// Seed default job-search personas (INSERT OR IGNORE — safe on existing DBs).
sqlInsertSearchProfileSeed({
id: "685b0000-0000-4000-8000-000000000001",
@@ -815,10 +839,91 @@ const migrations = [
industriesToAvoid: [],
aboutMe:
"Ilia Dobkin — SDET / test automation; paired login user `ilia` activates this profile and local resume ilia-dobkin.json (unset JOBOPS_LOCAL_RESUME_PATH so Settings path applies).",
basicAuthUser: "ilia",
basicAuthUser: "ilia,dobkin",
resumeLocalPath: "../data/resumes/ilia-dobkin.json",
},
}),
`UPDATE keyword_sets
SET terms = (
SELECT json_extract(sp.data, '$.targetRoles')
FROM search_profiles sp
WHERE sp.id = keyword_sets.owner_profile_id
),
updated_at = datetime('now')
WHERE (terms = '[]' OR terms = '')
AND owner_profile_id != '__default__'
AND EXISTS (
SELECT 1 FROM search_profiles sp
WHERE sp.id = keyword_sets.owner_profile_id
AND json_array_length(json_extract(sp.data, '$.targetRoles')) > 0
)`,
`UPDATE search_profiles
SET data = json_set(data, '$.basicAuthUser', 'ilia,dobkin'),
updated_at = datetime('now')
WHERE id = '685b0000-0000-4000-8000-000000000001'
AND json_extract(data, '$.basicAuthUser') = 'ilia'`,
`INSERT INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
SELECT
'ilia-dobkin-keyword-set',
'685b0000-0000-4000-8000-000000000001',
'SDET',
'["SDET","QA automation engineer","automation test engineer","playwright","software development engineer in test","quality assurance automation engineer"]',
1,
datetime('now'),
datetime('now')
WHERE NOT EXISTS (
SELECT 1 FROM keyword_sets
WHERE owner_profile_id = '685b0000-0000-4000-8000-000000000001'
AND name = 'SDET'
)`,
`DELETE FROM keyword_sets
WHERE owner_profile_id = '685b0000-0000-4000-8000-000000000001'
AND name = 'Default'
AND EXISTS (
SELECT 1 FROM keyword_sets ks
WHERE ks.owner_profile_id = '685b0000-0000-4000-8000-000000000001'
AND ks.name = 'SDET'
)`,
`UPDATE keyword_sets
SET is_active = 1, updated_at = datetime('now')
WHERE owner_profile_id = '685b0000-0000-4000-8000-000000000001'
AND name = 'SDET'`,
`UPDATE keyword_sets
SET is_active = 0, updated_at = datetime('now')
WHERE owner_profile_id = '685b0000-0000-4000-8000-000000000001'
AND name != 'SDET'
AND is_active = 1
AND EXISTS (
SELECT 1 FROM keyword_sets ks
WHERE ks.owner_profile_id = '685b0000-0000-4000-8000-000000000001'
AND ks.name = 'SDET'
AND ks.is_active = 1
)`,
`INSERT OR IGNORE INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
VALUES (
'ilia-dobkin-caseware',
'685b0000-0000-4000-8000-000000000001',
'Caseware',
'["Caseware","Caseware template developer","Caseware template specialist","Caseware engagement template","Caseware working papers","Caseware Assurance","Caseware consultant","CaseWare"]',
0,
datetime('now'),
datetime('now')
)`,
`UPDATE keyword_sets
SET terms = '["Caseware","Caseware template developer","Caseware template specialist","Caseware engagement template","Caseware working papers","Caseware Assurance","Caseware consultant","CaseWare"]',
updated_at = datetime('now')
WHERE id = 'ilia-dobkin-caseware'`,
`INSERT OR IGNORE INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
VALUES (
'ilia-dobkin-lead',
'685b0000-0000-4000-8000-000000000001',
'Lead',
'["lead SDET","lead QA","test lead","QA manager","engineering manager test"]',
0,
datetime('now'),
datetime('now')
)`,
`ALTER TABLE jobs ADD COLUMN sponsorship_signals TEXT`,
sqlInsertSearchProfileSeed({
id: "685b0000-0000-4000-8000-000000000002",
name: "Iliya Cherepaha",
@@ -861,6 +966,16 @@ const migrations = [
resumeLocalPath: "../data/resumes/cherepaha.json",
},
}),
`UPDATE search_profiles
SET data = json_set(data, '$.resumeLocalPath', '../data/resumes/ilia-dobkin.json'),
updated_at = datetime('now')
WHERE id = '685b0000-0000-4000-8000-000000000001'
AND coalesce(json_extract(data, '$.resumeLocalPath'), '') = ''`,
`UPDATE search_profiles
SET data = json_set(data, '$.resumeLocalPath', '../data/resumes/cherepaha.json'),
updated_at = datetime('now')
WHERE id = '685b0000-0000-4000-8000-000000000002'
AND coalesce(json_extract(data, '$.resumeLocalPath'), '') = ''`,
];
console.log("🔧 Running database migrations...");
+28
View File
@@ -116,6 +116,7 @@ export const jobs = sqliteTable(
coverLetter: text("cover_letter"),
sponsorMatchScore: real("sponsor_match_score"),
sponsorMatchNames: text("sponsor_match_names"),
sponsorshipSignals: text("sponsorship_signals"),
notes: text("notes"),
// Timestamps
@@ -283,6 +284,31 @@ export const searchProfiles = sqliteTable("search_profiles", {
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
});
export const keywordSets = sqliteTable(
"keyword_sets",
{
id: text("id").primaryKey(),
ownerProfileId: text("owner_profile_id").notNull().default("__default__"),
name: text("name").notNull(),
terms: text("terms").notNull(),
isActive: integer("is_active", { mode: "boolean" })
.notNull()
.default(false),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
},
(table) => ({
ownerNameUnique: uniqueIndex("idx_keyword_sets_owner_name").on(
table.ownerProfileId,
table.name,
),
ownerActiveIndex: index("idx_keyword_sets_owner_active").on(
table.ownerProfileId,
table.isActive,
),
}),
);
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
@@ -490,6 +516,8 @@ export type JobChatRunRow = typeof jobChatRuns.$inferSelect;
export type NewJobChatRunRow = typeof jobChatRuns.$inferInsert;
export type SearchProfileRow = typeof searchProfiles.$inferSelect;
export type NewSearchProfileRow = typeof searchProfiles.$inferInsert;
export type KeywordSetRow = typeof keywordSets.$inferSelect;
export type NewKeywordSetRow = typeof keywordSets.$inferInsert;
export type SettingsRow = typeof settings.$inferSelect;
export type NewSettingsRow = typeof settings.$inferInsert;
export type PostApplicationIntegrationRow =
@@ -15,6 +15,10 @@ vi.mock("@server/repositories/profiles", () => ({
getProfileById: vi.fn().mockResolvedValue(null),
}));
vi.mock("@server/repositories/keyword-sets", () => ({
getActiveKeywordSetTerms: vi.fn().mockResolvedValue(null),
}));
vi.mock("@server/extractors/registry", () => ({
getExtractorRegistry: vi.fn(),
}));
@@ -148,6 +152,67 @@ describe("discoverJobsStep", () => {
);
});
it("does not throw when a source fails but discovered jobs are filtered to zero", async () => {
const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("@server/extractors/registry");
const jobspyManifest = {
id: "jobspy",
displayName: "JobSpy",
providesSources: ["linkedin"],
run: vi.fn().mockResolvedValue({
success: true,
jobs: [
{
source: "linkedin",
title: "Unrelated sales role",
employer: "ACME",
jobUrl: "https://example.com/job",
jobDescription: "B2B sales",
},
],
}),
};
const hiringCafeManifest = {
id: "hiringcafe",
displayName: "Hiring Cafe",
providesSources: ["hiringcafe"],
run: vi.fn().mockResolvedValue({
success: false,
jobs: [],
error: "Unauthorized",
}),
};
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["caseware"]),
} as any);
vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue({
manifests: new Map([
["jobspy", jobspyManifest as any],
["hiringcafe", hiringCafeManifest as any],
]),
manifestBySource: new Map([
["linkedin", jobspyManifest as any],
["hiringcafe", hiringCafeManifest as any],
]),
availableSources: ["linkedin", "hiringcafe"],
} as any);
const result = await discoverJobsStep({
mergedConfig: {
...baseConfig,
sources: ["linkedin", "hiringcafe"],
},
});
expect(result.discoveredJobs).toHaveLength(0);
expect(result.sourceErrors).toEqual([
"Hiring Cafe: Unauthorized (sources: hiringcafe)",
]);
});
it("throws when all enabled sources fail", async () => {
const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("@server/extractors/registry");
@@ -441,7 +506,9 @@ describe("discoverJobsStep", () => {
});
expect(result.discoveredJobs).toHaveLength(1);
expect(result.discoveredJobs[0]?.jobUrl).toBe("https://example.com/job-sdet");
expect(result.discoveredJobs[0]?.jobUrl).toBe(
"https://example.com/job-sdet",
);
});
it("drops jobs with blocked country in description when location is worldwide", async () => {
@@ -535,7 +602,7 @@ describe("discoverJobsStep", () => {
jobs: [
{
source: "ukvisajobs",
title: "Developer - Leeds",
title: "Engineer - Leeds",
employer: "Contoso",
location: "Leeds, England, UK",
jobUrl: "https://example.com/ukv-1",
@@ -651,4 +718,258 @@ describe("discoverJobsStep", () => {
"https://example.com/existing",
]);
});
it("keyword set filtering rejects loose matches like engagement without caseware", async () => {
const settingsRepo = await import("@server/repositories/settings");
const keywordSetsRepo = await import("@server/repositories/keyword-sets");
const registryModule = await import("@server/extractors/registry");
const jobspyManifest = {
id: "jobspy",
displayName: "JobSpy",
providesSources: ["linkedin"],
run: vi.fn().mockResolvedValue({
success: true,
jobs: [
{
source: "linkedin",
title: "Machine Learning Engineer",
employer: "ACME",
jobUrl: "https://example.com/ml",
jobDescription:
"stakeholder engagement and cross-functional collaboration",
},
{
source: "linkedin",
title: "Consultant Glaucoma",
employer: "NHS",
jobUrl: "https://example.com/consultant",
},
],
}),
};
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["ignored"]),
} as any);
vi.mocked(keywordSetsRepo.getActiveKeywordSetTerms).mockResolvedValue([
"Caseware",
"Caseware engagement template",
"Caseware consultant",
]);
vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue({
manifests: new Map([["jobspy", jobspyManifest as any]]),
manifestBySource: new Map([["linkedin", jobspyManifest as any]]),
availableSources: ["linkedin"],
} as any);
const result = await discoverJobsStep({
mergedConfig: { ...baseConfig, sources: ["linkedin"] },
});
expect(result.discoveredJobs).toHaveLength(0);
});
it("keyword set filtering keeps caseware jobs and drops sdet jobs", async () => {
const settingsRepo = await import("@server/repositories/settings");
const keywordSetsRepo = await import("@server/repositories/keyword-sets");
const registryModule = await import("@server/extractors/registry");
const jobspyManifest = {
id: "jobspy",
displayName: "JobSpy",
providesSources: ["linkedin"],
run: vi.fn().mockResolvedValue({
success: true,
jobs: [
{
source: "linkedin",
title: "SDET",
employer: "ACME",
jobUrl: "https://example.com/sdet",
jobDescription: "Playwright automation",
},
{
source: "linkedin",
title: "Caseware Template Developer",
employer: "Firm",
jobUrl: "https://example.com/caseware",
},
],
}),
};
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["ignored"]),
} as any);
vi.mocked(keywordSetsRepo.getActiveKeywordSetTerms).mockResolvedValue([
"Caseware",
"Caseware template developer",
]);
vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue({
manifests: new Map([["jobspy", jobspyManifest as any]]),
manifestBySource: new Map([["linkedin", jobspyManifest as any]]),
availableSources: ["linkedin"],
} as any);
const result = await discoverJobsStep({
mergedConfig: { ...baseConfig, sources: ["linkedin"] },
});
expect(result.discoveredJobs).toHaveLength(1);
expect(result.discoveredJobs[0]?.title).toBe("Caseware Template Developer");
});
it("keyword set filtering keeps sdet jobs and drops caseware-only jobs", async () => {
const settingsRepo = await import("@server/repositories/settings");
const keywordSetsRepo = await import("@server/repositories/keyword-sets");
const registryModule = await import("@server/extractors/registry");
const jobspyManifest = {
id: "jobspy",
displayName: "JobSpy",
providesSources: ["linkedin"],
run: vi.fn().mockResolvedValue({
success: true,
jobs: [
{
source: "linkedin",
title: "Caseware Implementation Consultant",
employer: "Firm",
jobUrl: "https://example.com/caseware",
},
{
source: "linkedin",
title: "SDET",
employer: "ACME",
jobUrl: "https://example.com/sdet",
},
],
}),
};
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["ignored"]),
} as any);
vi.mocked(keywordSetsRepo.getActiveKeywordSetTerms).mockResolvedValue([
"SDET",
"playwright",
]);
vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue({
manifests: new Map([["jobspy", jobspyManifest as any]]),
manifestBySource: new Map([["linkedin", jobspyManifest as any]]),
availableSources: ["linkedin"],
} as any);
const result = await discoverJobsStep({
mergedConfig: { ...baseConfig, sources: ["linkedin"] },
});
expect(result.discoveredJobs).toHaveLength(1);
expect(result.discoveredJobs[0]?.title).toBe("SDET");
});
it("skips profile must-have skills and deal breakers when an active keyword set is used", async () => {
const settingsRepo = await import("@server/repositories/settings");
const profilesRepo = await import("@server/repositories/profiles");
const keywordSetsRepo = await import("@server/repositories/keyword-sets");
const registryModule = await import("@server/extractors/registry");
const jobspyManifest = {
id: "jobspy",
displayName: "JobSpy",
providesSources: ["linkedin"],
run: vi.fn().mockResolvedValue({
success: true,
jobs: [
{
source: "linkedin",
title: "Caseware Template Developer",
employer: "ACME",
jobUrl: "https://example.com/caseware",
jobDescription: "hybrid working and flexible schedule",
},
],
}),
};
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["ignored"]),
} as any);
vi.mocked(keywordSetsRepo.getActiveKeywordSetTerms).mockResolvedValue([
"caseware",
]);
vi.mocked(profilesRepo.getProfileById).mockResolvedValue({
data: {
targetRoles: ["SDET"],
mustHaveSkills: ["Playwright"],
dealBreakers: ["hybrid"],
},
} as any);
vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue({
manifests: new Map([["jobspy", jobspyManifest as any]]),
manifestBySource: new Map([["linkedin", jobspyManifest as any]]),
availableSources: ["linkedin"],
} as any);
const result = await discoverJobsStep({
mergedConfig: {
...baseConfig,
ownerProfileId: "685b0000-0000-4000-8000-000000000001",
sources: ["linkedin"],
},
});
expect(result.discoveredJobs).toHaveLength(1);
expect(result.discoveredJobs[0]?.title).toBe("Caseware Template Developer");
});
it("uses the active keyword set terms instead of global settings", async () => {
const settingsRepo = await import("@server/repositories/settings");
const keywordSetsRepo = await import("@server/repositories/keyword-sets");
const registryModule = await import("@server/extractors/registry");
const jobspyManifest = {
id: "jobspy",
displayName: "JobSpy",
providesSources: ["linkedin"],
run: vi.fn().mockResolvedValue({
success: true,
jobs: [
{
source: "linkedin",
title: "Engineer",
employer: "ACME",
jobUrl: "https://example.com/job",
},
],
}),
};
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["ignored-global-term"]),
} as any);
vi.mocked(keywordSetsRepo.getActiveKeywordSetTerms).mockResolvedValue([
"sdet",
"playwright",
]);
vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue({
manifests: new Map([["jobspy", jobspyManifest as any]]),
manifestBySource: new Map([["linkedin", jobspyManifest as any]]),
availableSources: ["linkedin"],
} as any);
await discoverJobsStep({ mergedConfig: baseConfig });
expect(jobspyManifest.run).toHaveBeenCalledWith(
expect.objectContaining({
searchTerms: ["sdet", "playwright"],
}),
);
});
});
@@ -3,6 +3,7 @@ import { sanitizeUnknown } from "@infra/sanitize";
import { getExtractorRegistry } from "@server/extractors/registry";
import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import { getAllJobUrls } from "@server/repositories/jobs";
import * as keywordSetsRepo from "@server/repositories/keyword-sets";
import { getProfileById } from "@server/repositories/profiles";
import * as settingsRepo from "@server/repositories/settings";
import { asyncPool } from "@server/utils/async-pool";
@@ -117,6 +118,20 @@ function normalizeText(value: string | null | undefined): string {
return (value ?? "").toLowerCase().replace(/\s+/g, " ").trim();
}
const QA_DOMAIN_HINTS = [
"qa",
"sdet",
"test",
"testing",
"automation",
"playwright",
] as const;
function phrasesLookLikeQaDomain(phrases: string[]): boolean {
const blob = phrases.map((p) => normalizeText(p)).join(" ");
return QA_DOMAIN_HINTS.some((hint) => blob.includes(hint));
}
function buildRoleMatchers(phrases: string[]): {
phraseMatchers: string[];
tokenMatchers: string[];
@@ -134,14 +149,46 @@ function buildRoleMatchers(phrases: string[]): {
}
}
// Ensure common QA acronyms remain even if user only typed long-form roles.
for (const token of ["qa", "sdet", "test", "testing", "automation"]) {
tokenSet.add(token);
// Only augment with QA tokens when the search is QA/SDET-oriented (not Caseware etc.).
if (phrasesLookLikeQaDomain(phraseMatchers)) {
for (const token of QA_DOMAIN_HINTS) {
tokenSet.add(token);
}
}
return { phraseMatchers, tokenMatchers: [...tokenSet] };
}
/** Strict filter for active keyword sets: full phrases or whole terms only (no token splitting). */
function filterJobsByKeywordSetTerms(args: {
jobs: CreateJobInput[];
terms: string[];
}): { jobs: CreateJobInput[]; dropped: number } {
const normalizedTerms = [
...new Set(args.terms.map((term) => normalizeText(term)).filter(Boolean)),
];
if (normalizedTerms.length === 0) {
return { jobs: args.jobs, dropped: 0 };
}
const phraseTerms = normalizedTerms.filter((term) => term.includes(" "));
const wordTerms = normalizedTerms.filter((term) => !term.includes(" "));
const filtered = args.jobs.filter((job) => {
const title = normalizeText(job.title);
const body = normalizeText(job.jobDescription);
const haystack = `${title}\n${body}`;
if (phraseTerms.some((phrase) => haystack.includes(phrase))) {
return true;
}
return wordTerms.some((word) => haystack.includes(word));
});
return { jobs: filtered, dropped: args.jobs.length - filtered.length };
}
function matchesAny(text: string, needles: string[]): boolean {
if (!text) return false;
for (const needle of needles) {
@@ -209,22 +256,30 @@ export async function discoverJobsStep(args: {
const settings = await settingsRepo.getAllSettings();
const registry = await getExtractorRegistry();
const searchTermsSetting = settings.searchTerms;
let searchTerms: string[] = [];
if (searchTermsSetting) {
searchTerms = JSON.parse(searchTermsSetting) as string[];
} else {
const defaultSearchTermsEnv =
process.env.JOBSPY_SEARCH_TERMS || "web developer";
searchTerms = defaultSearchTermsEnv
.split("|")
.map((term) => term.trim())
.filter(Boolean);
}
const ownerProfileId =
args.mergedConfig.ownerProfileId ?? DEFAULT_JOB_OWNER_PROFILE_ID;
let searchTerms: string[] = [];
const activeKeywordTerms =
await keywordSetsRepo.getActiveKeywordSetTerms(ownerProfileId);
const usedActiveKeywordSet = Boolean(
activeKeywordTerms && activeKeywordTerms.length > 0,
);
if (activeKeywordTerms && activeKeywordTerms.length > 0) {
searchTerms = [...activeKeywordTerms];
} else {
const searchTermsSetting = settings.searchTerms;
if (searchTermsSetting) {
searchTerms = JSON.parse(searchTermsSetting) as string[];
} else {
const defaultSearchTermsEnv =
process.env.JOBSPY_SEARCH_TERMS || "web developer";
searchTerms = defaultSearchTermsEnv
.split("|")
.map((term) => term.trim())
.filter(Boolean);
}
}
let searchProfileTargetRoles: string[] = [];
let searchProfileMustHaveSkills: string[] = [];
let searchProfileDealBreakers: string[] = [];
@@ -256,10 +311,10 @@ export async function discoverJobsStep(args: {
searchProfileTargetRoles = parsed.data.targetRoles ?? [];
searchProfileMustHaveSkills = parsed.data.mustHaveSkills ?? [];
searchProfileDealBreakers = parsed.data.dealBreakers ?? [];
if (searchProfileTargetRoles.length > 0) {
if (searchProfileTargetRoles.length > 0 && !usedActiveKeywordSet) {
mergeTargetRoles(searchProfileTargetRoles);
}
} else if (row.data.targetRoles?.length) {
} else if (row.data.targetRoles?.length && !usedActiveKeywordSet) {
// Legacy profile shapes: keep augmenting terms but we won't enforce strict filtering.
mergeTargetRoles(row.data.targetRoles);
}
@@ -274,12 +329,13 @@ export async function discoverJobsStep(args: {
searchProfileTargetRoles = parsed.data.targetRoles ?? [];
searchProfileMustHaveSkills = parsed.data.mustHaveSkills ?? [];
searchProfileDealBreakers = parsed.data.dealBreakers ?? [];
if (searchProfileTargetRoles.length > 0) {
if (searchProfileTargetRoles.length > 0 && !usedActiveKeywordSet) {
mergeTargetRoles(searchProfileTargetRoles);
}
} else if (
Array.isArray((profile as { targetRoles?: unknown }).targetRoles) &&
(profile as { targetRoles: unknown[] }).targetRoles.length > 0
(profile as { targetRoles: unknown[] }).targetRoles.length > 0 &&
!usedActiveKeywordSet
) {
mergeTargetRoles((profile as { targetRoles: unknown }).targetRoles);
}
@@ -289,6 +345,13 @@ export async function discoverJobsStep(args: {
}
}
logger.info("Discovery search terms resolved", {
step: "discover-jobs",
usedActiveKeywordSet,
termCount: searchTerms.length,
termsPreview: searchTerms.slice(0, 12),
});
const geographyCountryKey = inferCountryKeyFromSearchGeography(
settings.searchCities,
settings.jobspyLocation,
@@ -613,35 +676,68 @@ export async function discoverJobsStep(args: {
return { discoveredJobs: allowedCountryFilteredJobs, sourceErrors };
}
const profileFilterRolePhrases =
searchProfileTargetRoles.length > 0
? searchProfileTargetRoles
: searchTerms;
const profileMustHaveSkills = usedActiveKeywordSet
? []
: searchProfileMustHaveSkills;
const profileDealBreakers = usedActiveKeywordSet
? []
: searchProfileDealBreakers;
const strictProfileFilteringEnabled =
searchProfileTargetRoles.length > 0 ||
searchProfileMustHaveSkills.length > 0 ||
searchProfileDealBreakers.length > 0;
const profileFiltered = strictProfileFilteringEnabled
? filterJobsBySearchProfile({
!usedActiveKeywordSet &&
(profileFilterRolePhrases.length > 0 ||
profileMustHaveSkills.length > 0 ||
profileDealBreakers.length > 0);
const profileFiltered = usedActiveKeywordSet
? filterJobsByKeywordSetTerms({
jobs: allowedCountryFilteredJobs,
targetRolePhrases: searchProfileTargetRoles.length
? searchProfileTargetRoles
: searchTerms,
mustHaveSkills: searchProfileMustHaveSkills,
dealBreakers: searchProfileDealBreakers,
terms: searchTerms,
})
: { jobs: allowedCountryFilteredJobs, dropped: 0 };
: strictProfileFilteringEnabled
? filterJobsBySearchProfile({
jobs: allowedCountryFilteredJobs,
targetRolePhrases: profileFilterRolePhrases,
mustHaveSkills: profileMustHaveSkills,
dealBreakers: profileDealBreakers,
})
: { jobs: allowedCountryFilteredJobs, dropped: 0 };
if (profileFiltered.dropped > 0) {
logger.info("Dropped discovered jobs that didn't match search profile", {
step: "discover-jobs",
droppedCount: profileFiltered.dropped,
targetRolesCount: searchProfileTargetRoles.length,
mustHaveSkillsCount: searchProfileMustHaveSkills.length,
dealBreakersCount: searchProfileDealBreakers.length,
usedActiveKeywordSet,
targetRolesCount: usedActiveKeywordSet
? searchTerms.length
: profileFilterRolePhrases.length,
mustHaveSkillsCount: profileMustHaveSkills.length,
dealBreakersCount: profileDealBreakers.length,
});
}
if (profileFiltered.jobs.length === 0 && sourceErrors.length > 0) {
if (discoveredJobs.length === 0 && sourceErrors.length > 0) {
throw new Error(`All sources failed: ${sourceErrors.join("; ")}`);
}
if (
profileFiltered.jobs.length === 0 &&
allowedCountryFilteredJobs.length > 0
) {
logger.warn(
"All discovered jobs were removed by country or profile filters",
{
step: "discover-jobs",
discoveredCount: discoveredJobs.length,
afterCountryFilterCount: allowedCountryFilteredJobs.length,
usedActiveKeywordSet,
allowedCountry: geographyCountryKey,
},
);
}
if (sourceErrors.length > 0) {
logger.warn("Some discovery sources failed", { sourceErrors });
}
@@ -4,12 +4,31 @@ import * as settingsRepo from "@server/repositories/settings";
import { scoreJobSuitability } from "@server/services/scorer";
import * as visaSponsors from "@server/services/visa-sponsors/index";
import { asyncPool } from "@server/utils/async-pool";
import {
detectSponsorshipSignals,
serializeSponsorshipSignals,
} from "@shared/sponsorship-signals";
import type { Job } from "@shared/types";
import { progressHelpers, updateProgress } from "../progress";
import type { ScoredJob } from "./types";
const SCORING_CONCURRENCY = 4;
function buildSponsorshipSignalsUpdate(job: Job): {
sponsorshipSignals: string | null;
} {
return {
sponsorshipSignals: serializeSponsorshipSignals(
detectSponsorshipSignals({
jobDescription: job.jobDescription,
location: job.location,
companyAddresses: job.companyAddresses,
companyDescription: job.companyDescription,
}),
),
};
}
export async function scoreJobsStep(args: {
profile: Record<string, unknown>;
ownerProfileId: string;
@@ -53,6 +72,9 @@ export async function scoreJobsStep(args: {
!Number.isNaN(job.suitabilityScore);
if (hasCachedScore) {
if (job.sponsorshipSignals == null) {
await jobsRepo.updateJob(job.id, buildSponsorshipSignalsUpdate(job));
}
completed += 1;
progressHelpers.scoringJob(
completed,
@@ -100,6 +122,7 @@ export async function scoreJobsStep(args: {
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
sponsorMatchScore,
sponsorMatchNames,
...buildSponsorshipSignalsUpdate(job),
...(shouldAutoSkip ? { status: "skipped" } : {}),
});
@@ -338,6 +338,7 @@ export async function getJobListItems(
closedAt: jobs.closedAt,
suitabilityScore: jobs.suitabilityScore,
sponsorMatchScore: jobs.sponsorMatchScore,
sponsorshipSignals: jobs.sponsorshipSignals,
jobType: jobs.jobType,
jobFunction: jobs.jobFunction,
salaryMinAmount: jobs.salaryMinAmount,
@@ -934,6 +935,7 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
tracerLinksEnabled: row.tracerLinksEnabled ?? false,
sponsorMatchScore: row.sponsorMatchScore ?? null,
sponsorMatchNames: row.sponsorMatchNames ?? null,
sponsorshipSignals: row.sponsorshipSignals ?? null,
notes: row.notes ?? null,
jobType: row.jobType ?? null,
salarySource: row.salarySource ?? null,
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it } from "vitest";
import { db } from "../db";
import { keywordSets, settings } from "../db/schema";
import {
activateKeywordSet,
createKeywordSet,
ensureKeywordSetsForOwner,
getActiveKeywordSetTerms,
listKeywordSets,
syncActiveKeywordSetTerms,
} from "./keyword-sets";
const OWNER = "__test_keyword_sets__";
describe("keyword-sets repository", () => {
beforeEach(async () => {
await db.delete(keywordSets);
await db.delete(settings);
});
it("creates a default set for a new owner", async () => {
await ensureKeywordSetsForOwner(OWNER);
const sets = await listKeywordSets(OWNER);
expect(sets).toHaveLength(1);
expect(sets[0]?.name).toBe("Default");
expect(sets[0]?.isActive).toBe(true);
});
it("activates a set and returns its terms for discovery", async () => {
const first = await createKeywordSet(OWNER, {
name: "SDET",
terms: ["sdet", "playwright"],
});
const second = await createKeywordSet(OWNER, {
name: "Lead",
terms: ["engineering manager"],
});
await activateKeywordSet(second.id, OWNER);
const terms = await getActiveKeywordSetTerms(OWNER);
expect(terms).toEqual(["engineering manager"]);
await activateKeywordSet(first.id, OWNER);
expect(await getActiveKeywordSetTerms(OWNER)).toEqual([
"sdet",
"playwright",
]);
});
it("syncs terms on the active set", async () => {
await ensureKeywordSetsForOwner(OWNER);
const sets = await listKeywordSets(OWNER);
const activeId = sets[0]?.id;
expect(activeId).toBeTruthy();
await syncActiveKeywordSetTerms(OWNER, ["caseware", "guidewire"]);
expect(await getActiveKeywordSetTerms(OWNER)).toEqual([
"caseware",
"guidewire",
]);
});
});
@@ -0,0 +1,305 @@
import { randomUUID } from "node:crypto";
import type {
CreateKeywordSetInput,
KeywordSet,
UpdateKeywordSetInput,
} from "@shared/types";
import { and, eq } from "drizzle-orm";
import { db } from "../db";
import { keywordSets } from "../db/schema";
import { getProfileById } from "./profiles";
import { getSetting, setSetting } from "./settings";
const MAX_SETS_PER_OWNER = 30;
const MAX_TERMS_PER_SET = 100;
function parseTerms(raw: string): string[] {
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.filter((t): t is string => typeof t === "string")
.map((t) => t.trim())
.filter(Boolean)
.slice(0, MAX_TERMS_PER_SET);
} catch {
return [];
}
}
function mapRow(row: typeof keywordSets.$inferSelect): KeywordSet {
return {
id: row.id,
name: row.name,
terms: parseTerms(row.terms),
isActive: row.isActive,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async function countSetsForOwner(ownerProfileId: string): Promise<number> {
const rows = await db
.select({ id: keywordSets.id })
.from(keywordSets)
.where(eq(keywordSets.ownerProfileId, ownerProfileId));
return rows.length;
}
async function clearActiveForOwner(ownerProfileId: string): Promise<void> {
await db
.update(keywordSets)
.set({ isActive: false, updatedAt: new Date().toISOString() })
.where(
and(
eq(keywordSets.ownerProfileId, ownerProfileId),
eq(keywordSets.isActive, true),
),
);
}
async function readGlobalSearchTermsFallback(): Promise<string[]> {
const raw = await getSetting("searchTerms");
if (!raw) {
const envDefault = process.env.JOBSPY_SEARCH_TERMS || "web developer";
return envDefault
.split("|")
.map((t) => t.trim())
.filter(Boolean);
}
return parseTerms(raw);
}
async function defaultTermsForOwner(ownerProfileId: string): Promise<string[]> {
if (ownerProfileId === "__default__") {
return readGlobalSearchTermsFallback();
}
const profile = await getProfileById(ownerProfileId);
const roles = profile?.data?.targetRoles ?? [];
const fromProfile = roles
.filter((role): role is string => typeof role === "string")
.map((role) => role.trim())
.filter(Boolean);
if (fromProfile.length > 0) return fromProfile;
return readGlobalSearchTermsFallback();
}
export async function ensureKeywordSetsForOwner(
ownerProfileId: string,
): Promise<void> {
const count = await countSetsForOwner(ownerProfileId);
if (count > 0) return;
const terms = await defaultTermsForOwner(ownerProfileId);
const now = new Date().toISOString();
await db.insert(keywordSets).values({
id: randomUUID(),
ownerProfileId,
name: "Default",
terms: JSON.stringify(terms),
isActive: true,
createdAt: now,
updatedAt: now,
});
}
export async function listKeywordSets(
ownerProfileId: string,
): Promise<KeywordSet[]> {
await ensureKeywordSetsForOwner(ownerProfileId);
const rows = await db
.select()
.from(keywordSets)
.where(eq(keywordSets.ownerProfileId, ownerProfileId))
.orderBy(keywordSets.name);
return rows.map(mapRow);
}
export async function getKeywordSetById(
id: string,
ownerProfileId: string,
): Promise<KeywordSet | null> {
const [row] = await db
.select()
.from(keywordSets)
.where(
and(
eq(keywordSets.id, id),
eq(keywordSets.ownerProfileId, ownerProfileId),
),
);
return row ? mapRow(row) : null;
}
export async function getActiveKeywordSet(
ownerProfileId: string,
): Promise<KeywordSet | null> {
await ensureKeywordSetsForOwner(ownerProfileId);
const [row] = await db
.select()
.from(keywordSets)
.where(
and(
eq(keywordSets.ownerProfileId, ownerProfileId),
eq(keywordSets.isActive, true),
),
);
if (row) return mapRow(row);
const all = await listKeywordSets(ownerProfileId);
return all[0] ?? null;
}
export async function getActiveKeywordSetTerms(
ownerProfileId: string,
): Promise<string[] | null> {
const active = await getActiveKeywordSet(ownerProfileId);
if (!active) return null;
return active.terms;
}
export async function createKeywordSet(
ownerProfileId: string,
input: CreateKeywordSetInput,
): Promise<KeywordSet> {
await ensureKeywordSetsForOwner(ownerProfileId);
const count = await countSetsForOwner(ownerProfileId);
if (count >= MAX_SETS_PER_OWNER) {
throw new Error(`Maximum of ${MAX_SETS_PER_OWNER} keyword sets per user`);
}
const name = input.name.trim();
const terms = (input.terms ?? [])
.map((t) => t.trim())
.filter(Boolean)
.slice(0, MAX_TERMS_PER_SET);
const now = new Date().toISOString();
const id = randomUUID();
const shouldActivate = count === 0;
if (shouldActivate) {
await clearActiveForOwner(ownerProfileId);
}
await db.insert(keywordSets).values({
id,
ownerProfileId,
name,
terms: JSON.stringify(terms),
isActive: shouldActivate,
createdAt: now,
updatedAt: now,
});
const created = await getKeywordSetById(id, ownerProfileId);
if (!created) throw new Error("Failed to create keyword set");
return created;
}
export async function updateKeywordSet(
id: string,
ownerProfileId: string,
input: UpdateKeywordSetInput,
): Promise<KeywordSet | null> {
const existing = await getKeywordSetById(id, ownerProfileId);
if (!existing) return null;
const updates: Partial<typeof keywordSets.$inferInsert> = {
updatedAt: new Date().toISOString(),
};
if (input.name !== undefined) updates.name = input.name.trim();
if (input.terms !== undefined) {
updates.terms = JSON.stringify(
input.terms
.map((t) => t.trim())
.filter(Boolean)
.slice(0, MAX_TERMS_PER_SET),
);
}
await db
.update(keywordSets)
.set(updates)
.where(
and(
eq(keywordSets.id, id),
eq(keywordSets.ownerProfileId, ownerProfileId),
),
);
const updated = await getKeywordSetById(id, ownerProfileId);
if (
updated?.isActive &&
ownerProfileId === "__default__" &&
input.terms !== undefined
) {
await setSetting("searchTerms", JSON.stringify(updated.terms));
}
return updated;
}
export async function deleteKeywordSet(
id: string,
ownerProfileId: string,
): Promise<boolean> {
const existing = await getKeywordSetById(id, ownerProfileId);
if (!existing) return false;
const count = await countSetsForOwner(ownerProfileId);
if (count <= 1) {
throw new Error("Cannot delete the last keyword set");
}
await db
.delete(keywordSets)
.where(
and(
eq(keywordSets.id, id),
eq(keywordSets.ownerProfileId, ownerProfileId),
),
);
if (existing.isActive) {
const remaining = await listKeywordSets(ownerProfileId);
if (remaining[0]) {
await activateKeywordSet(remaining[0].id, ownerProfileId);
}
}
return true;
}
export async function activateKeywordSet(
id: string,
ownerProfileId: string,
): Promise<KeywordSet | null> {
const existing = await getKeywordSetById(id, ownerProfileId);
if (!existing) return null;
const now = new Date().toISOString();
await clearActiveForOwner(ownerProfileId);
await db
.update(keywordSets)
.set({ isActive: true, updatedAt: now })
.where(
and(
eq(keywordSets.id, id),
eq(keywordSets.ownerProfileId, ownerProfileId),
),
);
const activated = await getKeywordSetById(id, ownerProfileId);
if (activated && ownerProfileId === "__default__") {
await setSetting("searchTerms", JSON.stringify(activated.terms));
}
return activated;
}
export async function syncActiveKeywordSetTerms(
ownerProfileId: string,
terms: string[],
): Promise<KeywordSet | null> {
await ensureKeywordSetsForOwner(ownerProfileId);
const active = await getActiveKeywordSet(ownerProfileId);
if (!active) return null;
return updateKeywordSet(active.id, ownerProfileId, { terms });
}
@@ -42,15 +42,26 @@ export async function listProfiles(): Promise<SearchProfile[]> {
return rows.map(mapRow);
}
export function profileMatchesBasicAuthUser(
basicAuthUser: string | null | undefined,
username: string,
): boolean {
const u = username.trim().toLowerCase();
if (!u) return false;
const aliases = (basicAuthUser ?? "")
.split(",")
.map((part) => part.trim().toLowerCase())
.filter(Boolean);
return aliases.includes(u);
}
/** When Basic Auth maps users to profiles, each user only sees rows with matching `basicAuthUser`. */
export async function listProfilesForBasicAuthUser(
username: string,
): Promise<SearchProfile[]> {
const u = username.trim().toLowerCase();
if (!u) return [];
const all = await listProfiles();
return all.filter(
(p) => (p.data.basicAuthUser ?? "").trim().toLowerCase() === u,
return all.filter((p) =>
profileMatchesBasicAuthUser(p.data.basicAuthUser, username),
);
}
@@ -103,10 +114,10 @@ export async function getSearchProfileIdForBasicAuthUser(
username: string,
): Promise<string | null> {
const profiles = await listProfiles();
const u = username.trim().toLowerCase();
for (const p of profiles) {
const bu = (p.data.basicAuthUser ?? "").trim().toLowerCase();
if (bu && bu === u) return p.id;
if (profileMatchesBasicAuthUser(p.data.basicAuthUser, username)) {
return p.id;
}
}
return null;
}
+66 -110
View File
@@ -4,38 +4,11 @@ import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearProfileCache, getProfile } from "./profile";
// Mock the dependencies
vi.mock("../repositories/settings", () => ({
getSetting: vi.fn(),
}));
vi.mock("./rxresume", () => ({
getResume: vi.fn(),
RxResumeAuthConfigError: class RxResumeAuthConfigError extends Error {
constructor() {
super("Reactive Resume credentials not configured.");
this.name = "RxResumeAuthConfigError";
}
},
}));
import { getSetting } from "../repositories/settings";
import { getResume, RxResumeAuthConfigError } from "./rxresume";
function mockRxResumeOnlyFlow(resumeId: string) {
vi.mocked(getSetting).mockImplementation(async (key: string) => {
if (key === "localResumeProfilePath") return null;
if (key === "rxresumeMode") return "v5";
if (
key === "rxresumeBaseResumeId" ||
key === "rxresumeBaseResumeIdV5" ||
key === "rxresumeBaseResumeIdV4"
) {
return resumeId;
}
return null;
});
}
describe("getProfile", () => {
beforeEach(() => {
@@ -44,87 +17,14 @@ describe("getProfile", () => {
delete process.env.JOBOPS_LOCAL_RESUME_PATH;
});
it("should throw an error if no local file and rxresumeBaseResumeId is not configured", async () => {
vi.mocked(getSetting).mockImplementation(async (key: string) => {
if (key === "localResumeProfilePath") return null;
return null;
});
it("throws when no local resume path is configured", async () => {
vi.mocked(getSetting).mockResolvedValue(null);
await expect(getProfile()).rejects.toThrow(
/Base resume not configured|JOBOPS_LOCAL_RESUME_PATH|local resume path/i,
);
});
it("should fetch profile from Reactive Resume when configured", async () => {
const mockResumeData = { basics: { name: "Test User" } };
mockRxResumeOnlyFlow("test-resume-id");
vi.mocked(getResume).mockResolvedValue({
id: "test-resume-id",
data: mockResumeData,
} as any);
const profile = await getProfile();
expect(getResume).toHaveBeenCalledWith("test-resume-id");
expect(profile).toEqual(mockResumeData);
});
it("should cache the profile and not refetch on subsequent calls", async () => {
const mockResumeData = { basics: { name: "Test User" } };
mockRxResumeOnlyFlow("test-resume-id");
vi.mocked(getResume).mockResolvedValue({
id: "test-resume-id",
data: mockResumeData,
} as any);
await getProfile();
await getProfile();
expect(getResume).toHaveBeenCalledTimes(1);
});
it("should refetch when forceRefresh is true", async () => {
const mockResumeData = { basics: { name: "Test User" } };
mockRxResumeOnlyFlow("test-resume-id");
vi.mocked(getResume).mockResolvedValue({
id: "test-resume-id",
data: mockResumeData,
} as any);
await getProfile();
await getProfile(true);
expect(getResume).toHaveBeenCalledTimes(2);
expect(vi.mocked(getResume).mock.calls[0]).toEqual(["test-resume-id"]);
expect(vi.mocked(getResume).mock.calls[1]).toEqual([
"test-resume-id",
{ forceRefresh: true },
]);
});
it("should throw user-friendly error on credential issues", async () => {
mockRxResumeOnlyFlow("test-resume-id");
vi.mocked(getResume).mockRejectedValue(
new (RxResumeAuthConfigError as unknown as new () => Error)(),
);
await expect(getProfile()).rejects.toThrow(
"Reactive Resume credentials not configured.",
);
});
it("should throw error if resume data is empty", async () => {
mockRxResumeOnlyFlow("test-resume-id");
vi.mocked(getResume).mockResolvedValue({
id: "test-resume-id",
data: null,
} as any);
await expect(getProfile()).rejects.toThrow(
"Resume data is empty or invalid",
);
});
it("loads profile from localResumeProfilePath when set", async () => {
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-"));
const filePath = join(dir, "resume.json");
@@ -138,10 +38,55 @@ describe("getProfile", () => {
const profile = await getProfile();
expect(profile).toEqual(mockResumeData);
expect(getResume).not.toHaveBeenCalled();
});
it("prefers JOBOPS_LOCAL_RESUME_PATH over RxResume", async () => {
it("caches the profile and does not re-read on subsequent calls", async () => {
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-cache-"));
const filePath = join(dir, "resume.json");
await writeFile(
filePath,
JSON.stringify({ basics: { name: "Cached User" } }),
"utf8",
);
vi.mocked(getSetting).mockImplementation(async (key: string) => {
if (key === "localResumeProfilePath") return filePath;
return null;
});
const first = await getProfile();
const second = await getProfile();
expect(first).toBe(second);
expect(second.basics?.name).toBe("Cached User");
});
it("refetches from disk when forceRefresh is true", async () => {
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-refresh-"));
const filePath = join(dir, "resume.json");
await writeFile(
filePath,
JSON.stringify({ basics: { name: "First" } }),
"utf8",
);
vi.mocked(getSetting).mockImplementation(async (key: string) => {
if (key === "localResumeProfilePath") return filePath;
return null;
});
await getProfile();
await writeFile(
filePath,
JSON.stringify({ basics: { name: "Second" } }),
"utf8",
);
const profile = await getProfile(true);
expect(profile.basics?.name).toBe("Second");
});
it("prefers JOBOPS_LOCAL_RESUME_PATH over the settings path", async () => {
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-env-"));
const filePath = join(dir, "resume.json");
await writeFile(
@@ -151,14 +96,25 @@ describe("getProfile", () => {
);
process.env.JOBOPS_LOCAL_RESUME_PATH = filePath;
mockRxResumeOnlyFlow("ignored-id");
vi.mocked(getResume).mockResolvedValue({
id: "ignored-id",
data: { basics: { name: "Remote User" } },
} as any);
vi.mocked(getSetting).mockImplementation(async (key: string) => {
if (key === "localResumeProfilePath") return "/other/resume.json";
return null;
});
const profile = await getProfile();
expect(profile.basics?.name).toBe("Env User");
expect(getResume).not.toHaveBeenCalled();
});
it("throws when the local file is not valid JSON", async () => {
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-bad-"));
const filePath = join(dir, "resume.json");
await writeFile(filePath, "not json", "utf8");
vi.mocked(getSetting).mockImplementation(async (key: string) => {
if (key === "localResumeProfilePath") return filePath;
return null;
});
await expect(getProfile()).rejects.toThrow(/not valid JSON/i);
});
});
+5 -56
View File
@@ -6,11 +6,6 @@ import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import { getProfileById } from "@server/repositories/profiles";
import { getSetting } from "@server/repositories/settings";
import type { ResumeProfile } from "@shared/types";
import { getResume, RxResumeAuthConfigError } from "./rxresume";
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
let cachedProfile: ResumeProfile | null = null;
let cachedResumeId: string | null = null;
/** Cache key is absolute path + file mtime (ms). */
let cachedLocalSourceKey: string | null = null;
@@ -84,7 +79,7 @@ async function loadProfileFromLocalFile(
parsed = JSON.parse(raw);
} catch {
throw new Error(
`Local resume file at ${absolutePath} is not valid JSON (Reactive Resume export shape expected).`,
`Local resume file at ${absolutePath} is not valid JSON (resume export shape expected).`,
);
}
@@ -103,10 +98,9 @@ async function loadProfileFromLocalFile(
}
/**
* Get the base resume profile: local JSON file (if configured) first, else Reactive Resume API.
* Get the base resume profile from a local JSON file when configured.
*
* Local sources (in order): `JOBOPS_LOCAL_RESUME_PATH` env, then `localResumeProfilePath` setting.
* Otherwise requires `rxresumeBaseResumeId` and RxResume credentials.
*
* Results are cached until clearProfileCache() is called.
*
@@ -118,52 +112,9 @@ export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
return loadProfileFromLocalFile(localPath, forceRefresh);
}
const { resumeId: rxresumeBaseResumeId } =
await getConfiguredRxResumeBaseResumeId();
if (!rxresumeBaseResumeId) {
throw new Error(
"Base resume not configured. Set JOBOPS_LOCAL_RESUME_PATH or local resume path in Settings, or select a base resume from Reactive Resume.",
);
}
// Return cached profile if valid
if (
cachedProfile &&
cachedResumeId === rxresumeBaseResumeId &&
!forceRefresh
) {
return cachedProfile;
}
try {
logger.info("Fetching profile from Reactive Resume", {
resumeId: rxresumeBaseResumeId,
});
const resume = forceRefresh
? await getResume(rxresumeBaseResumeId, { forceRefresh: true })
: await getResume(rxresumeBaseResumeId);
if (!resume.data || typeof resume.data !== "object") {
throw new Error("Resume data is empty or invalid");
}
cachedProfile = resume.data as unknown as ResumeProfile;
cachedResumeId = rxresumeBaseResumeId;
logger.info("Profile loaded from Reactive Resume", {
resumeId: rxresumeBaseResumeId,
});
return cachedProfile;
} catch (error) {
if (error instanceof RxResumeAuthConfigError) {
throw new Error(error.message);
}
logger.error("Failed to load profile from Reactive Resume", {
resumeId: rxresumeBaseResumeId,
error,
});
throw error;
}
throw new Error(
"Base resume not configured. Set JOBOPS_LOCAL_RESUME_PATH or a local resume path in Settings.",
);
}
/**
@@ -178,8 +129,6 @@ export async function getPersonName(): Promise<string> {
* Clear the profile cache.
*/
export function clearProfileCache(): void {
cachedProfile = null;
cachedResumeId = null;
cachedLocalSourceKey = null;
cachedLocalProfile = null;
}