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:
@@ -254,3 +254,19 @@ ADZUNA_APP_KEY=
|
|||||||
# Comma-separated paths under https://arc.dev used when seeding defaults (e.g. Playwright + Cypress feeds).
|
# 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
|
# ARC_REMOTE_JOBS_PATHS=/remote-jobs/playwright,/remote-jobs/cypress
|
||||||
# Prefer Settings for overrides: arcRemoteJobsPaths (JSON array), arcMaxJobsPerPath (default 120).
|
# 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.
|
||||||
|
|||||||
+6
-1
@@ -18,6 +18,11 @@ data/
|
|||||||
# Extractor storage outputs and cached auth (per-extractor runtime data)
|
# Extractor storage outputs and cached auth (per-extractor runtime data)
|
||||||
extractors/*/storage/
|
extractors/*/storage/
|
||||||
|
|
||||||
# OS files
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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`).
|
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. Persist data: compose mounts `./data` — back up that directory.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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).
|
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:
|
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.
|
2. `orchestrator/src/server/services/hiring-cafe.ts` runs the extractor, streams progress events, and maps rows for pipeline import.
|
||||||
|
|
||||||
## Why it exists
|
## Why it exists
|
||||||
@@ -57,6 +57,12 @@ npm --workspace hiringcafe-extractor run start
|
|||||||
|
|
||||||
## Common problems
|
## 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
|
### 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.
|
- 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 |
|
| [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` |
|
| [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` |
|
| [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` |
|
| [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 |
|
| [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` |
|
| [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
|
- Adzuna app ID/app key
|
||||||
- Optional basic authentication for write operations
|
- Optional basic authentication for write operations
|
||||||
|
|
||||||
|
### Job search profiles and local resume
|
||||||
|
|
||||||
|
- **Search profiles** store target roles, deal-breakers, locations, and scoring context. Use the profile picker in Settings to create, switch, or activate a profile.
|
||||||
|
- When **Basic Auth** is enabled, each login user maps to profiles whose `basicAuthUser` field lists that username. Comma-separated aliases are supported (for example `ilia,dobkin` accepts either login name).
|
||||||
|
- Jobs are scoped per profile (`owner_profile_id`). Pipeline runs and the orchestrator board only show jobs for the profile tied to your current login.
|
||||||
|
- **Local resume** JSON paths can be set per profile via `resumeLocalPath` in profile data (for example `../data/resumes/ilia-dobkin.json`). JobOps resolves that path relative to the orchestrator working directory when `JOBOPS_LOCAL_RESUME_PATH` is unset.
|
||||||
|
- Activating a profile on Settings calls `POST /api/profiles/:id/activate` and updates `activeProfileId`, `jobSearchProfile`, and `localResumeProfilePath` when Basic Auth is off.
|
||||||
|
|
||||||
### Backup
|
### Backup
|
||||||
|
|
||||||

|

|
||||||
@@ -260,6 +268,12 @@ curl -X POST "http://localhost:3001/api/backups"
|
|||||||
- Ensure `JOBOPS_PUBLIC_BASE_URL` is set for background/pipeline usage.
|
- Ensure `JOBOPS_PUBLIC_BASE_URL` is set for background/pipeline usage.
|
||||||
- Ensure the configured host is publicly reachable and `/health` responds.
|
- 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
|
## Related pages
|
||||||
|
|
||||||
- [Company skip list](./company-skip-list)
|
- [Company skip list](./company-skip-list)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
@@ -230,7 +224,18 @@ function extractResultsBatch(payload: unknown): RawHiringCafeJob[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payloadRecord = asRecord(payload);
|
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 [];
|
if (!Array.isArray(results)) return [];
|
||||||
|
|
||||||
return results.filter(
|
return results.filter(
|
||||||
@@ -242,7 +247,22 @@ function extractResultsBatch(payload: unknown): RawHiringCafeJob[] {
|
|||||||
function parseTotalCount(payload: unknown): number | null {
|
function parseTotalCount(payload: unknown): number | null {
|
||||||
const payloadRecord = asRecord(payload);
|
const payloadRecord = asRecord(payload);
|
||||||
if (!payloadRecord) return null;
|
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 {
|
function buildCityLocationId(input: string): string {
|
||||||
@@ -501,23 +521,37 @@ function createCitySearchState(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callHiringCafeApi(
|
async function readBuildIdFromPage(page: Page): Promise<string> {
|
||||||
page: Page,
|
const buildId = await page.evaluate(() => {
|
||||||
endpoint: string,
|
const element = document.querySelector("#__NEXT_DATA__");
|
||||||
params: Record<string, string>,
|
if (!element?.textContent) return null;
|
||||||
): Promise<unknown> {
|
const data = JSON.parse(element.textContent) as { buildId?: string };
|
||||||
const response = await page.evaluate(
|
return data.buildId ?? null;
|
||||||
async ({ endpointArg, paramsArg }) => {
|
});
|
||||||
const url = new URL(endpointArg, window.location.origin);
|
if (!buildId) {
|
||||||
for (const [key, value] of Object.entries(paramsArg)) {
|
throw new Error("Hiring Cafe page did not expose Next.js buildId");
|
||||||
url.searchParams.set(key, value);
|
}
|
||||||
|
return buildId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(url.toString(), {
|
async function fetchSsrSearchPage(
|
||||||
method: "GET",
|
page: Page,
|
||||||
|
buildId: string,
|
||||||
|
searchState: Record<string, unknown>,
|
||||||
|
pageNo: number,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const response = await page.evaluate(
|
||||||
|
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, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json, text/plain, */*",
|
Accept: "application/json",
|
||||||
|
"x-nextjs-data": "1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -539,7 +573,7 @@ async function callHiringCafeApi(
|
|||||||
|
|
||||||
return output;
|
return output;
|
||||||
},
|
},
|
||||||
{ endpointArg: endpoint, paramsArg: params },
|
{ buildIdArg: buildId, searchStateArg: searchState, pageNoArg: pageNo },
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = response as BrowserApiResponse;
|
const result = response as BrowserApiResponse;
|
||||||
@@ -547,14 +581,14 @@ async function callHiringCafeApi(
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const snippet = result.responseText.slice(0, 250);
|
const snippet = result.responseText.slice(0, 250);
|
||||||
throw new Error(
|
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) {
|
if (result.data === null) {
|
||||||
const snippet = result.responseText.slice(0, 250);
|
const snippet = result.responseText.slice(0, 250);
|
||||||
throw new Error(
|
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();
|
await initializePage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildId = await readBuildIdFromPage(page);
|
||||||
|
|
||||||
const countryLocation = resolveHiringCafeCountryLocation(country);
|
const countryLocation = resolveHiringCafeCountryLocation(country);
|
||||||
const countryLong =
|
const countryLong =
|
||||||
countryLocation?.address_components[0]?.long_name ?? "United Kingdom";
|
countryLocation?.address_components[0]?.long_name ?? "United Kingdom";
|
||||||
@@ -664,40 +700,23 @@ async function run(): Promise<void> {
|
|||||||
dateFetchedPastNDays,
|
dateFetchedPastNDays,
|
||||||
workplaceTypes,
|
workplaceTypes,
|
||||||
});
|
});
|
||||||
const encodedSearchState = encodeSearchState(searchState);
|
let termTarget = maxJobsPerTerm;
|
||||||
|
|
||||||
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 pageNo = 0;
|
let pageNo = 0;
|
||||||
let termCollected = 0;
|
let termCollected = 0;
|
||||||
|
|
||||||
while (termCollected < termTarget && pageNo < PAGE_LIMIT) {
|
while (termCollected < termTarget && pageNo < PAGE_LIMIT) {
|
||||||
const size = Math.min(1000, termTarget - termCollected);
|
const jobsPayload = await fetchSsrSearchPage(
|
||||||
const jobsPayload = await callHiringCafeApi(page, "/api/search-jobs", {
|
page,
|
||||||
size: String(size),
|
buildId,
|
||||||
page: String(pageNo),
|
searchState,
|
||||||
s: encodedSearchState,
|
pageNo,
|
||||||
});
|
);
|
||||||
|
|
||||||
|
const totalAvailable = parseTotalCount(jobsPayload);
|
||||||
|
if (totalAvailable !== null) {
|
||||||
|
termTarget = Math.min(maxJobsPerTerm, totalAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
const batch = extractResultsBatch(jobsPayload);
|
const batch = extractResultsBatch(jobsPayload);
|
||||||
if (batch.length === 0) break;
|
if (batch.length === 0) break;
|
||||||
@@ -727,7 +746,7 @@ async function run(): Promise<void> {
|
|||||||
totalCollected: termCollected,
|
totalCollected: termCollected,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (batch.length < size) break;
|
if (isLastSsrPage(jobsPayload)) break;
|
||||||
pageNo += 1;
|
pageNo += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { fileURLToPath } from "node:url";
|
||||||
import { resolveSearchCities } from "@shared/search-cities.js";
|
import { resolveSearchCities } from "@shared/search-cities.js";
|
||||||
import type { CreateJobInput, JobSource } from "@shared/types/jobs";
|
import type { CreateJobInput, JobSource } from "@shared/types/jobs";
|
||||||
import { normalizeIsRemote } from "@shared/work-arrangement.js";
|
|
||||||
import {
|
import {
|
||||||
toNumberOrNull,
|
toNumberOrNull,
|
||||||
toStringOrNull,
|
toStringOrNull,
|
||||||
} from "@shared/utils/type-conversion.js";
|
} from "@shared/utils/type-conversion.js";
|
||||||
|
import { normalizeIsRemote } from "@shared/work-arrangement.js";
|
||||||
|
|
||||||
const srcDir = dirname(fileURLToPath(import.meta.url));
|
const srcDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const EXTRACTOR_DIR = join(srcDir, "..");
|
const EXTRACTOR_DIR = join(srcDir, "..");
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
JobsListResponse,
|
||||||
JobsRevisionResponse,
|
JobsRevisionResponse,
|
||||||
JobTracerLinksResponse,
|
JobTracerLinksResponse,
|
||||||
|
KeywordSet,
|
||||||
ManualJobDraft,
|
ManualJobDraft,
|
||||||
ManualJobFetchResponse,
|
ManualJobFetchResponse,
|
||||||
ManualJobInferenceResponse,
|
ManualJobInferenceResponse,
|
||||||
@@ -39,7 +40,6 @@ import type {
|
|||||||
ProfileStatusResponse,
|
ProfileStatusResponse,
|
||||||
ResumeProfile,
|
ResumeProfile,
|
||||||
ResumeProjectCatalogItem,
|
ResumeProjectCatalogItem,
|
||||||
RxResumeMode,
|
|
||||||
SearchProfile,
|
SearchProfile,
|
||||||
StageEvent,
|
StageEvent,
|
||||||
StageEventMetadata,
|
StageEventMetadata,
|
||||||
@@ -393,9 +393,14 @@ export async function activateSearchProfileForBasicAuthUser(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!listPayload.ok || !Array.isArray(listPayload.data)) return;
|
if (!listPayload.ok || !Array.isArray(listPayload.data)) return;
|
||||||
const match = listPayload.data.find(
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
(row) => (row.data?.basicAuthUser ?? "").trim() === username.trim(),
|
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;
|
if (!match) return;
|
||||||
await fetch(`${API_BASE}/profiles/${match.id}/activate`, {
|
await fetch(`${API_BASE}/profiles/${match.id}/activate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1533,19 +1538,6 @@ export async function getProfileProjects(): Promise<
|
|||||||
export async function getResumeProjectsCatalog(): Promise<
|
export async function getResumeProjectsCatalog(): Promise<
|
||||||
ResumeProjectCatalogItem[]
|
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();
|
return getProfileProjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1586,19 +1578,6 @@ export async function getLlmModels(input?: {
|
|||||||
return data.models;
|
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> {
|
export async function validateResumeConfig(): Promise<ValidationResult> {
|
||||||
return fetchApi<ValidationResult>("/onboarding/validate/resume");
|
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
|
// Database API
|
||||||
export async function clearDatabase(): Promise<{
|
export async function clearDatabase(): Promise<{
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1790,3 +1746,41 @@ export async function generateProfileFromResume(): Promise<JobSearchProfile> {
|
|||||||
method: "POST",
|
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";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn, formatDate, sourceLabel } from "@/lib/utils";
|
import { cn, formatDate, sourceLabel } from "@/lib/utils";
|
||||||
import { useSettings } from "../hooks/useSettings";
|
import { useSettings } from "../hooks/useSettings";
|
||||||
|
import { SponsorshipSignalsPills } from "./SponsorshipSignalsPills";
|
||||||
import {
|
import {
|
||||||
getJobStatusIndicator,
|
getJobStatusIndicator,
|
||||||
getTracerStatusIndicator,
|
getTracerStatusIndicator,
|
||||||
@@ -255,6 +256,10 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
|
|||||||
onCheck={onCheckSponsor}
|
onCheck={onCheckSponsor}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<SponsorshipSignalsPills
|
||||||
|
sponsorshipSignals={job.sponsorshipSignals}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ScoreMeter score={job.suitabilityScore} />
|
<ScoreMeter score={job.suitabilityScore} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import { useSettings } from "@client/hooks/useSettings";
|
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 type React from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
@@ -12,8 +12,6 @@ const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
|||||||
vi.mock("@client/api", () => ({
|
vi.mock("@client/api", () => ({
|
||||||
getDemoInfo: vi.fn(),
|
getDemoInfo: vi.fn(),
|
||||||
validateLlm: vi.fn(),
|
validateLlm: vi.fn(),
|
||||||
validateRxresume: vi.fn(),
|
|
||||||
validateResumeConfig: vi.fn(),
|
|
||||||
updateSettings: 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", () => ({
|
vi.mock("@/components/ui/alert-dialog", () => ({
|
||||||
AlertDialog: ({ children }: { children: React.ReactNode }) => (
|
AlertDialog: ({ children }: { children: React.ReactNode }) => (
|
||||||
<div>{children}</div>
|
<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", () => ({
|
vi.mock("@/components/ui/select", () => ({
|
||||||
Select: ({ children }: { children: React.ReactNode }) => (
|
Select: ({ children }: { children: React.ReactNode }) => (
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
@@ -105,11 +86,6 @@ const settingsResponse = {
|
|||||||
settings: {
|
settings: {
|
||||||
llmProvider: { value: "openrouter", default: "openrouter", override: null },
|
llmProvider: { value: "openrouter", default: "openrouter", override: null },
|
||||||
llmApiKeyHint: null,
|
llmApiKeyHint: null,
|
||||||
rxresumeEmail: "",
|
|
||||||
rxresumeUrl: "",
|
|
||||||
rxresumeApiKeyHint: null,
|
|
||||||
rxresumePasswordHint: null,
|
|
||||||
rxresumeBaseResumeId: null,
|
|
||||||
localResumeProfilePath: null,
|
localResumeProfilePath: null,
|
||||||
localResumeFileConfigured: false,
|
localResumeFileConfigured: false,
|
||||||
},
|
},
|
||||||
@@ -131,19 +107,11 @@ describe("OnboardingGate", () => {
|
|||||||
vi.mocked(useSettings).mockReturnValue(settingsResponse as any);
|
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({
|
vi.mocked(api.validateLlm).mockResolvedValue({
|
||||||
valid: false,
|
valid: false,
|
||||||
message: "Invalid",
|
message: "Invalid",
|
||||||
});
|
});
|
||||||
vi.mocked(api.validateRxresume).mockResolvedValue({
|
|
||||||
valid: true,
|
|
||||||
message: null,
|
|
||||||
});
|
|
||||||
vi.mocked(api.validateResumeConfig).mockResolvedValue({
|
|
||||||
valid: true,
|
|
||||||
message: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<OnboardingGate />);
|
render(<OnboardingGate />);
|
||||||
|
|
||||||
@@ -151,123 +119,38 @@ describe("OnboardingGate", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
|
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the gate when all validations succeed", async () => {
|
|
||||||
vi.mocked(useSettings).mockReturnValue({
|
|
||||||
...settingsResponse,
|
|
||||||
settings: {
|
|
||||||
...settingsResponse.settings,
|
|
||||||
rxresumeApiKeyHint: "abcd1234",
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
vi.mocked(api.validateLlm).mockResolvedValue({
|
|
||||||
valid: true,
|
|
||||||
message: null,
|
|
||||||
});
|
|
||||||
vi.mocked(api.validateRxresume).mockResolvedValue({
|
|
||||||
valid: true,
|
|
||||||
message: null,
|
|
||||||
});
|
|
||||||
vi.mocked(api.validateResumeConfig).mockResolvedValue({
|
|
||||||
valid: true,
|
|
||||||
message: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<OnboardingGate />);
|
|
||||||
|
|
||||||
await waitFor(() => expect(api.validateLlm).toHaveBeenCalled());
|
|
||||||
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 () => {
|
|
||||||
vi.mocked(useSettings).mockReturnValue({
|
|
||||||
...settingsResponse,
|
|
||||||
settings: {
|
|
||||||
...settingsResponse.settings,
|
|
||||||
llmProvider: { value: "ollama", default: "ollama", override: null },
|
|
||||||
localResumeFileConfigured: true,
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(<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(
|
expect(
|
||||||
screen.getByDisplayValue("https://self-hosted.example.com"),
|
screen.queryByLabelText("Local resume path"),
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Resume file")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the gate when LLM validation succeeds", async () => {
|
||||||
|
vi.mocked(api.validateLlm).mockResolvedValue({
|
||||||
|
valid: true,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OnboardingGate />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.validateLlm).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<OnboardingGate />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(api.validateLlm).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import { ReactiveResumeConfigPanel } from "@client/components/ReactiveResumeConfigPanel";
|
|
||||||
import { useDemoInfo } from "@client/hooks/useDemoInfo";
|
import { useDemoInfo } from "@client/hooks/useDemoInfo";
|
||||||
import { useRxResumeConfigState } from "@client/hooks/useRxResumeConfigState";
|
|
||||||
import { useSettings } from "@client/hooks/useSettings";
|
import { useSettings } from "@client/hooks/useSettings";
|
||||||
import {
|
|
||||||
getInitialRxResumeMode,
|
|
||||||
getRxResumeCredentialDrafts,
|
|
||||||
getRxResumeMissingCredentialLabels,
|
|
||||||
validateAndMaybePersistRxResumeMode,
|
|
||||||
} from "@client/lib/rxresume-config";
|
|
||||||
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
|
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
import {
|
import {
|
||||||
getLlmProviderConfig,
|
getLlmProviderConfig,
|
||||||
@@ -19,10 +10,10 @@ import {
|
|||||||
} from "@client/pages/settings/utils";
|
} from "@client/pages/settings/utils";
|
||||||
import { getDefaultModelForProvider } from "@shared/settings-registry";
|
import { getDefaultModelForProvider } from "@shared/settings-registry";
|
||||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
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 { Check } from "lucide-react";
|
||||||
import type React from "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 { Controller, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -48,22 +39,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ValidationState = ValidationResult & { checked: boolean };
|
type ValidationState = ValidationResult & { checked: boolean };
|
||||||
type TimestampedValidationState = ValidationState & { testedAt: number | null };
|
|
||||||
|
|
||||||
type OnboardingFormData = {
|
type OnboardingFormData = {
|
||||||
llmProvider: string;
|
llmProvider: string;
|
||||||
llmBaseUrl: string;
|
llmBaseUrl: string;
|
||||||
llmApiKey: string;
|
llmApiKey: string;
|
||||||
rxresumeMode: RxResumeMode;
|
|
||||||
rxresumeEmail: string;
|
|
||||||
rxresumeUrl: string;
|
|
||||||
rxresumePassword: string;
|
|
||||||
rxresumeApiKey: string;
|
|
||||||
rxresumeBaseResumeId: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_VALIDATION_STATE: ValidationState = {
|
const EMPTY_VALIDATION_STATE: ValidationState = {
|
||||||
@@ -72,27 +55,6 @@ const EMPTY_VALIDATION_STATE: ValidationState = {
|
|||||||
checked: false,
|
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 = () => {
|
export const OnboardingGate: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
settings,
|
settings,
|
||||||
@@ -100,41 +62,11 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
refreshSettings,
|
refreshSettings,
|
||||||
} = useSettings();
|
} = 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 [isSavingEnv, setIsSavingEnv] = useState(false);
|
||||||
const [isValidatingLlm, setIsValidatingLlm] = useState(false);
|
const [isValidatingLlm, setIsValidatingLlm] = useState(false);
|
||||||
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
|
|
||||||
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
|
|
||||||
const [llmValidation, setLlmValidation] = useState<ValidationState>(
|
const [llmValidation, setLlmValidation] = useState<ValidationState>(
|
||||||
EMPTY_VALIDATION_STATE,
|
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 demoInfo = useDemoInfo();
|
||||||
const demoMode = demoInfo?.demoMode ?? false;
|
const demoMode = demoInfo?.demoMode ?? false;
|
||||||
|
|
||||||
@@ -144,12 +76,6 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
llmProvider: "",
|
llmProvider: "",
|
||||||
llmBaseUrl: "",
|
llmBaseUrl: "",
|
||||||
llmApiKey: "",
|
llmApiKey: "",
|
||||||
rxresumeMode: "v5",
|
|
||||||
rxresumeEmail: "",
|
|
||||||
rxresumeUrl: "",
|
|
||||||
rxresumePassword: "",
|
|
||||||
rxresumeApiKey: "",
|
|
||||||
rxresumeBaseResumeId: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,26 +113,6 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [getValues, settings?.llmProvider]);
|
}, [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(
|
const selectedProvider = normalizeLlmProvider(
|
||||||
llmProvider || settings?.llmProvider?.value || "openrouter",
|
llmProvider || settings?.llmProvider?.value || "openrouter",
|
||||||
);
|
);
|
||||||
@@ -220,234 +126,68 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
|
|
||||||
const llmKeyHint = settings?.llmApiKeyHint ?? null;
|
const llmKeyHint = settings?.llmApiKeyHint ?? null;
|
||||||
const hasLlmKey = Boolean(llmKeyHint);
|
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 llmValidated = requiresLlmKey ? llmValidation.valid : true;
|
||||||
|
|
||||||
|
const hasCheckedValidations = requiresLlmKey ? llmValidation.checked : true;
|
||||||
|
|
||||||
const shouldOpen =
|
const shouldOpen =
|
||||||
!demoMode &&
|
!demoMode &&
|
||||||
Boolean(settings && !settingsLoading) &&
|
Boolean(settings && !settingsLoading) &&
|
||||||
hasCheckedValidations &&
|
hasCheckedValidations &&
|
||||||
!(
|
!llmValidated;
|
||||||
llmValidated &&
|
|
||||||
(skipRxResumeOnboarding
|
|
||||||
? true
|
|
||||||
: rxresumeValidation.valid && baseResumeValidation.valid)
|
|
||||||
);
|
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
const initialMode = getInitialRxResumeMode({
|
|
||||||
savedMode: (settings.rxresumeMode?.value ??
|
|
||||||
null) as RxResumeMode | null,
|
|
||||||
hasV4: storedRxResume.hasV4,
|
|
||||||
hasV5: storedRxResume.hasV5,
|
|
||||||
});
|
|
||||||
const selectedId = syncBaseResumeIdsForMode(initialMode);
|
|
||||||
reset({
|
reset({
|
||||||
llmProvider: settings.llmProvider?.value || "",
|
llmProvider: settings.llmProvider?.value || "",
|
||||||
llmBaseUrl: settings.llmBaseUrl?.value || "",
|
llmBaseUrl: settings.llmBaseUrl?.value || "",
|
||||||
llmApiKey: "",
|
llmApiKey: "",
|
||||||
rxresumeMode: initialMode,
|
|
||||||
rxresumeEmail: "",
|
|
||||||
rxresumeUrl: settings.rxresumeUrl ?? "",
|
|
||||||
rxresumePassword: "",
|
|
||||||
rxresumeApiKey: "",
|
|
||||||
rxresumeBaseResumeId: selectedId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [settings, reset]);
|
||||||
settings,
|
|
||||||
reset,
|
|
||||||
storedRxResume.hasV4,
|
|
||||||
storedRxResume.hasV5,
|
|
||||||
syncBaseResumeIdsForMode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Clear base URL when provider doesn't require it
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showBaseUrl) {
|
if (!showBaseUrl) {
|
||||||
setValue("llmBaseUrl", "");
|
setValue("llmBaseUrl", "");
|
||||||
}
|
}
|
||||||
}, [showBaseUrl, setValue]);
|
}, [showBaseUrl, setValue]);
|
||||||
|
|
||||||
// Reset LLM validation when provider changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProvider) return;
|
if (!selectedProvider) return;
|
||||||
setLlmValidation({ valid: false, message: null, checked: false });
|
setLlmValidation({ valid: false, message: null, checked: false });
|
||||||
}, [selectedProvider]);
|
}, [selectedProvider]);
|
||||||
|
|
||||||
const steps = useMemo(
|
const runLlmValidation = useCallback(async () => {
|
||||||
() =>
|
|
||||||
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 () => {
|
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
const validations: Promise<ValidationResult>[] = [];
|
|
||||||
if (requiresLlmKey) {
|
if (requiresLlmKey) {
|
||||||
validations.push(validateLlm());
|
await validateLlm();
|
||||||
} else {
|
} else {
|
||||||
setLlmValidation({ valid: true, message: null, checked: true });
|
setLlmValidation({ valid: true, message: null, checked: true });
|
||||||
}
|
}
|
||||||
if (!skipRxResumeOnboarding) {
|
}, [settings, requiresLlmKey, validateLlm]);
|
||||||
validations.push(validateRxresume(), validateBaseResume());
|
|
||||||
} else {
|
|
||||||
setRxresumeValidation({ valid: true, message: null, checked: true });
|
|
||||||
setBaseResumeValidation({ valid: true, message: null, checked: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (demoMode) return;
|
if (demoMode) return;
|
||||||
if (!settings || settingsLoading) return;
|
if (!settings || settingsLoading) return;
|
||||||
const needsValidation =
|
if (requiresLlmKey ? llmValidation.checked : true) return;
|
||||||
(requiresLlmKey ? !llmValidation.checked : false) ||
|
void runLlmValidation();
|
||||||
(skipRxResumeOnboarding
|
|
||||||
? false
|
|
||||||
: !rxresumeValidation.checked || !baseResumeValidation.checked);
|
|
||||||
if (!needsValidation) return;
|
|
||||||
void runAllValidations();
|
|
||||||
}, [
|
}, [
|
||||||
settings,
|
settings,
|
||||||
settingsLoading,
|
settingsLoading,
|
||||||
requiresLlmKey,
|
requiresLlmKey,
|
||||||
llmValidation.checked,
|
llmValidation.checked,
|
||||||
rxresumeValidation.checked,
|
runLlmValidation,
|
||||||
baseResumeValidation.checked,
|
|
||||||
runAllValidations,
|
|
||||||
demoMode,
|
demoMode,
|
||||||
skipRxResumeOnboarding,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSaveLlm = async (): Promise<boolean> => {
|
const handleSaveLlm = async () => {
|
||||||
const values = getValues();
|
const values = getValues();
|
||||||
const apiKeyValue = values.llmApiKey.trim();
|
const apiKeyValue = values.llmApiKey.trim();
|
||||||
const baseUrlValue = values.llmBaseUrl.trim();
|
const baseUrlValue = values.llmBaseUrl.trim();
|
||||||
|
|
||||||
if (requiresLlmKey && !apiKeyValue && !hasLlmKey) {
|
if (requiresLlmKey && !apiKeyValue && !hasLlmKey) {
|
||||||
toast.info("Add your LLM API key to continue");
|
toast.info("Add your LLM API key to continue");
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -457,7 +197,7 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
toast.error(validation.message || "LLM validation failed");
|
toast.error(validation.message || "LLM validation failed");
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const update: Partial<UpdateSettingsInput> = {
|
const update: Partial<UpdateSettingsInput> = {
|
||||||
@@ -484,165 +224,19 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
? `Default for ${providerConfig.label}: ${defaultModel}.`
|
? `Default for ${providerConfig.label}: ${defaultModel}.`
|
||||||
: "Select the model manually in Settings > Model.",
|
: "Select the model manually in Settings > Model.",
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Failed to save LLM settings";
|
error instanceof Error ? error.message : "Failed to save LLM settings";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
return false;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingEnv(false);
|
setIsSavingEnv(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveRxresume = async (): Promise<boolean> => {
|
const isBusy = isSavingEnv || settingsLoading || isValidatingLlm;
|
||||||
const values = getValues();
|
const progressValue = llmValidated ? 100 : 0;
|
||||||
const modeValue = values.rxresumeMode;
|
|
||||||
const draftCredentials = getRxResumeCredentialDrafts(values);
|
|
||||||
const missing = getRxResumeMissingCredentialLabels({
|
|
||||||
mode: modeValue,
|
|
||||||
stored: storedRxResume,
|
|
||||||
draft: draftCredentials,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (!shouldOpen) return null;
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open>
|
<AlertDialog open>
|
||||||
@@ -654,62 +248,34 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
|
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Let's get your workspace ready. Add your keys and resume once,
|
Connect your LLM provider to run job scoring, summaries, and
|
||||||
then the pipeline can run end-to-end.
|
tailoring. You can add a resume file later in Settings if you need
|
||||||
|
PDF export or resume-based scoring.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<Tabs value={currentStep} onValueChange={setCurrentStep}>
|
<FieldLabel className="w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none">
|
||||||
<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">
|
<div className="w-full rounded-md border-b-2 border-primary bg-muted/60 px-3 py-4 text-left">
|
||||||
{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">
|
<Field orientation="horizontal" className="items-start">
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
<FieldTitle>{step.label}</FieldTitle>
|
<FieldTitle>LLM Provider</FieldTitle>
|
||||||
<FieldDescription>{step.subtitle}</FieldDescription>
|
<FieldDescription>Provider + credentials</FieldDescription>
|
||||||
</FieldContent>
|
</FieldContent>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
|
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
|
||||||
isComplete
|
llmValidated
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground",
|
: "bg-muted text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isComplete ? (
|
{llmValidated ? <Check className="h-3.5 w-3.5" /> : "1"}
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
index + 1
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</Field>
|
</Field>
|
||||||
</TabsTrigger>
|
</div>
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="llm" className="space-y-4 pt-6">
|
<div className="space-y-4 pt-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold">Connect LLM provider</p>
|
<p className="text-sm font-semibold">Connect LLM provider</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -794,101 +360,22 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
{llmValidation.checked && !llmValidation.valid ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
<TabsContent value="rxresume" className="space-y-4 pt-6">
|
{llmValidation.message ?? "LLM validation failed."}
|
||||||
<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>
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-end">
|
||||||
<Button
|
<Button onClick={handleSaveLlm} disabled={isBusy}>
|
||||||
variant="outline"
|
|
||||||
onClick={handleBack}
|
|
||||||
disabled={!canGoBack || isBusy}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button onClick={handlePrimaryAction} disabled={isBusy}>
|
|
||||||
{isBusy
|
{isBusy
|
||||||
? "Validating..."
|
? "Validating..."
|
||||||
: getStepPrimaryLabel({
|
: llmValidated
|
||||||
currentStep,
|
? "Revalidate"
|
||||||
llmValidated,
|
: "Connect"}
|
||||||
rxresumeValidated: rxresumeValidation.valid,
|
|
||||||
baseResumeValidated: baseResumeValidation.valid,
|
|
||||||
})}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Progress value={progressValue} className="h-2" />
|
<Progress value={progressValue} className="h-2" />
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogContent>
|
</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,
|
closedAt: null,
|
||||||
suitabilityScore: null,
|
suitabilityScore: null,
|
||||||
sponsorMatchScore: null,
|
sponsorMatchScore: null,
|
||||||
|
sponsorshipSignals: null,
|
||||||
jobType: null,
|
jobType: null,
|
||||||
jobFunction: null,
|
jobFunction: null,
|
||||||
salaryMinAmount: null,
|
salaryMinAmount: null,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ let mockAutomaticRunValues: AutomaticRunValues = {
|
|||||||
topN: 12,
|
topN: 12,
|
||||||
minSuitabilityScore: 55,
|
minSuitabilityScore: 55,
|
||||||
searchTerms: ["backend"],
|
searchTerms: ["backend"],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
@@ -407,6 +408,7 @@ describe("OrchestratorPage", () => {
|
|||||||
topN: 12,
|
topN: 12,
|
||||||
minSuitabilityScore: 55,
|
minSuitabilityScore: 55,
|
||||||
searchTerms: ["backend"],
|
searchTerms: ["backend"],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
@@ -759,7 +761,8 @@ describe("OrchestratorPage", () => {
|
|||||||
fireEvent.click(screen.getByTestId("run-automatic"));
|
fireEvent.click(screen.getByTestId("run-automatic"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(api.updateSettings).toHaveBeenCalledWith({
|
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
searchTerms: ["backend"],
|
searchTerms: ["backend"],
|
||||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
jobspyResultsWanted: 150,
|
jobspyResultsWanted: 150,
|
||||||
@@ -767,9 +770,12 @@ describe("OrchestratorPage", () => {
|
|||||||
ukvisajobsMaxJobs: 150,
|
ukvisajobsMaxJobs: 150,
|
||||||
adzunaMaxJobsPerTerm: 150,
|
adzunaMaxJobsPerTerm: 150,
|
||||||
startupjobsMaxJobsPerTerm: 150,
|
startupjobsMaxJobsPerTerm: 150,
|
||||||
|
workingnomadsMaxJobsPerTerm: 150,
|
||||||
|
testdevjobsMaxPages: 10,
|
||||||
jobspyCountryIndeed: "united kingdom",
|
jobspyCountryIndeed: "united kingdom",
|
||||||
searchCities: "United Kingdom",
|
searchCities: "United Kingdom",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(api.runPipeline).toHaveBeenCalledWith({
|
expect(api.runPipeline).toHaveBeenCalledWith({
|
||||||
topN: 12,
|
topN: 12,
|
||||||
@@ -790,6 +796,7 @@ describe("OrchestratorPage", () => {
|
|||||||
topN: 12,
|
topN: 12,
|
||||||
minSuitabilityScore: 55,
|
minSuitabilityScore: 55,
|
||||||
searchTerms: ["backend"],
|
searchTerms: ["backend"],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: ["London", "Manchester"],
|
cityLocations: ["London", "Manchester"],
|
||||||
@@ -825,6 +832,7 @@ describe("OrchestratorPage", () => {
|
|||||||
topN: 12,
|
topN: 12,
|
||||||
minSuitabilityScore: 55,
|
minSuitabilityScore: 55,
|
||||||
searchTerms: ["backend"],
|
searchTerms: ["backend"],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: ["Leeds", "Manchester"],
|
cityLocations: ["Leeds", "Manchester"],
|
||||||
@@ -860,6 +868,7 @@ describe("OrchestratorPage", () => {
|
|||||||
topN: 12,
|
topN: 12,
|
||||||
minSuitabilityScore: 55,
|
minSuitabilityScore: 55,
|
||||||
searchTerms: ["backend"],
|
searchTerms: ["backend"],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: ["Leeds", "Manchester"],
|
cityLocations: ["Leeds", "Manchester"],
|
||||||
@@ -967,6 +976,7 @@ describe("OrchestratorPage", () => {
|
|||||||
topN: 12,
|
topN: 12,
|
||||||
minSuitabilityScore: 55,
|
minSuitabilityScore: 55,
|
||||||
searchTerms: ["backend"],
|
searchTerms: ["backend"],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 150,
|
runBudget: 150,
|
||||||
country: "united states",
|
country: "united states",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
setCountrySelection,
|
setCountrySelection,
|
||||||
sponsorFilter,
|
sponsorFilter,
|
||||||
setSponsorFilter,
|
setSponsorFilter,
|
||||||
|
sponsorshipSignalsFilter,
|
||||||
|
setSponsorshipSignalsFilter,
|
||||||
|
hideSponsorBlockers,
|
||||||
|
setHideSponsorBlockers,
|
||||||
workplaceFilter,
|
workplaceFilter,
|
||||||
setWorkplaceFilter,
|
setWorkplaceFilter,
|
||||||
salaryFilter,
|
salaryFilter,
|
||||||
@@ -205,6 +209,8 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
countriesFilter,
|
countriesFilter,
|
||||||
countriesExcludeFilter,
|
countriesExcludeFilter,
|
||||||
sponsorFilter,
|
sponsorFilter,
|
||||||
|
sponsorshipSignalsFilter,
|
||||||
|
hideSponsorBlockers,
|
||||||
workplaceFilter,
|
workplaceFilter,
|
||||||
salaryFilter,
|
salaryFilter,
|
||||||
sort,
|
sort,
|
||||||
@@ -387,6 +393,8 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
"countries",
|
"countries",
|
||||||
"countriesExclude",
|
"countriesExclude",
|
||||||
"sponsor",
|
"sponsor",
|
||||||
|
"sponsorSignal",
|
||||||
|
"hideSponsorBlockers",
|
||||||
"salaryMode",
|
"salaryMode",
|
||||||
"salaryMin",
|
"salaryMin",
|
||||||
"salaryMax",
|
"salaryMax",
|
||||||
@@ -525,6 +533,10 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
sponsorFilter={sponsorFilter}
|
sponsorFilter={sponsorFilter}
|
||||||
onSponsorFilterChange={setSponsorFilter}
|
onSponsorFilterChange={setSponsorFilter}
|
||||||
|
sponsorshipSignalsFilter={sponsorshipSignalsFilter}
|
||||||
|
onSponsorshipSignalsFilterChange={setSponsorshipSignalsFilter}
|
||||||
|
hideSponsorBlockers={hideSponsorBlockers}
|
||||||
|
onHideSponsorBlockersChange={setHideSponsorBlockers}
|
||||||
workplaceFilter={workplaceFilter}
|
workplaceFilter={workplaceFilter}
|
||||||
onWorkplaceFilterChange={setWorkplaceFilter}
|
onWorkplaceFilterChange={setWorkplaceFilter}
|
||||||
salaryFilter={salaryFilter}
|
salaryFilter={salaryFilter}
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ vi.mock("../api", () => ({
|
|||||||
getSettings: vi.fn(),
|
getSettings: vi.fn(),
|
||||||
getLlmModels: vi.fn().mockResolvedValue([]),
|
getLlmModels: vi.fn().mockResolvedValue([]),
|
||||||
updateSettings: vi.fn(),
|
updateSettings: vi.fn(),
|
||||||
validateRxresume: vi.fn(),
|
|
||||||
getRxResumeProjects: vi.fn(),
|
|
||||||
clearDatabase: vi.fn(),
|
clearDatabase: vi.fn(),
|
||||||
deleteJobsByStatus: vi.fn(),
|
deleteJobsByStatus: vi.fn(),
|
||||||
getTracerReadiness: vi.fn(),
|
getTracerReadiness: vi.fn(),
|
||||||
@@ -74,13 +72,6 @@ const openWritingStyleSection = async () => {
|
|||||||
fireEvent.click(chatTrigger);
|
fireEvent.click(chatTrigger);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openReactiveResumeSection = async () => {
|
|
||||||
const trigger = await screen.findByRole("button", {
|
|
||||||
name: /reactive resume/i,
|
|
||||||
});
|
|
||||||
fireEvent.click(trigger);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("SettingsPage", () => {
|
describe("SettingsPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -98,11 +89,6 @@ describe("SettingsPage", () => {
|
|||||||
lastSuccessAt: Date.now(),
|
lastSuccessAt: Date.now(),
|
||||||
reason: null,
|
reason: null,
|
||||||
});
|
});
|
||||||
vi.mocked(api.validateRxresume).mockResolvedValue({
|
|
||||||
valid: false,
|
|
||||||
message: "Missing credentials",
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -297,192 +283,6 @@ describe("SettingsPage", () => {
|
|||||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows saving when both Reactive Resume v4 and v5 credentials are present", async () => {
|
|
||||||
const settingsWithBothRxResumeAuth = createAppSettings({
|
|
||||||
rxresumeEmail: "resume@example.com",
|
|
||||||
rxresumePasswordHint: "pass",
|
|
||||||
rxresumeApiKeyHint: "api_",
|
|
||||||
});
|
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(settingsWithBothRxResumeAuth);
|
|
||||||
vi.mocked(api.updateSettings).mockResolvedValue(
|
|
||||||
settingsWithBothRxResumeAuth,
|
|
||||||
);
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
const displayTrigger = await screen.findByRole("button", {
|
|
||||||
name: /display settings/i,
|
|
||||||
});
|
|
||||||
fireEvent.click(displayTrigger);
|
|
||||||
const sponsorCheckbox = screen.getByLabelText(
|
|
||||||
/show visa sponsor information/i,
|
|
||||||
);
|
|
||||||
fireEvent.click(sponsorCheckbox);
|
|
||||||
|
|
||||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
|
||||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
|
||||||
fireEvent.click(saveButton);
|
|
||||||
|
|
||||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
|
|
||||||
expect(toast.error).not.toHaveBeenCalledWith(
|
|
||||||
"Choose one Reactive Resume auth method",
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("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 () => {
|
it("saves the writing language mode through the settings page", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
vi.mocked(api.updateSettings).mockResolvedValue(
|
vi.mocked(api.updateSettings).mockResolvedValue(
|
||||||
|
|||||||
@@ -1,27 +1,17 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import { PageHeader } from "@client/components/layout";
|
import { PageHeader } from "@client/components/layout";
|
||||||
import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation";
|
import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation";
|
||||||
import { useRxResumeConfigState } from "@client/hooks/useRxResumeConfigState";
|
|
||||||
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
|
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
|
||||||
import {
|
|
||||||
coerceRxResumeMode,
|
|
||||||
getRxResumeCredentialDrafts,
|
|
||||||
getRxResumeCredentialPrecheckFailure,
|
|
||||||
isRxResumeAvailabilityValidationFailure,
|
|
||||||
isRxResumeBlockingValidationFailure,
|
|
||||||
RXRESUME_MODES,
|
|
||||||
RXRESUME_PRECHECK_MESSAGES,
|
|
||||||
toRxResumeValidationPayload,
|
|
||||||
validateAndMaybePersistRxResumeMode,
|
|
||||||
} from "@client/lib/rxresume-config";
|
|
||||||
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
||||||
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
|
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
|
||||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
||||||
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
|
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
|
||||||
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
|
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
|
||||||
import { JobSearchProfileSection } from "@client/pages/settings/components/JobSearchProfileSection";
|
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 { 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 { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
|
||||||
import { TracerLinksSettingsSection } from "@client/pages/settings/components/TracerLinksSettingsSection";
|
import { TracerLinksSettingsSection } from "@client/pages/settings/components/TracerLinksSettingsSection";
|
||||||
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
|
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
|
||||||
@@ -42,8 +32,6 @@ import type {
|
|||||||
JobStatus,
|
JobStatus,
|
||||||
ResumeProjectCatalogItem,
|
ResumeProjectCatalogItem,
|
||||||
ResumeProjectsSettings,
|
ResumeProjectsSettings,
|
||||||
RxResumeMode,
|
|
||||||
ValidationResult,
|
|
||||||
} from "@shared/types.js";
|
} from "@shared/types.js";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
@@ -105,37 +93,20 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|||||||
blockedCompanyKeywords: [],
|
blockedCompanyKeywords: [],
|
||||||
blockedCountries: [],
|
blockedCountries: [],
|
||||||
scoringInstructions: "",
|
scoringInstructions: "",
|
||||||
|
ashbyCompanies: [],
|
||||||
|
greenhouseCompanies: [],
|
||||||
|
leverCompanies: [],
|
||||||
|
smartrecruitersCompanies: [],
|
||||||
|
teamtailorCompanies: [],
|
||||||
|
huntflowTenants: [],
|
||||||
|
factorialTenants: [],
|
||||||
|
workdayTenants: [],
|
||||||
|
careersPageUrls: [],
|
||||||
|
icimsTenants: [],
|
||||||
|
elutaRssLocations: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
type LlmProviderValue = LlmProviderId | null;
|
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 = (
|
const normalizeLlmProviderValue = (
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
@@ -233,6 +204,17 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
blockedCompanyKeywords: data.blockedCompanyKeywords.override ?? [],
|
blockedCompanyKeywords: data.blockedCompanyKeywords.override ?? [],
|
||||||
blockedCountries: data.blockedCountries.override ?? [],
|
blockedCountries: data.blockedCountries.override ?? [],
|
||||||
scoringInstructions: data.scoringInstructions.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) => {
|
const normalizeString = (value: string | null | undefined) => {
|
||||||
@@ -348,13 +330,11 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
},
|
},
|
||||||
envSettings: {
|
envSettings: {
|
||||||
readable: {
|
readable: {
|
||||||
rxresumeEmail: settings?.rxresumeEmail ?? "",
|
|
||||||
ukvisajobsEmail: settings?.ukvisajobsEmail ?? "",
|
ukvisajobsEmail: settings?.ukvisajobsEmail ?? "",
|
||||||
adzunaAppId: settings?.adzunaAppId ?? "",
|
adzunaAppId: settings?.adzunaAppId ?? "",
|
||||||
basicAuthUser: settings?.basicAuthUser ?? "",
|
basicAuthUser: settings?.basicAuthUser ?? "",
|
||||||
},
|
},
|
||||||
private: {
|
private: {
|
||||||
rxresumePasswordHint: settings?.rxresumePasswordHint ?? null,
|
|
||||||
ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null,
|
ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null,
|
||||||
adzunaAppKeyHint: settings?.adzunaAppKeyHint ?? null,
|
adzunaAppKeyHint: settings?.adzunaAppKeyHint ?? null,
|
||||||
basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null,
|
basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null,
|
||||||
@@ -411,6 +391,52 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
default: settings?.scoringInstructions?.default ?? "",
|
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 queryClient = useQueryClient();
|
||||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [rxresumeValidationStatuses, setRxresumeValidationStatuses] = useState<{
|
|
||||||
v4: RxResumeValidationBadgeState;
|
|
||||||
v5: RxResumeValidationBadgeState;
|
|
||||||
}>({
|
|
||||||
v4: EMPTY_RXRESUME_VALIDATION_BADGE_STATE,
|
|
||||||
v5: EMPTY_RXRESUME_VALIDATION_BADGE_STATE,
|
|
||||||
});
|
|
||||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
|
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
|
||||||
"discovered",
|
"discovered",
|
||||||
]);
|
]);
|
||||||
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<
|
|
||||||
string | null
|
|
||||||
>(null);
|
|
||||||
const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState<
|
|
||||||
ResumeProjectCatalogItem[] | null
|
|
||||||
>(null);
|
|
||||||
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
// Backup state
|
// Backup state
|
||||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
||||||
const [isDeletingBackup, setIsDeletingBackup] = useState(false);
|
const [isDeletingBackup, setIsDeletingBackup] = useState(false);
|
||||||
@@ -461,17 +471,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
reset,
|
reset,
|
||||||
setError,
|
setError,
|
||||||
setValue,
|
setValue,
|
||||||
getValues,
|
|
||||||
control,
|
control,
|
||||||
formState: { isDirty, errors, isValid, dirtyFields },
|
formState: { isDirty, errors, isValid, dirtyFields },
|
||||||
} = methods;
|
} = methods;
|
||||||
const {
|
|
||||||
storedRxResume,
|
|
||||||
getBaseResumeIdForMode,
|
|
||||||
setBaseResumeIdForMode,
|
|
||||||
syncBaseResumeIdsForMode,
|
|
||||||
} = useRxResumeConfigState(settings);
|
|
||||||
|
|
||||||
const settingsQuery = useQuery({
|
const settingsQuery = useQuery({
|
||||||
queryKey: queryKeys.settings.current(),
|
queryKey: queryKeys.settings.current(),
|
||||||
queryFn: api.getSettings,
|
queryFn: api.getSettings,
|
||||||
@@ -487,18 +489,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const isLoadingBackups = backupsQuery.isLoading;
|
const isLoadingBackups = backupsQuery.isLoading;
|
||||||
useQueryErrorToast(backupsQuery.error, "Failed to load backups");
|
useQueryErrorToast(backupsQuery.error, "Failed to load backups");
|
||||||
|
|
||||||
const rxresumeMode = (settings?.rxresumeMode?.value ?? "v5") as RxResumeMode;
|
|
||||||
const selectedRxresumeMode = (useWatch({
|
|
||||||
control,
|
|
||||||
name: "rxresumeMode",
|
|
||||||
}) ?? rxresumeMode) as RxResumeMode;
|
|
||||||
const resumeProjectsValue = useWatch({
|
const resumeProjectsValue = useWatch({
|
||||||
control,
|
control,
|
||||||
name: "resumeProjects",
|
name: "resumeProjects",
|
||||||
});
|
});
|
||||||
const hasRxResumeAccess = Boolean(
|
|
||||||
rxresumeValidationStatuses[selectedRxresumeMode].valid,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsQuery.data) return;
|
if (!settingsQuery.data) return;
|
||||||
@@ -508,77 +502,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
useQueryErrorToast(settingsQuery.error, "Failed to load settings");
|
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 derived = getDerivedSettings(settings);
|
||||||
const {
|
const {
|
||||||
model,
|
model,
|
||||||
@@ -592,6 +515,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
backup,
|
backup,
|
||||||
scoring,
|
scoring,
|
||||||
jobSearchProfile,
|
jobSearchProfile,
|
||||||
|
jobSources,
|
||||||
} = derived;
|
} = derived;
|
||||||
|
|
||||||
const handleCreateBackup = async () => {
|
const handleCreateBackup = async () => {
|
||||||
@@ -652,105 +576,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [refreshReadiness]);
|
}, [refreshReadiness]);
|
||||||
|
|
||||||
const setRxResumeValidationStatus = useCallback(
|
const effectiveMaxProjectsTotal = profileProjects.length;
|
||||||
(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 lockedCount = resumeProjectsValue?.lockedProjectIds.length ?? 0;
|
const lockedCount = resumeProjectsValue?.lockedProjectIds.length ?? 0;
|
||||||
|
|
||||||
const canSave = isDirty && isValid;
|
const canSave = isDirty && isValid;
|
||||||
@@ -781,14 +607,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
const envPayload: Partial<UpdateSettingsInput> = {};
|
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) {
|
if (dirtyFields.localResumeProfilePath) {
|
||||||
envPayload.localResumeProfilePath = normalizeString(
|
envPayload.localResumeProfilePath = normalizeString(
|
||||||
data.localResumeProfilePath,
|
data.localResumeProfilePath,
|
||||||
@@ -834,16 +652,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
if (value !== undefined) envPayload.llmApiKey = value;
|
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) {
|
if (dirtyFields.ukvisajobsPassword) {
|
||||||
const value = normalizePrivateInput(data.ukvisajobsPassword);
|
const value = normalizePrivateInput(data.ukvisajobsPassword);
|
||||||
if (value !== undefined) envPayload.ukvisajobsPassword = value;
|
if (value !== undefined) envPayload.ukvisajobsPassword = value;
|
||||||
@@ -890,12 +698,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
||||||
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
||||||
resumeProjects: resumeProjectsOverride,
|
resumeProjects: resumeProjectsOverride,
|
||||||
...(dirtyFields.rxresumeMode
|
|
||||||
? { rxresumeMode: data.rxresumeMode ?? "v5" }
|
|
||||||
: {}),
|
|
||||||
...(dirtyFields.rxresumeBaseResumeId
|
|
||||||
? { rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId) }
|
|
||||||
: {}),
|
|
||||||
showSponsorInfo: nullIfSame(
|
showSponsorInfo: nullIfSame(
|
||||||
data.showSponsorInfo,
|
data.showSponsorInfo,
|
||||||
display.showSponsorInfo.default,
|
display.showSponsorInfo.default,
|
||||||
@@ -951,84 +753,107 @@ export const SettingsPage: React.FC = () => {
|
|||||||
normalizeString(data.scoringInstructions),
|
normalizeString(data.scoringInstructions),
|
||||||
scoring.scoringInstructions.default,
|
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,
|
...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);
|
const updated = await updateSettingsMutation.mutateAsync(payload);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1048,9 +873,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setSettings(updated);
|
setSettings(updated);
|
||||||
reset(mapSettingsToForm(updated));
|
reset(mapSettingsToForm(updated));
|
||||||
toast.success("Settings saved");
|
toast.success("Settings saved");
|
||||||
if (rxResumeSaveWarningMessage) {
|
|
||||||
toast.info(rxResumeSaveWarningMessage);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Failed to save settings";
|
error instanceof Error ? error.message : "Failed to save settings";
|
||||||
@@ -1184,29 +1006,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
<ReactiveResumeSection
|
<ResumeSettingsSection
|
||||||
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
|
profileProjects={profileProjects}
|
||||||
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}
|
|
||||||
lockedCount={lockedCount}
|
lockedCount={lockedCount}
|
||||||
maxProjectsTotal={effectiveMaxProjectsTotal}
|
maxProjectsTotal={effectiveMaxProjectsTotal}
|
||||||
isProjectsLoading={isFetchingRxResumeProjects}
|
localResumeFileConfigured={Boolean(
|
||||||
|
settings?.localResumeFileConfigured,
|
||||||
|
)}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
@@ -1232,6 +1038,15 @@ export const SettingsPage: React.FC = () => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
<KeywordSetsSettingsSection
|
||||||
|
isLoading={isLoading}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
<JobSourcesSettingsSection
|
||||||
|
values={jobSources}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
<ScoringSettingsSection
|
<ScoringSettingsSection
|
||||||
values={scoring}
|
values={scoring}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ beforeEach(() => {
|
|||||||
status: "applied",
|
status: "applied",
|
||||||
suitabilityScore: null,
|
suitabilityScore: null,
|
||||||
sponsorMatchScore: null,
|
sponsorMatchScore: null,
|
||||||
|
sponsorshipSignals: null,
|
||||||
jobType: null,
|
jobType: null,
|
||||||
jobFunction: null,
|
jobFunction: null,
|
||||||
salaryMinAmount: null,
|
salaryMinAmount: null,
|
||||||
@@ -137,6 +138,7 @@ beforeEach(() => {
|
|||||||
status: "applied",
|
status: "applied",
|
||||||
suitabilityScore: null,
|
suitabilityScore: null,
|
||||||
sponsorMatchScore: null,
|
sponsorMatchScore: null,
|
||||||
|
sponsorshipSignals: null,
|
||||||
jobType: null,
|
jobType: null,
|
||||||
jobFunction: null,
|
jobFunction: null,
|
||||||
salaryMinAmount: null,
|
salaryMinAmount: null,
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ vi.mock("@/lib/user-location", () => ({
|
|||||||
getDetectedCountryKey: getDetectedCountryKeyMock,
|
getDetectedCountryKey: getDetectedCountryKeyMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./KeywordSetPicker", () => ({
|
||||||
|
KeywordSetPicker: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
describe("AutomaticRunTab", () => {
|
describe("AutomaticRunTab", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getDetectedCountryKeyMock.mockReset();
|
getDetectedCountryKeyMock.mockReset();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
normalizeCountryKey,
|
normalizeCountryKey,
|
||||||
SUPPORTED_COUNTRY_KEYS,
|
SUPPORTED_COUNTRY_KEYS,
|
||||||
} from "@shared/location-support.js";
|
} 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 { Loader2, Sparkles } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
WORKPLACE_TYPE_OPTIONS,
|
WORKPLACE_TYPE_OPTIONS,
|
||||||
type WorkplaceType,
|
type WorkplaceType,
|
||||||
} from "./automatic-run";
|
} from "./automatic-run";
|
||||||
|
import { KeywordSetPicker } from "./KeywordSetPicker";
|
||||||
import { TokenizedInput } from "./TokenizedInput";
|
import { TokenizedInput } from "./TokenizedInput";
|
||||||
|
|
||||||
interface AutomaticRunTabProps {
|
interface AutomaticRunTabProps {
|
||||||
@@ -62,7 +63,8 @@ const DEFAULT_VALUES: AutomaticRunValues = {
|
|||||||
topN: 10,
|
topN: 10,
|
||||||
minSuitabilityScore: 50,
|
minSuitabilityScore: 50,
|
||||||
searchTerms: ["web developer"],
|
searchTerms: ["web developer"],
|
||||||
runBudget: 200,
|
activeKeywordSetId: null,
|
||||||
|
runBudget: 500,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||||
@@ -163,6 +165,9 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const [activeKeywordSetId, setActiveKeywordSetId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const { watch, reset, setValue } = useForm<AutomaticRunFormValues>({
|
const { watch, reset, setValue } = useForm<AutomaticRunFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
topN: String(DEFAULT_VALUES.topN),
|
topN: String(DEFAULT_VALUES.topN),
|
||||||
@@ -187,6 +192,25 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
const searchTerms = watch("searchTerms");
|
const searchTerms = watch("searchTerms");
|
||||||
const searchTermDraft = watch("searchTermDraft");
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const memory = loadAutomaticRunMemory();
|
const memory = loadAutomaticRunMemory();
|
||||||
@@ -274,6 +298,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
cityLocations,
|
cityLocations,
|
||||||
workplaceTypes: normalizeWorkplaceTypes(workplaceTypes),
|
workplaceTypes: normalizeWorkplaceTypes(workplaceTypes),
|
||||||
searchTerms,
|
searchTerms,
|
||||||
|
activeKeywordSetId,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
topNInput,
|
topNInput,
|
||||||
@@ -283,6 +308,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
cityLocations,
|
cityLocations,
|
||||||
workplaceTypes,
|
workplaceTypes,
|
||||||
searchTerms,
|
searchTerms,
|
||||||
|
activeKeywordSetId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const workplaceTypeSelectionInvalid = workplaceTypes.length === 0;
|
const workplaceTypeSelectionInvalid = workplaceTypes.length === 0;
|
||||||
@@ -320,13 +346,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
const filtered = pipelineSources.filter((source) =>
|
const filtered = pipelineSources.filter((source) =>
|
||||||
isSourceAvailableForRun(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 === pipelineSources.length) return;
|
||||||
if (filtered.length > 0) {
|
if (filtered.length > 0) {
|
||||||
onSetPipelineSources(filtered);
|
onSetPipelineSources(filtered);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (compatibleEnabledSources.length > 0) {
|
if (compatibleEnabledSources.length > 0) {
|
||||||
onSetPipelineSources([compatibleEnabledSources[0]]);
|
onSetPipelineSources([...compatibleEnabledSources]);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
compatibleEnabledSources,
|
compatibleEnabledSources,
|
||||||
@@ -335,14 +368,19 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
pipelineSources,
|
pipelineSources,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mergedDiscoveryTerms = useMemo(
|
const mergedDiscoveryTerms = useMemo(() => {
|
||||||
() =>
|
if (values.activeKeywordSetId) {
|
||||||
mergeDiscoverySearchTerms(
|
return values.searchTerms.map((t) => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return mergeDiscoverySearchTerms(
|
||||||
values.searchTerms,
|
values.searchTerms,
|
||||||
settings?.jobSearchProfile?.value?.targetRoles,
|
settings?.jobSearchProfile?.value?.targetRoles,
|
||||||
),
|
|
||||||
[values.searchTerms, settings?.jobSearchProfile?.value?.targetRoles],
|
|
||||||
);
|
);
|
||||||
|
}, [
|
||||||
|
values.searchTerms,
|
||||||
|
values.activeKeywordSetId,
|
||||||
|
settings?.jobSearchProfile?.value?.targetRoles,
|
||||||
|
]);
|
||||||
|
|
||||||
const estimate = useMemo(
|
const estimate = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -600,7 +638,11 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>Search terms</CardTitle>
|
<CardTitle>Search terms</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
|
<KeywordSetPicker
|
||||||
|
disabled={isSaving || isPipelineRunning}
|
||||||
|
onActiveSetChange={handleActiveKeywordSetChange}
|
||||||
|
/>
|
||||||
<TokenizedInput
|
<TokenizedInput
|
||||||
id="search-terms-input"
|
id="search-terms-input"
|
||||||
values={searchTerms}
|
values={searchTerms}
|
||||||
@@ -619,10 +661,37 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
Sources ({compatiblePipelineSources.length}/
|
Sources ({compatiblePipelineSources.length}/
|
||||||
{compatibleEnabledSources.length})
|
{compatibleEnabledSources.length})
|
||||||
</CardTitle>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByPlaceholderText(
|
screen.getByPlaceholderText(
|
||||||
"Search jobs by job title or company name...",
|
"Search jobs by title, company, location, or sponsorship signals...",
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -52,7 +52,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@disc" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -70,7 +70,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@ready" } });
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
@@ -91,7 +91,7 @@ describe("JobCommandBar", () => {
|
|||||||
expect(dialog.className).not.toContain("border-sky-500/50");
|
expect(dialog.className).not.toContain("border-sky-500/50");
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@disc" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -118,7 +118,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@ready" } });
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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: "@" } });
|
fireEvent.change(input, { target: { value: "@" } });
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@prog" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -192,7 +192,7 @@ describe("JobCommandBar", () => {
|
|||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText(
|
screen.getByPlaceholderText(
|
||||||
"Search jobs by job title or company name...",
|
"Search jobs by title, company, location, or sponsorship signals...",
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
target: { value: "Globex" },
|
target: { value: "Globex" },
|
||||||
@@ -220,7 +220,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@disc" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -257,7 +257,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@disc" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -280,7 +280,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@ready" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -302,7 +302,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@ready" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -323,7 +323,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@ready" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -342,7 +342,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@ready" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -353,7 +353,7 @@ describe("JobCommandBar", () => {
|
|||||||
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
|
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByPlaceholderText(
|
screen.getByPlaceholderText(
|
||||||
"Search jobs by job title or company name...",
|
"Search jobs by title, company, location, or sponsorship signals...",
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -368,7 +368,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@ready" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -390,7 +390,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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.change(input, { target: { value: "@all" } });
|
||||||
fireEvent.keyDown(input, { key: "Tab" });
|
fireEvent.keyDown(input, { key: "Tab" });
|
||||||
@@ -423,7 +423,7 @@ describe("JobCommandBar", () => {
|
|||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText(
|
screen.getByPlaceholderText(
|
||||||
"Search jobs by job title or company name...",
|
"Search jobs by title, company, location, or sponsorship signals...",
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
target: { value: "Globex" },
|
target: { value: "Globex" },
|
||||||
@@ -472,7 +472,7 @@ describe("JobCommandBar", () => {
|
|||||||
|
|
||||||
openWithKeyboard();
|
openWithKeyboard();
|
||||||
const input = screen.getByPlaceholderText(
|
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"];
|
const lockTokens = ["@ready", "@disc", "@applied", "@skip", "@exp"];
|
||||||
|
|
||||||
|
|||||||
@@ -169,10 +169,11 @@ export const JobCommandBar: React.FC<JobCommandBarProps> = ({
|
|||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">Job Search</DialogTitle>
|
<DialogTitle className="sr-only">Job Search</DialogTitle>
|
||||||
<DialogDescription className="sr-only">
|
<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>
|
</DialogDescription>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search jobs by job title or company name..."
|
placeholder="Search jobs by title, company, location, or sponsorship signals..."
|
||||||
value={query}
|
value={query}
|
||||||
onValueChange={setQuery}
|
onValueChange={setQuery}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sponsorshipSignalSearchHaystack } from "@shared/sponsorship-signals";
|
||||||
import type { JobListItem, JobStatus } from "@shared/types.js";
|
import type { JobListItem, JobStatus } from "@shared/types.js";
|
||||||
import type { FilterTab } from "./constants";
|
import type { FilterTab } from "./constants";
|
||||||
|
|
||||||
@@ -144,12 +145,23 @@ export const computeJobMatchScore = (
|
|||||||
job.location ?? "",
|
job.location ?? "",
|
||||||
normalizedQuery,
|
normalizedQuery,
|
||||||
);
|
);
|
||||||
|
const sponsorshipScore = computeFieldMatchScore(
|
||||||
|
sponsorshipSignalSearchHaystack(job.sponsorshipSignals),
|
||||||
|
normalizedQuery,
|
||||||
|
);
|
||||||
|
|
||||||
// Prefer title/company matches over location when scores tie.
|
// Prefer title/company matches over location when scores tie.
|
||||||
// Only apply bias when a field actually matched.
|
// Only apply bias when a field actually matched.
|
||||||
const titleRankedScore = titleScore > 0 ? titleScore + 8 : 0;
|
const titleRankedScore = titleScore > 0 ? titleScore + 8 : 0;
|
||||||
const employerRankedScore = employerScore > 0 ? employerScore + 12 : 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 = (
|
export const groupJobsForCommandBar = (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SponsorshipSignalsPills } from "@client/components/SponsorshipSignalsPills";
|
||||||
import type { JobListItem } from "@shared/types.js";
|
import type { JobListItem } from "@shared/types.js";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { defaultStatusToken, statusTokens } from "./constants";
|
import { defaultStatusToken, statusTokens } from "./constants";
|
||||||
@@ -77,6 +78,11 @@ export const JobRowContent = ({
|
|||||||
Found {formatDiscoveredAt(job.discoveredAt)}
|
Found {formatDiscoveredAt(job.discoveredAt)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<SponsorshipSignalsPills
|
||||||
|
sponsorshipSignals={job.sponsorshipSignals}
|
||||||
|
size="xs"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasScore && (
|
{hasScore && (
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import * as api from "@client/api";
|
||||||
|
import type { KeywordSet } from "@shared/types";
|
||||||
|
import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type KeywordSetPickerProps = {
|
||||||
|
disabled?: boolean;
|
||||||
|
onActiveSetChange: (set: KeywordSet) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KeywordSetPicker: React.FC<KeywordSetPickerProps> = ({
|
||||||
|
disabled = false,
|
||||||
|
onActiveSetChange,
|
||||||
|
}) => {
|
||||||
|
const [sets, setSets] = useState<KeywordSet[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newSetName, setNewSetName] = useState("");
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const onActiveSetChangeRef = useRef(onActiveSetChange);
|
||||||
|
const lastNotifiedIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
onActiveSetChangeRef.current = onActiveSetChange;
|
||||||
|
|
||||||
|
const notifyActiveSet = useCallback((set: KeywordSet) => {
|
||||||
|
if (lastNotifiedIdRef.current === set.id) return;
|
||||||
|
lastNotifiedIdRef.current = set.id;
|
||||||
|
onActiveSetChangeRef.current(set);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSets = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await api.listKeywordSets();
|
||||||
|
setSets(list);
|
||||||
|
const active = list.find((s) => s.isActive) ?? list[0];
|
||||||
|
if (active) notifyActiveSet(active);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load keyword sets");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [notifyActiveSet]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSets();
|
||||||
|
}, [loadSets]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
async (setId: string) => {
|
||||||
|
if (disabled || loading) return;
|
||||||
|
const current = sets.find((s) => s.id === setId);
|
||||||
|
if (current?.isActive) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const activated = await api.activateKeywordSet(setId);
|
||||||
|
setSets((prev) =>
|
||||||
|
prev.map((s) => ({ ...s, isActive: s.id === activated.id })),
|
||||||
|
);
|
||||||
|
notifyActiveSet(activated);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to switch keyword set");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, loading, notifyActiveSet, sets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async () => {
|
||||||
|
const name = newSetName.trim();
|
||||||
|
if (!name) {
|
||||||
|
toast.error("Enter a name for the keyword set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const created = await api.createKeywordSet({ name, terms: [] });
|
||||||
|
const activated = await api.activateKeywordSet(created.id);
|
||||||
|
setSets((prev) => [
|
||||||
|
...prev.map((s) => ({ ...s, isActive: false })),
|
||||||
|
{ ...activated, isActive: true },
|
||||||
|
]);
|
||||||
|
notifyActiveSet(activated);
|
||||||
|
setNewSetName("");
|
||||||
|
setShowCreate(false);
|
||||||
|
toast.success(`Keyword set "${activated.name}" created`);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to create keyword set";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}, [newSetName, notifyActiveSet]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (setId: string) => {
|
||||||
|
if (disabled || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.deleteKeywordSet(setId);
|
||||||
|
lastNotifiedIdRef.current = null;
|
||||||
|
const list = await api.listKeywordSets();
|
||||||
|
setSets(list);
|
||||||
|
const active = list.find((s) => s.isActive) ?? list[0];
|
||||||
|
if (active) notifyActiveSet(active);
|
||||||
|
toast.success("Keyword set deleted");
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to delete keyword set";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, loading, notifyActiveSet],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSet = sets.find((s) => s.isActive) ?? sets[0];
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium shrink-0">Active set</span>
|
||||||
|
{activeSet ? (
|
||||||
|
<Select
|
||||||
|
value={activeSet.id}
|
||||||
|
onValueChange={handleSelect}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-[min(100%,240px)]"
|
||||||
|
aria-label="Keyword set"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Keyword set" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sets.map((set) => (
|
||||||
|
<SelectItem key={set.id} value={set.id}>
|
||||||
|
{set.name}
|
||||||
|
{set.isActive ? " (active)" : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{loading ? "Loading sets…" : "No keyword sets"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || creating}
|
||||||
|
onClick={() => setShowCreate((v) => !v)}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
New set
|
||||||
|
</Button>
|
||||||
|
{activeSet && sets.length > 1 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={() => void handleDelete(activeSet.id)}
|
||||||
|
title={`Delete "${activeSet.name}"`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{showCreate ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={newSetName}
|
||||||
|
onChange={(e) => setNewSetName(e.target.value)}
|
||||||
|
placeholder="e.g. SDET, Caseware, Lead"
|
||||||
|
className="max-w-xs"
|
||||||
|
disabled={disabled || creating}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || creating}
|
||||||
|
onClick={() => void handleCreate()}
|
||||||
|
>
|
||||||
|
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{sets.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
|
{sets.map((set) => {
|
||||||
|
const isActive = set.id === activeSet?.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={set.id}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || loading || isActive}
|
||||||
|
onClick={() => void handleSelect(set.id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-primary bg-primary/10 text-foreground"
|
||||||
|
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{set.name}
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
({set.terms.length})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : !loading ? (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
No keyword sets loaded. Use "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(),
|
onApplySettingsCompanySkipListChange: vi.fn(),
|
||||||
sponsorFilter: "all" as SponsorFilter,
|
sponsorFilter: "all" as SponsorFilter,
|
||||||
onSponsorFilterChange: vi.fn(),
|
onSponsorFilterChange: vi.fn(),
|
||||||
|
sponsorshipSignalsFilter: [],
|
||||||
|
onSponsorshipSignalsFilterChange: vi.fn(),
|
||||||
|
hideSponsorBlockers: false,
|
||||||
|
onHideSponsorBlockersChange: vi.fn(),
|
||||||
workplaceFilter: "all" as WorkplaceFilter,
|
workplaceFilter: "all" as WorkplaceFilter,
|
||||||
onWorkplaceFilterChange: vi.fn(),
|
onWorkplaceFilterChange: vi.fn(),
|
||||||
salaryFilter: {
|
salaryFilter: {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { KbdHint } from "@client/components/KbdHint";
|
import { KbdHint } from "@client/components/KbdHint";
|
||||||
import { getDisplayKey, SHORTCUTS } from "@client/lib/shortcut-map";
|
import { getDisplayKey, SHORTCUTS } from "@client/lib/shortcut-map";
|
||||||
import { formatCountryLabel } from "@shared/location-support";
|
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 type { JobSource } from "@shared/types.js";
|
||||||
import { Filter, Search } from "lucide-react";
|
import { Filter, Search } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
@@ -64,6 +71,10 @@ interface OrchestratorFiltersProps {
|
|||||||
onApplySettingsCompanySkipListChange: (value: boolean) => void;
|
onApplySettingsCompanySkipListChange: (value: boolean) => void;
|
||||||
sponsorFilter: SponsorFilter;
|
sponsorFilter: SponsorFilter;
|
||||||
onSponsorFilterChange: (value: SponsorFilter) => void;
|
onSponsorFilterChange: (value: SponsorFilter) => void;
|
||||||
|
sponsorshipSignalsFilter: SponsorshipSignalId[];
|
||||||
|
onSponsorshipSignalsFilterChange: (values: SponsorshipSignalId[]) => void;
|
||||||
|
hideSponsorBlockers: boolean;
|
||||||
|
onHideSponsorBlockersChange: (value: boolean) => void;
|
||||||
workplaceFilter: WorkplaceFilter;
|
workplaceFilter: WorkplaceFilter;
|
||||||
onWorkplaceFilterChange: (value: WorkplaceFilter) => void;
|
onWorkplaceFilterChange: (value: WorkplaceFilter) => void;
|
||||||
salaryFilter: SalaryFilter;
|
salaryFilter: SalaryFilter;
|
||||||
@@ -174,6 +185,10 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
|||||||
onApplySettingsCompanySkipListChange,
|
onApplySettingsCompanySkipListChange,
|
||||||
sponsorFilter,
|
sponsorFilter,
|
||||||
onSponsorFilterChange,
|
onSponsorFilterChange,
|
||||||
|
sponsorshipSignalsFilter,
|
||||||
|
onSponsorshipSignalsFilterChange,
|
||||||
|
hideSponsorBlockers,
|
||||||
|
onHideSponsorBlockersChange,
|
||||||
workplaceFilter,
|
workplaceFilter,
|
||||||
onWorkplaceFilterChange,
|
onWorkplaceFilterChange,
|
||||||
salaryFilter,
|
salaryFilter,
|
||||||
@@ -239,6 +254,8 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
|||||||
) +
|
) +
|
||||||
Number(!applySettingsCompanySkipList) +
|
Number(!applySettingsCompanySkipList) +
|
||||||
Number(sponsorFilter !== "all") +
|
Number(sponsorFilter !== "all") +
|
||||||
|
Number(sponsorshipSignalsFilter.length > 0) +
|
||||||
|
Number(hideSponsorBlockers) +
|
||||||
Number(workplaceFilter !== "all") +
|
Number(workplaceFilter !== "all") +
|
||||||
Number(
|
Number(
|
||||||
(typeof salaryFilter.min === "number" && salaryFilter.min > 0) ||
|
(typeof salaryFilter.min === "number" && salaryFilter.min > 0) ||
|
||||||
@@ -255,6 +272,8 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
|||||||
employerExcludeFilter.length,
|
employerExcludeFilter.length,
|
||||||
applySettingsCompanySkipList,
|
applySettingsCompanySkipList,
|
||||||
sponsorFilter,
|
sponsorFilter,
|
||||||
|
sponsorshipSignalsFilter.length,
|
||||||
|
hideSponsorBlockers,
|
||||||
workplaceFilter,
|
workplaceFilter,
|
||||||
salaryFilter.min,
|
salaryFilter.min,
|
||||||
salaryFilter.max,
|
salaryFilter.max,
|
||||||
@@ -553,7 +572,8 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>Sponsor status</CardTitle>
|
<CardTitle>Sponsor status</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
{sponsorOptions.map((option) => (
|
{sponsorOptions.map((option) => (
|
||||||
<Button
|
<Button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
@@ -569,6 +589,143 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
|||||||
{option.label}
|
{option.label}
|
||||||
</Button>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
AUTOMATIC_PRESETS,
|
AUTOMATIC_PRESETS,
|
||||||
|
buildMaxCoverageDiscoveryLimits,
|
||||||
calculateAutomaticEstimate,
|
calculateAutomaticEstimate,
|
||||||
deriveExtractorLimits,
|
deriveExtractorLimits,
|
||||||
mergeDiscoverySearchTerms,
|
mergeDiscoverySearchTerms,
|
||||||
@@ -26,6 +27,7 @@ describe("automatic-run utilities", () => {
|
|||||||
topN: 10,
|
topN: 10,
|
||||||
minSuitabilityScore: 50,
|
minSuitabilityScore: 50,
|
||||||
searchTerms: ["backend", "platform"],
|
searchTerms: ["backend", "platform"],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 100,
|
runBudget: 100,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
@@ -71,6 +73,7 @@ describe("automatic-run utilities", () => {
|
|||||||
topN: 10,
|
topN: 10,
|
||||||
minSuitabilityScore: 50,
|
minSuitabilityScore: 50,
|
||||||
searchTerms: [],
|
searchTerms: [],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 750,
|
runBudget: 750,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
@@ -112,6 +115,7 @@ describe("automatic-run utilities", () => {
|
|||||||
topN: 10,
|
topN: 10,
|
||||||
minSuitabilityScore: 50,
|
minSuitabilityScore: 50,
|
||||||
searchTerms: ["backend", "platform"],
|
searchTerms: ["backend", "platform"],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 120,
|
runBudget: 120,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
@@ -130,6 +134,7 @@ describe("automatic-run utilities", () => {
|
|||||||
topN: 10,
|
topN: 10,
|
||||||
minSuitabilityScore: 50,
|
minSuitabilityScore: 50,
|
||||||
searchTerms: ["backend", "platform"],
|
searchTerms: ["backend", "platform"],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 120,
|
runBudget: 120,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
@@ -148,6 +153,7 @@ describe("automatic-run utilities", () => {
|
|||||||
topN: 10,
|
topN: 10,
|
||||||
minSuitabilityScore: 50,
|
minSuitabilityScore: 50,
|
||||||
searchTerms: ["backend", "platform"],
|
searchTerms: ["backend", "platform"],
|
||||||
|
activeKeywordSetId: null,
|
||||||
runBudget: 120,
|
runBudget: 120,
|
||||||
country: "united kingdom",
|
country: "united kingdom",
|
||||||
cityLocations: [],
|
cityLocations: [],
|
||||||
@@ -159,4 +165,16 @@ describe("automatic-run utilities", () => {
|
|||||||
expect(estimate.discovered.cap).toBeGreaterThan(0);
|
expect(estimate.discovered.cap).toBeGreaterThan(0);
|
||||||
expect(estimate.discovered.cap).toBeLessThanOrEqual(120);
|
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,
|
parseSearchCitiesSetting,
|
||||||
serializeSearchCitiesSetting,
|
serializeSearchCitiesSetting,
|
||||||
} from "@shared/search-cities.js";
|
} from "@shared/search-cities.js";
|
||||||
|
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||||
import type { JobSource } from "@shared/types";
|
import type { JobSource } from "@shared/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +40,7 @@ export interface AutomaticRunValues {
|
|||||||
topN: number;
|
topN: number;
|
||||||
minSuitabilityScore: number;
|
minSuitabilityScore: number;
|
||||||
searchTerms: string[];
|
searchTerms: string[];
|
||||||
|
activeKeywordSetId: string | null;
|
||||||
runBudget: number;
|
runBudget: number;
|
||||||
country: string;
|
country: string;
|
||||||
cityLocations: string[];
|
cityLocations: string[];
|
||||||
@@ -80,7 +82,7 @@ export const AUTOMATIC_PRESETS: Record<
|
|||||||
detailed: {
|
detailed: {
|
||||||
topN: 20,
|
topN: 20,
|
||||||
minSuitabilityScore: 35,
|
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[] {
|
export function parseSearchTermsInput(input: string): string[] {
|
||||||
return input
|
return input
|
||||||
.split(/[\n,]/g)
|
.split(/[\n,]/g)
|
||||||
|
|||||||
@@ -3,8 +3,17 @@ import {
|
|||||||
EXTRACTOR_SOURCE_METADATA,
|
EXTRACTOR_SOURCE_METADATA,
|
||||||
PIPELINE_EXTRACTOR_SOURCE_IDS,
|
PIPELINE_EXTRACTOR_SOURCE_IDS,
|
||||||
} from "@shared/extractors";
|
} from "@shared/extractors";
|
||||||
|
import {
|
||||||
|
SPONSORSHIP_SIGNAL_IDS,
|
||||||
|
type SponsorshipSignalId,
|
||||||
|
} from "@shared/sponsorship-signals";
|
||||||
import type { JobSource, JobStatus } from "@shared/types";
|
import type { JobSource, JobStatus } from "@shared/types";
|
||||||
|
|
||||||
|
export type { SponsorshipSignalId };
|
||||||
|
export const sponsorshipSignalFilterOptions = [
|
||||||
|
...SPONSORSHIP_SIGNAL_IDS,
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const DEFAULT_PIPELINE_SOURCES: JobSource[] = [
|
export const DEFAULT_PIPELINE_SOURCES: JobSource[] = [
|
||||||
"gradcracker",
|
"gradcracker",
|
||||||
"indeed",
|
"indeed",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { serializeSponsorshipSignals } from "@shared/sponsorship-signals";
|
||||||
import { createJob } from "@shared/testing/factories";
|
import { createJob } from "@shared/testing/factories";
|
||||||
import type { Job } from "@shared/types";
|
import type { Job } from "@shared/types";
|
||||||
import { renderHook } from "@testing-library/react";
|
import { renderHook } from "@testing-library/react";
|
||||||
@@ -36,6 +37,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{
|
{
|
||||||
@@ -67,6 +70,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{
|
{
|
||||||
@@ -99,6 +104,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"confirmed",
|
"confirmed",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{
|
{
|
||||||
@@ -111,6 +118,90 @@ describe("useFilteredJobs", () => {
|
|||||||
expect(result.current.map((job) => job.id)).toEqual(["confirmed"]);
|
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", () => {
|
it("filters by salary range using structured and text salary fields", () => {
|
||||||
const jobs: Job[] = [
|
const jobs: Job[] = [
|
||||||
{ ...baseJob, id: "structured", salaryMinAmount: 70000 },
|
{ ...baseJob, id: "structured", salaryMinAmount: 70000 },
|
||||||
@@ -128,6 +219,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "between", min: 60000, max: 80000 },
|
{ mode: "between", min: 60000, max: 80000 },
|
||||||
{
|
{
|
||||||
@@ -160,6 +253,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{
|
{
|
||||||
@@ -193,6 +288,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"remote",
|
"remote",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -209,6 +306,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"not_remote",
|
"not_remote",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -225,6 +324,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"unknown",
|
"unknown",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -249,6 +350,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -288,6 +391,8 @@ describe("useFilteredJobs", () => {
|
|||||||
["united kingdom"],
|
["united kingdom"],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -312,6 +417,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -359,6 +466,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
["united kingdom"],
|
["united kingdom"],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -381,6 +490,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -412,6 +523,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -443,6 +556,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -466,6 +581,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -497,6 +614,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
@@ -520,6 +639,8 @@ describe("useFilteredJobs", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
"all",
|
"all",
|
||||||
|
[],
|
||||||
|
false,
|
||||||
"all",
|
"all",
|
||||||
{ mode: "at_least", min: null, max: null },
|
{ mode: "at_least", min: null, max: null },
|
||||||
{ key: "score", direction: "desc" },
|
{ key: "score", direction: "desc" },
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { DuplicateDismissReason } from "@client/lib/job-dedup";
|
import type { DuplicateDismissReason } from "@client/lib/job-dedup";
|
||||||
import { jobMatchesAllowedCountry } from "@shared/blocked-countries";
|
import { jobMatchesAllowedCountry } from "@shared/blocked-countries";
|
||||||
import { textMatchesKeyword } from "@shared/keyword-match";
|
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 type { JobListItem, JobSource } from "@shared/types";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type {
|
import type {
|
||||||
@@ -57,6 +62,8 @@ export const useFilteredJobs = (
|
|||||||
countriesFilter: string[],
|
countriesFilter: string[],
|
||||||
countriesExcludeFilter: string[],
|
countriesExcludeFilter: string[],
|
||||||
sponsorFilter: SponsorFilter,
|
sponsorFilter: SponsorFilter,
|
||||||
|
sponsorshipSignalsFilter: SponsorshipSignalId[],
|
||||||
|
hideSponsorBlockers: boolean,
|
||||||
workplaceFilter: WorkplaceFilter,
|
workplaceFilter: WorkplaceFilter,
|
||||||
salaryFilter: SalaryFilter,
|
salaryFilter: SalaryFilter,
|
||||||
sort: JobSort,
|
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") {
|
if (workplaceFilter !== "all") {
|
||||||
filtered = filtered.filter((job) => {
|
filtered = filtered.filter((job) => {
|
||||||
if (workplaceFilter === "remote") return job.isRemote === true;
|
if (workplaceFilter === "remote") return job.isRemote === true;
|
||||||
@@ -252,6 +274,8 @@ export const useFilteredJobs = (
|
|||||||
countriesFilter,
|
countriesFilter,
|
||||||
countriesExcludeFilter,
|
countriesExcludeFilter,
|
||||||
sponsorFilter,
|
sponsorFilter,
|
||||||
|
sponsorshipSignalsFilter,
|
||||||
|
hideSponsorBlockers,
|
||||||
workplaceFilter,
|
workplaceFilter,
|
||||||
salaryFilter,
|
salaryFilter,
|
||||||
sort,
|
sort,
|
||||||
|
|||||||
@@ -635,6 +635,7 @@ describe("useOrchestratorData", () => {
|
|||||||
status: "discovered",
|
status: "discovered",
|
||||||
suitabilityScore: null,
|
suitabilityScore: null,
|
||||||
sponsorMatchScore: null,
|
sponsorMatchScore: null,
|
||||||
|
sponsorshipSignals: null,
|
||||||
jobType: null,
|
jobType: null,
|
||||||
jobFunction: null,
|
jobFunction: null,
|
||||||
salaryMinAmount: null,
|
salaryMinAmount: null,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
normalizeCountryKey,
|
normalizeCountryKey,
|
||||||
SUPPORTED_COUNTRY_KEYS,
|
SUPPORTED_COUNTRY_KEYS,
|
||||||
} from "@shared/location-support";
|
} from "@shared/location-support";
|
||||||
|
import type { SponsorshipSignalId } from "@shared/sponsorship-signals";
|
||||||
import type { JobSource } from "@shared/types";
|
import type { JobSource } from "@shared/types";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
@@ -13,7 +14,7 @@ import type {
|
|||||||
SponsorFilter,
|
SponsorFilter,
|
||||||
WorkplaceFilter,
|
WorkplaceFilter,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { DEFAULT_SORT } from "./constants";
|
import { DEFAULT_SORT, sponsorshipSignalFilterOptions } from "./constants";
|
||||||
|
|
||||||
const allowedSponsorFilters: SponsorFilter[] = [
|
const allowedSponsorFilters: SponsorFilter[] = [
|
||||||
"all",
|
"all",
|
||||||
@@ -43,6 +44,10 @@ const allowedWorkplaceFilters: WorkplaceFilter[] = [
|
|||||||
"unknown",
|
"unknown",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const allowedSponsorshipSignals = new Set<string>(
|
||||||
|
sponsorshipSignalFilterOptions,
|
||||||
|
);
|
||||||
|
|
||||||
const allowedJobSources = new Set<string>(EXTRACTOR_SOURCE_IDS);
|
const allowedJobSources = new Set<string>(EXTRACTOR_SOURCE_IDS);
|
||||||
const allowedCountryKeys = new Set(SUPPORTED_COUNTRY_KEYS);
|
const allowedCountryKeys = new Set(SUPPORTED_COUNTRY_KEYS);
|
||||||
|
|
||||||
@@ -214,6 +219,58 @@ export const useOrchestratorFilters = () => {
|
|||||||
: "all";
|
: "all";
|
||||||
}, [searchParams]);
|
}, [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(
|
const setSponsorFilter = useCallback(
|
||||||
(value: SponsorFilter) => {
|
(value: SponsorFilter) => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
@@ -438,6 +495,8 @@ export const useOrchestratorFilters = () => {
|
|||||||
prev.delete("countries");
|
prev.delete("countries");
|
||||||
prev.delete("countriesExclude");
|
prev.delete("countriesExclude");
|
||||||
prev.delete("sponsor");
|
prev.delete("sponsor");
|
||||||
|
prev.delete("sponsorSignal");
|
||||||
|
prev.delete("hideSponsorBlockers");
|
||||||
prev.delete("workplace");
|
prev.delete("workplace");
|
||||||
prev.delete("salaryMode");
|
prev.delete("salaryMode");
|
||||||
prev.delete("salaryMin");
|
prev.delete("salaryMin");
|
||||||
@@ -465,6 +524,10 @@ export const useOrchestratorFilters = () => {
|
|||||||
setCountrySelection,
|
setCountrySelection,
|
||||||
sponsorFilter,
|
sponsorFilter,
|
||||||
setSponsorFilter,
|
setSponsorFilter,
|
||||||
|
sponsorshipSignalsFilter,
|
||||||
|
setSponsorshipSignalsFilter,
|
||||||
|
hideSponsorBlockers,
|
||||||
|
setHideSponsorBlockers,
|
||||||
workplaceFilter,
|
workplaceFilter,
|
||||||
setWorkplaceFilter,
|
setWorkplaceFilter,
|
||||||
salaryFilter,
|
salaryFilter,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { toast } from "sonner";
|
|||||||
import { trackProductEvent } from "@/lib/analytics";
|
import { trackProductEvent } from "@/lib/analytics";
|
||||||
import type { AutomaticRunValues } from "./automatic-run";
|
import type { AutomaticRunValues } from "./automatic-run";
|
||||||
import {
|
import {
|
||||||
deriveExtractorLimits,
|
buildMaxCoverageDiscoveryLimits,
|
||||||
mergeDiscoverySearchTerms,
|
mergeDiscoverySearchTerms,
|
||||||
serializeCityLocationsSetting,
|
serializeCityLocationsSetting,
|
||||||
} from "./automatic-run";
|
} from "./automatic-run";
|
||||||
@@ -169,7 +169,9 @@ export function usePipelineControls(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedSearchTerms = mergeDiscoverySearchTerms(
|
const mergedSearchTerms = values.activeKeywordSetId
|
||||||
|
? values.searchTerms.map((t) => t.trim()).filter(Boolean)
|
||||||
|
: mergeDiscoverySearchTerms(
|
||||||
values.searchTerms,
|
values.searchTerms,
|
||||||
settings?.jobSearchProfile?.value?.targetRoles,
|
settings?.jobSearchProfile?.value?.targetRoles,
|
||||||
);
|
);
|
||||||
@@ -180,11 +182,6 @@ export function usePipelineControls(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const limits = deriveExtractorLimits({
|
|
||||||
budget: values.runBudget,
|
|
||||||
searchTerms: mergedSearchTerms,
|
|
||||||
sources: compatibleSources,
|
|
||||||
});
|
|
||||||
const hasJobSpySite = compatibleSources.some(
|
const hasJobSpySite = compatibleSources.some(
|
||||||
(source) =>
|
(source) =>
|
||||||
source === "indeed" ||
|
source === "indeed" ||
|
||||||
@@ -202,14 +199,19 @@ export function usePipelineControls(
|
|||||||
serializedCities
|
serializedCities
|
||||||
? serializedCities
|
? serializedCities
|
||||||
: formatCountryLabel(values.country);
|
: formatCountryLabel(values.country);
|
||||||
|
if (values.activeKeywordSetId) {
|
||||||
|
await api.updateKeywordSet(values.activeKeywordSetId, {
|
||||||
|
terms: values.searchTerms,
|
||||||
|
});
|
||||||
|
}
|
||||||
await api.updateSettings({
|
await api.updateSettings({
|
||||||
searchTerms: values.searchTerms,
|
searchTerms: values.searchTerms,
|
||||||
workplaceTypes: values.workplaceTypes,
|
workplaceTypes: values.workplaceTypes,
|
||||||
jobspyResultsWanted: limits.jobspyResultsWanted,
|
...buildMaxCoverageDiscoveryLimits({
|
||||||
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
|
budget: values.runBudget,
|
||||||
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
|
searchTerms: mergedSearchTerms,
|
||||||
adzunaMaxJobsPerTerm: limits.adzunaMaxJobsPerTerm,
|
sources: compatibleSources,
|
||||||
startupjobsMaxJobsPerTerm: limits.startupjobsMaxJobsPerTerm,
|
}),
|
||||||
jobspyCountryIndeed: values.country,
|
jobspyCountryIndeed: values.country,
|
||||||
searchCities,
|
searchCities,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,7 +67,36 @@ describe("usePipelineSources", () => {
|
|||||||
expect(result.current.pipelineSources).toEqual(["gradcracker"]);
|
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(
|
ensureStorage().setItem(
|
||||||
PIPELINE_SOURCES_STORAGE_KEY,
|
PIPELINE_SOURCES_STORAGE_KEY,
|
||||||
JSON.stringify(["ukvisajobs"]),
|
JSON.stringify(["ukvisajobs"]),
|
||||||
@@ -77,7 +106,7 @@ describe("usePipelineSources", () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() => usePipelineSources(enabledSources));
|
const { result } = renderHook(() => usePipelineSources(enabledSources));
|
||||||
|
|
||||||
expect(result.current.pipelineSources).toEqual(["gradcracker"]);
|
expect(result.current.pipelineSources).toEqual(["gradcracker", "linkedin"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores toggles for disabled sources", () => {
|
it("ignores toggles for disabled sources", () => {
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ const resolveAllowedSources = (enabledSources?: readonly JobSource[]) =>
|
|||||||
? (enabledSources as JobSource[])
|
? (enabledSources as JobSource[])
|
||||||
: DEFAULT_PIPELINE_SOURCES;
|
: DEFAULT_PIPELINE_SOURCES;
|
||||||
|
|
||||||
const normalizeSources = (
|
const mergeAllowedSources = (
|
||||||
sources: JobSource[],
|
sources: JobSource[],
|
||||||
allowedSources: JobSource[],
|
allowedSources: JobSource[],
|
||||||
) => {
|
): JobSource[] => {
|
||||||
const filtered = sources.filter((value) => allowedSources.includes(value));
|
const kept = sources.filter((value) => allowedSources.includes(value));
|
||||||
return filtered.length > 0 ? filtered : allowedSources.slice(0, 1);
|
const missing = allowedSources.filter((value) => !kept.includes(value));
|
||||||
|
const merged = [...kept, ...missing];
|
||||||
|
return merged.length > 0 ? merged : [...allowedSources];
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourcesMatch = (left: JobSource[], right: JobSource[]) =>
|
const sourcesMatch = (left: JobSource[], right: JobSource[]) =>
|
||||||
@@ -31,22 +33,22 @@ export const usePipelineSources = (enabledSources?: readonly JobSource[]) => {
|
|||||||
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
|
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
|
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;
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
if (!Array.isArray(parsed))
|
if (!Array.isArray(parsed))
|
||||||
return normalizeSources(allowedSources, allowedSources);
|
return mergeAllowedSources([], allowedSources);
|
||||||
const next = parsed.filter((value): value is JobSource =>
|
const next = parsed.filter((value): value is JobSource =>
|
||||||
orderedSources.includes(value as JobSource),
|
orderedSources.includes(value as JobSource),
|
||||||
);
|
);
|
||||||
return normalizeSources(next, allowedSources);
|
return mergeAllowedSources(next, allowedSources);
|
||||||
} catch {
|
} catch {
|
||||||
return normalizeSources(allowedSources, allowedSources);
|
return mergeAllowedSources([], allowedSources);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPipelineSources((current) => {
|
setPipelineSources((current) => {
|
||||||
const normalized = normalizeSources(current, allowedSources);
|
const normalized = mergeAllowedSources(current, allowedSources);
|
||||||
return sourcesMatch(current, normalized) ? current : normalized;
|
return sourcesMatch(current, normalized) ? current : normalized;
|
||||||
});
|
});
|
||||||
}, [allowedSources]);
|
}, [allowedSources]);
|
||||||
|
|||||||
@@ -27,6 +27,37 @@ describe("orchestrator utils", () => {
|
|||||||
expect(enabled).toContain("themuse");
|
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", () => {
|
it("counts processing jobs in ready and discovered tabs", () => {
|
||||||
const jobs = [
|
const jobs = [
|
||||||
createJob({ id: "ready", status: "ready", closedAt: null }),
|
createJob({ id: "ready", status: "ready", closedAt: null }),
|
||||||
|
|||||||
@@ -212,6 +212,12 @@ export const getEnabledSources = (
|
|||||||
const hasWorkdayTenants = (settings.workdayTenants?.value ?? []).length > 0;
|
const hasWorkdayTenants = (settings.workdayTenants?.value ?? []).length > 0;
|
||||||
const hasSmartrecruitersCompanies =
|
const hasSmartrecruitersCompanies =
|
||||||
(settings.smartrecruitersCompanies?.value ?? []).length > 0;
|
(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 =
|
const hasElutaRssLocations =
|
||||||
(settings.elutaRssLocations?.value ?? []).length > 0;
|
(settings.elutaRssLocations?.value ?? []).length > 0;
|
||||||
const hasIcimsTenants = (settings.icimsTenants?.value ?? []).length > 0;
|
const hasIcimsTenants = (settings.icimsTenants?.value ?? []).length > 0;
|
||||||
@@ -281,6 +287,22 @@ export const getEnabledSources = (
|
|||||||
if (hasSmartrecruitersCompanies) enabled.push(source);
|
if (hasSmartrecruitersCompanies) enabled.push(source);
|
||||||
continue;
|
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 (source === "icims") {
|
||||||
if (hasIcimsTenants) enabled.push(source);
|
if (hasIcimsTenants) enabled.push(source);
|
||||||
continue;
|
continue;
|
||||||
@@ -307,7 +329,11 @@ export const getEnabledSources = (
|
|||||||
source === "arbeitnow" ||
|
source === "arbeitnow" ||
|
||||||
source === "himalayas" ||
|
source === "himalayas" ||
|
||||||
source === "weworkremotely" ||
|
source === "weworkremotely" ||
|
||||||
|
source === "workingnomads" ||
|
||||||
source === "fourdayweek" ||
|
source === "fourdayweek" ||
|
||||||
|
source === "testdevjobs" ||
|
||||||
|
source === "wellfound" ||
|
||||||
|
source === "builtin" ||
|
||||||
source === "qajobsboard" ||
|
source === "qajobsboard" ||
|
||||||
source === "arcdev"
|
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 EnvironmentSettingsHarness = () => {
|
||||||
const methods = useForm<UpdateSettingsInput>({
|
const methods = useForm<UpdateSettingsInput>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
rxresumeEmail: "resume@example.com",
|
|
||||||
ukvisajobsEmail: "visa@example.com",
|
ukvisajobsEmail: "visa@example.com",
|
||||||
basicAuthUser: "admin",
|
basicAuthUser: "admin",
|
||||||
rxresumePassword: "",
|
|
||||||
ukvisajobsPassword: "",
|
ukvisajobsPassword: "",
|
||||||
adzunaAppId: "adzuna-id",
|
adzunaAppId: "adzuna-id",
|
||||||
adzunaAppKey: "",
|
adzunaAppKey: "",
|
||||||
@@ -26,13 +24,11 @@ const EnvironmentSettingsHarness = () => {
|
|||||||
<EnvironmentSettingsSection
|
<EnvironmentSettingsSection
|
||||||
values={{
|
values={{
|
||||||
readable: {
|
readable: {
|
||||||
rxresumeEmail: "resume@example.com",
|
|
||||||
ukvisajobsEmail: "visa@example.com",
|
ukvisajobsEmail: "visa@example.com",
|
||||||
adzunaAppId: "adzuna-id",
|
adzunaAppId: "adzuna-id",
|
||||||
basicAuthUser: "admin",
|
basicAuthUser: "admin",
|
||||||
},
|
},
|
||||||
private: {
|
private: {
|
||||||
rxresumePasswordHint: null,
|
|
||||||
ukvisajobsPasswordHint: "pass",
|
ukvisajobsPasswordHint: "pass",
|
||||||
adzunaAppKeyHint: "adzu",
|
adzunaAppKeyHint: "adzu",
|
||||||
basicAuthPasswordHint: "abcd",
|
basicAuthPasswordHint: "abcd",
|
||||||
@@ -66,8 +62,6 @@ describe("EnvironmentSettingsSection", () => {
|
|||||||
// Sections
|
// Sections
|
||||||
expect(screen.getByText("Service Accounts")).toBeInTheDocument();
|
expect(screen.getByText("Service Accounts")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Security")).toBeInTheDocument();
|
expect(screen.getByText("Security")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("RxResume")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", {
|
screen.getByRole("button", {
|
||||||
name: /sign out \/ switch user/i,
|
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 && (
|
{!selectedId && profiles.length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground text-center py-2">
|
<p className="text-xs text-muted-foreground text-center py-2">
|
||||||
Create a profile to get started. "Generate from resume" will analyse
|
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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 })
|
setValue("blockedCompanyKeywords", value, { shouldDirty: true })
|
||||||
}
|
}
|
||||||
placeholder='e.g. "recruitment", "staffing"'
|
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"
|
removeLabelPrefix="Remove blocked keyword"
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,13 +33,11 @@ export type ChatValues = {
|
|||||||
|
|
||||||
export type EnvSettingsValues = {
|
export type EnvSettingsValues = {
|
||||||
readable: {
|
readable: {
|
||||||
rxresumeEmail: string;
|
|
||||||
ukvisajobsEmail: string;
|
ukvisajobsEmail: string;
|
||||||
adzunaAppId: string;
|
adzunaAppId: string;
|
||||||
basicAuthUser: string;
|
basicAuthUser: string;
|
||||||
};
|
};
|
||||||
private: {
|
private: {
|
||||||
rxresumePasswordHint: string | null;
|
|
||||||
ukvisajobsPasswordHint: string | null;
|
ukvisajobsPasswordHint: string | null;
|
||||||
adzunaAppKeyHint: string | null;
|
adzunaAppKeyHint: string | null;
|
||||||
basicAuthPasswordHint: string | null;
|
basicAuthPasswordHint: string | null;
|
||||||
@@ -62,3 +60,17 @@ export type ScoringValues = {
|
|||||||
blockedCountries: EffectiveDefault<string[]>;
|
blockedCountries: EffectiveDefault<string[]>;
|
||||||
scoringInstructions: 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 { demoRouter } from "./routes/demo";
|
||||||
import { ghostwriterRouter } from "./routes/ghostwriter";
|
import { ghostwriterRouter } from "./routes/ghostwriter";
|
||||||
import { jobsRouter } from "./routes/jobs";
|
import { jobsRouter } from "./routes/jobs";
|
||||||
|
import { keywordSetsRouter } from "./routes/keyword-sets";
|
||||||
import { manualJobsRouter } from "./routes/manual-jobs";
|
import { manualJobsRouter } from "./routes/manual-jobs";
|
||||||
import { onboardingRouter } from "./routes/onboarding";
|
import { onboardingRouter } from "./routes/onboarding";
|
||||||
import { pipelineRouter } from "./routes/pipeline";
|
import { pipelineRouter } from "./routes/pipeline";
|
||||||
@@ -35,6 +36,7 @@ apiRouter.use("/manual-jobs", manualJobsRouter);
|
|||||||
apiRouter.use("/webhook", webhookRouter);
|
apiRouter.use("/webhook", webhookRouter);
|
||||||
apiRouter.use("/profile", profileRouter);
|
apiRouter.use("/profile", profileRouter);
|
||||||
apiRouter.use("/profiles", profilesRouter);
|
apiRouter.use("/profiles", profilesRouter);
|
||||||
|
apiRouter.use("/keyword-sets", keywordSetsRouter);
|
||||||
apiRouter.use("/database", databaseRouter);
|
apiRouter.use("/database", databaseRouter);
|
||||||
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
|
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
|
||||||
apiRouter.use("/onboarding", onboardingRouter);
|
apiRouter.use("/onboarding", onboardingRouter);
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ import { scoreJobSuitability } from "@server/services/scorer";
|
|||||||
import { getTracerReadiness } from "@server/services/tracer-links";
|
import { getTracerReadiness } from "@server/services/tracer-links";
|
||||||
import * as visaSponsors from "@server/services/visa-sponsors/index";
|
import * as visaSponsors from "@server/services/visa-sponsors/index";
|
||||||
import { asyncPool } from "@server/utils/async-pool";
|
import { asyncPool } from "@server/utils/async-pool";
|
||||||
|
import {
|
||||||
|
detectSponsorshipSignals,
|
||||||
|
serializeSponsorshipSignals,
|
||||||
|
} from "@shared/sponsorship-signals";
|
||||||
import {
|
import {
|
||||||
APPLICATION_OUTCOMES,
|
APPLICATION_OUTCOMES,
|
||||||
APPLICATION_STAGES,
|
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,
|
req.params.id,
|
||||||
input,
|
input,
|
||||||
currentJob.ownerProfileId,
|
currentJob.ownerProfileId,
|
||||||
@@ -1196,6 +1200,27 @@ jobsRouter.patch("/:id", async (req: Request, res: Response) => {
|
|||||||
return fail(res, err);
|
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", {
|
logger.info("Job updated", {
|
||||||
route: "PATCH /api/jobs/:id",
|
route: "PATCH /api/jobs/:id",
|
||||||
jobId: req.params.id,
|
jobId: req.params.id,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { badRequest } from "@infra/errors";
|
||||||
|
import { asyncRoute, fail, ok } from "@infra/http";
|
||||||
|
import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
|
||||||
|
import { getJobOwnerProfileId } from "@server/infra/request-context";
|
||||||
|
import * as keywordSetsRepo from "@server/repositories/keyword-sets";
|
||||||
|
import { type Request, type Response, Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const keywordSetsRouter = Router();
|
||||||
|
|
||||||
|
const termsSchema = z
|
||||||
|
.array(z.string().trim().min(1).max(200))
|
||||||
|
.max(100)
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
terms: termsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(100).optional(),
|
||||||
|
terms: termsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveOwnerProfileId(): string {
|
||||||
|
const fromContext = getJobOwnerProfileId();
|
||||||
|
if (fromContext && fromContext !== "__unmapped__") {
|
||||||
|
return fromContext;
|
||||||
|
}
|
||||||
|
return DEFAULT_JOB_OWNER_PROFILE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRepoError(res: Response, error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (/maximum of/i.test(message) || /cannot delete/i.test(message)) {
|
||||||
|
return fail(res, badRequest(message));
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
keywordSetsRouter.get(
|
||||||
|
"/",
|
||||||
|
asyncRoute(async (_req: Request, res: Response) => {
|
||||||
|
const ownerProfileId = resolveOwnerProfileId();
|
||||||
|
const sets = await keywordSetsRepo.listKeywordSets(ownerProfileId);
|
||||||
|
return ok(res, sets);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
keywordSetsRouter.post(
|
||||||
|
"/",
|
||||||
|
asyncRoute(async (req: Request, res: Response) => {
|
||||||
|
const parsed = createSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw badRequest("Invalid keyword set payload", {
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ownerProfileId = resolveOwnerProfileId();
|
||||||
|
try {
|
||||||
|
const created = await keywordSetsRepo.createKeywordSet(
|
||||||
|
ownerProfileId,
|
||||||
|
parsed.data,
|
||||||
|
);
|
||||||
|
return ok(res, created);
|
||||||
|
} catch (error) {
|
||||||
|
return handleRepoError(res, error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
keywordSetsRouter.patch(
|
||||||
|
"/:id",
|
||||||
|
asyncRoute(async (req: Request, res: Response) => {
|
||||||
|
const parsed = updateSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw badRequest("Invalid keyword set payload", {
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ownerProfileId = resolveOwnerProfileId();
|
||||||
|
const updated = await keywordSetsRepo.updateKeywordSet(
|
||||||
|
req.params.id,
|
||||||
|
ownerProfileId,
|
||||||
|
parsed.data,
|
||||||
|
);
|
||||||
|
if (!updated) {
|
||||||
|
return fail(res, badRequest("Keyword set not found"));
|
||||||
|
}
|
||||||
|
return ok(res, updated);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
keywordSetsRouter.delete(
|
||||||
|
"/:id",
|
||||||
|
asyncRoute(async (req: Request, res: Response) => {
|
||||||
|
const ownerProfileId = resolveOwnerProfileId();
|
||||||
|
try {
|
||||||
|
const deleted = await keywordSetsRepo.deleteKeywordSet(
|
||||||
|
req.params.id,
|
||||||
|
ownerProfileId,
|
||||||
|
);
|
||||||
|
if (!deleted) {
|
||||||
|
return fail(res, badRequest("Keyword set not found"));
|
||||||
|
}
|
||||||
|
return ok(res, { deleted: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleRepoError(res, error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
keywordSetsRouter.post(
|
||||||
|
"/:id/activate",
|
||||||
|
asyncRoute(async (req: Request, res: Response) => {
|
||||||
|
const ownerProfileId = resolveOwnerProfileId();
|
||||||
|
const activated = await keywordSetsRepo.activateKeywordSet(
|
||||||
|
req.params.id,
|
||||||
|
ownerProfileId,
|
||||||
|
);
|
||||||
|
if (!activated) {
|
||||||
|
return fail(res, badRequest("Keyword set not found"));
|
||||||
|
}
|
||||||
|
return ok(res, activated);
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -2,18 +2,6 @@ import type { Server } from "node:http";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { startServer, stopServer } from "./test-utils";
|
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
|
// Mock the profile service
|
||||||
vi.mock("@server/services/profile", () => ({
|
vi.mock("@server/services/profile", () => ({
|
||||||
getProfile: vi.fn(),
|
getProfile: vi.fn(),
|
||||||
@@ -21,21 +9,10 @@ vi.mock("@server/services/profile", () => ({
|
|||||||
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
|
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 {
|
import {
|
||||||
getProfile,
|
getProfile,
|
||||||
resolveLocalResumeFilePath,
|
resolveLocalResumeFilePath,
|
||||||
} from "@server/services/profile";
|
} from "@server/services/profile";
|
||||||
import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume";
|
|
||||||
|
|
||||||
describe.sequential("Profile API routes", () => {
|
describe.sequential("Profile API routes", () => {
|
||||||
let server: Server;
|
let server: Server;
|
||||||
@@ -181,8 +158,8 @@ describe.sequential("Profile API routes", () => {
|
|||||||
expect(body.data.error).toBeNull();
|
expect(body.data.error).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns exists: false when rxresumeBaseResumeId is not configured", async () => {
|
it("returns exists: false when no local resume path is configured", async () => {
|
||||||
vi.mocked(getSetting).mockResolvedValue(null);
|
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(null);
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/profile/status`);
|
const res = await fetch(`${baseUrl}/api/profile/status`);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -190,29 +167,15 @@ describe.sequential("Profile API routes", () => {
|
|||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(body.ok).toBe(true);
|
expect(body.ok).toBe(true);
|
||||||
expect(body.data.exists).toBe(false);
|
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 () => {
|
it("returns exists: false when the local resume file cannot be loaded", async () => {
|
||||||
vi.mocked(getSetting).mockResolvedValue("test-resume-id");
|
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(
|
||||||
vi.mocked(getResume).mockResolvedValue({
|
"/data/resumes/missing.json",
|
||||||
id: "test-resume-id",
|
);
|
||||||
data: { basics: { name: "Test" } },
|
vi.mocked(getProfile).mockRejectedValue(
|
||||||
} as any);
|
new Error("Cannot read local resume file"),
|
||||||
|
|
||||||
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)(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/api/profile/status`);
|
const res = await fetch(`${baseUrl}/api/profile/status`);
|
||||||
@@ -221,23 +184,7 @@ describe.sequential("Profile API routes", () => {
|
|||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(body.ok).toBe(true);
|
expect(body.ok).toBe(true);
|
||||||
expect(body.data.exists).toBe(false);
|
expect(body.data.exists).toBe(false);
|
||||||
expect(body.data.error).toContain("credentials not configured");
|
expect(body.data.error).toContain("Cannot read local resume file");
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,6 @@ import {
|
|||||||
resolveLocalResumeFilePath,
|
resolveLocalResumeFilePath,
|
||||||
} from "@server/services/profile";
|
} from "@server/services/profile";
|
||||||
import { extractProjectsFromProfile } from "@server/services/resumeProjects";
|
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";
|
import { type Request, type Response, Router } from "express";
|
||||||
|
|
||||||
export const profileRouter = Router();
|
export const profileRouter = Router();
|
||||||
@@ -53,53 +47,21 @@ profileRouter.get("/", async (_req: Request, res: Response) => {
|
|||||||
profileRouter.get("/status", async (_req: Request, res: Response) => {
|
profileRouter.get("/status", async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const localPath = await resolveLocalResumeFilePath();
|
const localPath = await resolveLocalResumeFilePath();
|
||||||
if (localPath) {
|
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) {
|
|
||||||
ok(res, {
|
ok(res, {
|
||||||
exists: false,
|
exists: false,
|
||||||
error:
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the resume is accessible
|
|
||||||
try {
|
try {
|
||||||
const resume = await getResume(rxresumeBaseResumeId);
|
await getProfile();
|
||||||
if (!resume.data || typeof resume.data !== "object") {
|
|
||||||
ok(res, {
|
|
||||||
exists: false,
|
|
||||||
error: "Selected resume is empty or invalid.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ok(res, { exists: true, error: null });
|
ok(res, { exists: true, error: null });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RxResumeAuthConfigError) {
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
ok(res, { exists: false, error: error.message });
|
ok(res, { exists: false, error: message });
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown 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) => {
|
profileRouter.post("/refresh", async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
clearProfileCache();
|
clearProfileCache();
|
||||||
clearRxResumeResumeCache();
|
|
||||||
const profile = await getProfile(true);
|
const profile = await getProfile(true);
|
||||||
ok(res, profile);
|
ok(res, profile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -15,16 +15,6 @@ import { type Request, type Response, Router } from "express";
|
|||||||
|
|
||||||
export const profilesRouter = Router();
|
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(
|
function assertProfileVisibleToRequest(
|
||||||
req: Request,
|
req: Request,
|
||||||
profile: SearchProfile,
|
profile: SearchProfile,
|
||||||
@@ -32,7 +22,12 @@ function assertProfileVisibleToRequest(
|
|||||||
if (!isBasicAuthEnabled()) return true;
|
if (!isBasicAuthEnabled()) return true;
|
||||||
const username = parseBasicAuthUsername(req.headers.authorization);
|
const username = parseBasicAuthUsername(req.headers.authorization);
|
||||||
if (!username?.trim()) return "unauthorized";
|
if (!username?.trim()) return "unauthorized";
|
||||||
return profileMatchesBasicAuthUser(profile, username) ? true : "forbidden";
|
return profilesRepo.profileMatchesBasicAuthUser(
|
||||||
|
profile.data.basicAuthUser,
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
? true
|
||||||
|
: "forbidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
profilesRouter.get(
|
profilesRouter.get(
|
||||||
@@ -206,7 +201,7 @@ profilesRouter.post(
|
|||||||
Array.isArray(resumeProfile)
|
Array.isArray(resumeProfile)
|
||||||
) {
|
) {
|
||||||
throw badRequest(
|
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(
|
const generated = await generateProfileFromResume(
|
||||||
|
|||||||
@@ -267,17 +267,25 @@ export const DEMO_SOURCE_BASE_URLS: Record<JobSource, string> = {
|
|||||||
arbeitnow: "https://www.arbeitnow.com",
|
arbeitnow: "https://www.arbeitnow.com",
|
||||||
himalayas: "https://himalayas.app",
|
himalayas: "https://himalayas.app",
|
||||||
weworkremotely: "https://weworkremotely.com",
|
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",
|
fourdayweek: "https://4dayweek.io",
|
||||||
ashby: "https://jobs.ashbyhq.com",
|
ashby: "https://jobs.ashbyhq.com",
|
||||||
lever: "https://jobs.lever.co",
|
lever: "https://jobs.lever.co",
|
||||||
greenhouse: "https://boards.greenhouse.io",
|
greenhouse: "https://boards.greenhouse.io",
|
||||||
workday: "https://workday.com",
|
workday: "https://workday.com",
|
||||||
smartrecruiters: "https://www.smartrecruiters.com",
|
smartrecruiters: "https://www.smartrecruiters.com",
|
||||||
|
teamtailor: "https://teamtailor.com",
|
||||||
|
huntflow: "https://huntflow.io",
|
||||||
|
factorial: "https://factorialhr.com",
|
||||||
icims: "https://www.icims.com",
|
icims: "https://www.icims.com",
|
||||||
bctenet: "https://www.bctechnology.com",
|
bctenet: "https://www.bctechnology.com",
|
||||||
eluta: "https://www.eluta.ca",
|
eluta: "https://www.eluta.ca",
|
||||||
qajobsboard: "https://www.qajobsboard.com",
|
qajobsboard: "https://www.qajobsboard.com",
|
||||||
arcdev: "https://arc.dev",
|
arcdev: "https://arc.dev",
|
||||||
|
careerspages: "https://example.com",
|
||||||
manual: "https://example.com",
|
manual: "https://example.com",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -788,6 +788,30 @@ const migrations = [
|
|||||||
`ALTER TABLE jobs ADD COLUMN content_fingerprint TEXT`,
|
`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 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).
|
// Seed default job-search personas (INSERT OR IGNORE — safe on existing DBs).
|
||||||
sqlInsertSearchProfileSeed({
|
sqlInsertSearchProfileSeed({
|
||||||
id: "685b0000-0000-4000-8000-000000000001",
|
id: "685b0000-0000-4000-8000-000000000001",
|
||||||
@@ -815,10 +839,91 @@ const migrations = [
|
|||||||
industriesToAvoid: [],
|
industriesToAvoid: [],
|
||||||
aboutMe:
|
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).",
|
"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",
|
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({
|
sqlInsertSearchProfileSeed({
|
||||||
id: "685b0000-0000-4000-8000-000000000002",
|
id: "685b0000-0000-4000-8000-000000000002",
|
||||||
name: "Iliya Cherepaha",
|
name: "Iliya Cherepaha",
|
||||||
@@ -861,6 +966,16 @@ const migrations = [
|
|||||||
resumeLocalPath: "../data/resumes/cherepaha.json",
|
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...");
|
console.log("🔧 Running database migrations...");
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export const jobs = sqliteTable(
|
|||||||
coverLetter: text("cover_letter"),
|
coverLetter: text("cover_letter"),
|
||||||
sponsorMatchScore: real("sponsor_match_score"),
|
sponsorMatchScore: real("sponsor_match_score"),
|
||||||
sponsorMatchNames: text("sponsor_match_names"),
|
sponsorMatchNames: text("sponsor_match_names"),
|
||||||
|
sponsorshipSignals: text("sponsorship_signals"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
@@ -283,6 +284,31 @@ export const searchProfiles = sqliteTable("search_profiles", {
|
|||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
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", {
|
export const settings = sqliteTable("settings", {
|
||||||
key: text("key").primaryKey(),
|
key: text("key").primaryKey(),
|
||||||
value: text("value").notNull(),
|
value: text("value").notNull(),
|
||||||
@@ -490,6 +516,8 @@ export type JobChatRunRow = typeof jobChatRuns.$inferSelect;
|
|||||||
export type NewJobChatRunRow = typeof jobChatRuns.$inferInsert;
|
export type NewJobChatRunRow = typeof jobChatRuns.$inferInsert;
|
||||||
export type SearchProfileRow = typeof searchProfiles.$inferSelect;
|
export type SearchProfileRow = typeof searchProfiles.$inferSelect;
|
||||||
export type NewSearchProfileRow = typeof searchProfiles.$inferInsert;
|
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 SettingsRow = typeof settings.$inferSelect;
|
||||||
export type NewSettingsRow = typeof settings.$inferInsert;
|
export type NewSettingsRow = typeof settings.$inferInsert;
|
||||||
export type PostApplicationIntegrationRow =
|
export type PostApplicationIntegrationRow =
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ vi.mock("@server/repositories/profiles", () => ({
|
|||||||
getProfileById: vi.fn().mockResolvedValue(null),
|
getProfileById: vi.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@server/repositories/keyword-sets", () => ({
|
||||||
|
getActiveKeywordSetTerms: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@server/extractors/registry", () => ({
|
vi.mock("@server/extractors/registry", () => ({
|
||||||
getExtractorRegistry: vi.fn(),
|
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 () => {
|
it("throws when all enabled sources fail", async () => {
|
||||||
const settingsRepo = await import("@server/repositories/settings");
|
const settingsRepo = await import("@server/repositories/settings");
|
||||||
const registryModule = await import("@server/extractors/registry");
|
const registryModule = await import("@server/extractors/registry");
|
||||||
@@ -441,7 +506,9 @@ describe("discoverJobsStep", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.discoveredJobs).toHaveLength(1);
|
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 () => {
|
it("drops jobs with blocked country in description when location is worldwide", async () => {
|
||||||
@@ -535,7 +602,7 @@ describe("discoverJobsStep", () => {
|
|||||||
jobs: [
|
jobs: [
|
||||||
{
|
{
|
||||||
source: "ukvisajobs",
|
source: "ukvisajobs",
|
||||||
title: "Developer - Leeds",
|
title: "Engineer - Leeds",
|
||||||
employer: "Contoso",
|
employer: "Contoso",
|
||||||
location: "Leeds, England, UK",
|
location: "Leeds, England, UK",
|
||||||
jobUrl: "https://example.com/ukv-1",
|
jobUrl: "https://example.com/ukv-1",
|
||||||
@@ -651,4 +718,258 @@ describe("discoverJobsStep", () => {
|
|||||||
"https://example.com/existing",
|
"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 { getExtractorRegistry } from "@server/extractors/registry";
|
||||||
import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
|
import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
|
||||||
import { getAllJobUrls } from "@server/repositories/jobs";
|
import { getAllJobUrls } from "@server/repositories/jobs";
|
||||||
|
import * as keywordSetsRepo from "@server/repositories/keyword-sets";
|
||||||
import { getProfileById } from "@server/repositories/profiles";
|
import { getProfileById } from "@server/repositories/profiles";
|
||||||
import * as settingsRepo from "@server/repositories/settings";
|
import * as settingsRepo from "@server/repositories/settings";
|
||||||
import { asyncPool } from "@server/utils/async-pool";
|
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();
|
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[]): {
|
function buildRoleMatchers(phrases: string[]): {
|
||||||
phraseMatchers: string[];
|
phraseMatchers: string[];
|
||||||
tokenMatchers: string[];
|
tokenMatchers: string[];
|
||||||
@@ -134,14 +149,46 @@ function buildRoleMatchers(phrases: string[]): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure common QA acronyms remain even if user only typed long-form roles.
|
// Only augment with QA tokens when the search is QA/SDET-oriented (not Caseware etc.).
|
||||||
for (const token of ["qa", "sdet", "test", "testing", "automation"]) {
|
if (phrasesLookLikeQaDomain(phraseMatchers)) {
|
||||||
|
for (const token of QA_DOMAIN_HINTS) {
|
||||||
tokenSet.add(token);
|
tokenSet.add(token);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { phraseMatchers, tokenMatchers: [...tokenSet] };
|
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 {
|
function matchesAny(text: string, needles: string[]): boolean {
|
||||||
if (!text) return false;
|
if (!text) return false;
|
||||||
for (const needle of needles) {
|
for (const needle of needles) {
|
||||||
@@ -209,9 +256,19 @@ export async function discoverJobsStep(args: {
|
|||||||
const settings = await settingsRepo.getAllSettings();
|
const settings = await settingsRepo.getAllSettings();
|
||||||
const registry = await getExtractorRegistry();
|
const registry = await getExtractorRegistry();
|
||||||
|
|
||||||
const searchTermsSetting = settings.searchTerms;
|
const ownerProfileId =
|
||||||
let searchTerms: string[] = [];
|
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) {
|
if (searchTermsSetting) {
|
||||||
searchTerms = JSON.parse(searchTermsSetting) as string[];
|
searchTerms = JSON.parse(searchTermsSetting) as string[];
|
||||||
} else {
|
} else {
|
||||||
@@ -222,9 +279,7 @@ export async function discoverJobsStep(args: {
|
|||||||
.map((term) => term.trim())
|
.map((term) => term.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const ownerProfileId =
|
|
||||||
args.mergedConfig.ownerProfileId ?? DEFAULT_JOB_OWNER_PROFILE_ID;
|
|
||||||
let searchProfileTargetRoles: string[] = [];
|
let searchProfileTargetRoles: string[] = [];
|
||||||
let searchProfileMustHaveSkills: string[] = [];
|
let searchProfileMustHaveSkills: string[] = [];
|
||||||
let searchProfileDealBreakers: string[] = [];
|
let searchProfileDealBreakers: string[] = [];
|
||||||
@@ -256,10 +311,10 @@ export async function discoverJobsStep(args: {
|
|||||||
searchProfileTargetRoles = parsed.data.targetRoles ?? [];
|
searchProfileTargetRoles = parsed.data.targetRoles ?? [];
|
||||||
searchProfileMustHaveSkills = parsed.data.mustHaveSkills ?? [];
|
searchProfileMustHaveSkills = parsed.data.mustHaveSkills ?? [];
|
||||||
searchProfileDealBreakers = parsed.data.dealBreakers ?? [];
|
searchProfileDealBreakers = parsed.data.dealBreakers ?? [];
|
||||||
if (searchProfileTargetRoles.length > 0) {
|
if (searchProfileTargetRoles.length > 0 && !usedActiveKeywordSet) {
|
||||||
mergeTargetRoles(searchProfileTargetRoles);
|
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.
|
// Legacy profile shapes: keep augmenting terms but we won't enforce strict filtering.
|
||||||
mergeTargetRoles(row.data.targetRoles);
|
mergeTargetRoles(row.data.targetRoles);
|
||||||
}
|
}
|
||||||
@@ -274,12 +329,13 @@ export async function discoverJobsStep(args: {
|
|||||||
searchProfileTargetRoles = parsed.data.targetRoles ?? [];
|
searchProfileTargetRoles = parsed.data.targetRoles ?? [];
|
||||||
searchProfileMustHaveSkills = parsed.data.mustHaveSkills ?? [];
|
searchProfileMustHaveSkills = parsed.data.mustHaveSkills ?? [];
|
||||||
searchProfileDealBreakers = parsed.data.dealBreakers ?? [];
|
searchProfileDealBreakers = parsed.data.dealBreakers ?? [];
|
||||||
if (searchProfileTargetRoles.length > 0) {
|
if (searchProfileTargetRoles.length > 0 && !usedActiveKeywordSet) {
|
||||||
mergeTargetRoles(searchProfileTargetRoles);
|
mergeTargetRoles(searchProfileTargetRoles);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
Array.isArray((profile as { targetRoles?: unknown }).targetRoles) &&
|
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);
|
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(
|
const geographyCountryKey = inferCountryKeyFromSearchGeography(
|
||||||
settings.searchCities,
|
settings.searchCities,
|
||||||
settings.jobspyLocation,
|
settings.jobspyLocation,
|
||||||
@@ -613,18 +676,32 @@ export async function discoverJobsStep(args: {
|
|||||||
return { discoveredJobs: allowedCountryFilteredJobs, sourceErrors };
|
return { discoveredJobs: allowedCountryFilteredJobs, sourceErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileFilterRolePhrases =
|
||||||
|
searchProfileTargetRoles.length > 0
|
||||||
|
? searchProfileTargetRoles
|
||||||
|
: searchTerms;
|
||||||
|
const profileMustHaveSkills = usedActiveKeywordSet
|
||||||
|
? []
|
||||||
|
: searchProfileMustHaveSkills;
|
||||||
|
const profileDealBreakers = usedActiveKeywordSet
|
||||||
|
? []
|
||||||
|
: searchProfileDealBreakers;
|
||||||
const strictProfileFilteringEnabled =
|
const strictProfileFilteringEnabled =
|
||||||
searchProfileTargetRoles.length > 0 ||
|
!usedActiveKeywordSet &&
|
||||||
searchProfileMustHaveSkills.length > 0 ||
|
(profileFilterRolePhrases.length > 0 ||
|
||||||
searchProfileDealBreakers.length > 0;
|
profileMustHaveSkills.length > 0 ||
|
||||||
const profileFiltered = strictProfileFilteringEnabled
|
profileDealBreakers.length > 0);
|
||||||
|
const profileFiltered = usedActiveKeywordSet
|
||||||
|
? filterJobsByKeywordSetTerms({
|
||||||
|
jobs: allowedCountryFilteredJobs,
|
||||||
|
terms: searchTerms,
|
||||||
|
})
|
||||||
|
: strictProfileFilteringEnabled
|
||||||
? filterJobsBySearchProfile({
|
? filterJobsBySearchProfile({
|
||||||
jobs: allowedCountryFilteredJobs,
|
jobs: allowedCountryFilteredJobs,
|
||||||
targetRolePhrases: searchProfileTargetRoles.length
|
targetRolePhrases: profileFilterRolePhrases,
|
||||||
? searchProfileTargetRoles
|
mustHaveSkills: profileMustHaveSkills,
|
||||||
: searchTerms,
|
dealBreakers: profileDealBreakers,
|
||||||
mustHaveSkills: searchProfileMustHaveSkills,
|
|
||||||
dealBreakers: searchProfileDealBreakers,
|
|
||||||
})
|
})
|
||||||
: { jobs: allowedCountryFilteredJobs, dropped: 0 };
|
: { jobs: allowedCountryFilteredJobs, dropped: 0 };
|
||||||
|
|
||||||
@@ -632,16 +709,35 @@ export async function discoverJobsStep(args: {
|
|||||||
logger.info("Dropped discovered jobs that didn't match search profile", {
|
logger.info("Dropped discovered jobs that didn't match search profile", {
|
||||||
step: "discover-jobs",
|
step: "discover-jobs",
|
||||||
droppedCount: profileFiltered.dropped,
|
droppedCount: profileFiltered.dropped,
|
||||||
targetRolesCount: searchProfileTargetRoles.length,
|
usedActiveKeywordSet,
|
||||||
mustHaveSkillsCount: searchProfileMustHaveSkills.length,
|
targetRolesCount: usedActiveKeywordSet
|
||||||
dealBreakersCount: searchProfileDealBreakers.length,
|
? 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("; ")}`);
|
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) {
|
if (sourceErrors.length > 0) {
|
||||||
logger.warn("Some discovery sources failed", { sourceErrors });
|
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 { scoreJobSuitability } from "@server/services/scorer";
|
||||||
import * as visaSponsors from "@server/services/visa-sponsors/index";
|
import * as visaSponsors from "@server/services/visa-sponsors/index";
|
||||||
import { asyncPool } from "@server/utils/async-pool";
|
import { asyncPool } from "@server/utils/async-pool";
|
||||||
|
import {
|
||||||
|
detectSponsorshipSignals,
|
||||||
|
serializeSponsorshipSignals,
|
||||||
|
} from "@shared/sponsorship-signals";
|
||||||
import type { Job } from "@shared/types";
|
import type { Job } from "@shared/types";
|
||||||
import { progressHelpers, updateProgress } from "../progress";
|
import { progressHelpers, updateProgress } from "../progress";
|
||||||
import type { ScoredJob } from "./types";
|
import type { ScoredJob } from "./types";
|
||||||
|
|
||||||
const SCORING_CONCURRENCY = 4;
|
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: {
|
export async function scoreJobsStep(args: {
|
||||||
profile: Record<string, unknown>;
|
profile: Record<string, unknown>;
|
||||||
ownerProfileId: string;
|
ownerProfileId: string;
|
||||||
@@ -53,6 +72,9 @@ export async function scoreJobsStep(args: {
|
|||||||
!Number.isNaN(job.suitabilityScore);
|
!Number.isNaN(job.suitabilityScore);
|
||||||
|
|
||||||
if (hasCachedScore) {
|
if (hasCachedScore) {
|
||||||
|
if (job.sponsorshipSignals == null) {
|
||||||
|
await jobsRepo.updateJob(job.id, buildSponsorshipSignalsUpdate(job));
|
||||||
|
}
|
||||||
completed += 1;
|
completed += 1;
|
||||||
progressHelpers.scoringJob(
|
progressHelpers.scoringJob(
|
||||||
completed,
|
completed,
|
||||||
@@ -100,6 +122,7 @@ export async function scoreJobsStep(args: {
|
|||||||
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
|
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
|
||||||
sponsorMatchScore,
|
sponsorMatchScore,
|
||||||
sponsorMatchNames,
|
sponsorMatchNames,
|
||||||
|
...buildSponsorshipSignalsUpdate(job),
|
||||||
...(shouldAutoSkip ? { status: "skipped" } : {}),
|
...(shouldAutoSkip ? { status: "skipped" } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ export async function getJobListItems(
|
|||||||
closedAt: jobs.closedAt,
|
closedAt: jobs.closedAt,
|
||||||
suitabilityScore: jobs.suitabilityScore,
|
suitabilityScore: jobs.suitabilityScore,
|
||||||
sponsorMatchScore: jobs.sponsorMatchScore,
|
sponsorMatchScore: jobs.sponsorMatchScore,
|
||||||
|
sponsorshipSignals: jobs.sponsorshipSignals,
|
||||||
jobType: jobs.jobType,
|
jobType: jobs.jobType,
|
||||||
jobFunction: jobs.jobFunction,
|
jobFunction: jobs.jobFunction,
|
||||||
salaryMinAmount: jobs.salaryMinAmount,
|
salaryMinAmount: jobs.salaryMinAmount,
|
||||||
@@ -934,6 +935,7 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
|||||||
tracerLinksEnabled: row.tracerLinksEnabled ?? false,
|
tracerLinksEnabled: row.tracerLinksEnabled ?? false,
|
||||||
sponsorMatchScore: row.sponsorMatchScore ?? null,
|
sponsorMatchScore: row.sponsorMatchScore ?? null,
|
||||||
sponsorMatchNames: row.sponsorMatchNames ?? null,
|
sponsorMatchNames: row.sponsorMatchNames ?? null,
|
||||||
|
sponsorshipSignals: row.sponsorshipSignals ?? null,
|
||||||
notes: row.notes ?? null,
|
notes: row.notes ?? null,
|
||||||
jobType: row.jobType ?? null,
|
jobType: row.jobType ?? null,
|
||||||
salarySource: row.salarySource ?? null,
|
salarySource: row.salarySource ?? null,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { keywordSets, settings } from "../db/schema";
|
||||||
|
import {
|
||||||
|
activateKeywordSet,
|
||||||
|
createKeywordSet,
|
||||||
|
ensureKeywordSetsForOwner,
|
||||||
|
getActiveKeywordSetTerms,
|
||||||
|
listKeywordSets,
|
||||||
|
syncActiveKeywordSetTerms,
|
||||||
|
} from "./keyword-sets";
|
||||||
|
|
||||||
|
const OWNER = "__test_keyword_sets__";
|
||||||
|
|
||||||
|
describe("keyword-sets repository", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(keywordSets);
|
||||||
|
await db.delete(settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a default set for a new owner", async () => {
|
||||||
|
await ensureKeywordSetsForOwner(OWNER);
|
||||||
|
const sets = await listKeywordSets(OWNER);
|
||||||
|
expect(sets).toHaveLength(1);
|
||||||
|
expect(sets[0]?.name).toBe("Default");
|
||||||
|
expect(sets[0]?.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates a set and returns its terms for discovery", async () => {
|
||||||
|
const first = await createKeywordSet(OWNER, {
|
||||||
|
name: "SDET",
|
||||||
|
terms: ["sdet", "playwright"],
|
||||||
|
});
|
||||||
|
const second = await createKeywordSet(OWNER, {
|
||||||
|
name: "Lead",
|
||||||
|
terms: ["engineering manager"],
|
||||||
|
});
|
||||||
|
await activateKeywordSet(second.id, OWNER);
|
||||||
|
const terms = await getActiveKeywordSetTerms(OWNER);
|
||||||
|
expect(terms).toEqual(["engineering manager"]);
|
||||||
|
await activateKeywordSet(first.id, OWNER);
|
||||||
|
expect(await getActiveKeywordSetTerms(OWNER)).toEqual([
|
||||||
|
"sdet",
|
||||||
|
"playwright",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs terms on the active set", async () => {
|
||||||
|
await ensureKeywordSetsForOwner(OWNER);
|
||||||
|
const sets = await listKeywordSets(OWNER);
|
||||||
|
const activeId = sets[0]?.id;
|
||||||
|
expect(activeId).toBeTruthy();
|
||||||
|
await syncActiveKeywordSetTerms(OWNER, ["caseware", "guidewire"]);
|
||||||
|
expect(await getActiveKeywordSetTerms(OWNER)).toEqual([
|
||||||
|
"caseware",
|
||||||
|
"guidewire",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type {
|
||||||
|
CreateKeywordSetInput,
|
||||||
|
KeywordSet,
|
||||||
|
UpdateKeywordSetInput,
|
||||||
|
} from "@shared/types";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { keywordSets } from "../db/schema";
|
||||||
|
import { getProfileById } from "./profiles";
|
||||||
|
import { getSetting, setSetting } from "./settings";
|
||||||
|
|
||||||
|
const MAX_SETS_PER_OWNER = 30;
|
||||||
|
const MAX_TERMS_PER_SET = 100;
|
||||||
|
|
||||||
|
function parseTerms(raw: string): string[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter((t): t is string => typeof t === "string")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, MAX_TERMS_PER_SET);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: typeof keywordSets.$inferSelect): KeywordSet {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
terms: parseTerms(row.terms),
|
||||||
|
isActive: row.isActive,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countSetsForOwner(ownerProfileId: string): Promise<number> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: keywordSets.id })
|
||||||
|
.from(keywordSets)
|
||||||
|
.where(eq(keywordSets.ownerProfileId, ownerProfileId));
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearActiveForOwner(ownerProfileId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(keywordSets)
|
||||||
|
.set({ isActive: false, updatedAt: new Date().toISOString() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(keywordSets.ownerProfileId, ownerProfileId),
|
||||||
|
eq(keywordSets.isActive, true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readGlobalSearchTermsFallback(): Promise<string[]> {
|
||||||
|
const raw = await getSetting("searchTerms");
|
||||||
|
if (!raw) {
|
||||||
|
const envDefault = process.env.JOBSPY_SEARCH_TERMS || "web developer";
|
||||||
|
return envDefault
|
||||||
|
.split("|")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return parseTerms(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultTermsForOwner(ownerProfileId: string): Promise<string[]> {
|
||||||
|
if (ownerProfileId === "__default__") {
|
||||||
|
return readGlobalSearchTermsFallback();
|
||||||
|
}
|
||||||
|
const profile = await getProfileById(ownerProfileId);
|
||||||
|
const roles = profile?.data?.targetRoles ?? [];
|
||||||
|
const fromProfile = roles
|
||||||
|
.filter((role): role is string => typeof role === "string")
|
||||||
|
.map((role) => role.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (fromProfile.length > 0) return fromProfile;
|
||||||
|
return readGlobalSearchTermsFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureKeywordSetsForOwner(
|
||||||
|
ownerProfileId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const count = await countSetsForOwner(ownerProfileId);
|
||||||
|
if (count > 0) return;
|
||||||
|
|
||||||
|
const terms = await defaultTermsForOwner(ownerProfileId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db.insert(keywordSets).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
ownerProfileId,
|
||||||
|
name: "Default",
|
||||||
|
terms: JSON.stringify(terms),
|
||||||
|
isActive: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listKeywordSets(
|
||||||
|
ownerProfileId: string,
|
||||||
|
): Promise<KeywordSet[]> {
|
||||||
|
await ensureKeywordSetsForOwner(ownerProfileId);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(keywordSets)
|
||||||
|
.where(eq(keywordSets.ownerProfileId, ownerProfileId))
|
||||||
|
.orderBy(keywordSets.name);
|
||||||
|
return rows.map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKeywordSetById(
|
||||||
|
id: string,
|
||||||
|
ownerProfileId: string,
|
||||||
|
): Promise<KeywordSet | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(keywordSets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(keywordSets.id, id),
|
||||||
|
eq(keywordSets.ownerProfileId, ownerProfileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return row ? mapRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveKeywordSet(
|
||||||
|
ownerProfileId: string,
|
||||||
|
): Promise<KeywordSet | null> {
|
||||||
|
await ensureKeywordSetsForOwner(ownerProfileId);
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(keywordSets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(keywordSets.ownerProfileId, ownerProfileId),
|
||||||
|
eq(keywordSets.isActive, true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (row) return mapRow(row);
|
||||||
|
|
||||||
|
const all = await listKeywordSets(ownerProfileId);
|
||||||
|
return all[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveKeywordSetTerms(
|
||||||
|
ownerProfileId: string,
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
const active = await getActiveKeywordSet(ownerProfileId);
|
||||||
|
if (!active) return null;
|
||||||
|
return active.terms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createKeywordSet(
|
||||||
|
ownerProfileId: string,
|
||||||
|
input: CreateKeywordSetInput,
|
||||||
|
): Promise<KeywordSet> {
|
||||||
|
await ensureKeywordSetsForOwner(ownerProfileId);
|
||||||
|
const count = await countSetsForOwner(ownerProfileId);
|
||||||
|
if (count >= MAX_SETS_PER_OWNER) {
|
||||||
|
throw new Error(`Maximum of ${MAX_SETS_PER_OWNER} keyword sets per user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = input.name.trim();
|
||||||
|
const terms = (input.terms ?? [])
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, MAX_TERMS_PER_SET);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const id = randomUUID();
|
||||||
|
const shouldActivate = count === 0;
|
||||||
|
|
||||||
|
if (shouldActivate) {
|
||||||
|
await clearActiveForOwner(ownerProfileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(keywordSets).values({
|
||||||
|
id,
|
||||||
|
ownerProfileId,
|
||||||
|
name,
|
||||||
|
terms: JSON.stringify(terms),
|
||||||
|
isActive: shouldActivate,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await getKeywordSetById(id, ownerProfileId);
|
||||||
|
if (!created) throw new Error("Failed to create keyword set");
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateKeywordSet(
|
||||||
|
id: string,
|
||||||
|
ownerProfileId: string,
|
||||||
|
input: UpdateKeywordSetInput,
|
||||||
|
): Promise<KeywordSet | null> {
|
||||||
|
const existing = await getKeywordSetById(id, ownerProfileId);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const updates: Partial<typeof keywordSets.$inferInsert> = {
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (input.name !== undefined) updates.name = input.name.trim();
|
||||||
|
if (input.terms !== undefined) {
|
||||||
|
updates.terms = JSON.stringify(
|
||||||
|
input.terms
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, MAX_TERMS_PER_SET),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(keywordSets)
|
||||||
|
.set(updates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(keywordSets.id, id),
|
||||||
|
eq(keywordSets.ownerProfileId, ownerProfileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await getKeywordSetById(id, ownerProfileId);
|
||||||
|
if (
|
||||||
|
updated?.isActive &&
|
||||||
|
ownerProfileId === "__default__" &&
|
||||||
|
input.terms !== undefined
|
||||||
|
) {
|
||||||
|
await setSetting("searchTerms", JSON.stringify(updated.terms));
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteKeywordSet(
|
||||||
|
id: string,
|
||||||
|
ownerProfileId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const existing = await getKeywordSetById(id, ownerProfileId);
|
||||||
|
if (!existing) return false;
|
||||||
|
|
||||||
|
const count = await countSetsForOwner(ownerProfileId);
|
||||||
|
if (count <= 1) {
|
||||||
|
throw new Error("Cannot delete the last keyword set");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(keywordSets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(keywordSets.id, id),
|
||||||
|
eq(keywordSets.ownerProfileId, ownerProfileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.isActive) {
|
||||||
|
const remaining = await listKeywordSets(ownerProfileId);
|
||||||
|
if (remaining[0]) {
|
||||||
|
await activateKeywordSet(remaining[0].id, ownerProfileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateKeywordSet(
|
||||||
|
id: string,
|
||||||
|
ownerProfileId: string,
|
||||||
|
): Promise<KeywordSet | null> {
|
||||||
|
const existing = await getKeywordSetById(id, ownerProfileId);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await clearActiveForOwner(ownerProfileId);
|
||||||
|
await db
|
||||||
|
.update(keywordSets)
|
||||||
|
.set({ isActive: true, updatedAt: now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(keywordSets.id, id),
|
||||||
|
eq(keywordSets.ownerProfileId, ownerProfileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const activated = await getKeywordSetById(id, ownerProfileId);
|
||||||
|
if (activated && ownerProfileId === "__default__") {
|
||||||
|
await setSetting("searchTerms", JSON.stringify(activated.terms));
|
||||||
|
}
|
||||||
|
return activated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncActiveKeywordSetTerms(
|
||||||
|
ownerProfileId: string,
|
||||||
|
terms: string[],
|
||||||
|
): Promise<KeywordSet | null> {
|
||||||
|
await ensureKeywordSetsForOwner(ownerProfileId);
|
||||||
|
const active = await getActiveKeywordSet(ownerProfileId);
|
||||||
|
if (!active) return null;
|
||||||
|
return updateKeywordSet(active.id, ownerProfileId, { terms });
|
||||||
|
}
|
||||||
@@ -42,15 +42,26 @@ export async function listProfiles(): Promise<SearchProfile[]> {
|
|||||||
return rows.map(mapRow);
|
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`. */
|
/** When Basic Auth maps users to profiles, each user only sees rows with matching `basicAuthUser`. */
|
||||||
export async function listProfilesForBasicAuthUser(
|
export async function listProfilesForBasicAuthUser(
|
||||||
username: string,
|
username: string,
|
||||||
): Promise<SearchProfile[]> {
|
): Promise<SearchProfile[]> {
|
||||||
const u = username.trim().toLowerCase();
|
|
||||||
if (!u) return [];
|
|
||||||
const all = await listProfiles();
|
const all = await listProfiles();
|
||||||
return all.filter(
|
return all.filter((p) =>
|
||||||
(p) => (p.data.basicAuthUser ?? "").trim().toLowerCase() === u,
|
profileMatchesBasicAuthUser(p.data.basicAuthUser, username),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +114,10 @@ export async function getSearchProfileIdForBasicAuthUser(
|
|||||||
username: string,
|
username: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const profiles = await listProfiles();
|
const profiles = await listProfiles();
|
||||||
const u = username.trim().toLowerCase();
|
|
||||||
for (const p of profiles) {
|
for (const p of profiles) {
|
||||||
const bu = (p.data.basicAuthUser ?? "").trim().toLowerCase();
|
if (profileMatchesBasicAuthUser(p.data.basicAuthUser, username)) {
|
||||||
if (bu && bu === u) return p.id;
|
return p.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,38 +4,11 @@ import { join } from "node:path";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { clearProfileCache, getProfile } from "./profile";
|
import { clearProfileCache, getProfile } from "./profile";
|
||||||
|
|
||||||
// Mock the dependencies
|
|
||||||
vi.mock("../repositories/settings", () => ({
|
vi.mock("../repositories/settings", () => ({
|
||||||
getSetting: vi.fn(),
|
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 { 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", () => {
|
describe("getProfile", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,87 +17,14 @@ describe("getProfile", () => {
|
|||||||
delete process.env.JOBOPS_LOCAL_RESUME_PATH;
|
delete process.env.JOBOPS_LOCAL_RESUME_PATH;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw an error if no local file and rxresumeBaseResumeId is not configured", async () => {
|
it("throws when no local resume path is configured", async () => {
|
||||||
vi.mocked(getSetting).mockImplementation(async (key: string) => {
|
vi.mocked(getSetting).mockResolvedValue(null);
|
||||||
if (key === "localResumeProfilePath") return null;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(getProfile()).rejects.toThrow(
|
await expect(getProfile()).rejects.toThrow(
|
||||||
/Base resume not configured|JOBOPS_LOCAL_RESUME_PATH|local resume path/i,
|
/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 () => {
|
it("loads profile from localResumeProfilePath when set", async () => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-"));
|
const dir = await mkdtemp(join(tmpdir(), "jobber-profile-"));
|
||||||
const filePath = join(dir, "resume.json");
|
const filePath = join(dir, "resume.json");
|
||||||
@@ -138,10 +38,55 @@ describe("getProfile", () => {
|
|||||||
|
|
||||||
const profile = await getProfile();
|
const profile = await getProfile();
|
||||||
expect(profile).toEqual(mockResumeData);
|
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 dir = await mkdtemp(join(tmpdir(), "jobber-profile-env-"));
|
||||||
const filePath = join(dir, "resume.json");
|
const filePath = join(dir, "resume.json");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -151,14 +96,25 @@ describe("getProfile", () => {
|
|||||||
);
|
);
|
||||||
process.env.JOBOPS_LOCAL_RESUME_PATH = filePath;
|
process.env.JOBOPS_LOCAL_RESUME_PATH = filePath;
|
||||||
|
|
||||||
mockRxResumeOnlyFlow("ignored-id");
|
vi.mocked(getSetting).mockImplementation(async (key: string) => {
|
||||||
vi.mocked(getResume).mockResolvedValue({
|
if (key === "localResumeProfilePath") return "/other/resume.json";
|
||||||
id: "ignored-id",
|
return null;
|
||||||
data: { basics: { name: "Remote User" } },
|
});
|
||||||
} as any);
|
|
||||||
|
|
||||||
const profile = await getProfile();
|
const profile = await getProfile();
|
||||||
expect(profile.basics?.name).toBe("Env User");
|
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 { getProfileById } from "@server/repositories/profiles";
|
||||||
import { getSetting } from "@server/repositories/settings";
|
import { getSetting } from "@server/repositories/settings";
|
||||||
import type { ResumeProfile } from "@shared/types";
|
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). */
|
/** Cache key is absolute path + file mtime (ms). */
|
||||||
let cachedLocalSourceKey: string | null = null;
|
let cachedLocalSourceKey: string | null = null;
|
||||||
@@ -84,7 +79,7 @@ async function loadProfileFromLocalFile(
|
|||||||
parsed = JSON.parse(raw);
|
parsed = JSON.parse(raw);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
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.
|
* 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.
|
* Results are cached until clearProfileCache() is called.
|
||||||
*
|
*
|
||||||
@@ -118,54 +112,11 @@ export async function getProfile(forceRefresh = false): Promise<ResumeProfile> {
|
|||||||
return loadProfileFromLocalFile(localPath, forceRefresh);
|
return loadProfileFromLocalFile(localPath, forceRefresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { resumeId: rxresumeBaseResumeId } =
|
|
||||||
await getConfiguredRxResumeBaseResumeId();
|
|
||||||
|
|
||||||
if (!rxresumeBaseResumeId) {
|
|
||||||
throw new Error(
|
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.",
|
"Base resume not configured. Set JOBOPS_LOCAL_RESUME_PATH or a local resume path in Settings.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the person's name from the profile.
|
* Get the person's name from the profile.
|
||||||
*/
|
*/
|
||||||
@@ -178,8 +129,6 @@ export async function getPersonName(): Promise<string> {
|
|||||||
* Clear the profile cache.
|
* Clear the profile cache.
|
||||||
*/
|
*/
|
||||||
export function clearProfileCache(): void {
|
export function clearProfileCache(): void {
|
||||||
cachedProfile = null;
|
|
||||||
cachedResumeId = null;
|
|
||||||
cachedLocalSourceKey = null;
|
cachedLocalSourceKey = null;
|
||||||
cachedLocalProfile = null;
|
cachedLocalProfile = null;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user