[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
) {
+ return (
+
+ )
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+ [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 (
+
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+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 (
+
+ {errors.map(
+ (error, index) =>
+ error?.message && {error.message}
+ )}
+
+ )
+ }, [children, errors])
+
+ if (!content) {
+ return null
+ }
+
+ return (
+
+ {content}
+
+ )
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+}
diff --git a/orchestrator/src/components/ui/label.tsx b/orchestrator/src/components/ui/label.tsx
new file mode 100644
index 0000000..5341821
--- /dev/null
+++ b/orchestrator/src/components/ui/label.tsx
@@ -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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/orchestrator/src/components/ui/radio-group.tsx b/orchestrator/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..9d3a26e
--- /dev/null
+++ b/orchestrator/src/components/ui/radio-group.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+
+
+
+
+ )
+})
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
+
+export { RadioGroup, RadioGroupItem }
diff --git a/orchestrator/src/components/ui/tooltip.tsx b/orchestrator/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..0ebefb5
--- /dev/null
+++ b/orchestrator/src/components/ui/tooltip.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/orchestrator/src/index.css b/orchestrator/src/index.css
index a9bb87e..cca4e24 100644
--- a/orchestrator/src/index.css
+++ b/orchestrator/src/index.css
@@ -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;
-}
\ No newline at end of file
+}
diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts
index ffb826c..33befd9 100644
--- a/orchestrator/src/server/api/routes.ts
+++ b/orchestrator/src/server/api/routes.ts
@@ -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);
diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts
index b544a3c..7fba136 100644
--- a/orchestrator/src/server/api/routes/jobs.test.ts
+++ b/orchestrator/src/server/api/routes/jobs.test.ts
@@ -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');
+ });
});
diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts
index 8af43a5..366cbcc 100644
--- a/orchestrator/src/server/api/routes/jobs.ts
+++ b/orchestrator/src/server/api/routes/jobs.ts
@@ -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
*/
diff --git a/orchestrator/src/server/api/routes/manual-jobs.ts b/orchestrator/src/server/api/routes/manual-jobs.ts
index 98dc595..2a2cb11 100644
--- a/orchestrator/src/server/api/routes/manual-jobs.ts
+++ b/orchestrator/src/server/api/routes/manual-jobs.ts
@@ -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');
}
diff --git a/orchestrator/src/server/api/routes/onboarding.test.ts b/orchestrator/src/server/api/routes/onboarding.test.ts
new file mode 100644
index 0000000..976e3b6
--- /dev/null
+++ b/orchestrator/src/server/api/routes/onboarding.test.ts
@@ -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: '',
+ },
+ };
+}
diff --git a/orchestrator/src/server/api/routes/onboarding.ts b/orchestrator/src/server/api/routes/onboarding.ts
new file mode 100644
index 0000000..723c620
--- /dev/null
+++ b/orchestrator/src/server/api/routes/onboarding.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 });
+});
diff --git a/orchestrator/src/server/api/routes/pipeline.test.ts b/orchestrator/src/server/api/routes/pipeline.test.ts
index 0d7dc34..68c80f3 100644
--- a/orchestrator/src/server/api/routes/pipeline.test.ts
+++ b/orchestrator/src/server/api/routes/pipeline.test.ts
@@ -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();
}
diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts
index 9e91930..db71386 100644
--- a/orchestrator/src/server/api/routes/profile.test.ts
+++ b/orchestrator/src/server/api/routes/profile.test.ts
@@ -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: '',
+ },
+ };
+}
diff --git a/orchestrator/src/server/api/routes/profile.ts b/orchestrator/src/server/api/routes/profile.ts
index 1dc8067..c75bd31 100644
--- a/orchestrator/src/server/api/routes/profile.ts
+++ b/orchestrator/src/server/api/routes/profile.ts
@@ -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 {
+ 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).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 });
+ }
+});
diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts
index 1e13474..06fd064 100644
--- a/orchestrator/src/server/api/routes/settings.test.ts
+++ b/orchestrator/src/server/api/routes/settings.test.ts
@@ -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');
});
});
diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts
index 0301e55..ffd36c6 100644
--- a/orchestrator/src/server/api/routes/settings.ts
+++ b/orchestrator/src/server/api/routes/settings.ts
@@ -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[] = [];
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;
- 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;
+ 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 });
}
});
diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts
index ec6bd96..8787a9d 100644
--- a/orchestrator/src/server/api/routes/test-utils.ts
+++ b/orchestrator/src/server/api/routes/test-utils.ts
@@ -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((resolve) => server.once('listening', () => resolve()));
diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts
index b592371..1cd20e7 100644
--- a/orchestrator/src/server/app.ts
+++ b/orchestrator/src/server/app.ts
@@ -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) => {
diff --git a/orchestrator/src/server/db/migrate.ts b/orchestrator/src/server/db/migrate.ts
index 894205a..fde7b8b 100644
--- a/orchestrator/src/server/db/migrate.ts
+++ b/orchestrator/src/server/db/migrate.ts
@@ -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)`,
diff --git a/orchestrator/src/server/db/schema.ts b/orchestrator/src/server/db/schema.ts
index 8afd889..df4b72c 100644
--- a/orchestrator/src/server/db/schema.ts
+++ b/orchestrator/src/server/db/schema.ts
@@ -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'))`),
diff --git a/orchestrator/src/server/index.ts b/orchestrator/src/server/index.ts
index df29e1b..fc7e212 100644
--- a/orchestrator/src/server/index.ts
+++ b/orchestrator/src/server/index.ts
@@ -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();
diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts
index 3a61930..f10398a 100644
--- a/orchestrator/src/server/pipeline/orchestrator.ts
+++ b/orchestrator/src/server/pipeline/orchestrator.ts
@@ -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 = {}): 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;
+ });
// Step 2: Run crawler
console.log('\n🕷️ Running crawler...');
@@ -120,8 +123,11 @@ export async function runPipeline(config: Partial = {}): 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 = {}): 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 = {}): 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 = {}): 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 = {}): 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 = {}): 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 = {}): 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 = {}): 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 = {}): 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> {
- const rxResumeBaseResumeId = await settingsRepo.getSetting('rxResumeBaseResumeId');
- if (rxResumeBaseResumeId) {
- try {
- const resume = await getResume(rxResumeBaseResumeId);
- return resume.data as Record;
- } 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);
- }
-}
diff --git a/orchestrator/src/server/pipeline/sponsor-matching.test.ts b/orchestrator/src/server/pipeline/sponsor-matching.test.ts
new file mode 100644
index 0000000..1f2dd9e
--- /dev/null
+++ b/orchestrator/src/server/pipeline/sponsor-matching.test.ts
@@ -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 => ({
+ 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;
+ let calculateSponsorMatchSummary: ReturnType;
+ let scoreJobSuitability: ReturnType;
+ let updateJob: ReturnType;
+ let getUnscoredDiscoveredJobs: ReturnType;
+ let bulkCreateJobs: ReturnType;
+
+ 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;
+ calculateSponsorMatchSummary = visaSponsors.calculateSponsorMatchSummary as ReturnType;
+ scoreJobSuitability = scorer.scoreJobSuitability as ReturnType;
+ updateJob = jobsRepo.updateJob as ReturnType;
+ getUnscoredDiscoveredJobs = jobsRepo.getUnscoredDiscoveredJobs as ReturnType;
+ bulkCreateJobs = jobsRepo.bulkCreateJobs as ReturnType;
+
+ // 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']),
+ })
+ );
+ });
+ });
+});
diff --git a/orchestrator/src/server/repositories/jobs.ts b/orchestrator/src/server/repositories/jobs.ts
index 500441a..29a9292 100644
--- a/orchestrator/src/server/repositories/jobs.ts
+++ b/orchestrator/src/server/repositories/jobs.ts
@@ -16,7 +16,7 @@ export async function getAllJobs(statuses?: JobStatus[]): Promise {
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 {
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 {
createdAt: now,
updatedAt: now,
});
-
+
return (await getJobById(id))!;
}
@@ -114,7 +114,7 @@ export async function createJob(input: CreateJobInput): Promise {
*/
export async function updateJob(id: string, input: UpdateJobInput): Promise {
const now = new Date().toISOString();
-
+
await db.update(jobs)
.set({
...input,
@@ -123,7 +123,7 @@ export async function updateJob(id: string, input: UpdateJobInput): Promise {
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> {
})
.from(jobs)
.groupBy(jobs.status);
-
+
const stats: Record = {
discovered: 0,
processing: 0,
@@ -168,11 +168,11 @@ export async function getJobStats(): Promise> {
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 {
)
.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,
diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts
index a7de290..6f98678 100644
--- a/orchestrator/src/server/repositories/settings.ts
+++ b/orchestrator/src/server/repositories/settings.ts
@@ -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 {
const [row] = await db.select().from(settings).where(eq(settings.key, key))
return row?.value ?? null
}
+export async function getAllSettings(): Promise>> {
+ const rows = await db.select().from(settings)
+ return rows.reduce((acc, row) => {
+ acc[row.key as SettingKey] = row.value
+ return acc
+ }, {} as Partial>)
+}
+
export async function setSetting(key: SettingKey, value: string | null): Promise {
const now = new Date().toISOString()
diff --git a/orchestrator/src/server/services/ai-resilience.test.ts b/orchestrator/src/server/services/ai-resilience.test.ts
index acce505..fc45424 100644
--- a/orchestrator/src/server/services/ai-resilience.test.ts
+++ b/orchestrator/src/server/services/ai-resilience.test.ts
@@ -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
});
diff --git a/orchestrator/src/server/services/envSettings.ts b/orchestrator/src/server/services/envSettings.ts
new file mode 100644
index 0000000..6137ccb
--- /dev/null
+++ b/orchestrator/src/server/services/envSettings.ts
@@ -0,0 +1,118 @@
+import * as settingsRepo from '@server/repositories/settings.js';
+import { SettingKey } from '@server/repositories/settings.js';
+
+const envDefaults: Record = { ...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 {
+ 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>
+): Promise> {
+ const activeOverrides = overrides || await settingsRepo.getAllSettings();
+ const readableValues: Record = {};
+ const privateValues: Record = {};
+
+ 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,
+};
diff --git a/orchestrator/src/server/services/index.ts b/orchestrator/src/server/services/index.ts
index 72f6b05..a9b3d16 100644
--- a/orchestrator/src/server/services/index.ts
+++ b/orchestrator/src/server/services/index.ts
@@ -4,3 +4,4 @@ export * from './scorer.js';
export * from './summary.js';
export * from './pdf.js';
export * from './notion.js';
+export * from './profile.js';
diff --git a/orchestrator/src/server/services/manualJob.test.ts b/orchestrator/src/server/services/manualJob.test.ts
index 00321c4..dd6277f 100644
--- a/orchestrator/src/server/services/manualJob.test.ts
+++ b/orchestrator/src/server/services/manualJob.test.ts
@@ -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;
diff --git a/orchestrator/src/server/services/manualJob.ts b/orchestrator/src/server/services/manualJob.ts
index f2cac94..a4dc149 100644
--- a/orchestrator/src/server/services/manualJob.ts
+++ b/orchestrator/src/server/services/manualJob.ts
@@ -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 {
- 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 {
+ 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({
+ 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 {
- 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): ManualJobDraft {
- const fields: Array = [
- '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;
-}
diff --git a/orchestrator/src/server/services/openrouter.test.ts b/orchestrator/src/server/services/openrouter.test.ts
new file mode 100644
index 0000000..c36d77c
--- /dev/null
+++ b/orchestrator/src/server/services/openrouter.test.ts
@@ -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();
+ });
+});
diff --git a/orchestrator/src/server/services/openrouter.ts b/orchestrator/src/server/services/openrouter.ts
new file mode 100644
index 0000000..cb52fea
--- /dev/null
+++ b/orchestrator/src/server/services/openrouter.ts
@@ -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;
+ required: string[];
+ additionalProperties: boolean;
+ };
+}
+
+export interface OpenRouterRequestOptions {
+ /** 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 {
+ success: true;
+ data: T;
+}
+
+export interface OpenRouterError {
+ success: false;
+ error: string;
+}
+
+export type OpenRouterResponse = OpenRouterResult | OpenRouterError;
+
+/**
+ * Call OpenRouter API with structured JSON output.
+ *
+ * @returns Parsed JSON response matching the schema, or an error object
+ */
+export async function callOpenRouter(
+ options: OpenRouterRequestOptions
+): Promise> {
+ 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(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(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 {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
diff --git a/orchestrator/src/server/services/pdf-skills-validation.test.ts b/orchestrator/src/server/services/pdf-skills-validation.test.ts
new file mode 100644
index 0000000..6ca353e
--- /dev/null
+++ b/orchestrator/src/server/services/pdf-skills-validation.test.ts
@@ -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);
+ });
+});
diff --git a/orchestrator/src/server/services/pdf-tailoring.test.ts b/orchestrator/src/server/services/pdf-tailoring.test.ts
index ab8ae6e..df187fe 100644
--- a/orchestrator/src/server/services/pdf-tailoring.test.ts
+++ b/orchestrator/src/server/services/pdf-tailoring.test.ts
@@ -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', () => ({
diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts
index bdfaadf..9ee7847 100644
--- a/orchestrator/src/server/services/pdf.ts
+++ b/orchestrator/src/server/services/pdf.ts
@@ -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 {
- 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;
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 {
+ 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.
*/
diff --git a/orchestrator/src/server/services/profile.test.ts b/orchestrator/src/server/services/profile.test.ts
new file mode 100644
index 0000000..7d17a42
--- /dev/null
+++ b/orchestrator/src/server/services/profile.test.ts
@@ -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();
+ });
+});
diff --git a/orchestrator/src/server/services/profile.ts b/orchestrator/src/server/services/profile.ts
new file mode 100644
index 0000000..dd935dd
--- /dev/null
+++ b/orchestrator/src/server/services/profile.ts
@@ -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 {
+ 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 {
+ const profile = await getProfile();
+ return profile?.basics?.name || 'Resume';
+}
+
+/**
+ * Clear the profile cache.
+ */
+export function clearProfileCache(): void {
+ cachedProfile = null;
+}
diff --git a/orchestrator/src/server/services/projectSelection.ts b/orchestrator/src/server/services/projectSelection.ts
index 1fd405f..36c63fc 100644
--- a/orchestrator/src/server/services/projectSelection.ts
+++ b/orchestrator/src/server/services/projectSelection.ts
@@ -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();
- 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();
+ 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()}…`;
}
-
diff --git a/orchestrator/src/server/services/resumeProjects.test.ts b/orchestrator/src/server/services/resumeProjects.test.ts
index 8ca4d94..a43a891 100644
--- a/orchestrator/src/server/services/resumeProjects.test.ts
+++ b/orchestrator/src/server/services/resumeProjects.test.ts
@@ -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'
});
diff --git a/orchestrator/src/server/services/resumeProjects.ts b/orchestrator/src/server/services/resumeProjects.ts
index b701432..faec0c9 100644
--- a/orchestrator/src/server/services/resumeProjects.ts
+++ b/orchestrator/src/server/services/resumeProjects.ts
@@ -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 {
- 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 };
-
diff --git a/orchestrator/src/server/services/rxresume-client.test.ts b/orchestrator/src/server/services/rxresume-client.test.ts
new file mode 100644
index 0000000..59123dc
--- /dev/null
+++ b/orchestrator/src/server/services/rxresume-client.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/orchestrator/src/server/services/rxresume-client.ts b/orchestrator/src/server/services/rxresume-client.ts
new file mode 100644
index 0000000..ca15e14
--- /dev/null
+++ b/orchestrator/src/server/services/rxresume-client.ts
@@ -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;
+
+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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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}`);
+ }
+ }
+}
diff --git a/orchestrator/src/server/services/rxresume.ts b/orchestrator/src/server/services/rxresume.ts
index 29aab9f..4d18865 100644
--- a/orchestrator/src/server/services/rxresume.ts
+++ b/orchestrator/src/server/services/rxresume.ts
@@ -1,4 +1,4 @@
-import { resumeDataSchema } from "../../shared/rxresume-schema";
+import { resumeDataSchema } from "../../shared/rxresume-schema.js";
export interface RxResumeResponse {
id: string;
diff --git a/orchestrator/src/server/services/scorer.test.ts b/orchestrator/src/server/services/scorer.test.ts
new file mode 100644
index 0000000..08caa61
--- /dev/null
+++ b/orchestrator/src/server/services/scorer.test.ts
@@ -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');
+ });
+ });
+});
diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts
index 4c7e18d..7c64bdb 100644
--- a/orchestrator/src/server/services/scorer.ts
+++ b/orchestrator/src/server/services/scorer.ts
@@ -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
): Promise {
- 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 {
- 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": "" }
-`;
+IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.
+
+REQUIRED FORMAT (exactly this structure):
+{"score": , "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);
}
diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts
new file mode 100644
index 0000000..907f6ce
--- /dev/null
+++ b/orchestrator/src/server/services/settings.ts
@@ -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 {
+ // 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;
+}
diff --git a/orchestrator/src/server/services/summary.ts b/orchestrator/src/server/services/summary.ts
index fc70590..9a9e27b 100644
--- a/orchestrator/src/server/services/summary.ts
+++ b/orchestrator/src/server/services/summary.ts
@@ -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
): Promise {
- 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({
+ 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, 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, 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)}
diff --git a/orchestrator/src/server/services/visa-sponsors/index.test.ts b/orchestrator/src/server/services/visa-sponsors/index.test.ts
new file mode 100644
index 0000000..93272a2
--- /dev/null
+++ b/orchestrator/src/server/services/visa-sponsors/index.test.ts
@@ -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']));
+ });
+});
diff --git a/orchestrator/src/server/services/visa-sponsors/index.ts b/orchestrator/src/server/services/visa-sponsors/index.ts
index b01c5f1..963d887 100644
--- a/orchestrator/src/server/services/visa-sponsors/index.ts
+++ b/orchestrator/src/server/services/visa-sponsors/index.ts
@@ -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 {
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(); // 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 {
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 {
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);
}
diff --git a/orchestrator/src/shared/rxresume-schema.test.ts b/orchestrator/src/shared/rxresume-schema.test.ts
new file mode 100644
index 0000000..450a994
--- /dev/null
+++ b/orchestrator/src/shared/rxresume-schema.test.ts
@@ -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();
+ }
+ });
+ });
+});
diff --git a/orchestrator/src/shared/rxresume-schema.ts b/orchestrator/src/shared/rxresume-schema.ts
index 95ad09b..10f2c3a 100644
--- a/orchestrator/src/shared/rxresume-schema.ts
+++ b/orchestrator/src/shared/rxresume-schema.ts
@@ -1,527 +1,897 @@
-import z from "zod";
+// combined types from: https://github.com/amruthpillai/reactive-resume/tree/v4.5.5/libs/schema/src
-export const templateSchema = z.enum([
- "azurill",
- "bronzor",
- "chikorita",
- "ditto",
- "ditgar",
- "gengar",
- "glalie",
- "kakuna",
- "lapras",
- "leafish",
- "onyx",
- "pikachu",
- "rhyhorn",
-]);
+import { z } from "zod";
-export type Template = z.infer;
+// --- Shared ---
-export const iconSchema = z
+export type FilterKeys = {
+ [Key in keyof T]: T[Key] extends Condition ? Key : never;
+}[keyof T];
+
+export const idSchema = z
.string()
- .describe(
- "The icon to display for the custom field. Must be a valid icon name from @phosphor-icons/web icon set, or an empty string to hide. Default to '' (empty string) when unsure which icons are available.",
- );
+ .cuid2()
+ .length(24)
+ .describe("Unique identifier for the item (CUID2 format)");
-export const localeSchema = z
- .union([
- z.literal("af-ZA"),
- z.literal("am-ET"),
- z.literal("ar-SA"),
- z.literal("az-AZ"),
- z.literal("bg-BG"),
- z.literal("bn-BD"),
- z.literal("ca-ES"),
- z.literal("cs-CZ"),
- z.literal("da-DK"),
- z.literal("de-DE"),
- z.literal("el-GR"),
- z.literal("en-US"),
- z.literal("es-ES"),
- z.literal("fa-IR"),
- z.literal("fi-FI"),
- z.literal("fr-FR"),
- z.literal("he-IL"),
- z.literal("hi-IN"),
- z.literal("hu-HU"),
- z.literal("id-ID"),
- z.literal("it-IT"),
- z.literal("ja-JP"),
- z.literal("km-KH"),
- z.literal("kn-IN"),
- z.literal("ko-KR"),
- z.literal("lt-LT"),
- z.literal("lv-LV"),
- z.literal("ml-IN"),
- z.literal("mr-IN"),
- z.literal("ms-MY"),
- z.literal("ne-NP"),
- z.literal("nl-NL"),
- z.literal("no-NO"),
- z.literal("or-IN"),
- z.literal("pl-PL"),
- z.literal("pt-BR"),
- z.literal("pt-PT"),
- z.literal("ro-RO"),
- z.literal("ru-RU"),
- z.literal("sk-SK"),
- z.literal("sq-AL"),
- z.literal("sr-SP"),
- z.literal("sv-SE"),
- z.literal("ta-IN"),
- z.literal("te-IN"),
- z.literal("th-TH"),
- z.literal("tr-TR"),
- z.literal("uk-UA"),
- z.literal("uz-UZ"),
- z.literal("vi-VN"),
- z.literal("zh-CN"),
- z.literal("zh-TW"),
- z.literal("zu-ZA"),
- ])
- .describe("The language used in the resume, used for displaying pre-translated section headings, if not overridden.")
- .catch("en-US");
+export const itemSchema = z.object({
+ id: idSchema,
+ visible: z.boolean(),
+});
+
+export type Item = z.infer;
+
+export const defaultItem: Item = {
+ id: "",
+ visible: true,
+};
export const urlSchema = z.object({
- url: z
- .string()
- .describe(
- "The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). Leave blank to hide.",
- ),
- label: z.string().describe("The label to display for the URL. Leave blank to display the URL as-is."),
+ label: z.string(),
+ href: z.literal("").or(z.string().url()),
});
-export const pictureSchema = z.object({
- hidden: z.boolean().describe("Whether to hide the picture from the resume."),
- url: z
- .string()
- .describe(
- "The URL to the picture to display on the resume. Must be a valid URL with a protocol (http:// or https://). Leave blank to hide.",
- ),
- size: z
- .number()
- .min(32)
- .max(512)
- .describe("The size of the picture to display on the resume, defined in points (pt)."),
- rotation: z
- .number()
- .min(0)
- .max(360)
- .describe("The rotation of the picture to display on the resume, defined in degrees (°)."),
- aspectRatio: z
- .number()
- .min(0.5)
- .max(2.5)
- .describe(
- "The aspect ratio of the picture to display on the resume, defined as width / height (e.g. 1.5 for 1.5:1 or 0.5 for 1:2).",
- ),
- borderRadius: z
- .number()
- .min(0)
- .max(100)
- .describe("The border radius of the picture to display on the resume, defined in points (pt)."),
- borderColor: z
- .string()
- .describe("The color of the border of the picture to display on the resume, defined as rgba(r, g, b, a)."),
- borderWidth: z
- .number()
- .min(0)
- .describe("The width of the border of the picture to display on the resume, defined in points (pt)."),
- shadowColor: z
- .string()
- .describe("The color of the shadow of the picture to display on the resume, defined as rgba(r, g, b, a)."),
- shadowWidth: z
- .number()
- .min(0)
- .describe("The width of the shadow of the picture to display on the resume, defined in points (pt)."),
-});
+export type URL = z.infer;
+
+export const defaultUrl: URL = {
+ label: "",
+ href: "",
+};
+
+// --- Basics ---
export const customFieldSchema = z.object({
- id: z.string().describe("The unique identifier for the custom field. Usually generated as a UUID."),
- icon: iconSchema,
- text: z.string().describe("The text to display for the custom field."),
+ id: z.string().cuid2(),
+ icon: z.string(),
+ name: z.string(),
+ value: z.string(),
});
+export type CustomField = z.infer;
+
export const basicsSchema = z.object({
- name: z.string().describe("The full name of the author of the resume."),
- headline: z.string().describe("The headline of the author of the resume."),
- email: z.string().email().or(z.literal("")).describe("The email address of the author of the resume. Leave blank to hide."),
- phone: z.string().describe("The phone number of the author of the resume. Leave blank to hide."),
- location: z.string().describe("The location of the author of the resume."),
- website: urlSchema.describe("The website of the author of the resume."),
- customFields: z.array(customFieldSchema).describe("The custom fields to display on the resume."),
+ name: z.string(),
+ headline: z.string(),
+ email: z.literal("").or(z.string().email()),
+ phone: z.string(),
+ location: z.string(),
+ url: urlSchema,
+ customFields: z.array(customFieldSchema),
+ picture: z.object({
+ url: z.string(),
+ size: z.number().default(64),
+ aspectRatio: z.number().default(1),
+ borderRadius: z.number().default(0),
+ effects: z.object({
+ hidden: z.boolean().default(false),
+ border: z.boolean().default(false),
+ grayscale: z.boolean().default(false),
+ }),
+ }),
});
-export const summarySchema = z.object({
- title: z.string().describe("The title of the summary of the resume."),
- columns: z.number().describe("The number of columns the summary should span across."),
- hidden: z.boolean().describe("Whether to hide the summary from the resume."),
- content: z
- .string()
- .describe("The content of the summary of the resume. This should be a HTML-formatted string. Leave blank to hide."),
+export type Basics = z.infer;
+
+export const defaultBasics: Basics = {
+ name: "",
+ headline: "",
+ email: "",
+ phone: "",
+ location: "",
+ url: defaultUrl,
+ customFields: [],
+ picture: {
+ url: "",
+ size: 64,
+ aspectRatio: 1,
+ borderRadius: 0,
+ effects: {
+ hidden: false,
+ border: false,
+ grayscale: false,
+ },
+ },
+};
+
+// --- Metadata ---
+
+export const defaultLayout = [
+ [
+ ["profiles", "summary", "experience", "education", "projects", "volunteer", "references"],
+ ["skills", "interests", "certifications", "awards", "publications", "languages"],
+ ],
+];
+
+export const metadataSchema = z.object({
+ template: z.string().default("rhyhorn"),
+ layout: z.array(z.array(z.array(z.string()))).default(defaultLayout), // pages -> columns -> sections
+ css: z.object({
+ value: z.string().default("* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}"),
+ visible: z.boolean().default(false),
+ }),
+ page: z.object({
+ margin: z.number().default(18),
+ format: z.enum(["a4", "letter"]).default("a4"),
+ options: z.object({
+ breakLine: z.boolean().default(true),
+ pageNumbers: z.boolean().default(true),
+ }),
+ }),
+ theme: z.object({
+ background: z.string().default("#ffffff"),
+ text: z.string().default("#000000"),
+ primary: z.string().default("#dc2626"),
+ }),
+ typography: z.object({
+ font: z.object({
+ family: z.string().default("IBM Plex Serif"),
+ subset: z.string().default("latin"),
+ variants: z.array(z.string()).default(["regular"]),
+ size: z.number().default(14),
+ }),
+ lineHeight: z.number().default(1.5),
+ hideIcons: z.boolean().default(false),
+ underlineLinks: z.boolean().default(true),
+ }),
+ notes: z.string().default(""),
});
-export const baseItemSchema = z.object({
- id: z.string().describe("The unique identifier for the item. Usually generated as a UUID."),
- hidden: z.boolean().describe("Whether to hide the item from the resume."),
+export type Metadata = z.infer;
+
+export const defaultMetadata: Metadata = {
+ template: "rhyhorn",
+ layout: defaultLayout,
+ css: {
+ value: "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}",
+ 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", "italic", "600"],
+ size: 14,
+ },
+ lineHeight: 1.5,
+ hideIcons: false,
+ underlineLinks: true,
+ },
+ notes: "",
+};
+
+// --- Sections ---
+
+// Award
+export const awardSchema = itemSchema.extend({
+ title: z.string().min(1),
+ awarder: z.string(),
+ date: z.string(),
+ summary: z.string(),
+ url: urlSchema,
});
-export const awardItemSchema = baseItemSchema.extend({
- title: z.string().min(1).describe("The title of the award."),
- awarder: z.string().describe("The awarder of the award."),
- date: z.string().describe("The date when the award was received."),
- website: urlSchema.describe("The website of the award, if any."),
- description: z
- .string()
- .describe("The description of the award. This should be a HTML-formatted string. Leave blank to hide."),
+export type Award = z.infer;
+
+export const defaultAward: Award = {
+ ...defaultItem,
+ title: "",
+ awarder: "",
+ date: "",
+ summary: "",
+ url: defaultUrl,
+};
+
+// Certification
+export const certificationSchema = itemSchema.extend({
+ name: z.string().min(1),
+ issuer: z.string(),
+ date: z.string(),
+ summary: z.string(),
+ url: urlSchema,
});
-export const certificationItemSchema = baseItemSchema.extend({
- title: z.string().min(1).describe("The title of the certification."),
- issuer: z.string().describe("The issuer of the certification."),
- date: z.string().describe("The date when the certification was received."),
- website: urlSchema.describe("The website of the certification, if any."),
- description: z
- .string()
- .describe("The description of the certification. This should be a HTML-formatted string. Leave blank to hide."),
-});
+export type Certification = z.infer