feat: keyword sets, sponsorship signals, and extractor/profile fixes
All checks were successful
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:
ilia 2026-06-11 10:23:27 -04:00
parent ecf1829ed8
commit 84e6835b11
117 changed files with 7123 additions and 3029 deletions

View File

@ -254,3 +254,19 @@ ADZUNA_APP_KEY=
# Comma-separated paths under https://arc.dev used when seeding defaults (e.g. Playwright + Cypress feeds).
# ARC_REMOTE_JOBS_PATHS=/remote-jobs/playwright,/remote-jobs/cypress
# Prefer Settings for overrides: arcRemoteJobsPaths (JSON array), arcMaxJobsPerPath (default 120).
# =============================================================================
# New public job boards — optional (auto-enabled, no credentials)
# =============================================================================
# Caps via Settings: workingnomadsMaxJobsPerTerm, testdevjobsMaxJobsPerTerm,
# testdevjobsMaxPages, builtinMaxJobsPerTerm, builtinMaxPagesPerTerm,
# wellfoundMaxJobsPerTerm (Wellfound uses Camoufox when installed).
# =============================================================================
# Teamtailor / Huntflow / Factorial / Career pages — optional
# =============================================================================
# TEAMTAILOR_COMPANIES=testgorilla,polestar
# HUNTFLOW_TENANTS=apicworld
# FACTORIAL_TENANTS=yourbourse
# CAREERS_PAGE_URLS=https://sentry.io/careers/,https://example.com/careers
# Prefer Settings → Job sources & ATS for the same fields.

7
.gitignore vendored
View File

@ -18,6 +18,11 @@ data/
# Extractor storage outputs and cached auth (per-extractor runtime data)
extractors/*/storage/
# OS files
# OS / editor
.DS_Store
Thumbs.db
.vscode/
# Local scratch / extractor debug dumps (not part of the app)
storage/
solution-workload-test.*

View File

@ -55,6 +55,14 @@ git add -A && git commit -m "Your message" && git push gitea main
5. Open the UI: `http://<VM-IP>:3005` (port mapped in `docker-compose.yml`).
**Local machine (loopback only, avoids binding `0.0.0.0:3005`):**
```bash
docker compose -f docker-compose.yml -f docker-compose.localhost.yml up -d --build
```
UI: `http://127.0.0.1:13005`
6. Persist data: compose mounts `./data` — back up that directory.
---

View File

@ -0,0 +1,6 @@
# Local-only overlay: single loopback port (does not publish 0.0.0.0:3005).
# Usage: docker compose -f docker-compose.yml -f docker-compose.localhost.yml up -d --build
services:
job-ops:
ports: !override
- "127.0.0.1:13005:3001"

View File

@ -11,11 +11,11 @@ Original website: [hiring.cafe](https://hiring.cafe)
Special thanks: Initial implementation inspiration came from [umur957/hiring-cafe-job-scraper](https://github.com/umur957/hiring-cafe-job-scraper).
Hiring Cafe is a browser-backed extractor that queries Hiring Cafe search APIs and maps results into the orchestrator `CreateJobInput` shape.
Hiring Cafe is a browser-backed extractor that loads the site in Firefox (Camoufox when available), then fetches paginated search results through Hiring Cafe's Next.js SSR data endpoint and maps rows into the orchestrator `CreateJobInput` shape.
Implementation split:
1. `extractors/hiringcafe/src/main.ts` builds search state, calls Hiring Cafe APIs, and writes dataset JSON.
1. `extractors/hiringcafe/src/main.ts` builds search state, reads the page `buildId`, calls `/_next/data/{buildId}/index.json?searchState=...&page=...`, and writes dataset JSON.
2. `orchestrator/src/server/services/hiring-cafe.ts` runs the extractor, streams progress events, and maps rows for pipeline import.
## Why it exists
@ -57,6 +57,12 @@ npm --workspace hiringcafe-extractor run start
## Common problems
### Hiring Cafe returns 401 Unauthorized or 404 on `/api/search-jobs`
- Hiring Cafe removed or locked down the legacy GET `/api/search-jobs` and `/api/search-jobs/get-total-count` endpoints.
- Current JobOps builds use the Next.js SSR transport (`ssrHits`, `ssrTotalCount`, `ssrIsLastPage`) instead.
- Upgrade to a build that includes this change if logs still show `401` / `404` on the old API paths.
### Hiring Cafe returns 429 / Vercel security checkpoint
- The extractor first attempts Camoufox-backed Firefox and falls back to vanilla Firefox startup if Camoufox is unstable locally.

View File

@ -16,7 +16,7 @@ Extractor integrations are now registered through manifests and loaded automatic
| [Gradcracker](/docs/next/extractors/gradcracker) | UK graduate roles from Gradcracker | Crawling stability depends on page structure and anti-bot behavior; tuned for low concurrency | `GRADCRACKER_SEARCH_TERMS`, `GRADCRACKER_MAX_JOBS_PER_TERM`, `JOBOPS_SKIP_APPLY_FOR_EXISTING` | Scrapes listing metadata, then detail pages and apply URL resolution |
| [JobSpy](/docs/next/extractors/jobspy) | Multi-source discovery (Indeed, LinkedIn, Glassdoor) | Requires Python wrapper execution per term; source availability and quality vary by site/location | `JOBSPY_SITES`, `JOBSPY_SEARCH_TERMS`, `JOBSPY_RESULTS_WANTED`, `JOBSPY_HOURS_OLD`, `JOBSPY_LINKEDIN_FETCH_DESCRIPTION` | Produces JSON per term, then orchestrator normalizes and de-duplicates by `jobUrl` |
| [Adzuna](/docs/next/extractors/adzuna) | API-based multi-country discovery with low scraping overhead | Requires valid App ID/App Key; country must be in Adzuna-supported list | `ADZUNA_APP_ID`, `ADZUNA_APP_KEY`, `ADZUNA_MAX_JOBS_PER_TERM` | API pagination to dataset output; orchestrator maps progress and de-duplicates by `sourceJobId`/`jobUrl` |
| [Hiring Cafe](/docs/next/extractors/hiring-cafe) | Browser-backed discovery using Hiring Cafe search APIs | Subject to upstream anti-bot checks; uses browser context and encoded search-state payloads | `HIRING_CAFE_SEARCH_TERMS`, `HIRING_CAFE_COUNTRY`, `HIRING_CAFE_MAX_JOBS_PER_TERM`, `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` | Uses existing pipeline term/country/budget knobs and maps directly to normalized jobs |
| [Hiring Cafe](/docs/next/extractors/hiring-cafe) | Browser-backed discovery via Hiring Cafe Next.js SSR search payload | Subject to upstream anti-bot checks; uses browser context and JSON `searchState` query params | `HIRING_CAFE_SEARCH_TERMS`, `HIRING_CAFE_COUNTRY`, `HIRING_CAFE_MAX_JOBS_PER_TERM`, `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` | Uses existing pipeline term/country/budget knobs and maps directly to normalized jobs |
| [startup.jobs](/docs/next/extractors/startup-jobs) | Startup-focused discovery through the published `startup-jobs-scraper` package | No credentials required; detail enrichment depends on Playwright browser binaries being installed | existing pipeline `searchTerms`, selected country/cities, `jobspyResultsWanted`; `npx playwright install` for fresh environments | Algolia-backed search plus detail-page enrichment via package import; orchestrator maps normalized records and de-duplicates by `jobUrl` |
| [UKVisaJobs](/docs/next/extractors/ukvisajobs) | UK visa sponsorship-focused roles | Requires authenticated session and periodic token/cookie refresh | `UKVISAJOBS_EMAIL`, `UKVISAJOBS_PASSWORD`, `UKVISAJOBS_MAX_JOBS`, `UKVISAJOBS_SEARCH_KEYWORD` | API pagination + dataset output; orchestrator de-dupes and may fetch missing descriptions |
| [SmartRecruiters](/docs/next/extractors/smartrecruiters) | Enterprise employers on SmartRecruiters public boards | No auth; needs configured company identifiers; one HTTP round-trip per posting for apply URLs + descriptions | `SMARTRECRUITERS_COMPANIES`, `SMARTRECRUITERS_MAX_JOBS_PER_COMPANY` | Paginates the public Posting API, filters by pipeline terms, normalizes to `CreateJobInput` |

View File

@ -153,6 +153,14 @@ Readiness requires:
- Adzuna app ID/app key
- Optional basic authentication for write operations
### Job search profiles and local resume
- **Search profiles** store target roles, deal-breakers, locations, and scoring context. Use the profile picker in Settings to create, switch, or activate a profile.
- When **Basic Auth** is enabled, each login user maps to profiles whose `basicAuthUser` field lists that username. Comma-separated aliases are supported (for example `ilia,dobkin` accepts either login name).
- Jobs are scoped per profile (`owner_profile_id`). Pipeline runs and the orchestrator board only show jobs for the profile tied to your current login.
- **Local resume** JSON paths can be set per profile via `resumeLocalPath` in profile data (for example `../data/resumes/ilia-dobkin.json`). JobOps resolves that path relative to the orchestrator working directory when `JOBOPS_LOCAL_RESUME_PATH` is unset.
- Activating a profile on Settings calls `POST /api/profiles/:id/activate` and updates `activeProfileId`, `jobSearchProfile`, and `localResumeProfilePath` when Basic Auth is off.
### Backup
![Backup settings section](/img/features/settings-backup-section.png)
@ -260,6 +268,12 @@ curl -X POST "http://localhost:3001/api/backups"
- Ensure `JOBOPS_PUBLIC_BASE_URL` is set for background/pipeline usage.
- Ensure the configured host is publicly reachable and `/health` responds.
### Profile activate returns 403 or jobs list is empty after login
- Confirm your Basic Auth username appears in the profile's `basicAuthUser` list (comma-separated aliases are allowed).
- If you previously ran pipelines before per-profile ownership existed, older jobs may still be under `__default__`. Re-run discovery while logged in, or migrate rows in `jobs.owner_profile_id` to your profile id.
- Ensure the profile has `resumeLocalPath` pointing at an on-disk resume JSON if you rely on per-profile resumes without `JOBOPS_LOCAL_RESUME_PATH`.
## Related pages
- [Company skip list](./company-skip-list)

View File

@ -0,0 +1,189 @@
/**
* Built In tech job board (JSON-LD ItemList embedded in SSR HTML).
*
* https://builtin.com/jobs
*/
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import type { CreateJobInput } from "@shared/types/jobs";
const ORIGIN = "https://builtin.com";
interface BuiltinListItem {
title: string;
url: string;
description?: string;
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function decodeJsonString(value: string): string {
return value
.replace(/\\"/g, '"')
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\\\/g, "\\");
}
function readMaxPages(raw: string | undefined): number {
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
if (!Number.isFinite(parsed)) return 3;
return Math.min(Math.max(parsed, 1), 15);
}
function parseListItems(html: string): BuiltinListItem[] {
const items: BuiltinListItem[] = [];
const pattern =
/\{"@type":"ListItem","position":\d+,"name":"((?:\\.|[^"\\])*)","url":"(https:\/\/builtin\.com\/job\/[^"]+)"(?:,"description":"((?:\\.|[^"\\])*)")?\}/g;
for (const match of html.matchAll(pattern)) {
const title = decodeJsonString(match[1] ?? "");
const url = match[2];
if (!title || !url) continue;
items.push({
title,
url,
description: match[3] ? decodeJsonString(match[3]) : undefined,
});
}
return items;
}
function slugToWords(slug: string): string {
return slug
.split("-")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function employerFromUrl(jobUrl: string): string {
const match = jobUrl.match(/\/job\/[^/]+\/(\d+)/);
if (!match) return "Unknown Employer";
const slugMatch = jobUrl.match(/\/job\/([^/]+)\/\d+/);
const slug = slugMatch?.[1] ?? "";
const parts = slug.split("-");
const maybeId = parts[parts.length - 1];
if (/^\d+$/.test(maybeId ?? "")) parts.pop();
const trimmed = parts.slice(-2);
if (trimmed.length === 0) return slugToWords(slug);
return slugToWords(trimmed.join("-"));
}
function searchPath(term: string | null): string {
if (!term) return "/jobs/remote";
const query = encodeURIComponent(term.trim());
return `/jobs/remote?search=${query}`;
}
function matchesTerm(item: BuiltinListItem, term: string): boolean {
const lower = term.toLowerCase();
if (item.title.toLowerCase().includes(lower)) return true;
if (item.description?.toLowerCase().includes(lower)) return true;
return false;
}
async function fetchPage(path: string, page: number): Promise<string> {
const separator = path.includes("?") ? "&" : "?";
const url =
page <= 1 ? `${ORIGIN}${path}` : `${ORIGIN}${path}${separator}page=${page}`;
const response = await fetch(url, {
headers: { Accept: "text/html", "User-Agent": "JobOps/1.0" },
});
if (!response.ok) {
throw new Error(`Built In request failed (${response.status}) for ${url}`);
}
return response.text();
}
export const manifest: ExtractorManifest = {
id: "builtin",
displayName: "Built In",
providesSources: ["builtin"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
const maxJobs = context.settings.builtinMaxJobsPerTerm
? Number.parseInt(context.settings.builtinMaxJobsPerTerm, 10)
: 100;
const cap = Number.isFinite(maxJobs)
? Math.min(Math.max(maxJobs, 1), 500)
: 100;
const maxPages = readMaxPages(context.settings.builtinMaxPagesPerTerm);
const terms = context.searchTerms.length > 0 ? context.searchTerms : [null];
const seen = new Set<string>();
const out: CreateJobInput[] = [];
try {
for (let termIndex = 0; termIndex < terms.length; termIndex += 1) {
if (context.shouldCancel?.()) break;
const term = terms[termIndex];
const path = searchPath(term);
for (let page = 1; page <= maxPages; page += 1) {
if (context.shouldCancel?.()) break;
if (out.length >= cap) break;
context.onProgress?.({
phase: "list",
termsProcessed: termIndex,
termsTotal: terms.length,
currentUrl: `${ORIGIN}${path}`,
detail: `Built In: term ${termIndex + 1}/${terms.length}, page ${page}`,
});
const html = await fetchPage(path, page);
const items = parseListItems(html);
if (items.length === 0) break;
for (const item of items) {
if (out.length >= cap) break;
if (term && !matchesTerm(item, term)) continue;
const key = item.url;
if (seen.has(key)) continue;
seen.add(key);
out.push({
source: "builtin",
sourceJobId: item.url.split("/").pop(),
title: item.title,
employer: employerFromUrl(item.url),
jobUrl: item.url,
applicationLink: item.url,
location: "Remote",
isRemote: true,
jobDescription: item.description,
});
}
}
}
context.onProgress?.({
phase: "list",
termsProcessed: terms.length,
termsTotal: terms.length,
currentUrl: `${ORIGIN}/jobs`,
jobPagesProcessed: out.length,
detail: `Built In: ${out.length} jobs`,
});
return { success: true, jobs: out };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, jobs: out, error: message };
}
},
};
export default manifest;

