Merge branch 'main' into reactive-resume-v5
This commit is contained in:
commit
44779c96fa
12
.env.example
12
.env.example
@ -6,16 +6,16 @@
|
||||
# OpenRouter API for AI scoring and summaries
|
||||
# Get your key at: https://openrouter.ai/keys
|
||||
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||
MODEL=openai/gpt-4o-mini
|
||||
MODEL=google/gemini-3-flash-preview
|
||||
|
||||
# RXResume credentials for PDF generation
|
||||
# Create an account at: https://rxresu.me
|
||||
# for reference: https://docs.rxresu.me/guides/using-the-api
|
||||
RXRESUME_API_KEY=
|
||||
# Create an account at: https://v4.rxresu.me
|
||||
RXRESUME_EMAIL=your_email@example.com
|
||||
RXRESUME_PASSWORD=your_password_here
|
||||
|
||||
# Optional: Basic Auth for write access (read-only without auth)
|
||||
# Optional: Basic Auth for write access
|
||||
# the app is fully unauthenticated if this isn't set, which is the default
|
||||
# When set, all write actions (POST/PATCH/DELETE) require Basic Auth.
|
||||
# Browsing remains public and read-only.
|
||||
BASIC_AUTH_USER=
|
||||
BASIC_AUTH_PASSWORD=
|
||||
|
||||
|
||||
29
README.md
29
README.md
@ -6,7 +6,7 @@ AI-powered job discovery and application pipeline. Automatically finds jobs, sco
|
||||
1. **Search**: Scrapes Gradcracker, Indeed, LinkedIn, and UK Visa Sponsorship jobs.
|
||||
2. **Score**: AI ranks jobs by suitability using OpenRouter.
|
||||
3. **Tailor**: Generates a custom resume summary for top-tier matches.
|
||||
4. **Export**: Automates [RxResume](https://rxresu.me) to create tailored PDFs.
|
||||
4. **Export**: Automates [RxResume](https://v4.rxresu.me) to create tailored PDFs.
|
||||
5. **Manage**: Review and mark jobs as "Applied" via the dashboard (syncs to Notion).
|
||||
|
||||
## Example of generating a tailored resume for a job
|
||||
@ -17,20 +17,17 @@ https://github.com/user-attachments/assets/06e5e782-47f5-42d0-8b28-b89102d7ea1b
|
||||
|
||||
## Quick Start
|
||||
```bash
|
||||
# 1. Setup environment
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Run with Docker
|
||||
# 1. Run with Docker
|
||||
docker compose up -d --build
|
||||
|
||||
# 3. Access Dashboard
|
||||
# 2. Open the dashboard
|
||||
# http://localhost:3005
|
||||
```
|
||||
|
||||
## Setup
|
||||
Essential variables in `.env`:
|
||||
- `OPENROUTER_API_KEY`: For job scoring and tailoring.
|
||||
- `RXRESUME_EMAIL`/`PASSWORD`: To automate PDF exports.
|
||||
The app will guide you through setup on first launch. The onboarding wizard helps you:
|
||||
- Connect your OpenRouter API key (for AI scoring/tailoring)
|
||||
- Add your RxResume credentials (for PDF export)
|
||||
- Upload your base resume JSON (exported from RxResume)
|
||||
|
||||
## Structure
|
||||
- `/orchestrator`: React frontend + Node.js backend & pipeline.
|
||||
@ -43,14 +40,8 @@ Orchestrator docs here: `documentation/orchestrator.md`
|
||||
|
||||
## Read-only mode (Basic Auth)
|
||||
|
||||
Set `BASIC_AUTH_USER` and `BASIC_AUTH_PASSWORD` in `.env` to make the app read-only for the public.
|
||||
You can make the app read-only for the public by setting a username and password in the **Settings** page.
|
||||
After this, all write actions (POST/PATCH/DELETE) require Basic Auth; browsing and viewing remain public.
|
||||
2. Put your exported RXResume JSON at `resume-generator/base.json`.
|
||||
3. Start: `docker compose up -d --build`
|
||||
4. Open:
|
||||
- Dashboard/UI: `http://localhost:3005`
|
||||
- API: `http://localhost:3005/api`
|
||||
- Health: `http://localhost:3005/health`
|
||||
|
||||
Persistent data lives in `./data` (bind-mounted into the container).
|
||||
|
||||
@ -97,6 +88,10 @@ Dev URLs:
|
||||
- **Pipeline config knobs**: `POST /api/pipeline/run` accepts `{ topN, minSuitabilityScore }`; `PIPELINE_TOP_N`/`PIPELINE_MIN_SCORE` are used by `npm run pipeline:run` (CLI runner).
|
||||
- **Anti-bot reality**: crawling is headless + "humanized", but sites can still block; expect occasional flakiness.
|
||||
|
||||
Note on Analytics: The current alpha version includes anonymous analytics (Umami) to help me debug performance. This will be made opt-in only in the upcoming updates. If you want to disable it now, block umami.dakheera47.com in your firewall.
|
||||
|
||||
[](https://app.repohistory.com/star-history)
|
||||
|
||||
## License
|
||||
|
||||
AGPLv3
|
||||
|
||||
@ -14,10 +14,6 @@ services:
|
||||
volumes:
|
||||
# Persist database and generated PDFs
|
||||
- ./data:/app/data
|
||||
# Base resume JSON (read-only)
|
||||
- ./resume-generator/base.json:/app/resume-generator/base.json:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Server config
|
||||
- NODE_ENV=production
|
||||
|
||||
@ -38,7 +38,7 @@ Once a job is `ready`, the Ready panel is the "shipping lane":
|
||||
|
||||
The PDF is generated from:
|
||||
|
||||
- The base resume JSON (`resume-generator/base.json`).
|
||||
- The base resume JSON (uploaded via the Onboarding UI or Settings).
|
||||
- The job description (used for AI tailoring and project selection).
|
||||
- Your tailored summary/headline/skills and selected projects.
|
||||
|
||||
|
||||
@ -1,61 +1,40 @@
|
||||
# Self-Hosting (Docker Compose)
|
||||
|
||||
This project is designed to be self-hostable with a single Docker Compose command.
|
||||
The easiest way to run JobOps is via Docker Compose. The app is self-configuring and will guide you through the setup on your first visit.
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Docker Desktop or Docker Engine + Compose v2
|
||||
- An OpenRouter API key (required for AI scoring and summaries)
|
||||
- RXResume credentials (only if you want PDF exports)
|
||||
|
||||
## 1) Clone and set up environment
|
||||
## 1) Start the stack
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and set at least:
|
||||
- `OPENROUTER_API_KEY`
|
||||
|
||||
Optional but commonly used:
|
||||
- `RXRESUME_EMAIL`, `RXRESUME_PASSWORD` (for CV PDF generation)
|
||||
- `UKVISAJOBS_EMAIL`, `UKVISAJOBS_PASSWORD` (if you want to scrape UKVisaJobs)
|
||||
- `BASIC_AUTH_USER`, `BASIC_AUTH_PASSWORD` (read-only public, auth required for writes)
|
||||
|
||||
## 2) Provide a base resume JSON
|
||||
|
||||
The container mounts a base resume JSON at `resume-generator/base.json`.
|
||||
|
||||
- Create or copy your exported RXResume JSON to:
|
||||
- `resume-generator/base.json`
|
||||
|
||||
If you do not plan to generate PDFs, you can still provide a minimal JSON file to satisfy the mount.
|
||||
|
||||
## 3) Start the stack
|
||||
No environment variables are strictly required to start. Simply run:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
This will build a single container that runs the API, UI, scrapers, and resume generator.
|
||||
This builds a single container that runs the API, UI, scrapers, and resume generator.
|
||||
|
||||
## 4) Access the app
|
||||
## 2) Access the app and Onboard
|
||||
|
||||
- Dashboard: http://localhost:3005
|
||||
- API: http://localhost:3005/api
|
||||
- Health: http://localhost:3005/health
|
||||
Open your browser to:
|
||||
- **Dashboard**: http://localhost:3005
|
||||
|
||||
On first launch, you will be greeted by an **Onboarding Wizard**. The app will help you validate and save your configuration:
|
||||
|
||||
1. **Connect AI**: Add your OpenRouter API key (required for job scoring and summaries).
|
||||
2. **PDF Export**: Add your RxResume credentials (if you want to generate tailored PDFs).
|
||||
3. **Resume JSON**: Upload your base resume JSON (exported from RxResume).
|
||||
|
||||
The app saves these to its persistent database, so you don't need to manage `.env` files for basic setup. All other settings (like search terms, job sources, and more) can also be configured directly in the UI.
|
||||
|
||||
## Persistent data
|
||||
|
||||
`./data` is bind-mounted into the container. It stores:
|
||||
- SQLite DB: `data/jobs.db`
|
||||
- SQLite DB: `data/jobs.db` (contains your API keys and configuration)
|
||||
- Generated PDFs: `data/pdfs/`
|
||||
|
||||
## Common issues
|
||||
|
||||
- First build is slow: Playwright + Camoufox download Firefox during the image build.
|
||||
- Scraping can be blocked by target sites (LinkedIn/Indeed/UKVisa). Retry or adjust sources.
|
||||
- Missing `resume-generator/base.json` will break PDF generation (and the mount).
|
||||
- Resume JSON: Stored internally after upload.
|
||||
|
||||
## Updating
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ orchestrator/
|
||||
2. **Set up environment:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your API keys
|
||||
# The app is self-configuring. You can add keys via the UI Onboarding.
|
||||
```
|
||||
|
||||
3. **Initialize database:**
|
||||
|
||||
868
orchestrator/package-lock.json
generated
868
orchestrator/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,7 +9,7 @@
|
||||
"dev:server": "tsx watch src/server/index.ts",
|
||||
"dev:client": "vite --host",
|
||||
"build": "npm run build:client && npm run build:server",
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
"build:server": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json",
|
||||
"build:client": "vite build",
|
||||
"start": "node dist/server/index.js",
|
||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
||||
@ -20,16 +20,22 @@
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@paralleldrive/cuid2": "^3.0.6",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -39,13 +45,13 @@
|
||||
"express": "^4.18.2",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@ -70,7 +76,9 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.0.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsx": "^4.19.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.0.16"
|
||||
|
||||
@ -11,6 +11,7 @@ import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
|
||||
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
||||
import { OnboardingGate } from "./components/OnboardingGate";
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const location = useLocation();
|
||||
@ -27,6 +28,7 @@ export const App: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OnboardingGate />
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
key={pageKey}
|
||||
|
||||
@ -8,7 +8,6 @@ import type {
|
||||
JobsListResponse,
|
||||
PipelineStatusResponse,
|
||||
JobSource,
|
||||
PipelineRun,
|
||||
AppSettings,
|
||||
ResumeProjectsSettings,
|
||||
ResumeProjectCatalogItem,
|
||||
@ -20,6 +19,9 @@ import type {
|
||||
VisaSponsorSearchResponse,
|
||||
VisaSponsorStatusResponse,
|
||||
VisaSponsor,
|
||||
ResumeProfile,
|
||||
ProfileStatusResponse,
|
||||
ValidationResult,
|
||||
} from '../../shared/types';
|
||||
import { trackEvent } from "@/lib/analytics";
|
||||
|
||||
@ -86,6 +88,12 @@ export async function generateJobPdf(id: string): Promise<Job> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkSponsor(id: string): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}/check-sponsor`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAsApplied(id: string): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}/apply`, {
|
||||
method: 'POST',
|
||||
@ -168,6 +176,38 @@ export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]>
|
||||
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
|
||||
}
|
||||
|
||||
export async function getProfile(): Promise<ResumeProfile> {
|
||||
return fetchApi<ResumeProfile>('/profile');
|
||||
}
|
||||
|
||||
export async function getProfileStatus(): Promise<ProfileStatusResponse> {
|
||||
return fetchApi<ProfileStatusResponse>('/profile/status');
|
||||
}
|
||||
|
||||
export async function uploadProfile(profile: ResumeProfile): Promise<ProfileStatusResponse> {
|
||||
return fetchApi<ProfileStatusResponse>('/profile/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ profile }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateOpenrouter(apiKey?: string): Promise<ValidationResult> {
|
||||
return fetchApi<ValidationResult>('/onboarding/validate/openrouter', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateRxresume(email?: string, password?: string): Promise<ValidationResult> {
|
||||
return fetchApi<ValidationResult>('/onboarding/validate/rxresume', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateResumeJson(): Promise<ValidationResult> {
|
||||
return fetchApi<ValidationResult>('/onboarding/validate/resume');
|
||||
}
|
||||
|
||||
export async function updateSettings(update: {
|
||||
model?: string | null
|
||||
@ -186,7 +226,15 @@ export async function updateSettings(update: {
|
||||
jobspyCountryIndeed?: string | null
|
||||
jobspySites?: string[] | null
|
||||
jobspyLinkedinFetchDescription?: boolean | null
|
||||
rxResumeBaseResumeId?: string | null
|
||||
showSponsorInfo?: boolean | null
|
||||
openrouterApiKey?: string | null
|
||||
rxresumeEmail?: string | null
|
||||
rxresumePassword?: string | null
|
||||
basicAuthUser?: string | null
|
||||
basicAuthPassword?: string | null
|
||||
ukvisajobsEmail?: string | null
|
||||
ukvisajobsPassword?: string | null
|
||||
webhookSecret?: string | null
|
||||
}): Promise<AppSettings> {
|
||||
return fetchApi<AppSettings>('/settings', {
|
||||
method: 'PATCH',
|
||||
|
||||
104
orchestrator/src/client/components/JobHeader.test.tsx
Normal file
104
orchestrator/src/client/components/JobHeader.test.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { JobHeader } from "./JobHeader";
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
import type { Job } from "../../shared/types";
|
||||
|
||||
// Mock useSettings
|
||||
vi.mock("../hooks/useSettings", () => ({
|
||||
useSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock api
|
||||
vi.mock("../api", () => ({
|
||||
checkSponsor: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock Tooltip components to simplify testing
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-content">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockJob: Job = {
|
||||
id: "job-1",
|
||||
title: "Software Engineer",
|
||||
employer: "Tech Corp",
|
||||
location: "London",
|
||||
salary: "£60,000",
|
||||
deadline: "2025-12-31",
|
||||
status: "discovered",
|
||||
source: "linkedin",
|
||||
suitabilityScore: 85,
|
||||
suitabilityReason: "Strong match",
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
// Other fields...
|
||||
} as Job;
|
||||
|
||||
describe("JobHeader", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(useSettings as any).mockReturnValue({
|
||||
showSponsorInfo: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders basic job information", () => {
|
||||
render(<JobHeader job={mockJob} />);
|
||||
expect(screen.getByText("Software Engineer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tech Corp")).toBeInTheDocument();
|
||||
expect(screen.getByText("London")).toBeInTheDocument();
|
||||
expect(screen.getByText("£60,000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
|
||||
const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
|
||||
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />);
|
||||
|
||||
const button = screen.getByText("Check Sponsorship Status");
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onCheckSponsor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows 'Confirmed Sponsor' when score >= 95", () => {
|
||||
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98, sponsorMatchNames: '["Tech Corp Ltd"]' };
|
||||
render(<JobHeader job={jobWithSponsor} />);
|
||||
|
||||
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Potential Sponsor' when score is between 80 and 94", () => {
|
||||
const jobWithPotential = { ...mockJob, sponsorMatchScore: 85, sponsorMatchNames: '["Techy Corp"]' };
|
||||
render(<JobHeader job={jobWithPotential} />);
|
||||
|
||||
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Sponsor Not Found' when score < 80", () => {
|
||||
const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' };
|
||||
render(<JobHeader job={jobNoSponsor} />);
|
||||
|
||||
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides sponsor info when showSponsorInfo is false", () => {
|
||||
(useSettings as any).mockReturnValue({
|
||||
showSponsorInfo: false,
|
||||
});
|
||||
|
||||
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
|
||||
render(<JobHeader job={jobWithSponsor} />);
|
||||
|
||||
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,13 +1,18 @@
|
||||
import React from "react";
|
||||
import { Calendar, DollarSign, MapPin } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn, formatDate, sourceLabel } from "@/lib/utils";
|
||||
import type { Job, JobStatus } from "../../shared/types";
|
||||
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
|
||||
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
|
||||
interface JobHeaderProps {
|
||||
job: Job;
|
||||
className?: string;
|
||||
onCheckSponsor?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
|
||||
@ -42,7 +47,101 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||
interface SponsorPillProps {
|
||||
score: number | null;
|
||||
names: string | null;
|
||||
onCheck?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
const parsedNames = useMemo(() => {
|
||||
if (!names) return [];
|
||||
try {
|
||||
return JSON.parse(names) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [names]);
|
||||
|
||||
const handleCheck = async () => {
|
||||
if (!onCheck) return;
|
||||
setIsChecking(true);
|
||||
try {
|
||||
await onCheck();
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show "Check" button if no score and callback provided
|
||||
if (score == null && onCheck) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 px-1.5 text-xs font-medium text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
onClick={handleCheck}
|
||||
disabled={isChecking}
|
||||
>
|
||||
{isChecking ? (
|
||||
<Loader2 className="h-2 w-2 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-2 w-2" />
|
||||
)}
|
||||
<span>{isChecking ? "Checking..." : "Check Sponsorship Status"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">Check if employer is a visa sponsor</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (score == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatus = (s: number) => {
|
||||
if (s >= 95) return { label: "Confirmed Sponsor", dot: "bg-emerald-500", color: "text-emerald-400" };
|
||||
if (s >= 80) return { label: "Potential Sponsor", dot: "bg-amber-500", color: "text-amber-400" };
|
||||
return { label: "Sponsor Not Found", dot: "bg-slate-500", color: "text-slate-400" };
|
||||
};
|
||||
|
||||
const status = getStatus(score);
|
||||
const tooltipContent = `${score}% match`;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80 cursor-help">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", status.dot)} />
|
||||
{status.label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{parsedNames.length > 0 && (
|
||||
<p className="text-xs font-medium space-x-1">
|
||||
<span className="opacity-70">Matched</span>
|
||||
<span>{parsedNames.join(", ")}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="opacity-80 mt-1 text-[10px]">{tooltipContent}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, onCheckSponsor }) => {
|
||||
const { showSponsorInfo } = useSettings();
|
||||
const deadline = formatDate(job.deadline);
|
||||
|
||||
return (
|
||||
@ -51,7 +150,9 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold text-foreground/90">{job.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{job.employer}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{job.employer}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
|
||||
{sourceLabel[job.source]}
|
||||
@ -82,7 +183,16 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||
|
||||
{/* Status and score: single line, subdued */}
|
||||
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
||||
<StatusPill status={job.status} />
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusPill status={job.status} />
|
||||
{showSponsorInfo && (
|
||||
<SponsorPill
|
||||
score={job.sponsorMatchScore}
|
||||
names={job.sponsorMatchNames}
|
||||
onCheck={onCheckSponsor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ScoreMeter score={job.suitabilityScore} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
501
orchestrator/src/client/components/OnboardingGate.tsx
Normal file
501
orchestrator/src/client/components/OnboardingGate.tsx
Normal file
@ -0,0 +1,501 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Check } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as api from "@client/api"
|
||||
import { useSettings } from "@client/hooks/useSettings"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
||||
import type { ResumeProfile, ValidationResult } from "@shared/types"
|
||||
|
||||
type ValidationState = ValidationResult & { checked: boolean }
|
||||
|
||||
export const OnboardingGate: React.FC = () => {
|
||||
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
|
||||
const [isSavingEnv, setIsSavingEnv] = useState(false)
|
||||
const [isUploadingResume, setIsUploadingResume] = useState(false)
|
||||
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
|
||||
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
|
||||
const [isValidatingResume, setIsValidatingResume] = useState(false)
|
||||
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
})
|
||||
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>({
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
})
|
||||
const [resumeValidation, setResumeValidation] = useState<ValidationState>({
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
})
|
||||
const [currentStep, setCurrentStep] = useState<string | null>(null)
|
||||
|
||||
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
|
||||
const [rxresumeEmail, setRxresumeEmail] = useState("")
|
||||
const [rxresumePassword, setRxresumePassword] = useState("")
|
||||
const [resumeFile, setResumeFile] = useState<File | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const validateResume = useCallback(async () => {
|
||||
setIsValidatingResume(true)
|
||||
try {
|
||||
const result = await api.validateResumeJson()
|
||||
setResumeValidation({ ...result, checked: true })
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Resume validation failed"
|
||||
const result = { valid: false, message }
|
||||
setResumeValidation({ ...result, checked: true })
|
||||
return result
|
||||
} finally {
|
||||
setIsValidatingResume(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const validateOpenrouter = useCallback(async (apiKey?: string) => {
|
||||
setIsValidatingOpenrouter(true)
|
||||
try {
|
||||
const result = await api.validateOpenrouter(apiKey)
|
||||
setOpenrouterValidation({ ...result, checked: true })
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "OpenRouter validation failed"
|
||||
const result = { valid: false, message }
|
||||
setOpenrouterValidation({ ...result, checked: true })
|
||||
return result
|
||||
} finally {
|
||||
setIsValidatingOpenrouter(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const validateRxresume = useCallback(async (email?: string, password?: string) => {
|
||||
setIsValidatingRxresume(true)
|
||||
try {
|
||||
const result = await api.validateRxresume(email, password)
|
||||
setRxresumeValidation({ ...result, checked: true })
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "RxResume validation failed"
|
||||
const result = { valid: false, message }
|
||||
setRxresumeValidation({ ...result, checked: true })
|
||||
return result
|
||||
} finally {
|
||||
setIsValidatingRxresume(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint)
|
||||
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
|
||||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
|
||||
const hasBaseResume = resumeValidation.valid
|
||||
|
||||
const shouldOpen = Boolean(settings && !settingsLoading)
|
||||
&& !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid)
|
||||
|
||||
const openrouterCurrent = settings?.openrouterApiKeyHint
|
||||
? formatSecretHint(settings.openrouterApiKeyHint)
|
||||
: undefined
|
||||
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
|
||||
? settings.rxresumeEmail
|
||||
: undefined
|
||||
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
|
||||
? formatSecretHint(settings.rxresumePasswordHint)
|
||||
: undefined
|
||||
|
||||
const steps = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "openrouter",
|
||||
label: "Connect AI",
|
||||
subtitle: "OpenRouter key",
|
||||
complete: openrouterValidation.valid,
|
||||
},
|
||||
{
|
||||
id: "rxresume",
|
||||
label: "PDF Export",
|
||||
subtitle: "RxResume login",
|
||||
complete: rxresumeValidation.valid,
|
||||
},
|
||||
{
|
||||
id: "resume",
|
||||
label: "Resume JSON",
|
||||
subtitle: "Upload your file",
|
||||
complete: resumeValidation.valid,
|
||||
},
|
||||
],
|
||||
[openrouterValidation.valid, resumeValidation.valid, rxresumeValidation.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
|
||||
const results = await Promise.allSettled([
|
||||
validateOpenrouter(),
|
||||
validateRxresume(),
|
||||
validateResume(),
|
||||
])
|
||||
|
||||
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, validateOpenrouter, validateRxresume, validateResume])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings || settingsLoading) return
|
||||
if (openrouterValidation.checked || rxresumeValidation.checked || resumeValidation.checked) return
|
||||
void runAllValidations()
|
||||
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, resumeValidation.checked, runAllValidations])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const results = await Promise.allSettled([refreshSettings(), runAllValidations()])
|
||||
const failed = results.find((result) => result.status === "rejected")
|
||||
if (failed) {
|
||||
const reason = failed.status === "rejected" ? failed.reason : null
|
||||
const message = reason instanceof Error ? reason.message : "Failed to refresh setup"
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveOpenrouter = async (): Promise<boolean> => {
|
||||
const openrouterValue = openrouterApiKey.trim()
|
||||
if (!openrouterValue && !hasOpenrouterKey) {
|
||||
toast.info("Add your OpenRouter API key to continue")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = await validateOpenrouter(openrouterValue || undefined)
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.message || "OpenRouter validation failed")
|
||||
return false
|
||||
}
|
||||
|
||||
if (openrouterValue) {
|
||||
setIsSavingEnv(true)
|
||||
await api.updateSettings({ openrouterApiKey: openrouterValue })
|
||||
await refreshSettings()
|
||||
setOpenrouterApiKey("")
|
||||
}
|
||||
|
||||
toast.success("OpenRouter connected")
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save OpenRouter key"
|
||||
toast.error(message)
|
||||
return false
|
||||
} finally {
|
||||
setIsSavingEnv(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveRxresume = async (): Promise<boolean> => {
|
||||
const emailValue = rxresumeEmail.trim()
|
||||
const passwordValue = rxresumePassword.trim()
|
||||
const missing: string[] = []
|
||||
|
||||
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email")
|
||||
if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password")
|
||||
|
||||
if (missing.length > 0) {
|
||||
toast.info("Almost there", {
|
||||
description: `Missing: ${missing.join(", ")}`,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = await validateRxresume(emailValue || undefined, passwordValue || undefined)
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.message || "RxResume validation failed")
|
||||
return false
|
||||
}
|
||||
|
||||
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {}
|
||||
if (emailValue) update.rxresumeEmail = emailValue
|
||||
if (passwordValue) update.rxresumePassword = passwordValue
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
setIsSavingEnv(true)
|
||||
await api.updateSettings(update)
|
||||
await refreshSettings()
|
||||
setRxresumePassword("")
|
||||
}
|
||||
|
||||
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 {
|
||||
setIsSavingEnv(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadResume = async (): Promise<boolean> => {
|
||||
if (!resumeFile) {
|
||||
const validation = await validateResume()
|
||||
if (!validation.valid) {
|
||||
toast.info(validation.message || "Upload your resume JSON to continue")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploadingResume(true)
|
||||
const text = await resumeFile.text()
|
||||
let parsed: ResumeProfile
|
||||
try {
|
||||
parsed = JSON.parse(text) as ResumeProfile
|
||||
} catch {
|
||||
throw new Error("Resume JSON is invalid. Export the base.json from RxResume.")
|
||||
}
|
||||
|
||||
await api.uploadProfile(parsed)
|
||||
await validateResume()
|
||||
setResumeFile(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
toast.success("Resume uploaded")
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to upload resume"
|
||||
toast.error(message)
|
||||
return false
|
||||
} finally {
|
||||
setIsUploadingResume(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resumeFileName = resumeFile?.name || ""
|
||||
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 || isUploadingResume || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingResume
|
||||
const canGoBack = stepIndex > 0
|
||||
const primaryLabel = currentStep === "resume"
|
||||
? (resumeValidation.valid ? "Finish" : "Upload and validate")
|
||||
: currentStep === "openrouter"
|
||||
? (openrouterValidation.valid ? "Revalidate" : "Validate")
|
||||
: currentStep === "rxresume"
|
||||
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
|
||||
: "Validate"
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
if (!currentStep) return
|
||||
if (currentStep === "openrouter") {
|
||||
await handleSaveOpenrouter()
|
||||
return
|
||||
}
|
||||
if (currentStep === "rxresume") {
|
||||
await handleSaveRxresume()
|
||||
return
|
||||
}
|
||||
if (currentStep === "resume") {
|
||||
if (hasBaseResume) {
|
||||
await handleRefresh()
|
||||
return
|
||||
}
|
||||
await handleUploadResume()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (!canGoBack) return
|
||||
setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep)
|
||||
}
|
||||
|
||||
if (!shouldOpen || !currentStep) return null
|
||||
|
||||
return (
|
||||
<AlertDialog open>
|
||||
<AlertDialogContent
|
||||
className="max-w-3xl max-h-[90vh] overflow-hidden p-0"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="space-y-6 px-6 py-6 max-h-[calc(90vh-3.5rem)] overflow-y-auto">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Let’s get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<Tabs value={currentStep} onValueChange={setCurrentStep}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep
|
||||
const isComplete = step.complete
|
||||
|
||||
return (
|
||||
<FieldLabel
|
||||
key={step.id}
|
||||
className="w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none"
|
||||
>
|
||||
<TabsTrigger
|
||||
value={step.id}
|
||||
className={cn(
|
||||
"w-full rounded-none border-b-2 border-transparent px-3 py-4 text-left shadow-none",
|
||||
isActive ? "border-primary bg-muted/60 text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Field orientation="horizontal" className="items-start">
|
||||
<FieldContent>
|
||||
<FieldTitle>{step.label}</FieldTitle>
|
||||
<FieldDescription>{step.subtitle}</FieldDescription>
|
||||
</FieldContent>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
|
||||
isComplete
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isComplete ? <Check className="h-3.5 w-3.5" /> : index + 1}
|
||||
</span>
|
||||
</Field>
|
||||
</TabsTrigger>
|
||||
</FieldLabel>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="openrouter" className="space-y-4 pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Connect OpenRouter</p>
|
||||
<p className="text-xs text-muted-foreground">Used for job scoring, summaries, and tailoring.</p>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="OpenRouter API key"
|
||||
inputProps={{
|
||||
name: "openrouterApiKey",
|
||||
value: openrouterApiKey,
|
||||
onChange: (event) => setOpenrouterApiKey(event.target.value),
|
||||
}}
|
||||
type="password"
|
||||
placeholder="sk-or-v1..."
|
||||
current={openrouterCurrent}
|
||||
helper="Create a key at openrouter.ai"
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rxresume" className="space-y-4 pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Link your RxResume account</p>
|
||||
<p className="text-xs text-muted-foreground">Used to export tailored PDFs.</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="Email"
|
||||
inputProps={{
|
||||
name: "rxresumeEmail",
|
||||
value: rxresumeEmail,
|
||||
onChange: (event) => setRxresumeEmail(event.target.value),
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
current={rxresumeEmailCurrent}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={{
|
||||
name: "rxresumePassword",
|
||||
value: rxresumePassword,
|
||||
onChange: (event) => setRxresumePassword(event.target.value),
|
||||
}}
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
current={rxresumePasswordCurrent}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="resume" className="space-y-4 pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Upload your resume JSON</p>
|
||||
<p className="text-xs text-muted-foreground">Use the JSON export you downloaded from v4.rxresu.me.</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="resumeFile" className="text-sm font-medium">
|
||||
Resume JSON
|
||||
</label>
|
||||
<Input
|
||||
id="resumeFile"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={(event) => setResumeFile(event.target.files?.[0] ?? null)}
|
||||
disabled={isUploadingResume}
|
||||
/>
|
||||
{resumeFileName && (
|
||||
<p className="text-xs text-muted-foreground">Selected: {resumeFileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleBack} disabled={!canGoBack || isBusy}>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={handleRefresh} disabled={isBusy}>
|
||||
Refresh status
|
||||
</Button>
|
||||
<Button onClick={handlePrimaryAction} disabled={isBusy}>
|
||||
{isBusy ? "Working..." : primaryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={progressValue} className="h-2" />
|
||||
|
||||
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
Friendly heads-up: pipelines can be slow or a little flaky in alpha. If anything feels off, open a GitHub issue and
|
||||
we will take a look.{" "}
|
||||
<a
|
||||
className="font-semibold text-foreground underline underline-offset-2"
|
||||
href="https://github.com/DaKheera47/job-ops/issues"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open an issue
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@ -3,6 +3,8 @@
|
||||
*
|
||||
* Designed for a single, fast, repeatable workflow: verify → download → apply → mark applied.
|
||||
* The PDF is the primary artifact, represented abstractly through an Application Kit summary.
|
||||
*
|
||||
* Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@ -42,14 +44,16 @@ import {
|
||||
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
||||
import { TailorMode } from "./discovered-panel/TailorMode";
|
||||
import { useProfile } from "../hooks/useProfile";
|
||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||
|
||||
type PanelMode = "ready" | "tailor";
|
||||
|
||||
interface ReadyPanelProps {
|
||||
job: Job | null;
|
||||
onJobUpdated: () => void | Promise<void>;
|
||||
onJobMoved: (jobId: string) => void;
|
||||
onEditTailoring: () => void;
|
||||
onEditDescription: () => void;
|
||||
}
|
||||
|
||||
const safeFilenamePart = (value: string | null | undefined) =>
|
||||
@ -59,9 +63,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
job,
|
||||
onJobUpdated,
|
||||
onJobMoved,
|
||||
onEditTailoring,
|
||||
onEditDescription,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<PanelMode>("ready");
|
||||
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
@ -72,11 +75,18 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
} | null>(null);
|
||||
|
||||
const { personName } = useProfile();
|
||||
|
||||
// Load project catalog once
|
||||
useEffect(() => {
|
||||
api.getProfileProjects().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Reset mode when job changes
|
||||
useEffect(() => {
|
||||
setMode("ready");
|
||||
}, [job?.id]);
|
||||
|
||||
// Compute derived values
|
||||
const pdfHref = job
|
||||
? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}`
|
||||
@ -141,7 +151,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
// Revert to ready status
|
||||
await api.updateJob(jobId, { status: "ready" });
|
||||
toast.success("Reverted to Ready");
|
||||
|
||||
|
||||
if (recentlyApplied?.timeoutId) {
|
||||
clearTimeout(recentlyApplied.timeoutId);
|
||||
}
|
||||
@ -198,6 +208,23 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
}
|
||||
}, [job]);
|
||||
|
||||
// Handler for regenerating PDF after tailoring edits
|
||||
const handleTailorFinalize = useCallback(async () => {
|
||||
if (!job) return;
|
||||
try {
|
||||
setIsRegenerating(true);
|
||||
await api.generateJobPdf(job.id);
|
||||
toast.success("PDF regenerated");
|
||||
await onJobUpdated();
|
||||
setMode("ready");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
}, [job, onJobUpdated]);
|
||||
|
||||
// Empty state
|
||||
if (!job) {
|
||||
return (
|
||||
@ -213,9 +240,29 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Tailor mode - reuse the same TailorMode component with 'ready' variant
|
||||
if (mode === "tailor") {
|
||||
return (
|
||||
<TailorMode
|
||||
job={job}
|
||||
onBack={() => setMode("ready")}
|
||||
onFinalize={handleTailorFinalize}
|
||||
isFinalizing={isRegenerating}
|
||||
variant="ready"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<JobHeader job={job} className="pb-4 border-b border-border/40" />
|
||||
<JobHeader
|
||||
job={job}
|
||||
className="pb-4 border-b border-border/40"
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(job.id);
|
||||
await onJobUpdated();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────
|
||||
PRIMARY ACTION CLUSTER
|
||||
@ -235,7 +282,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
|
||||
<a
|
||||
href={pdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(job.employer)}.pdf`}
|
||||
download={`${safeFilenamePart(personName)}_${safeFilenamePart(job.employer)}.pdf`}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">Download PDF</span>
|
||||
@ -325,7 +372,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-56">
|
||||
{/* Fix/Edit actions */}
|
||||
<DropdownMenuItem onSelect={onEditTailoring}>
|
||||
<DropdownMenuItem onSelect={() => setMode("tailor")}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit tailoring
|
||||
</DropdownMenuItem>
|
||||
@ -338,11 +385,6 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={onEditDescription}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit job description
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Utility actions */}
|
||||
|
||||
@ -14,6 +14,7 @@ interface DecideModeProps {
|
||||
onTailor: () => void;
|
||||
onSkip: () => void;
|
||||
isSkipping: boolean;
|
||||
onCheckSponsor?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
@ -21,6 +22,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
onTailor,
|
||||
onSkip,
|
||||
isSkipping,
|
||||
onCheckSponsor,
|
||||
}) => {
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
const jobLink = job.applicationLink || job.jobUrl;
|
||||
@ -33,7 +35,10 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
<div className='space-y-4 pb-4'>
|
||||
<JobHeader job={job} />
|
||||
<JobHeader
|
||||
job={job}
|
||||
onCheckSponsor={onCheckSponsor}
|
||||
/>
|
||||
|
||||
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'>
|
||||
<Button
|
||||
|
||||
@ -85,6 +85,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
onTailor={() => setMode("tailor")}
|
||||
onSkip={handleSkip}
|
||||
isSkipping={isSkipping}
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(job.id);
|
||||
await onJobUpdated();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TailorMode
|
||||
|
||||
@ -15,6 +15,8 @@ interface TailorModeProps {
|
||||
onBack: () => void;
|
||||
onFinalize: () => void;
|
||||
isFinalizing: boolean;
|
||||
/** Variant controls the finalize button text. Default is 'discovered'. */
|
||||
variant?: 'discovered' | 'ready';
|
||||
}
|
||||
|
||||
export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
@ -22,6 +24,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
onBack,
|
||||
onFinalize,
|
||||
isFinalizing,
|
||||
variant = 'discovered',
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
@ -274,7 +277,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
<div className='space-y-2'>
|
||||
{!canFinalize && (
|
||||
<p className='text-[10px] text-center text-muted-foreground'>
|
||||
Add a summary and select at least one project to finalize.
|
||||
Add a summary and select at least one project to {variant === 'ready' ? 'regenerate' : 'finalize'}.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
@ -285,17 +288,19 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
{isFinalizing ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Finalizing & generating PDF...
|
||||
{variant === 'ready' ? 'Regenerating PDF...' : 'Finalizing & generating PDF...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className='mr-2 h-4 w-4' />
|
||||
Finalize & Move to Ready
|
||||
{variant === 'ready' ? 'Regenerate PDF' : 'Finalize & Move to Ready'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className='text-[10px] text-center text-muted-foreground/70'>
|
||||
This will generate your tailored PDF and move the job to Ready.
|
||||
{variant === 'ready'
|
||||
? 'This will save your changes and regenerate the tailored PDF.'
|
||||
: 'This will generate your tailored PDF and move the job to Ready.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
91
orchestrator/src/client/hooks/useProfile.ts
Normal file
91
orchestrator/src/client/hooks/useProfile.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as api from '../api';
|
||||
import type { ResumeProfile } from '../../shared/types';
|
||||
|
||||
let profileCache: ResumeProfile | null = null;
|
||||
let profileError: Error | null = null;
|
||||
let subscribers: Set<(profile: ResumeProfile | null, error: Error | null) => void> = new Set();
|
||||
let isFetching = false;
|
||||
|
||||
/**
|
||||
* Hook to get the full profile data from base.json.
|
||||
* Caches the result to avoid re-fetching.
|
||||
*/
|
||||
export function useProfile() {
|
||||
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
|
||||
const [error, setError] = useState<Error | null>(profileError);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileCache) {
|
||||
setProfile(profileCache);
|
||||
}
|
||||
if (profileError) {
|
||||
setError(profileError);
|
||||
}
|
||||
|
||||
const handleUpdate = (newProfile: ResumeProfile | null, newError: Error | null) => {
|
||||
setProfile(newProfile);
|
||||
setError(newError);
|
||||
};
|
||||
|
||||
subscribers.add(handleUpdate);
|
||||
|
||||
if (!profileCache && !isFetching) {
|
||||
isFetching = true;
|
||||
profileError = null;
|
||||
api.getProfile()
|
||||
.then((data) => {
|
||||
profileCache = data;
|
||||
profileError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
})
|
||||
.catch((err) => {
|
||||
profileError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(profileCache, profileError));
|
||||
})
|
||||
.finally(() => {
|
||||
isFetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscribers.delete(handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshProfile = async () => {
|
||||
isFetching = true;
|
||||
profileError = null;
|
||||
subscribers.forEach(sub => sub(profileCache, null));
|
||||
|
||||
try {
|
||||
const data = await api.getProfile();
|
||||
profileCache = data;
|
||||
profileError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
return data;
|
||||
} catch (err) {
|
||||
profileError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(profileCache, profileError));
|
||||
throw profileError;
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
profile,
|
||||
error,
|
||||
isLoading: !profile && isFetching && !error,
|
||||
personName: profile?.basics?.name || 'Resume',
|
||||
refreshProfile,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetProfileCache() {
|
||||
profileCache = null;
|
||||
profileError = null;
|
||||
isFetching = false;
|
||||
subscribers.clear();
|
||||
}
|
||||
80
orchestrator/src/client/hooks/useSettings.test.ts
Normal file
80
orchestrator/src/client/hooks/useSettings.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useSettings, _resetSettingsCache } from './useSettings';
|
||||
import * as api from '../api';
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetSettingsCache();
|
||||
});
|
||||
|
||||
it('fetches settings on mount if not already cached', async () => {
|
||||
const mockSettings = { showSponsorInfo: false };
|
||||
(api.getSettings as any).mockResolvedValue(mockSettings);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
// Should start in loading state
|
||||
expect(result.current.settings).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings).toEqual(mockSettings);
|
||||
});
|
||||
|
||||
expect(result.current.showSponsorInfo).toBe(false);
|
||||
expect(api.getSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses default values when settings are null', async () => {
|
||||
(api.getSettings as any).mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
// settings is null, so showSponsorInfo should default to true
|
||||
expect(result.current.showSponsorInfo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('provides a refresh function that updates settings', async () => {
|
||||
const initialSettings = { showSponsorInfo: true };
|
||||
const updatedSettings = { showSponsorInfo: false };
|
||||
|
||||
(api.getSettings as any).mockResolvedValueOnce(initialSettings);
|
||||
(api.getSettings as any).mockResolvedValueOnce(updatedSettings);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings).toEqual(initialSettings);
|
||||
});
|
||||
|
||||
let refreshed;
|
||||
await waitFor(async () => {
|
||||
refreshed = await result.current.refreshSettings();
|
||||
});
|
||||
|
||||
expect(refreshed).toEqual(updatedSettings);
|
||||
expect(result.current.settings).toEqual(updatedSettings);
|
||||
expect(result.current.showSponsorInfo).toBe(false);
|
||||
});
|
||||
|
||||
it('handles errors when fetching settings', async () => {
|
||||
const mockError = new Error('Failed to fetch');
|
||||
(api.getSettings as any).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.settings).toBeNull();
|
||||
});
|
||||
});
|
||||
87
orchestrator/src/client/hooks/useSettings.ts
Normal file
87
orchestrator/src/client/hooks/useSettings.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AppSettings } from '../../shared/types';
|
||||
import * as api from '../api';
|
||||
|
||||
let settingsCache: AppSettings | null = null;
|
||||
let settingsError: Error | null = null;
|
||||
let subscribers: Set<(settings: AppSettings | null, error: Error | null) => void> = new Set();
|
||||
let isFetching = false;
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
|
||||
const [error, setError] = useState<Error | null>(settingsError);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsCache) {
|
||||
setSettings(settingsCache);
|
||||
}
|
||||
if (settingsError) {
|
||||
setError(settingsError);
|
||||
}
|
||||
|
||||
const handleUpdate = (newSettings: AppSettings | null, newError: Error | null) => {
|
||||
setSettings(newSettings);
|
||||
setError(newError);
|
||||
};
|
||||
|
||||
subscribers.add(handleUpdate);
|
||||
|
||||
if (!settingsCache && !isFetching) {
|
||||
isFetching = true;
|
||||
settingsError = null;
|
||||
api.getSettings()
|
||||
.then((data) => {
|
||||
settingsCache = data;
|
||||
settingsError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
})
|
||||
.catch((err) => {
|
||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(settingsCache, settingsError));
|
||||
})
|
||||
.finally(() => {
|
||||
isFetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscribers.delete(handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshSettings = async () => {
|
||||
isFetching = true;
|
||||
settingsError = null;
|
||||
subscribers.forEach(sub => sub(settingsCache, null));
|
||||
|
||||
try {
|
||||
const data = await api.getSettings();
|
||||
settingsCache = data;
|
||||
settingsError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
return data;
|
||||
} catch (err) {
|
||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(settingsCache, settingsError));
|
||||
throw settingsError;
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
error,
|
||||
isLoading: !settings && isFetching && !error,
|
||||
showSponsorInfo: settings?.showSponsorInfo ?? true,
|
||||
refreshSettings,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetSettingsCache() {
|
||||
settingsCache = null;
|
||||
settingsError = null;
|
||||
isFetching = false;
|
||||
subscribers.clear();
|
||||
}
|
||||
@ -33,6 +33,8 @@ const jobFixture: Job = {
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
@ -198,7 +200,7 @@ describe("OrchestratorPage", () => {
|
||||
// Clicking job-2 should update URL
|
||||
const job2Button = screen.getByTestId("select-job-2");
|
||||
fireEvent.click(job2Button);
|
||||
|
||||
|
||||
// Wait for URL to update
|
||||
await waitFor(() => {
|
||||
expect(locationText()).toContain("/all/job-2");
|
||||
|
||||
@ -23,14 +23,14 @@ vi.mock("sonner", () => ({
|
||||
}))
|
||||
|
||||
const baseSettings: AppSettings = {
|
||||
model: "openai/gpt-4o-mini",
|
||||
defaultModel: "openai/gpt-4o-mini",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
defaultModel: "google/gemini-3-flash-preview",
|
||||
overrideModel: null,
|
||||
modelScorer: "openai/gpt-4o-mini",
|
||||
modelScorer: "google/gemini-3-flash-preview",
|
||||
overrideModelScorer: null,
|
||||
modelTailoring: "openai/gpt-4o-mini",
|
||||
modelTailoring: "google/gemini-3-flash-preview",
|
||||
overrideModelTailoring: null,
|
||||
modelProjectSelection: "openai/gpt-4o-mini",
|
||||
modelProjectSelection: "google/gemini-3-flash-preview",
|
||||
overrideModelProjectSelection: null,
|
||||
pipelineWebhookUrl: "",
|
||||
defaultPipelineWebhookUrl: "",
|
||||
@ -92,6 +92,18 @@ const baseSettings: AppSettings = {
|
||||
jobspyLinkedinFetchDescription: true,
|
||||
defaultJobspyLinkedinFetchDescription: true,
|
||||
overrideJobspyLinkedinFetchDescription: null,
|
||||
showSponsorInfo: true,
|
||||
defaultShowSponsorInfo: true,
|
||||
overrideShowSponsorInfo: null,
|
||||
openrouterApiKeyHint: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumePasswordHint: null,
|
||||
basicAuthUser: "",
|
||||
basicAuthPasswordHint: null,
|
||||
ukvisajobsEmail: "",
|
||||
ukvisajobsPasswordHint: null,
|
||||
webhookSecretHint: null,
|
||||
basicAuthActive: false,
|
||||
}
|
||||
|
||||
const renderPage = () => {
|
||||
@ -138,6 +150,28 @@ describe("SettingsPage", () => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Settings saved")
|
||||
})
|
||||
|
||||
it("shows validation error for too long model override", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
|
||||
renderPage()
|
||||
|
||||
const modelTrigger = await screen.findByRole("button", { name: /model/i })
|
||||
fireEvent.click(modelTrigger)
|
||||
|
||||
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main")
|
||||
const modelInput = within(modelField).getByRole("textbox")
|
||||
|
||||
// Change to > 200 chars
|
||||
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } })
|
||||
|
||||
// Should see error message
|
||||
expect(await screen.findByText(/String must contain at most 200 character\(s\)/i)).toBeInTheDocument()
|
||||
|
||||
// Save button should be disabled due to validation error (isValid will be false)
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it("clears jobs by status and summarizes results", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 })
|
||||
@ -161,4 +195,89 @@ describe("SettingsPage", () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("enables save button when model is changed", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
renderPage()
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
const modelTrigger = await screen.findByRole("button", { name: /model/i })
|
||||
fireEvent.click(modelTrigger)
|
||||
const modelInput = screen.getByLabelText(/override model/i)
|
||||
fireEvent.change(modelInput, { target: { value: "new-model" } })
|
||||
expect(saveButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("enables save button when numeric setting is changed", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
renderPage()
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
|
||||
const visaTrigger = await screen.findByRole("button", { name: /ukvisajobs extractor/i })
|
||||
fireEvent.click(visaTrigger)
|
||||
const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i)
|
||||
fireEvent.change(maxJobsInput, { target: { value: "100" } })
|
||||
expect(saveButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("enables save button when display setting is changed", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
renderPage()
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
|
||||
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)
|
||||
expect(saveButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("enables save button when basic auth toggle is changed", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
renderPage()
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
|
||||
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
|
||||
fireEvent.click(envTrigger)
|
||||
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
|
||||
fireEvent.click(authCheckbox)
|
||||
expect(saveButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("wipes basic auth credentials when toggle is disabled and saved", async () => {
|
||||
// Initial state: Basic Auth is active
|
||||
const activeSettings = {
|
||||
...baseSettings,
|
||||
basicAuthActive: true,
|
||||
basicAuthUser: "admin",
|
||||
basicAuthPasswordHint: "pass",
|
||||
}
|
||||
vi.mocked(api.getSettings).mockResolvedValue(activeSettings)
|
||||
vi.mocked(api.updateSettings).mockResolvedValue(baseSettings)
|
||||
|
||||
renderPage()
|
||||
|
||||
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
|
||||
fireEvent.click(envTrigger)
|
||||
|
||||
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
|
||||
expect(authCheckbox).toBeChecked()
|
||||
|
||||
// Disable it
|
||||
fireEvent.click(authCheckbox)
|
||||
expect(authCheckbox).not.toBeChecked()
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
expect(saveButton).toBeEnabled()
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled())
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
basicAuthUser: null,
|
||||
basicAuthPassword: null,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,52 +1,244 @@
|
||||
/**
|
||||
* Settings page.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Settings } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useForm, FormProvider } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
||||
import { PageHeader } from "../components/layout"
|
||||
import { PageHeader } from "@client/components/layout"
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types"
|
||||
import * as api from "../api"
|
||||
import type { AppSettings, JobStatus } from "@shared/types"
|
||||
import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import * as api from "@client/api"
|
||||
import { arraysEqual } from "@/lib/utils"
|
||||
import { resumeProjectsEqual } from "./settings/utils"
|
||||
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
|
||||
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
|
||||
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
|
||||
import { JobspySection } from "./settings/components/JobspySection"
|
||||
import { ModelSettingsSection } from "./settings/components/ModelSettingsSection"
|
||||
import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSection"
|
||||
import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection"
|
||||
import { SearchTermsSection } from "./settings/components/SearchTermsSection"
|
||||
import { UkvisajobsSection } from "./settings/components/UkvisajobsSection"
|
||||
import { ReactiveResumeSection } from "./settings/components/ReactiveResumeSection"
|
||||
import { resumeProjectsEqual } from "@client/pages/settings/utils"
|
||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"
|
||||
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection"
|
||||
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection"
|
||||
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection"
|
||||
import { JobspySection } from "@client/pages/settings/components/JobspySection"
|
||||
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
|
||||
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection"
|
||||
import { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection"
|
||||
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection"
|
||||
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
|
||||
|
||||
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
model: "",
|
||||
modelScorer: "",
|
||||
modelTailoring: "",
|
||||
modelProjectSelection: "",
|
||||
pipelineWebhookUrl: "",
|
||||
jobCompleteWebhookUrl: "",
|
||||
resumeProjects: null,
|
||||
ukvisajobsMaxJobs: null,
|
||||
gradcrackerMaxJobsPerTerm: null,
|
||||
searchTerms: null,
|
||||
jobspyLocation: null,
|
||||
jobspyResultsWanted: null,
|
||||
jobspyHoursOld: null,
|
||||
jobspyCountryIndeed: null,
|
||||
jobspySites: null,
|
||||
jobspyLinkedinFetchDescription: null,
|
||||
showSponsorInfo: null,
|
||||
openrouterApiKey: "",
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
basicAuthUser: "",
|
||||
basicAuthPassword: "",
|
||||
ukvisajobsEmail: "",
|
||||
ukvisajobsPassword: "",
|
||||
webhookSecret: "",
|
||||
enableBasicAuth: false,
|
||||
}
|
||||
|
||||
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
model: null,
|
||||
modelScorer: null,
|
||||
modelTailoring: null,
|
||||
modelProjectSelection: null,
|
||||
pipelineWebhookUrl: null,
|
||||
jobCompleteWebhookUrl: null,
|
||||
resumeProjects: null,
|
||||
ukvisajobsMaxJobs: null,
|
||||
gradcrackerMaxJobsPerTerm: null,
|
||||
searchTerms: null,
|
||||
jobspyLocation: null,
|
||||
jobspyResultsWanted: null,
|
||||
jobspyHoursOld: null,
|
||||
jobspyCountryIndeed: null,
|
||||
jobspySites: null,
|
||||
jobspyLinkedinFetchDescription: null,
|
||||
showSponsorInfo: null,
|
||||
openrouterApiKey: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumePassword: null,
|
||||
basicAuthUser: null,
|
||||
basicAuthPassword: null,
|
||||
ukvisajobsEmail: null,
|
||||
ukvisajobsPassword: null,
|
||||
webhookSecret: null,
|
||||
enableBasicAuth: undefined,
|
||||
}
|
||||
|
||||
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
model: data.overrideModel ?? "",
|
||||
modelScorer: data.overrideModelScorer ?? "",
|
||||
modelTailoring: data.overrideModelTailoring ?? "",
|
||||
modelProjectSelection: data.overrideModelProjectSelection ?? "",
|
||||
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
|
||||
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
|
||||
resumeProjects: data.resumeProjects,
|
||||
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
|
||||
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
|
||||
searchTerms: data.overrideSearchTerms,
|
||||
jobspyLocation: data.overrideJobspyLocation,
|
||||
jobspyResultsWanted: data.overrideJobspyResultsWanted,
|
||||
jobspyHoursOld: data.overrideJobspyHoursOld,
|
||||
jobspyCountryIndeed: data.overrideJobspyCountryIndeed,
|
||||
jobspySites: data.overrideJobspySites,
|
||||
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
|
||||
showSponsorInfo: data.overrideShowSponsorInfo,
|
||||
openrouterApiKey: "",
|
||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||
rxresumePassword: "",
|
||||
basicAuthUser: data.basicAuthUser ?? "",
|
||||
basicAuthPassword: "",
|
||||
ukvisajobsEmail: data.ukvisajobsEmail ?? "",
|
||||
ukvisajobsPassword: "",
|
||||
webhookSecret: "",
|
||||
enableBasicAuth: data.basicAuthActive,
|
||||
})
|
||||
|
||||
const normalizeString = (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
const normalizePrivateInput = (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim()
|
||||
if (trimmed === "") return null
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
|
||||
if (!left && !right) return true
|
||||
if (!left || !right) return false
|
||||
return arraysEqual(left, right)
|
||||
}
|
||||
|
||||
const isSameSortedStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
|
||||
if (!left && !right) return true
|
||||
if (!left || !right) return false
|
||||
return arraysEqual(left.slice().sort(), right.slice().sort())
|
||||
}
|
||||
|
||||
const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
|
||||
value === defaultValue ? null : value ?? null
|
||||
|
||||
const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) =>
|
||||
isSameStringList(value, defaultValue) ? null : value ?? null
|
||||
|
||||
const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) =>
|
||||
isSameSortedStringList(value, defaultValue) ? null : value ?? null
|
||||
|
||||
const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
const profileProjects = settings?.profileProjects ?? []
|
||||
|
||||
return {
|
||||
model: {
|
||||
effective: settings?.model ?? "",
|
||||
default: settings?.defaultModel ?? "",
|
||||
scorer: settings?.modelScorer ?? "",
|
||||
tailoring: settings?.modelTailoring ?? "",
|
||||
projectSelection: settings?.modelProjectSelection ?? "",
|
||||
},
|
||||
pipelineWebhook: {
|
||||
effective: settings?.pipelineWebhookUrl ?? "",
|
||||
default: settings?.defaultPipelineWebhookUrl ?? "",
|
||||
},
|
||||
jobCompleteWebhook: {
|
||||
effective: settings?.jobCompleteWebhookUrl ?? "",
|
||||
default: settings?.defaultJobCompleteWebhookUrl ?? "",
|
||||
},
|
||||
ukvisajobs: {
|
||||
effective: settings?.ukvisajobsMaxJobs ?? 50,
|
||||
default: settings?.defaultUkvisajobsMaxJobs ?? 50,
|
||||
},
|
||||
gradcracker: {
|
||||
effective: settings?.gradcrackerMaxJobsPerTerm ?? 50,
|
||||
default: settings?.defaultGradcrackerMaxJobsPerTerm ?? 50,
|
||||
},
|
||||
searchTerms: {
|
||||
effective: settings?.searchTerms ?? [],
|
||||
default: settings?.defaultSearchTerms ?? [],
|
||||
},
|
||||
jobspy: {
|
||||
location: {
|
||||
effective: settings?.jobspyLocation ?? "",
|
||||
default: settings?.defaultJobspyLocation ?? "",
|
||||
},
|
||||
resultsWanted: {
|
||||
effective: settings?.jobspyResultsWanted ?? 200,
|
||||
default: settings?.defaultJobspyResultsWanted ?? 200,
|
||||
},
|
||||
hoursOld: {
|
||||
effective: settings?.jobspyHoursOld ?? 72,
|
||||
default: settings?.defaultJobspyHoursOld ?? 72,
|
||||
},
|
||||
countryIndeed: {
|
||||
effective: settings?.jobspyCountryIndeed ?? "",
|
||||
default: settings?.defaultJobspyCountryIndeed ?? "",
|
||||
},
|
||||
sites: {
|
||||
effective: settings?.jobspySites ?? ["indeed", "linkedin"],
|
||||
default: settings?.defaultJobspySites ?? ["indeed", "linkedin"],
|
||||
},
|
||||
linkedinFetchDescription: {
|
||||
effective: settings?.jobspyLinkedinFetchDescription ?? true,
|
||||
default: settings?.defaultJobspyLinkedinFetchDescription ?? true,
|
||||
},
|
||||
},
|
||||
display: {
|
||||
effective: settings?.showSponsorInfo ?? true,
|
||||
default: settings?.defaultShowSponsorInfo ?? true,
|
||||
},
|
||||
envSettings: {
|
||||
readable: {
|
||||
rxresumeEmail: settings?.rxresumeEmail ?? "",
|
||||
ukvisajobsEmail: settings?.ukvisajobsEmail ?? "",
|
||||
basicAuthUser: settings?.basicAuthUser ?? "",
|
||||
},
|
||||
private: {
|
||||
openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null,
|
||||
rxresumePasswordHint: settings?.rxresumePasswordHint ?? null,
|
||||
ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null,
|
||||
basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null,
|
||||
webhookSecretHint: settings?.webhookSecretHint ?? null,
|
||||
},
|
||||
basicAuthActive: settings?.basicAuthActive ?? false,
|
||||
},
|
||||
defaultResumeProjects: settings?.defaultResumeProjects ?? null,
|
||||
|
||||
profileProjects,
|
||||
maxProjectsTotal: profileProjects.length,
|
||||
}
|
||||
}
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||
const [modelDraft, setModelDraft] = useState("")
|
||||
const [modelScorerDraft, setModelScorerDraft] = useState("")
|
||||
const [modelTailoringDraft, setModelTailoringDraft] = useState("")
|
||||
const [modelProjectSelectionDraft, setModelProjectSelectionDraft] = useState("")
|
||||
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
|
||||
const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("")
|
||||
const [resumeProjectsDraft, setResumeProjectsDraft] = useState<ResumeProjectsSettings | null>(null)
|
||||
const [ukvisajobsMaxJobsDraft, setUkvisajobsMaxJobsDraft] = useState<number | null>(null)
|
||||
const [gradcrackerMaxJobsPerTermDraft, setGradcrackerMaxJobsPerTermDraft] = useState<number | null>(null)
|
||||
const [searchTermsDraft, setSearchTermsDraft] = useState<string[] | null>(null)
|
||||
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
|
||||
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
|
||||
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
|
||||
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
|
||||
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
|
||||
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
|
||||
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<string | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
|
||||
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
resolver: zodResolver(updateSettingsSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: DEFAULT_FORM_VALUES,
|
||||
})
|
||||
|
||||
const { handleSubmit, reset, setError, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
setIsLoading(true)
|
||||
@ -55,23 +247,7 @@ export const SettingsPage: React.FC = () => {
|
||||
.then((data) => {
|
||||
if (!isMounted) return
|
||||
setSettings(data)
|
||||
setModelDraft(data.overrideModel ?? "")
|
||||
setModelScorerDraft(data.overrideModelScorer ?? "")
|
||||
setModelTailoringDraft(data.overrideModelTailoring ?? "")
|
||||
setModelProjectSelectionDraft(data.overrideModelProjectSelection ?? "")
|
||||
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
|
||||
setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "")
|
||||
setResumeProjectsDraft(data.resumeProjects)
|
||||
setUkvisajobsMaxJobsDraft(data.overrideUkvisajobsMaxJobs)
|
||||
setGradcrackerMaxJobsPerTermDraft(data.overrideGradcrackerMaxJobsPerTerm)
|
||||
setSearchTermsDraft(data.overrideSearchTerms)
|
||||
setJobspyLocationDraft(data.overrideJobspyLocation)
|
||||
setJobspyResultsWantedDraft(data.overrideJobspyResultsWanted)
|
||||
setJobspyHoursOldDraft(data.overrideJobspyHoursOld)
|
||||
setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed)
|
||||
setJobspySitesDraft(data.overrideJobspySites)
|
||||
setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription)
|
||||
setRxResumeBaseResumeIdDraft(data.rxResumeBaseResumeId)
|
||||
reset(mapSettingsToForm(data))
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to load settings"
|
||||
@ -85,186 +261,126 @@ export const SettingsPage: React.FC = () => {
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [])
|
||||
}, [reset])
|
||||
|
||||
const effectiveModel = settings?.model ?? ""
|
||||
const defaultModel = settings?.defaultModel ?? ""
|
||||
const overrideModel = settings?.overrideModel
|
||||
const effectiveModelScorer = settings?.modelScorer ?? ""
|
||||
const overrideModelScorer = settings?.overrideModelScorer
|
||||
const effectiveModelTailoring = settings?.modelTailoring ?? ""
|
||||
const overrideModelTailoring = settings?.overrideModelTailoring
|
||||
const effectiveModelProjectSelection = settings?.modelProjectSelection ?? ""
|
||||
const overrideModelProjectSelection = settings?.overrideModelProjectSelection
|
||||
const effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? ""
|
||||
const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? ""
|
||||
const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl
|
||||
const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? ""
|
||||
const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? ""
|
||||
const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl
|
||||
const effectiveUkvisajobsMaxJobs = settings?.ukvisajobsMaxJobs ?? 50
|
||||
const defaultUkvisajobsMaxJobs = settings?.defaultUkvisajobsMaxJobs ?? 50
|
||||
const overrideUkvisajobsMaxJobs = settings?.overrideUkvisajobsMaxJobs
|
||||
const effectiveGradcrackerMaxJobsPerTerm = settings?.gradcrackerMaxJobsPerTerm ?? 50
|
||||
const defaultGradcrackerMaxJobsPerTerm = settings?.defaultGradcrackerMaxJobsPerTerm ?? 50
|
||||
const overrideGradcrackerMaxJobsPerTerm = settings?.overrideGradcrackerMaxJobsPerTerm
|
||||
const effectiveSearchTerms = settings?.searchTerms ?? []
|
||||
const defaultSearchTerms = settings?.defaultSearchTerms ?? []
|
||||
const overrideSearchTerms = settings?.overrideSearchTerms
|
||||
const effectiveJobspyLocation = settings?.jobspyLocation ?? ""
|
||||
const defaultJobspyLocation = settings?.defaultJobspyLocation ?? ""
|
||||
const overrideJobspyLocation = settings?.overrideJobspyLocation
|
||||
const effectiveJobspyResultsWanted = settings?.jobspyResultsWanted ?? 200
|
||||
const defaultJobspyResultsWanted = settings?.defaultJobspyResultsWanted ?? 200
|
||||
const overrideJobspyResultsWanted = settings?.overrideJobspyResultsWanted
|
||||
const effectiveJobspyHoursOld = settings?.jobspyHoursOld ?? 72
|
||||
const defaultJobspyHoursOld = settings?.defaultJobspyHoursOld ?? 72
|
||||
const overrideJobspyHoursOld = settings?.overrideJobspyHoursOld
|
||||
const effectiveJobspyCountryIndeed = settings?.jobspyCountryIndeed ?? ""
|
||||
const defaultJobspyCountryIndeed = settings?.defaultJobspyCountryIndeed ?? ""
|
||||
const overrideJobspyCountryIndeed = settings?.overrideJobspyCountryIndeed
|
||||
const effectiveJobspySites = settings?.jobspySites ?? ["indeed", "linkedin"]
|
||||
const defaultJobspySites = settings?.defaultJobspySites ?? ["indeed", "linkedin"]
|
||||
const overrideJobspySites = settings?.overrideJobspySites
|
||||
const effectiveJobspyLinkedinFetchDescription = settings?.jobspyLinkedinFetchDescription ?? true
|
||||
const defaultJobspyLinkedinFetchDescription = settings?.defaultJobspyLinkedinFetchDescription ?? true
|
||||
const overrideJobspyLinkedinFetchDescription = settings?.overrideJobspyLinkedinFetchDescription
|
||||
const profileProjects = settings?.profileProjects ?? []
|
||||
const maxProjectsTotal = profileProjects.length
|
||||
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
|
||||
const derived = getDerivedSettings(settings)
|
||||
const {
|
||||
model,
|
||||
pipelineWebhook,
|
||||
jobCompleteWebhook,
|
||||
ukvisajobs,
|
||||
gradcracker,
|
||||
searchTerms,
|
||||
jobspy,
|
||||
display,
|
||||
envSettings,
|
||||
defaultResumeProjects,
|
||||
profileProjects,
|
||||
maxProjectsTotal,
|
||||
} = derived
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
if (!settings || !resumeProjectsDraft) return false
|
||||
const next = modelDraft.trim()
|
||||
const current = (overrideModel ?? "").trim()
|
||||
const nextScorer = modelScorerDraft.trim()
|
||||
const currentScorer = (overrideModelScorer ?? "").trim()
|
||||
const nextTailoring = modelTailoringDraft.trim()
|
||||
const currentTailoring = (overrideModelTailoring ?? "").trim()
|
||||
const nextProjectSelection = modelProjectSelectionDraft.trim()
|
||||
const currentProjectSelection = (overrideModelProjectSelection ?? "").trim()
|
||||
const nextWebhook = pipelineWebhookUrlDraft.trim()
|
||||
const currentWebhook = (overridePipelineWebhookUrl ?? "").trim()
|
||||
const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim()
|
||||
const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim()
|
||||
const ukvisajobsChanged = ukvisajobsMaxJobsDraft !== (overrideUkvisajobsMaxJobs ?? null)
|
||||
const gradcrackerChanged = gradcrackerMaxJobsPerTermDraft !== (overrideGradcrackerMaxJobsPerTerm ?? null)
|
||||
const searchTermsChanged = JSON.stringify(searchTermsDraft) !== JSON.stringify(overrideSearchTerms ?? null)
|
||||
return (
|
||||
next !== current ||
|
||||
nextScorer !== currentScorer ||
|
||||
nextTailoring !== currentTailoring ||
|
||||
nextProjectSelection !== currentProjectSelection ||
|
||||
nextWebhook !== currentWebhook ||
|
||||
nextJobCompleteWebhook !== currentJobCompleteWebhook ||
|
||||
!resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects) ||
|
||||
ukvisajobsChanged ||
|
||||
gradcrackerChanged ||
|
||||
searchTermsChanged ||
|
||||
jobspyLocationDraft !== (overrideJobspyLocation ?? null) ||
|
||||
jobspyResultsWantedDraft !== (overrideJobspyResultsWanted ?? null) ||
|
||||
jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) ||
|
||||
jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) ||
|
||||
JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) ||
|
||||
jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) ||
|
||||
rxResumeBaseResumeIdDraft !== (settings.rxResumeBaseResumeId ?? null)
|
||||
)
|
||||
}, [
|
||||
settings,
|
||||
modelDraft,
|
||||
modelScorerDraft,
|
||||
modelTailoringDraft,
|
||||
modelProjectSelectionDraft,
|
||||
pipelineWebhookUrlDraft,
|
||||
jobCompleteWebhookUrlDraft,
|
||||
overrideModel,
|
||||
overrideModelScorer,
|
||||
overrideModelTailoring,
|
||||
overrideModelProjectSelection,
|
||||
overridePipelineWebhookUrl,
|
||||
overrideJobCompleteWebhookUrl,
|
||||
resumeProjectsDraft,
|
||||
ukvisajobsMaxJobsDraft,
|
||||
overrideUkvisajobsMaxJobs,
|
||||
gradcrackerMaxJobsPerTermDraft,
|
||||
overrideGradcrackerMaxJobsPerTerm,
|
||||
searchTermsDraft,
|
||||
overrideSearchTerms,
|
||||
jobspyLocationDraft,
|
||||
jobspyResultsWantedDraft,
|
||||
jobspyHoursOldDraft,
|
||||
jobspyCountryIndeedDraft,
|
||||
jobspySitesDraft,
|
||||
jobspyLinkedinFetchDescriptionDraft,
|
||||
overrideJobspyLocation,
|
||||
overrideJobspyResultsWanted,
|
||||
overrideJobspyHoursOld,
|
||||
overrideJobspyCountryIndeed,
|
||||
overrideJobspySites,
|
||||
overrideJobspyLinkedinFetchDescription,
|
||||
rxResumeBaseResumeIdDraft,
|
||||
])
|
||||
const watchedValues = watch()
|
||||
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!settings || !resumeProjectsDraft) return
|
||||
const canSave = isDirty && isValid
|
||||
|
||||
const onSave = async (data: UpdateSettingsInput) => {
|
||||
if (!settings) return
|
||||
if (data.enableBasicAuth && !settings.basicAuthActive) {
|
||||
const password = data.basicAuthPassword?.trim() ?? ""
|
||||
if (!password) {
|
||||
setError("basicAuthPassword", {
|
||||
type: "manual",
|
||||
message: "Password is required when basic auth is enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
const trimmed = modelDraft.trim()
|
||||
const trimmedScorer = modelScorerDraft.trim()
|
||||
const trimmedTailoring = modelTailoringDraft.trim()
|
||||
const trimmedProjectSelection = modelProjectSelectionDraft.trim()
|
||||
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
|
||||
const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim()
|
||||
const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects)
|
||||
|
||||
// Prepare payload: nullify if equal to default
|
||||
const resumeProjectsData = data.resumeProjects
|
||||
const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects))
|
||||
? null
|
||||
: resumeProjectsDraft
|
||||
const ukvisajobsMaxJobsOverride = ukvisajobsMaxJobsDraft === defaultUkvisajobsMaxJobs ? null : ukvisajobsMaxJobsDraft
|
||||
const gradcrackerMaxJobsPerTermOverride = gradcrackerMaxJobsPerTermDraft === defaultGradcrackerMaxJobsPerTerm ? null : gradcrackerMaxJobsPerTermDraft
|
||||
const searchTermsOverride = arraysEqual(searchTermsDraft ?? [], defaultSearchTerms) ? null : searchTermsDraft
|
||||
const jobspyLocationOverride = jobspyLocationDraft === defaultJobspyLocation ? null : jobspyLocationDraft
|
||||
const jobspyResultsWantedOverride = jobspyResultsWantedDraft === defaultJobspyResultsWanted ? null : jobspyResultsWantedDraft
|
||||
const jobspyHoursOldOverride = jobspyHoursOldDraft === defaultJobspyHoursOld ? null : jobspyHoursOldDraft
|
||||
const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft
|
||||
const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft
|
||||
const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft
|
||||
const rxResumeBaseResumeIdOverride = rxResumeBaseResumeIdDraft
|
||||
const updated = await api.updateSettings({
|
||||
model: trimmed.length > 0 ? trimmed : null,
|
||||
modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null,
|
||||
modelTailoring: trimmedTailoring.length > 0 ? trimmedTailoring : null,
|
||||
modelProjectSelection: trimmedProjectSelection.length > 0 ? trimmedProjectSelection : null,
|
||||
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
|
||||
jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null,
|
||||
: resumeProjectsData
|
||||
|
||||
const envPayload: Partial<UpdateSettingsInput> = {}
|
||||
|
||||
if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) {
|
||||
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail)
|
||||
}
|
||||
|
||||
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
||||
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail)
|
||||
}
|
||||
|
||||
if (data.enableBasicAuth === false) {
|
||||
envPayload.basicAuthUser = null
|
||||
envPayload.basicAuthPassword = null
|
||||
} else if (dirtyFields.enableBasicAuth || dirtyFields.basicAuthUser || dirtyFields.basicAuthPassword) {
|
||||
// If enabling basic auth or changing either field, ensure we send at least the username
|
||||
// to keep the pair consistent in the backend.
|
||||
envPayload.basicAuthUser = normalizeString(data.basicAuthUser)
|
||||
|
||||
if (dirtyFields.basicAuthPassword) {
|
||||
const value = normalizePrivateInput(data.basicAuthPassword)
|
||||
if (value !== undefined) envPayload.basicAuthPassword = value
|
||||
}
|
||||
}
|
||||
|
||||
if (dirtyFields.openrouterApiKey) {
|
||||
const value = normalizePrivateInput(data.openrouterApiKey)
|
||||
if (value !== undefined) envPayload.openrouterApiKey = value
|
||||
}
|
||||
|
||||
if (dirtyFields.rxresumePassword) {
|
||||
const value = normalizePrivateInput(data.rxresumePassword)
|
||||
if (value !== undefined) envPayload.rxresumePassword = value
|
||||
}
|
||||
|
||||
if (dirtyFields.ukvisajobsPassword) {
|
||||
const value = normalizePrivateInput(data.ukvisajobsPassword)
|
||||
if (value !== undefined) envPayload.ukvisajobsPassword = value
|
||||
}
|
||||
|
||||
if (dirtyFields.webhookSecret) {
|
||||
const value = normalizePrivateInput(data.webhookSecret)
|
||||
if (value !== undefined) envPayload.webhookSecret = value
|
||||
}
|
||||
|
||||
const payload: UpdateSettingsInput = {
|
||||
model: normalizeString(data.model),
|
||||
modelScorer: normalizeString(data.modelScorer),
|
||||
modelTailoring: normalizeString(data.modelTailoring),
|
||||
modelProjectSelection: normalizeString(data.modelProjectSelection),
|
||||
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
||||
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
||||
resumeProjects: resumeProjectsOverride,
|
||||
ukvisajobsMaxJobs: ukvisajobsMaxJobsOverride,
|
||||
gradcrackerMaxJobsPerTerm: gradcrackerMaxJobsPerTermOverride,
|
||||
searchTerms: searchTermsOverride,
|
||||
jobspyLocation: jobspyLocationOverride,
|
||||
jobspyResultsWanted: jobspyResultsWantedOverride,
|
||||
jobspyHoursOld: jobspyHoursOldOverride,
|
||||
jobspyCountryIndeed: jobspyCountryIndeedOverride,
|
||||
jobspySites: jobspySitesOverride,
|
||||
jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride,
|
||||
rxResumeBaseResumeId: rxResumeBaseResumeIdOverride,
|
||||
})
|
||||
ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default),
|
||||
gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default),
|
||||
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
|
||||
jobspyLocation: nullIfSame(data.jobspyLocation, jobspy.location.default),
|
||||
jobspyResultsWanted: nullIfSame(data.jobspyResultsWanted, jobspy.resultsWanted.default),
|
||||
jobspyHoursOld: nullIfSame(data.jobspyHoursOld, jobspy.hoursOld.default),
|
||||
jobspyCountryIndeed: nullIfSame(data.jobspyCountryIndeed, jobspy.countryIndeed.default),
|
||||
jobspySites: nullIfSameSortedList(data.jobspySites, jobspy.sites.default),
|
||||
jobspyLinkedinFetchDescription: nullIfSame(
|
||||
data.jobspyLinkedinFetchDescription,
|
||||
jobspy.linkedinFetchDescription.default
|
||||
),
|
||||
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
||||
...envPayload,
|
||||
}
|
||||
|
||||
// Remove virtual field because the backend doesn't expect it
|
||||
// this exists only to toggle the UI
|
||||
// need to track it so that the save button is enabled when it changes
|
||||
delete payload.enableBasicAuth
|
||||
|
||||
const updated = await api.updateSettings(payload)
|
||||
setSettings(updated)
|
||||
setModelDraft(updated.overrideModel ?? "")
|
||||
setModelScorerDraft(updated.overrideModelScorer ?? "")
|
||||
setModelTailoringDraft(updated.overrideModelTailoring ?? "")
|
||||
setModelProjectSelectionDraft(updated.overrideModelProjectSelection ?? "")
|
||||
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
|
||||
setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "")
|
||||
setResumeProjectsDraft(updated.resumeProjects)
|
||||
setUkvisajobsMaxJobsDraft(updated.overrideUkvisajobsMaxJobs)
|
||||
setGradcrackerMaxJobsPerTermDraft(updated.overrideGradcrackerMaxJobsPerTerm)
|
||||
setSearchTermsDraft(updated.overrideSearchTerms)
|
||||
setJobspyLocationDraft(updated.overrideJobspyLocation)
|
||||
setJobspyResultsWantedDraft(updated.overrideJobspyResultsWanted)
|
||||
setJobspyHoursOldDraft(updated.overrideJobspyHoursOld)
|
||||
setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed)
|
||||
setJobspySitesDraft(updated.overrideJobspySites)
|
||||
setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription)
|
||||
setRxResumeBaseResumeIdDraft(updated.rxResumeBaseResumeId)
|
||||
reset(mapSettingsToForm(updated))
|
||||
toast.success("Settings saved")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save settings"
|
||||
@ -273,6 +389,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearDatabase = async () => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
@ -331,43 +448,9 @@ export const SettingsPage: React.FC = () => {
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
const updated = await api.updateSettings({
|
||||
model: null,
|
||||
modelScorer: null,
|
||||
modelTailoring: null,
|
||||
modelProjectSelection: null,
|
||||
pipelineWebhookUrl: null,
|
||||
jobCompleteWebhookUrl: null,
|
||||
resumeProjects: null,
|
||||
ukvisajobsMaxJobs: null,
|
||||
gradcrackerMaxJobsPerTerm: null,
|
||||
searchTerms: null,
|
||||
jobspyLocation: null,
|
||||
jobspyResultsWanted: null,
|
||||
jobspyHoursOld: null,
|
||||
jobspyCountryIndeed: null,
|
||||
jobspySites: null,
|
||||
jobspyLinkedinFetchDescription: null,
|
||||
rxResumeBaseResumeId: null,
|
||||
})
|
||||
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD)
|
||||
setSettings(updated)
|
||||
setModelDraft("")
|
||||
setModelScorerDraft("")
|
||||
setModelTailoringDraft("")
|
||||
setModelProjectSelectionDraft("")
|
||||
setPipelineWebhookUrlDraft("")
|
||||
setJobCompleteWebhookUrlDraft("")
|
||||
setResumeProjectsDraft(updated.resumeProjects)
|
||||
setUkvisajobsMaxJobsDraft(null)
|
||||
setGradcrackerMaxJobsPerTermDraft(null)
|
||||
setSearchTermsDraft(null)
|
||||
setJobspyLocationDraft(null)
|
||||
setJobspyResultsWantedDraft(null)
|
||||
setJobspyHoursOldDraft(null)
|
||||
setJobspyCountryIndeedDraft(null)
|
||||
setJobspySitesDraft(null)
|
||||
setJobspyLinkedinFetchDescriptionDraft(null)
|
||||
setRxResumeBaseResumeIdDraft(null)
|
||||
reset(mapSettingsToForm(updated))
|
||||
toast.success("Reset to default")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
||||
@ -378,7 +461,7 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...methods}>
|
||||
<PageHeader
|
||||
icon={Settings}
|
||||
title="Settings"
|
||||
@ -388,103 +471,51 @@ export const SettingsPage: React.FC = () => {
|
||||
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
|
||||
<Accordion type="multiple" className="w-full space-y-4">
|
||||
<ModelSettingsSection
|
||||
modelDraft={modelDraft}
|
||||
setModelDraft={setModelDraft}
|
||||
modelScorerDraft={modelScorerDraft}
|
||||
setModelScorerDraft={setModelScorerDraft}
|
||||
modelTailoringDraft={modelTailoringDraft}
|
||||
setModelTailoringDraft={setModelTailoringDraft}
|
||||
modelProjectSelectionDraft={modelProjectSelectionDraft}
|
||||
setModelProjectSelectionDraft={setModelProjectSelectionDraft}
|
||||
effectiveModel={effectiveModel}
|
||||
effectiveModelScorer={effectiveModelScorer}
|
||||
effectiveModelTailoring={effectiveModelTailoring}
|
||||
effectiveModelProjectSelection={effectiveModelProjectSelection}
|
||||
defaultModel={defaultModel}
|
||||
values={model}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<PipelineWebhookSection
|
||||
pipelineWebhookUrlDraft={pipelineWebhookUrlDraft}
|
||||
setPipelineWebhookUrlDraft={setPipelineWebhookUrlDraft}
|
||||
defaultPipelineWebhookUrl={defaultPipelineWebhookUrl}
|
||||
effectivePipelineWebhookUrl={effectivePipelineWebhookUrl}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<JobCompleteWebhookSection
|
||||
jobCompleteWebhookUrlDraft={jobCompleteWebhookUrlDraft}
|
||||
setJobCompleteWebhookUrlDraft={setJobCompleteWebhookUrlDraft}
|
||||
defaultJobCompleteWebhookUrl={defaultJobCompleteWebhookUrl}
|
||||
effectiveJobCompleteWebhookUrl={effectiveJobCompleteWebhookUrl}
|
||||
<WebhooksSection
|
||||
pipelineWebhook={pipelineWebhook}
|
||||
jobCompleteWebhook={jobCompleteWebhook}
|
||||
webhookSecretHint={envSettings.private.webhookSecretHint}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<UkvisajobsSection
|
||||
ukvisajobsMaxJobsDraft={ukvisajobsMaxJobsDraft}
|
||||
setUkvisajobsMaxJobsDraft={setUkvisajobsMaxJobsDraft}
|
||||
defaultUkvisajobsMaxJobs={defaultUkvisajobsMaxJobs}
|
||||
effectiveUkvisajobsMaxJobs={effectiveUkvisajobsMaxJobs}
|
||||
values={ukvisajobs}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<GradcrackerSection
|
||||
gradcrackerMaxJobsPerTermDraft={gradcrackerMaxJobsPerTermDraft}
|
||||
setGradcrackerMaxJobsPerTermDraft={setGradcrackerMaxJobsPerTermDraft}
|
||||
defaultGradcrackerMaxJobsPerTerm={defaultGradcrackerMaxJobsPerTerm}
|
||||
effectiveGradcrackerMaxJobsPerTerm={effectiveGradcrackerMaxJobsPerTerm}
|
||||
values={gradcracker}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<SearchTermsSection
|
||||
searchTermsDraft={searchTermsDraft}
|
||||
setSearchTermsDraft={setSearchTermsDraft}
|
||||
defaultSearchTerms={defaultSearchTerms}
|
||||
effectiveSearchTerms={effectiveSearchTerms}
|
||||
values={searchTerms}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<JobspySection
|
||||
jobspySitesDraft={jobspySitesDraft}
|
||||
setJobspySitesDraft={setJobspySitesDraft}
|
||||
defaultJobspySites={defaultJobspySites}
|
||||
effectiveJobspySites={effectiveJobspySites}
|
||||
jobspyLocationDraft={jobspyLocationDraft}
|
||||
setJobspyLocationDraft={setJobspyLocationDraft}
|
||||
defaultJobspyLocation={defaultJobspyLocation}
|
||||
effectiveJobspyLocation={effectiveJobspyLocation}
|
||||
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
|
||||
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
|
||||
defaultJobspyResultsWanted={defaultJobspyResultsWanted}
|
||||
effectiveJobspyResultsWanted={effectiveJobspyResultsWanted}
|
||||
jobspyHoursOldDraft={jobspyHoursOldDraft}
|
||||
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
|
||||
defaultJobspyHoursOld={defaultJobspyHoursOld}
|
||||
effectiveJobspyHoursOld={effectiveJobspyHoursOld}
|
||||
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
|
||||
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
|
||||
defaultJobspyCountryIndeed={defaultJobspyCountryIndeed}
|
||||
effectiveJobspyCountryIndeed={effectiveJobspyCountryIndeed}
|
||||
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
|
||||
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
|
||||
defaultJobspyLinkedinFetchDescription={defaultJobspyLinkedinFetchDescription}
|
||||
effectiveJobspyLinkedinFetchDescription={effectiveJobspyLinkedinFetchDescription}
|
||||
values={jobspy}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ResumeProjectsSection
|
||||
resumeProjectsDraft={resumeProjectsDraft}
|
||||
setResumeProjectsDraft={setResumeProjectsDraft}
|
||||
profileProjects={profileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={maxProjectsTotal}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ReactiveResumeSection
|
||||
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
|
||||
setRxResumeBaseResumeIdDraft={setRxResumeBaseResumeIdDraft}
|
||||
hasRxResumeApiKey={settings?.hasRxResumeApiKey ?? false}
|
||||
<DisplaySettingsSection
|
||||
values={display}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<EnvironmentSettingsSection
|
||||
values={envSettings}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
@ -499,14 +530,19 @@ export const SettingsPage: React.FC = () => {
|
||||
</Accordion>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
|
||||
<Button onClick={handleSubmit(onSave)} disabled={isLoading || isSaving || !canSave}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div className="text-destructive text-sm mt-2">
|
||||
Please fix the errors before saving.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -31,6 +31,9 @@ vi.mock("../../components", () => ({
|
||||
DiscoveredPanel: ({ job }: { job: Job | null }) => (
|
||||
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
|
||||
),
|
||||
JobHeader: () => <div data-testid="job-header" />,
|
||||
FitAssessment: () => <div data-testid="fit-assessment" />,
|
||||
TailoredSummary: () => <div data-testid="tailored-summary" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ReadyPanel", () => ({
|
||||
@ -63,6 +66,7 @@ vi.mock("../../api", () => ({
|
||||
generateJobPdf: vi.fn(),
|
||||
markAsApplied: vi.fn(),
|
||||
skipJob: vi.fn(),
|
||||
getProfile: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@ -100,6 +104,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
@ -154,23 +160,7 @@ describe("JobDetailPanel", () => {
|
||||
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
|
||||
});
|
||||
|
||||
it("wires ready panel edit actions back to the page", () => {
|
||||
const onSetActiveTab = vi.fn();
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="ready"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ status: "ready" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||
onSetActiveTab={onSetActiveTab}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit description/i }));
|
||||
expect(onSetActiveTab).toHaveBeenCalledWith("discovered");
|
||||
});
|
||||
|
||||
it("shows an empty state when no job is selected", () => {
|
||||
render(
|
||||
|
||||
@ -30,6 +30,7 @@ import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml }
|
||||
import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components";
|
||||
import { ReadyPanel } from "../../components/ReadyPanel";
|
||||
import { TailoringEditor } from "../../components/TailoringEditor";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import * as api from "../../api";
|
||||
import type { Job } from "../../../shared/types";
|
||||
import type { FilterTab } from "./constants";
|
||||
@ -59,6 +60,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
|
||||
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
|
||||
|
||||
const { personName } = useProfile();
|
||||
|
||||
useEffect(() => {
|
||||
setHasUnsavedTailoring(false);
|
||||
saveTailoringRef.current = null;
|
||||
@ -243,17 +246,6 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
job={selectedJob}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={handleJobMoved}
|
||||
onEditTailoring={() => {
|
||||
onSetActiveTab("discovered");
|
||||
setTimeout(() => setDetailTab("tailoring"), 50);
|
||||
}}
|
||||
onEditDescription={() => {
|
||||
onSetActiveTab("discovered");
|
||||
setTimeout(() => {
|
||||
setDetailTab("description");
|
||||
setIsEditingDescription(true);
|
||||
}, 50);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -269,7 +261,13 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<JobHeader job={selectedJob} />
|
||||
<JobHeader
|
||||
job={selectedJob}
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(selectedJob.id);
|
||||
await onJobUpdated();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
@ -368,7 +366,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={selectedPdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||
download={`${personName.replace(/\s+/g, '_')}_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
|
||||
@ -31,6 +31,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
|
||||
@ -16,7 +16,7 @@ import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import type { JobStatus } from "@shared/types"
|
||||
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "../constants"
|
||||
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "@client/pages/settings/constants"
|
||||
|
||||
type DangerZoneSectionProps = {
|
||||
statusesToClear: JobStatus[]
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
import React from "react"
|
||||
import { useFormContext, Controller } from "react-hook-form"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import type { DisplayValues } from "@client/pages/settings/types"
|
||||
|
||||
type DisplaySettingsSectionProps = {
|
||||
values: DisplayValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { default: defaultShowSponsorInfo, effective: effectiveShowSponsorInfo } = values
|
||||
const { control } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="display" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Display Settings</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Controller
|
||||
name="showSponsorInfo"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="showSponsorInfo"
|
||||
checked={field.value ?? defaultShowSponsorInfo}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked === "indeterminate" ? null : checked === true)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="showSponsorInfo"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Show visa sponsor information
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Display a badge next to the employer name showing the match
|
||||
percentage with the UK visa sponsor list. This helps identify
|
||||
employers that are licensed to sponsor work visas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">
|
||||
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">
|
||||
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { useForm, FormProvider } from "react-hook-form"
|
||||
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
|
||||
const EnvironmentSettingsHarness = () => {
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
defaultValues: {
|
||||
rxresumeEmail: "resume@example.com",
|
||||
ukvisajobsEmail: "visa@example.com",
|
||||
basicAuthUser: "admin",
|
||||
openrouterApiKey: "",
|
||||
rxresumePassword: "",
|
||||
ukvisajobsPassword: "",
|
||||
basicAuthPassword: "",
|
||||
webhookSecret: "",
|
||||
enableBasicAuth: true,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["environment"]}>
|
||||
<EnvironmentSettingsSection
|
||||
values={{
|
||||
readable: {
|
||||
rxresumeEmail: "resume@example.com",
|
||||
ukvisajobsEmail: "visa@example.com",
|
||||
basicAuthUser: "admin",
|
||||
},
|
||||
private: {
|
||||
openrouterApiKeyHint: "sk-1",
|
||||
rxresumePasswordHint: null,
|
||||
ukvisajobsPasswordHint: "pass",
|
||||
basicAuthPasswordHint: "abcd",
|
||||
webhookSecretHint: "sec-",
|
||||
},
|
||||
basicAuthActive: true,
|
||||
}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("EnvironmentSettingsSection", () => {
|
||||
it("renders values grouped logically and masks private secrets with hints", () => {
|
||||
render(<EnvironmentSettingsHarness />)
|
||||
|
||||
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument()
|
||||
expect(screen.getByText("Not set")).toBeInTheDocument()
|
||||
|
||||
// Basic Auth
|
||||
expect(screen.getByLabelText("Enable basic authentication")).toBeChecked()
|
||||
expect(screen.getByDisplayValue("admin")).toBeInTheDocument()
|
||||
|
||||
// Sections
|
||||
expect(screen.getByText("External Services")).toBeInTheDocument()
|
||||
expect(screen.getByText("Service Accounts")).toBeInTheDocument()
|
||||
expect(screen.getByText("Security")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,159 @@
|
||||
import React from "react"
|
||||
import { useFormContext, Controller } from "react-hook-form"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import type { EnvSettingsValues } from "@client/pages/settings/types"
|
||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
|
||||
type EnvironmentSettingsSectionProps = {
|
||||
values: EnvSettingsValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProps> = ({
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { register, control, watch, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
const { private: privateValues } = values
|
||||
|
||||
const isBasicAuthEnabled = watch("enableBasicAuth")
|
||||
|
||||
return (
|
||||
<AccordionItem value="environment" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Environment & Accounts</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-8">
|
||||
{/* External Services */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">External Services</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="OpenRouter API key"
|
||||
inputProps={register("openrouterApiKey")}
|
||||
type="password"
|
||||
placeholder="Enter new key"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.openrouterApiKey?.message as string | undefined}
|
||||
current={formatSecretHint(privateValues.openrouterApiKeyHint)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Service Accounts */}
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Service Accounts</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold">RxResume</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="Email"
|
||||
inputProps={register("rxresumeEmail")}
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.rxresumeEmail?.message as string | undefined}
|
||||
/>
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={register("rxresumePassword")}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.rxresumePassword?.message as string | undefined}
|
||||
current={formatSecretHint(privateValues.rxresumePasswordHint)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold">UKVisaJobs</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="Email"
|
||||
inputProps={register("ukvisajobsEmail")}
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.ukvisajobsEmail?.message as string | undefined}
|
||||
/>
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={register("ukvisajobsPassword")}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.ukvisajobsPassword?.message as string | undefined}
|
||||
current={formatSecretHint(privateValues.ukvisajobsPasswordHint)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Security */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Security</div>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Controller
|
||||
name="enableBasicAuth"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="enableBasicAuth"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="enableBasicAuth"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Enable basic authentication
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Require a username and password for write operations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBasicAuthEnabled && (
|
||||
<div className="grid gap-4 md:grid-cols-2 pt-2">
|
||||
<SettingsInput
|
||||
label="Username"
|
||||
inputProps={register("basicAuthUser")}
|
||||
placeholder="username"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.basicAuthUser?.message as string | undefined}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={register("basicAuthPassword")}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.basicAuthPassword?.message as string | undefined}
|
||||
current={formatSecretHint(privateValues.basicAuthPasswordHint)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -1,26 +1,25 @@
|
||||
import React from "react"
|
||||
import { useFormContext, Controller } from "react-hook-form"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import type { NumericSettingValues } from "@client/pages/settings/types"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
|
||||
type GradcrackerSectionProps = {
|
||||
gradcrackerMaxJobsPerTermDraft: number | null
|
||||
setGradcrackerMaxJobsPerTermDraft: (value: number | null) => void
|
||||
defaultGradcrackerMaxJobsPerTerm: number
|
||||
effectiveGradcrackerMaxJobsPerTerm: number
|
||||
values: NumericSettingValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
||||
gradcrackerMaxJobsPerTermDraft,
|
||||
setGradcrackerMaxJobsPerTermDraft,
|
||||
defaultGradcrackerMaxJobsPerTerm,
|
||||
effectiveGradcrackerMaxJobsPerTerm,
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { effective: effectiveGradcrackerMaxJobsPerTerm, default: defaultGradcrackerMaxJobsPerTerm } = values
|
||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
@ -28,41 +27,35 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max jobs per search term</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setGradcrackerMaxJobsPerTermDraft(null)
|
||||
} else {
|
||||
setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">{effectiveGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name="gradcrackerMaxJobsPerTerm"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Max jobs per search term"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 1000,
|
||||
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
|
||||
onChange: (event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
field.onChange(null)
|
||||
} else {
|
||||
field.onChange(Math.min(1000, Math.max(1, value)))
|
||||
}
|
||||
},
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.gradcrackerMaxJobsPerTerm?.message as string | undefined}
|
||||
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`}
|
||||
current={String(effectiveGradcrackerMaxJobsPerTerm)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type JobCompleteWebhookSectionProps = {
|
||||
jobCompleteWebhookUrlDraft: string
|
||||
setJobCompleteWebhookUrlDraft: (value: string) => void
|
||||
defaultJobCompleteWebhookUrl: string
|
||||
effectiveJobCompleteWebhookUrl: string
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps> = ({
|
||||
jobCompleteWebhookUrlDraft,
|
||||
setJobCompleteWebhookUrlDraft,
|
||||
defaultJobCompleteWebhookUrl,
|
||||
effectiveJobCompleteWebhookUrl,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Job Complete Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Job completion webhook URL</div>
|
||||
<Input
|
||||
value={jobCompleteWebhookUrlDraft}
|
||||
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
|
||||
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
When set, the server sends a POST when you mark a job as applied (includes the job description).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">{effectiveJobCompleteWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -1,52 +1,44 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import { useState } from "react"
|
||||
import { useForm, FormProvider } from "react-hook-form"
|
||||
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { JobspySection } from "./JobspySection"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
|
||||
const JobspyHarness = () => {
|
||||
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
|
||||
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
|
||||
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
|
||||
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
|
||||
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
|
||||
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
defaultValues: {
|
||||
jobspySites: ["indeed", "linkedin"],
|
||||
jobspyLocation: "UK",
|
||||
jobspyResultsWanted: 200,
|
||||
jobspyHoursOld: 72,
|
||||
jobspyCountryIndeed: "UK",
|
||||
jobspyLinkedinFetchDescription: true,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" defaultValue={["jobspy"]}>
|
||||
<JobspySection
|
||||
jobspySitesDraft={jobspySitesDraft}
|
||||
setJobspySitesDraft={setJobspySitesDraft}
|
||||
defaultJobspySites={["indeed", "linkedin"]}
|
||||
effectiveJobspySites={["indeed", "linkedin"]}
|
||||
jobspyLocationDraft={jobspyLocationDraft}
|
||||
setJobspyLocationDraft={setJobspyLocationDraft}
|
||||
defaultJobspyLocation="UK"
|
||||
effectiveJobspyLocation="UK"
|
||||
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
|
||||
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
|
||||
defaultJobspyResultsWanted={200}
|
||||
effectiveJobspyResultsWanted={200}
|
||||
jobspyHoursOldDraft={jobspyHoursOldDraft}
|
||||
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
|
||||
defaultJobspyHoursOld={72}
|
||||
effectiveJobspyHoursOld={72}
|
||||
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
|
||||
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
|
||||
defaultJobspyCountryIndeed="UK"
|
||||
effectiveJobspyCountryIndeed="UK"
|
||||
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
|
||||
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
|
||||
defaultJobspyLinkedinFetchDescription={true}
|
||||
effectiveJobspyLinkedinFetchDescription={true}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["jobspy"]}>
|
||||
<JobspySection
|
||||
values={{
|
||||
sites: { default: ["indeed", "linkedin"], effective: ["indeed", "linkedin"] },
|
||||
location: { default: "UK", effective: "UK" },
|
||||
resultsWanted: { default: 200, effective: 200 },
|
||||
hoursOld: { default: 72, effective: 72 },
|
||||
countryIndeed: { default: "UK", effective: "UK" },
|
||||
linkedinFetchDescription: { default: true, effective: true },
|
||||
}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
describe("JobspySection", () => {
|
||||
it("toggles scraped sites and keeps checkboxes in sync", () => {
|
||||
render(<JobspyHarness />)
|
||||
@ -72,8 +64,8 @@ describe("JobspySection", () => {
|
||||
const resultsWantedInput = numericInputs[0]
|
||||
const hoursOldInput = numericInputs[1]
|
||||
|
||||
fireEvent.change(resultsWantedInput, { target: { value: "999" } })
|
||||
expect(resultsWantedInput).toHaveValue(500)
|
||||
fireEvent.change(resultsWantedInput, { target: { value: "1001" } })
|
||||
expect(resultsWantedInput).toHaveValue(1000)
|
||||
|
||||
fireEvent.change(hoursOldInput, { target: { value: "0" } })
|
||||
expect(hoursOldInput).toHaveValue(1)
|
||||
|
||||
@ -1,67 +1,34 @@
|
||||
import React from "react"
|
||||
import { useFormContext, Controller } 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 { Separator } from "@/components/ui/separator"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import type { JobspyValues } from "@client/pages/settings/types"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
|
||||
type JobspySectionProps = {
|
||||
jobspySitesDraft: string[] | null
|
||||
setJobspySitesDraft: (value: string[] | null) => void
|
||||
defaultJobspySites: string[]
|
||||
effectiveJobspySites: string[]
|
||||
jobspyLocationDraft: string | null
|
||||
setJobspyLocationDraft: (value: string | null) => void
|
||||
defaultJobspyLocation: string
|
||||
effectiveJobspyLocation: string
|
||||
jobspyResultsWantedDraft: number | null
|
||||
setJobspyResultsWantedDraft: (value: number | null) => void
|
||||
defaultJobspyResultsWanted: number
|
||||
effectiveJobspyResultsWanted: number
|
||||
jobspyHoursOldDraft: number | null
|
||||
setJobspyHoursOldDraft: (value: number | null) => void
|
||||
defaultJobspyHoursOld: number
|
||||
effectiveJobspyHoursOld: number
|
||||
jobspyCountryIndeedDraft: string | null
|
||||
setJobspyCountryIndeedDraft: (value: string | null) => void
|
||||
defaultJobspyCountryIndeed: string
|
||||
effectiveJobspyCountryIndeed: string
|
||||
jobspyLinkedinFetchDescriptionDraft: boolean | null
|
||||
setJobspyLinkedinFetchDescriptionDraft: (value: boolean | null) => void
|
||||
defaultJobspyLinkedinFetchDescription: boolean
|
||||
effectiveJobspyLinkedinFetchDescription: boolean
|
||||
values: JobspyValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||
jobspySitesDraft,
|
||||
setJobspySitesDraft,
|
||||
defaultJobspySites,
|
||||
effectiveJobspySites,
|
||||
jobspyLocationDraft,
|
||||
setJobspyLocationDraft,
|
||||
defaultJobspyLocation,
|
||||
effectiveJobspyLocation,
|
||||
jobspyResultsWantedDraft,
|
||||
setJobspyResultsWantedDraft,
|
||||
defaultJobspyResultsWanted,
|
||||
effectiveJobspyResultsWanted,
|
||||
jobspyHoursOldDraft,
|
||||
setJobspyHoursOldDraft,
|
||||
defaultJobspyHoursOld,
|
||||
effectiveJobspyHoursOld,
|
||||
jobspyCountryIndeedDraft,
|
||||
setJobspyCountryIndeedDraft,
|
||||
defaultJobspyCountryIndeed,
|
||||
effectiveJobspyCountryIndeed,
|
||||
jobspyLinkedinFetchDescriptionDraft,
|
||||
setJobspyLinkedinFetchDescriptionDraft,
|
||||
defaultJobspyLinkedinFetchDescription,
|
||||
effectiveJobspyLinkedinFetchDescription,
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const {
|
||||
sites,
|
||||
location,
|
||||
resultsWanted,
|
||||
hoursOld,
|
||||
countryIndeed,
|
||||
linkedinFetchDescription,
|
||||
} = values
|
||||
const { control, register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="jobspy" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
@ -73,149 +40,160 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||
<div className="text-sm font-medium">Scraped Sites</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="site-indeed"
|
||||
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = jobspySitesDraft ?? defaultJobspySites
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('indeed')) next.push('indeed')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'indeed')
|
||||
}
|
||||
setJobspySitesDraft(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
<Controller
|
||||
name="jobspySites"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="site-indeed"
|
||||
checked={field.value?.includes('indeed') ?? sites.default.includes('indeed')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = field.value ?? sites.default
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('indeed')) next.push('indeed')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'indeed')
|
||||
}
|
||||
field.onChange(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="site-linkedin"
|
||||
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = jobspySitesDraft ?? defaultJobspySites
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('linkedin')) next.push('linkedin')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'linkedin')
|
||||
}
|
||||
setJobspySitesDraft(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
<Controller
|
||||
name="jobspySites"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="site-linkedin"
|
||||
checked={field.value?.includes('linkedin') ?? sites.default.includes('linkedin')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = field.value ?? sites.default
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('linkedin')) next.push('linkedin')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'linkedin')
|
||||
}
|
||||
field.onChange(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
|
||||
</div>
|
||||
</div>
|
||||
{errors.jobspySites && <p className="text-xs text-destructive">{errors.jobspySites.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Select which sites JobSpy should scrape.
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {(effectiveJobspySites || []).join(', ') || "None"}</span>
|
||||
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
|
||||
<span>Effective: {(sites.effective || []).join(', ') || "None"}</span>
|
||||
<span>Default: {(sites.default || []).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Location</div>
|
||||
<Input
|
||||
value={jobspyLocationDraft ?? defaultJobspyLocation}
|
||||
onChange={(event) => setJobspyLocationDraft(event.target.value)}
|
||||
placeholder={defaultJobspyLocation || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Location to search for jobs (e.g. "UK", "London", "Remote").
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLocation || "—"}</span>
|
||||
<span>Default: {defaultJobspyLocation || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Location"
|
||||
inputProps={register("jobspyLocation")}
|
||||
placeholder={location.default || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.jobspyLocation?.message as string | undefined}
|
||||
helper={'Location to search for jobs (e.g. "UK", "London", "Remote").'}
|
||||
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Results Wanted</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={500}
|
||||
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setJobspyResultsWantedDraft(null)
|
||||
} else {
|
||||
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Number of results to fetch per term per site. Max 500.
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyResultsWanted}</span>
|
||||
<span>Default: {defaultJobspyResultsWanted}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name="jobspyResultsWanted"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Results Wanted"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 1000,
|
||||
value: field.value ?? resultsWanted.default,
|
||||
onChange: (event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
field.onChange(null)
|
||||
} else {
|
||||
field.onChange(Math.min(1000, Math.max(1, value)))
|
||||
}
|
||||
},
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.jobspyResultsWanted?.message as string | undefined}
|
||||
helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`}
|
||||
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Hours Old</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={168}
|
||||
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setJobspyHoursOldDraft(null)
|
||||
} else {
|
||||
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Max age of jobs in hours (e.g. 72 for 3 days).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyHoursOld}h</span>
|
||||
<span>Default: {defaultJobspyHoursOld}h</span>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name="jobspyHoursOld"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Hours Old"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 720,
|
||||
value: field.value ?? hoursOld.default,
|
||||
onChange: (event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
field.onChange(null)
|
||||
} else {
|
||||
field.onChange(Math.min(720, Math.max(1, value)))
|
||||
}
|
||||
},
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.jobspyHoursOld?.message as string | undefined}
|
||||
helper={`Max age of jobs in hours (e.g. 72 for 3 days). Default: ${hoursOld.default}. Max 720.`}
|
||||
current={`Effective: ${hoursOld.effective}h | Default: ${hoursOld.default}h`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Indeed Country</div>
|
||||
<Input
|
||||
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
|
||||
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
|
||||
placeholder={defaultJobspyCountryIndeed || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyCountryIndeed || "—"}</span>
|
||||
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Indeed Country"
|
||||
inputProps={register("jobspyCountryIndeed")}
|
||||
placeholder={countryIndeed.default || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.jobspyCountryIndeed?.message as string | undefined}
|
||||
helper={'Country domain for Indeed (e.g. "UK" for indeed.co.uk).'}
|
||||
current={`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="linkedin-desc"
|
||||
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
|
||||
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
|
||||
disabled={isLoading || isSaving}
|
||||
<Controller
|
||||
name="jobspyLinkedinFetchDescription"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="linkedin-desc"
|
||||
checked={field.value ?? linkedinFetchDescription.default}
|
||||
onCheckedChange={(checked) => field.onChange(!!checked)}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
@ -228,8 +206,8 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
|
||||
</p>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
<span>Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}</span>
|
||||
<span>Default: {linkedinFetchDescription.default ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,44 +1,26 @@
|
||||
import React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import type { ModelValues } from "@client/pages/settings/types"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
|
||||
type ModelSettingsSectionProps = {
|
||||
modelDraft: string
|
||||
setModelDraft: (value: string) => void
|
||||
modelScorerDraft: string
|
||||
setModelScorerDraft: (value: string) => void
|
||||
modelTailoringDraft: string
|
||||
setModelTailoringDraft: (value: string) => void
|
||||
modelProjectSelectionDraft: string
|
||||
setModelProjectSelectionDraft: (value: string) => void
|
||||
effectiveModel: string
|
||||
effectiveModelScorer: string
|
||||
effectiveModelTailoring: string
|
||||
effectiveModelProjectSelection: string
|
||||
defaultModel: string
|
||||
values: ModelValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
modelDraft,
|
||||
setModelDraft,
|
||||
modelScorerDraft,
|
||||
setModelScorerDraft,
|
||||
modelTailoringDraft,
|
||||
setModelTailoringDraft,
|
||||
modelProjectSelectionDraft,
|
||||
setModelProjectSelectionDraft,
|
||||
effectiveModel,
|
||||
effectiveModelScorer,
|
||||
effectiveModelTailoring,
|
||||
effectiveModelProjectSelection,
|
||||
defaultModel,
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { effective, default: defaultModel, scorer, tailoring, projectSelection } = values
|
||||
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="model" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
@ -46,18 +28,15 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Override model</div>
|
||||
<Input
|
||||
value={modelDraft}
|
||||
onChange={(event) => setModelDraft(event.target.value)}
|
||||
placeholder={defaultModel || "openai/gpt-4o-mini"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Leave blank to use the default from server env (`MODEL`).
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Override model"
|
||||
inputProps={register("model")}
|
||||
placeholder={defaultModel || "google/gemini-3-flash-preview"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.model?.message as string | undefined}
|
||||
helper="Leave blank to use the default from server env (`MODEL`)."
|
||||
current={effective || "—"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
@ -65,44 +44,32 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
<div className="text-sm font-medium">Task-Specific Overrides</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Scoring Model</div>
|
||||
<Input
|
||||
value={modelScorerDraft}
|
||||
onChange={(event) => setModelScorerDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Scoring Model"
|
||||
inputProps={register("modelScorer")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelScorer?.message as string | undefined}
|
||||
current={scorer || effective || "—"}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Tailoring Model</div>
|
||||
<Input
|
||||
value={modelTailoringDraft}
|
||||
onChange={(event) => setModelTailoringDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Tailoring Model"
|
||||
inputProps={register("modelTailoring")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelTailoring?.message as string | undefined}
|
||||
current={tailoring || effective || "—"}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Project Selection Model</div>
|
||||
<Input
|
||||
value={modelProjectSelectionDraft}
|
||||
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Project Selection Model"
|
||||
inputProps={register("modelProjectSelection")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelProjectSelection?.message as string | undefined}
|
||||
current={projectSelection || effective || "—"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -111,7 +78,7 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Global Effective</div>
|
||||
<div className="break-words font-mono text-xs">{effectiveModel || "—"}</div>
|
||||
<div className="break-words font-mono text-xs">{effective || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type PipelineWebhookSectionProps = {
|
||||
pipelineWebhookUrlDraft: string
|
||||
setPipelineWebhookUrlDraft: (value: string) => void
|
||||
defaultPipelineWebhookUrl: string
|
||||
effectivePipelineWebhookUrl: string
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
|
||||
pipelineWebhookUrlDraft,
|
||||
setPipelineWebhookUrlDraft,
|
||||
defaultPipelineWebhookUrl,
|
||||
effectivePipelineWebhookUrl,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Pipeline Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Pipeline status webhook URL</div>
|
||||
<Input
|
||||
value={pipelineWebhookUrlDraft}
|
||||
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
|
||||
placeholder={defaultPipelineWebhookUrl || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">{effectivePipelineWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{defaultPipelineWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
import { useState } from "react"
|
||||
import { useForm, FormProvider } from "react-hook-form"
|
||||
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { ResumeProjectsSection } from "./ResumeProjectsSection"
|
||||
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
|
||||
import type { ResumeProjectCatalogItem } from "@shared/types"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
|
||||
const profileProjects: ResumeProjectCatalogItem[] = [
|
||||
{
|
||||
@ -23,25 +24,31 @@ const profileProjects: ResumeProjectCatalogItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: ResumeProjectsSettings | null }) => {
|
||||
const [draft, setDraft] = useState<ResumeProjectsSettings | null>(initialDraft)
|
||||
const lockedCount = draft?.lockedProjectIds.length ?? 0
|
||||
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: UpdateSettingsInput["resumeProjects"] }) => {
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
defaultValues: {
|
||||
resumeProjects: initialDraft
|
||||
}
|
||||
})
|
||||
const watched = methods.watch()
|
||||
const lockedCount = watched.resumeProjects?.lockedProjectIds.length ?? 0
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" defaultValue={["resume-projects"]}>
|
||||
<ResumeProjectsSection
|
||||
resumeProjectsDraft={draft}
|
||||
setResumeProjectsDraft={setDraft}
|
||||
profileProjects={profileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={profileProjects.length}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["resume-projects"]}>
|
||||
<ResumeProjectsSection
|
||||
profileProjects={profileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={profileProjects.length}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
describe("ResumeProjectsSection", () => {
|
||||
it("clamps max projects to the locked count", async () => {
|
||||
render(
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import React from "react"
|
||||
import { useFormContext, Controller } 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 { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
|
||||
import type { ResumeProjectCatalogItem } from "@shared/types"
|
||||
import { clampInt } from "@/lib/utils"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
|
||||
type ResumeProjectsSectionProps = {
|
||||
resumeProjectsDraft: ResumeProjectsSettings | null
|
||||
setResumeProjectsDraft: (value: ResumeProjectsSettings | null) => void
|
||||
profileProjects: ResumeProjectCatalogItem[]
|
||||
lockedCount: number
|
||||
maxProjectsTotal: number
|
||||
@ -19,14 +19,14 @@ type ResumeProjectsSectionProps = {
|
||||
}
|
||||
|
||||
export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
|
||||
resumeProjectsDraft,
|
||||
setResumeProjectsDraft,
|
||||
profileProjects,
|
||||
lockedCount,
|
||||
maxProjectsTotal,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
@ -36,113 +36,126 @@ export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max projects included</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={resumeProjectsDraft?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const next = Number(event.target.value)
|
||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
|
||||
}}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
<Controller
|
||||
name="resumeProjects"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={field.value?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!field.value) return
|
||||
const next = Number(event.target.value)
|
||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||
field.onChange({ ...field.value, maxProjects: clamped })
|
||||
}}
|
||||
disabled={isLoading || isSaving || !field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.resumeProjects?.maxProjects && <p className="text-xs text-destructive">{errors.resumeProjects.maxProjects.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
|
||||
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
|
||||
AI pool (max projects AI can use): {maxProjectsTotal}. Locked projects always count towards this cap. Locked: {lockedCount} · Total profile projects: {profileProjects.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="w-[110px]">Base visible</TableHead>
|
||||
<TableHead className="w-[90px]">Locked</TableHead>
|
||||
<TableHead className="w-[140px]">AI selectable</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
|
||||
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
|
||||
const excluded = !locked && !aiSelectable
|
||||
|
||||
return (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{project.name || project.id}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[project.description, project.date].filter(Boolean).join(" · ")}
|
||||
{excluded ? " · Excluded" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
|
||||
if (isChecked) {
|
||||
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
|
||||
const nextSelectable = selectableIds.filter((id) => id !== project.id)
|
||||
const minCap = lockedIds.length
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: lockedIds,
|
||||
aiSelectableProjectIds: nextSelectable,
|
||||
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: nextLocked,
|
||||
aiSelectableProjectIds: selectableIds,
|
||||
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
const nextSelectable = isChecked
|
||||
? selectableIds.includes(project.id)
|
||||
? selectableIds
|
||||
: [...selectableIds, project.id]
|
||||
: selectableIds.filter((id) => id !== project.id)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<Controller
|
||||
name="resumeProjects"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="w-[110px]">Base visible</TableHead>
|
||||
<TableHead className="w-[90px]">Locked</TableHead>
|
||||
<TableHead className="w-[140px]">AI selectable</TableHead>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(field.value?.lockedProjectIds.includes(project.id))
|
||||
const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id))
|
||||
const excluded = !locked && !aiSelectable
|
||||
|
||||
return (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{project.name || project.id}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[project.description, project.date].filter(Boolean).join(" · ")}
|
||||
{excluded ? " · Excluded" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={isLoading || isSaving || !field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!field.value) return
|
||||
const isChecked = checked === true
|
||||
const lockedIds = field.value.lockedProjectIds.slice()
|
||||
const selectableIds = field.value.aiSelectableProjectIds.slice()
|
||||
|
||||
if (isChecked) {
|
||||
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
|
||||
const nextSelectable = selectableIds.filter((id) => id !== project.id)
|
||||
const minCap = lockedIds.length
|
||||
field.onChange({
|
||||
...field.value,
|
||||
lockedProjectIds: lockedIds,
|
||||
aiSelectableProjectIds: nextSelectable,
|
||||
maxProjects: Math.max(field.value.maxProjects, minCap),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||
field.onChange({
|
||||
...field.value,
|
||||
lockedProjectIds: nextLocked,
|
||||
aiSelectableProjectIds: selectableIds,
|
||||
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={locked || isLoading || isSaving || !field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!field.value) return
|
||||
const isChecked = checked === true
|
||||
const selectableIds = field.value.aiSelectableProjectIds.slice()
|
||||
const nextSelectable = isChecked
|
||||
? selectableIds.includes(project.id)
|
||||
? selectableIds
|
||||
: [...selectableIds, project.id]
|
||||
: selectableIds.filter((id) => id !== project.id)
|
||||
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable })
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,25 +1,25 @@
|
||||
import React from "react"
|
||||
import { useFormContext, Controller } from "react-hook-form"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import type { SearchTermsValues } from "@client/pages/settings/types"
|
||||
|
||||
type SearchTermsSectionProps = {
|
||||
searchTermsDraft: string[] | null
|
||||
setSearchTermsDraft: (value: string[] | null) => void
|
||||
defaultSearchTerms: string[]
|
||||
effectiveSearchTerms: string[]
|
||||
values: SearchTermsValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
||||
searchTermsDraft,
|
||||
setSearchTermsDraft,
|
||||
defaultSearchTerms,
|
||||
effectiveSearchTerms,
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { default: defaultSearchTerms, effective: effectiveSearchTerms } = values
|
||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="search-terms" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
@ -29,24 +29,30 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Global search terms</div>
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value
|
||||
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
|
||||
setSearchTermsDraft(terms)
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Clean up on blur
|
||||
if (searchTermsDraft) {
|
||||
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. web developer"
|
||||
disabled={isLoading || isSaving}
|
||||
rows={5}
|
||||
<Controller
|
||||
name="searchTerms"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={field.value ? field.value.join('\n') : (defaultSearchTerms ?? []).join('\n')}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value
|
||||
const terms = text.split('\n')
|
||||
field.onChange(terms)
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (field.value) {
|
||||
field.onChange(field.value.map(t => t.trim()).filter(Boolean))
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. web developer"
|
||||
disabled={isLoading || isSaving}
|
||||
rows={5}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.searchTerms && <p className="text-xs text-destructive">{errors.searchTerms.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
One term per line. Applies to UKVisaJobs and other supported extractors.
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
type SettingsInputProps = {
|
||||
label: string
|
||||
inputProps: React.InputHTMLAttributes<HTMLInputElement>
|
||||
placeholder?: string
|
||||
type?: React.HTMLInputTypeAttribute
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
helper?: string
|
||||
current?: string | null
|
||||
}
|
||||
|
||||
export const SettingsInput: React.FC<SettingsInputProps> = ({
|
||||
label,
|
||||
inputProps,
|
||||
placeholder,
|
||||
type = "text",
|
||||
disabled,
|
||||
error,
|
||||
helper,
|
||||
current,
|
||||
}) => {
|
||||
const id = inputProps.id || inputProps.name
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Input {...inputProps} id={id} type={type} placeholder={placeholder} disabled={disabled} />
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{helper && <div className="text-xs text-muted-foreground">{helper}</div>}
|
||||
{current !== undefined && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current: <span className="font-mono">{current}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,26 +1,25 @@
|
||||
import React from "react"
|
||||
import { useFormContext, Controller } from "react-hook-form"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import type { NumericSettingValues } from "@client/pages/settings/types"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
|
||||
type UkvisajobsSectionProps = {
|
||||
ukvisajobsMaxJobsDraft: number | null
|
||||
setUkvisajobsMaxJobsDraft: (value: number | null) => void
|
||||
defaultUkvisajobsMaxJobs: number
|
||||
effectiveUkvisajobsMaxJobs: number
|
||||
values: NumericSettingValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
||||
ukvisajobsMaxJobsDraft,
|
||||
setUkvisajobsMaxJobsDraft,
|
||||
defaultUkvisajobsMaxJobs,
|
||||
effectiveUkvisajobsMaxJobs,
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { effective: effectiveUkvisajobsMaxJobs, default: defaultUkvisajobsMaxJobs } = values
|
||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
@ -28,41 +27,35 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max jobs to fetch</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setUkvisajobsMaxJobsDraft(null)
|
||||
} else {
|
||||
setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">{effectiveUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name="ukvisajobsMaxJobs"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Max jobs to fetch"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 1000,
|
||||
value: field.value ?? defaultUkvisajobsMaxJobs,
|
||||
onChange: (event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
field.onChange(null)
|
||||
} else {
|
||||
field.onChange(Math.min(1000, Math.max(1, value)))
|
||||
}
|
||||
},
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.ukvisajobsMaxJobs?.message as string | undefined}
|
||||
helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${defaultUkvisajobsMaxJobs}. Range: 1-1000.`}
|
||||
current={String(effectiveUkvisajobsMaxJobs)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { useForm, FormProvider } from "react-hook-form"
|
||||
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { WebhooksSection } from "./WebhooksSection"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
|
||||
const WebhooksHarness = () => {
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
defaultValues: {
|
||||
pipelineWebhookUrl: "https://pipeline.com",
|
||||
jobCompleteWebhookUrl: "https://job.com",
|
||||
webhookSecret: "",
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["webhooks"]}>
|
||||
<WebhooksSection
|
||||
pipelineWebhook={{
|
||||
default: "https://default-p.com",
|
||||
effective: "https://pipeline.com",
|
||||
}}
|
||||
jobCompleteWebhook={{
|
||||
default: "https://default-j.com",
|
||||
effective: "https://job.com",
|
||||
}}
|
||||
webhookSecretHint="sec-"
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("WebhooksSection", () => {
|
||||
it("renders both webhook sections and the secret", () => {
|
||||
render(<WebhooksHarness />)
|
||||
|
||||
expect(screen.getByText("Pipeline Status")).toBeInTheDocument()
|
||||
expect(screen.getByText("Job Completion")).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByDisplayValue("https://pipeline.com")).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText("sec-********")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,79 @@
|
||||
import React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import type { WebhookValues } from "@client/pages/settings/types"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
||||
|
||||
type WebhooksSectionProps = {
|
||||
pipelineWebhook: WebhookValues
|
||||
jobCompleteWebhook: WebhookValues
|
||||
webhookSecretHint: string | null
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
|
||||
pipelineWebhook,
|
||||
jobCompleteWebhook,
|
||||
webhookSecretHint,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="webhooks" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Webhooks</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">Pipeline Status</div>
|
||||
<SettingsInput
|
||||
label="Webhook URL"
|
||||
inputProps={register("pipelineWebhookUrl")}
|
||||
placeholder={pipelineWebhook.default || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.pipelineWebhookUrl?.message as string | undefined}
|
||||
helper={`When set, the server sends a POST on pipeline completion/failure. Default: ${pipelineWebhook.default || "—"}.`}
|
||||
current={pipelineWebhook.effective || "—"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">Job Completion</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsInput
|
||||
label="Webhook URL"
|
||||
inputProps={register("jobCompleteWebhookUrl")}
|
||||
placeholder={jobCompleteWebhook.default || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.jobCompleteWebhookUrl?.message as string | undefined}
|
||||
helper={`When set, the server sends a POST when you mark a job as applied (includes the job description). Default: ${jobCompleteWebhook.default || "—"}.`}
|
||||
current={jobCompleteWebhook.effective || "—"}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
label="Webhook Secret"
|
||||
inputProps={register("webhookSecret")}
|
||||
type="password"
|
||||
placeholder="Enter new secret"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.webhookSecret?.message as string | undefined}
|
||||
helper="Secret sent to webhook (Bearer token)"
|
||||
current={formatSecretHint(webhookSecretHint)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
40
orchestrator/src/client/pages/settings/types.ts
Normal file
40
orchestrator/src/client/pages/settings/types.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export type EffectiveDefault<T> = {
|
||||
effective: T
|
||||
default: T
|
||||
}
|
||||
|
||||
export type ModelValues = EffectiveDefault<string> & {
|
||||
scorer: string
|
||||
tailoring: string
|
||||
projectSelection: string
|
||||
}
|
||||
|
||||
export type WebhookValues = EffectiveDefault<string>
|
||||
export type NumericSettingValues = EffectiveDefault<number>
|
||||
export type SearchTermsValues = EffectiveDefault<string[]>
|
||||
export type DisplayValues = EffectiveDefault<boolean>
|
||||
|
||||
export type JobspyValues = {
|
||||
sites: EffectiveDefault<string[]>
|
||||
location: EffectiveDefault<string>
|
||||
resultsWanted: EffectiveDefault<number>
|
||||
hoursOld: EffectiveDefault<number>
|
||||
countryIndeed: EffectiveDefault<string>
|
||||
linkedinFetchDescription: EffectiveDefault<boolean>
|
||||
}
|
||||
|
||||
export type EnvSettingsValues = {
|
||||
readable: {
|
||||
rxresumeEmail: string
|
||||
ukvisajobsEmail: string
|
||||
basicAuthUser: string
|
||||
}
|
||||
private: {
|
||||
openrouterApiKeyHint: string | null
|
||||
rxresumePasswordHint: string | null
|
||||
ukvisajobsPasswordHint: string | null
|
||||
basicAuthPasswordHint: string | null
|
||||
webhookSecretHint: string | null
|
||||
}
|
||||
basicAuthActive: boolean
|
||||
}
|
||||
@ -12,3 +12,5 @@ export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjects
|
||||
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
|
||||
)
|
||||
}
|
||||
|
||||
export const formatSecretHint = (hint: string | null) => (hint ? `${hint}********` : "Not set")
|
||||
|
||||
@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
244
orchestrator/src/components/ui/field.tsx
Normal file
244
orchestrator/src/components/ui/field.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field data-[invalid=true]:text-destructive flex w-full gap-3",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start",
|
||||
],
|
||||
responsive: [
|
||||
"@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4",
|
||||
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (errors?.length === 1 && errors[0]?.message) {
|
||||
return errors[0].message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{errors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-destructive text-sm font-normal", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
26
orchestrator/src/components/ui/label.tsx
Normal file
26
orchestrator/src/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
42
orchestrator/src/components/ui/radio-group.tsx
Normal file
42
orchestrator/src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-3.5 w-3.5 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
32
orchestrator/src/components/ui/tooltip.tsx
Normal file
32
orchestrator/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@ -1,15 +1,10 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600;700&family=Lora:wght@400;500;600;700&display=swap");
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@tailwind utilities;
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
@ -132,68 +127,6 @@
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
--tracking-normal: 0em;
|
||||
|
||||
--animate-in: in 0.5s ease-out forwards;
|
||||
--animate-out: out 0.3s ease-in forwards;
|
||||
--animate-fade-in: fade-in 0.5s ease-out forwards;
|
||||
--animate-fade-out: fade-out 0.3s ease-in forwards;
|
||||
--animate-slide-in-from-left: slide-in-from-left 0.5s ease-out forwards;
|
||||
--animate-slide-out-to-left: slide-out-to-left 0.3s ease-in forwards;
|
||||
--animate-slide-in-from-right: slide-in-from-right 0.5s ease-out forwards;
|
||||
--animate-slide-out-to-right: slide-out-to-right 0.3s ease-in forwards;
|
||||
--animate-slide-in-from-top: slide-in-from-top 0.5s ease-out forwards;
|
||||
--animate-slide-out-to-top: slide-out-to-top 0.3s ease-in forwards;
|
||||
--animate-slide-in-from-bottom: slide-in-from-bottom 0.5s ease-out forwards;
|
||||
--animate-slide-out-to-bottom: slide-out-to-bottom 0.3s ease-in forwards;
|
||||
|
||||
@keyframes in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes slide-in-from-left {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
@keyframes slide-out-to-left {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-100%); }
|
||||
}
|
||||
@keyframes slide-in-from-right {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
@keyframes slide-out-to-right {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
@keyframes slide-in-from-top {
|
||||
from { transform: translateY(-100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
@keyframes slide-out-to-top {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-100%); }
|
||||
}
|
||||
@keyframes slide-in-from-bottom {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
@keyframes slide-out-to-bottom {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(100%); }
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -255,6 +188,7 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
@apply bg-background text-foreground antialiased;
|
||||
@ -276,4 +210,4 @@
|
||||
.page-exit-active {
|
||||
opacity: 0;
|
||||
transition: opacity 75ms ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import { webhookRouter } from './routes/webhook.js';
|
||||
import { profileRouter } from './routes/profile.js';
|
||||
import { databaseRouter } from './routes/database.js';
|
||||
import { visaSponsorsRouter } from './routes/visa-sponsors.js';
|
||||
import { onboardingRouter } from './routes/onboarding.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
|
||||
@ -24,3 +25,4 @@ apiRouter.use('/webhook', webhookRouter);
|
||||
apiRouter.use('/profile', profileRouter);
|
||||
apiRouter.use('/database', databaseRouter);
|
||||
apiRouter.use('/visa-sponsors', visaSponsorsRouter);
|
||||
apiRouter.use('/onboarding', onboardingRouter);
|
||||
|
||||
@ -70,7 +70,7 @@ describe.sequential('Jobs API routes', () => {
|
||||
|
||||
it('applies a job and syncs to Notion', async () => {
|
||||
const { createNotionEntry } = await import('../../services/notion.js');
|
||||
vi.mocked(createNotionEntry).mockResolvedValue({ pageId: 'page-123' });
|
||||
vi.mocked(createNotionEntry).mockResolvedValue({ success: true, pageId: 'page-123' });
|
||||
|
||||
const { createJob } = await import('../../repositories/jobs.js');
|
||||
const job = await createJob({
|
||||
@ -95,4 +95,26 @@ describe.sequential('Jobs API routes', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('checks visa sponsor status for a job', async () => {
|
||||
const { searchSponsors } = await import('../../services/visa-sponsors/index.js');
|
||||
vi.mocked(searchSponsors).mockReturnValue([
|
||||
{ sponsor: { organisationName: 'ACME CORP SPONSOR' } as any, score: 100, matchedName: 'acme corp sponsor' }
|
||||
]);
|
||||
|
||||
const { createJob } = await import('../../repositories/jobs.js');
|
||||
const job = await createJob({
|
||||
source: 'manual',
|
||||
title: 'Sponsored Dev',
|
||||
employer: 'Acme',
|
||||
jobUrl: 'https://example.com/job/4',
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/check-sponsor`, { method: 'POST' });
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.sponsorMatchScore).toBe(100);
|
||||
expect(body.data.sponsorMatchNames).toContain('ACME CORP SPONSOR');
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import * as jobsRepo from '../../repositories/jobs.js';
|
||||
import * as settingsRepo from '../../repositories/settings.js';
|
||||
import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js';
|
||||
import { createNotionEntry } from '../../services/notion.js';
|
||||
import * as visaSponsors from '../../services/visa-sponsors/index.js';
|
||||
import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.js';
|
||||
|
||||
export const jobsRouter = Router();
|
||||
@ -47,6 +48,8 @@ const updateJobSchema = z.object({
|
||||
tailoredSummary: z.string().optional(),
|
||||
selectedProjectIds: z.string().optional(),
|
||||
pdfPath: z.string().optional(),
|
||||
sponsorMatchScore: z.number().min(0).max(100).optional(),
|
||||
sponsorMatchNames: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -136,6 +139,49 @@ jobsRouter.post('/:id/summarize', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/check-sponsor - Check if employer is a visa sponsor
|
||||
*/
|
||||
jobsRouter.post('/:id/check-sponsor', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const job = await jobsRepo.getJobById(req.params.id);
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||
}
|
||||
|
||||
if (!job.employer) {
|
||||
return res.status(400).json({ success: false, error: 'Job has no employer name' });
|
||||
}
|
||||
|
||||
// Search for sponsor matches
|
||||
const sponsorResults = visaSponsors.searchSponsors(job.employer, {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
|
||||
const { sponsorMatchScore, sponsorMatchNames } = visaSponsors.calculateSponsorMatchSummary(sponsorResults);
|
||||
|
||||
// Update job with sponsor match info
|
||||
const updatedJob = await jobsRepo.updateJob(job.id, {
|
||||
sponsorMatchScore: sponsorMatchScore,
|
||||
sponsorMatchNames: sponsorMatchNames ?? undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedJob,
|
||||
matchResults: sponsorResults.slice(0, 5).map(r => ({
|
||||
name: r.sponsor.organisationName,
|
||||
score: r.score,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/generate-pdf - Generate PDF using current manual overrides
|
||||
*/
|
||||
|
||||
@ -4,7 +4,7 @@ import { z } from 'zod';
|
||||
import * as jobsRepo from '../../repositories/jobs.js';
|
||||
import { inferManualJobDetails } from '../../services/manualJob.js';
|
||||
import { scoreJobSuitability } from '../../services/scorer.js';
|
||||
import { loadResumeProfile } from '../../services/resumeProjects.js';
|
||||
import { getProfile } from '../../services/profile.js';
|
||||
import type { ApiResponse, ManualJobInferenceResponse } from '../../../shared/types.js';
|
||||
|
||||
export const manualJobsRouter = Router();
|
||||
@ -98,7 +98,7 @@ manualJobsRouter.post('/import', async (req: Request, res: Response) => {
|
||||
// Score asynchronously so the import returns immediately.
|
||||
(async () => {
|
||||
try {
|
||||
const rawProfile = await loadResumeProfile();
|
||||
const rawProfile = await getProfile();
|
||||
if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format');
|
||||
}
|
||||
|
||||
273
orchestrator/src/server/api/routes/onboarding.test.ts
Normal file
273
orchestrator/src/server/api/routes/onboarding.test.ts
Normal file
@ -0,0 +1,273 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
import { RxResumeClient } from '@server/services/rxresume-client.js';
|
||||
|
||||
describe.sequential('Onboarding API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalFetch = global.fetch;
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('POST /api/onboarding/validate/openrouter', () => {
|
||||
it('returns invalid when no API key is provided and none in env', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain('missing');
|
||||
});
|
||||
|
||||
it('returns invalid when API key is empty string', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey: ' ' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain('missing');
|
||||
});
|
||||
|
||||
it('validates an invalid API key against OpenRouter', async () => {
|
||||
global.fetch = vi.fn((input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url.startsWith('https://openrouter.ai/api/v1/key')) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: { message: 'invalid api key' } }),
|
||||
} as Response);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/openrouter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey: 'sk-or-invalid-key-12345' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
// Should be invalid because the key is fake
|
||||
expect(body.data.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/onboarding/validate/rxresume', () => {
|
||||
it('returns invalid when no credentials are provided and none in env', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain('missing');
|
||||
});
|
||||
|
||||
it('returns invalid when only email is provided', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'test@example.com' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain('missing');
|
||||
});
|
||||
|
||||
it('returns invalid when only password is provided', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'testpass' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain('missing');
|
||||
});
|
||||
|
||||
it('validates invalid credentials against RxResume', async () => {
|
||||
vi.spyOn(RxResumeClient, 'verifyCredentials').mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
message: 'InvalidCredentials',
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: 'nonexistent@test.com',
|
||||
password: 'wrongpassword123',
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
// Should be invalid because credentials are fake
|
||||
expect(body.data.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('handles whitespace-only credentials', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: ' ', password: ' ' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain('missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/onboarding/validate/resume', () => {
|
||||
it('returns invalid when no resume file exists', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns invalid when resume file is empty', async () => {
|
||||
// Create an empty resume file
|
||||
const resumePath = join(tempDir, 'resume.json');
|
||||
await writeFile(resumePath, '');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns invalid when resume file is invalid JSON', async () => {
|
||||
const resumePath = join(tempDir, 'resume.json');
|
||||
await writeFile(resumePath, 'not valid json {{{');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns invalid with field path when resume does not match schema', async () => {
|
||||
const resumePath = join(tempDir, 'resume.json');
|
||||
// Valid JSON but missing required fields
|
||||
await writeFile(resumePath, JSON.stringify({ foo: 'bar' }));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
// Should include field path in error message
|
||||
expect(body.data.message).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns valid when resume file is valid and matches schema', async () => {
|
||||
const resumePath = join(tempDir, 'resume.json');
|
||||
const validResume = createMinimalValidResume();
|
||||
await writeFile(resumePath, JSON.stringify(validResume));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.valid).toBe(true);
|
||||
expect(body.data.message).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a minimal valid RxResume v4 schema compliant JSON
|
||||
*/
|
||||
function createMinimalValidResume() {
|
||||
return {
|
||||
basics: {
|
||||
name: 'Test User',
|
||||
headline: 'Software Developer',
|
||||
email: 'test@example.com',
|
||||
phone: '',
|
||||
location: '',
|
||||
url: { label: '', href: '' },
|
||||
customFields: [],
|
||||
picture: {
|
||||
url: '',
|
||||
size: 64,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 0,
|
||||
effects: { hidden: false, border: false, grayscale: false },
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
summary: { id: 'summary', name: 'Summary', columns: 1, separateLinks: true, visible: true, content: '' },
|
||||
skills: { id: 'skills', name: 'Skills', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
awards: { id: 'awards', name: 'Awards', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
certifications: { id: 'certifications', name: 'Certifications', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
education: { id: 'education', name: 'Education', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
experience: { id: 'experience', name: 'Experience', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
volunteer: { id: 'volunteer', name: 'Volunteer', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
interests: { id: 'interests', name: 'Interests', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
languages: { id: 'languages', name: 'Languages', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
profiles: { id: 'profiles', name: 'Profiles', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
projects: { id: 'projects', name: 'Projects', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
publications: { id: 'publications', name: 'Publications', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
references: { id: 'references', name: 'References', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
custom: {},
|
||||
},
|
||||
metadata: {
|
||||
template: 'rhyhorn',
|
||||
layout: [[['summary'], ['skills']]],
|
||||
css: { value: '', visible: false },
|
||||
page: { margin: 18, format: 'a4', options: { breakLine: true, pageNumbers: true } },
|
||||
theme: { background: '#ffffff', text: '#000000', primary: '#dc2626' },
|
||||
typography: {
|
||||
font: { family: 'IBM Plex Serif', subset: 'latin', variants: ['regular'], size: 14 },
|
||||
lineHeight: 1.5,
|
||||
hideIcons: false,
|
||||
underlineLinks: true,
|
||||
},
|
||||
notes: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
124
orchestrator/src/server/api/routes/onboarding.ts
Normal file
124
orchestrator/src/server/api/routes/onboarding.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
|
||||
import { resumeDataSchema } from '@shared/rxresume-schema.js';
|
||||
import { DEFAULT_PROFILE_PATH } from '@server/services/profile.js';
|
||||
import { RxResumeClient } from '@server/services/rxresume-client.js';
|
||||
|
||||
export const onboardingRouter = Router();
|
||||
|
||||
type ValidationResponse = {
|
||||
valid: boolean;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
async function validateOpenrouter(apiKey?: string | null): Promise<ValidationResponse> {
|
||||
const key = apiKey?.trim() || process.env.OPENROUTER_API_KEY || '';
|
||||
if (!key) {
|
||||
return { valid: false, message: 'OpenRouter API key is missing.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/key', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && typeof payload === 'object' && 'error' in payload) {
|
||||
const errorObj = payload.error as { message?: string; code?: number | string };
|
||||
const message = errorObj?.message || '';
|
||||
const code = errorObj?.code ? ` (${errorObj.code})` : '';
|
||||
detail = `${message}${code}`.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return { valid: false, message: 'Invalid OpenRouter API key. Check the key and try again.' };
|
||||
}
|
||||
|
||||
const fallback = `OpenRouter returned ${response.status}`;
|
||||
return { valid: false, message: detail || fallback };
|
||||
}
|
||||
|
||||
return { valid: true, message: null };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'OpenRouter validation failed.';
|
||||
return { valid: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
async function validateResumeJson(): Promise<ValidationResponse> {
|
||||
try {
|
||||
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
|
||||
if (!fileInfo.isFile() || fileInfo.size === 0) {
|
||||
return { valid: false, message: 'Resume JSON is missing.' };
|
||||
}
|
||||
|
||||
const raw = await readFile(DEFAULT_PROFILE_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const result = resumeDataSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const issue = result.error.issues[0];
|
||||
const path = issue?.path?.join('.') || '';
|
||||
const baseMessage = issue?.message ?? 'Resume JSON does not match the expected schema.';
|
||||
const details = path
|
||||
? `Field "${path}": ${baseMessage}`
|
||||
: baseMessage;
|
||||
return { valid: false, message: details };
|
||||
}
|
||||
|
||||
return { valid: true, message: null };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to read resume JSON.';
|
||||
return { valid: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
async function validateRxresume(email?: string | null, password?: string | null): Promise<ValidationResponse> {
|
||||
const rxEmail = email?.trim() || process.env.RXRESUME_EMAIL || '';
|
||||
const rxPassword = password?.trim() || process.env.RXRESUME_PASSWORD || '';
|
||||
|
||||
if (!rxEmail || !rxPassword) {
|
||||
return { valid: false, message: 'RxResume credentials are missing.' };
|
||||
}
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(rxEmail, rxPassword);
|
||||
|
||||
if (result.ok) {
|
||||
return { valid: true, message: null };
|
||||
}
|
||||
|
||||
const normalizedMessage = result.message?.toLowerCase() ?? '';
|
||||
if (result.status === 401 || normalizedMessage.includes('invalidcredentials')) {
|
||||
return { valid: false, message: 'Invalid RxResume credentials. Check your email and password and try again.' };
|
||||
}
|
||||
|
||||
const message = result.message || `RxResume validation failed (HTTP ${result.status})`;
|
||||
return { valid: false, message };
|
||||
}
|
||||
|
||||
onboardingRouter.post('/validate/openrouter', async (req: Request, res: Response) => {
|
||||
const apiKey = typeof req.body?.apiKey === 'string' ? req.body.apiKey : undefined;
|
||||
const result = await validateOpenrouter(apiKey);
|
||||
res.json({ success: true, data: result });
|
||||
});
|
||||
|
||||
onboardingRouter.post('/validate/rxresume', async (req: Request, res: Response) => {
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email : undefined;
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : undefined;
|
||||
const result = await validateRxresume(email, password);
|
||||
res.json({ success: true, data: result });
|
||||
});
|
||||
|
||||
onboardingRouter.get('/validate/resume', async (_req: Request, res: Response) => {
|
||||
const result = await validateResumeJson();
|
||||
res.json({ success: true, data: result });
|
||||
});
|
||||
@ -54,11 +54,14 @@ describe.sequential('Pipeline API routes', () => {
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (reader) {
|
||||
const chunk = await reader.read();
|
||||
controller.abort();
|
||||
await reader.cancel();
|
||||
const text = new TextDecoder().decode(chunk.value);
|
||||
expect(text).toContain('data:');
|
||||
try {
|
||||
const { value } = await reader.read();
|
||||
const text = new TextDecoder().decode(value);
|
||||
expect(text).toContain('data:');
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
controller.abort();
|
||||
}
|
||||
} else {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { writeFile, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Profile API routes', () => {
|
||||
@ -16,10 +18,236 @@ describe.sequential('Profile API routes', () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
it('returns empty projects when resume is missing', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/profile/projects`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null profile when resume is missing', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/profile`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toBeNull();
|
||||
});
|
||||
|
||||
it('returns base resume projects', async () => {
|
||||
// Create valid resume file first
|
||||
const resumePath = join(tempDir, 'resume.json');
|
||||
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/profile/projects`);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns full base resume profile', async () => {
|
||||
// Create valid resume file first
|
||||
const resumePath = join(tempDir, 'resume.json');
|
||||
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/profile`);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toBeDefined();
|
||||
expect(typeof body.data).toBe('object');
|
||||
});
|
||||
|
||||
|
||||
describe('GET /api/profile/status', () => {
|
||||
it('returns exists: false when resume file does not exist', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/profile/status`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.exists).toBe(false);
|
||||
expect(body.data.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns exists: false when resume file is empty', async () => {
|
||||
const resumePath = join(tempDir, 'resume.json');
|
||||
await writeFile(resumePath, '');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/profile/status`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.data.exists).toBe(false);
|
||||
});
|
||||
|
||||
it('returns exists: true when valid resume file exists', async () => {
|
||||
const resumePath = join(tempDir, 'resume.json');
|
||||
await writeFile(resumePath, JSON.stringify(createMinimalValidResume()));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/profile/status`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.exists).toBe(true);
|
||||
expect(body.data.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/profile/upload', () => {
|
||||
it('rejects request without profile payload', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/profile/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('Invalid profile payload');
|
||||
});
|
||||
|
||||
it('rejects array as profile payload', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/profile/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: [] }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('Invalid profile payload');
|
||||
});
|
||||
|
||||
it('rejects primitive as profile payload', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/profile/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: 'not an object' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('Invalid profile payload');
|
||||
});
|
||||
|
||||
it('rejects invalid resume with detailed field path in error', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/profile/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: { foo: 'bar' } }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('Invalid resume JSON');
|
||||
// Should include field path in error message
|
||||
expect(body.error).toMatch(/Field "[^"]+"/);
|
||||
});
|
||||
|
||||
it('accepts valid resume and creates file', async () => {
|
||||
const validResume = createMinimalValidResume();
|
||||
const res = await fetch(`${baseUrl}/api/profile/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: validResume }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.exists).toBe(true);
|
||||
expect(body.data.error).toBeNull();
|
||||
|
||||
// Verify file was created
|
||||
const resumePath = join(tempDir, 'resume.json');
|
||||
const fileInfo = await stat(resumePath);
|
||||
expect(fileInfo.isFile()).toBe(true);
|
||||
expect(fileInfo.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('overwrites existing resume file', async () => {
|
||||
const resumePath = join(tempDir, 'resume.json');
|
||||
const oldResume = createMinimalValidResume();
|
||||
oldResume.basics.name = 'Old Name';
|
||||
await writeFile(resumePath, JSON.stringify(oldResume));
|
||||
|
||||
const newResume = createMinimalValidResume();
|
||||
newResume.basics.name = 'New Name';
|
||||
const res = await fetch(`${baseUrl}/api/profile/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: newResume }),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
// Verify profile was updated
|
||||
const profileRes = await fetch(`${baseUrl}/api/profile`);
|
||||
const profileBody = await profileRes.json();
|
||||
expect(profileBody.data.basics.name).toBe('New Name');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a minimal valid RxResume v4 schema compliant JSON
|
||||
*/
|
||||
function createMinimalValidResume() {
|
||||
return {
|
||||
basics: {
|
||||
name: 'Test User',
|
||||
headline: 'Software Developer',
|
||||
email: 'test@example.com',
|
||||
phone: '',
|
||||
location: '',
|
||||
url: { label: '', href: '' },
|
||||
customFields: [],
|
||||
picture: {
|
||||
url: '',
|
||||
size: 64,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 0,
|
||||
effects: { hidden: false, border: false, grayscale: false },
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
summary: { id: 'summary', name: 'Summary', columns: 1, separateLinks: true, visible: true, content: '' },
|
||||
skills: { id: 'skills', name: 'Skills', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
awards: { id: 'awards', name: 'Awards', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
certifications: { id: 'certifications', name: 'Certifications', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
education: { id: 'education', name: 'Education', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
experience: { id: 'experience', name: 'Experience', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
volunteer: { id: 'volunteer', name: 'Volunteer', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
interests: { id: 'interests', name: 'Interests', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
languages: { id: 'languages', name: 'Languages', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
profiles: { id: 'profiles', name: 'Profiles', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
projects: { id: 'projects', name: 'Projects', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
publications: { id: 'publications', name: 'Publications', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
references: { id: 'references', name: 'References', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
custom: {},
|
||||
},
|
||||
metadata: {
|
||||
template: 'rhyhorn',
|
||||
layout: [[['summary'], ['skills']]],
|
||||
css: { value: '', visible: false },
|
||||
page: { margin: 18, format: 'a4', options: { breakLine: true, pageNumbers: true } },
|
||||
theme: { background: '#ffffff', text: '#000000', primary: '#dc2626' },
|
||||
typography: {
|
||||
font: { family: 'IBM Plex Serif', subset: 'latin', variants: ['regular'], size: 14 },
|
||||
lineHeight: 1.5,
|
||||
hideIcons: false,
|
||||
underlineLinks: true,
|
||||
},
|
||||
notes: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,14 +1,31 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { extractProjectsFromProfile, loadResumeProfile } from '../../services/resumeProjects.js';
|
||||
import { mkdir, stat, writeFile } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
import { extractProjectsFromProfile } from '../../services/resumeProjects.js';
|
||||
import { clearProfileCache, DEFAULT_PROFILE_PATH, getProfile } from '../../services/profile.js';
|
||||
import { resumeDataSchema } from '@shared/rxresume-schema.js';
|
||||
|
||||
export const profileRouter = Router();
|
||||
|
||||
async function profileExists(): Promise<boolean> {
|
||||
try {
|
||||
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
|
||||
return fileInfo.isFile() && fileInfo.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/projects - Get all projects available in the base resume
|
||||
*/
|
||||
profileRouter.get('/projects', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const profile = await loadResumeProfile();
|
||||
if (!(await profileExists())) {
|
||||
res.json({ success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
res.json({ success: true, data: catalog });
|
||||
} catch (error) {
|
||||
@ -16,3 +33,76 @@ profileRouter.get('/projects', async (req: Request, res: Response) => {
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/profile - Get the full base resume profile
|
||||
*/
|
||||
profileRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await profileExists())) {
|
||||
res.json({ success: true, data: null });
|
||||
return;
|
||||
}
|
||||
const profile = await getProfile();
|
||||
res.json({ success: true, data: profile });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/profile/status - Check if base resume exists
|
||||
*/
|
||||
profileRouter.get('/status', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
|
||||
const exists = fileInfo.isFile() && fileInfo.size > 0;
|
||||
res.json({ success: true, data: { exists, error: exists ? null : 'Resume file is empty' } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.json({ success: true, data: { exists: false, error: message } });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/profile/upload - Upload base resume JSON
|
||||
*/
|
||||
profileRouter.post('/upload', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const profile = (req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>).profile : null) as unknown;
|
||||
|
||||
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
||||
throw new Error('Invalid profile payload. Expected a JSON object.');
|
||||
}
|
||||
|
||||
const parsed = resumeDataSchema.safeParse(profile);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
const path = issue?.path?.join('.') || '';
|
||||
const baseMessage = issue?.message ?? 'Resume JSON does not match the RxResume schema.';
|
||||
const details = path ? `Field "${path}": ${baseMessage}` : baseMessage;
|
||||
throw new Error(`Invalid resume JSON: ${details}`);
|
||||
}
|
||||
|
||||
const existing = await stat(DEFAULT_PROFILE_PATH).catch(() => null);
|
||||
if (existing && existing.isDirectory()) {
|
||||
throw new Error('Resume path is a directory. Remove it and upload again.');
|
||||
}
|
||||
|
||||
await mkdir(dirname(DEFAULT_PROFILE_PATH), { recursive: true });
|
||||
await writeFile(DEFAULT_PROFILE_PATH, JSON.stringify(parsed.data, null, 2), 'utf-8');
|
||||
clearProfileCache();
|
||||
|
||||
res.json({ success: true, data: { exists: true, error: null } });
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code === 'EROFS') {
|
||||
message = 'Resume path is read-only. Remove the bind mount and restart the container.';
|
||||
}
|
||||
}
|
||||
res.status(400).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@ -9,7 +9,12 @@ describe.sequential('Settings API routes', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer({
|
||||
env: {
|
||||
OPENROUTER_API_KEY: 'secret-key',
|
||||
RXRESUME_EMAIL: 'resume@example.com',
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@ -22,6 +27,9 @@ describe.sequential('Settings API routes', () => {
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.defaultModel).toBe('test-model');
|
||||
expect(Array.isArray(body.data.searchTerms)).toBe(true);
|
||||
expect(body.data.rxresumeEmail).toBe('resume@example.com');
|
||||
expect(body.data.openrouterApiKeyHint).toBe('secr');
|
||||
expect(body.data.basicAuthActive).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid settings updates and persists overrides', async () => {
|
||||
@ -35,11 +43,32 @@ describe.sequential('Settings API routes', () => {
|
||||
const patchRes = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ searchTerms: ['engineer'] }),
|
||||
body: JSON.stringify({
|
||||
searchTerms: ['engineer'],
|
||||
rxresumeEmail: 'updated@example.com',
|
||||
openrouterApiKey: 'updated-secret',
|
||||
}),
|
||||
});
|
||||
const patchBody = await patchRes.json();
|
||||
expect(patchBody.success).toBe(true);
|
||||
expect(patchBody.data.searchTerms).toEqual(['engineer']);
|
||||
expect(patchBody.data.overrideSearchTerms).toEqual(['engineer']);
|
||||
expect(patchBody.data.rxresumeEmail).toBe('updated@example.com');
|
||||
expect(patchBody.data.openrouterApiKeyHint).toBe('upda');
|
||||
});
|
||||
|
||||
it('validates basic auth requirements', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enableBasicAuth: true,
|
||||
basicAuthUser: '',
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('Username is required');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,296 +1,195 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as settingsRepo from '../../repositories/settings.js';
|
||||
import { updateSettingsSchema } from '@shared/settings-schema.js';
|
||||
import * as settingsRepo from '@server/repositories/settings.js';
|
||||
import {
|
||||
applyEnvValue,
|
||||
normalizeEnvInput,
|
||||
} from '@server/services/envSettings.js';
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
loadResumeProfile,
|
||||
normalizeResumeProjectsSettings,
|
||||
resolveResumeProjectsSettings,
|
||||
} from '../../services/resumeProjects.js';
|
||||
import { listResumes } from '../../services/rxresume.js';
|
||||
} from '@server/services/resumeProjects.js';
|
||||
import { getProfile } from '@server/services/profile.js';
|
||||
import { getEffectiveSettings } from '@server/services/settings.js';
|
||||
import { listResumes } from '@server/services/rxresume.js';
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
/**
|
||||
* Helper to fetch all settings and their defaults
|
||||
*/
|
||||
async function getFullSettings() {
|
||||
const overrideModel = await settingsRepo.getSetting('model');
|
||||
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const model = overrideModel || defaultModel;
|
||||
|
||||
// Specific AI models
|
||||
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
|
||||
const modelScorer = overrideModelScorer || model;
|
||||
|
||||
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
|
||||
const modelTailoring = overrideModelTailoring || model;
|
||||
|
||||
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
|
||||
const modelProjectSelection = overrideModelProjectSelection || model;
|
||||
|
||||
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl');
|
||||
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
|
||||
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
|
||||
|
||||
const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl');
|
||||
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||
|
||||
const profile = await loadResumeProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||
|
||||
const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs');
|
||||
const defaultUkvisajobsMaxJobs = 50;
|
||||
const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null;
|
||||
const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs;
|
||||
|
||||
const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm');
|
||||
const defaultGradcrackerMaxJobsPerTerm = 50;
|
||||
const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null;
|
||||
const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm;
|
||||
|
||||
const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms');
|
||||
const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer';
|
||||
const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean);
|
||||
const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null;
|
||||
const searchTerms = overrideSearchTerms ?? defaultSearchTerms;
|
||||
|
||||
// JobSpy settings
|
||||
const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation');
|
||||
const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK';
|
||||
const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation;
|
||||
|
||||
const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted');
|
||||
const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10);
|
||||
const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null;
|
||||
const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted;
|
||||
|
||||
const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld');
|
||||
const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10);
|
||||
const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null;
|
||||
const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld;
|
||||
|
||||
const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed');
|
||||
const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK';
|
||||
const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed;
|
||||
|
||||
const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites');
|
||||
const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null;
|
||||
const jobspySites = overrideJobspySites ?? defaultJobspySites;
|
||||
|
||||
const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription');
|
||||
const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1';
|
||||
const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw
|
||||
? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1'
|
||||
: null;
|
||||
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
|
||||
|
||||
const rxResumeBaseResumeId = await settingsRepo.getSetting('rxResumeBaseResumeId');
|
||||
const hasRxResumeApiKey = !!process.env.RXRESUME_API_KEY;
|
||||
|
||||
return {
|
||||
model,
|
||||
defaultModel,
|
||||
overrideModel,
|
||||
modelScorer,
|
||||
overrideModelScorer,
|
||||
modelTailoring,
|
||||
overrideModelTailoring,
|
||||
modelProjectSelection,
|
||||
overrideModelProjectSelection,
|
||||
pipelineWebhookUrl,
|
||||
defaultPipelineWebhookUrl,
|
||||
overridePipelineWebhookUrl,
|
||||
jobCompleteWebhookUrl,
|
||||
defaultJobCompleteWebhookUrl,
|
||||
overrideJobCompleteWebhookUrl,
|
||||
...resumeProjectsData,
|
||||
ukvisajobsMaxJobs,
|
||||
defaultUkvisajobsMaxJobs,
|
||||
overrideUkvisajobsMaxJobs,
|
||||
gradcrackerMaxJobsPerTerm,
|
||||
defaultGradcrackerMaxJobsPerTerm,
|
||||
overrideGradcrackerMaxJobsPerTerm,
|
||||
searchTerms,
|
||||
defaultSearchTerms,
|
||||
overrideSearchTerms,
|
||||
jobspyLocation,
|
||||
defaultJobspyLocation,
|
||||
overrideJobspyLocation,
|
||||
jobspyResultsWanted,
|
||||
defaultJobspyResultsWanted,
|
||||
overrideJobspyResultsWanted,
|
||||
jobspyHoursOld,
|
||||
defaultJobspyHoursOld,
|
||||
overrideJobspyHoursOld,
|
||||
jobspyCountryIndeed,
|
||||
defaultJobspyCountryIndeed,
|
||||
overrideJobspyCountryIndeed,
|
||||
jobspySites,
|
||||
defaultJobspySites,
|
||||
overrideJobspySites,
|
||||
jobspyLinkedinFetchDescription,
|
||||
defaultJobspyLinkedinFetchDescription,
|
||||
overrideJobspyLinkedinFetchDescription,
|
||||
rxResumeBaseResumeId,
|
||||
hasRxResumeApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/settings - Get app settings (effective + defaults)
|
||||
*/
|
||||
settingsRouter.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await getFullSettings();
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
const data = await getEffectiveSettings();
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
const updateSettingsSchema = z.object({
|
||||
model: z.string().trim().min(1).max(200).nullable().optional(),
|
||||
modelScorer: z.string().trim().min(1).max(200).nullable().optional(),
|
||||
modelTailoring: z.string().trim().min(1).max(200).nullable().optional(),
|
||||
modelProjectSelection: z.string().trim().min(1).max(200).nullable().optional(),
|
||||
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
||||
jobCompleteWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
||||
resumeProjects: z.object({
|
||||
maxProjects: z.number().int().min(0).max(50),
|
||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
}).nullable().optional(),
|
||||
ukvisajobsMaxJobs: z.number().int().min(1).max(200).nullable().optional(),
|
||||
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(200).nullable().optional(),
|
||||
searchTerms: z.array(z.string().trim().min(1).max(200)).max(50).nullable().optional(),
|
||||
jobspyLocation: z.string().trim().min(1).max(100).nullable().optional(),
|
||||
jobspyResultsWanted: z.number().int().min(1).max(500).nullable().optional(),
|
||||
jobspyHoursOld: z.number().int().min(1).max(168).nullable().optional(),
|
||||
jobspyCountryIndeed: z.string().trim().min(1).max(100).nullable().optional(),
|
||||
jobspySites: z.array(z.string().trim().min(1).max(50)).max(10).nullable().optional(),
|
||||
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
|
||||
rxResumeBaseResumeId: z.string().trim().min(1).max(200).nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/settings - Update settings overrides
|
||||
*/
|
||||
settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = updateSettingsSchema.parse(req.body);
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if ('model' in input) {
|
||||
const model = input.model ?? null;
|
||||
await settingsRepo.setSetting('model', model);
|
||||
promises.push(settingsRepo.setSetting('model', input.model ?? null));
|
||||
}
|
||||
|
||||
if ('modelScorer' in input) {
|
||||
await settingsRepo.setSetting('modelScorer', input.modelScorer ?? null);
|
||||
promises.push(settingsRepo.setSetting('modelScorer', input.modelScorer ?? null));
|
||||
}
|
||||
if ('modelTailoring' in input) {
|
||||
await settingsRepo.setSetting('modelTailoring', input.modelTailoring ?? null);
|
||||
promises.push(settingsRepo.setSetting('modelTailoring', input.modelTailoring ?? null));
|
||||
}
|
||||
if ('modelProjectSelection' in input) {
|
||||
await settingsRepo.setSetting('modelProjectSelection', input.modelProjectSelection ?? null);
|
||||
promises.push(settingsRepo.setSetting('modelProjectSelection', input.modelProjectSelection ?? null));
|
||||
}
|
||||
|
||||
if ('pipelineWebhookUrl' in input) {
|
||||
const pipelineWebhookUrl = input.pipelineWebhookUrl ?? null;
|
||||
await settingsRepo.setSetting('pipelineWebhookUrl', pipelineWebhookUrl);
|
||||
promises.push(settingsRepo.setSetting('pipelineWebhookUrl', input.pipelineWebhookUrl ?? null));
|
||||
}
|
||||
|
||||
if ('jobCompleteWebhookUrl' in input) {
|
||||
const webhookUrl = input.jobCompleteWebhookUrl ?? null;
|
||||
await settingsRepo.setSetting('jobCompleteWebhookUrl', webhookUrl);
|
||||
promises.push(settingsRepo.setSetting('jobCompleteWebhookUrl', input.jobCompleteWebhookUrl ?? null));
|
||||
}
|
||||
|
||||
if ('resumeProjects' in input) {
|
||||
const resumeProjects = input.resumeProjects ?? null;
|
||||
|
||||
if (resumeProjects === null) {
|
||||
await settingsRepo.setSetting('resumeProjects', null);
|
||||
promises.push(settingsRepo.setSetting('resumeProjects', null));
|
||||
} else {
|
||||
const rawProfile = await loadResumeProfile();
|
||||
promises.push((async () => {
|
||||
const rawProfile = await getProfile();
|
||||
|
||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
||||
}
|
||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
||||
}
|
||||
|
||||
const profile = rawProfile as Record<string, unknown>;
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((p) => p.id));
|
||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||
await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized));
|
||||
const profile = rawProfile as Record<string, unknown>;
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((p) => p.id));
|
||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||
await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized));
|
||||
})());
|
||||
}
|
||||
}
|
||||
|
||||
if ('ukvisajobsMaxJobs' in input) {
|
||||
const ukvisajobsMaxJobs = input.ukvisajobsMaxJobs ?? null;
|
||||
await settingsRepo.setSetting('ukvisajobsMaxJobs', ukvisajobsMaxJobs !== null ? String(ukvisajobsMaxJobs) : null);
|
||||
const val = input.ukvisajobsMaxJobs ?? null;
|
||||
promises.push(settingsRepo.setSetting('ukvisajobsMaxJobs', val !== null ? String(val) : null));
|
||||
}
|
||||
|
||||
if ('gradcrackerMaxJobsPerTerm' in input) {
|
||||
const gradcrackerMaxJobsPerTerm = input.gradcrackerMaxJobsPerTerm ?? null;
|
||||
await settingsRepo.setSetting('gradcrackerMaxJobsPerTerm', gradcrackerMaxJobsPerTerm !== null ? String(gradcrackerMaxJobsPerTerm) : null);
|
||||
const val = input.gradcrackerMaxJobsPerTerm ?? null;
|
||||
promises.push(settingsRepo.setSetting('gradcrackerMaxJobsPerTerm', val !== null ? String(val) : null));
|
||||
}
|
||||
|
||||
if ('searchTerms' in input) {
|
||||
const searchTerms = input.searchTerms ?? null;
|
||||
await settingsRepo.setSetting('searchTerms', searchTerms !== null ? JSON.stringify(searchTerms) : null);
|
||||
const val = input.searchTerms ?? null;
|
||||
promises.push(settingsRepo.setSetting('searchTerms', val !== null ? JSON.stringify(val) : null));
|
||||
}
|
||||
|
||||
if ('jobspyLocation' in input) {
|
||||
const value = input.jobspyLocation ?? null;
|
||||
await settingsRepo.setSetting('jobspyLocation', value);
|
||||
promises.push(settingsRepo.setSetting('jobspyLocation', input.jobspyLocation ?? null));
|
||||
}
|
||||
|
||||
if ('jobspyResultsWanted' in input) {
|
||||
const value = input.jobspyResultsWanted ?? null;
|
||||
await settingsRepo.setSetting('jobspyResultsWanted', value !== null ? String(value) : null);
|
||||
const val = input.jobspyResultsWanted ?? null;
|
||||
promises.push(settingsRepo.setSetting('jobspyResultsWanted', val !== null ? String(val) : null));
|
||||
}
|
||||
|
||||
if ('jobspyHoursOld' in input) {
|
||||
const value = input.jobspyHoursOld ?? null;
|
||||
await settingsRepo.setSetting('jobspyHoursOld', value !== null ? String(value) : null);
|
||||
const val = input.jobspyHoursOld ?? null;
|
||||
promises.push(settingsRepo.setSetting('jobspyHoursOld', val !== null ? String(val) : null));
|
||||
}
|
||||
|
||||
if ('jobspyCountryIndeed' in input) {
|
||||
const value = input.jobspyCountryIndeed ?? null;
|
||||
await settingsRepo.setSetting('jobspyCountryIndeed', value);
|
||||
promises.push(settingsRepo.setSetting('jobspyCountryIndeed', input.jobspyCountryIndeed ?? null));
|
||||
}
|
||||
|
||||
if ('jobspySites' in input) {
|
||||
const value = input.jobspySites ?? null;
|
||||
await settingsRepo.setSetting('jobspySites', value !== null ? JSON.stringify(value) : null);
|
||||
const val = input.jobspySites ?? null;
|
||||
promises.push(settingsRepo.setSetting('jobspySites', val !== null ? JSON.stringify(val) : null));
|
||||
}
|
||||
|
||||
if ('jobspyLinkedinFetchDescription' in input) {
|
||||
const value = input.jobspyLinkedinFetchDescription ?? null;
|
||||
await settingsRepo.setSetting('jobspyLinkedinFetchDescription', value !== null ? (value ? '1' : '0') : null);
|
||||
const val = input.jobspyLinkedinFetchDescription ?? null;
|
||||
promises.push(settingsRepo.setSetting('jobspyLinkedinFetchDescription', val !== null ? (val ? '1' : '0') : null));
|
||||
}
|
||||
|
||||
if ('rxResumeBaseResumeId' in input) {
|
||||
await settingsRepo.setSetting('rxResumeBaseResumeId', input.rxResumeBaseResumeId ?? null);
|
||||
if ('showSponsorInfo' in input) {
|
||||
const val = input.showSponsorInfo ?? null;
|
||||
promises.push(settingsRepo.setSetting('showSponsorInfo', val !== null ? (val ? '1' : '0') : null));
|
||||
}
|
||||
|
||||
const data = await getFullSettings();
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
if ('openrouterApiKey' in input) {
|
||||
const value = normalizeEnvInput(input.openrouterApiKey);
|
||||
promises.push(settingsRepo.setSetting('openrouterApiKey', value).then(() => {
|
||||
applyEnvValue('OPENROUTER_API_KEY', value);
|
||||
}));
|
||||
}
|
||||
|
||||
if ('rxresumeEmail' in input) {
|
||||
const value = normalizeEnvInput(input.rxresumeEmail);
|
||||
promises.push(settingsRepo.setSetting('rxresumeEmail', value).then(() => {
|
||||
applyEnvValue('RXRESUME_EMAIL', value);
|
||||
}));
|
||||
}
|
||||
|
||||
if ('rxresumePassword' in input) {
|
||||
const value = normalizeEnvInput(input.rxresumePassword);
|
||||
promises.push(settingsRepo.setSetting('rxresumePassword', value).then(() => {
|
||||
applyEnvValue('RXRESUME_PASSWORD', value);
|
||||
}));
|
||||
}
|
||||
|
||||
if ('basicAuthUser' in input) {
|
||||
const value = normalizeEnvInput(input.basicAuthUser);
|
||||
promises.push(settingsRepo.setSetting('basicAuthUser', value).then(() => {
|
||||
applyEnvValue('BASIC_AUTH_USER', value);
|
||||
}));
|
||||
}
|
||||
|
||||
if ('basicAuthPassword' in input) {
|
||||
const value = normalizeEnvInput(input.basicAuthPassword);
|
||||
promises.push(settingsRepo.setSetting('basicAuthPassword', value).then(() => {
|
||||
applyEnvValue('BASIC_AUTH_PASSWORD', value);
|
||||
}));
|
||||
}
|
||||
|
||||
if ('ukvisajobsEmail' in input) {
|
||||
const value = normalizeEnvInput(input.ukvisajobsEmail);
|
||||
promises.push(settingsRepo.setSetting('ukvisajobsEmail', value).then(() => {
|
||||
applyEnvValue('UKVISAJOBS_EMAIL', value);
|
||||
}));
|
||||
}
|
||||
|
||||
if ('ukvisajobsPassword' in input) {
|
||||
const value = normalizeEnvInput(input.ukvisajobsPassword);
|
||||
promises.push(settingsRepo.setSetting('ukvisajobsPassword', value).then(() => {
|
||||
applyEnvValue('UKVISAJOBS_PASSWORD', value);
|
||||
}));
|
||||
}
|
||||
|
||||
if ('webhookSecret' in input) {
|
||||
const value = normalizeEnvInput(input.webhookSecret);
|
||||
promises.push(settingsRepo.setSetting('webhookSecret', value).then(() => {
|
||||
applyEnvValue('WEBHOOK_SECRET', value);
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const data = await getEffectiveSettings();
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
// PATCH usually returns 500 for unknown, but let's stick to what was there (400?)
|
||||
// Wait, the file said 400? Let's verify line 608.
|
||||
res.status(400).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@ -28,7 +28,7 @@ vi.mock('../../pipeline/index.js', () => {
|
||||
getPipelineStatus: vi.fn(() => ({ isRunning: false })),
|
||||
subscribeToProgress: vi.fn((listener: (data: unknown) => void) => {
|
||||
listener(progress);
|
||||
return () => {};
|
||||
return () => { };
|
||||
}),
|
||||
};
|
||||
});
|
||||
@ -54,6 +54,13 @@ vi.mock('../../services/visa-sponsors/index.js', () => ({
|
||||
searchSponsors: vi.fn(),
|
||||
getOrganizationDetails: vi.fn(),
|
||||
downloadLatestCsv: vi.fn(),
|
||||
calculateSponsorMatchSummary: vi.fn((results) => {
|
||||
if (!results || results.length === 0) return { sponsorMatchScore: 0, sponsorMatchNames: null };
|
||||
return {
|
||||
sponsorMatchScore: results[0].score,
|
||||
sponsorMatchNames: JSON.stringify(results.map((r: any) => r.sponsor.organisationName))
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
@ -79,11 +86,14 @@ export async function startServer(options?: {
|
||||
};
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const { applyStoredEnvOverrides } = await import('../../services/envSettings.js');
|
||||
const { createApp } = await import('../../app.js');
|
||||
const { closeDb } = await import('../../db/index.js');
|
||||
const { getPipelineStatus } = await import('../../pipeline/index.js');
|
||||
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false });
|
||||
|
||||
await applyStoredEnvOverrides();
|
||||
|
||||
const app = createApp();
|
||||
const server = app.listen(0);
|
||||
await new Promise<void>((resolve) => server.once('listening', () => resolve()));
|
||||
|
||||
@ -13,12 +13,19 @@ import { getDataDir } from './config/dataDir.js';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function createBasicAuthGuard() {
|
||||
const BASIC_AUTH_USER = process.env.BASIC_AUTH_USER || '';
|
||||
const BASIC_AUTH_PASSWORD = process.env.BASIC_AUTH_PASSWORD || '';
|
||||
const basicAuthEnabled = BASIC_AUTH_USER.length > 0 && BASIC_AUTH_PASSWORD.length > 0;
|
||||
function getAuthConfig() {
|
||||
const user = process.env.BASIC_AUTH_USER || '';
|
||||
const pass = process.env.BASIC_AUTH_PASSWORD || '';
|
||||
return {
|
||||
user,
|
||||
pass,
|
||||
enabled: user.length > 0 && pass.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function isAuthorized(req: express.Request): boolean {
|
||||
if (!basicAuthEnabled) return false;
|
||||
const { user: authUser, pass: authPass, enabled } = getAuthConfig();
|
||||
if (!enabled) return false;
|
||||
const authHeader = req.headers.authorization || '';
|
||||
if (!authHeader.startsWith('Basic ')) return false;
|
||||
const encoded = authHeader.slice('Basic '.length).trim();
|
||||
@ -32,7 +39,7 @@ function createBasicAuthGuard() {
|
||||
if (separatorIndex === -1) return false;
|
||||
const user = decoded.slice(0, separatorIndex);
|
||||
const pass = decoded.slice(separatorIndex + 1);
|
||||
return user === BASIC_AUTH_USER && pass === BASIC_AUTH_PASSWORD;
|
||||
return user === authUser && pass === authPass;
|
||||
}
|
||||
|
||||
function isPublicReadOnlyRoute(method: string, path: string): boolean {
|
||||
@ -48,7 +55,8 @@ function createBasicAuthGuard() {
|
||||
}
|
||||
|
||||
const middleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (!basicAuthEnabled || !requiresAuth(req.method, req.path)) return next();
|
||||
const { enabled } = getAuthConfig();
|
||||
if (!enabled || !requiresAuth(req.method, req.path)) return next();
|
||||
if (isAuthorized(req)) return next();
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="Job Ops"');
|
||||
res.status(401).send('Authentication required');
|
||||
@ -57,7 +65,7 @@ function createBasicAuthGuard() {
|
||||
return {
|
||||
middleware,
|
||||
isAuthorized,
|
||||
basicAuthEnabled,
|
||||
basicAuthEnabled: getAuthConfig().enabled,
|
||||
};
|
||||
}
|
||||
|
||||
@ -66,7 +74,7 @@ export function createApp() {
|
||||
const authGuard = createBasicAuthGuard();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
// Logging middleware
|
||||
app.use((req, res, next) => {
|
||||
|
||||
@ -132,6 +132,10 @@ const migrations = [
|
||||
`ALTER TABLE jobs ADD COLUMN tailored_headline TEXT`,
|
||||
`ALTER TABLE jobs ADD COLUMN tailored_skills TEXT`,
|
||||
|
||||
// Add sponsor match columns for visa sponsor matching feature
|
||||
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
|
||||
`ALTER TABLE jobs ADD COLUMN sponsor_match_names TEXT`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started_at ON pipeline_runs(started_at)`,
|
||||
|
||||
@ -64,6 +64,8 @@ export const jobs = sqliteTable('jobs', {
|
||||
selectedProjectIds: text('selected_project_ids'),
|
||||
pdfPath: text('pdf_path'),
|
||||
notionPageId: text('notion_page_id'),
|
||||
sponsorMatchScore: real('sponsor_match_score'),
|
||||
sponsorMatchNames: text('sponsor_match_names'),
|
||||
|
||||
// Timestamps
|
||||
discoveredAt: text('discovered_at').notNull().default(sql`(datetime('now'))`),
|
||||
|
||||
@ -4,14 +4,18 @@
|
||||
|
||||
import './config/env.js';
|
||||
import { createApp } from './app.js';
|
||||
import { applyStoredEnvOverrides } from './services/envSettings.js';
|
||||
import { initialize as initializeVisaSponsors } from './services/visa-sponsors/index.js';
|
||||
|
||||
const app = createApp();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
async function startServer() {
|
||||
await applyStoredEnvOverrides();
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`
|
||||
const app = createApp();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ 🚀 Job Ops Orchestrator ║
|
||||
@ -25,10 +29,13 @@ app.listen(PORT, async () => {
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
// Initialize visa sponsors service (downloads data if needed, starts scheduler)
|
||||
try {
|
||||
await initializeVisaSponsors();
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to initialize visa sponsors service:', error);
|
||||
}
|
||||
});
|
||||
// Initialize visa sponsors service (downloads data if needed, starts scheduler)
|
||||
try {
|
||||
await initializeVisaSponsors();
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to initialize visa sponsors service:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void startServer();
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
* 3. Leave all jobs in "discovered" for manual processing
|
||||
*/
|
||||
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { runCrawler } from '../services/crawler.js';
|
||||
@ -16,16 +15,17 @@ import { runUkVisaJobs } from '../services/ukvisajobs.js';
|
||||
import { scoreJobSuitability } from '../services/scorer.js';
|
||||
import { generateTailoring } from '../services/summary.js';
|
||||
import { generatePdf } from '../services/pdf.js';
|
||||
import { getProfile } from '../services/profile.js';
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import { pickProjectIdsForJob } from '../services/projectSelection.js';
|
||||
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from '../services/resumeProjects.js';
|
||||
import * as jobsRepo from '../repositories/jobs.js';
|
||||
import * as pipelineRepo from '../repositories/pipeline.js';
|
||||
import * as settingsRepo from '../repositories/settings.js';
|
||||
import * as visaSponsors from '../services/visa-sponsors/index.js';
|
||||
import { progressHelpers, resetProgress, updateProgress } from './progress.js';
|
||||
import type { CreateJobInput, Job, JobSource, PipelineConfig } from '../../shared/types.js';
|
||||
import { getDataDir } from '../config/dataDir.js';
|
||||
import { getResume } from '../services/rxresume.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_PROFILE_PATH = join(__dirname, '../../../../resume-generator/base.json');
|
||||
@ -112,7 +112,10 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
try {
|
||||
// Step 1: Load profile
|
||||
console.log('\n📋 Loading profile...');
|
||||
const profile = await loadProfile(mergedConfig.profilePath);
|
||||
const profile = await getProfile(mergedConfig.profilePath).catch((error) => {
|
||||
console.warn('⚠️ Failed to load profile for scoring, using empty profile:', error);
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
|
||||
// Step 2: Run crawler
|
||||
console.log('\n🕷️ Running crawler...');
|
||||
@ -120,8 +123,11 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
const discoveredJobs: CreateJobInput[] = [];
|
||||
const sourceErrors: string[] = [];
|
||||
|
||||
// Read all settings at once to avoid sequential DB calls
|
||||
const settings = await settingsRepo.getAllSettings();
|
||||
|
||||
// Read search terms setting
|
||||
const searchTermsSetting = await settingsRepo.getSetting('searchTerms');
|
||||
const searchTermsSetting = settings.searchTerms;
|
||||
let searchTerms: string[] = [];
|
||||
|
||||
if (searchTermsSetting) {
|
||||
@ -138,7 +144,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
);
|
||||
|
||||
// Apply setting override for JobSpy sites
|
||||
const jobspySitesSettingRaw = await settingsRepo.getSetting('jobspySites');
|
||||
const jobspySitesSettingRaw = settings.jobspySites;
|
||||
if (jobspySitesSettingRaw) {
|
||||
try {
|
||||
const allowed = JSON.parse(jobspySitesSettingRaw);
|
||||
@ -156,11 +162,11 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
detail: `JobSpy: scraping ${jobSpySites.join(', ')}...`,
|
||||
});
|
||||
|
||||
const jobspyLocationSetting = await settingsRepo.getSetting('jobspyLocation');
|
||||
const jobspyResultsWantedSetting = await settingsRepo.getSetting('jobspyResultsWanted');
|
||||
const jobspyHoursOldSetting = await settingsRepo.getSetting('jobspyHoursOld');
|
||||
const jobspyCountryIndeedSetting = await settingsRepo.getSetting('jobspyCountryIndeed');
|
||||
const jobspyLinkedinFetchDescriptionSetting = await settingsRepo.getSetting('jobspyLinkedinFetchDescription');
|
||||
const jobspyLocationSetting = settings.jobspyLocation;
|
||||
const jobspyResultsWantedSetting = settings.jobspyResultsWanted;
|
||||
const jobspyHoursOldSetting = settings.jobspyHoursOld;
|
||||
const jobspyCountryIndeedSetting = settings.jobspyCountryIndeed;
|
||||
const jobspyLinkedinFetchDescriptionSetting = settings.jobspyLinkedinFetchDescription;
|
||||
|
||||
const jobSpyResult = await runJobSpy({
|
||||
sites: jobSpySites,
|
||||
@ -169,7 +175,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
resultsWanted: jobspyResultsWantedSetting ? parseInt(jobspyResultsWantedSetting, 10) : undefined,
|
||||
hoursOld: jobspyHoursOldSetting ? parseInt(jobspyHoursOldSetting, 10) : undefined,
|
||||
countryIndeed: jobspyCountryIndeedSetting ?? undefined,
|
||||
linkedinFetchDescription: jobspyLinkedinFetchDescriptionSetting !== null ? jobspyLinkedinFetchDescriptionSetting === '1' : undefined,
|
||||
linkedinFetchDescription: jobspyLinkedinFetchDescriptionSetting !== null && jobspyLinkedinFetchDescriptionSetting !== undefined ? jobspyLinkedinFetchDescriptionSetting === '1' : undefined,
|
||||
});
|
||||
if (!jobSpyResult.success) {
|
||||
sourceErrors.push(`jobspy: ${jobSpyResult.error ?? 'unknown error'}`);
|
||||
@ -188,7 +194,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
// Pass existing URLs to avoid clicking "Apply" on jobs we already have
|
||||
const existingJobUrls = await jobsRepo.getAllJobUrls();
|
||||
|
||||
const gradcrackerMaxJobsSetting = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm');
|
||||
const gradcrackerMaxJobsSetting = settings.gradcrackerMaxJobsPerTerm;
|
||||
const gradcrackerMaxJobs = gradcrackerMaxJobsSetting ? parseInt(gradcrackerMaxJobsSetting, 10) : 50;
|
||||
|
||||
const crawlerResult = await runCrawler({
|
||||
@ -223,7 +229,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
});
|
||||
|
||||
// Read max jobs setting from database (default to 50 if not set)
|
||||
const ukvisajobsMaxJobsSetting = await settingsRepo.getSetting('ukvisajobsMaxJobs');
|
||||
const ukvisajobsMaxJobsSetting = settings.ukvisajobsMaxJobs;
|
||||
const ukvisajobsMaxJobs = ukvisajobsMaxJobsSetting ? parseInt(ukvisajobsMaxJobsSetting, 10) : 50;
|
||||
|
||||
const ukVisaResult = await runUkVisaJobs({
|
||||
@ -294,10 +300,27 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
suitabilityReason: reason,
|
||||
});
|
||||
|
||||
// Update score in database
|
||||
// Calculate sponsor match score using fuzzy search
|
||||
let sponsorMatchScore = 0;
|
||||
let sponsorMatchNames: string | undefined;
|
||||
|
||||
if (job.employer) {
|
||||
const sponsorResults = visaSponsors.searchSponsors(job.employer, {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
|
||||
const summary = visaSponsors.calculateSponsorMatchSummary(sponsorResults);
|
||||
sponsorMatchScore = summary.sponsorMatchScore;
|
||||
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
||||
}
|
||||
|
||||
// Update score and sponsor match in database
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
sponsorMatchScore,
|
||||
sponsorMatchNames,
|
||||
});
|
||||
}
|
||||
|
||||
@ -329,7 +352,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
|
||||
// Process job (Generate Summary + PDF)
|
||||
// We catch errors here to ensure one failure doesn't stop the whole batch
|
||||
const result = await processJob(job.id);
|
||||
const result = await processJob(job.id, { profilePath: mergedConfig.profilePath });
|
||||
|
||||
if (result.success) {
|
||||
processedCount++;
|
||||
@ -396,12 +419,17 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export type ProcessJobOptions = {
|
||||
force?: boolean;
|
||||
profilePath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Step 1: Generate AI summary and suggest projects.
|
||||
*/
|
||||
export async function summarizeJob(
|
||||
jobId: string,
|
||||
options?: { force?: boolean }
|
||||
options?: ProcessJobOptions
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@ -412,7 +440,7 @@ export async function summarizeJob(
|
||||
const job = await jobsRepo.getJobById(jobId);
|
||||
if (!job) return { success: false, error: 'Job not found' };
|
||||
|
||||
const profile = await loadProfile(DEFAULT_PROFILE_PATH);
|
||||
const profile = await getProfile(options?.profilePath);
|
||||
|
||||
// 1. Generate Summary & Tailoring
|
||||
let tailoredSummary = job.tailoredSummary;
|
||||
@ -473,7 +501,8 @@ export async function summarizeJob(
|
||||
* Step 2: Generate PDF using current summary and project selection.
|
||||
*/
|
||||
export async function generateFinalPdf(
|
||||
jobId: string
|
||||
jobId: string,
|
||||
options?: ProcessJobOptions
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@ -495,7 +524,7 @@ export async function generateFinalPdf(
|
||||
skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : []
|
||||
},
|
||||
job.jobDescription || '',
|
||||
DEFAULT_PROFILE_PATH,
|
||||
options?.profilePath || DEFAULT_PROFILE_PATH,
|
||||
job.selectedProjectIds
|
||||
);
|
||||
|
||||
@ -522,7 +551,7 @@ export async function generateFinalPdf(
|
||||
*/
|
||||
export async function processJob(
|
||||
jobId: string,
|
||||
options?: { force?: boolean }
|
||||
options?: ProcessJobOptions
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@ -533,7 +562,7 @@ export async function processJob(
|
||||
if (!sumResult.success) return sumResult;
|
||||
|
||||
// Step 2: Generate PDF
|
||||
const pdfResult = await generateFinalPdf(jobId);
|
||||
const pdfResult = await generateFinalPdf(jobId, options);
|
||||
return pdfResult;
|
||||
|
||||
} catch (error) {
|
||||
@ -548,28 +577,3 @@ export async function processJob(
|
||||
export function getPipelineStatus(): { isRunning: boolean } {
|
||||
return { isRunning: isPipelineRunning };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the user profile from JSON file.
|
||||
*/
|
||||
async function loadProfile(profilePath: string): Promise<Record<string, unknown>> {
|
||||
const rxResumeBaseResumeId = await settingsRepo.getSetting('rxResumeBaseResumeId');
|
||||
if (rxResumeBaseResumeId) {
|
||||
try {
|
||||
const resume = await getResume(rxResumeBaseResumeId);
|
||||
return resume.data as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load resume from Reactive Resume (${rxResumeBaseResumeId}):`, error);
|
||||
throw new Error(`Failed to load profile from Reactive Resume (ID: ${rxResumeBaseResumeId}). Please check your API key and connection.`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(profilePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
const message = `No local profile found at ${profilePath} and no Reactive Resume base ID is configured. Reactive Resume integration is required for tailoring.`;
|
||||
console.error(`❌ ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
418
orchestrator/src/server/pipeline/sponsor-matching.test.ts
Normal file
418
orchestrator/src/server/pipeline/sponsor-matching.test.ts
Normal file
@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Tests for sponsor match calculation logic in the pipeline orchestrator.
|
||||
*
|
||||
* These tests verify that during job scoring, the sponsor matching functionality
|
||||
* correctly calculates and stores sponsor match scores and names.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Job } from '../../shared/types.js';
|
||||
|
||||
// Mock the visa-sponsors module
|
||||
vi.mock('../services/visa-sponsors/index.js', () => ({
|
||||
searchSponsors: vi.fn(),
|
||||
calculateSponsorMatchSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the scorer module
|
||||
vi.mock('../services/scorer.js', () => ({
|
||||
scoreJobSuitability: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the jobs repository
|
||||
vi.mock('../repositories/jobs.js', () => ({
|
||||
updateJob: vi.fn(),
|
||||
getUnscoredDiscoveredJobs: vi.fn(),
|
||||
getJobById: vi.fn(),
|
||||
bulkCreateJobs: vi.fn(),
|
||||
getAllJobUrls: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock other dependencies to prevent side effects
|
||||
vi.mock('../repositories/pipeline.js', () => ({
|
||||
createPipelineRun: vi.fn(() => ({ id: 'test-run-id' })),
|
||||
updatePipelineRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../repositories/settings.js', () => ({
|
||||
getSetting: vi.fn().mockResolvedValue(null),
|
||||
getAllSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock('../services/crawler.js', () => ({
|
||||
runCrawler: vi.fn(() => ({ success: true, jobs: [] })),
|
||||
}));
|
||||
|
||||
vi.mock('../services/jobspy.js', () => ({
|
||||
runJobSpy: vi.fn(() => ({ success: true, jobs: [] })),
|
||||
}));
|
||||
|
||||
vi.mock('../services/ukvisajobs.js', () => ({
|
||||
runUkVisaJobs: vi.fn(() => ({ success: true, jobs: [] })),
|
||||
}));
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Mock job template
|
||||
const createMockJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
id: 'test-job-1',
|
||||
source: 'gradcracker',
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: 'Software Engineer',
|
||||
employer: 'Acme Corporation Ltd',
|
||||
employerUrl: null,
|
||||
jobUrl: 'http://test.com/job',
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: 'London',
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: 'Looking for a TypeScript developer.',
|
||||
status: 'discovered',
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: now,
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Sponsor Match Calculation', () => {
|
||||
let searchSponsors: ReturnType<typeof vi.fn>;
|
||||
let calculateSponsorMatchSummary: ReturnType<typeof vi.fn>;
|
||||
let scoreJobSuitability: ReturnType<typeof vi.fn>;
|
||||
let updateJob: ReturnType<typeof vi.fn>;
|
||||
let getUnscoredDiscoveredJobs: ReturnType<typeof vi.fn>;
|
||||
let bulkCreateJobs: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Get mocked functions
|
||||
const visaSponsors = await import('../services/visa-sponsors/index.js');
|
||||
const scorer = await import('../services/scorer.js');
|
||||
const jobsRepo = await import('../repositories/jobs.js');
|
||||
|
||||
searchSponsors = visaSponsors.searchSponsors as ReturnType<typeof vi.fn>;
|
||||
calculateSponsorMatchSummary = visaSponsors.calculateSponsorMatchSummary as ReturnType<typeof vi.fn>;
|
||||
scoreJobSuitability = scorer.scoreJobSuitability as ReturnType<typeof vi.fn>;
|
||||
updateJob = jobsRepo.updateJob as ReturnType<typeof vi.fn>;
|
||||
getUnscoredDiscoveredJobs = jobsRepo.getUnscoredDiscoveredJobs as ReturnType<typeof vi.fn>;
|
||||
bulkCreateJobs = jobsRepo.bulkCreateJobs as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Default mock implementations
|
||||
scoreJobSuitability.mockResolvedValue({ score: 75, reason: 'Good match' });
|
||||
bulkCreateJobs.mockResolvedValue({ created: 0, skipped: 0 });
|
||||
updateJob.mockResolvedValue(undefined);
|
||||
|
||||
calculateSponsorMatchSummary.mockImplementation((results: any[]) => {
|
||||
if (results.length === 0) return { sponsorMatchScore: 0, sponsorMatchNames: null };
|
||||
const topScore = results[0].score;
|
||||
const perfectMatches = results.filter((r: any) => r.score === 100);
|
||||
const matchesToReport = perfectMatches.length >= 2 ? perfectMatches.slice(0, 2) : [results[0]];
|
||||
return {
|
||||
sponsorMatchScore: topScore,
|
||||
sponsorMatchNames: JSON.stringify(matchesToReport.map((r: any) => r.sponsor.organisationName)),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('searchSponsors integration', () => {
|
||||
it('should calculate sponsor match score when employer matches a sponsor', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Acme Corporation Ltd' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning a match
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'ACME CORPORATION LIMITED' },
|
||||
score: 85,
|
||||
matchedName: 'acme corporation',
|
||||
},
|
||||
]);
|
||||
|
||||
// Import and run pipeline
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Verify searchSponsors was called with correct parameters
|
||||
expect(searchSponsors).toHaveBeenCalledWith('Acme Corporation Ltd', {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
|
||||
// Verify updateJob was called with sponsor match data
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
suitabilityScore: 75,
|
||||
suitabilityReason: 'Good match',
|
||||
sponsorMatchScore: 85,
|
||||
sponsorMatchNames: JSON.stringify(['ACME CORPORATION LIMITED']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle 100% perfect matches correctly', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Microsoft UK' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning perfect matches
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'MICROSOFT UK LIMITED' },
|
||||
score: 100,
|
||||
matchedName: 'microsoft uk',
|
||||
},
|
||||
{
|
||||
sponsor: { organisationName: 'MICROSOFT UK LTD' },
|
||||
score: 100,
|
||||
matchedName: 'microsoft uk',
|
||||
},
|
||||
{
|
||||
sponsor: { organisationName: 'MICROSOFT LIMITED' },
|
||||
score: 80,
|
||||
matchedName: 'microsoft',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Should include up to 2 perfect matches
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 100,
|
||||
sponsorMatchNames: JSON.stringify([
|
||||
'MICROSOFT UK LIMITED',
|
||||
'MICROSOFT UK LTD',
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should report single top match when no perfect matches exist', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Tech Corp' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning partial matches only
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'TECH CORPORATION' },
|
||||
score: 75,
|
||||
matchedName: 'tech corporation',
|
||||
},
|
||||
{
|
||||
sponsor: { organisationName: 'TECHNO CORP' },
|
||||
score: 60,
|
||||
matchedName: 'techno corp',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Should only include the top match since none are 100%
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 75,
|
||||
sponsorMatchNames: JSON.stringify(['TECH CORPORATION']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set sponsor match when no matches found', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Unknown Company XYZ' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning no matches
|
||||
searchSponsors.mockReturnValue([]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// sponsorMatchScore should be 0 (not set) and sponsorMatchNames undefined
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
suitabilityScore: 75,
|
||||
suitabilityReason: 'Good match',
|
||||
})
|
||||
);
|
||||
|
||||
// Verify that sponsorMatchScore is 0 and sponsorMatchNames is not included
|
||||
// when there are no matches
|
||||
const updateCall = updateJob.mock.calls[0][1];
|
||||
expect(updateCall.sponsorMatchScore).toBe(0);
|
||||
expect(updateCall.sponsorMatchNames).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip sponsor matching when job has no employer', async () => {
|
||||
const mockJob = createMockJob({ employer: null as unknown as string });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// searchSponsors should not be called
|
||||
expect(searchSponsors).not.toHaveBeenCalled();
|
||||
|
||||
// updateJob should still be called but without sponsor data
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
suitabilityScore: 75,
|
||||
suitabilityReason: 'Good match',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip sponsor matching when job has empty employer string', async () => {
|
||||
const mockJob = createMockJob({ employer: '' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// searchSponsors should not be called for empty string
|
||||
expect(searchSponsors).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sponsor match edge cases', () => {
|
||||
it('should use correct limit and minScore options', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Test Company' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
searchSponsors.mockReturnValue([]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
expect(searchSponsors).toHaveBeenCalledWith('Test Company', {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single 100% match correctly', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Google UK' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'GOOGLE UK LIMITED' },
|
||||
score: 100,
|
||||
matchedName: 'google uk',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Single perfect match should be reported
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 100,
|
||||
sponsorMatchNames: JSON.stringify(['GOOGLE UK LIMITED']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should process multiple jobs with different sponsor matches', async () => {
|
||||
const mockJob1 = createMockJob({
|
||||
id: 'job-1',
|
||||
employer: 'Amazon UK',
|
||||
});
|
||||
const mockJob2 = createMockJob({
|
||||
id: 'job-2',
|
||||
employer: 'Meta Platforms',
|
||||
});
|
||||
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob1, mockJob2]);
|
||||
|
||||
// Different results for each employer
|
||||
searchSponsors
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
sponsor: { organisationName: 'AMAZON UK SERVICES LTD' },
|
||||
score: 90,
|
||||
matchedName: 'amazon uk',
|
||||
},
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
sponsor: { organisationName: 'META PLATFORMS IRELAND LIMITED' },
|
||||
score: 80,
|
||||
matchedName: 'meta platforms',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Verify both jobs were processed with different sponsor data
|
||||
expect(updateJob).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 90,
|
||||
sponsorMatchNames: JSON.stringify(['AMAZON UK SERVICES LTD']),
|
||||
})
|
||||
);
|
||||
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'job-2',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 80,
|
||||
sponsorMatchNames: JSON.stringify(['META PLATFORMS IRELAND LIMITED']),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -16,7 +16,7 @@ export async function getAllJobs(statuses?: JobStatus[]): Promise<Job[]> {
|
||||
const query = statuses && statuses.length > 0
|
||||
? db.select().from(jobs).where(inArray(jobs.status, statuses)).orderBy(desc(jobs.discoveredAt))
|
||||
: db.select().from(jobs).orderBy(desc(jobs.discoveredAt));
|
||||
|
||||
|
||||
const rows = await query;
|
||||
return rows.map(mapRowToJob);
|
||||
}
|
||||
@ -54,10 +54,10 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
||||
await db.insert(jobs).values({
|
||||
id,
|
||||
source: input.source,
|
||||
@ -105,7 +105,7 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
|
||||
return (await getJobById(id))!;
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
|
||||
*/
|
||||
export async function updateJob(id: string, input: UpdateJobInput): Promise<Job | null> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
||||
await db.update(jobs)
|
||||
.set({
|
||||
...input,
|
||||
@ -123,7 +123,7 @@ export async function updateJob(id: string, input: UpdateJobInput): Promise<Job
|
||||
...(input.status === 'applied' && !input.appliedAt ? { appliedAt: now } : {}),
|
||||
})
|
||||
.where(eq(jobs.id, id));
|
||||
|
||||
|
||||
return getJobById(id);
|
||||
}
|
||||
|
||||
@ -133,18 +133,18 @@ export async function updateJob(id: string, input: UpdateJobInput): Promise<Job
|
||||
export async function bulkCreateJobs(inputs: CreateJobInput[]): Promise<{ created: number; skipped: number }> {
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
|
||||
for (const input of inputs) {
|
||||
const existing = await getJobByUrl(input.jobUrl);
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
await createJob(input);
|
||||
created++;
|
||||
}
|
||||
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
@ -159,7 +159,7 @@ export async function getJobStats(): Promise<Record<JobStatus, number>> {
|
||||
})
|
||||
.from(jobs)
|
||||
.groupBy(jobs.status);
|
||||
|
||||
|
||||
const stats: Record<JobStatus, number> = {
|
||||
discovered: 0,
|
||||
processing: 0,
|
||||
@ -168,11 +168,11 @@ export async function getJobStats(): Promise<Record<JobStatus, number>> {
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
};
|
||||
|
||||
|
||||
for (const row of result) {
|
||||
stats[row.status as JobStatus] = row.count;
|
||||
}
|
||||
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@ -191,7 +191,7 @@ export async function getJobsForProcessing(limit: number = 10): Promise<Job[]> {
|
||||
)
|
||||
.orderBy(desc(jobs.discoveredAt))
|
||||
.limit(limit);
|
||||
|
||||
|
||||
return rows.map(mapRowToJob);
|
||||
}
|
||||
|
||||
@ -246,6 +246,8 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
||||
selectedProjectIds: row.selectedProjectIds ?? null,
|
||||
pdfPath: row.pdfPath,
|
||||
notionPageId: row.notionPageId,
|
||||
sponsorMatchScore: row.sponsorMatchScore ?? null,
|
||||
sponsorMatchNames: row.sponsorMatchNames ?? null,
|
||||
jobType: row.jobType ?? null,
|
||||
salarySource: row.salarySource ?? null,
|
||||
salaryInterval: row.salaryInterval ?? null,
|
||||
|
||||
@ -23,13 +23,29 @@ export type SettingKey = 'model'
|
||||
| 'jobspyCountryIndeed'
|
||||
| 'jobspySites'
|
||||
| 'jobspyLinkedinFetchDescription'
|
||||
| 'rxResumeBaseResumeId'
|
||||
| 'showSponsorInfo'
|
||||
| 'openrouterApiKey'
|
||||
| 'rxresumeEmail'
|
||||
| 'rxresumePassword'
|
||||
| 'basicAuthUser'
|
||||
| 'basicAuthPassword'
|
||||
| 'ukvisajobsEmail'
|
||||
| 'ukvisajobsPassword'
|
||||
| 'webhookSecret'
|
||||
|
||||
export async function getSetting(key: SettingKey): Promise<string | null> {
|
||||
const [row] = await db.select().from(settings).where(eq(settings.key, key))
|
||||
return row?.value ?? null
|
||||
}
|
||||
|
||||
export async function getAllSettings(): Promise<Partial<Record<SettingKey, string>>> {
|
||||
const rows = await db.select().from(settings)
|
||||
return rows.reduce((acc, row) => {
|
||||
acc[row.key as SettingKey] = row.value
|
||||
return acc
|
||||
}, {} as Partial<Record<SettingKey, string>>)
|
||||
}
|
||||
|
||||
export async function setSetting(key: SettingKey, value: string | null): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
|
||||
@ -38,6 +38,8 @@ const mockJob: Job = {
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
@ -103,15 +105,15 @@ describe('AI Service Resilience', () => {
|
||||
|
||||
it('should fallback to mock scoring if API Key is missing', async () => {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
|
||||
|
||||
// Should NOT call fetch
|
||||
const result = await scoreJobSuitability(mockJob, mockProfile);
|
||||
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
// Mock score logic gives 50 + points for keywords.
|
||||
// 'TypeScript' and 'React' are in JD (5+5) -> 60?
|
||||
// "Senior" is bad keyword (-10)? -> 50?
|
||||
// Let's just check it didn't crash and returned a number
|
||||
// Let's just check it didn't crash and returned a number
|
||||
expect(typeof result.score).toBe('number');
|
||||
expect(result.reason).toContain('keyword matching');
|
||||
});
|
||||
@ -124,7 +126,7 @@ describe('AI Service Resilience', () => {
|
||||
} as any);
|
||||
|
||||
// Spy on console.error to keep test output clean
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
||||
|
||||
const result = await scoreJobSuitability(mockJob, mockProfile);
|
||||
|
||||
@ -134,22 +136,22 @@ describe('AI Service Resilience', () => {
|
||||
});
|
||||
|
||||
it('should handle Malformed/Invalid JSON in API response', async () => {
|
||||
const mockResponse = {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'This is not JSON at all, just text.' } }]
|
||||
})
|
||||
};
|
||||
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => { });
|
||||
|
||||
const result = await scoreJobSuitability(mockJob, mockProfile);
|
||||
|
||||
|
||||
expect(result.reason).toContain('keyword matching'); // Fell back
|
||||
});
|
||||
|
||||
it('should extract JSON from markdown code blocks', async () => {
|
||||
const mockResponse = {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'Here is the score: ```json\n{ "score": 90, "reason": "Good" }\n```' } }]
|
||||
@ -169,7 +171,7 @@ describe('AI Service Resilience', () => {
|
||||
];
|
||||
|
||||
it('should return projects selected by AI', async () => {
|
||||
const mockResponse = {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: JSON.stringify({ selectedProjectIds: ['p1'] }) } }]
|
||||
@ -187,9 +189,9 @@ describe('AI Service Resilience', () => {
|
||||
});
|
||||
|
||||
it('should fallback if API fails', async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
|
||||
vi.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await pickProjectIdsForJob({
|
||||
const result = await pickProjectIdsForJob({
|
||||
jobDescription: 'React dev', // Should match p1 due to keyword 'React'
|
||||
eligibleProjects: mockProjects,
|
||||
desiredCount: 1
|
||||
@ -218,18 +220,18 @@ describe('AI Service Resilience', () => {
|
||||
expect(result).toEqual(['p2']);
|
||||
});
|
||||
|
||||
it('should validate returned IDs exist in eligible list', async () => {
|
||||
it('should validate returned IDs exist in eligible list', async () => {
|
||||
// AI returns an ID that doesn't exist ('p999')
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: JSON.stringify({ selectedProjectIds: ['p999', 'p1'] }) } }]
|
||||
choices: [{ message: { content: JSON.stringify({ selectedProjectIds: ['p999', 'p1'] }) } }]
|
||||
})
|
||||
};
|
||||
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
||||
|
||||
const result = await pickProjectIdsForJob({
|
||||
jobDescription: 'stuff',
|
||||
jobDescription: 'stuff',
|
||||
eligibleProjects: mockProjects,
|
||||
desiredCount: 2
|
||||
});
|
||||
|
||||
118
orchestrator/src/server/services/envSettings.ts
Normal file
118
orchestrator/src/server/services/envSettings.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import * as settingsRepo from '@server/repositories/settings.js';
|
||||
import { SettingKey } from '@server/repositories/settings.js';
|
||||
|
||||
const envDefaults: Record<string, string | undefined> = { ...process.env };
|
||||
|
||||
const readableStringConfig: { settingKey: SettingKey, envKey: string }[] = [
|
||||
{ settingKey: 'rxresumeEmail', envKey: 'RXRESUME_EMAIL' },
|
||||
{ settingKey: 'ukvisajobsEmail', envKey: 'UKVISAJOBS_EMAIL' },
|
||||
{ settingKey: 'basicAuthUser', envKey: 'BASIC_AUTH_USER' },
|
||||
];
|
||||
|
||||
const readableBooleanConfig: { settingKey: SettingKey, envKey: string, defaultValue: boolean }[] = [];
|
||||
|
||||
const privateStringConfig: { settingKey: SettingKey, envKey: string, hintKey: string }[] = [
|
||||
{ settingKey: 'openrouterApiKey', envKey: 'OPENROUTER_API_KEY', hintKey: 'openrouterApiKeyHint' },
|
||||
{ settingKey: 'rxresumePassword', envKey: 'RXRESUME_PASSWORD', hintKey: 'rxresumePasswordHint' },
|
||||
{ settingKey: 'ukvisajobsPassword', envKey: 'UKVISAJOBS_PASSWORD', hintKey: 'ukvisajobsPasswordHint' },
|
||||
{ settingKey: 'basicAuthPassword', envKey: 'BASIC_AUTH_PASSWORD', hintKey: 'basicAuthPasswordHint' },
|
||||
{ settingKey: 'webhookSecret', envKey: 'WEBHOOK_SECRET', hintKey: 'webhookSecretHint' },
|
||||
];
|
||||
|
||||
export function normalizeEnvInput(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseEnvBoolean(raw: string | null | undefined, defaultValue: boolean): boolean {
|
||||
if (raw === undefined || raw === null || raw === '') return defaultValue;
|
||||
if (raw === 'false' || raw === '0') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function applyEnvValue(envKey: string, value: string | null): void {
|
||||
if (value === null) {
|
||||
const fallback = envDefaults[envKey];
|
||||
if (fallback === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = fallback;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
process.env[envKey] = value;
|
||||
}
|
||||
|
||||
export function serializeEnvBoolean(value: boolean | null): string | null {
|
||||
if (value === null) return null;
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
export async function applyStoredEnvOverrides(): Promise<void> {
|
||||
await Promise.all([
|
||||
...readableStringConfig.map(async ({ settingKey, envKey }) => {
|
||||
const override = await settingsRepo.getSetting(settingKey);
|
||||
if (override === null) return;
|
||||
applyEnvValue(envKey, normalizeEnvInput(override));
|
||||
}),
|
||||
...readableBooleanConfig.map(async ({ settingKey, envKey, defaultValue }) => {
|
||||
const override = await settingsRepo.getSetting(settingKey);
|
||||
if (override === null) return;
|
||||
const parsed = parseEnvBoolean(override, defaultValue);
|
||||
applyEnvValue(envKey, serializeEnvBoolean(parsed));
|
||||
}),
|
||||
...privateStringConfig.map(async ({ settingKey, envKey }) => {
|
||||
const override = await settingsRepo.getSetting(settingKey);
|
||||
if (override === null) return;
|
||||
applyEnvValue(envKey, normalizeEnvInput(override));
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getEnvSettingsData(
|
||||
overrides?: Partial<Record<SettingKey, string>>
|
||||
): Promise<Record<string, string | boolean | number | null>> {
|
||||
const activeOverrides = overrides || await settingsRepo.getAllSettings();
|
||||
const readableValues: Record<string, string | boolean | null> = {};
|
||||
const privateValues: Record<string, string | null> = {};
|
||||
|
||||
for (const { settingKey, envKey } of readableStringConfig) {
|
||||
const override = activeOverrides[settingKey] ?? null;
|
||||
const rawValue = override ?? process.env[envKey];
|
||||
readableValues[settingKey] = normalizeEnvInput(rawValue);
|
||||
}
|
||||
|
||||
for (const { settingKey, envKey, defaultValue } of readableBooleanConfig) {
|
||||
const override = activeOverrides[settingKey] ?? null;
|
||||
const rawValue = override ?? process.env[envKey];
|
||||
readableValues[settingKey] = parseEnvBoolean(rawValue, defaultValue);
|
||||
}
|
||||
|
||||
for (const { settingKey, envKey, hintKey } of privateStringConfig) {
|
||||
const override = activeOverrides[settingKey] ?? null;
|
||||
const rawValue = override ?? process.env[envKey];
|
||||
if (!rawValue) {
|
||||
privateValues[hintKey] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hintLength = rawValue.length > 4 ? 4 : Math.max(rawValue.length - 1, 1);
|
||||
privateValues[hintKey] = rawValue.slice(0, hintLength);
|
||||
}
|
||||
|
||||
const basicAuthUser = activeOverrides['basicAuthUser'] ?? process.env.BASIC_AUTH_USER;
|
||||
const basicAuthPassword = activeOverrides['basicAuthPassword'] ?? process.env.BASIC_AUTH_PASSWORD;
|
||||
|
||||
return {
|
||||
...readableValues,
|
||||
...privateValues,
|
||||
basicAuthActive: Boolean(basicAuthUser && basicAuthPassword),
|
||||
};
|
||||
}
|
||||
|
||||
export const envSettingConfig = {
|
||||
readableStringConfig,
|
||||
readableBooleanConfig,
|
||||
privateStringConfig,
|
||||
};
|
||||
@ -4,3 +4,4 @@ export * from './scorer.js';
|
||||
export * from './summary.js';
|
||||
export * from './pdf.js';
|
||||
export * from './notion.js';
|
||||
export * from './profile.js';
|
||||
|
||||
@ -4,6 +4,7 @@ import { inferManualJobDetails } from "./manualJob.js";
|
||||
|
||||
vi.mock("../repositories/settings.js", () => ({
|
||||
getSetting: vi.fn(),
|
||||
getAllSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
const originalEnv = process.env;
|
||||
|
||||
@ -4,18 +4,57 @@
|
||||
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import type { ManualJobDraft } from '../../shared/types.js';
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
|
||||
|
||||
export interface ManualJobInferenceResult {
|
||||
job: ManualJobDraft;
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
export async function inferManualJobDetails(jobDescription: string): Promise<ManualJobInferenceResult> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
/** Raw response type from the API (all fields are strings) */
|
||||
interface ManualJobApiResponse {
|
||||
title: string;
|
||||
employer: string;
|
||||
location: string;
|
||||
salary: string;
|
||||
deadline: string;
|
||||
jobUrl: string;
|
||||
applicationLink: string;
|
||||
jobType: string;
|
||||
jobLevel: string;
|
||||
jobFunction: string;
|
||||
disciplines: string;
|
||||
degreeRequired: string;
|
||||
starting: string;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
/** JSON schema for manual job extraction response */
|
||||
const MANUAL_JOB_SCHEMA: JsonSchemaDefinition = {
|
||||
name: 'manual_job_details',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
employer: { type: 'string', description: 'Company/employer name' },
|
||||
location: { type: 'string', description: 'Job location' },
|
||||
salary: { type: 'string', description: 'Salary information' },
|
||||
deadline: { type: 'string', description: 'Application deadline' },
|
||||
jobUrl: { type: 'string', description: 'URL of the job listing' },
|
||||
applicationLink: { type: 'string', description: 'Direct application URL' },
|
||||
jobType: { type: 'string', description: 'Employment type (full-time, part-time, etc.)' },
|
||||
jobLevel: { type: 'string', description: 'Seniority level (entry, mid, senior, etc.)' },
|
||||
jobFunction: { type: 'string', description: 'Job function/category' },
|
||||
disciplines: { type: 'string', description: 'Required disciplines or fields' },
|
||||
degreeRequired: { type: 'string', description: 'Required degree or education' },
|
||||
starting: { type: 'string', description: 'Start date information' },
|
||||
},
|
||||
required: ['title', 'employer', 'location', 'salary', 'deadline', 'jobUrl', 'applicationLink', 'jobType', 'jobLevel', 'jobFunction', 'disciplines', 'degreeRequired', 'starting'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
export async function inferManualJobDetails(jobDescription: string): Promise<ManualJobInferenceResult> {
|
||||
if (!process.env.OPENROUTER_API_KEY) {
|
||||
return {
|
||||
job: {},
|
||||
warning: 'OPENROUTER_API_KEY not set. Fill details manually.',
|
||||
@ -23,44 +62,24 @@ export async function inferManualJobDetails(jobDescription: string): Promise<Man
|
||||
}
|
||||
|
||||
const overrideModel = await getSetting('model');
|
||||
const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const model = overrideModel || process.env.MODEL || 'google/gemini-3-flash-preview';
|
||||
const prompt = buildInferencePrompt(jobDescription);
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'http://localhost',
|
||||
'X-Title': 'JobOpsOrchestrator',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
const result = await callOpenRouter<ManualJobApiResponse>({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
jsonSchema: MANUAL_JOB_SCHEMA,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error('No content in response');
|
||||
}
|
||||
|
||||
const parsed = parseJsonFromContent(content);
|
||||
return { job: normalizeDraft(parsed) };
|
||||
} catch (error) {
|
||||
console.warn('Manual job inference failed:', error);
|
||||
if (!result.success) {
|
||||
console.warn('Manual job inference failed:', result.error);
|
||||
return {
|
||||
job: {},
|
||||
warning: 'AI inference failed. Fill details manually.',
|
||||
};
|
||||
}
|
||||
|
||||
return { job: normalizeDraft(result.data) };
|
||||
}
|
||||
|
||||
function buildInferencePrompt(jd: string): string {
|
||||
@ -106,58 +125,23 @@ OUTPUT FORMAT (JSON ONLY):
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function parseJsonFromContent(content: string): Record<string, unknown> {
|
||||
const trimmed = content.trim();
|
||||
const withoutFences = trimmed.replace(/```(?:json)?\s*|```/gi, '').trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(withoutFences);
|
||||
} catch {
|
||||
const firstBrace = withoutFences.indexOf('{');
|
||||
const lastBrace = withoutFences.lastIndexOf('}');
|
||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||
const sliced = withoutFences.slice(firstBrace, lastBrace + 1);
|
||||
return JSON.parse(sliced);
|
||||
}
|
||||
throw new Error('Unable to parse JSON from model response');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDraft(parsed: Record<string, unknown>): ManualJobDraft {
|
||||
const fields: Array<keyof ManualJobDraft> = [
|
||||
'title',
|
||||
'employer',
|
||||
'location',
|
||||
'salary',
|
||||
'deadline',
|
||||
'jobUrl',
|
||||
'applicationLink',
|
||||
'jobType',
|
||||
'jobLevel',
|
||||
'jobFunction',
|
||||
'disciplines',
|
||||
'degreeRequired',
|
||||
'starting',
|
||||
];
|
||||
|
||||
function normalizeDraft(parsed: ManualJobApiResponse): ManualJobDraft {
|
||||
const out: ManualJobDraft = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const value = toCleanString(parsed[field]);
|
||||
if (value) out[field] = value;
|
||||
}
|
||||
// Map each field, only including non-empty strings
|
||||
if (parsed.title?.trim()) out.title = parsed.title.trim();
|
||||
if (parsed.employer?.trim()) out.employer = parsed.employer.trim();
|
||||
if (parsed.location?.trim()) out.location = parsed.location.trim();
|
||||
if (parsed.salary?.trim()) out.salary = parsed.salary.trim();
|
||||
if (parsed.deadline?.trim()) out.deadline = parsed.deadline.trim();
|
||||
if (parsed.jobUrl?.trim()) out.jobUrl = parsed.jobUrl.trim();
|
||||
if (parsed.applicationLink?.trim()) out.applicationLink = parsed.applicationLink.trim();
|
||||
if (parsed.jobType?.trim()) out.jobType = parsed.jobType.trim();
|
||||
if (parsed.jobLevel?.trim()) out.jobLevel = parsed.jobLevel.trim();
|
||||
if (parsed.jobFunction?.trim()) out.jobFunction = parsed.jobFunction.trim();
|
||||
if (parsed.disciplines?.trim()) out.disciplines = parsed.disciplines.trim();
|
||||
if (parsed.degreeRequired?.trim()) out.degreeRequired = parsed.degreeRequired.trim();
|
||||
if (parsed.starting?.trim()) out.starting = parsed.starting.trim();
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function toCleanString(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
199
orchestrator/src/server/services/openrouter.test.ts
Normal file
199
orchestrator/src/server/services/openrouter.test.ts
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Tests for the shared OpenRouter API helper.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { callOpenRouter, parseJsonContent, type JsonSchemaDefinition } from './openrouter.js';
|
||||
|
||||
// Mock fetch globally
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
const testSchema: JsonSchemaDefinition = {
|
||||
name: 'test_schema',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string', description: 'A test value' },
|
||||
count: { type: 'integer', description: 'A test count' },
|
||||
},
|
||||
required: ['value', 'count'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe('callOpenRouter', () => {
|
||||
beforeEach(() => {
|
||||
process.env.OPENROUTER_API_KEY = 'test-api-key';
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return error when API key is not set', async () => {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
|
||||
const result = await callOpenRouter({
|
||||
model: 'test-model',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
jsonSchema: testSchema,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain('API_KEY');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return parsed data on successful response', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: JSON.stringify({ value: 'hello', count: 42 }) } }],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await callOpenRouter<{ value: string; count: number }>({
|
||||
model: 'test-model',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
jsonSchema: testSchema,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.value).toBe('hello');
|
||||
expect(result.data.count).toBe(42);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
} as Response);
|
||||
|
||||
const result = await callOpenRouter({
|
||||
model: 'test-model',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
jsonSchema: testSchema,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain('500');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty response content', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: '' } }],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await callOpenRouter({
|
||||
model: 'test-model',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
jsonSchema: testSchema,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain('No content');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include json_schema in request body', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: '{"value": "test", "count": 1}' } }],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await callOpenRouter({
|
||||
model: 'test-model',
|
||||
messages: [{ role: 'user', content: 'test prompt' }],
|
||||
jsonSchema: testSchema,
|
||||
});
|
||||
|
||||
const fetchCall = vi.mocked(global.fetch).mock.calls[0];
|
||||
const body = JSON.parse(fetchCall[1]?.body as string);
|
||||
|
||||
expect(body.response_format.type).toBe('json_schema');
|
||||
expect(body.response_format.json_schema.name).toBe('test_schema');
|
||||
expect(body.response_format.json_schema.strict).toBe(true);
|
||||
});
|
||||
|
||||
it('should retry on parsing failures when maxRetries is set', async () => {
|
||||
let callCount = 0;
|
||||
vi.mocked(global.fetch).mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount < 3) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'invalid json' } }],
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: '{"value": "success", "count": 3}' } }],
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
// Suppress console output during test
|
||||
vi.spyOn(console, 'log').mockImplementation(() => { });
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
vi.spyOn(console, 'error').mockImplementation(() => { });
|
||||
|
||||
const result = await callOpenRouter<{ value: string; count: number }>({
|
||||
model: 'test-model',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
jsonSchema: testSchema,
|
||||
maxRetries: 2,
|
||||
retryDelayMs: 10, // Fast retries for tests
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.value).toBe('success');
|
||||
}
|
||||
expect(callCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJsonContent', () => {
|
||||
it('should parse clean JSON', () => {
|
||||
const result = parseJsonContent<{ foo: string }>('{"foo": "bar"}');
|
||||
expect(result.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('should handle markdown code fences', () => {
|
||||
const result = parseJsonContent<{ foo: string }>('```json\n{"foo": "bar"}\n```');
|
||||
expect(result.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('should handle json without language specifier', () => {
|
||||
const result = parseJsonContent<{ foo: string }>('```\n{"foo": "bar"}\n```');
|
||||
expect(result.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('should extract JSON from surrounding text', () => {
|
||||
const result = parseJsonContent<{ foo: string }>('Here is the result: {"foo": "bar"} as requested.');
|
||||
expect(result.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('should throw on completely invalid content', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => { });
|
||||
expect(() => parseJsonContent('not json at all')).toThrow();
|
||||
});
|
||||
});
|
||||
169
orchestrator/src/server/services/openrouter.ts
Normal file
169
orchestrator/src/server/services/openrouter.ts
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Shared OpenRouter API helper for structured JSON responses.
|
||||
*/
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export interface JsonSchemaDefinition {
|
||||
name: string;
|
||||
schema: {
|
||||
type: 'object';
|
||||
properties: Record<string, unknown>;
|
||||
required: string[];
|
||||
additionalProperties: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenRouterRequestOptions<T> {
|
||||
/** The model to use (e.g., 'google/gemini-3-flash-preview') */
|
||||
model: string;
|
||||
/** The prompt messages to send */
|
||||
messages: Array<{ role: 'user' | 'system' | 'assistant'; content: string }>;
|
||||
/** JSON schema for structured output */
|
||||
jsonSchema: JsonSchemaDefinition;
|
||||
/** Number of retries on parsing failures (default: 0) */
|
||||
maxRetries?: number;
|
||||
/** Delay between retries in ms (default: 500) */
|
||||
retryDelayMs?: number;
|
||||
/** Job ID for logging purposes */
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
export interface OpenRouterResult<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface OpenRouterError {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type OpenRouterResponse<T> = OpenRouterResult<T> | OpenRouterError;
|
||||
|
||||
/**
|
||||
* Call OpenRouter API with structured JSON output.
|
||||
*
|
||||
* @returns Parsed JSON response matching the schema, or an error object
|
||||
*/
|
||||
export async function callOpenRouter<T>(
|
||||
options: OpenRouterRequestOptions<T>
|
||||
): Promise<OpenRouterResponse<T>> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'OPENROUTER_API_KEY not configured' };
|
||||
}
|
||||
|
||||
const { model, messages, jsonSchema, maxRetries = 0, retryDelayMs = 500, jobId } = options;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
console.log(`🔄 [${jobId ?? 'unknown'}] Retry attempt ${attempt}/${maxRetries}...`);
|
||||
await sleep(retryDelayMs * attempt);
|
||||
}
|
||||
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'JobOps',
|
||||
'X-Title': 'JobOpsOrchestrator',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: jsonSchema.name,
|
||||
strict: true,
|
||||
schema: jsonSchema.schema,
|
||||
},
|
||||
},
|
||||
plugins: [{ id: 'response-healing' }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Throw error with status to allow specific retries
|
||||
const errorBody = await response.text().catch(() => 'No error body');
|
||||
const err = new Error(`OpenRouter API error: ${response.status}`);
|
||||
(err as any).status = response.status;
|
||||
(err as any).body = errorBody;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('No content in response');
|
||||
}
|
||||
|
||||
// Parse JSON - structured outputs should always return valid JSON
|
||||
const parsed = parseJsonContent<T>(content, jobId);
|
||||
|
||||
return { success: true, data: parsed };
|
||||
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const status = (error as any).status;
|
||||
|
||||
// Retry on:
|
||||
// 1. Parsing errors (AI returned malformed JSON)
|
||||
// 2. Rate limits (429)
|
||||
// 3. Server errors (5xx)
|
||||
// 4. Timeouts/Network issues
|
||||
const shouldRetry =
|
||||
message.includes('parse') ||
|
||||
status === 429 ||
|
||||
(status >= 500 && status <= 599) ||
|
||||
message.toLowerCase().includes('timeout') ||
|
||||
message.toLowerCase().includes('fetch failed');
|
||||
|
||||
if (attempt < maxRetries && shouldRetry) {
|
||||
console.warn(`⚠️ [${jobId ?? 'unknown'}] Attempt ${attempt + 1} failed (${status ?? 'no-status'}): ${message}. Retrying...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: 'All retry attempts failed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON content from OpenRouter response.
|
||||
* Handles common AI quirks like markdown code fences.
|
||||
*/
|
||||
export function parseJsonContent<T>(content: string, jobId?: string): T {
|
||||
let candidate = content.trim();
|
||||
|
||||
// Remove markdown code fences if present
|
||||
candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim();
|
||||
|
||||
// Try to extract JSON object if there's surrounding text
|
||||
// Use non-greedy match and find the outermost braces
|
||||
const firstBrace = candidate.indexOf('{');
|
||||
const lastBrace = candidate.lastIndexOf('}');
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||
candidate = candidate.substring(firstBrace, lastBrace + 1);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(candidate) as T;
|
||||
} catch (error) {
|
||||
console.error(`❌ [${jobId ?? 'unknown'}] Failed to parse JSON:`, candidate.substring(0, 200));
|
||||
throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
257
orchestrator/src/server/services/pdf-skills-validation.test.ts
Normal file
257
orchestrator/src/server/services/pdf-skills-validation.test.ts
Normal file
@ -0,0 +1,257 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { generatePdf } from './pdf.js';
|
||||
|
||||
// Define mock data in hoisted block
|
||||
const { mocks, mockProfile } = vi.hoisted(() => {
|
||||
const profile = {
|
||||
sections: {
|
||||
summary: { content: 'Original Summary' },
|
||||
skills: {
|
||||
items: [
|
||||
{ id: 's1', name: 'Existing Skill', visible: true, description: 'Existing Desc', level: 3, keywords: ['k1'] }
|
||||
]
|
||||
},
|
||||
projects: { items: [] }
|
||||
},
|
||||
basics: { headline: 'Original Headline' }
|
||||
};
|
||||
|
||||
return {
|
||||
mockProfile: profile,
|
||||
mocks: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Configure base mock implementations
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
||||
mocks.writeFile.mockResolvedValue(undefined);
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
return {
|
||||
default: mocks,
|
||||
...mocks
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
default: { existsSync: vi.fn().mockReturnValue(true) }
|
||||
}));
|
||||
|
||||
vi.mock('../repositories/settings.js', () => ({
|
||||
getSetting: vi.fn().mockResolvedValue(null),
|
||||
getAllSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock('./projectSelection.js', () => ({
|
||||
pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('./resumeProjects.js', () => ({
|
||||
extractProjectsFromProfile: vi.fn().mockReturnValue({ catalog: [], selectionItems: [] }),
|
||||
resolveResumeProjectsSettings: vi.fn().mockReturnValue({
|
||||
resumeProjects: { lockedProjectIds: [], aiSelectableProjectIds: [], maxProjects: 2 }
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn().mockImplementation(() => ({
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') cb(0);
|
||||
return {};
|
||||
}),
|
||||
})),
|
||||
default: {
|
||||
spawn: vi.fn().mockImplementation(() => ({
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') cb(0);
|
||||
return {};
|
||||
}),
|
||||
}))
|
||||
}
|
||||
}));
|
||||
|
||||
describe('PDF Service Skills Validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
||||
});
|
||||
|
||||
it('should add required schema fields (visible, description) to new skills', async () => {
|
||||
// AI often returns just name and keywords
|
||||
const newSkills = [
|
||||
{ name: 'New Skill', keywords: ['k2'] },
|
||||
{ name: 'Existing Skill', keywords: ['k3', 'k4'] } // Should merge with s1
|
||||
];
|
||||
|
||||
const tailoredContent = { skills: newSkills };
|
||||
|
||||
await generatePdf('job-skills-1', tailoredContent, 'Job Desc');
|
||||
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
const callArgs = mocks.writeFile.mock.calls[0];
|
||||
const savedResumeJson = JSON.parse(callArgs[1] as string);
|
||||
|
||||
const skillItems = savedResumeJson.sections.skills.items;
|
||||
|
||||
// Check "New Skill"
|
||||
const newSkill = skillItems.find((s: any) => s.name === 'New Skill');
|
||||
expect(newSkill).toBeDefined();
|
||||
|
||||
// These are the validations failing in user report:
|
||||
expect(newSkill.visible).toBe(true); // Should default to true
|
||||
expect(typeof newSkill.description).toBe('string'); // Should default to ""
|
||||
expect(newSkill.description).toBe('');
|
||||
// Optional but good to check
|
||||
expect(newSkill.id).toBeDefined();
|
||||
expect(newSkill.level).toBe(1);
|
||||
|
||||
// Check "Existing Skill" - should preserve existing fields if not overwritten?
|
||||
// In the implementation, we look up existing.
|
||||
// existing.visible => true, existing.description => 'Existing Desc', existing.level => 3
|
||||
const existingSkill = skillItems.find((s: any) => s.name === 'Existing Skill');
|
||||
expect(existingSkill.visible).toBe(true);
|
||||
expect(existingSkill.description).toBe('Existing Desc');
|
||||
expect(existingSkill.level).toBe(3);
|
||||
expect(existingSkill.keywords).toEqual(['k3', 'k4']); // Should use new keywords or existing? Implementation uses new || existing.
|
||||
});
|
||||
|
||||
it('should sanitize base resume even if no skills are tailored', async () => {
|
||||
// Mock profile has an invalid skill (missing visible/description in the raw json implied,
|
||||
// though our mock above has them. Let's make a truly invalid one locally)
|
||||
const invalidProfile = {
|
||||
...mockProfile,
|
||||
sections: {
|
||||
...mockProfile.sections,
|
||||
skills: {
|
||||
items: [
|
||||
{ name: 'Invalid Skill' } // Missing visible, description, id, level
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
mocks.readFile.mockResolvedValueOnce(JSON.stringify(invalidProfile));
|
||||
|
||||
// No tailoring, pass dummy path to bypass getProfile cache and use readFile mock
|
||||
await generatePdf('job-no-tailor', {}, 'Job Desc', 'dummy.json');
|
||||
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
const callArgs = mocks.writeFile.mock.calls[0];
|
||||
const savedResumeJson = JSON.parse(callArgs[1] as string);
|
||||
|
||||
const item = savedResumeJson.sections.skills.items[0];
|
||||
|
||||
// Ensure defaults are applied even if we didn't use the tailoring logic block
|
||||
expect(item.visible).toBe(true);
|
||||
expect(item.description).toBe('');
|
||||
expect(item.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should generate CUID2-compatible IDs for skills without IDs', async () => {
|
||||
// Profile with skills missing IDs (common when AI generates them)
|
||||
const profileWithoutIds = {
|
||||
...mockProfile,
|
||||
sections: {
|
||||
...mockProfile.sections,
|
||||
skills: {
|
||||
items: [
|
||||
{ name: 'Skill 1', keywords: ['a'] },
|
||||
{ name: 'Skill 2', keywords: ['b'] },
|
||||
{ name: 'Skill 3', keywords: ['c'] }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds));
|
||||
|
||||
await generatePdf('job-cuid2-test', {}, 'Job Desc', 'dummy.json');
|
||||
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
const callArgs = mocks.writeFile.mock.calls[0];
|
||||
const savedResumeJson = JSON.parse(callArgs[1] as string);
|
||||
|
||||
const skillItems = savedResumeJson.sections.skills.items;
|
||||
|
||||
// All skills should have IDs
|
||||
skillItems.forEach((skill: any, index: number) => {
|
||||
expect(skill.id).toBeDefined();
|
||||
expect(typeof skill.id).toBe('string');
|
||||
expect(skill.id.length).toBeGreaterThanOrEqual(20);
|
||||
|
||||
// CUID2 format: starts with a letter, lowercase alphanumeric
|
||||
expect(skill.id).toMatch(/^[a-z][a-z0-9]+$/);
|
||||
});
|
||||
|
||||
// IDs should be unique
|
||||
const ids = skillItems.map((s: any) => s.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('should NOT generate IDs like "skill-0" which are invalid CUID2', async () => {
|
||||
const profileWithoutIds = {
|
||||
...mockProfile,
|
||||
sections: {
|
||||
...mockProfile.sections,
|
||||
skills: {
|
||||
items: [
|
||||
{ name: 'Skill Without ID', keywords: ['test'] }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds));
|
||||
|
||||
await generatePdf('job-no-skill-prefix', {}, 'Job Desc', 'dummy.json');
|
||||
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
const callArgs = mocks.writeFile.mock.calls[0];
|
||||
const savedResumeJson = JSON.parse(callArgs[1] as string);
|
||||
|
||||
const skill = savedResumeJson.sections.skills.items[0];
|
||||
|
||||
// ID should NOT be in the old invalid format
|
||||
expect(skill.id).not.toMatch(/^skill-\d+$/);
|
||||
|
||||
// Should be valid CUID2 format
|
||||
expect(skill.id).toMatch(/^[a-z][a-z0-9]+$/);
|
||||
});
|
||||
|
||||
it('should preserve existing valid IDs and not regenerate them', async () => {
|
||||
const validCuid2Id = 'ck9w4ygzq0000xmn5h0jt7l5c';
|
||||
const profileWithValidId = {
|
||||
...mockProfile,
|
||||
sections: {
|
||||
...mockProfile.sections,
|
||||
skills: {
|
||||
items: [
|
||||
{ id: validCuid2Id, name: 'Skill With Valid ID', keywords: ['test'], visible: true, description: '', level: 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithValidId));
|
||||
|
||||
await generatePdf('job-preserve-id', {}, 'Job Desc', 'dummy.json');
|
||||
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
const callArgs = mocks.writeFile.mock.calls[0];
|
||||
const savedResumeJson = JSON.parse(callArgs[1] as string);
|
||||
|
||||
const skill = savedResumeJson.sections.skills.items[0];
|
||||
|
||||
// Should preserve the original valid ID
|
||||
expect(skill.id).toBe(validCuid2Id);
|
||||
});
|
||||
});
|
||||
@ -49,6 +49,7 @@ vi.mock('fs', () => ({
|
||||
|
||||
vi.mock('../repositories/settings.js', () => ({
|
||||
getSetting: vi.fn().mockResolvedValue(null),
|
||||
getAllSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock('./projectSelection.js', () => ({
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
/**
|
||||
* Service for generating PDF resumes using Reactive Resume API.
|
||||
* Service for generating PDF resumes using RxResume automation.
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
import { writeFile, mkdir, access } from 'fs/promises';
|
||||
import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import { pickProjectIdsForJob } from './projectSelection.js';
|
||||
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
|
||||
import { getDataDir } from '../config/dataDir.js';
|
||||
import { getResume, importResume, exportResumePdf, deleteResume } from './rxresume.js';
|
||||
import { getProfile } from './profile.js';
|
||||
|
||||
const OUTPUT_DIR = join(getDataDir(), 'pdfs');
|
||||
const RESUME_GEN_DIR = process.env.RESUME_GEN_DIR || join(getDataDir(), '..', 'resume-generator');
|
||||
|
||||
export interface PdfResult {
|
||||
success: boolean;
|
||||
@ -23,90 +26,100 @@ export interface PdfResult {
|
||||
export interface TailoredPdfContent {
|
||||
summary?: string | null;
|
||||
headline?: string | null;
|
||||
skills?: any | null; // Accept any for flexibility, expected to be items array or parsed JSON
|
||||
skills?: any | null; // Accept any for flexibility, expected to be items array or parsed JSON
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a tailored PDF resume for a job using Reactive Resume API.
|
||||
* Generate a tailored PDF resume for a job using RxResume automation.
|
||||
*/
|
||||
export async function generatePdf(
|
||||
jobId: string,
|
||||
tailoredContent: TailoredPdfContent,
|
||||
jobDescription: string,
|
||||
_baseResumePath?: string, // Deprecated/ignored when using API
|
||||
baseResumePath?: string,
|
||||
selectedProjectIds?: string | null
|
||||
): Promise<PdfResult> {
|
||||
console.log(`📄 Generating PDF for job ${jobId} using Reactive Resume API...`);
|
||||
|
||||
let tempResumeId: string | null = null;
|
||||
console.log(`📄 Generating PDF for job ${jobId}...`);
|
||||
|
||||
try {
|
||||
// 1. Get base resume ID from settings
|
||||
const baseResumeId = await getSetting('rxResumeBaseResumeId');
|
||||
if (!baseResumeId) {
|
||||
throw new Error('rxResumeBaseResumeId not configured in settings. Please select a base resume in settings first.');
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!existsSync(OUTPUT_DIR)) {
|
||||
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 2. Fetch base resume data
|
||||
console.log(` Fetching base resume ${baseResumeId}...`);
|
||||
const baseResumeResponse = await getResume(baseResumeId);
|
||||
const resumeData = baseResumeResponse.data;
|
||||
// Read base resume
|
||||
const baseResume = baseResumePath
|
||||
? JSON.parse(await readFile(baseResumePath, 'utf-8'))
|
||||
: JSON.parse(JSON.stringify(await getProfile()));
|
||||
|
||||
// 3. Apply tailoring
|
||||
// Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords)
|
||||
// This fixes issues where the base JSON uses a shorthand format (missing required fields)
|
||||
if (baseResume.sections?.skills?.items && Array.isArray(baseResume.sections.skills.items)) {
|
||||
baseResume.sections.skills.items = baseResume.sections.skills.items.map((skill: any) => ({
|
||||
...skill,
|
||||
id: skill.id || createId(),
|
||||
visible: skill.visible ?? true,
|
||||
// Zod schema requires string, default to empty string if missing
|
||||
description: skill.description ?? '',
|
||||
level: skill.level ?? 1,
|
||||
keywords: skill.keywords || [],
|
||||
}));
|
||||
}
|
||||
|
||||
// Inject tailored summary
|
||||
if (tailoredContent.summary) {
|
||||
if (resumeData.sections?.summary) {
|
||||
resumeData.sections.summary.content = tailoredContent.summary;
|
||||
} else if (resumeData.basics?.summary) {
|
||||
resumeData.basics.summary = tailoredContent.summary;
|
||||
if (baseResume.sections?.summary) {
|
||||
baseResume.sections.summary.content = tailoredContent.summary;
|
||||
} else if (baseResume.basics?.summary) {
|
||||
baseResume.basics.summary = tailoredContent.summary;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject tailored headline
|
||||
if (tailoredContent.headline) {
|
||||
if (resumeData.basics) {
|
||||
resumeData.basics.headline = tailoredContent.headline;
|
||||
resumeData.basics.label = tailoredContent.headline;
|
||||
if (baseResume.basics) {
|
||||
baseResume.basics.headline = tailoredContent.headline;
|
||||
baseResume.basics.label = tailoredContent.headline;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject tailored skills
|
||||
if (tailoredContent.skills) {
|
||||
const rawSkills = Array.isArray(tailoredContent.skills)
|
||||
const newSkills = Array.isArray(tailoredContent.skills)
|
||||
? tailoredContent.skills
|
||||
: typeof tailoredContent.skills === 'string'
|
||||
? JSON.parse(tailoredContent.skills)
|
||||
: null;
|
||||
|
||||
if (rawSkills && resumeData.sections?.skills) {
|
||||
// Ensure each skill item has all required fields per OpenAPI spec
|
||||
const normalizedSkills = rawSkills.map((skill: any, index: number) => ({
|
||||
id: skill.id || `skill-${index}-${Date.now()}`,
|
||||
hidden: skill.hidden ?? false,
|
||||
icon: skill.icon || '',
|
||||
name: skill.name || '',
|
||||
proficiency: skill.proficiency || '',
|
||||
level: skill.level ?? 0,
|
||||
keywords: Array.isArray(skill.keywords) ? skill.keywords : [],
|
||||
}));
|
||||
resumeData.sections.skills.items = normalizedSkills;
|
||||
if (newSkills && baseResume.sections?.skills) {
|
||||
// Ensure each skill item has required schema fields
|
||||
const existingSkills = baseResume.sections.skills.items || [];
|
||||
const skillsWithSchema = newSkills.map((newSkill: any) => {
|
||||
// Try to find matching existing skill to preserve id and other fields
|
||||
const existing = existingSkills.find((s: any) => s.name === newSkill.name);
|
||||
|
||||
return {
|
||||
id: newSkill.id || existing?.id || createId(),
|
||||
visible: newSkill.visible !== undefined ? newSkill.visible : (existing?.visible ?? true),
|
||||
name: newSkill.name || existing?.name || '',
|
||||
description: newSkill.description !== undefined ? newSkill.description : (existing?.description || ''),
|
||||
level: newSkill.level !== undefined ? newSkill.level : (existing?.level ?? 1),
|
||||
keywords: newSkill.keywords || existing?.keywords || [],
|
||||
};
|
||||
});
|
||||
|
||||
baseResume.sections.skills.items = skillsWithSchema;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Select projects and set visibility
|
||||
// Select projects and set visibility
|
||||
try {
|
||||
let selectedSet: Set<string>;
|
||||
|
||||
if (selectedProjectIds) {
|
||||
selectedSet = new Set(selectedProjectIds.split(',').map(s => s.trim()).filter(Boolean));
|
||||
} else {
|
||||
const { catalog, selectionItems } = extractProjectsFromProfile(resumeData);
|
||||
const { catalog, selectionItems } = extractProjectsFromProfile(baseResume);
|
||||
const overrideResumeProjectsRaw = await getSetting('resumeProjects');
|
||||
const { resumeProjects } = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||
|
||||
@ -124,7 +137,7 @@ export async function generatePdf(
|
||||
selectedSet = new Set([...locked, ...picked]);
|
||||
}
|
||||
|
||||
const projectsSection = resumeData.sections?.projects;
|
||||
const projectsSection = baseResume.sections?.projects;
|
||||
const projectItems = projectsSection?.items;
|
||||
if (Array.isArray(projectItems)) {
|
||||
for (const item of projectItems) {
|
||||
@ -139,41 +152,23 @@ export async function generatePdf(
|
||||
console.warn(` ⚠️ Project visibility step failed for job ${jobId}:`, err);
|
||||
}
|
||||
|
||||
// 5. Import as temporary resume
|
||||
console.log(` Importing temporary resume for job ${jobId}...`);
|
||||
const timestamp = new Date().getTime();
|
||||
const tempName = `[TEMP] ${resumeData.basics?.name || 'Resume'} - ${jobId.slice(0, 8)} (${timestamp})`;
|
||||
// Write modified resume to temp file
|
||||
const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`);
|
||||
await writeFile(tempResumePath, JSON.stringify(baseResume, null, 2));
|
||||
|
||||
tempResumeId = await importResume({
|
||||
name: tempName,
|
||||
slug: `temp-${jobId}-${timestamp}`,
|
||||
data: resumeData,
|
||||
});
|
||||
|
||||
if (!tempResumeId) {
|
||||
throw new Error('Failed to get ID for imported resume');
|
||||
}
|
||||
|
||||
// 6. Export as PDF
|
||||
console.log(` Printing PDF...`);
|
||||
const pdfUrl = await exportResumePdf(tempResumeId);
|
||||
|
||||
if (!pdfUrl) {
|
||||
throw new Error('Reactive Resume did not return a PDF URL');
|
||||
}
|
||||
|
||||
// 7. Download PDF
|
||||
// Generate PDF using Python script - output directly to our data folder
|
||||
const outputFilename = `resume_${jobId}.pdf`;
|
||||
const outputPath = join(OUTPUT_DIR, outputFilename);
|
||||
|
||||
console.log(` Downloading PDF from ${pdfUrl}...`);
|
||||
const pdfResponse = await fetch(pdfUrl);
|
||||
if (!pdfResponse.ok) {
|
||||
throw new Error(`Failed to download PDF (${pdfResponse.status}): ${pdfResponse.statusText}`);
|
||||
}
|
||||
await runPythonPdfGenerator(tempResumePath, outputFilename, OUTPUT_DIR);
|
||||
|
||||
const buffer = await pdfResponse.arrayBuffer();
|
||||
await writeFile(outputPath, Buffer.from(buffer));
|
||||
// Cleanup temp file
|
||||
try {
|
||||
const { unlink } = await import('fs/promises');
|
||||
await unlink(tempResumePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
console.log(`✅ PDF generated: ${outputPath}`);
|
||||
|
||||
@ -182,19 +177,44 @@ export async function generatePdf(
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`❌ PDF generation failed: ${message}`);
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
// 8. Cleanup temp resume
|
||||
if (tempResumeId) {
|
||||
try {
|
||||
console.log(` Cleaning up temporary resume ${tempResumeId}...`);
|
||||
await deleteResume(tempResumeId);
|
||||
} catch (cleanupError) {
|
||||
console.warn(` ⚠️ Failed to delete temporary resume ${tempResumeId}:`, cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the Python RXResume automation script.
|
||||
*/
|
||||
async function runPythonPdfGenerator(
|
||||
jsonPath: string,
|
||||
outputFilename: string,
|
||||
outputDir: string
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use the virtual environment's Python (or system python in Docker)
|
||||
const pythonPath = process.env.PYTHON_PATH || join(RESUME_GEN_DIR, '.venv', 'bin', 'python');
|
||||
|
||||
const child = spawn(pythonPath, ['rxresume_automation.py'], {
|
||||
cwd: RESUME_GEN_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
RESUME_JSON_PATH: jsonPath,
|
||||
OUTPUT_FILENAME: outputFilename,
|
||||
OUTPUT_DIR: outputDir,
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Python script exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a PDF exists for a job.
|
||||
*/
|
||||
|
||||
32
orchestrator/src/server/services/profile.test.ts
Normal file
32
orchestrator/src/server/services/profile.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { getProfile } from './profile.js';
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const fn = vi.fn();
|
||||
return {
|
||||
readFile: fn,
|
||||
default: {
|
||||
readFile: fn
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('getProfile failure', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should throw an error if the profile file does not exist', async () => {
|
||||
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
||||
|
||||
await expect(getProfile('/non/existent/path.json', true)).rejects.toThrow('ENOENT: no such file or directory');
|
||||
});
|
||||
|
||||
it('should throw an error if the profile file is invalid JSON', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue('invalid json');
|
||||
|
||||
await expect(getProfile('/invalid/json.json', true)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
48
orchestrator/src/server/services/profile.ts
Normal file
48
orchestrator/src/server/services/profile.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { getDataDir } from '../config/dataDir.js';
|
||||
|
||||
export const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(getDataDir(), 'resume.json');
|
||||
|
||||
let cachedProfile: any = null;
|
||||
let cachedProfilePath: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the base resume profile from resume.json.
|
||||
* Caches the result since it doesn't change often.
|
||||
* @param profilePath Optional absolute path to profile JSON. Defaults to base.json.
|
||||
* @param forceRefresh Force reload from disk.
|
||||
*/
|
||||
export async function getProfile(profilePath?: string, forceRefresh = false): Promise<any> {
|
||||
const targetPath = profilePath || DEFAULT_PROFILE_PATH;
|
||||
|
||||
if (cachedProfile && cachedProfilePath === targetPath && !forceRefresh) {
|
||||
return cachedProfile;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(targetPath, 'utf-8');
|
||||
cachedProfile = JSON.parse(content);
|
||||
cachedProfilePath = targetPath;
|
||||
return cachedProfile;
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load profile from ${targetPath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the person's name from the profile.
|
||||
*/
|
||||
export async function getPersonName(): Promise<string> {
|
||||
const profile = await getProfile();
|
||||
return profile?.basics?.name || 'Resume';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the profile cache.
|
||||
*/
|
||||
export function clearProfileCache(): void {
|
||||
cachedProfile = null;
|
||||
}
|
||||
@ -1,8 +1,27 @@
|
||||
/**
|
||||
* Service for AI-powered project selection for resumes.
|
||||
*/
|
||||
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
|
||||
import type { ResumeProjectSelectionItem } from './resumeProjects.js';
|
||||
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
/** JSON schema for project selection response */
|
||||
const PROJECT_SELECTION_SCHEMA: JsonSchemaDefinition = {
|
||||
name: 'project_selection',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
selectedProjectIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of project IDs to include on the resume',
|
||||
},
|
||||
},
|
||||
required: ['selectedProjectIds'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
export async function pickProjectIdsForJob(args: {
|
||||
jobDescription: string;
|
||||
@ -15,15 +34,16 @@ export async function pickProjectIdsForJob(args: {
|
||||
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
|
||||
if (eligibleIds.size === 0) return [];
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
if (!process.env.OPENROUTER_API_KEY) {
|
||||
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||
}
|
||||
|
||||
const overrideModel = await getSetting('model');
|
||||
const overrideModelProjectSelection = await getSetting('modelProjectSelection');
|
||||
const [overrideModel, overrideModelProjectSelection] = await Promise.all([
|
||||
getSetting('model'),
|
||||
getSetting('modelProjectSelection'),
|
||||
]);
|
||||
// Precedence: Project-specific override > Global override > Env var > Default
|
||||
const model = overrideModelProjectSelection || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const model = overrideModelProjectSelection || overrideModel || process.env.MODEL || 'google/gemini-3-flash-preview';
|
||||
|
||||
const prompt = buildProjectSelectionPrompt({
|
||||
jobDescription: args.jobDescription,
|
||||
@ -31,53 +51,39 @@ export async function pickProjectIdsForJob(args: {
|
||||
desiredCount,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'http://localhost',
|
||||
'X-Title': 'JobOpsOrchestrator',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
const result = await callOpenRouter<{ selectedProjectIds: string[] }>({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
jsonSchema: PROJECT_SELECTION_SCHEMA,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content;
|
||||
if (!content) throw new Error('No content in response');
|
||||
|
||||
const parsed = JSON.parse(content) as any;
|
||||
const selectedProjectIds = Array.isArray(parsed?.selectedProjectIds) ? parsed.selectedProjectIds : [];
|
||||
const unique: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of selectedProjectIds) {
|
||||
if (typeof id !== 'string') continue;
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!eligibleIds.has(trimmed)) continue;
|
||||
if (seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
unique.push(trimmed);
|
||||
if (unique.length >= desiredCount) break;
|
||||
}
|
||||
|
||||
if (unique.length === 0) {
|
||||
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||
}
|
||||
|
||||
return unique;
|
||||
} catch {
|
||||
if (!result.success) {
|
||||
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||
}
|
||||
|
||||
const selectedProjectIds = Array.isArray(result.data?.selectedProjectIds)
|
||||
? result.data.selectedProjectIds
|
||||
: [];
|
||||
|
||||
// Validate and dedupe the returned IDs
|
||||
const unique: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of selectedProjectIds) {
|
||||
if (typeof id !== 'string') continue;
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!eligibleIds.has(trimmed)) continue;
|
||||
if (seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
unique.push(trimmed);
|
||||
if (unique.length >= desiredCount) break;
|
||||
}
|
||||
|
||||
if (unique.length === 0) {
|
||||
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||
}
|
||||
|
||||
return unique;
|
||||
}
|
||||
|
||||
function buildProjectSelectionPrompt(args: {
|
||||
@ -167,4 +173,3 @@ function truncate(input: string, maxChars: number): string {
|
||||
if (input.length <= maxChars) return input;
|
||||
return `${input.slice(0, maxChars - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
|
||||
@ -66,7 +66,7 @@ describe('Resume Projects Logic', () => {
|
||||
});
|
||||
|
||||
it('should ensure maxProjects is at least len(locked)', () => {
|
||||
const input = {
|
||||
const input = {
|
||||
maxProjects: 1, // Too small
|
||||
lockedProjectIds: ['a', 'b'],
|
||||
aiSelectableProjectIds: []
|
||||
@ -105,6 +105,7 @@ describe('Resume Projects Logic', () => {
|
||||
// p1 is visible in base, so it should be locked by default
|
||||
expect(result.resumeProjects.lockedProjectIds).toEqual(['p1']);
|
||||
expect(result.resumeProjects.aiSelectableProjectIds).toEqual(['p2', 'p3']);
|
||||
expect(result.resumeProjects.maxProjects).toBe(3);
|
||||
});
|
||||
|
||||
it('should apply valid overrides', () => {
|
||||
@ -126,7 +127,7 @@ describe('Resume Projects Logic', () => {
|
||||
});
|
||||
|
||||
it('should handle invalid overrides by falling back to defaults', () => {
|
||||
const result = rp.resolveResumeProjectsSettings({
|
||||
const result = rp.resolveResumeProjectsSettings({
|
||||
catalog: mockCatalog,
|
||||
overrideRaw: '{"broken json'
|
||||
});
|
||||
|
||||
@ -1,41 +1,6 @@
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import { getResume } from './rxresume.js';
|
||||
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from '../../shared/types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const DEFAULT_RESUME_PROFILE_PATH =
|
||||
process.env.RESUME_PROFILE_PATH || join(__dirname, '../../../../resume-generator/base.json');
|
||||
|
||||
type ResumeProjectSelectionItem = ResumeProjectCatalogItem & { summaryText: string };
|
||||
|
||||
export async function loadResumeProfile(profilePath: string = DEFAULT_RESUME_PROFILE_PATH): Promise<unknown> {
|
||||
const rxResumeBaseResumeId = await getSetting('rxResumeBaseResumeId');
|
||||
|
||||
if (rxResumeBaseResumeId) {
|
||||
try {
|
||||
const resume = await getResume(rxResumeBaseResumeId);
|
||||
return resume.data;
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load resume from Reactive Resume (${rxResumeBaseResumeId}):`, error);
|
||||
throw new Error(`Failed to load profile from Reactive Resume (ID: ${rxResumeBaseResumeId}). Please check your API key and connection.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to local file
|
||||
try {
|
||||
const { readFile } = await import('fs/promises');
|
||||
const content = await readFile(profilePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ No local profile found at ${profilePath} and no Reactive Resume base ID is configured. Reactive Resume integration is required for tailoring.`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function extractProjectsFromProfile(profile: unknown): {
|
||||
catalog: ResumeProjectCatalogItem[];
|
||||
selectionItems: ResumeProjectSelectionItem[];
|
||||
@ -78,7 +43,7 @@ export function buildDefaultResumeProjectsSettings(
|
||||
.filter((id) => !lockedSet.has(id));
|
||||
|
||||
const total = catalog.length;
|
||||
const preferredMax = Math.max(lockedProjectIds.length, 4);
|
||||
const preferredMax = Math.max(lockedProjectIds.length, 3);
|
||||
const maxProjects = total === 0 ? 0 : Math.min(total, preferredMax);
|
||||
|
||||
return normalizeResumeProjectsSettings(
|
||||
@ -181,4 +146,3 @@ function uniqueStrings(values: string[]): string[] {
|
||||
}
|
||||
|
||||
export type { ResumeProjectSelectionItem };
|
||||
|
||||
|
||||
507
orchestrator/src/server/services/rxresume-client.test.ts
Normal file
507
orchestrator/src/server/services/rxresume-client.test.ts
Normal file
@ -0,0 +1,507 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { RxResumeClient } from './rxresume-client.js';
|
||||
|
||||
describe('RxResumeClient', () => {
|
||||
describe('verifyCredentials (static)', () => {
|
||||
it('returns ok: true for successful login', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/auth/login',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ identifier: 'test@example.com', password: 'password123' }),
|
||||
})
|
||||
);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns ok: false with status 401 for invalid credentials', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => JSON.stringify({ message: 'InvalidCredentials' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'wrong@example.com',
|
||||
'badpassword',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.message).toBe('InvalidCredentials');
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns ok: false with error message for other HTTP errors', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => JSON.stringify({ error: 'Internal Server Error' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.message).toBe('Internal Server Error');
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns ok: false with statusMessage from response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => JSON.stringify({ statusMessage: 'Account suspended' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.message).toBe('Account suspended');
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('handles network errors gracefully', async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error('Network timeout'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.message).toBe('Network timeout');
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('handles non-JSON error response body', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: async () => 'Bad Gateway',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(502);
|
||||
// Should handle gracefully even if body is not JSON
|
||||
expect(result).toBeDefined();
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('handles empty response body', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(404);
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('handles string response directly', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => '"Direct string error"',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await RxResumeClient.verifyCredentials(
|
||||
'test@example.com',
|
||||
'password123',
|
||||
'https://mock.rxresume.test'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.message).toBe('Direct string error');
|
||||
}
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses default baseURL when not provided', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await RxResumeClient.verifyCredentials('test@example.com', 'password123');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://v4.rxresu.me/api/auth/login',
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance methods', () => {
|
||||
let client: RxResumeClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new RxResumeClient('https://mock.rxresume.test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('returns access token on successful login', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ accessToken: 'mock-token-123' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const token = await client.login('test@example.com', 'password123');
|
||||
|
||||
expect(token).toBe('mock-token-123');
|
||||
});
|
||||
|
||||
it('handles token in data.accessToken format', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: { accessToken: 'nested-token' } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const token = await client.login('test@example.com', 'password123');
|
||||
|
||||
expect(token).toBe('nested-token');
|
||||
});
|
||||
|
||||
it('handles token field instead of accessToken', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ token: 'alt-token-field' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const token = await client.login('test@example.com', 'password123');
|
||||
|
||||
expect(token).toBe('alt-token-field');
|
||||
});
|
||||
|
||||
it('throws error on login failure', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => 'Unauthorized',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.login('wrong@example.com', 'badpass')).rejects.toThrow(
|
||||
'Login failed: HTTP 401'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when token is not found in response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ user: { id: '123' } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.login('test@example.com', 'password123')).rejects.toThrow(
|
||||
'could not locate access token'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('returns resume id on successful creation', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ id: 'resume-id-123' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const id = await client.create({ basics: { name: 'Test' } }, 'mock-token');
|
||||
|
||||
expect(id).toBe('resume-id-123');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/resume/import',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer mock-token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles id in nested data.resume.id format', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: { resume: { id: 'nested-resume-id' } } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const id = await client.create({}, 'mock-token');
|
||||
|
||||
expect(id).toBe('nested-resume-id');
|
||||
});
|
||||
|
||||
it('throws error on creation failure', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => 'Invalid resume data',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.create({}, 'mock-token')).rejects.toThrow('Create failed: HTTP 400');
|
||||
});
|
||||
|
||||
it('throws error when id is not found in response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.create({}, 'mock-token')).rejects.toThrow(
|
||||
'could not locate resume id'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('print', () => {
|
||||
it('returns print URL on success', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: 'https://pdf.rxresume.test/print/123' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const url = await client.print('resume-123', 'mock-token');
|
||||
|
||||
expect(url).toBe('https://pdf.rxresume.test/print/123');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/resume/print/resume-123',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer mock-token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles href field instead of url', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ href: 'https://alt-url.test' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const url = await client.print('resume-123', 'mock-token');
|
||||
|
||||
expect(url).toBe('https://alt-url.test');
|
||||
});
|
||||
|
||||
it('throws error on print failure', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => 'Resume not found',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.print('nonexistent', 'mock-token')).rejects.toThrow(
|
||||
'Print failed: HTTP 404'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when URL is not found in response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: 'queued' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.print('resume-123', 'mock-token')).rejects.toThrow(
|
||||
'could not locate URL'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes resume ID in URL', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: 'https://test.com' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await client.print('resume with spaces', 'mock-token');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/resume/print/resume%20with%20spaces',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('completes successfully on 200 response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.delete('resume-123', 'mock-token')).resolves.toBeUndefined();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/resume/resume-123',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer mock-token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('completes successfully on 204 No Content', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false, // 204 is technically not "ok" in some implementations
|
||||
status: 204,
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.delete('resume-123', 'mock-token')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws error on delete failure', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => 'Forbidden',
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await expect(client.delete('resume-123', 'mock-token')).rejects.toThrow(
|
||||
'Delete failed: HTTP 403'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes resume ID in URL', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await client.delete('resume/with/slashes', 'mock-token');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://mock.rxresume.test/api/resume/resume%2Fwith%2Fslashes',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('default baseURL', () => {
|
||||
it('uses https://v4.rxresu.me by default', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ accessToken: 'token' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const client = new RxResumeClient();
|
||||
await client.login('test@example.com', 'password');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://v4.rxresu.me/api/auth/login',
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
});
|
||||
213
orchestrator/src/server/services/rxresume-client.ts
Normal file
213
orchestrator/src/server/services/rxresume-client.ts
Normal file
@ -0,0 +1,213 @@
|
||||
// rxresume-client.ts
|
||||
// Minimal client for https://v4.rxresu.me
|
||||
// Currently only verifyCredentials is in use; other methods are reserved for future use.
|
||||
//
|
||||
// NOTE (critical): Credentials should never be hardcoded or logged.
|
||||
|
||||
type AnyObj = Record<string, unknown>;
|
||||
|
||||
export type VerifyResult =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false;
|
||||
status: number;
|
||||
// Message is best-effort; server responses vary.
|
||||
message?: string;
|
||||
// Some APIs include error codes/details.
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export class RxResumeClient {
|
||||
constructor(private readonly baseURL = 'https://v4.rxresu.me') { }
|
||||
|
||||
/**
|
||||
* Verify a username/password combo WITHOUT persisting a logged-in session.
|
||||
*
|
||||
* Reality check:
|
||||
* - Most sites only expose "verify" by attempting login.
|
||||
* - This method does a stateless request to test credentials.
|
||||
*/
|
||||
static async verifyCredentials(
|
||||
identifier: string,
|
||||
password: string,
|
||||
baseURL = 'https://v4.rxresu.me'
|
||||
): Promise<VerifyResult> {
|
||||
try {
|
||||
const res = await fetch(`${baseURL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
// No credentials mode - we don't want to persist cookies
|
||||
});
|
||||
|
||||
if (res.ok) return { ok: true };
|
||||
|
||||
// Best-effort message extraction
|
||||
let data: AnyObj = {};
|
||||
try {
|
||||
const text = await res.text();
|
||||
data = text ? (JSON.parse(text) as AnyObj) : {};
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
|
||||
const message =
|
||||
(typeof data === 'string' ? data : undefined) ??
|
||||
(typeof data?.message === 'string' ? data.message : undefined) ??
|
||||
(typeof data?.error === 'string' ? data.error : undefined) ??
|
||||
(typeof data?.statusMessage === 'string' ? data.statusMessage : undefined);
|
||||
|
||||
return { ok: false, status: res.status, message, details: data };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
message: error instanceof Error ? error.message : 'Network error',
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RESERVED FOR FUTURE USE
|
||||
// The following methods support full resume lifecycle management via the
|
||||
// RxResume API. They are not currently used but are kept for future features.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Returns the auth token on success.
|
||||
*/
|
||||
async login(identifier: string, password: string): Promise<string> {
|
||||
const res = await fetch(`${this.baseURL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Login failed: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as AnyObj;
|
||||
// The API may return the token in different ways
|
||||
const token =
|
||||
data?.accessToken ??
|
||||
data?.access_token ??
|
||||
data?.token ??
|
||||
(data?.data as AnyObj)?.accessToken ??
|
||||
(data?.data as AnyObj)?.token;
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new Error(
|
||||
`Login succeeded but could not locate access token in response. Response keys: ${Object.keys(data).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/resume/import
|
||||
*/
|
||||
async create(resumeData: unknown, token: string): Promise<string> {
|
||||
const res = await fetch(`${this.baseURL}/api/resume/import`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ data: resumeData }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Create failed: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const d = (await res.json()) as AnyObj;
|
||||
const id =
|
||||
d?.id ??
|
||||
(d?.data as AnyObj)?.id ??
|
||||
(d?.resume as AnyObj)?.id ??
|
||||
(d?.result as AnyObj)?.id ??
|
||||
(d?.payload as AnyObj)?.id ??
|
||||
((d?.data as AnyObj)?.resume as AnyObj)?.id;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
throw new Error(
|
||||
`Create succeeded but could not locate resume id in response. Response keys: ${Object.keys(d).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/resume/print/:id
|
||||
* Returns the print URL from the response.
|
||||
*/
|
||||
async print(resumeId: string, token: string): Promise<string> {
|
||||
const res = await fetch(
|
||||
`${this.baseURL}/api/resume/print/${encodeURIComponent(resumeId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Print failed: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const d = (await res.json()) as AnyObj;
|
||||
const url =
|
||||
d?.url ??
|
||||
d?.href ??
|
||||
(d?.data as AnyObj)?.url ??
|
||||
(d?.data as AnyObj)?.href ??
|
||||
(d?.result as AnyObj)?.url ??
|
||||
(d?.result as AnyObj)?.href;
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new Error(
|
||||
`Print succeeded but could not locate URL in response. Response: ${JSON.stringify(d)}`
|
||||
);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/resume/:id
|
||||
*/
|
||||
async delete(resumeId: string, token: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${this.baseURL}/api/resume/${encodeURIComponent(resumeId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Delete failed: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { resumeDataSchema } from "../../shared/rxresume-schema";
|
||||
import { resumeDataSchema } from "../../shared/rxresume-schema.js";
|
||||
|
||||
export interface RxResumeResponse {
|
||||
id: string;
|
||||
|
||||
241
orchestrator/src/server/services/scorer.test.ts
Normal file
241
orchestrator/src/server/services/scorer.test.ts
Normal file
@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Tests for scorer.ts - focusing on robust JSON parsing from AI responses
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJsonFromContent } from './scorer.js';
|
||||
|
||||
describe('parseJsonFromContent', () => {
|
||||
describe('valid JSON inputs', () => {
|
||||
it('should parse clean JSON object', () => {
|
||||
const input = '{"score": 85, "reason": "Great match"}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(85);
|
||||
expect(result.reason).toBe('Great match');
|
||||
});
|
||||
|
||||
it('should parse JSON with extra whitespace', () => {
|
||||
const input = ' { "score" : 75 , "reason" : "Good fit" } ';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(75);
|
||||
expect(result.reason).toBe('Good fit');
|
||||
});
|
||||
|
||||
it('should parse JSON with newlines', () => {
|
||||
const input = `{
|
||||
"score": 90,
|
||||
"reason": "Excellent match for the role"
|
||||
}`;
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(90);
|
||||
expect(result.reason).toBe('Excellent match for the role');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown code fences', () => {
|
||||
it('should strip ```json code fences', () => {
|
||||
const input = '```json\n{"score": 80, "reason": "Match"}\n```';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(80);
|
||||
});
|
||||
|
||||
it('should strip ```JSON code fences (uppercase)', () => {
|
||||
const input = '```JSON\n{"score": 80, "reason": "Match"}\n```';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(80);
|
||||
});
|
||||
|
||||
it('should strip ``` code fences without language specifier', () => {
|
||||
const input = '```\n{"score": 70, "reason": "Decent"}\n```';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(70);
|
||||
});
|
||||
|
||||
it('should handle nested code fence patterns', () => {
|
||||
const input = 'Here is the score:\n```json\n{"score": 65, "reason": "Partial match"}\n```\nEnd.';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(65);
|
||||
});
|
||||
});
|
||||
|
||||
describe('surrounding text', () => {
|
||||
it('should extract JSON from text before', () => {
|
||||
const input = 'Based on my analysis, here is my evaluation: {"score": 55, "reason": "Limited match"}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(55);
|
||||
});
|
||||
|
||||
it('should extract JSON from text after', () => {
|
||||
const input = '{"score": 60, "reason": "Moderate match"} I hope this helps!';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(60);
|
||||
});
|
||||
|
||||
it('should extract JSON from surrounding text on both sides', () => {
|
||||
const input = 'Here is my response:\n\n{"score": 45, "reason": "Below average fit"}\n\nLet me know if you need more details.';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(45);
|
||||
});
|
||||
});
|
||||
|
||||
describe('common JSON formatting issues', () => {
|
||||
it('should handle trailing comma before closing brace', () => {
|
||||
const input = '{"score": 78, "reason": "Good skills",}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(78);
|
||||
});
|
||||
|
||||
it('should handle single quotes instead of double quotes', () => {
|
||||
const input = "{'score': 82, 'reason': 'Strong candidate'}";
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(82);
|
||||
});
|
||||
|
||||
it('should handle unquoted keys', () => {
|
||||
const input = '{score: 77, reason: "Reasonable match"}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(77);
|
||||
});
|
||||
|
||||
it('should handle mixed issues (trailing comma, single quotes)', () => {
|
||||
const input = "{'score': 68, 'reason': 'Average fit',}";
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(68);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decimal scores', () => {
|
||||
it('should parse and round decimal scores', () => {
|
||||
// parseJsonFromContent returns raw value for valid JSON; rounding only in regex fallback
|
||||
const input = '{"score": 85.7, "reason": "Very good match"}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(85.7);
|
||||
});
|
||||
|
||||
it('should parse decimal scores in malformed text', () => {
|
||||
const input = 'The score is score: 72.3, reason: "Above average"';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(72);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed responses - regex fallback', () => {
|
||||
it('should extract score from completely malformed response', () => {
|
||||
const input = 'I think the score should be score: 50 and the reason: "Average candidate"';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(50);
|
||||
});
|
||||
|
||||
it('should extract score with equals sign syntax', () => {
|
||||
const input = 'score = 88, reason = "Excellent match"';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(88);
|
||||
});
|
||||
|
||||
it('should handle reason with special characters', () => {
|
||||
const input = '{"score": 73, "reason": "Good match! The candidate\'s skills align well."}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(73);
|
||||
});
|
||||
|
||||
it('should provide default reason when only score is extractable', () => {
|
||||
const input = 'I rate this candidate 85 out of 100 - score: 85';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(85);
|
||||
expect(result.reason).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle zero score', () => {
|
||||
const input = '{"score": 0, "reason": "No match at all"}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle score of 100', () => {
|
||||
const input = '{"score": 100, "reason": "Perfect candidate"}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(100);
|
||||
});
|
||||
|
||||
it('should handle empty reason', () => {
|
||||
const input = '{"score": 50, "reason": ""}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(50);
|
||||
expect(result.reason).toBe('');
|
||||
});
|
||||
|
||||
it('should handle multiline reason', () => {
|
||||
const input = `{"score": 70, "reason": "Good skills match. Experience is a bit lacking."}`;
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(70);
|
||||
expect(result.reason).toContain('Good skills match');
|
||||
});
|
||||
|
||||
it('should handle unicode in reason', () => {
|
||||
const input = '{"score": 80, "reason": "Great match ✓ for this role"}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('failure cases', () => {
|
||||
it('should throw when no score can be extracted', () => {
|
||||
const input = 'This is just plain text with no JSON or score.';
|
||||
expect(() => parseJsonFromContent(input)).toThrow('Unable to parse JSON from model response');
|
||||
});
|
||||
|
||||
it('should throw for empty input', () => {
|
||||
expect(() => parseJsonFromContent('')).toThrow('Unable to parse JSON from model response');
|
||||
});
|
||||
|
||||
it('should throw for only whitespace', () => {
|
||||
expect(() => parseJsonFromContent(' \n\t ')).toThrow('Unable to parse JSON from model response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world AI responses', () => {
|
||||
it('should handle GPT-style verbose response', () => {
|
||||
const input = `Based on my analysis of the job description and candidate profile, I have evaluated the fit:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"score": 72,
|
||||
"reason": "Strong React and TypeScript skills match. However, the role requires 5+ years experience which the candidate may not have."
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
This score reflects the candidate's technical capabilities while accounting for the experience gap.`;
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(72);
|
||||
expect(result.reason).toContain('React and TypeScript');
|
||||
});
|
||||
|
||||
it('should handle Claude-style response with thinking', () => {
|
||||
const input = `Let me evaluate this candidate against the job requirements.
|
||||
|
||||
{"score": 83, "reason": "Excellent frontend skills with React and modern tooling. Good culture fit based on startup experience."}`;
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(83);
|
||||
});
|
||||
|
||||
it('should handle response with JSON5-style comments', () => {
|
||||
// Some models output JSON5-like syntax with comments
|
||||
const input = `{
|
||||
"score": 67, // Good but not great
|
||||
"reason": "Matches most requirements but lacks cloud experience"
|
||||
}`;
|
||||
// This will fail standard parse but regex should catch it
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(67);
|
||||
});
|
||||
|
||||
it('should handle response with extra properties', () => {
|
||||
const input = '{"score": 79, "reason": "Good match", "confidence": "high", "breakdown": {"skills": 25, "experience": 20}}';
|
||||
const result = parseJsonFromContent(input);
|
||||
expect(result.score).toBe(79);
|
||||
expect(result.reason).toBe('Good match');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -4,105 +4,176 @@
|
||||
|
||||
import type { Job } from '../../shared/types.js';
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
|
||||
|
||||
interface SuitabilityResult {
|
||||
score: number; // 0-100
|
||||
reason: string; // Explanation
|
||||
}
|
||||
|
||||
/** JSON schema for suitability scoring response */
|
||||
const SCORING_SCHEMA: JsonSchemaDefinition = {
|
||||
name: 'job_suitability_score',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
score: {
|
||||
type: 'integer',
|
||||
description: 'Suitability score from 0 to 100',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: 'Brief 1-2 sentence explanation of the score',
|
||||
},
|
||||
},
|
||||
required: ['score', 'reason'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Score a job's suitability based on profile and job description.
|
||||
* Includes retry logic for when AI returns garbage responses.
|
||||
*/
|
||||
export async function scoreJobSuitability(
|
||||
job: Job,
|
||||
profile: Record<string, unknown>
|
||||
): Promise<SuitabilityResult> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
if (!process.env.OPENROUTER_API_KEY) {
|
||||
console.warn('⚠️ OPENROUTER_API_KEY not set, using mock scoring');
|
||||
return mockScore(job);
|
||||
}
|
||||
|
||||
const overrideModel = await getSetting('model');
|
||||
const overrideModelScorer = await getSetting('modelScorer');
|
||||
const [overrideModel, overrideModelScorer] = await Promise.all([
|
||||
getSetting('model'),
|
||||
getSetting('modelScorer'),
|
||||
]);
|
||||
// Precedence: Scorer-specific override > Global override > Env var > Default
|
||||
const model = overrideModelScorer || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
|
||||
const prompt = buildScoringPrompt(job, profile);
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'http://localhost',
|
||||
'X-Title': 'JobOpsOrchestrator',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('No content in response');
|
||||
}
|
||||
const model = overrideModelScorer || overrideModel || process.env.MODEL || 'google/gemini-3-flash-preview';
|
||||
|
||||
const parsed = parseJsonFromContent(content);
|
||||
return {
|
||||
score: Math.min(100, Math.max(0, parsed.score || 0)),
|
||||
reason: parsed.reason || 'No explanation provided',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to score job:', error);
|
||||
const prompt = buildScoringPrompt(job, profile);
|
||||
|
||||
const result = await callOpenRouter<{ score: number; reason: string }>({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
jsonSchema: SCORING_SCHEMA,
|
||||
maxRetries: 2,
|
||||
jobId: job.id,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`);
|
||||
return mockScore(job);
|
||||
}
|
||||
|
||||
const { score, reason } = result.data;
|
||||
|
||||
// Validate we got a reasonable response
|
||||
if (typeof score !== 'number' || isNaN(score)) {
|
||||
console.error(`❌ [Job ${job.id}] Invalid score in response, using mock scoring`);
|
||||
return mockScore(job);
|
||||
}
|
||||
|
||||
return {
|
||||
score: Math.min(100, Math.max(0, Math.round(score))),
|
||||
reason: reason || 'No explanation provided',
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonFromContent(content: string): { score?: number; reason?: string } {
|
||||
const trimmed = content.trim();
|
||||
const withoutFences = trimmed.replace(/```(?:json)?\s*|```/gi, '').trim();
|
||||
const candidate = withoutFences;
|
||||
/**
|
||||
* Robustly parse JSON from AI-generated content.
|
||||
* Handles common AI quirks: markdown fences, extra text, trailing commas, etc.
|
||||
*
|
||||
* @deprecated Use callOpenRouter with structured outputs instead. Kept for backwards compatibility with tests.
|
||||
*/
|
||||
export function parseJsonFromContent(content: string, jobId?: string): { score?: number; reason?: string } {
|
||||
const originalContent = content;
|
||||
let candidate = content.trim();
|
||||
|
||||
// Step 1: Remove markdown code fences (with or without language specifier)
|
||||
candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim();
|
||||
|
||||
// Step 2: Try to extract JSON object if there's surrounding text
|
||||
const jsonMatch = candidate.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
candidate = jsonMatch[0];
|
||||
}
|
||||
|
||||
// Step 3: Try direct parse first
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
const firstBrace = candidate.indexOf('{');
|
||||
const lastBrace = candidate.lastIndexOf('}');
|
||||
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
||||
const sliced = candidate.slice(firstBrace, lastBrace + 1);
|
||||
return JSON.parse(sliced);
|
||||
}
|
||||
throw new Error('Unable to parse JSON from model response');
|
||||
// Continue with sanitization
|
||||
}
|
||||
|
||||
// Step 4: Fix common JSON issues
|
||||
let sanitized = candidate;
|
||||
|
||||
// Remove JavaScript-style comments (// and /* */)
|
||||
sanitized = sanitized.replace(/\/\/[^\n]*/g, '');
|
||||
sanitized = sanitized.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
// Remove trailing commas before } or ]
|
||||
sanitized = sanitized.replace(/,\s*([\]}])/g, '$1');
|
||||
|
||||
// Fix unquoted keys: word: -> "word":
|
||||
// Be more careful - only match at start of object or after comma
|
||||
sanitized = sanitized.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
|
||||
|
||||
// Fix single quotes to double quotes
|
||||
sanitized = sanitized.replace(/'/g, '"');
|
||||
|
||||
// Remove ALL control characters (including newlines/tabs INSIDE string values which break JSON)
|
||||
// First, let's normalize the string - escape actual newlines inside strings
|
||||
sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, (match) => {
|
||||
if (match === '\n') return '\\n';
|
||||
if (match === '\r') return '\\r';
|
||||
if (match === '\t') return '\\t';
|
||||
return '';
|
||||
});
|
||||
|
||||
// Step 5: Try parsing the sanitized version
|
||||
try {
|
||||
return JSON.parse(sanitized);
|
||||
} catch {
|
||||
// Continue with more aggressive extraction
|
||||
}
|
||||
|
||||
// Step 6: Even more aggressive - try to rebuild a minimal valid JSON
|
||||
// by extracting just the score and reason values
|
||||
const scoreMatch = originalContent.match(/["']?score["']?\s*[:=]\s*(\d+(?:\.\d+)?)/i);
|
||||
const reasonMatch = originalContent.match(/["']?reason["']?\s*[:=]\s*["']([^"'\n]+)["']/i) ||
|
||||
originalContent.match(/["']?reason["']?\s*[:=]\s*["']?(.*?)["']?\s*[,}\n]/is);
|
||||
|
||||
if (scoreMatch) {
|
||||
const score = Math.round(parseFloat(scoreMatch[1]));
|
||||
const reason = reasonMatch ? reasonMatch[1].trim().replace(/[\x00-\x1F\x7F]/g, '') : 'Score extracted from malformed response';
|
||||
console.log(`⚠️ [Job ${jobId || 'unknown'}] Parsed score via regex fallback: ${score}`);
|
||||
return { score, reason };
|
||||
}
|
||||
|
||||
// Log the failure with full content for debugging
|
||||
console.error(`❌ [Job ${jobId || 'unknown'}] Failed to parse AI response. Raw content (first 500 chars):`,
|
||||
originalContent.substring(0, 500));
|
||||
console.error(` Sanitized content (first 500 chars):`, sanitized.substring(0, 500));
|
||||
|
||||
throw new Error('Unable to parse JSON from model response');
|
||||
}
|
||||
|
||||
function buildScoringPrompt(job: Job, profile: Record<string, unknown>): string {
|
||||
return `
|
||||
You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.
|
||||
return `You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.
|
||||
|
||||
Consider:
|
||||
- Skills match (technologies, frameworks, languages)
|
||||
- Experience level match
|
||||
- Location/remote work alignment
|
||||
- Industry/domain fit
|
||||
- Career growth potential
|
||||
SCORING CRITERIA:
|
||||
- Skills match (technologies, frameworks, languages): 0-30 points
|
||||
- Experience level match: 0-25 points
|
||||
- Location/remote work alignment: 0-15 points
|
||||
- Industry/domain fit: 0-15 points
|
||||
- Career growth potential: 0-15 points
|
||||
|
||||
Candidate Profile:
|
||||
CANDIDATE PROFILE:
|
||||
${JSON.stringify(profile, null, 2)}
|
||||
|
||||
Job Listing:
|
||||
JOB LISTING:
|
||||
Title: ${job.title}
|
||||
Employer: ${job.employer}
|
||||
Location: ${job.location || 'Not specified'}
|
||||
@ -110,33 +181,39 @@ Salary: ${job.salary || 'Not specified'}
|
||||
Degree Required: ${job.degreeRequired || 'Not specified'}
|
||||
Disciplines: ${job.disciplines || 'Not specified'}
|
||||
|
||||
Job Description:
|
||||
JOB DESCRIPTION:
|
||||
${job.jobDescription || 'No description available'}
|
||||
|
||||
Respond with JSON only (no code fences): { "score": <0-100>, "reason": "<brief explanation>" }
|
||||
`;
|
||||
IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.
|
||||
|
||||
REQUIRED FORMAT (exactly this structure):
|
||||
{"score": <integer 0-100>, "reason": "<1-2 sentence explanation>"}
|
||||
|
||||
EXAMPLE VALID RESPONSE:
|
||||
{"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`;
|
||||
}
|
||||
|
||||
|
||||
function mockScore(job: Job): SuitabilityResult {
|
||||
// Simple keyword-based scoring as fallback
|
||||
const jd = (job.jobDescription || '').toLowerCase();
|
||||
const title = job.title.toLowerCase();
|
||||
|
||||
|
||||
const goodKeywords = ['typescript', 'react', 'node', 'python', 'web', 'frontend', 'backend', 'fullstack', 'software', 'engineer', 'developer'];
|
||||
const badKeywords = ['senior', '5+ years', '10+ years', 'principal', 'staff', 'manager'];
|
||||
|
||||
|
||||
let score = 50;
|
||||
|
||||
|
||||
for (const kw of goodKeywords) {
|
||||
if (jd.includes(kw) || title.includes(kw)) score += 5;
|
||||
}
|
||||
|
||||
|
||||
for (const kw of badKeywords) {
|
||||
if (jd.includes(kw) || title.includes(kw)) score -= 10;
|
||||
}
|
||||
|
||||
|
||||
score = Math.min(100, Math.max(0, score));
|
||||
|
||||
|
||||
return {
|
||||
score,
|
||||
reason: 'Scored using keyword matching (API key not configured)',
|
||||
@ -160,6 +237,6 @@ export async function scoreAndRankJobs(
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
return scoredJobs.sort((a, b) => b.suitabilityScore - a.suitabilityScore);
|
||||
}
|
||||
|
||||
149
orchestrator/src/server/services/settings.ts
Normal file
149
orchestrator/src/server/services/settings.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { AppSettings } from '@shared/types.js';
|
||||
import * as settingsRepo from '@server/repositories/settings.js';
|
||||
import { getEnvSettingsData } from './envSettings.js';
|
||||
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
|
||||
import { getProfile } from './profile.js';
|
||||
|
||||
/**
|
||||
* Get the effective app settings, combining environment variables and database overrides.
|
||||
*/
|
||||
export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
// Parallelize slow operations
|
||||
const [overrides, profile] = await Promise.all([
|
||||
settingsRepo.getAllSettings(),
|
||||
getProfile().catch((error) => {
|
||||
console.warn('Failed to load base resume profile for settings:', error);
|
||||
return {};
|
||||
}),
|
||||
]);
|
||||
|
||||
const envSettings = await getEnvSettingsData(overrides);
|
||||
|
||||
const defaultModel = process.env.MODEL || 'google/gemini-3-flash-preview';
|
||||
const overrideModel = overrides.model ?? null;
|
||||
const model = overrideModel || defaultModel;
|
||||
|
||||
const overrideModelScorer = overrides.modelScorer ?? null;
|
||||
const modelScorer = overrideModelScorer || model;
|
||||
|
||||
const overrideModelTailoring = overrides.modelTailoring ?? null;
|
||||
const modelTailoring = overrideModelTailoring || model;
|
||||
|
||||
const overrideModelProjectSelection = overrides.modelProjectSelection ?? null;
|
||||
const modelProjectSelection = overrideModelProjectSelection || model;
|
||||
|
||||
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
|
||||
const overridePipelineWebhookUrl = overrides.pipelineWebhookUrl ?? null;
|
||||
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
|
||||
|
||||
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||
const overrideJobCompleteWebhookUrl = overrides.jobCompleteWebhookUrl ?? null;
|
||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const overrideResumeProjectsRaw = overrides.resumeProjects ?? null;
|
||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||
|
||||
const defaultUkvisajobsMaxJobs = 50;
|
||||
const overrideUkvisajobsMaxJobsRaw = overrides.ukvisajobsMaxJobs;
|
||||
const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null;
|
||||
const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs;
|
||||
|
||||
const defaultGradcrackerMaxJobsPerTerm = 50;
|
||||
const overrideGradcrackerMaxJobsPerTermRaw = overrides.gradcrackerMaxJobsPerTerm;
|
||||
const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null;
|
||||
const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm;
|
||||
|
||||
const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer';
|
||||
const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean);
|
||||
const overrideSearchTermsRaw = overrides.searchTerms;
|
||||
const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null;
|
||||
const searchTerms = overrideSearchTerms ?? defaultSearchTerms;
|
||||
|
||||
const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK';
|
||||
const overrideJobspyLocation = overrides.jobspyLocation ?? null;
|
||||
const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation;
|
||||
|
||||
const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10);
|
||||
const overrideJobspyResultsWantedRaw = overrides.jobspyResultsWanted;
|
||||
const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null;
|
||||
const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted;
|
||||
|
||||
const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10);
|
||||
const overrideJobspyHoursOldRaw = overrides.jobspyHoursOld;
|
||||
const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null;
|
||||
const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld;
|
||||
|
||||
const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK';
|
||||
const overrideJobspyCountryIndeed = overrides.jobspyCountryIndeed ?? null;
|
||||
const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed;
|
||||
|
||||
const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const overrideJobspySitesRaw = overrides.jobspySites;
|
||||
const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null;
|
||||
const jobspySites = overrideJobspySites ?? defaultJobspySites;
|
||||
|
||||
const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1';
|
||||
const overrideJobspyLinkedinFetchDescriptionRaw = overrides.jobspyLinkedinFetchDescription;
|
||||
const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw
|
||||
? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1'
|
||||
: null;
|
||||
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
|
||||
|
||||
const defaultShowSponsorInfo = true;
|
||||
const overrideShowSponsorInfoRaw = overrides.showSponsorInfo;
|
||||
const overrideShowSponsorInfo = overrideShowSponsorInfoRaw
|
||||
? overrideShowSponsorInfoRaw === 'true' || overrideShowSponsorInfoRaw === '1'
|
||||
: null;
|
||||
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
|
||||
|
||||
return {
|
||||
model,
|
||||
defaultModel,
|
||||
overrideModel,
|
||||
modelScorer,
|
||||
overrideModelScorer,
|
||||
modelTailoring,
|
||||
overrideModelTailoring,
|
||||
modelProjectSelection,
|
||||
overrideModelProjectSelection,
|
||||
pipelineWebhookUrl,
|
||||
defaultPipelineWebhookUrl,
|
||||
overridePipelineWebhookUrl,
|
||||
jobCompleteWebhookUrl,
|
||||
defaultJobCompleteWebhookUrl,
|
||||
overrideJobCompleteWebhookUrl,
|
||||
...resumeProjectsData,
|
||||
ukvisajobsMaxJobs,
|
||||
defaultUkvisajobsMaxJobs,
|
||||
overrideUkvisajobsMaxJobs,
|
||||
gradcrackerMaxJobsPerTerm,
|
||||
defaultGradcrackerMaxJobsPerTerm,
|
||||
overrideGradcrackerMaxJobsPerTerm,
|
||||
searchTerms,
|
||||
defaultSearchTerms,
|
||||
overrideSearchTerms,
|
||||
jobspyLocation,
|
||||
defaultJobspyLocation,
|
||||
overrideJobspyLocation,
|
||||
jobspyResultsWanted,
|
||||
defaultJobspyResultsWanted,
|
||||
overrideJobspyResultsWanted,
|
||||
jobspyHoursOld,
|
||||
defaultJobspyHoursOld,
|
||||
overrideJobspyHoursOld,
|
||||
jobspyCountryIndeed,
|
||||
defaultJobspyCountryIndeed,
|
||||
overrideJobspyCountryIndeed,
|
||||
jobspySites,
|
||||
defaultJobspySites,
|
||||
overrideJobspySites,
|
||||
jobspyLinkedinFetchDescription,
|
||||
defaultJobspyLinkedinFetchDescription,
|
||||
overrideJobspyLinkedinFetchDescription,
|
||||
showSponsorInfo,
|
||||
defaultShowSponsorInfo,
|
||||
overrideShowSponsorInfo,
|
||||
...envSettings,
|
||||
} as AppSettings;
|
||||
}
|
||||
@ -3,13 +3,12 @@
|
||||
*/
|
||||
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
import { callOpenRouter, type JsonSchemaDefinition } from './openrouter.js';
|
||||
|
||||
export interface TailoredData {
|
||||
summary: string;
|
||||
headline: string;
|
||||
skills: any[];
|
||||
skills: Array<{ name: string; keywords: string[] }>;
|
||||
}
|
||||
|
||||
export interface TailoringResult {
|
||||
@ -18,6 +17,46 @@ export interface TailoringResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** JSON schema for resume tailoring response */
|
||||
const TAILORING_SCHEMA: JsonSchemaDefinition = {
|
||||
name: 'resume_tailoring',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
headline: {
|
||||
type: 'string',
|
||||
description: 'Job title headline matching the JD exactly',
|
||||
},
|
||||
summary: {
|
||||
type: 'string',
|
||||
description: 'Tailored resume summary paragraph',
|
||||
},
|
||||
skills: {
|
||||
type: 'array',
|
||||
description: 'Skills sections with keywords tailored to the job',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Skill category name (e.g., Frontend, Backend)',
|
||||
},
|
||||
keywords: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of skills/technologies in this category',
|
||||
},
|
||||
},
|
||||
required: ['name', 'keywords'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['headline', 'summary', 'skills'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate tailored resume content (summary, headline, skills) for a job.
|
||||
*/
|
||||
@ -25,65 +64,44 @@ export async function generateTailoring(
|
||||
jobDescription: string,
|
||||
profile: Record<string, unknown>
|
||||
): Promise<TailoringResult> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
if (!process.env.OPENROUTER_API_KEY) {
|
||||
console.warn('⚠️ OPENROUTER_API_KEY not set, cannot generate tailoring');
|
||||
return { success: false, error: 'API key not configured' };
|
||||
}
|
||||
|
||||
const overrideModel = await getSetting('model');
|
||||
const overrideModelTailoring = await getSetting('modelTailoring');
|
||||
// Precedence: Tailoring-specific override > Global override > Env var > Default
|
||||
const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const prompt = buildTailoringPrompt(profile, jobDescription);
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'http://localhost',
|
||||
'X-Title': 'JobOpsOrchestrator',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('No content in response');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
// Basic validation
|
||||
if (!parsed.summary || !parsed.headline || !Array.isArray(parsed.skills)) {
|
||||
console.warn('⚠️ AI response missing required fields:', parsed);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
summary: sanitizeText(parsed.summary || ''),
|
||||
headline: sanitizeText(parsed.headline || ''),
|
||||
skills: parsed.skills || []
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: message };
|
||||
const [overrideModel, overrideModelTailoring] = await Promise.all([
|
||||
getSetting('model'),
|
||||
getSetting('modelTailoring'),
|
||||
]);
|
||||
// Precedence: Tailoring-specific override > Global override > Env var > Default
|
||||
const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'google/gemini-3-flash-preview';
|
||||
const prompt = buildTailoringPrompt(profile, jobDescription);
|
||||
|
||||
const result = await callOpenRouter<TailoredData>({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
jsonSchema: TAILORING_SCHEMA,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
const { summary, headline, skills } = result.data;
|
||||
|
||||
// Basic validation
|
||||
if (!summary || !headline || !Array.isArray(skills)) {
|
||||
console.warn('⚠️ AI response missing required fields:', result.data);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
summary: sanitizeText(summary || ''),
|
||||
headline: sanitizeText(headline || ''),
|
||||
skills: skills || []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -112,14 +130,14 @@ function buildTailoringPrompt(profile: Record<string, unknown>, jd: string): str
|
||||
},
|
||||
skills: (profile as any).sections?.skills || (profile as any).skills,
|
||||
projects: (profile as any).sections?.projects?.items?.map((p: any) => ({
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
keywords: p.keywords
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
keywords: p.keywords
|
||||
})),
|
||||
experience: (profile as any).sections?.experience?.items?.map((e: any) => ({
|
||||
company: e.company,
|
||||
position: e.position,
|
||||
summary: e.summary
|
||||
company: e.company,
|
||||
position: e.position,
|
||||
summary: e.summary
|
||||
}))
|
||||
};
|
||||
|
||||
@ -127,8 +145,8 @@ function buildTailoringPrompt(profile: Record<string, unknown>, jd: string): str
|
||||
You are an expert resume writer tailoring a profile for a specific job application.
|
||||
You must return a JSON object with three fields: "headline", "summary", and "skills".
|
||||
|
||||
JOB DESCRIPTION:
|
||||
${jd.slice(0, 3000)} ... (truncated if too long)
|
||||
JOB DESCRIPTION (JD):
|
||||
${jd}
|
||||
|
||||
MY PROFILE:
|
||||
${JSON.stringify(relevantProfile, null, 2)}
|
||||
|
||||
107
orchestrator/src/server/services/visa-sponsors/index.test.ts
Normal file
107
orchestrator/src/server/services/visa-sponsors/index.test.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateSponsorMatchSummary } from './index.js';
|
||||
import type { VisaSponsorSearchResult } from '../../../shared/types.js';
|
||||
|
||||
describe('calculateSponsorMatchSummary', () => {
|
||||
it('should return default values for empty results', () => {
|
||||
const results: VisaSponsorSearchResult[] = [];
|
||||
const summary = calculateSponsorMatchSummary(results);
|
||||
|
||||
expect(summary.sponsorMatchScore).toBe(0);
|
||||
expect(summary.sponsorMatchNames).toBeNull();
|
||||
});
|
||||
|
||||
it('should report the top match when it is not a perfect match', () => {
|
||||
const results: VisaSponsorSearchResult[] = [
|
||||
{
|
||||
score: 85,
|
||||
sponsor: { organisationName: 'Tech Corp' } as any,
|
||||
matchedName: 'tech corp'
|
||||
},
|
||||
{
|
||||
score: 60,
|
||||
sponsor: { organisationName: 'Other Ltd' } as any,
|
||||
matchedName: 'other'
|
||||
}
|
||||
];
|
||||
|
||||
const summary = calculateSponsorMatchSummary(results);
|
||||
|
||||
expect(summary.sponsorMatchScore).toBe(85);
|
||||
expect(summary.sponsorMatchNames).toBe(JSON.stringify(['Tech Corp']));
|
||||
});
|
||||
|
||||
it('should report a single perfect match', () => {
|
||||
const results: VisaSponsorSearchResult[] = [
|
||||
{
|
||||
score: 100,
|
||||
sponsor: { organisationName: 'Exact Match Ltd' } as any,
|
||||
matchedName: 'exact match'
|
||||
},
|
||||
{
|
||||
score: 90,
|
||||
sponsor: { organisationName: 'Close Match' } as any,
|
||||
matchedName: 'close'
|
||||
}
|
||||
];
|
||||
|
||||
const summary = calculateSponsorMatchSummary(results);
|
||||
|
||||
expect(summary.sponsorMatchScore).toBe(100);
|
||||
expect(summary.sponsorMatchNames).toBe(JSON.stringify(['Exact Match Ltd']));
|
||||
});
|
||||
|
||||
it('should report exactly two 100% matches when two or more exist', () => {
|
||||
const results: VisaSponsorSearchResult[] = [
|
||||
{
|
||||
score: 100,
|
||||
sponsor: { organisationName: 'First PerfectMatch' } as any,
|
||||
matchedName: 'match'
|
||||
},
|
||||
{
|
||||
score: 100,
|
||||
sponsor: { organisationName: 'Second PerfectMatch' } as any,
|
||||
matchedName: 'match'
|
||||
},
|
||||
{
|
||||
score: 100,
|
||||
sponsor: { organisationName: 'Third PerfectMatch' } as any,
|
||||
matchedName: 'match'
|
||||
},
|
||||
{
|
||||
score: 50,
|
||||
sponsor: { organisationName: 'Common Co' } as any,
|
||||
matchedName: 'common'
|
||||
}
|
||||
];
|
||||
|
||||
const summary = calculateSponsorMatchSummary(results);
|
||||
|
||||
expect(summary.sponsorMatchScore).toBe(100);
|
||||
const names = JSON.parse(summary.sponsorMatchNames!);
|
||||
expect(names).toHaveLength(2);
|
||||
expect(names).toContain('First PerfectMatch');
|
||||
expect(names).toContain('Second PerfectMatch');
|
||||
expect(names).not.toContain('Third PerfectMatch');
|
||||
});
|
||||
|
||||
it('should only report the single top result if no 100% matches exist', () => {
|
||||
const results: VisaSponsorSearchResult[] = [
|
||||
{
|
||||
score: 99,
|
||||
sponsor: { organisationName: 'Almost Perfect' } as any,
|
||||
matchedName: 'almost'
|
||||
},
|
||||
{
|
||||
score: 98,
|
||||
sponsor: { organisationName: 'Second Best' } as any,
|
||||
matchedName: 'best'
|
||||
}
|
||||
];
|
||||
|
||||
const summary = calculateSponsorMatchSummary(results);
|
||||
|
||||
expect(summary.sponsorMatchScore).toBe(99);
|
||||
expect(summary.sponsorMatchNames).toBe(JSON.stringify(['Almost Perfect']));
|
||||
});
|
||||
});
|
||||
@ -57,20 +57,20 @@ let updateError: string | null = null;
|
||||
*/
|
||||
export function normalizeCompanyName(name: string): string {
|
||||
let normalized = name.toLowerCase().trim();
|
||||
|
||||
|
||||
// Remove common punctuation and special chars
|
||||
normalized = normalized.replace(/[.,'"()[\]{}!?@#$%^&*+=|\\/<>:;`~]/g, ' ');
|
||||
|
||||
|
||||
// Remove suffixes
|
||||
for (const suffix of COMPANY_SUFFIXES) {
|
||||
// Word boundary matching
|
||||
const regex = new RegExp(`\\b${suffix}\\b`, 'gi');
|
||||
normalized = normalized.replace(regex, '');
|
||||
}
|
||||
|
||||
|
||||
// Collapse whitespace
|
||||
normalized = normalized.replace(/\s+/g, ' ').trim();
|
||||
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@ -81,27 +81,27 @@ export function normalizeCompanyName(name: string): string {
|
||||
export function calculateSimilarity(str1: string, str2: string): number {
|
||||
const s1 = str1.toLowerCase();
|
||||
const s2 = str2.toLowerCase();
|
||||
|
||||
|
||||
if (s1 === s2) return 100;
|
||||
if (s1.length === 0 || s2.length === 0) return 0;
|
||||
|
||||
|
||||
// Check if one contains the other
|
||||
if (s1.includes(s2) || s2.includes(s1)) {
|
||||
const longerLen = Math.max(s1.length, s2.length);
|
||||
const shorterLen = Math.min(s1.length, s2.length);
|
||||
return Math.round((shorterLen / longerLen) * 100);
|
||||
}
|
||||
|
||||
|
||||
// Levenshtein distance
|
||||
const matrix: number[][] = [];
|
||||
|
||||
|
||||
for (let i = 0; i <= s1.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= s2.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
|
||||
for (let i = 1; i <= s1.length; i++) {
|
||||
for (let j = 1; j <= s2.length; j++) {
|
||||
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
|
||||
@ -112,10 +112,10 @@ export function calculateSimilarity(str1: string, str2: string): number {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const distance = matrix[s1.length][s2.length];
|
||||
const maxLen = Math.max(s1.length, s2.length);
|
||||
|
||||
|
||||
return Math.round(((maxLen - distance) / maxLen) * 100);
|
||||
}
|
||||
|
||||
@ -125,12 +125,12 @@ export function calculateSimilarity(str1: string, str2: string): number {
|
||||
export function parseCsv(content: string): VisaSponsor[] {
|
||||
const lines = content.split('\n');
|
||||
const sponsors: VisaSponsor[] = [];
|
||||
|
||||
|
||||
// Skip header
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
|
||||
// Parse CSV with proper quote handling
|
||||
const fields = parseCSVLine(line);
|
||||
if (fields.length >= 5) {
|
||||
@ -143,7 +143,7 @@ export function parseCsv(content: string): VisaSponsor[] {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return sponsors;
|
||||
}
|
||||
|
||||
@ -154,11 +154,11 @@ function parseCSVLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
|
||||
if (char === '"' && !inQuotes) {
|
||||
inQuotes = true;
|
||||
} else if (char === '"' && inQuotes) {
|
||||
@ -176,7 +176,7 @@ function parseCSVLine(line: string): string[] {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fields.push(current.trim());
|
||||
return fields;
|
||||
}
|
||||
@ -186,7 +186,7 @@ function parseCSVLine(line: string): string[] {
|
||||
*/
|
||||
function getCsvFiles(): string[] {
|
||||
if (!fs.existsSync(DATA_DIR)) return [];
|
||||
|
||||
|
||||
return fs.readdirSync(DATA_DIR)
|
||||
.filter(f => f.endsWith('.csv'))
|
||||
.sort()
|
||||
@ -245,25 +245,25 @@ function cleanupOldCsvFiles(): void {
|
||||
*/
|
||||
async function extractCsvUrl(): Promise<string> {
|
||||
const pageUrl = 'https://www.gov.uk/government/publications/register-of-licensed-sponsors-workers';
|
||||
|
||||
|
||||
console.log('📄 Fetching gov.uk page to find CSV link...');
|
||||
const response = await fetch(pageUrl);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch gov.uk page: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
|
||||
// Look for the Worker and Temporary Worker CSV link
|
||||
const csvMatch = html.match(
|
||||
/href="(https:\/\/assets\.publishing\.service\.gov\.uk\/media\/[^"]+Worker_and_Temporary_Worker\.csv)"/
|
||||
);
|
||||
|
||||
|
||||
if (!csvMatch) {
|
||||
throw new Error('Could not find Worker and Temporary Worker CSV link on gov.uk page');
|
||||
}
|
||||
|
||||
|
||||
return csvMatch[1];
|
||||
}
|
||||
|
||||
@ -274,52 +274,52 @@ export async function downloadLatestCsv(): Promise<{ success: boolean; message:
|
||||
if (isUpdating) {
|
||||
return { success: false, message: 'Update already in progress' };
|
||||
}
|
||||
|
||||
|
||||
isUpdating = true;
|
||||
updateError = null;
|
||||
|
||||
|
||||
try {
|
||||
// Extract the CSV URL from the page
|
||||
const csvUrl = await extractCsvUrl();
|
||||
console.log(`📥 Downloading CSV from: ${csvUrl}`);
|
||||
|
||||
|
||||
const response = await fetch(csvUrl);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download CSV: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const csvContent = await response.text();
|
||||
|
||||
|
||||
// Validate CSV has content
|
||||
const sponsors = parseCsv(csvContent);
|
||||
if (sponsors.length === 0) {
|
||||
throw new Error('Downloaded CSV appears to be empty or invalid');
|
||||
}
|
||||
|
||||
|
||||
// Generate filename with date
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `visa_sponsors_${dateStr}.csv`;
|
||||
const filepath = path.join(DATA_DIR, filename);
|
||||
|
||||
|
||||
// Save the CSV
|
||||
fs.writeFileSync(filepath, csvContent);
|
||||
|
||||
|
||||
// Update metadata
|
||||
writeMetadata({
|
||||
lastUpdated: new Date().toISOString(),
|
||||
csvFile: filename,
|
||||
});
|
||||
|
||||
|
||||
// Cleanup old files
|
||||
cleanupOldCsvFiles();
|
||||
|
||||
|
||||
// Clear cache so next search loads new data
|
||||
sponsorsCache = null;
|
||||
cacheLoadedAt = null;
|
||||
|
||||
|
||||
console.log(`✅ Downloaded visa sponsor list: ${sponsors.length} sponsors`);
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully downloaded ${sponsors.length} sponsors`,
|
||||
@ -345,17 +345,17 @@ export function loadSponsors(): VisaSponsor[] {
|
||||
return sponsorsCache;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const metadata = readMetadata();
|
||||
if (!metadata.csvFile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const csvPath = path.join(DATA_DIR, metadata.csvFile);
|
||||
if (!fs.existsSync(csvPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(csvPath, 'utf-8');
|
||||
sponsorsCache = parseCsv(content);
|
||||
@ -375,26 +375,26 @@ export function searchSponsors(
|
||||
options: { limit?: number; minScore?: number } = {}
|
||||
): VisaSponsorSearchResult[] {
|
||||
const { limit = 50, minScore = 30 } = options;
|
||||
|
||||
|
||||
const sponsors = loadSponsors();
|
||||
if (sponsors.length === 0 || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const normalizedQuery = normalizeCompanyName(query);
|
||||
const results: VisaSponsorSearchResult[] = [];
|
||||
const seen = new Set<string>(); // Dedupe by org name
|
||||
|
||||
|
||||
for (const sponsor of sponsors) {
|
||||
// Skip if we've already seen this org name
|
||||
if (seen.has(sponsor.organisationName)) continue;
|
||||
seen.add(sponsor.organisationName);
|
||||
|
||||
|
||||
const normalizedSponsor = normalizeCompanyName(sponsor.organisationName);
|
||||
|
||||
|
||||
// Calculate similarity
|
||||
const score = calculateSimilarity(normalizedQuery, normalizedSponsor);
|
||||
|
||||
|
||||
if (score >= minScore) {
|
||||
results.push({
|
||||
sponsor,
|
||||
@ -403,20 +403,43 @@ export function searchSponsors(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Sort by score descending
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
|
||||
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match summary from search results
|
||||
*/
|
||||
export function calculateSponsorMatchSummary(
|
||||
results: VisaSponsorSearchResult[]
|
||||
): { sponsorMatchScore: number; sponsorMatchNames: string | null } {
|
||||
if (results.length === 0) {
|
||||
return { sponsorMatchScore: 0, sponsorMatchNames: null };
|
||||
}
|
||||
|
||||
const topScore = results[0].score;
|
||||
// Get all 100% matches, or just the top match
|
||||
const perfectMatches = results.filter(r => r.score === 100);
|
||||
const matchesToReport = perfectMatches.length >= 2
|
||||
? perfectMatches.slice(0, 2)
|
||||
: [results[0]];
|
||||
|
||||
return {
|
||||
sponsorMatchScore: topScore,
|
||||
sponsorMatchNames: JSON.stringify(matchesToReport.map(r => r.sponsor.organisationName)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of the visa sponsor service
|
||||
*/
|
||||
export function getStatus(): VisaSponsorStatus {
|
||||
const metadata = readMetadata();
|
||||
const sponsors = loadSponsors();
|
||||
|
||||
|
||||
return {
|
||||
lastUpdated: metadata.lastUpdated,
|
||||
csvPath: metadata.csvFile ? path.join(DATA_DIR, metadata.csvFile) : null,
|
||||
@ -449,12 +472,12 @@ function calculateNextUpdateTime(hour = 2): Date {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(hour, 0, 0, 0);
|
||||
|
||||
|
||||
// If we've passed the time today, schedule for tomorrow
|
||||
if (next <= now) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@ -472,12 +495,12 @@ function scheduleNextUpdate(hour = 2): void {
|
||||
if (scheduledTimer) {
|
||||
clearTimeout(scheduledTimer);
|
||||
}
|
||||
|
||||
|
||||
nextScheduledUpdateTime = calculateNextUpdateTime(hour);
|
||||
const delay = nextScheduledUpdateTime.getTime() - Date.now();
|
||||
|
||||
|
||||
console.log(`⏰ Next visa sponsor update scheduled for: ${nextScheduledUpdateTime.toISOString()}`);
|
||||
|
||||
|
||||
scheduledTimer = setTimeout(async () => {
|
||||
console.log('🔄 Running scheduled visa sponsor update...');
|
||||
await downloadLatestCsv();
|
||||
@ -510,7 +533,7 @@ export function stopScheduler(): void {
|
||||
*/
|
||||
export async function initialize(): Promise<void> {
|
||||
const metadata = readMetadata();
|
||||
|
||||
|
||||
if (!metadata.csvFile) {
|
||||
console.log('📥 No visa sponsor data found, downloading...');
|
||||
await downloadLatestCsv();
|
||||
@ -518,7 +541,7 @@ export async function initialize(): Promise<void> {
|
||||
const sponsors = loadSponsors();
|
||||
console.log(`✅ Visa sponsor service initialized with ${sponsors.length} sponsors`);
|
||||
}
|
||||
|
||||
|
||||
// Start the scheduler for automatic daily updates at 2 AM
|
||||
startScheduler(2);
|
||||
}
|
||||
|
||||
152
orchestrator/src/shared/rxresume-schema.test.ts
Normal file
152
orchestrator/src/shared/rxresume-schema.test.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { idSchema, skillSchema, resumeDataSchema } from './rxresume-schema.js';
|
||||
|
||||
describe('RxResume Schema Validation', () => {
|
||||
describe('idSchema (CUID2)', () => {
|
||||
it('should accept valid CUID2 IDs generated by the library', () => {
|
||||
// Generate real CUID2 IDs using the official library
|
||||
const validIds = [
|
||||
createId(),
|
||||
createId(),
|
||||
createId(),
|
||||
];
|
||||
|
||||
validIds.forEach(id => {
|
||||
const result = idSchema.safeParse(id);
|
||||
expect(result.success, `ID "${id}" should be valid`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid IDs like "skill-0"', () => {
|
||||
const invalidIds = [
|
||||
'skill-0', // contains hyphen
|
||||
'skill-1', // contains hyphen
|
||||
'skill-123', // contains hyphen
|
||||
'item_1', // contains underscore
|
||||
'ABC123', // uppercase letters
|
||||
'', // empty
|
||||
];
|
||||
|
||||
invalidIds.forEach(id => {
|
||||
const result = idSchema.safeParse(id);
|
||||
expect(result.success, `ID "${id}" should be invalid`).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('skillSchema', () => {
|
||||
it('should accept valid skill with CUID2 ID', () => {
|
||||
const validSkill = {
|
||||
id: createId(),
|
||||
visible: true,
|
||||
name: 'JavaScript',
|
||||
description: '',
|
||||
level: 3,
|
||||
keywords: ['ES6', 'TypeScript'],
|
||||
};
|
||||
|
||||
const result = skillSchema.safeParse(validSkill);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject skill with invalid ID format', () => {
|
||||
const invalidSkill = {
|
||||
id: 'skill-0', // Invalid CUID2
|
||||
visible: true,
|
||||
name: 'JavaScript',
|
||||
description: '',
|
||||
level: 3,
|
||||
keywords: ['ES6'],
|
||||
};
|
||||
|
||||
const result = skillSchema.safeParse(invalidSkill);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain('id');
|
||||
expect(result.error.issues[0].message).toContain('cuid2');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeDataSchema', () => {
|
||||
it('should reject resume with invalid skill IDs', () => {
|
||||
const resumeWithInvalidIds = {
|
||||
basics: {
|
||||
name: 'John Doe',
|
||||
headline: 'Developer',
|
||||
email: 'john@example.com',
|
||||
phone: '',
|
||||
location: '',
|
||||
url: { label: '', href: '' },
|
||||
customFields: [],
|
||||
picture: {
|
||||
url: '',
|
||||
size: 64,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 0,
|
||||
effects: { hidden: false, border: false, grayscale: false },
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
summary: { id: 'summary', name: 'Summary', columns: 1, separateLinks: true, visible: true, content: '' },
|
||||
skills: {
|
||||
id: 'skills',
|
||||
name: 'Skills',
|
||||
columns: 1,
|
||||
separateLinks: true,
|
||||
visible: true,
|
||||
items: [
|
||||
{
|
||||
id: 'skill-0', // Invalid!
|
||||
visible: true,
|
||||
name: 'JavaScript',
|
||||
description: '',
|
||||
level: 1,
|
||||
keywords: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
// Minimal required sections
|
||||
awards: { id: 'awards', name: 'Awards', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
certifications: { id: 'certifications', name: 'Certifications', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
education: { id: 'education', name: 'Education', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
experience: { id: 'experience', name: 'Experience', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
volunteer: { id: 'volunteer', name: 'Volunteer', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
interests: { id: 'interests', name: 'Interests', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
languages: { id: 'languages', name: 'Languages', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
profiles: { id: 'profiles', name: 'Profiles', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
projects: { id: 'projects', name: 'Projects', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
publications: { id: 'publications', name: 'Publications', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
references: { id: 'references', name: 'References', columns: 1, separateLinks: true, visible: true, items: [] },
|
||||
custom: {},
|
||||
},
|
||||
metadata: {
|
||||
template: 'rhyhorn',
|
||||
layout: [[['summary'], ['skills']]],
|
||||
css: { value: '', visible: false },
|
||||
page: { margin: 18, format: 'a4', options: { breakLine: true, pageNumbers: true } },
|
||||
theme: { background: '#ffffff', text: '#000000', primary: '#dc2626' },
|
||||
typography: {
|
||||
font: { family: 'IBM Plex Serif', subset: 'latin', variants: ['regular'], size: 14 },
|
||||
lineHeight: 1.5,
|
||||
hideIcons: false,
|
||||
underlineLinks: true,
|
||||
},
|
||||
notes: '',
|
||||
},
|
||||
};
|
||||
|
||||
const result = resumeDataSchema.safeParse(resumeWithInvalidIds);
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (!result.success) {
|
||||
// Should have error about the skill ID
|
||||
const idError = result.error.issues.find(
|
||||
issue => issue.path.join('.').includes('skills.items') && issue.path.includes('id')
|
||||
);
|
||||
expect(idError).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
49
orchestrator/src/shared/settings-schema.ts
Normal file
49
orchestrator/src/shared/settings-schema.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const resumeProjectsSchema = z.object({
|
||||
maxProjects: z.number().int().min(0).max(100),
|
||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
});
|
||||
|
||||
export const updateSettingsSchema = z.object({
|
||||
model: z.string().trim().max(200).nullable().optional(),
|
||||
modelScorer: z.string().trim().max(200).nullable().optional(),
|
||||
modelTailoring: z.string().trim().max(200).nullable().optional(),
|
||||
modelProjectSelection: z.string().trim().max(200).nullable().optional(),
|
||||
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
||||
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
||||
resumeProjects: resumeProjectsSchema.nullable().optional(),
|
||||
ukvisajobsMaxJobs: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
searchTerms: z.array(z.string().trim().min(1).max(200)).max(100).nullable().optional(),
|
||||
jobspyLocation: z.string().trim().max(100).nullable().optional(),
|
||||
jobspyResultsWanted: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
jobspyHoursOld: z.number().int().min(1).max(720).nullable().optional(),
|
||||
jobspyCountryIndeed: z.string().trim().max(100).nullable().optional(),
|
||||
jobspySites: z.array(z.string().trim().min(1).max(50)).max(20).nullable().optional(),
|
||||
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
|
||||
showSponsorInfo: z.boolean().nullable().optional(),
|
||||
openrouterApiKey: z.string().trim().max(2000).nullable().optional(),
|
||||
rxresumeEmail: z.string().trim().max(200).nullable().optional(),
|
||||
rxresumePassword: z.string().trim().max(2000).nullable().optional(),
|
||||
basicAuthUser: z.string().trim().max(200).nullable().optional(),
|
||||
basicAuthPassword: z.string().trim().max(2000).nullable().optional(),
|
||||
ukvisajobsEmail: z.string().trim().max(200).nullable().optional(),
|
||||
ukvisajobsPassword: z.string().trim().max(2000).nullable().optional(),
|
||||
webhookSecret: z.string().trim().max(2000).nullable().optional(),
|
||||
enableBasicAuth: z.boolean().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.enableBasicAuth) {
|
||||
if (!data.basicAuthUser || data.basicAuthUser.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Username is required when basic auth is enabled",
|
||||
path: ["basicAuthUser"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type UpdateSettingsInput = z.infer<typeof updateSettingsSchema>;
|
||||
export type ResumeProjectsSettingsInput = z.infer<typeof resumeProjectsSchema>;
|
||||
@ -50,6 +50,8 @@ export interface Job {
|
||||
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
||||
pdfPath: string | null; // Path to generated PDF
|
||||
notionPageId: string | null; // Notion page ID if synced
|
||||
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
|
||||
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
|
||||
|
||||
// JobSpy fields (nullable for non-JobSpy sources)
|
||||
jobType: string | null;
|
||||
@ -164,6 +166,8 @@ export interface UpdateJobInput {
|
||||
pdfPath?: string;
|
||||
notionPageId?: string;
|
||||
appliedAt?: string;
|
||||
sponsorMatchScore?: number;
|
||||
sponsorMatchNames?: string;
|
||||
}
|
||||
|
||||
export interface PipelineConfig {
|
||||
@ -264,6 +268,79 @@ export interface ResumeProjectsSettings {
|
||||
aiSelectableProjectIds: string[];
|
||||
}
|
||||
|
||||
export interface ResumeProfile {
|
||||
basics?: {
|
||||
name?: string;
|
||||
label?: string;
|
||||
image?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
url?: string;
|
||||
summary?: string;
|
||||
headline?: string;
|
||||
location?: {
|
||||
address?: string;
|
||||
postalCode?: string;
|
||||
city?: string;
|
||||
countryCode?: string;
|
||||
region?: string;
|
||||
};
|
||||
profiles?: Array<{
|
||||
network?: string;
|
||||
username?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
};
|
||||
sections?: {
|
||||
summary?: {
|
||||
id?: string;
|
||||
visible?: boolean;
|
||||
name?: string;
|
||||
content?: string;
|
||||
};
|
||||
skills?: {
|
||||
id?: string;
|
||||
visible?: boolean;
|
||||
name?: string;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
level: number;
|
||||
keywords: string[];
|
||||
visible: boolean;
|
||||
}>;
|
||||
};
|
||||
projects?: {
|
||||
id?: string;
|
||||
visible?: boolean;
|
||||
name?: string;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
date: string;
|
||||
summary: string;
|
||||
visible: boolean;
|
||||
keywords?: string[];
|
||||
url?: string;
|
||||
}>;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ProfileStatusResponse {
|
||||
exists: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
model: string;
|
||||
defaultModel: string;
|
||||
@ -313,6 +390,16 @@ export interface AppSettings {
|
||||
jobspyLinkedinFetchDescription: boolean;
|
||||
defaultJobspyLinkedinFetchDescription: boolean;
|
||||
overrideJobspyLinkedinFetchDescription: boolean | null;
|
||||
rxResumeBaseResumeId: string | null;
|
||||
hasRxResumeApiKey: boolean;
|
||||
showSponsorInfo: boolean;
|
||||
defaultShowSponsorInfo: boolean;
|
||||
overrideShowSponsorInfo: boolean | null;
|
||||
openrouterApiKeyHint: string | null;
|
||||
rxresumeEmail: string | null;
|
||||
rxresumePasswordHint: string | null;
|
||||
basicAuthUser: string | null;
|
||||
basicAuthPasswordHint: string | null;
|
||||
ukvisajobsEmail: string | null;
|
||||
ukvisajobsPasswordHint: string | null;
|
||||
webhookSecretHint: string | null;
|
||||
basicAuthActive: boolean;
|
||||
}
|
||||
|
||||
@ -3,24 +3,5 @@ import type { Config } from "tailwindcss";
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user