feat: keyword sets, sponsorship signals, and extractor/profile fixes
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:
parent
ecf1829ed8
commit
84e6835b11
16
.env.example
16
.env.example
@ -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
7
.gitignore
vendored
@ -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.*
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
6
docker-compose.localhost.yml
Normal file
6
docker-compose.localhost.yml
Normal 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"
|
||||
@ -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.
|
||||
|
||||
@ -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` |
|
||||
|
||||
@ -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
|
||||
|
||||

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