View File

@ -0,0 +1,17 @@
{
"name": "builtin-extractor",
"version": "0.0.1",
"type": "module",
"description": "Built In tech job board extractor",
"main": "manifest.ts",
"dependencies": {
"job-ops-shared": "^1.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "~5.9.0"
},
"scripts": {
"check:types": "tsc --noEmit"
}
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"outDir": "dist",
"strict": true,
"noUnusedLocals": false,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"@shared/*": ["../../shared/src/*"]
}
},
"include": ["./manifest.ts", "./src/**/*"]
}

View File

@ -0,0 +1,89 @@
/**
* Direct company career pages auto-detect ATS backends and pull public job feeds.
*/
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import type { CreateJobInput } from "@shared/types/jobs";
import {
detectAtsTargets,
fetchCareersPage,
fetchJobsForTarget,
readUrlList,
} from "./src/resolve-ats.js";
function matchesTerm(job: CreateJobInput, term: string): boolean {
const lower = term.toLowerCase();
if (job.title.toLowerCase().includes(lower)) return true;
if (job.employer.toLowerCase().includes(lower)) return true;
if (job.jobDescription?.toLowerCase().includes(lower)) return true;
return false;
}
export const manifest: ExtractorManifest = {
id: "careerspages",
displayName: "Career Pages",
providesSources: ["careerspages"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
const pageUrls = readUrlList(context.settings.careersPageUrls);
if (pageUrls.length === 0) {
return {
success: true,
jobs: [],
error:
"No career page URLs configured. Set CAREERS_PAGE_URLS or careersPageUrls (JSON array or comma/newline-separated URLs).",
};
}
const terms = context.searchTerms.length > 0 ? context.searchTerms : [];
const seen = new Set<string>();
const out: CreateJobInput[] = [];
try {
for (let i = 0; i < pageUrls.length; i += 1) {
if (context.shouldCancel?.()) break;
const pageUrl = pageUrls[i];
context.onProgress?.({
phase: "list",
termsProcessed: i,
termsTotal: pageUrls.length,
currentUrl: pageUrl,
detail: `Career pages: resolving ATS for ${pageUrl}`,
});
const html = await fetchCareersPage(pageUrl);
const targets = detectAtsTargets(html, pageUrl);
if (targets.length === 0) continue;
for (const target of targets) {
if (context.shouldCancel?.()) break;
const jobs = await fetchJobsForTarget(target);
for (const job of jobs) {
if (
terms.length > 0 &&
!terms.some((term) => matchesTerm(job, term))
) {
continue;
}
const key = job.jobUrl;
if (seen.has(key)) continue;
seen.add(key);
out.push(job);
}
}
}
return { success: true, jobs: out };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, jobs: out, error: message };
}
},
};
export default manifest;

View File

@ -0,0 +1,17 @@
{
"name": "careerspages-extractor",
"version": "0.0.1",
"type": "module",
"description": "Direct company career page ATS resolver extractor",
"main": "manifest.ts",
"dependencies": {
"job-ops-shared": "^1.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "~5.9.0"
},
"scripts": {
"check:types": "tsc --noEmit"
}
}

View File

@ -0,0 +1,345 @@
import type { CreateJobInput } from "@shared/types/jobs";
export type AtsKind =
| "ashby"
| "greenhouse"
| "lever"
| "teamtailor"
| "huntflow"
| "factorial";
export interface DetectedAts {
kind: AtsKind;
slug: string;
}
export function readUrlList(raw: string | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
}
} catch {
// fall through
}
return raw
.split(/[\n,;|]+/)
.map((entry) => entry.trim())
.filter(Boolean);
}
export function detectAtsTargets(html: string, pageUrl: string): DetectedAts[] {
const found: DetectedAts[] = [];
const seen = new Set<string>();
const add = (kind: AtsKind, slug: string) => {
const key = `${kind}:${slug.toLowerCase()}`;
if (!slug || seen.has(key)) return;
seen.add(key);
found.push({ kind, slug });
};
for (const match of html.matchAll(/jobs\.ashbyhq\.com\/([a-zA-Z0-9_-]+)/g)) {
add("ashby", match[1]);
}
for (const match of html.matchAll(
/boards-api\.greenhouse\.io\/v1\/boards\/([a-zA-Z0-9_-]+)/g,
)) {
add("greenhouse", match[1]);
}
for (const match of html.matchAll(
/boards\.greenhouse\.io\/([a-zA-Z0-9_-]+)/g,
)) {
add("greenhouse", match[1]);
}
for (const match of html.matchAll(/jobs\.lever\.co\/([a-zA-Z0-9_-]+)/g)) {
add("lever", match[1]);
}
for (const match of html.matchAll(
/https?:\/\/([a-z0-9-]+)\.teamtailor\.com/gi,
)) {
add("teamtailor", match[1]);
}
for (const match of html.matchAll(
/https?:\/\/([a-z0-9-]+)\.huntflow\.io/gi,
)) {
add("huntflow", match[1]);
}
for (const match of html.matchAll(
/https?:\/\/([a-z0-9-]+)\.factorialhr\.com/gi,
)) {
add("factorial", match[1]);
}
try {
const host = new URL(pageUrl).hostname.toLowerCase();
if (host.endsWith(".huntflow.io")) {
add("huntflow", host.replace(/\.huntflow\.io$/, ""));
}
if (host.endsWith(".factorialhr.com")) {
add("factorial", host.replace(/\.factorialhr\.com$/, ""));
}
if (host.endsWith(".teamtailor.com")) {
add("teamtailor", host.replace(/\.teamtailor\.com$/, ""));
}
} catch {
// ignore invalid pageUrl
}
return found;
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function stripHtml(html: string): string {
return decodeHtmlEntities(html)
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
async function fetchAshby(slug: string): Promise<CreateJobInput[]> {
const url = `https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(slug)}`;
const response = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!response.ok) return [];
const body = (await response.json()) as {
jobs?: Array<Record<string, unknown>>;
};
return (body.jobs ?? []).flatMap((job) => {
const jobUrl = asString(job.jobUrl);
if (!jobUrl) return [];
return [
{
source: "careerspages" as const,
sourceJobId: asString(job.id),
title: asString(job.title) ?? "Unknown Title",
employer: slug,
jobUrl,
applicationLink: asString(job.applyUrl) ?? jobUrl,
location:
asString(job.location) ?? asString(job.locationName) ?? "Unknown",
isRemote: job.isRemote === true,
datePosted: asString(job.publishedAt),
jobDescription:
asString(job.descriptionPlain) ??
(job.descriptionHtml
? stripHtml(String(job.descriptionHtml))
: undefined),
},
];
});
}
async function fetchGreenhouse(slug: string): Promise<CreateJobInput[]> {
const url = `https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(slug)}/jobs?content=true`;
const response = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!response.ok) return [];
const body = (await response.json()) as {
jobs?: Array<Record<string, unknown>>;
};
return (body.jobs ?? []).flatMap((job) => {
const jobUrl = asString(job.absolute_url);
if (!jobUrl) return [];
const location = job.location as { name?: string } | undefined;
return [
{
source: "careerspages" as const,
sourceJobId: String(job.id ?? jobUrl),
title: asString(job.title) ?? "Unknown Title",
employer: slug,
jobUrl,
applicationLink: jobUrl,
location: asString(location?.name) ?? "Unknown",
datePosted: asString(job.updated_at),
jobDescription: job.content
? stripHtml(String(job.content))
: undefined,
},
];
});
}
async function fetchLever(slug: string): Promise<CreateJobInput[]> {
const url = `https://api.lever.co/v0/postings/${encodeURIComponent(slug)}?mode=json`;
const response = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!response.ok) return [];
const body = (await response.json()) as Array<Record<string, unknown>>;
if (!Array.isArray(body)) return [];
return body.flatMap((job) => {
const jobUrl = asString(job.hostedUrl) ?? asString(job.applyUrl);
if (!jobUrl) return [];
const categories = job.categories as Record<string, unknown> | undefined;
return [
{
source: "careerspages" as const,
sourceJobId: asString(job.id),
title: asString(job.text) ?? "Unknown Title",
employer: slug,
jobUrl,
applicationLink: asString(job.applyUrl) ?? jobUrl,
location: asString(categories?.location) ?? "Unknown",
datePosted: asString(job.createdAt),
jobDescription: job.descriptionPlain
? String(job.descriptionPlain)
: job.description
? stripHtml(String(job.description))
: undefined,
},
];
});
}
function xmlText(xml: string, tag: string): string | undefined {
const pattern = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i");
const match = xml.match(pattern);
if (!match?.[1]) return undefined;
return (
match[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").trim() || undefined
);
}
async function fetchTeamtailor(slug: string): Promise<CreateJobInput[]> {
const url = `https://${encodeURIComponent(slug)}.teamtailor.com/jobs.rss?per_page=200`;
const response = await fetch(url, {
headers: { Accept: "application/rss+xml, application/xml, text/xml" },
});
if (!response.ok) return [];
const xml = await response.text();
const out: CreateJobInput[] = [];
for (const raw of xml.match(/<item>([\s\S]*?)<\/item>/gi) ?? []) {
const block = raw.replace(/^<item>/i, "").replace(/<\/item>$/i, "");
const jobUrl = xmlText(block, "link");
if (!jobUrl) continue;
out.push({
source: "careerspages",
sourceJobId: xmlText(block, "guid") ?? jobUrl,
title: xmlText(block, "title") ?? "Unknown Title",
employer: xmlText(block, "company_name") ?? slug,
jobUrl,
applicationLink: jobUrl,
location: xmlText(block, "location") ?? "Unknown",
datePosted: xmlText(block, "pubDate"),
jobDescription: xmlText(block, "description")
? stripHtml(xmlText(block, "description") ?? "")
: undefined,
});
}
return out;
}
async function fetchHuntflow(slug: string): Promise<CreateJobInput[]> {
const origin = `https://${slug}.huntflow.io`;
const response = await fetch(`${origin}/`, {
headers: { Accept: "text/html", "User-Agent": "JobOps/1.0" },
});
if (!response.ok) return [];
const html = await response.text();
const out: CreateJobInput[] = [];
const pattern =
/<article class="_item_[^"]*">[\s\S]*?<a href="(\/vacancy\/[^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<div class="_info_[^"]*">([^<]*)<\/div>/g;
for (const match of html.matchAll(pattern)) {
const jobUrl = `${origin}${match[1]}`;
out.push({
source: "careerspages",
sourceJobId: match[1]?.replace(/^\/vacancy\//, ""),
title: decodeHtmlEntities(match[2]?.trim() ?? "Unknown Title"),
employer: slug,
jobUrl,
applicationLink: jobUrl,
location: match[3]?.trim() || "Unknown",
isRemote: match[3]?.toLowerCase().includes("remote"),
});
}
return out;
}
async function fetchFactorial(slug: string): Promise<CreateJobInput[]> {
const origin = `https://${slug}.factorialhr.com`;
const response = await fetch(origin, {
headers: { Accept: "text/html", "User-Agent": "JobOps/1.0" },
});
if (!response.ok) return [];
const html = await response.text();
const out: CreateJobInput[] = [];
for (const match of html.matchAll(
/(?:href|data-job-postings-url)=['"]([^'"]*\/job_posting\/[^'"]+)['"]/g,
)) {
const raw = match[1];
const path = raw.startsWith("http")
? raw.replace(origin, "")
: raw.startsWith("/")
? raw
: `/${raw}`;
const jobUrl = `${origin}${path}`;
const slugPart = path.split("/").pop() ?? "";
const title = slugPart
.replace(/-\d+$/, "")
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
out.push({
source: "careerspages",
sourceJobId: slugPart,
title,
employer: slug,
jobUrl,
applicationLink: jobUrl,
location: "Unknown",
});
}
return out;
}
export async function fetchJobsForTarget(
target: DetectedAts,
): Promise<CreateJobInput[]> {
switch (target.kind) {
case "ashby":
return fetchAshby(target.slug);
case "greenhouse":
return fetchGreenhouse(target.slug);
case "lever":
return fetchLever(target.slug);
case "teamtailor":
return fetchTeamtailor(target.slug);
case "huntflow":
return fetchHuntflow(target.slug);
case "factorial":
return fetchFactorial(target.slug);
}
}
export async function fetchCareersPage(pageUrl: string): Promise<string> {
const response = await fetch(pageUrl, {
headers: { Accept: "text/html", "User-Agent": "JobOps/1.0" },
redirect: "follow",
});
if (!response.ok) {
throw new Error(
`Careers page request failed (${response.status}) for ${pageUrl}`,
);
}
return response.text();
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"outDir": "dist",
"strict": true,
"noUnusedLocals": false,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"@shared/*": ["../../shared/src/*"]
}
},
"include": ["./manifest.ts", "./src/**/*"]
}

View File

@ -0,0 +1,216 @@
/**
* Factorial HR public career sites tenant HTML job_posting pages.
*
* https://{tenant}.factorialhr.com/
*/
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import type { CreateJobInput } from "@shared/types/jobs";
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readTenants(raw: string | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
}
} catch {
// fall through
}
return raw
.split(/[\n,;|]+/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function stripHtml(html: string): string {
return decodeHtmlEntities(html)
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function parseJobLinks(html: string, origin: string): string[] {
const links = new Set<string>();
for (const match of html.matchAll(
/(?:href|data-job-postings-url)=['"]([^'"]*\/job_posting\/[^'"]+)['"]/g,
)) {
const raw = match[1];
const path = raw.startsWith("http")
? raw.replace(origin, "")
: raw.startsWith("/")
? raw
: `/${raw}`;
links.add(path);
}
return [...links];
}
function parseJobDetail(html: string): {
title?: string;
description?: string;
} {
const titleMatch =
html.match(/property='og:title'[^>]*content='([^']+)'/) ??
html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const bodyMatch = html.match(
/<div class='mb-12'>[\s\S]*?<div class='mb-2 sm:mb-4'>[\s\S]*?<\/h1>([\s\S]*?)<\/div>\s*<\/div>/,
);
return {
title: titleMatch?.[1]?.trim(),
description: bodyMatch?.[1] ? stripHtml(bodyMatch[1]) : undefined,
};
}
function matchesTerm(values: string[], term: string): boolean {
const lower = term.toLowerCase();
return values.some((value) => value.toLowerCase().includes(lower));
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, {
headers: { Accept: "text/html", "User-Agent": "JobOps/1.0" },
});
if (!response.ok) {
throw new Error(`Factorial request failed (${response.status}) for ${url}`);
}
return response.text();
}
function tenantOrigin(tenant: string): string {
const host = tenant.includes(".") ? tenant : `${tenant}.factorialhr.com`;
return host.startsWith("http") ? host : `https://${host}`;
}
function employerLabel(tenant: string, pageHtml: string): string {
const ogSiteMatch = pageHtml.match(
/property=['"]og:site_name['"][^>]*content=['"]([^'"]+)['"]/i,
);
if (ogSiteMatch?.[1]) {
const cleaned = ogSiteMatch[1].split(/\s[-|]\s/)[0]?.trim();
if (cleaned) return cleaned;
}
return tenant
.split(".")[0]
.split(/[-_]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export const manifest: ExtractorManifest = {
id: "factorial",
displayName: "Factorial (ATS)",
providesSources: ["factorial"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
const tenants = readTenants(context.settings.factorialTenants);
if (tenants.length === 0) {
return {
success: true,
jobs: [],
error:
"No Factorial tenants configured. Set FACTORIAL_TENANTS or factorialTenants (comma- or newline-separated subdomains, e.g. yourbourse).",
};
}
const terms = context.searchTerms.length > 0 ? context.searchTerms : [];
const seen = new Set<string>();
const out: CreateJobInput[] = [];
try {
for (let i = 0; i < tenants.length; i += 1) {
if (context.shouldCancel?.()) break;
const tenant = tenants[i];
const origin = tenantOrigin(tenant);
context.onProgress?.({
phase: "list",
termsProcessed: i,
termsTotal: tenants.length,
currentUrl: origin,
detail: `Factorial: ${tenant} (${i + 1}/${tenants.length})`,
});
const indexHtml = await fetchText(origin);
const employer = employerLabel(tenant, indexHtml);
const links = parseJobLinks(indexHtml, origin);
for (const link of links) {
if (context.shouldCancel?.()) break;
const jobUrl = `${origin}${link}`;
let title =
link.split("/").pop()?.replace(/-\d+$/, "").replace(/-/g, " ") ??
"Unknown Title";
title = title
.split(" ")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
let jobDescription: string | undefined;
try {
const detailHtml = await fetchText(jobUrl);
const detail = parseJobDetail(detailHtml);
if (detail.title) title = detail.title.replace(/\s+$/, "");
if (detail.description) jobDescription = detail.description;
} catch {
// keep index-derived row when detail fetch fails
}
const haystack = [title, jobDescription ?? ""];
if (
terms.length > 0 &&
!terms.some((term) => matchesTerm(haystack, term))
) {
continue;
}
const key = jobUrl;
if (seen.has(key)) continue;
seen.add(key);
out.push({
source: "factorial",
sourceJobId: link.split("/").pop(),
title,
employer,
jobUrl,
applicationLink: jobUrl,
location: "Unknown",
jobDescription,
});
}
}
return { success: true, jobs: out };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, jobs: out, error: message };
}
},
};
export default manifest;

View File

@ -0,0 +1,17 @@
{
"name": "factorial-extractor",
"version": "0.0.1",
"type": "module",
"description": "Factorial HR public career-site extractor",
"main": "manifest.ts",
"dependencies": {
"job-ops-shared": "^1.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "~5.9.0"
},
"scripts": {
"check:types": "tsc --noEmit"
}
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"outDir": "dist",
"strict": true,
"noUnusedLocals": false,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"@shared/*": ["../../shared/src/*"]
}
},
"include": ["./manifest.ts", "./src/**/*"]
}

View File

@ -111,12 +111,6 @@ function parseWorkplaceTypes(
}
}
function encodeSearchState(searchState: unknown): string {
const json = JSON.stringify(searchState);
const urlEncodedJson = encodeURIComponent(json);
return Buffer.from(urlEncodedJson, "utf-8").toString("base64");
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@ -230,7 +224,18 @@ function extractResultsBatch(payload: unknown): RawHiringCafeJob[] {
}
const payloadRecord = asRecord(payload);
const results = payloadRecord?.results;
if (!payloadRecord) return [];
const pageProps = asRecord(payloadRecord.pageProps);
const ssrHits = pageProps?.ssrHits ?? payloadRecord.ssrHits;
if (Array.isArray(ssrHits)) {
return ssrHits.filter(
(item): item is RawHiringCafeJob =>
Boolean(item) && typeof item === "object" && !Array.isArray(item),
);
}
const results = payloadRecord.results;
if (!Array.isArray(results)) return [];
return results.filter(
@ -242,7 +247,22 @@ function extractResultsBatch(payload: unknown): RawHiringCafeJob[] {
function parseTotalCount(payload: unknown): number | null {
const payloadRecord = asRecord(payload);
if (!payloadRecord) return null;
return toNumberOrNull(payloadRecord.total);
const pageProps = asRecord(payloadRecord.pageProps);
return (
toNumberOrNull(pageProps?.ssrTotalCount) ??
toNumberOrNull(payloadRecord.ssrTotalCount) ??
toNumberOrNull(payloadRecord.total)
);
}
function isLastSsrPage(payload: unknown): boolean {
const payloadRecord = asRecord(payload);
if (!payloadRecord) return true;
const pageProps = asRecord(payloadRecord.pageProps);
const isLastPage = pageProps?.ssrIsLastPage ?? payloadRecord.ssrIsLastPage;
return isLastPage === true;
}
function buildCityLocationId(input: string): string {
@ -501,23 +521,37 @@ function createCitySearchState(args: {
};
}
async function callHiringCafeApi(
async function readBuildIdFromPage(page: Page): Promise<string> {
const buildId = await page.evaluate(() => {
const element = document.querySelector("#__NEXT_DATA__");
if (!element?.textContent) return null;
const data = JSON.parse(element.textContent) as { buildId?: string };
return data.buildId ?? null;
});
if (!buildId) {
throw new Error("Hiring Cafe page did not expose Next.js buildId");
}
return buildId;
}
async function fetchSsrSearchPage(
page: Page,
endpoint: string,
params: Record<string, string>,
buildId: string,
searchState: Record<string, unknown>,
pageNo: number,
): Promise<unknown> {
const response = await page.evaluate(
async ({ endpointArg, paramsArg }) => {
const url = new URL(endpointArg, window.location.origin);
for (const [key, value] of Object.entries(paramsArg)) {
url.searchParams.set(key, value);
}
async ({ buildIdArg, searchStateArg, pageNoArg }) => {
const params = new URLSearchParams();
params.set("searchState", JSON.stringify(searchStateArg));
params.set("page", String(pageNoArg));
const url = `/_next/data/${buildIdArg}/index.json?${params.toString()}`;
const res = await fetch(url.toString(), {
method: "GET",
const res = await fetch(url, {
credentials: "include",
headers: {
Accept: "application/json, text/plain, */*",
Accept: "application/json",
"x-nextjs-data": "1",
},
});
@ -539,7 +573,7 @@ async function callHiringCafeApi(
return output;
},
{ endpointArg: endpoint, paramsArg: params },
{ buildIdArg: buildId, searchStateArg: searchState, pageNoArg: pageNo },
);
const result = response as BrowserApiResponse;
@ -547,14 +581,14 @@ async function callHiringCafeApi(
if (!result.ok) {
const snippet = result.responseText.slice(0, 250);
throw new Error(
`Hiring Cafe API ${endpoint} failed (${result.status} ${result.statusText}): ${snippet}`,
`Hiring Cafe SSR search failed (${result.status} ${result.statusText}): ${snippet}`,
);
}
if (result.data === null) {
const snippet = result.responseText.slice(0, 250);
throw new Error(
`Hiring Cafe API ${endpoint} returned non-JSON response: ${snippet}`,
`Hiring Cafe SSR search returned non-JSON response: ${snippet}`,
);
}
@ -626,6 +660,8 @@ async function run(): Promise<void> {
await initializePage();
}
const buildId = await readBuildIdFromPage(page);
const countryLocation = resolveHiringCafeCountryLocation(country);
const countryLong =
countryLocation?.address_components[0]?.long_name ?? "United Kingdom";
@ -664,40 +700,23 @@ async function run(): Promise<void> {
dateFetchedPastNDays,
workplaceTypes,
});
const encodedSearchState = encodeSearchState(searchState);
let totalAvailable: number | null = null;
try {
const countPayload = await callHiringCafeApi(
page,
"/api/search-jobs/get-total-count",
{
s: encodedSearchState,
},
);
totalAvailable = parseTotalCount(countPayload);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`Hiring Cafe count request failed for term '${searchTerm}': ${message}`,
);
}
const termTarget =
totalAvailable !== null
? Math.min(maxJobsPerTerm, totalAvailable)
: maxJobsPerTerm;
let termTarget = maxJobsPerTerm;
let pageNo = 0;
let termCollected = 0;
while (termCollected < termTarget && pageNo < PAGE_LIMIT) {
const size = Math.min(1000, termTarget - termCollected);
const jobsPayload = await callHiringCafeApi(page, "/api/search-jobs", {
size: String(size),
page: String(pageNo),
s: encodedSearchState,
});
const jobsPayload = await fetchSsrSearchPage(
page,
buildId,
searchState,
pageNo,
);
const totalAvailable = parseTotalCount(jobsPayload);
if (totalAvailable !== null) {
termTarget = Math.min(maxJobsPerTerm, totalAvailable);
}
const batch = extractResultsBatch(jobsPayload);
if (batch.length === 0) break;
@ -727,7 +746,7 @@ async function run(): Promise<void> {
totalCollected: termCollected,
});
if (batch.length < size) break;
if (isLastSsrPage(jobsPayload)) break;
pageNo += 1;
}

View File

@ -0,0 +1,221 @@
/**
* Huntflow public career sites SSR HTML listing + optional vacancy detail pages.
*
* https://{tenant}.huntflow.io/
*/
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import type { CreateJobInput } from "@shared/types/jobs";
interface HuntflowListing {
slug: string;
title: string;
location?: string;
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readTenants(raw: string | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
}
} catch {
// fall through
}
return raw
.split(/[\n,;|]+/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function stripHtml(html: string): string {
return decodeHtmlEntities(html)
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function parseListings(html: string): HuntflowListing[] {
const listings: HuntflowListing[] = [];
const pattern =
/<article class="_item_[^"]*">[\s\S]*?<a href="(\/vacancy\/[^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<div class="_info_[^"]*">([^<]*)<\/div>/g;
for (const match of html.matchAll(pattern)) {
const slug = match[1]?.replace(/^\/vacancy\//, "").replace(/\/$/, "");
const title = match[2]?.trim();
if (!slug || !title) continue;
listings.push({
slug,
title: decodeHtmlEntities(title),
location: match[3]?.trim() || undefined,
});
}
return listings;
}
function parseVacancyDetail(html: string): {
description?: string;
location?: string;
} {
const positionMatch = html.match(/<h1 class="_position_[^"]*">([^<]+)<\/h1>/);
const infoMatch = html.match(
/<div class="_infoWrapper_[^"]*">[\s\S]*?<div>([^<]+)<\/div>/,
);
const sections: string[] = [];
for (const match of html.matchAll(
/<div class="_content_1phzm_2">[\s\S]*?<!--\[-->([\s\S]*?)<!--\]-->/g,
)) {
const text = stripHtml(match[1] ?? "");
if (text) sections.push(text);
}
return {
location: infoMatch?.[1]?.trim() || undefined,
description:
sections.length > 0
? sections.join("\n\n")
: positionMatch?.[1]?.trim() || undefined,
};
}
function matchesTerm(values: string[], term: string): boolean {
const lower = term.toLowerCase();
return values.some((value) => value.toLowerCase().includes(lower));
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, {
headers: { Accept: "text/html", "User-Agent": "JobOps/1.0" },
});
if (!response.ok) {
throw new Error(`Huntflow request failed (${response.status}) for ${url}`);
}
return response.text();
}
function tenantOrigin(tenant: string): string {
const host = tenant.includes(".") ? tenant : `${tenant}.huntflow.io`;
return host.startsWith("http") ? host : `https://${host}`;
}
export const manifest: ExtractorManifest = {
id: "huntflow",
displayName: "Huntflow (ATS)",
providesSources: ["huntflow"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
const tenants = readTenants(context.settings.huntflowTenants);
if (tenants.length === 0) {
return {
success: true,
jobs: [],
error:
"No Huntflow tenants configured. Set HUNTFLOW_TENANTS or huntflowTenants (comma- or newline-separated subdomains, e.g. apicworld).",
};
}
const terms = context.searchTerms.length > 0 ? context.searchTerms : [];
const enrichDetails = true;
const seen = new Set<string>();
const out: CreateJobInput[] = [];
try {
for (let i = 0; i < tenants.length; i += 1) {
if (context.shouldCancel?.()) break;
const tenant = tenants[i];
const origin = tenantOrigin(tenant);
context.onProgress?.({
phase: "list",
termsProcessed: i,
termsTotal: tenants.length,
currentUrl: origin,
detail: `Huntflow: ${tenant} (${i + 1}/${tenants.length})`,
});
const indexHtml = await fetchText(`${origin}/`);
const employer = tenant
.split(".")[0]
.split(/[-_]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
for (const listing of parseListings(indexHtml)) {
if (context.shouldCancel?.()) break;
const haystack = [listing.title, listing.location ?? ""];
if (
terms.length > 0 &&
!terms.some((term) => matchesTerm(haystack, term))
) {
continue;
}
const jobUrl = `${origin}/vacancy/${listing.slug}`;
let location = listing.location ?? "Unknown";
let jobDescription: string | undefined;
let isRemote = location.toLowerCase().includes("remote");
if (enrichDetails) {
try {
const detailHtml = await fetchText(jobUrl);
const detail = parseVacancyDetail(detailHtml);
if (detail.location) location = detail.location;
if (detail.description) jobDescription = detail.description;
isRemote = location.toLowerCase().includes("remote");
} catch {
// keep listing row when detail fetch fails
}
}
const key = jobUrl;
if (seen.has(key)) continue;
seen.add(key);
out.push({
source: "huntflow",
sourceJobId: listing.slug,
title: listing.title,
employer,
jobUrl,
applicationLink: jobUrl,
location,
isRemote,
jobDescription,
});
}
}
return { success: true, jobs: out };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, jobs: out, error: message };
}
},
};
export default manifest;

View File

@ -0,0 +1,17 @@
{
"name": "huntflow-extractor",
"version": "0.0.1",
"type": "module",
"description": "Huntflow public career-site extractor",
"main": "manifest.ts",
"dependencies": {
"job-ops-shared": "^1.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "~5.9.0"
},
"scripts": {
"check:types": "tsc --noEmit"
}
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"outDir": "dist",
"strict": true,
"noUnusedLocals": false,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"@shared/*": ["../../shared/src/*"]
}
},
"include": ["./manifest.ts", "./src/**/*"]
}

View File

@ -6,11 +6,11 @@ import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { resolveSearchCities } from "@shared/search-cities.js";
import type { CreateJobInput, JobSource } from "@shared/types/jobs";
import { normalizeIsRemote } from "@shared/work-arrangement.js";
import {
toNumberOrNull,
toStringOrNull,
} from "@shared/utils/type-conversion.js";
import { normalizeIsRemote } from "@shared/work-arrangement.js";
const srcDir = dirname(fileURLToPath(import.meta.url));
const EXTRACTOR_DIR = join(srcDir, "..");

View File

@ -0,0 +1,216 @@
/**
* Teamtailor public career sites RSS feed per tenant.
*
* https://{company}.teamtailor.com/jobs.rss
*/
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import type { CreateJobInput } from "@shared/types/jobs";
interface TeamtailorItem {
title?: string;
link?: string;
guid?: string;
description?: string;
pubDate?: string;
remoteStatus?: string;
companyName?: string;
location?: string;
department?: string;
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readCompanies(raw: string | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
}
} catch {
// fall through
}
return raw
.split(/[\n,;|]+/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function xmlText(xml: string, tag: string): string | undefined {
const pattern = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i");
const match = xml.match(pattern);
if (!match?.[1]) return undefined;
return (
match[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").trim() || undefined
);
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function stripHtml(html: string): string {
return decodeHtmlEntities(html)
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function parseItems(xml: string): TeamtailorItem[] {
const items: TeamtailorItem[] = [];
const blocks = xml.match(/<item>([\s\S]*?)<\/item>/gi) ?? [];
for (const raw of blocks) {
const block = raw.replace(/^<item>/i, "").replace(/<\/item>$/i, "");
items.push({
title: xmlText(block, "title"),
link: xmlText(block, "link"),
guid: xmlText(block, "guid"),
description: xmlText(block, "description"),
pubDate: xmlText(block, "pubDate"),
remoteStatus: xmlText(block, "remoteStatus"),
companyName: xmlText(block, "company_name"),
location: xmlText(block, "location"),
department: xmlText(block, "department"),
});
}
return items;
}
function matchesTerm(item: TeamtailorItem, term: string): boolean {
const lower = term.toLowerCase();
if (item.title?.toLowerCase().includes(lower)) return true;
if (item.department?.toLowerCase().includes(lower)) return true;
const description = item.description ? stripHtml(item.description) : "";
if (description.toLowerCase().includes(lower)) return true;
return false;
}
function mapJob(
item: TeamtailorItem,
companySlug: string,
): CreateJobInput | null {
const jobUrl = asString(item.link);
if (!jobUrl) return null;
const employer =
asString(item.companyName) ??
companySlug
.split(/[-_]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
const remoteStatus = asString(item.remoteStatus)?.toLowerCase();
const isRemote =
remoteStatus === "fully" ||
remoteStatus === "hybrid" ||
item.location?.toLowerCase().includes("remote") === true;
return {
source: "teamtailor",
sourceJobId: item.guid ?? jobUrl,
title: asString(item.title) ?? "Unknown Title",
employer,
jobUrl,
applicationLink: jobUrl,
location: asString(item.location) ?? "Unknown",
isRemote,
datePosted: asString(item.pubDate),
jobDescription: item.description ? stripHtml(item.description) : undefined,
jobFunction: asString(item.department),
};
}
async function fetchCompanyFeed(company: string): Promise<TeamtailorItem[]> {
const url = `https://${encodeURIComponent(company)}.teamtailor.com/jobs.rss?per_page=200`;
const response = await fetch(url, {
headers: {
Accept: "application/rss+xml, application/xml, text/xml",
"User-Agent": "JobOps/1.0",
},
});
if (response.status === 404) return [];
if (!response.ok) {
throw new Error(
`Teamtailor RSS for "${company}" failed with status ${response.status}`,
);
}
return parseItems(await response.text());
}
export const manifest: ExtractorManifest = {
id: "teamtailor",
displayName: "Teamtailor (ATS)",
providesSources: ["teamtailor"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
const companies = readCompanies(context.settings.teamtailorCompanies);
if (companies.length === 0) {
return {
success: true,
jobs: [],
error:
"No Teamtailor companies configured. Set TEAMTAILOR_COMPANIES or teamtailorCompanies (comma- or newline-separated subdomains).",
};
}
const terms = context.searchTerms.length > 0 ? context.searchTerms : [];
const seen = new Set<string>();
const out: CreateJobInput[] = [];
try {
for (let i = 0; i < companies.length; i += 1) {
if (context.shouldCancel?.()) break;
const company = companies[i];
context.onProgress?.({
phase: "list",
termsProcessed: i,
termsTotal: companies.length,
currentUrl: company,
detail: `Teamtailor: ${company} (${i + 1}/${companies.length})`,
});
const items = await fetchCompanyFeed(company);
for (const item of items) {
if (
terms.length > 0 &&
!terms.some((term) => matchesTerm(item, term))
) {
continue;
}
const mapped = mapJob(item, company);
if (!mapped) continue;
const key = mapped.sourceJobId || mapped.jobUrl;
if (seen.has(key)) continue;
seen.add(key);
out.push(mapped);
}
}
return { success: true, jobs: out };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, jobs: out, error: message };
}
},
};
export default manifest;

View File

@ -0,0 +1,17 @@
{
"name": "teamtailor-extractor",
"version": "0.0.1",
"type": "module",
"description": "Teamtailor public RSS career-site extractor",
"main": "manifest.ts",
"dependencies": {
"job-ops-shared": "^1.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "~5.9.0"
},
"scripts": {
"check:types": "tsc --noEmit"
}
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"outDir": "dist",
"strict": true,
"noUnusedLocals": false,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"@shared/*": ["../../shared/src/*"]
}
},
"include": ["./manifest.ts", "./src/**/*"]
}

View File

@ -0,0 +1,259 @@
/**
* TestDevJobs QA-focused board (Gridsome SSR HTML + embedded job state).
*
* https://testdevjobs.com/software-testing-jobs/
*/
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import type { CreateJobInput } from "@shared/types/jobs";
const ORIGIN = "https://testdevjobs.com";
const LIST_PATH = "/software-testing-jobs/";
interface ListingJob {
path: string;
title: string;
employer: string;
datePosted?: string;
location?: string;
jobType?: string;
}
interface TestDevJobState {
id?: string;
jobTitle?: string;
path?: string;
jobDescription?: string;
joblocation?: string;
jobType?: string;
salary?: string;
isRemote?: boolean;
applyLink?: string;
companyName?: string;
jobPosted?: string;
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function stripHtml(html: string): string {
return decodeHtmlEntities(html)
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function readMaxPages(raw: string | undefined): number {
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
if (!Number.isFinite(parsed)) return 3;
return Math.min(Math.max(parsed, 1), 20);
}
function parseListingJobs(html: string): ListingJob[] {
const jobs: ListingJob[] = [];
const blocks = html.match(
/<div class="job-tile-wrapper[\s\S]*?<\/div>\s*<\/div>\s*<\/div>/g,
);
for (const block of blocks ?? []) {
const pathMatch = block.match(/href="(\/job\/[^"]+)"/);
const titleMatch = block.match(/class="ml-5 jobtitle[^"]*"[^>]*>([^<]+)</);
const employerMatch = block.match(
/class="mb-0 comptitle[^"]*"[^>]*>([^<]+)</,
);
const dateMatch = block.match(/itemprop="datePosted"[^>]*>([^<]+)</);
const locationMatch = block.match(
/itemprop="addressLocality"[^>]*>([^<]+)</,
);
const typeMatch = block.match(/itemprop="employmentType"[^>]*>([^<]+)</);
const path = pathMatch?.[1];
const title = titleMatch?.[1]?.trim();
if (!path || !title) continue;
jobs.push({
path,
title: decodeHtmlEntities(title),
employer: decodeHtmlEntities(
employerMatch?.[1]?.trim() ?? "Unknown Employer",
),
datePosted: dateMatch?.[1]?.trim(),
location: locationMatch?.[1]?.trim(),
jobType: typeMatch?.[1]?.trim(),
});
}
return jobs;
}
function parseInitialState(html: string): TestDevJobState | null {
const match = html.match(/window\.__INITIAL_STATE__=({[\s\S]*?});/);
if (!match?.[1]) return null;
try {
const parsed = JSON.parse(match[1]) as {
data?: { job?: TestDevJobState };
};
return parsed.data?.job ?? null;
} catch {
return null;
}
}
function matchesTerm(values: string[], term: string): boolean {
const lower = term.toLowerCase();
return values.some((value) => value.toLowerCase().includes(lower));
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, {
headers: { Accept: "text/html", "User-Agent": "JobOps/1.0" },
});
if (!response.ok) {
throw new Error(
`TestDevJobs request failed (${response.status}) for ${url}`,
);
}
return response.text();
}
function listingUrl(page: number): string {
if (page <= 1) return `${ORIGIN}${LIST_PATH}`;
return `${ORIGIN}${LIST_PATH}${page}/`;
}
export const manifest: ExtractorManifest = {
id: "testdevjobs",
displayName: "TestDevJobs",
providesSources: ["testdevjobs"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
const maxJobs = context.settings.testdevjobsMaxJobsPerTerm
? Number.parseInt(context.settings.testdevjobsMaxJobsPerTerm, 10)
: 100;
const cap = Number.isFinite(maxJobs)
? Math.min(Math.max(maxJobs, 1), 500)
: 100;
const maxPages = readMaxPages(context.settings.testdevjobsMaxPages);
const terms = context.searchTerms.length > 0 ? context.searchTerms : [];
const enrichDetails = true;
const seen = new Set<string>();
const out: CreateJobInput[] = [];
try {
for (let page = 1; page <= maxPages; page += 1) {
if (context.shouldCancel?.()) break;
if (out.length >= cap) break;
const url = listingUrl(page);
context.onProgress?.({
phase: "list",
termsProcessed: page - 1,
termsTotal: maxPages,
currentUrl: url,
detail: `TestDevJobs: listing page ${page}/${maxPages}`,
});
const html = await fetchText(url);
const listings = parseListingJobs(html);
if (listings.length === 0) break;
for (const listing of listings) {
if (context.shouldCancel?.()) break;
if (out.length >= cap) break;
const haystack = [
listing.title,
listing.employer,
listing.location ?? "",
listing.jobType ?? "",
];
if (
terms.length > 0 &&
!terms.some((term) => matchesTerm(haystack, term))
) {
continue;
}
let jobUrl = `${ORIGIN}${listing.path}`;
let applicationLink = jobUrl;
let jobDescription: string | undefined;
let location = listing.location;
let jobType = listing.jobType;
let datePosted = listing.datePosted;
let isRemote = listing.location?.toLowerCase().includes("remote");
if (enrichDetails) {
try {
const detailHtml = await fetchText(jobUrl);
const state = parseInitialState(detailHtml);
if (state) {
if (state.path) jobUrl = `${ORIGIN}${state.path}`;
if (state.applyLink) applicationLink = state.applyLink;
if (state.jobDescription) jobDescription = state.jobDescription;
if (state.joblocation) location = state.joblocation;
if (state.jobType) jobType = state.jobType;
if (state.jobPosted) datePosted = state.jobPosted;
if (typeof state.isRemote === "boolean")
isRemote = state.isRemote;
}
} catch {
// keep listing row when detail fetch fails
}
}
const key = listing.path;
if (seen.has(key)) continue;
seen.add(key);
out.push({
source: "testdevjobs",
sourceJobId: key.split("/").filter(Boolean).pop(),
title: listing.title,
employer: listing.employer,
jobUrl,
applicationLink,
location: location ?? "Unknown",
isRemote,
datePosted,
jobDescription,
jobType,
});
}
}
context.onProgress?.({
phase: "list",
termsProcessed: maxPages,
termsTotal: maxPages,
currentUrl: `${ORIGIN}${LIST_PATH}`,
jobPagesProcessed: out.length,
detail: `TestDevJobs: ${out.length} jobs`,
});
return { success: true, jobs: out };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, jobs: out, error: message };
}
},
};
export default manifest;

View File

@ -0,0 +1,17 @@
{
"name": "testdevjobs-extractor",
"version": "0.0.1",
"type": "module",
"description": "TestDevJobs QA board extractor",
"main": "manifest.ts",
"dependencies": {
"job-ops-shared": "^1.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "~5.9.0"
},
"scripts": {
"check:types": "tsc --noEmit"
}
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"outDir": "dist",
"strict": true,
"noUnusedLocals": false,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"@shared/*": ["../../shared/src/*"]
}
},
"include": ["./manifest.ts", "./src/**/*"]
}

View File

@ -0,0 +1,49 @@
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import { runWellfound } from "./src/run.js";
export const manifest: ExtractorManifest = {
id: "wellfound",
displayName: "Wellfound",
providesSources: ["wellfound"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
const parsedMax = context.settings.wellfoundMaxJobsPerTerm
? Number.parseInt(context.settings.wellfoundMaxJobsPerTerm, 10)
: Number.NaN;
const maxJobs = Number.isFinite(parsedMax) ? Math.max(1, parsedMax) : 50;
context.onProgress?.({
phase: "list",
termsProcessed: 0,
termsTotal: 1,
currentUrl: "https://wellfound.com/jobs",
detail: "Wellfound: launching browser scrape",
});
const result = await runWellfound({
searchTerms: context.searchTerms,
maxJobs,
});
if (!result.success) {
return { success: false, jobs: [], error: result.error };
}
context.onProgress?.({
phase: "list",
termsProcessed: 1,
termsTotal: 1,
currentUrl: "https://wellfound.com/jobs",
jobPagesProcessed: result.jobs.length,
detail: `Wellfound: ${result.jobs.length} jobs`,
});
return { success: true, jobs: result.jobs };
},
};
export default manifest;

View File

@ -0,0 +1,22 @@
{
"name": "wellfound-extractor",
"version": "0.0.1",
"type": "module",
"description": "Wellfound startup job board extractor (browser-backed)",
"main": "manifest.ts",
"dependencies": {
"camoufox-js": "^0.8.0",
"job-ops-shared": "^1.0.0",
"playwright": "^1.57.0",
"tsx": "^4.4.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "~5.9.0"
},
"scripts": {
"start": "tsx src/main.ts",
"check:types": "tsc --noEmit",
"get-binaries": "camoufox-js fetch"
}
}

View File

@ -0,0 +1,171 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchOptions } from "camoufox-js";
import { firefox } from "playwright";
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUTPUT_PATH = join(__dirname, "../storage/jobs.json");
interface ScrapedJob {
title: string;
employer: string;
jobUrl: string;
location?: string;
isRemote?: boolean;
}
function slugifyTerm(term: string): string {
return term
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function parseTerms(raw: string | undefined): string[] {
if (!raw) return ["software-engineer"];
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return parsed.map((entry) => slugifyTerm(String(entry))).filter(Boolean);
}
} catch {
// fall through
}
return raw
.split(/[\n|,]+/)
.map((entry) => slugifyTerm(entry))
.filter(Boolean);
}
function buildSearchUrl(roleSlug: string): string {
return `https://wellfound.com/role/l/${encodeURIComponent(roleSlug)}/remote`;
}
async function scrapeRole(
roleSlug: string,
maxJobs: number,
): Promise<ScrapedJob[]> {
const browser = await firefox.launch(await launchOptions({ headless: true }));
const page = await browser.newPage();
try {
await page.goto(buildSearchUrl(roleSlug), {
waitUntil: "domcontentloaded",
timeout: 90_000,
});
await page.waitForTimeout(4_000);
const jobs = await page.evaluate(() => {
const results: Array<{
title: string;
employer: string;
jobUrl: string;
location?: string;
}> = [];
const scripts = Array.from(
document.querySelectorAll('script[type="application/ld+json"]'),
);
for (const script of scripts) {
try {
const data = JSON.parse(script.textContent ?? "null") as unknown;
const rows = Array.isArray(data) ? data : [data];
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const record = row as Record<string, unknown>;
if (record["@type"] !== "JobPosting") continue;
const title = String(record.title ?? "").trim();
const jobUrl = String(record.url ?? record.sameAs ?? "").trim();
const org = record.hiringOrganization as
| Record<string, unknown>
| undefined;
const employer = String(org?.name ?? "Unknown Employer").trim();
const location = String(
(record.jobLocation as Record<string, unknown> | undefined)
?.name ??
record.jobLocation ??
"",
).trim();
if (!title || !jobUrl) continue;
results.push({
title,
employer,
jobUrl,
location: location || undefined,
});
}
} catch {
// ignore malformed JSON-LD blocks
}
}
if (results.length === 0) {
for (const anchor of Array.from(
document.querySelectorAll('a[href*="/job/"], a[href*="/jobs/"]'),
)) {
const href = anchor.getAttribute("href") ?? "";
if (!href.includes("/job")) continue;
const jobUrl = href.startsWith("http")
? href
: `https://wellfound.com${href.startsWith("/") ? href : `/${href}`}`;
const title = (anchor.textContent ?? "").trim();
if (!title || title.length < 4) continue;
results.push({
title,
employer: "Unknown Employer",
jobUrl,
});
}
}
return results;
});
const seen = new Set<string>();
const out: ScrapedJob[] = [];
for (const job of jobs) {
if (out.length >= maxJobs) break;
if (seen.has(job.jobUrl)) continue;
seen.add(job.jobUrl);
out.push({
...job,
isRemote: true,
});
}
return out;
} finally {
await browser.close();
}
}
async function main(): Promise<void> {
const terms = parseTerms(process.env.WELLFOUND_SEARCH_TERMS);
const maxJobs = Number.parseInt(process.env.WELLFOUND_MAX_JOBS ?? "50", 10);
const cap = Number.isFinite(maxJobs)
? Math.max(1, Math.min(maxJobs, 200))
: 50;
const all: ScrapedJob[] = [];
const seen = new Set<string>();
for (const term of terms) {
const rows = await scrapeRole(term, cap);
for (const row of rows) {
if (all.length >= cap) break;
if (seen.has(row.jobUrl)) continue;
seen.add(row.jobUrl);
all.push(row);
}
}
await mkdir(dirname(OUTPUT_PATH), { recursive: true });
await writeFile(OUTPUT_PATH, JSON.stringify(all, null, 2), "utf-8");
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View File

@ -0,0 +1,139 @@
import { spawn, spawnSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { envForExtractorSubprocess } from "@shared/extractor-subprocess-env.js";
import type { CreateJobInput } from "@shared/types/jobs";
const srcDir = dirname(fileURLToPath(import.meta.url));
const EXTRACTOR_DIR = join(srcDir, "..");
const OUTPUT_PATH = join(EXTRACTOR_DIR, "storage/jobs.json");
const require = createRequire(import.meta.url);
const TSX_CLI_PATH = (() => {
try {
return require.resolve("tsx/dist/cli.mjs");
} catch {
return null;
}
})();
interface ScrapedJob {
title?: string;
employer?: string;
jobUrl?: string;
location?: string;
isRemote?: boolean;
}
export interface RunWellfoundOptions {
searchTerms?: string[];
maxJobs?: number;
}
export interface WellfoundResult {
success: boolean;
jobs: CreateJobInput[];
error?: string;
}
function canRunNpmCommand(): boolean {
const result = spawnSync("npm", ["--version"], { stdio: "ignore" });
return !result.error && result.status === 0;
}
function mapJob(row: ScrapedJob): CreateJobInput | null {
const jobUrl = row.jobUrl?.trim();
if (!jobUrl) return null;
return {
source: "wellfound",
sourceJobId: jobUrl.split("/").filter(Boolean).pop(),
title: row.title?.trim() || "Unknown Title",
employer: row.employer?.trim() || "Unknown Employer",
jobUrl,
applicationLink: jobUrl,
location: row.location?.trim() || "Remote",
isRemote: row.isRemote ?? true,
};
}
export async function runWellfound(
options: RunWellfoundOptions = {},
): Promise<WellfoundResult> {
const searchTerms =
options.searchTerms && options.searchTerms.length > 0
? options.searchTerms
: ["software engineer"];
const maxJobs = options.maxJobs ?? 50;
const useNpmCommand = canRunNpmCommand();
if (!TSX_CLI_PATH && !useNpmCommand) {
return {
success: false,
jobs: [],
error: "Unable to execute Wellfound extractor (npm/tsx unavailable)",
};
}
try {
await new Promise<void>((resolve, reject) => {
const extractorEnv = envForExtractorSubprocess({
...process.env,
WELLFOUND_SEARCH_TERMS: JSON.stringify(searchTerms),
WELLFOUND_MAX_JOBS: String(maxJobs),
});
const child = TSX_CLI_PATH
? spawn(process.execPath, [TSX_CLI_PATH, join(srcDir, "main.ts")], {
cwd: EXTRACTOR_DIR,
env: extractorEnv,
stdio: ["ignore", "pipe", "pipe"],
})
: spawn("npx", ["tsx", join(srcDir, "main.ts")], {
cwd: EXTRACTOR_DIR,
env: extractorEnv,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(
new Error(
stderr.trim() ||
`Wellfound browser scrape exited with code ${code}`,
),
);
});
});
const raw = await readFile(OUTPUT_PATH, "utf-8");
const parsed = JSON.parse(raw) as unknown;
const rows = Array.isArray(parsed) ? parsed : [];
const jobs: CreateJobInput[] = [];
const seen = new Set<string>();
for (const value of rows) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const mapped = mapJob(value as ScrapedJob);
if (!mapped) continue;
const key = mapped.jobUrl;
if (seen.has(key)) continue;
seen.add(key);
jobs.push(mapped);
}
return { success: true, jobs };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, jobs: [], error: message };
}
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"outDir": "dist",
"strict": true,
"noUnusedLocals": false,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"@shared/*": ["../../shared/src/*"]
}
},
"include": ["./manifest.ts", "./src/**/*"]
}

View File

@ -0,0 +1,149 @@
/**
* Working Nomads public JSON feed.
*
* https://www.workingnomads.com/api/exposed_jobs/
*/
import type {
ExtractorManifest,
ExtractorRunResult,
} from "@shared/types/extractors";
import type { CreateJobInput } from "@shared/types/jobs";
const API_URL = "https://www.workingnomads.com/api/exposed_jobs/";
interface WorkingNomadsJob {
url?: string;
title?: string;
description?: string;
company_name?: string;
category_name?: string;
tags?: string;
location?: string;
pub_date?: string;
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\s+/g, " ")
.trim();
}
function matchesTerm(job: WorkingNomadsJob, term: string): boolean {
const lower = term.toLowerCase();
if (job.title?.toLowerCase().includes(lower)) return true;
if (job.company_name?.toLowerCase().includes(lower)) return true;
if (job.category_name?.toLowerCase().includes(lower)) return true;
if (job.tags?.toLowerCase().includes(lower)) return true;
const description = job.description ? stripHtml(job.description) : "";
if (description.toLowerCase().includes(lower)) return true;
return false;
}
function mapJob(raw: WorkingNomadsJob): CreateJobInput | null {
const jobUrl = asString(raw.url);
if (!jobUrl) return null;
const title = asString(raw.title) ?? "Unknown Title";
const employer = asString(raw.company_name) ?? "Unknown Employer";
const location = asString(raw.location) ?? "Remote";
const description = raw.description ? stripHtml(raw.description) : undefined;
return {
source: "workingnomads",
sourceJobId: jobUrl.split("/").filter(Boolean).pop(),
title,
employer,
jobUrl,
applicationLink: jobUrl,
location,
isRemote: true,
datePosted: asString(raw.pub_date),
jobDescription: description,
companyIndustry: asString(raw.category_name),
disciplines: asString(raw.tags),
};
}
export const manifest: ExtractorManifest = {
id: "workingnomads",
displayName: "Working Nomads",
providesSources: ["workingnomads"],
async run(context): Promise<ExtractorRunResult> {
if (context.shouldCancel?.()) return { success: true, jobs: [] };
const maxJobs = context.settings.workingnomadsMaxJobsPerTerm
? Number.parseInt(context.settings.workingnomadsMaxJobsPerTerm, 10)
: 100;
const cap = Number.isFinite(maxJobs)
? Math.min(Math.max(maxJobs, 1), 500)
: 100;
const terms = context.searchTerms.length > 0 ? context.searchTerms : [];
context.onProgress?.({
phase: "list",
termsProcessed: 0,
termsTotal: 1,
currentUrl: API_URL,
detail: "Working Nomads: fetching exposed_jobs API",
});
try {
const response = await fetch(API_URL, {
headers: { Accept: "application/json", "User-Agent": "JobOps/1.0" },
});
if (!response.ok) {
throw new Error(
`Working Nomads request failed with status ${response.status}`,
);
}
const body = (await response.json()) as unknown;
const rows = Array.isArray(body) ? body : [];
const seen = new Set<string>();
const out: CreateJobInput[] = [];
for (const row of rows as WorkingNomadsJob[]) {
if (out.length >= cap) break;
if (terms.length > 0 && !terms.some((term) => matchesTerm(row, term))) {
continue;
}
const mapped = mapJob(row);
if (!mapped) continue;
const key = mapped.sourceJobId || mapped.jobUrl;
if (seen.has(key)) continue;
seen.add(key);
out.push(mapped);
}
context.onProgress?.({
phase: "list",
termsProcessed: 1,
termsTotal: 1,
currentUrl: API_URL,
jobPagesProcessed: out.length,
detail: `Working Nomads: ${out.length} matched (${rows.length} total)`,
});
return { success: true, jobs: out };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, jobs: [], error: message };
}
},
};
export default manifest;

View File

@ -0,0 +1,17 @@
{
"name": "workingnomads-extractor",
"version": "0.0.1",
"type": "module",
"description": "Working Nomads public JSON feed extractor",
"main": "manifest.ts",
"dependencies": {
"job-ops-shared": "^1.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "~5.9.0"
},
"scripts": {
"check:types": "tsc --noEmit"
}
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"outDir": "dist",
"strict": true,
"noUnusedLocals": false,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"baseUrl": ".",
"paths": {
"@shared/*": ["../../shared/src/*"]
}
},
"include": ["./manifest.ts", "./src/**/*"]
}

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" },
);
}

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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,
};
}

View File

@ -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,
};
}
};

View File

@ -39,6 +39,7 @@ const makeJob = (overrides: Partial<JobListItem>): JobListItem => ({
closedAt: null,
suitabilityScore: null,
sponsorMatchScore: null,
sponsorshipSignals: null,
jobType: null,
jobFunction: null,
salaryMinAmount: null,

View File

@ -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: [],

View File

@ -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}

View File

@ -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(

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}

View File

@ -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,

View File

@ -25,6 +25,10 @@ vi.mock("@/lib/user-location", () => ({
getDetectedCountryKey: getDetectedCountryKeyMock,
}));
vi.mock("./KeywordSetPicker", () => ({
KeywordSetPicker: () => null,
}));
describe("AutomaticRunTab", () => {
beforeEach(() => {
getDetectedCountryKeyMock.mockReset();

View File

@ -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>

View File

@ -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"];

View File

@ -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}

View File

@ -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 = (

View File

@ -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 && (

View File

@ -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>
);
};

View File

@ -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: {

View File

@ -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>

View File

@ -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);
});
});

View File

@ -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)

View File

@ -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",

View File

@ -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" },

View File

@ -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,

View File

@ -635,6 +635,7 @@ describe("useOrchestratorData", () => {
status: "discovered",
suitabilityScore: null,
sponsorMatchScore: null,
sponsorshipSignals: null,
jobType: null,
jobFunction: null,
salaryMinAmount: null,

View File

@ -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,

View File

@ -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,
});

View File

@ -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", () => {

View File

@ -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]);

View File

@ -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 }),

View File

@ -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"
) {

View File

@ -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>
);
};

View File

@ -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,

View File

@ -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();
});
});

View File

@ -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>
);
};

View File

@ -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();
});
});

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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}
/>

View File

@ -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[]>;
};

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);

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,

View File

@ -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);
}),
);

View File

@ -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");
});
});

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) {

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(

View File

@ -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",
};

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...");

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 =

View File

@ -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"],
}),
);
});
});

View File

@ -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 });
}

View File

@ -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" } : {}),
});

View File

@ -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,

View File

@ -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",
]);
});
});

View File

@ -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 });
}

View File

@ -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;
}

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);
});
});

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;
}

Some files were not shown because too many files have changed in this diff Show More