diff --git a/.env.example b/.env.example index 5ae395c..71c4fee 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,7 @@ MODEL=google/gemini-3-flash-preview # Path is absolute or relative to the orchestrator process cwd (often `orchestrator/` when using `npm run dev` there). # Takes precedence over Settings → local path. PDF export still uses RxResume when enabled. # Example (monorepo): hand-authored v5 JSON may live under `data/resumes/` (that folder is gitignored by default). +# If you use seeded search profiles with `resumeLocalPath` + login auto-activate, leave this unset so Settings → local path wins. # JOBOPS_LOCAL_RESUME_PATH=../data/resumes/ilia-dobkin.json # RXResume credentials for PDF generation @@ -32,6 +33,14 @@ RXRESUME_PASSWORD=your_password_here # Optional: Basic Auth for write access # the app is fully unauthenticated if this isn't set, which is the default # When set, all write actions (POST/PATCH/DELETE) require Basic Auth. +# Optional second user (e.g. paired with a second search profile / `basicAuthUser` in profile JSON): +# BASIC_AUTH_USER_2= +# BASIC_AUTH_PASSWORD_2= +# Example local pairing with DB-seeded profiles (change passwords before exposing the UI): +# BASIC_AUTH_USER=ilia +# BASIC_AUTH_PASSWORD=changeme-ilia +# BASIC_AUTH_USER_2=cherepaha +# BASIC_AUTH_PASSWORD_2=changeme-cherepaha BASIC_AUTH_USER= BASIC_AUTH_PASSWORD= diff --git a/orchestrator/src/client/App.test.tsx b/orchestrator/src/client/App.test.tsx index 682891d..e27bdef 100644 --- a/orchestrator/src/client/App.test.tsx +++ b/orchestrator/src/client/App.test.tsx @@ -1,7 +1,7 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type React from "react"; import { MemoryRouter } from "react-router-dom"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; import { useDemoInfo } from "./hooks/useDemoInfo"; @@ -58,13 +58,29 @@ vi.mock("./pages/VisaSponsorsPage", () => ({ VisaSponsorsPage: () => null, })); +const originalFetch = globalThis.fetch; + describe("App demo banner", () => { - beforeEach(() => { - vi.clearAllMocks(); - localStorage.clear(); + afterAll(() => { + globalThis.fetch = originalFetch; }); - it("shows a waitlist link in demo mode", () => { + beforeEach(() => { + vi.mocked(useDemoInfo).mockClear(); + localStorage.clear(); + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/api/auth/basic-status")) { + return new Response( + JSON.stringify({ ok: true, data: { basicAuthEnabled: false } }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + }); + + it("shows a waitlist link in demo mode", async () => { vi.mocked(useDemoInfo).mockReturnValue({ demoMode: true, resetCadenceHours: 6, @@ -80,14 +96,14 @@ describe("App demo banner", () => { , ); - const link = screen.getByRole("link", { name: "try.jobops.app" }); + const link = await screen.findByRole("link", { name: "try.jobops.app" }); expect(link).toHaveAttribute( "href", "https://try.jobops.app?utm_source=demo&utm_medium=banner&utm_campaign=waitlist", ); }); - it("does not render the demo banner waitlist link when demo mode is disabled", () => { + it("does not render the demo banner waitlist link when demo mode is disabled", async () => { vi.mocked(useDemoInfo).mockReturnValue({ demoMode: false, resetCadenceHours: 6, @@ -103,10 +119,12 @@ describe("App demo banner", () => { , ); - expect(screen.queryByRole("link", { name: "try.jobops.app" })).toBeNull(); + await waitFor(() => { + expect(screen.queryByRole("link", { name: "try.jobops.app" })).toBeNull(); + }); }); - it("lets the user dismiss the waitlist banner and keeps it hidden", () => { + it("lets the user dismiss the waitlist banner and keeps it hidden", async () => { vi.mocked(useDemoInfo).mockReturnValue({ demoMode: true, resetCadenceHours: 6, @@ -123,7 +141,9 @@ describe("App demo banner", () => { ); fireEvent.click( - screen.getByRole("button", { name: /dismiss demo waitlist banner/i }), + await screen.findByRole("button", { + name: /dismiss demo waitlist banner/i, + }), ); expect(screen.queryByRole("link", { name: "try.jobops.app" })).toBeNull(); diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index a2f22c5..db13c70 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -9,6 +9,7 @@ import { CSSTransition, SwitchTransition } from "react-transition-group"; import { Button } from "@/components/ui/button"; import { Toaster } from "@/components/ui/sonner"; +import { BasicAuthAppGate } from "./components/BasicAuthAppGate"; import { BasicAuthPrompt } from "./components/BasicAuthPrompt"; import { OnboardingGate } from "./components/OnboardingGate"; import { useDemoInfo } from "./hooks/useDemoInfo"; @@ -66,96 +67,104 @@ export const App: React.FC = () => { return ( <> - - {demoInfo?.demoMode && !demoWaitlistBannerDismissed && ( -
-
-

- This is a read-only demo. Want JobOps without the Docker setup? ☁️{" "} - Cloud version coming soon — join the waitlist at{" "} - + + {demoInfo?.demoMode && !demoWaitlistBannerDismissed && ( +

+
+

+ This is a read-only demo. Want JobOps without the Docker setup? + ☁️ Cloud version coming soon — join the waitlist at{" "} + + try.jobops.app + +

+ -
-
- )} - {demoInfo?.demoMode && ( -
- Demo mode: integrations are simulated and data resets every{" "} - {demoInfo.resetCadenceHours} hours. -
- )} -
- - -
- - {/* Backwards-compatibility redirects */} - {REDIRECTS.map(({ from, to }) => ( - } - /> - ))} - - {/* Application routes */} - } /> - } - /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> - } - /> - + + Dismiss demo waitlist banner +
-
-
-
+
+ )} + {demoInfo?.demoMode && ( +
+ Demo mode: integrations are simulated and data resets every{" "} + {demoInfo.resetCadenceHours} hours. +
+ )} +
+ + +
+ + {/* Backwards-compatibility redirects */} + {REDIRECTS.map(({ from, to }) => ( + } + /> + ))} - + {/* Application routes */} + } /> + } + /> + } /> + } + /> + } /> + } /> + } /> + } + /> + } /> + } + /> + +
+
+
+
+ + + ); }; diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index bf326e7..4829538 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -121,12 +121,28 @@ export function setBasicAuthPromptHandler( export function clearBasicAuthCredentials(): void { cachedBasicAuthCredentials = null; + clearBasicAuthSessionStorage(); +} + +/** Clears stored Basic Auth and reloads so you can sign in as a different user (separate job data). */ +export function signOutBasicAuthAndReload(): void { + clearBasicAuthCredentials(); + window.location.reload(); +} + +export function setCachedBasicAuthCredentials( + credentials: BasicAuthCredentials | null, +): void { + cachedBasicAuthCredentials = credentials; + if (credentials) persistBasicAuthToSessionStorage(credentials); + else clearBasicAuthSessionStorage(); } export function __resetApiClientAuthForTests(): void { basicAuthPromptHandler = null; basicAuthPromptInFlight = null; cachedBasicAuthCredentials = null; + clearBasicAuthSessionStorage(); } function normalizeApiResponse( @@ -172,10 +188,63 @@ function describeAction(endpoint: string, method?: string): string { return "This action ran in demo simulation mode."; } -function encodeBasicAuth(credentials: BasicAuthCredentials): string { +export function encodeBasicAuthHeaderValue( + credentials: BasicAuthCredentials, +): string { return `Basic ${btoa(`${credentials.username}:${credentials.password}`)}`; } +const BASIC_AUTH_SESSION_STORAGE_KEY = "jobops_basic_auth_v1"; + +/** Read persisted credentials without updating the in-memory API client cache. */ +export function peekBasicAuthSessionCredentials(): BasicAuthCredentials | null { + return readBasicAuthFromSessionStorage(); +} + +/** In-memory session (after prompt) or persisted session (after reload). */ +export function getActiveBasicAuthCredentials(): BasicAuthCredentials | null { + if (cachedBasicAuthCredentials) return cachedBasicAuthCredentials; + return readBasicAuthFromSessionStorage(); +} + +function readBasicAuthFromSessionStorage(): BasicAuthCredentials | null { + try { + const raw = sessionStorage.getItem(BASIC_AUTH_SESSION_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return null; + const rec = parsed as Record; + const username = + typeof rec.username === "string" ? rec.username.trim() : ""; + const password = typeof rec.password === "string" ? rec.password : ""; + if (!username || !password) return null; + return { username, password }; + } catch { + return null; + } +} + +function persistBasicAuthToSessionStorage( + credentials: BasicAuthCredentials, +): void { + try { + sessionStorage.setItem( + BASIC_AUTH_SESSION_STORAGE_KEY, + JSON.stringify(credentials), + ); + } catch { + // Ignore quota / private mode. + } +} + +function clearBasicAuthSessionStorage(): void { + try { + sessionStorage.removeItem(BASIC_AUTH_SESSION_STORAGE_KEY); + } catch { + // Ignore. + } +} + function normalizeHeaders(headers?: HeadersInit): Record { if (!headers) return {}; if (headers instanceof Headers) { @@ -248,6 +317,152 @@ async function requestBasicAuthCredentials( return basicAuthPromptInFlight; } +/** Opens the Basic Auth dialog (when mounted) without waiting for a write 401 first. */ +export async function promptBasicAuthDialog( + request?: Partial>, +): Promise { + return requestBasicAuthCredentials({ + endpoint: request?.endpoint ?? "/auth/verify", + method: request?.method ?? "POST", + attempt: 1, + usernameHint: request?.usernameHint, + errorMessage: request?.errorMessage, + }); +} + +export type BasicAuthBasicStatusResponse = { + basicAuthEnabled: boolean; +}; + +export async function getBasicAuthBasicStatus(): Promise { + const response = await fetch(`${API_BASE}/auth/basic-status`); + const text = await response.text(); + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + throw new ApiClientError( + `Server error (${response.status}): Expected JSON.`, + { status: response.status }, + ); + } + const parsed = normalizeApiResponse(payload); + if ("ok" in parsed && parsed.ok) { + return parsed.data as BasicAuthBasicStatusResponse; + } + throw new ApiClientError("Failed to read Basic Auth status", { + status: response.status, + }); +} + +async function verifyBasicAuthCredentials( + credentials: BasicAuthCredentials, +): Promise { + const response = await fetch(`${API_BASE}/auth/verify`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: encodeBasicAuthHeaderValue(credentials), + }, + body: "{}", + }); + if (response.status === 401) return false; + const text = await response.text(); + try { + const payload = JSON.parse(text) as { ok?: boolean }; + return payload.ok === true; + } catch { + return false; + } +} + +export async function activateSearchProfileForBasicAuthUser( + username: string, + credentials: BasicAuthCredentials, +): Promise { + const auth = encodeBasicAuthHeaderValue(credentials); + const listResponse = await fetch(`${API_BASE}/profiles`); + const listText = await listResponse.text(); + let listPayload: { + ok?: boolean; + data?: Array<{ id: string; data: { basicAuthUser?: string | null } }>; + }; + try { + listPayload = JSON.parse(listText) as typeof listPayload; + } catch { + return; + } + if (!listPayload.ok || !Array.isArray(listPayload.data)) return; + const match = listPayload.data.find( + (row) => (row.data?.basicAuthUser ?? "").trim() === username.trim(), + ); + if (!match) return; + await fetch(`${API_BASE}/profiles/${match.id}/activate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: auth, + }, + body: "{}", + }); +} + +/** + * When Basic Auth is enabled: restore session credentials, verify, optionally activate + * the search profile tied to `basicAuthUser`. Returns true when no interactive prompt is needed. + */ +export async function tryBootstrapBasicAuthFromSessionOnly( + preloaded?: BasicAuthBasicStatusResponse, +): Promise { + const status = preloaded ?? (await getBasicAuthBasicStatus()); + if (!status.basicAuthEnabled) return true; + + const fromSession = readBasicAuthFromSessionStorage(); + if (!fromSession) return false; + + const ok = await verifyBasicAuthCredentials(fromSession); + if (!ok) { + clearBasicAuthSessionStorage(); + return false; + } + setCachedBasicAuthCredentials(fromSession); + await activateSearchProfileForBasicAuthUser( + fromSession.username, + fromSession, + ); + return true; +} + +/** Prompt, verify, persist credentials, and activate matching search profile. */ +export async function bootstrapBasicAuthWithPromptOnly(): Promise { + const credentials = await promptBasicAuthDialog(); + if (!credentials) return; + const verified = await verifyBasicAuthCredentials(credentials); + if (!verified) { + const retry = await promptBasicAuthDialog({ + errorMessage: "Invalid credentials. Please try again.", + }); + if (!retry) return; + const ok2 = await verifyBasicAuthCredentials(retry); + if (!ok2) return; + setCachedBasicAuthCredentials(retry); + await activateSearchProfileForBasicAuthUser(retry.username, retry); + return; + } + setCachedBasicAuthCredentials(credentials); + await activateSearchProfileForBasicAuthUser( + credentials.username, + credentials, + ); +} + +export async function bootstrapBasicAuthOnLoad( + preloaded?: BasicAuthBasicStatusResponse, +): Promise { + if (await tryBootstrapBasicAuthFromSessionOnly(preloaded)) return; + await bootstrapBasicAuthWithPromptOnly(); +} + async function fetchAndParse( endpoint: string, options: RequestInit | undefined, @@ -288,7 +503,7 @@ async function fetchApi( ): Promise { const method = (options?.method || "GET").toUpperCase(); let authHeader = cachedBasicAuthCredentials - ? encodeBasicAuth(cachedBasicAuthCredentials) + ? encodeBasicAuthHeaderValue(cachedBasicAuthCredentials) : undefined; let authAttempt = 0; let usernameHint = cachedBasicAuthCredentials?.username; @@ -319,9 +534,9 @@ async function fetchApi( if (!credentials) { throw toApiError(response, parsed); } - cachedBasicAuthCredentials = credentials; + setCachedBasicAuthCredentials(credentials); usernameHint = credentials.username; - authHeader = encodeBasicAuth(credentials); + authHeader = encodeBasicAuthHeaderValue(credentials); authAttempt += 1; continue; } @@ -481,7 +696,9 @@ async function streamSseEvents( "Content-Type": "application/json", }; if (cachedBasicAuthCredentials) { - headers.Authorization = encodeBasicAuth(cachedBasicAuthCredentials); + headers.Authorization = encodeBasicAuthHeaderValue( + cachedBasicAuthCredentials, + ); } const response = await fetch(`${API_BASE}${endpoint}`, { diff --git a/orchestrator/src/client/components/BasicAuthAppGate.tsx b/orchestrator/src/client/components/BasicAuthAppGate.tsx new file mode 100644 index 0000000..46a4108 --- /dev/null +++ b/orchestrator/src/client/components/BasicAuthAppGate.tsx @@ -0,0 +1,78 @@ +import * as api from "@client/api/client"; +import { Loader2 } from "lucide-react"; +import React from "react"; + +type Phase = "loading" | "prompt" | "ready"; + +/** + * Delays mounting children until Basic Auth is satisfied (or disabled), so hooks do not + * fire unauthenticated writes first. The login dialog is shown during the `prompt` phase. + */ +export const BasicAuthAppGate: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [phase, setPhase] = React.useState("loading"); + + React.useEffect(() => { + let cancelled = false; + + void (async () => { + try { + const status = await api.getBasicAuthBasicStatus(); + if (cancelled) return; + if (!status.basicAuthEnabled) { + setPhase("ready"); + return; + } + + const restored = await api.tryBootstrapBasicAuthFromSessionOnly(status); + if (cancelled) return; + if (restored) { + setPhase("ready"); + return; + } + + setPhase("prompt"); + await api.bootstrapBasicAuthWithPromptOnly(); + if (cancelled) return; + setPhase("ready"); + } catch { + if (!cancelled) setPhase("ready"); + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + if (phase === "loading") { + return ( +
+ +

+ Checking authentication… +

+
+ ); + } + + if (phase === "prompt") { + return ( +
+

Sign in

+

Use the dialog above to enter your username and password.

+

+ After you sign in, open the menu (☰) → Account → "Sign out / + switch user" to log in as someone else. Job lists are separate + per login. +

+
+ ); + } + + return <>{children}; +}; diff --git a/orchestrator/src/client/components/ManualImportFlow.tsx b/orchestrator/src/client/components/ManualImportFlow.tsx index 21d573e..aa1f534 100644 --- a/orchestrator/src/client/components/ManualImportFlow.tsx +++ b/orchestrator/src/client/components/ManualImportFlow.tsx @@ -1,4 +1,8 @@ import * as api from "@client/api"; +import { + MANUAL_IMPORT_SAMPLE_SDET_JD, + MANUAL_IMPORT_SAMPLE_SENIOR_QA_JD, +} from "@client/lib/manual-import-samples"; import type { ManualJobDraft } from "@shared/types.js"; import { ArrowLeft, @@ -331,6 +335,36 @@ export const ManualImportFlow: React.FC = ({ placeholder="Paste the full job description here, or enter a URL above to fetch it..." className="min-h-[200px] font-mono text-sm leading-relaxed" /> +
+ + +
+

+ Synthetic postings for quick manual-import testing (Ilia vs + Cherepaha-style roles). +

{error && ( diff --git a/orchestrator/src/client/components/layout.tsx b/orchestrator/src/client/components/layout.tsx index 4ddc8cb..663dd99 100644 --- a/orchestrator/src/client/components/layout.tsx +++ b/orchestrator/src/client/components/layout.tsx @@ -2,13 +2,15 @@ * Shared layout components for consistent page structure. */ -import { ExternalLink, type LucideIcon, Menu } from "lucide-react"; +import { signOutBasicAuthAndReload } from "@client/api/client"; +import { ExternalLink, LogOut, type LucideIcon, Menu } from "lucide-react"; import type React from "react"; import { useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent, @@ -23,6 +25,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { useBasicAuthNavSession } from "../hooks/useBasicAuthNavSession"; import { useVersionCheck } from "../hooks/useVersionCheck"; import { isNavActive, NAV_LINKS } from "./navigation"; import { StatusBadgeIndicator } from "./StatusIndicator"; @@ -60,6 +63,7 @@ export const PageHeader: React.FC = ({ const navOpen = controlledNavOpen ?? internalNavOpen; const setNavOpen = onNavOpenChange ?? setInternalNavOpen; const { version, updateAvailable } = useVersionCheck(); + const basicAuthSession = useBasicAuthNavSession(); const handleNavClick = (to: string, activePaths?: string[]) => { if (isNavActive(location.pathname, to, activePaths)) { @@ -103,6 +107,45 @@ export const PageHeader: React.FC = ({ ))} + {basicAuthSession.kind === "active" && ( + <> + +
+

+ Account +

+

+ Signed in as{" "} + + {basicAuthSession.username ?? "…"} + + . Jobs and pipeline runs for this deployment are{" "} + + only visible to this login + + — sign out to use another user (e.g.{" "} + ilia vs{" "} + + cherepaha + + ). +

+ +
+ + )} {showVersionFooter && (
diff --git a/orchestrator/src/client/hooks/useBasicAuthNavSession.ts b/orchestrator/src/client/hooks/useBasicAuthNavSession.ts new file mode 100644 index 0000000..14e9f84 --- /dev/null +++ b/orchestrator/src/client/hooks/useBasicAuthNavSession.ts @@ -0,0 +1,38 @@ +import { + getBasicAuthBasicStatus, + peekBasicAuthSessionCredentials, +} from "@client/api/client"; +import { useEffect, useState } from "react"; + +export type BasicAuthNavSession = + | { kind: "inactive" } + | { kind: "active"; username: string | null }; + +/** When Basic Auth is on, exposes the username from session storage for nav UI. */ +export function useBasicAuthNavSession(): BasicAuthNavSession { + const [state, setState] = useState({ kind: "inactive" }); + + useEffect(() => { + let cancelled = false; + const run = async () => { + try { + const status = await getBasicAuthBasicStatus(); + if (cancelled) return; + if (!status.basicAuthEnabled) { + setState({ kind: "inactive" }); + return; + } + const creds = peekBasicAuthSessionCredentials(); + setState({ kind: "active", username: creds?.username ?? null }); + } catch { + if (!cancelled) setState({ kind: "inactive" }); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, []); + + return state; +} diff --git a/orchestrator/src/client/lib/manual-import-samples.ts b/orchestrator/src/client/lib/manual-import-samples.ts new file mode 100644 index 0000000..c97ed78 --- /dev/null +++ b/orchestrator/src/client/lib/manual-import-samples.ts @@ -0,0 +1,38 @@ +/** + * Paste-ready job descriptions for local manual-import testing (Run → Manual). + * Titles/employers are synthetic; bodies are representative of each persona. + */ + +export const MANUAL_IMPORT_SAMPLE_SDET_JD = `Software Development Engineer in Test (SDET) +Employer: Northwind Labs (sample) +Location: Remote (Canada) +Type: Full-time + +We are hiring an SDET to own Playwright and API test automation for a regulated web platform. You will design end-to-end suites, stabilize flaky tests, and integrate runs into GitHub Actions. Strong TypeScript, contract testing with OpenAPI, and CI/CD ownership are required. + +Responsibilities: +- Build and maintain Playwright and REST API tests; review failures with developers. +- Improve test data and environment readiness; cut feedback time for releases. +- Partner with QA and platform on observability and performance smoke checks. + +Requirements: +- 5+ years test automation; TypeScript or JavaScript at production depth. +- Experience with CI (GitHub Actions or similar), Docker, and relational DB assertions. +`; + +export const MANUAL_IMPORT_SAMPLE_SENIOR_QA_JD = `Senior QA Analyst — Guidewire & integrations +Employer: Lakeshore Mutual (sample) +Location: Toronto, ON (hybrid) +Type: Contract + +Seeking a senior QA analyst to lead SIT/UAT and regression for Guidewire PolicyCenter and BillingCenter releases on AWS, plus Salesforce integration validation. Heavy API testing (Postman/SoapUI), SQL/Oracle data checks, and AODA/WCAG 2.0 AA accessibility sign-off. + +Responsibilities: +- Author test strategies, traceability, and defect triage with BAs and vendors. +- Execute REST/SOAP API and ETL validation; support Oracle migration cutovers. +- Run accessibility checks with NVDA/Axe; document evidence for audit. + +Requirements: +- 8+ years QA in insurance or large enterprise; Guidewire PC/BC/CC exposure strongly preferred. +- Azure DevOps or Jira for test management; Jenkins or similar for automation hooks. +`; diff --git a/orchestrator/src/client/lib/queryKeys.ts b/orchestrator/src/client/lib/queryKeys.ts index 2d3f678..4accc0b 100644 --- a/orchestrator/src/client/lib/queryKeys.ts +++ b/orchestrator/src/client/lib/queryKeys.ts @@ -1,6 +1,10 @@ import type { JobStatus, PostApplicationProvider } from "@shared/types"; export const queryKeys = { + searchProfiles: { + all: ["search-profiles"] as const, + list: () => [...queryKeys.searchProfiles.all, "list"] as const, + }, settings: { all: ["settings"] as const, current: () => [...queryKeys.settings.all, "current"] as const, diff --git a/orchestrator/src/client/lib/sse.ts b/orchestrator/src/client/lib/sse.ts index 27b50c9..98233e7 100644 --- a/orchestrator/src/client/lib/sse.ts +++ b/orchestrator/src/client/lib/sse.ts @@ -1,13 +1,106 @@ +import { + encodeBasicAuthHeaderValue, + getActiveBasicAuthCredentials, +} from "@client/api/client"; + interface EventSourceSubscriptionHandlers { onOpen?: () => void; onMessage: (payload: T) => void; onError?: () => void; } +function buildAuthHeaders(): Record | null { + const creds = getActiveBasicAuthCredentials(); + if (!creds) return null; + return { Authorization: encodeBasicAuthHeaderValue(creds) }; +} + +/** + * Consume `data: …` frames from an SSE byte stream (RFC 8895-style `data:` lines). + * Returns unconsumed tail after the last complete `\n\n` delimiter. + */ +function consumeSseText( + buffer: string, + onJsonLine: (line: string) => void, +): string { + let rest = buffer; + while (true) { + const sep = rest.indexOf("\n\n"); + if (sep === -1) return rest; + const frame = rest.slice(0, sep); + rest = rest.slice(sep + 2); + const trimmed = frame.trim(); + if (!trimmed || trimmed.startsWith(":")) continue; + const dataLines = frame + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.replace(/^data:\s?/, "").trim()) + .filter(Boolean); + if (dataLines.length === 0) continue; + onJsonLine(dataLines.join("\n")); + } +} + +function subscribeViaFetch( + url: string, + handlers: EventSourceSubscriptionHandlers, + authHeaders: Record, +): () => void { + const abort = new AbortController(); + let cancelled = false; + + const run = async () => { + try { + const response = await fetch(url, { + headers: { + Accept: "text/event-stream", + ...authHeaders, + }, + signal: abort.signal, + credentials: "same-origin", + }); + if (!response.ok || !response.body) { + handlers.onError?.(); + return; + } + handlers.onOpen?.(); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (!cancelled) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + buffer = consumeSseText(buffer, (jsonLine) => { + try { + handlers.onMessage(JSON.parse(jsonLine) as T); + } catch { + // Ignore malformed frames + } + }); + } + } catch { + if (!cancelled) handlers.onError?.(); + } + }; + + void run(); + + return () => { + cancelled = true; + abort.abort(); + }; +} + export function subscribeToEventSource( url: string, handlers: EventSourceSubscriptionHandlers, ): () => void { + const authHeaders = buildAuthHeaders(); + if (authHeaders) { + return subscribeViaFetch(url, handlers, authHeaders); + } + const eventSource = new EventSource(url); eventSource.onopen = () => { diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index e68ae00..6cafe61 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -178,6 +178,10 @@ vi.mock("./orchestrator/OrchestratorSummary", () => ({ OrchestratorSummary: () =>
, })); +vi.mock("./orchestrator/ProfileQuickSwitch", () => ({ + ProfileQuickSwitch: () => null, +})); + vi.mock("./orchestrator/JobCommandBar", () => ({ JobCommandBar: ({ onSelectJob, diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 7581ddf..68dc3d0 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -15,6 +15,7 @@ import { JobListPanel } from "./orchestrator/JobListPanel"; import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters"; import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader"; import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary"; +import { ProfileQuickSwitch } from "./orchestrator/ProfileQuickSwitch"; import { RunModeModal } from "./orchestrator/RunModeModal"; import { useFilteredJobs } from "./orchestrator/useFilteredJobs"; import { useJobSelectionActions } from "./orchestrator/useJobSelectionActions"; @@ -470,6 +471,8 @@ export const OrchestratorPage: React.FC = () => { isPipelineRunning={isPipelineRunning} /> + + {/* Main content: tabs/filters -> list/detail */}
{ isLoading={isLoading} jobs={jobs} activeJobs={activeJobs} + unfilteredTabCount={counts[activeTab]} + onResetFilters={resetFilters} selectedJobId={selectedJobId} selectedJobIds={selectedJobIds} activeTab={activeTab} diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx index 3b896e2..a1d7d02 100644 --- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx @@ -457,4 +457,34 @@ describe("AutomaticRunTab", () => { ); }); }); + + it("enables automatic run when search terms are empty but profile has target roles", () => { + const base = createAppSettings(); + render( + , + ); + + expect( + screen.getByRole("button", { name: "Start run now" }), + ).not.toBeDisabled(); + expect(screen.getByText("Platform engineer")).toBeInTheDocument(); + }); }); diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx index 75ecd11..8cd1592 100644 --- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx +++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx @@ -36,6 +36,7 @@ import { type AutomaticRunValues, calculateAutomaticEstimate, loadAutomaticRunMemory, + mergeDiscoverySearchTerms, normalizeWorkplaceTypes, parseCityLocationsInput, parseCityLocationsSetting, @@ -229,6 +230,21 @@ export const AutomaticRunTab: React.FC = ({ settings?.workplaceTypes?.value, ); + const savedSearchTerms = + settings?.searchTerms?.value + ?.map((t) => t.trim()) + .filter((t): t is string => Boolean(t)) ?? []; + const profileTargetRoles = + settings?.jobSearchProfile?.value?.targetRoles + ?.map((t) => t.trim()) + .filter((t): t is string => Boolean(t)) ?? []; + const initialSearchTerms = + savedSearchTerms.length > 0 + ? savedSearchTerms + : profileTargetRoles.length > 0 + ? profileTargetRoles + : DEFAULT_VALUES.searchTerms; + reset({ topN: String(topN), minSuitabilityScore: String(minSuitabilityScore), @@ -237,7 +253,7 @@ export const AutomaticRunTab: React.FC = ({ cityLocations: rememberedLocations, cityLocationDraft: "", workplaceTypes: rememberedWorkplaceTypes, - searchTerms: settings?.searchTerms?.value ?? DEFAULT_VALUES.searchTerms, + searchTerms: initialSearchTerms, searchTermDraft: "", }); setAdvancedOpen(false); @@ -319,13 +335,22 @@ export const AutomaticRunTab: React.FC = ({ pipelineSources, ]); + const mergedDiscoveryTerms = useMemo( + () => + mergeDiscoverySearchTerms( + values.searchTerms, + settings?.jobSearchProfile?.value?.targetRoles, + ), + [values.searchTerms, settings?.jobSearchProfile?.value?.targetRoles], + ); + const estimate = useMemo( () => calculateAutomaticEstimate({ - values, + values: { ...values, searchTerms: mergedDiscoveryTerms }, sources: compatiblePipelineSources, }), - [values, compatiblePipelineSources], + [values, mergedDiscoveryTerms, compatiblePipelineSources], ); const activePreset = useMemo( @@ -337,7 +362,7 @@ export const AutomaticRunTab: React.FC = ({ isPipelineRunning || isSaving || compatiblePipelineSources.length === 0 || - values.searchTerms.length === 0 || + mergedDiscoveryTerms.length === 0 || workplaceTypeSelectionInvalid; const toggleWorkplaceType = ( diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx index 42c31f5..24bd631 100644 --- a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx @@ -10,6 +10,7 @@ describe("JobListPanel", () => { isLoading jobs={[]} activeJobs={[]} + unfilteredTabCount={0} selectedJobId={null} selectedJobIds={new Set()} activeTab="ready" @@ -28,6 +29,7 @@ describe("JobListPanel", () => { isLoading={false} jobs={[]} activeJobs={[]} + unfilteredTabCount={0} selectedJobId={null} selectedJobIds={new Set()} activeTab="ready" @@ -43,6 +45,33 @@ describe("JobListPanel", () => { ).toBeInTheDocument(); }); + it("explains when filters hide jobs that exist in the tab", () => { + const onReset = vi.fn(); + const jobs = [ + createJob({ id: "job-1", title: "Backend Engineer" }), + createJob({ id: "job-2", title: "Other" }), + ]; + render( + , + ); + + expect(screen.getByText("No jobs match your filters")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + expect(onReset).toHaveBeenCalledTimes(1); + }); + it("renders jobs and notifies when a job is selected", () => { const onSelectJob = vi.fn(); const onToggleSelectJob = vi.fn(); @@ -61,6 +90,7 @@ describe("JobListPanel", () => { isLoading={false} jobs={jobs} activeJobs={jobs} + unfilteredTabCount={2} selectedJobId="job-1" selectedJobIds={new Set()} activeTab="ready" @@ -91,6 +121,7 @@ describe("JobListPanel", () => { isLoading={false} jobs={jobs} activeJobs={jobs} + unfilteredTabCount={2} selectedJobId="job-1" selectedJobIds={new Set(["job-1"])} activeTab="ready" @@ -114,6 +145,7 @@ describe("JobListPanel", () => { isLoading={false} jobs={jobs} activeJobs={jobs} + unfilteredTabCount={1} selectedJobId={null} selectedJobIds={new Set()} activeTab="ready" @@ -132,6 +164,7 @@ describe("JobListPanel", () => { isLoading={false} jobs={jobs} activeJobs={jobs} + unfilteredTabCount={1} selectedJobId="job-1" selectedJobIds={new Set()} activeTab="ready" @@ -150,6 +183,7 @@ describe("JobListPanel", () => { isLoading={false} jobs={jobs} activeJobs={jobs} + unfilteredTabCount={1} selectedJobId={null} selectedJobIds={new Set(["job-1"])} activeTab="ready" diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx index a7b4621..489f6f1 100644 --- a/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx @@ -1,6 +1,7 @@ import type { JobListItem } from "@shared/types.js"; import { Loader2 } from "lucide-react"; import type React from "react"; +import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import type { FilterTab } from "./constants"; @@ -11,6 +12,9 @@ interface JobListPanelProps { isLoading: boolean; jobs: JobListItem[]; activeJobs: JobListItem[]; + /** Jobs in this tab before list filters (search, sources, etc.). */ + unfilteredTabCount: number; + onResetFilters?: () => void; selectedJobId: string | null; selectedJobIds: Set; activeTab: FilterTab; @@ -23,6 +27,8 @@ export const JobListPanel: React.FC = ({ isLoading, jobs, activeJobs, + unfilteredTabCount, + onResetFilters, selectedJobId, selectedJobIds, activeTab, @@ -37,11 +43,27 @@ export const JobListPanel: React.FC = ({
Loading jobs...
) : activeJobs.length === 0 ? ( -
-
No jobs found
+
+
+ {unfilteredTabCount > 0 + ? "No jobs match your filters" + : "No jobs found"} +

- {emptyStateCopy[activeTab]} + {unfilteredTabCount > 0 + ? `There ${unfilteredTabCount === 1 ? "is" : "are"} ${unfilteredTabCount.toLocaleString()} job${unfilteredTabCount === 1 ? "" : "s"} in this tab before filters. Clear filters or adjust search.` + : emptyStateCopy[activeTab]}

+ {unfilteredTabCount > 0 && onResetFilters ? ( + + ) : null}
) : (
diff --git a/orchestrator/src/client/pages/orchestrator/ProfileQuickSwitch.tsx b/orchestrator/src/client/pages/orchestrator/ProfileQuickSwitch.tsx new file mode 100644 index 0000000..2157322 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/ProfileQuickSwitch.tsx @@ -0,0 +1,111 @@ +import * as api from "@client/api"; +import { useSettings } from "@client/hooks/useSettings"; +import { queryKeys } from "@client/lib/queryKeys"; +import type { SearchProfile } from "@shared/types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import type React from "react"; +import { useMemo } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +/** + * Search profile strip on the Jobs page: quick link to Settings, or inline + * switch when you have more than one profile for this login. + */ +export const ProfileQuickSwitch: React.FC = () => { + const queryClient = useQueryClient(); + const { settings, refreshSettings } = useSettings(); + + const { data: profiles = [], isLoading } = useQuery({ + queryKey: queryKeys.searchProfiles.list(), + queryFn: api.listProfiles, + }); + + const activeId = useMemo(() => { + const sid = settings?.activeProfileId?.trim() ?? ""; + if (sid && profiles.some((p) => p.id === sid)) return sid; + return profiles[0]?.id ?? ""; + }, [settings?.activeProfileId, profiles]); + + const activateMutation = useMutation({ + mutationFn: (id: string) => api.activateProfile(id), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.jobs.all }), + queryClient.invalidateQueries({ queryKey: queryKeys.profile.all }), + queryClient.invalidateQueries({ + queryKey: queryKeys.searchProfiles.all, + }), + ]); + await refreshSettings(); + toast.success("Search profile updated"); + }, + onError: (err: unknown) => { + const message = + err instanceof Error ? err.message : "Failed to activate profile"; + toast.error(message); + }, + }); + + if (isLoading && profiles.length === 0) { + return ( +
+ + Profiles… +
+ ); + } + + if (profiles.length === 0) return null; + + if (profiles.length === 1) { + const p = profiles[0] as SearchProfile; + return ( +
+ Search profile + {p.name} + +
+ ); + } + + return ( +
+ + Search profile + + + +
+ ); +}; diff --git a/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts b/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts index 6ab2723..0a98220 100644 --- a/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts +++ b/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts @@ -3,6 +3,7 @@ import { AUTOMATIC_PRESETS, calculateAutomaticEstimate, deriveExtractorLimits, + mergeDiscoverySearchTerms, parseSearchTermsInput, } from "./automatic-run"; @@ -92,6 +93,19 @@ describe("automatic-run utilities", () => { ]); }); + it("merges profile target roles after saved terms without duplicates", () => { + expect( + mergeDiscoverySearchTerms( + ["Backend Engineer", " "], + ["SRE", "backend engineer", " Platform"], + ), + ).toEqual(["Backend Engineer", "SRE", "Platform"]); + }); + + it("treats empty saved terms as eligible for profile-only roles", () => { + expect(mergeDiscoverySearchTerms([], ["Rust", " "])).toEqual(["Rust"]); + }); + it("includes adzuna in estimate caps", () => { const estimate = calculateAutomaticEstimate({ values: { diff --git a/orchestrator/src/client/pages/orchestrator/automatic-run.ts b/orchestrator/src/client/pages/orchestrator/automatic-run.ts index 9a8519a..cb10738 100644 --- a/orchestrator/src/client/pages/orchestrator/automatic-run.ts +++ b/orchestrator/src/client/pages/orchestrator/automatic-run.ts @@ -4,6 +4,29 @@ import { } from "@shared/search-cities.js"; import type { JobSource } from "@shared/types"; +/** + * Same merge order as server discovery (`discover-jobs`): explicit search terms + * first, then profile target roles that are not already present (case-insensitive). + */ +export function mergeDiscoverySearchTerms( + savedTerms: string[], + profileTargetRoles: string[] | null | undefined, +): string[] { + const normalized = savedTerms.map((t) => t.trim()).filter(Boolean); + const existingLower = new Set(normalized.map((t) => t.toLowerCase())); + const out = [...normalized]; + for (const role of profileTargetRoles ?? []) { + if (typeof role !== "string") continue; + const trimmed = role.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (existingLower.has(key)) continue; + out.push(trimmed); + existingLower.add(key); + } + return out; +} + export type AutomaticPresetId = "fast" | "balanced" | "detailed"; export type WorkplaceType = "remote" | "hybrid" | "onsite"; export const WORKPLACE_TYPE_OPTIONS: WorkplaceType[] = [ diff --git a/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts b/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts index 16d7ec1..38a409d 100644 --- a/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts +++ b/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts @@ -11,6 +11,7 @@ import { trackProductEvent } from "@/lib/analytics"; import type { AutomaticRunValues } from "./automatic-run"; import { deriveExtractorLimits, + mergeDiscoverySearchTerms, serializeCityLocationsSetting, } from "./automatic-run"; import type { RunMode } from "./run-mode"; @@ -57,7 +58,7 @@ export function usePipelineControls( const [runMode, setRunMode] = useState("automatic"); const [isCancelling, setIsCancelling] = useState(false); - const { refreshSettings } = useSettings(); + const { settings, refreshSettings } = useSettings(); useEffect(() => { if (!pipelineTerminalEvent) return; @@ -168,9 +169,20 @@ export function usePipelineControls( return; } + const mergedSearchTerms = mergeDiscoverySearchTerms( + values.searchTerms, + settings?.jobSearchProfile?.value?.targetRoles, + ); + if (mergedSearchTerms.length === 0) { + toast.error( + "Add at least one search term, or set target roles on your job search profile.", + ); + return; + } + const limits = deriveExtractorLimits({ budget: values.runBudget, - searchTerms: values.searchTerms, + searchTerms: mergedSearchTerms, sources: compatibleSources, }); const hasJobSpySite = compatibleSources.some( @@ -210,12 +222,12 @@ export function usePipelineControls( mode: "automatic", country: values.country, hasCityLocations: values.cityLocations.length > 0, - searchTermsCount: values.searchTerms.length, + searchTermsCount: mergedSearchTerms.length, }, }); setIsRunModeModalOpen(false); }, - [pipelineSources, refreshSettings, startPipelineRun], + [pipelineSources, refreshSettings, settings, startPipelineRun], ); const handleManualImported = useCallback( diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx index 93531a1..1f1f0ff 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx @@ -67,5 +67,11 @@ describe("EnvironmentSettingsSection", () => { expect(screen.getByText("Service Accounts")).toBeInTheDocument(); expect(screen.getByText("Security")).toBeInTheDocument(); expect(screen.queryByText("RxResume")).not.toBeInTheDocument(); + + expect( + screen.getByRole("button", { + name: /sign out \/ switch user/i, + }), + ).toBeInTheDocument(); }); }); diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx index 5ad5f5b..559fbb1 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx @@ -1,3 +1,4 @@ +import { signOutBasicAuthAndReload } from "@client/api/client"; import { SettingsInput } from "@client/pages/settings/components/SettingsInput"; import type { EnvSettingsValues } from "@client/pages/settings/types"; import { formatSecretHint } from "@client/pages/settings/utils"; @@ -9,6 +10,7 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Separator } from "@/components/ui/separator"; @@ -27,10 +29,14 @@ export const EnvironmentSettingsSection: React.FC< watch, formState: { errors }, } = useFormContext(); - const { private: privateValues } = values; + const { private: privateValues, basicAuthActive } = values; const isBasicAuthEnabled = watch("enableBasicAuth"); + const handleSignOutBasicAuth = () => { + signOutBasicAuthAndReload(); + }; + return ( @@ -151,6 +157,24 @@ export const EnvironmentSettingsSection: React.FC< />
)} + + {basicAuthActive && ( +
+

+ Credentials live in session storage. Sign out to switch login; + each user only sees jobs stored under their profile. +

+ +
+ )}
diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts index a834c01..8b8544b 100644 --- a/orchestrator/src/server/api/routes.ts +++ b/orchestrator/src/server/api/routes.ts @@ -3,6 +3,7 @@ */ import { Router } from "express"; +import { authRouter } from "./routes/auth"; import { backupRouter } from "./routes/backup"; import { databaseRouter } from "./routes/database"; import { demoRouter } from "./routes/demo"; @@ -22,6 +23,7 @@ import { webhookRouter } from "./routes/webhook"; export const apiRouter = Router(); +apiRouter.use("/auth", authRouter); apiRouter.use("/jobs", jobsRouter); apiRouter.use("/jobs/:id/chat", ghostwriterRouter); apiRouter.use("/demo", demoRouter); diff --git a/orchestrator/src/server/api/routes/auth.ts b/orchestrator/src/server/api/routes/auth.ts new file mode 100644 index 0000000..fc5667b --- /dev/null +++ b/orchestrator/src/server/api/routes/auth.ts @@ -0,0 +1,25 @@ +import { asyncRoute, ok } from "@infra/http"; +import { isBasicAuthEnabled } from "@server/infra/basic-auth-credentials"; +import { type Request, type Response, Router } from "express"; + +export const authRouter = Router(); + +/** + * GET /api/auth/basic-status — public; whether write operations require Basic Auth. + */ +authRouter.get( + "/basic-status", + asyncRoute(async (_req: Request, res: Response) => { + return ok(res, { basicAuthEnabled: isBasicAuthEnabled() }); + }), +); + +/** + * POST /api/auth/verify — requires Basic Auth when enabled; confirms credentials. + */ +authRouter.post( + "/verify", + asyncRoute(async (_req: Request, res: Response) => { + return ok(res, { valid: true }); + }), +); diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts index 4d0e113..011bc52 100644 --- a/orchestrator/src/server/api/routes/jobs.ts +++ b/orchestrator/src/server/api/routes/jobs.ts @@ -180,8 +180,27 @@ const updateJobSchema = z.object({ }); function isJobUrlConflictError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - return /UNIQUE constraint failed: jobs\.job_url/i.test(error.message); + let current: unknown = error; + for (let depth = 0; depth < 6 && current; depth += 1) { + if (current instanceof Error) { + const msg = current.message; + if ( + /UNIQUE constraint failed: jobs\.job_url/i.test(msg) || + /UNIQUE constraint failed.*idx_jobs_owner_profile_job_url/i.test(msg) || + /UNIQUE constraint failed:.*owner_profile_id.*job_url/i.test(msg) + ) { + return true; + } + } + current = + typeof current === "object" && + current !== null && + "cause" in current && + (current as { cause?: unknown }).cause !== undefined + ? (current as { cause: unknown }).cause + : null; + } + return false; } const transitionStageSchema = z.object({ @@ -357,7 +376,11 @@ async function executeJobActionForJob( }); } - const updated = await jobsRepo.updateJob(jobId, { status: "skipped" }); + const updated = await jobsRepo.updateJob( + jobId, + { status: "skipped" }, + job.ownerProfileId, + ); if (!updated) { throw new AppError({ status: 404, @@ -451,11 +474,15 @@ async function executeJobActionForJob( profile, ); - const updated = await jobsRepo.updateJob(job.id, { - suitabilityScore: score, - suitabilityReason: reason, - suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined, - }); + const updated = await jobsRepo.updateJob( + job.id, + { + suitabilityScore: score, + suitabilityReason: reason, + suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined, + }, + job.ownerProfileId, + ); if (!updated) { throw new AppError({ status: 404, @@ -503,7 +530,11 @@ async function executeJobActionForJob( searchProfile, ); - const updated = await jobsRepo.updateJob(job.id, { coverLetter }); + const updated = await jobsRepo.updateJob( + job.id, + { coverLetter }, + job.ownerProfileId, + ); if (!updated) { throw new AppError({ status: 404, @@ -1053,10 +1084,18 @@ jobsRouter.patch("/:id/outcome", async (req: Request, res: Response) => { const closedAt = input.outcome ? (input.closedAt ?? Math.floor(Date.now() / 1000)) : null; - const job = await jobsRepo.updateJob(req.params.id, { - outcome: input.outcome, - closedAt, - }); + const existing = await jobsRepo.getJobById(req.params.id); + if (!existing) { + return fail(res, notFound("Job not found")); + } + const job = await jobsRepo.updateJob( + req.params.id, + { + outcome: input.outcome, + closedAt, + }, + existing.ownerProfileId, + ); if (!job) { return fail(res, notFound("Job not found")); @@ -1120,7 +1159,11 @@ jobsRouter.patch("/:id", async (req: Request, res: Response) => { } } - const job = await jobsRepo.updateJob(req.params.id, input); + const job = await jobsRepo.updateJob( + req.params.id, + input, + currentJob.ownerProfileId, + ); if (!job) { const err = new AppError({ @@ -1235,10 +1278,14 @@ jobsRouter.post("/:id/check-sponsor", async (req: Request, res: Response) => { visaSponsors.calculateSponsorMatchSummary(sponsorResults); // Update job with sponsor match info - const updatedJob = await jobsRepo.updateJob(job.id, { - sponsorMatchScore: sponsorMatchScore, - sponsorMatchNames: sponsorMatchNames ?? undefined, - }); + const updatedJob = await jobsRepo.updateJob( + job.id, + { + sponsorMatchScore: sponsorMatchScore, + sponsorMatchNames: sponsorMatchNames ?? undefined, + }, + job.ownerProfileId, + ); res.json({ success: true, @@ -1317,10 +1364,14 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => { null, ); - const updatedJob = await jobsRepo.updateJob(job.id, { - status: "applied", - appliedAt, - }); + const updatedJob = await jobsRepo.updateJob( + job.id, + { + status: "applied", + appliedAt, + }, + job.ownerProfileId, + ); if (updatedJob) { notifyJobCompleteWebhook(updatedJob).catch((error) => { diff --git a/orchestrator/src/server/api/routes/pipeline.test.ts b/orchestrator/src/server/api/routes/pipeline.test.ts index 4b5efbb..41f1468 100644 --- a/orchestrator/src/server/api/routes/pipeline.test.ts +++ b/orchestrator/src/server/api/routes/pipeline.test.ts @@ -40,10 +40,13 @@ describe.sequential("Pipeline API routes", () => { }); const runBody = await runRes.json(); expect(runBody.ok).toBe(true); - expect(runPipeline).toHaveBeenCalledWith({ - topN: 5, - sources: ["gradcracker"], - }); + expect(runPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + topN: 5, + sources: ["gradcracker"], + ownerProfileId: "__default__", + }), + ); const glassdoorRunRes = await fetch(`${baseUrl}/api/pipeline/run`, { method: "POST", @@ -52,9 +55,13 @@ describe.sequential("Pipeline API routes", () => { }); const glassdoorRunBody = await glassdoorRunRes.json(); expect(glassdoorRunBody.ok).toBe(true); - expect(runPipeline).toHaveBeenNthCalledWith(2, { - sources: ["glassdoor"], - }); + expect(runPipeline).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + sources: ["glassdoor"], + ownerProfileId: "__default__", + }), + ); const adzunaRunRes = await fetch(`${baseUrl}/api/pipeline/run`, { method: "POST", @@ -63,9 +70,13 @@ describe.sequential("Pipeline API routes", () => { }); const adzunaRunBody = await adzunaRunRes.json(); expect(adzunaRunBody.ok).toBe(true); - expect(runPipeline).toHaveBeenNthCalledWith(3, { - sources: ["adzuna"], - }); + expect(runPipeline).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + sources: ["adzuna"], + ownerProfileId: "__default__", + }), + ); }); it("returns conflict when cancelling with no active pipeline", async () => { diff --git a/orchestrator/src/server/api/routes/pipeline.ts b/orchestrator/src/server/api/routes/pipeline.ts index 21ab4cf..24a3b6e 100644 --- a/orchestrator/src/server/api/routes/pipeline.ts +++ b/orchestrator/src/server/api/routes/pipeline.ts @@ -7,13 +7,17 @@ import { } from "@infra/errors"; import { fail, ok, okWithMeta } from "@infra/http"; import { logger } from "@infra/logger"; -import { runWithRequestContext } from "@infra/request-context"; +import { + getJobOwnerProfileId, + runWithRequestContext, +} from "@infra/request-context"; import { setupSse, startSseHeartbeat, writeSseData } from "@infra/sse"; import { isDemoMode } from "@server/config/demo"; import { type ExtractorRegistry, getExtractorRegistry, } from "@server/extractors/registry"; +import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context"; import { getPipelineStatus, requestPipelineCancel, @@ -157,9 +161,11 @@ pipelineRouter.post("/run", async (req: Request, res: Response) => { return okWithMeta(res, simulated, { simulated: true }); } - // Start pipeline in background - runWithRequestContext({}, () => { - runPipeline(config).catch((error) => { + // Start pipeline in background (preserve tenant for async pipeline work). + const ownerProfileId = + getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID; + runWithRequestContext({ ownerProfileId }, () => { + runPipeline({ ...config, ownerProfileId }).catch((error) => { logger.error("Background pipeline run failed", error); }); }); diff --git a/orchestrator/src/server/api/routes/profiles.ts b/orchestrator/src/server/api/routes/profiles.ts index f421dc0..98d9586 100644 --- a/orchestrator/src/server/api/routes/profiles.ts +++ b/orchestrator/src/server/api/routes/profiles.ts @@ -1,19 +1,47 @@ -import { badRequest } from "@infra/errors"; +import { badRequest, forbidden } from "@infra/errors"; import { asyncRoute, fail, ok } from "@infra/http"; import { logger } from "@infra/logger"; +import { + isBasicAuthEnabled, + parseBasicAuthUsername, +} from "@server/infra/basic-auth-credentials"; import * as profilesRepo from "@server/repositories/profiles"; import { setSetting } from "@server/repositories/settings"; -import { getProfile } from "@server/services/profile"; +import { clearProfileCache, getProfile } from "@server/services/profile"; import { generateProfileFromResume } from "@server/services/profile-generator"; import { jobSearchProfileSchema } from "@shared/settings-registry"; +import type { SearchProfile } from "@shared/types"; import { type Request, type Response, Router } from "express"; export const profilesRouter = Router(); +function profileMatchesBasicAuthUser( + profile: SearchProfile, + username: string | null, +): boolean { + if (!username?.trim()) return false; + const u = username.trim().toLowerCase(); + const bu = (profile.data.basicAuthUser ?? "").trim().toLowerCase(); + return Boolean(bu) && bu === u; +} + +function assertProfileVisibleToRequest( + req: Request, + profile: SearchProfile, +): boolean { + if (!isBasicAuthEnabled()) return true; + const username = parseBasicAuthUsername(req.headers.authorization); + return profileMatchesBasicAuthUser(profile, username); +} + profilesRouter.get( "/", - asyncRoute(async (_req: Request, res: Response) => { - const profiles = await profilesRepo.listProfiles(); + asyncRoute(async (req: Request, res: Response) => { + const username = parseBasicAuthUsername(req.headers.authorization); + const profiles = + isBasicAuthEnabled() && username?.trim() + ? await profilesRepo.listProfilesForBasicAuthUser(username) + : await profilesRepo.listProfiles(); return ok(res, profiles); }), ); @@ -25,6 +53,9 @@ profilesRouter.get( if (!profile) { return fail(res, badRequest("Profile not found")); } + if (!assertProfileVisibleToRequest(req, profile)) { + return fail(res, forbidden("You cannot access this profile")); + } return ok(res, profile); }), ); @@ -42,9 +73,14 @@ profilesRouter.post( issues: parsed.error.issues, }); } + const username = parseBasicAuthUsername(req.headers.authorization)?.trim(); + const dataWithOwner = + isBasicAuthEnabled() && username + ? { ...parsed.data, basicAuthUser: username } + : parsed.data; const profile = await profilesRepo.createProfile({ name: name.trim(), - data: parsed.data, + data: dataWithOwner, }); return ok(res, profile); }), @@ -53,6 +89,13 @@ profilesRouter.post( profilesRouter.patch( "/:id", asyncRoute(async (req: Request, res: Response) => { + const existing = await profilesRepo.getProfileById(req.params.id); + if (!existing) { + return fail(res, badRequest("Profile not found")); + } + if (!assertProfileVisibleToRequest(req, existing)) { + return fail(res, forbidden("You cannot update this profile")); + } const { name, data } = req.body; const updates: { name?: string; data?: typeof data } = {}; if (name !== undefined) { @@ -68,7 +111,18 @@ profilesRouter.patch( issues: parsed.error.issues, }); } - updates.data = parsed.data; + const username = parseBasicAuthUsername( + req.headers.authorization, + )?.trim(); + if (isBasicAuthEnabled() && username) { + const locked = (existing.data.basicAuthUser ?? "").trim(); + if (locked && locked.toLowerCase() !== username.toLowerCase()) { + return fail(res, forbidden("Cannot reassign basicAuthUser")); + } + updates.data = { ...parsed.data, basicAuthUser: username }; + } else { + updates.data = parsed.data; + } } const profile = await profilesRepo.updateProfile(req.params.id, updates); if (!profile) { @@ -81,6 +135,13 @@ profilesRouter.patch( profilesRouter.delete( "/:id", asyncRoute(async (req: Request, res: Response) => { + const existing = await profilesRepo.getProfileById(req.params.id); + if (!existing) { + return fail(res, badRequest("Profile not found")); + } + if (!assertProfileVisibleToRequest(req, existing)) { + return fail(res, forbidden("You cannot delete this profile")); + } const deleted = await profilesRepo.deleteProfile(req.params.id); if (!deleted) { return fail(res, badRequest("Profile not found")); @@ -96,8 +157,23 @@ profilesRouter.post( if (!profile) { return fail(res, badRequest("Profile not found")); } + if (!assertProfileVisibleToRequest(req, profile)) { + return fail(res, forbidden("You cannot activate this profile")); + } + const basicUser = parseBasicAuthUsername(req.headers.authorization)?.trim(); + + if (isBasicAuthEnabled() && basicUser) { + clearProfileCache(); + return ok(res, { activated: true, profileId: profile.id }); + } + + const resumePath = profile.data.resumeLocalPath?.trim(); await setSetting("activeProfileId", profile.id); await setSetting("jobSearchProfile", JSON.stringify(profile.data)); + if (resumePath) { + await setSetting("localResumeProfilePath", resumePath); + clearProfileCache(); + } return ok(res, { activated: true, profileId: profile.id }); }), ); diff --git a/orchestrator/src/server/api/routes/stats-proxy.test.ts b/orchestrator/src/server/api/routes/stats-proxy.test.ts index b5bc9cf..75b5f20 100644 --- a/orchestrator/src/server/api/routes/stats-proxy.test.ts +++ b/orchestrator/src/server/api/routes/stats-proxy.test.ts @@ -175,7 +175,7 @@ describe.sequential("Stats proxy routes", () => { await stopServer({ server, closeDb, tempDir }); ({ server, baseUrl, closeDb, tempDir } = await startServer({ env: { - BASIC_AUTH_USER: "admin", + BASIC_AUTH_USER: "ilia", BASIC_AUTH_PASSWORD: "secret", }, })); diff --git a/orchestrator/src/server/api/routes/tracer-links.test.ts b/orchestrator/src/server/api/routes/tracer-links.test.ts index 80dd164..62792a9 100644 --- a/orchestrator/src/server/api/routes/tracer-links.test.ts +++ b/orchestrator/src/server/api/routes/tracer-links.test.ts @@ -227,7 +227,8 @@ describe.sequential("Tracer links routes", () => { await stopServer({ server, closeDb, tempDir }); ({ server, baseUrl, closeDb, tempDir } = await startServer({ env: { - BASIC_AUTH_USER: "admin", + // Must match a seeded search profile `basicAuthUser` (see db/migrate.ts). + BASIC_AUTH_USER: "ilia", BASIC_AUTH_PASSWORD: "secret", }, })); @@ -235,7 +236,7 @@ describe.sequential("Tracer links routes", () => { const unauthorized = await fetch(`${baseUrl}/api/tracer-links/analytics`); expect(unauthorized.status).toBe(401); - const credentials = Buffer.from("admin:secret").toString("base64"); + const credentials = Buffer.from("ilia:secret").toString("base64"); const authorized = await fetch(`${baseUrl}/api/tracer-links/analytics`, { headers: { Authorization: `Basic ${credentials}`, diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts index 3b1b7f1..f457575 100644 --- a/orchestrator/src/server/app.ts +++ b/orchestrator/src/server/app.ts @@ -19,6 +19,12 @@ import { } from "@infra/http"; import { logger } from "@infra/logger"; import { sanitizeUnknown } from "@infra/sanitize"; +import { + basicAuthMatchesDecodedUserPass, + isBasicAuthEnabled, + parseBasicAuthCredentials, +} from "@server/infra/basic-auth-credentials"; +import { jobOwnerContextMiddleware } from "@server/infra/job-owner-context"; import cors from "cors"; import express from "express"; import { apiRouter } from "./api/index"; @@ -132,33 +138,21 @@ function buildUmamiProxyHeaders(req: express.Request): Headers { } export function createBasicAuthGuard() { - 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 (!isBasicAuthEnabled()) return false; + const parsed = parseBasicAuthCredentials(req.headers.authorization); + if (!parsed) return false; + return basicAuthMatchesDecodedUserPass(parsed.user, parsed.pass); } - function isAuthorized(req: express.Request): boolean { - 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(); - let decoded = ""; - try { - decoded = Buffer.from(encoded, "base64").toString("utf-8"); - } catch { - return false; - } - const separatorIndex = decoded.indexOf(":"); - if (separatorIndex === -1) return false; - const user = decoded.slice(0, separatorIndex); - const pass = decoded.slice(separatorIndex + 1); - return user === authUser && pass === authPass; + function isPublicApiGet(path: string): boolean { + const normalizedPath = path.split("?")[0] || path; + if (normalizedPath === "/api/auth/basic-status") return true; + if (normalizedPath === "/api/demo/info") return true; + if (normalizedPath === "/api/visa-sponsors/status") return true; + if (normalizedPath === "/api/profile/status") return true; + if (normalizedPath === "/api/pipeline/status") return true; + return false; } function isPublicReadOnlyRoute(method: string, path: string): boolean { @@ -175,10 +169,26 @@ export function createBasicAuthGuard() { function requiresAuth(method: string, path: string): boolean { if (isPublicReadOnlyRoute(method, path)) return false; if (isStatsRoute(path)) return false; + const normalizedPath = path.split("?")[0] || path; + if ( + method.toUpperCase() === "GET" && + normalizedPath === "/api/auth/basic-status" + ) { + return false; + } if (path.startsWith("/api/tracer-links")) { return method.toUpperCase() !== "OPTIONS"; } - return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase()); + const m = method.toUpperCase(); + if ( + isBasicAuthEnabled() && + path.startsWith("/api/") && + (m === "GET" || m === "HEAD") + ) { + if (isPublicApiGet(path)) return false; + return true; + } + return !["GET", "HEAD", "OPTIONS"].includes(m); } const middleware = ( @@ -186,8 +196,8 @@ export function createBasicAuthGuard() { res: express.Response, next: express.NextFunction, ) => { - const { enabled } = getAuthConfig(); - if (!enabled || !requiresAuth(req.method, req.path)) return next(); + if (!isBasicAuthEnabled() || !requiresAuth(req.method, req.path)) + return next(); if (isAuthorized(req)) return next(); fail(res, unauthorized("Authentication required")); }; @@ -195,7 +205,7 @@ export function createBasicAuthGuard() { return { middleware, isAuthorized, - basicAuthEnabled: getAuthConfig().enabled, + basicAuthEnabled: isBasicAuthEnabled(), }; } @@ -277,6 +287,7 @@ export function createApp() { // Optional Basic Auth for write access (read-only by default) app.use(authGuard.middleware); + app.use(jobOwnerContextMiddleware()); // API routes app.use("/api", apiRouter); diff --git a/orchestrator/src/server/basic-auth.test.ts b/orchestrator/src/server/basic-auth.test.ts index 1d9740f..de01e75 100644 --- a/orchestrator/src/server/basic-auth.test.ts +++ b/orchestrator/src/server/basic-auth.test.ts @@ -114,9 +114,32 @@ describe.sequential("Basic Auth read-only enforcement", () => { expect(res.status).not.toHaveBeenCalled(); }); + it("allows writes when the second Basic Auth user is configured", () => { + process.env.BASIC_AUTH_USER = "user1"; + process.env.BASIC_AUTH_PASSWORD = "pass1"; + process.env.BASIC_AUTH_USER_2 = "user2"; + process.env.BASIC_AUTH_PASSWORD_2 = "pass2"; + + const { middleware } = createBasicAuthGuard(); + const req = createMockRequest({ + method: "POST", + path: "/api/jobs/actions", + authorization: buildAuthHeader("user2", "pass2"), + }); + const res = createMockResponse(); + const next = vi.fn() as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + }); + it("does not require auth when Basic Auth is disabled", () => { delete process.env.BASIC_AUTH_USER; delete process.env.BASIC_AUTH_PASSWORD; + delete process.env.BASIC_AUTH_USER_2; + delete process.env.BASIC_AUTH_PASSWORD_2; const { middleware } = createBasicAuthGuard(); const req = createMockRequest({ diff --git a/orchestrator/src/server/db/migrate.ts b/orchestrator/src/server/db/migrate.ts index 7ffe0e1..e0a8b73 100644 --- a/orchestrator/src/server/db/migrate.ts +++ b/orchestrator/src/server/db/migrate.ts @@ -18,6 +18,17 @@ if (!existsSync(dataDir)) { const sqlite = new Database(DB_PATH); +function sqlInsertSearchProfileSeed(input: { + id: string; + name: string; + profile: Record; +}): string { + const data = JSON.stringify(input.profile).replace(/'/g, "''"); + const id = input.id.replace(/'/g, "''"); + const name = input.name.replace(/'/g, "''"); + return `INSERT OR IGNORE INTO search_profiles (id, name, data, created_at, updated_at) VALUES ('${id}', '${name}', '${data}', datetime('now'), datetime('now'))`; +} + const migrations = [ `CREATE TABLE IF NOT EXISTS jobs ( id TEXT PRIMARY KEY, @@ -666,6 +677,183 @@ const migrations = [ ORDER BY se.occurred_at DESC, se.id DESC LIMIT 1 ), 'applied') = 'closed'`, + + // Per-profile job ownership: composite uniqueness on (owner_profile_id, job_url). + `ALTER TABLE jobs ADD COLUMN owner_profile_id TEXT`, + `UPDATE jobs SET owner_profile_id = '__default__' WHERE owner_profile_id IS NULL OR trim(owner_profile_id) = ''`, + `PRAGMA foreign_keys = OFF`, + `DROP TABLE IF EXISTS jobs_reowner_v1`, + `CREATE TABLE jobs_reowner_v1 ( + id TEXT PRIMARY KEY, + owner_profile_id TEXT NOT NULL DEFAULT '__default__', + source TEXT NOT NULL DEFAULT 'gradcracker', + source_job_id TEXT, + job_url_direct TEXT, + date_posted TEXT, + job_type TEXT, + salary_source TEXT, + salary_interval TEXT, + salary_min_amount REAL, + salary_max_amount REAL, + salary_currency TEXT, + is_remote INTEGER, + job_level TEXT, + job_function TEXT, + listing_type TEXT, + emails TEXT, + company_industry TEXT, + company_logo TEXT, + company_url_direct TEXT, + company_addresses TEXT, + company_num_employees TEXT, + company_revenue TEXT, + company_description TEXT, + skills TEXT, + experience_range TEXT, + company_rating REAL, + company_reviews_count INTEGER, + vacancy_count INTEGER, + work_from_home_type TEXT, + title TEXT NOT NULL, + employer TEXT NOT NULL, + employer_url TEXT, + job_url TEXT NOT NULL, + application_link TEXT, + disciplines TEXT, + deadline TEXT, + salary TEXT, + location TEXT, + degree_required TEXT, + starting TEXT, + job_description TEXT, + status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'in_progress', 'skipped', 'expired')), + outcome TEXT, + closed_at INTEGER, + suitability_score REAL, + suitability_reason TEXT, + suitability_analysis TEXT, + cover_letter TEXT, + tailored_summary TEXT, + tailored_headline TEXT, + tailored_skills TEXT, + selected_project_ids TEXT, + pdf_path TEXT, + tracer_links_enabled INTEGER NOT NULL DEFAULT 0, + sponsor_match_score REAL, + sponsor_match_names TEXT, + notes TEXT, + discovered_at TEXT NOT NULL DEFAULT (datetime('now')), + processed_at TEXT, + applied_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(owner_profile_id, job_url) + )`, + `INSERT OR REPLACE INTO jobs_reowner_v1 ( + id, owner_profile_id, source, source_job_id, job_url_direct, date_posted, job_type, salary_source, salary_interval, + salary_min_amount, salary_max_amount, salary_currency, is_remote, job_level, job_function, listing_type, + emails, company_industry, company_logo, company_url_direct, company_addresses, company_num_employees, + company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count, + vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines, + deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at, + suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills, + selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, notes, discovered_at, processed_at, + applied_at, created_at, updated_at + ) + SELECT + id, + coalesce(nullif(trim(owner_profile_id), ''), '__default__'), + source, source_job_id, job_url_direct, date_posted, job_type, salary_source, salary_interval, + salary_min_amount, salary_max_amount, salary_currency, is_remote, job_level, job_function, listing_type, + emails, company_industry, company_logo, company_url_direct, company_addresses, company_num_employees, + company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count, + vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines, + deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at, + suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills, + selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, notes, discovered_at, processed_at, + applied_at, created_at, updated_at + FROM jobs`, + `DROP TABLE jobs`, + `ALTER TABLE jobs_reowner_v1 RENAME TO jobs`, + `PRAGMA foreign_keys = ON`, + `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_jobs_status_discovered_at ON jobs(status, discovered_at)`, + `CREATE INDEX IF NOT EXISTS idx_jobs_owner_profile_id ON jobs(owner_profile_id)`, + + // Seed default job-search personas (INSERT OR IGNORE — safe on existing DBs). + sqlInsertSearchProfileSeed({ + id: "685b0000-0000-4000-8000-000000000001", + name: "Ilia Dobkin", + profile: { + targetRoles: [ + "Software Development Engineer in Test", + "QA Automation Engineer", + "SDET", + ], + experienceLevel: "Senior", + mustHaveSkills: [ + "Playwright", + "Cypress", + "Selenium", + "TypeScript", + "API testing", + ], + niceToHaveSkills: ["Python", ".NET", "CI/CD"], + dealBreakers: [], + preferredWorkArrangement: ["remote", "hybrid"], + preferredLocations: ["Toronto", "Ontario", "Canada"], + minimumSalary: "", + industriesToTarget: ["Software", "FinTech", "iGaming"], + industriesToAvoid: [], + aboutMe: + "Ilia Dobkin — SDET / test automation; paired login user `ilia` activates this profile and local resume ilia-dobkin.json (unset JOBOPS_LOCAL_RESUME_PATH so Settings path applies).", + basicAuthUser: "ilia", + resumeLocalPath: "../data/resumes/ilia-dobkin.json", + }, + }), + sqlInsertSearchProfileSeed({ + id: "685b0000-0000-4000-8000-000000000002", + name: "Iliya Cherepaha", + profile: { + targetRoles: [ + "Senior QA Analyst", + "QA Analyst", + "Guidewire Tester", + "Test Analyst", + "Manual QA Tester", + "UAT Tester", + "Quality Assurance Specialist", + "Accessibility Tester", + "Integration Tester", + ], + experienceLevel: "Senior", + mustHaveSkills: [ + "Guidewire", + "API testing", + "Selenium", + "SQL", + "Accessibility", + "UAT", + ], + niceToHaveSkills: ["JMeter", "Azure DevOps", "Postman"], + dealBreakers: [], + preferredWorkArrangement: ["remote", "hybrid", "onsite"], + preferredLocations: ["Toronto", "Ontario", "GTA", "Canada"], + minimumSalary: "", + industriesToTarget: [ + "Insurance", + "Banking", + "Public sector", + "Enterprise software", + ], + industriesToAvoid: [], + aboutMe: + "Iliya Cherepaha — Senior QA (Guidewire, OPS, accessibility); paired login user `cherepaha` activates this profile and local resume cherepaha.json (unset JOBOPS_LOCAL_RESUME_PATH so Settings path applies).", + basicAuthUser: "cherepaha", + resumeLocalPath: "../data/resumes/cherepaha.json", + }, + }), ]; console.log("🔧 Running database migrations..."); diff --git a/orchestrator/src/server/db/schema.ts b/orchestrator/src/server/db/schema.ts index 693944d..adbc35d 100644 --- a/orchestrator/src/server/db/schema.ts +++ b/orchestrator/src/server/db/schema.ts @@ -28,92 +28,106 @@ import { uniqueIndex, } from "drizzle-orm/sqlite-core"; -export const jobs = sqliteTable("jobs", { - id: text("id").primaryKey(), +export const jobs = sqliteTable( + "jobs", + { + id: text("id").primaryKey(), - // From crawler - source: text("source").notNull().default("gradcracker"), - sourceJobId: text("source_job_id"), - jobUrlDirect: text("job_url_direct"), - datePosted: text("date_posted"), - title: text("title").notNull(), - employer: text("employer").notNull(), - employerUrl: text("employer_url"), - jobUrl: text("job_url").notNull().unique(), - applicationLink: text("application_link"), - disciplines: text("disciplines"), - deadline: text("deadline"), - salary: text("salary"), - location: text("location"), - degreeRequired: text("degree_required"), - starting: text("starting"), - jobDescription: text("job_description"), + /** Search profile that owns this row (multi-tenant). */ + ownerProfileId: text("owner_profile_id").notNull().default("__default__"), - // JobSpy fields (nullable for other sources) - jobType: text("job_type"), - salarySource: text("salary_source"), - salaryInterval: text("salary_interval"), - salaryMinAmount: real("salary_min_amount"), - salaryMaxAmount: real("salary_max_amount"), - salaryCurrency: text("salary_currency"), - isRemote: integer("is_remote", { mode: "boolean" }), - jobLevel: text("job_level"), - jobFunction: text("job_function"), - listingType: text("listing_type"), - emails: text("emails"), - companyIndustry: text("company_industry"), - companyLogo: text("company_logo"), - companyUrlDirect: text("company_url_direct"), - companyAddresses: text("company_addresses"), - companyNumEmployees: text("company_num_employees"), - companyRevenue: text("company_revenue"), - companyDescription: text("company_description"), - skills: text("skills"), - experienceRange: text("experience_range"), - companyRating: real("company_rating"), - companyReviewsCount: integer("company_reviews_count"), - vacancyCount: integer("vacancy_count"), - workFromHomeType: text("work_from_home_type"), + // From crawler + source: text("source").notNull().default("gradcracker"), + sourceJobId: text("source_job_id"), + jobUrlDirect: text("job_url_direct"), + datePosted: text("date_posted"), + title: text("title").notNull(), + employer: text("employer").notNull(), + employerUrl: text("employer_url"), + jobUrl: text("job_url").notNull(), + applicationLink: text("application_link"), + disciplines: text("disciplines"), + deadline: text("deadline"), + salary: text("salary"), + location: text("location"), + degreeRequired: text("degree_required"), + starting: text("starting"), + jobDescription: text("job_description"), - // Orchestrator enrichments - status: text("status", { - enum: [ - "discovered", - "processing", - "ready", - "applied", - "in_progress", - "skipped", - "expired", - ], - }) - .notNull() - .default("discovered"), - outcome: text("outcome", { enum: APPLICATION_OUTCOMES }), - closedAt: integer("closed_at", { mode: "number" }), - suitabilityScore: real("suitability_score"), - suitabilityReason: text("suitability_reason"), - suitabilityAnalysis: text("suitability_analysis"), - tailoredSummary: text("tailored_summary"), - tailoredHeadline: text("tailored_headline"), - tailoredSkills: text("tailored_skills"), - selectedProjectIds: text("selected_project_ids"), - pdfPath: text("pdf_path"), - tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" }) - .notNull() - .default(false), - coverLetter: text("cover_letter"), - sponsorMatchScore: real("sponsor_match_score"), - sponsorMatchNames: text("sponsor_match_names"), - notes: text("notes"), + // JobSpy fields (nullable for other sources) + jobType: text("job_type"), + salarySource: text("salary_source"), + salaryInterval: text("salary_interval"), + salaryMinAmount: real("salary_min_amount"), + salaryMaxAmount: real("salary_max_amount"), + salaryCurrency: text("salary_currency"), + isRemote: integer("is_remote", { mode: "boolean" }), + jobLevel: text("job_level"), + jobFunction: text("job_function"), + listingType: text("listing_type"), + emails: text("emails"), + companyIndustry: text("company_industry"), + companyLogo: text("company_logo"), + companyUrlDirect: text("company_url_direct"), + companyAddresses: text("company_addresses"), + companyNumEmployees: text("company_num_employees"), + companyRevenue: text("company_revenue"), + companyDescription: text("company_description"), + skills: text("skills"), + experienceRange: text("experience_range"), + companyRating: real("company_rating"), + companyReviewsCount: integer("company_reviews_count"), + vacancyCount: integer("vacancy_count"), + workFromHomeType: text("work_from_home_type"), - // Timestamps - discoveredAt: text("discovered_at").notNull().default(sql`(datetime('now'))`), - processedAt: text("processed_at"), - appliedAt: text("applied_at"), - createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), - updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), -}); + // Orchestrator enrichments + status: text("status", { + enum: [ + "discovered", + "processing", + "ready", + "applied", + "in_progress", + "skipped", + "expired", + ], + }) + .notNull() + .default("discovered"), + outcome: text("outcome", { enum: APPLICATION_OUTCOMES }), + closedAt: integer("closed_at", { mode: "number" }), + suitabilityScore: real("suitability_score"), + suitabilityReason: text("suitability_reason"), + suitabilityAnalysis: text("suitability_analysis"), + tailoredSummary: text("tailored_summary"), + tailoredHeadline: text("tailored_headline"), + tailoredSkills: text("tailored_skills"), + selectedProjectIds: text("selected_project_ids"), + pdfPath: text("pdf_path"), + tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" }) + .notNull() + .default(false), + coverLetter: text("cover_letter"), + sponsorMatchScore: real("sponsor_match_score"), + sponsorMatchNames: text("sponsor_match_names"), + notes: text("notes"), + + // Timestamps + discoveredAt: text("discovered_at") + .notNull() + .default(sql`(datetime('now'))`), + processedAt: text("processed_at"), + appliedAt: text("applied_at"), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), + }, + (table) => ({ + ownerJobUrlUnique: uniqueIndex("idx_jobs_owner_profile_job_url").on( + table.ownerProfileId, + table.jobUrl, + ), + }), +); export const stageEvents = sqliteTable("stage_events", { id: text("id").primaryKey(), diff --git a/orchestrator/src/server/infra/basic-auth-credentials.ts b/orchestrator/src/server/infra/basic-auth-credentials.ts new file mode 100644 index 0000000..934631f --- /dev/null +++ b/orchestrator/src/server/infra/basic-auth-credentials.ts @@ -0,0 +1,56 @@ +/** + * Basic Auth credential pairs from environment (primary + optional second user). + */ + +export type BasicAuthCredentialPair = { user: string; pass: string }; + +export function getBasicAuthCredentialPairs(): BasicAuthCredentialPair[] { + const pairs: BasicAuthCredentialPair[] = []; + const u1 = process.env.BASIC_AUTH_USER?.trim() ?? ""; + const p1 = process.env.BASIC_AUTH_PASSWORD?.trim() ?? ""; + if (u1 && p1) pairs.push({ user: u1, pass: p1 }); + const u2 = process.env.BASIC_AUTH_USER_2?.trim() ?? ""; + const p2 = process.env.BASIC_AUTH_PASSWORD_2?.trim() ?? ""; + if (u2 && p2) pairs.push({ user: u2, pass: p2 }); + return pairs; +} + +export function isBasicAuthEnabled(): boolean { + return getBasicAuthCredentialPairs().length > 0; +} + +export function basicAuthMatchesDecodedUserPass( + user: string, + pass: string, +): boolean { + return getBasicAuthCredentialPairs().some( + (pair) => pair.user === user && pair.pass === pass, + ); +} + +/** Returns Basic `user:pass` when the header is valid Base64. */ +export function parseBasicAuthCredentials( + authorizationHeader: string | undefined, +): { user: string; pass: string } | null { + const authHeader = authorizationHeader ?? ""; + if (!authHeader.startsWith("Basic ")) return null; + const encoded = authHeader.slice("Basic ".length).trim(); + try { + const decoded = Buffer.from(encoded, "base64").toString("utf-8"); + const separatorIndex = decoded.indexOf(":"); + if (separatorIndex === -1) return null; + return { + user: decoded.slice(0, separatorIndex), + pass: decoded.slice(separatorIndex + 1), + }; + } catch { + return null; + } +} + +/** Returns the Basic username (without password) when the header is valid Base64 `user:pass`. */ +export function parseBasicAuthUsername( + authorizationHeader: string | undefined, +): string | null { + return parseBasicAuthCredentials(authorizationHeader)?.user ?? null; +} diff --git a/orchestrator/src/server/infra/job-owner-context.ts b/orchestrator/src/server/infra/job-owner-context.ts new file mode 100644 index 0000000..3a00069 --- /dev/null +++ b/orchestrator/src/server/infra/job-owner-context.ts @@ -0,0 +1,80 @@ +import { forbidden } from "@infra/errors"; +import { fail } from "@infra/http"; +import { logger } from "@infra/logger"; +import { getSearchProfileIdForBasicAuthUser } from "@server/repositories/profiles"; +import type { NextFunction, Request, RequestHandler, Response } from "express"; +import { + isBasicAuthEnabled, + parseBasicAuthUsername, +} from "./basic-auth-credentials"; +import { runWithRequestContext } from "./request-context"; + +export const DEFAULT_JOB_OWNER_PROFILE_ID = "__default__"; + +/** + * Resolves the search profile id for the current request (Basic Auth → profile.basicAuthUser). + * When Basic Auth is off, returns {@link DEFAULT_JOB_OWNER_PROFILE_ID}. + */ +export async function resolveRequestJobOwnerProfileId( + req: Request, +): Promise { + if (!isBasicAuthEnabled()) { + return DEFAULT_JOB_OWNER_PROFILE_ID; + } + + const username = parseBasicAuthUsername(req.headers.authorization); + if (!username?.trim()) { + return DEFAULT_JOB_OWNER_PROFILE_ID; + } + + const profileId = await getSearchProfileIdForBasicAuthUser(username); + if (!profileId) { + logger.warn("Basic Auth user has no matching search profile", { + username: username.trim(), + }); + return "__unmapped__"; + } + return profileId; +} + +function isAuthBootstrapPath(path: string): boolean { + const p = path.split("?")[0] || path; + return p === "/api/auth/verify" || p === "/api/auth/basic-status"; +} + +/** + * When Basic Auth is enabled, require a matching search profile for mutating API calls + * (handled in route handlers). This middleware only sets {@link DEFAULT_JOB_OWNER_PROFILE_ID} + * or the resolved profile id on the request context for downstream filtering. + */ +export function jobOwnerContextMiddleware(): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.path.startsWith("/api")) { + next(); + return; + } + + void (async () => { + try { + let ownerProfileId = await resolveRequestJobOwnerProfileId(req); + if ( + ownerProfileId === "__unmapped__" && + isAuthBootstrapPath(req.path) + ) { + ownerProfileId = DEFAULT_JOB_OWNER_PROFILE_ID; + } else if (ownerProfileId === "__unmapped__") { + fail( + res, + forbidden( + "No job search profile is linked to this login. Set basicAuthUser on a profile in Settings.", + ), + ); + return; + } + runWithRequestContext({ ownerProfileId }, () => next()); + } catch (error) { + next(error); + } + })(); + }; +} diff --git a/orchestrator/src/server/infra/request-context.ts b/orchestrator/src/server/infra/request-context.ts index afd71d1..e14b75f 100644 --- a/orchestrator/src/server/infra/request-context.ts +++ b/orchestrator/src/server/infra/request-context.ts @@ -4,6 +4,8 @@ export type RequestContext = { requestId: string; pipelineRunId?: string; jobId?: string; + /** Search profile id for job list / pipeline isolation (Basic Auth). */ + ownerProfileId?: string; }; const storage = new AsyncLocalStorage(); @@ -28,3 +30,7 @@ export function runWithRequestContext( export function getRequestId(): string | undefined { return storage.getStore()?.requestId; } + +export function getJobOwnerProfileId(): string | undefined { + return storage.getStore()?.ownerProfileId; +} diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index b1b111b..c95f11e 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -9,7 +9,11 @@ import { join } from "node:path"; import { logger } from "@infra/logger"; -import { runWithRequestContext } from "@infra/request-context"; +import { + getJobOwnerProfileId, + runWithRequestContext, +} from "@infra/request-context"; +import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context"; import type { PipelineConfig } from "@shared/types"; import { getDataDir } from "../config/dataDir"; import * as jobsRepo from "../repositories/jobs"; @@ -88,137 +92,154 @@ export async function runPipeline( activePipelineRunId = "pending"; cancelRequestedAt = null; resetProgress(); - const mergedConfig = { ...DEFAULT_CONFIG, ...config }; + const mergedConfig: PipelineConfig = { + ...DEFAULT_CONFIG, + ...config, + ownerProfileId: + config.ownerProfileId ?? + getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, + }; + const ownerProfileId = + mergedConfig.ownerProfileId ?? DEFAULT_JOB_OWNER_PROFILE_ID; const pipelineRun = await pipelineRepo.createPipelineRun(); activePipelineRunId = pipelineRun.id; - return runWithRequestContext({ pipelineRunId: pipelineRun.id }, async () => { - const pipelineLogger = logger.child({ pipelineRunId: pipelineRun.id }); - let jobsDiscovered = 0; - let jobsProcessed = 0; - pipelineLogger.info("Starting pipeline run", { - topN: mergedConfig.topN, - minSuitabilityScore: mergedConfig.minSuitabilityScore, - sources: mergedConfig.sources, - }); - - try { - ensureNotCancelled(); - const profile = await loadProfileStep(); - - ensureNotCancelled(); - const { discoveredJobs } = await discoverJobsStep({ - mergedConfig, - shouldCancel: () => cancelRequestedAt !== null, + return runWithRequestContext( + { + pipelineRunId: pipelineRun.id, + ownerProfileId, + }, + async () => { + const pipelineLogger = logger.child({ pipelineRunId: pipelineRun.id }); + let jobsDiscovered = 0; + let jobsProcessed = 0; + pipelineLogger.info("Starting pipeline run", { + topN: mergedConfig.topN, + minSuitabilityScore: mergedConfig.minSuitabilityScore, + sources: mergedConfig.sources, }); - ensureNotCancelled(); - const { created } = await importJobsStep({ discoveredJobs }); - jobsDiscovered = created; + try { + ensureNotCancelled(); + const profile = await loadProfileStep(); - await pipelineRepo.updatePipelineRun(pipelineRun.id, { - jobsDiscovered: created, - }); + ensureNotCancelled(); + const { discoveredJobs } = await discoverJobsStep({ + mergedConfig, + shouldCancel: () => cancelRequestedAt !== null, + }); - ensureNotCancelled(); - const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ - profile, - shouldCancel: () => cancelRequestedAt !== null, - }); + ensureNotCancelled(); + const { created } = await importJobsStep({ discoveredJobs }); + jobsDiscovered = created; - ensureNotCancelled(); - const jobsToProcess = selectJobsStep({ - scoredJobs, - mergedConfig, - }); - - pipelineLogger.info("Selected jobs for processing", { - candidates: jobsToProcess.length, - }); - - const { processedCount } = await processJobsStep({ - jobsToProcess, - processJob, - shouldCancel: () => cancelRequestedAt !== null, - }); - jobsProcessed = processedCount; - - await pipelineRepo.updatePipelineRun(pipelineRun.id, { - status: "completed", - completedAt: new Date().toISOString(), - jobsProcessed: processedCount, - }); - - progressHelpers.complete(created, processedCount); - pipelineLogger.info("Pipeline run completed", { - jobsDiscovered: created, - jobsProcessed: processedCount, - }); - - await notifyPipelineWebhookStep("pipeline.completed", { - pipelineRunId: pipelineRun.id, - jobsDiscovered: created, - jobsScored: unprocessedJobs.length, - jobsProcessed: processedCount, - }); - - return { - success: true, - jobsDiscovered: created, - jobsProcessed: processedCount, - }; - } catch (error) { - if (error instanceof PipelineCancelledError) { - const message = "Cancelled by user request"; await pipelineRepo.updatePipelineRun(pipelineRun.id, { - status: "cancelled", + jobsDiscovered: created, + }); + + ensureNotCancelled(); + const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ + profile, + ownerProfileId, + shouldCancel: () => cancelRequestedAt !== null, + }); + + ensureNotCancelled(); + const jobsToProcess = selectJobsStep({ + scoredJobs, + mergedConfig, + }); + + pipelineLogger.info("Selected jobs for processing", { + candidates: jobsToProcess.length, + }); + + const { processedCount } = await processJobsStep({ + jobsToProcess, + processJob, + shouldCancel: () => cancelRequestedAt !== null, + }); + jobsProcessed = processedCount; + + await pipelineRepo.updatePipelineRun(pipelineRun.id, { + status: "completed", + completedAt: new Date().toISOString(), + jobsProcessed: processedCount, + }); + + progressHelpers.complete(created, processedCount); + pipelineLogger.info("Pipeline run completed", { + jobsDiscovered: created, + jobsProcessed: processedCount, + }); + + await notifyPipelineWebhookStep("pipeline.completed", { + pipelineRunId: pipelineRun.id, + jobsDiscovered: created, + jobsScored: unprocessedJobs.length, + jobsProcessed: processedCount, + }); + + return { + success: true, + jobsDiscovered: created, + jobsProcessed: processedCount, + }; + } catch (error) { + if (error instanceof PipelineCancelledError) { + const message = "Cancelled by user request"; + await pipelineRepo.updatePipelineRun(pipelineRun.id, { + status: "cancelled", + completedAt: new Date().toISOString(), + jobsDiscovered, + jobsProcessed, + errorMessage: message, + }); + progressHelpers.cancelled(message); + pipelineLogger.info("Pipeline run cancelled", { + jobsDiscovered, + jobsProcessed, + }); + return { + success: false, + jobsDiscovered, + jobsProcessed, + error: message, + }; + } + + const message = + error instanceof Error ? error.message : "Unknown error"; + + await pipelineRepo.updatePipelineRun(pipelineRun.id, { + status: "failed", completedAt: new Date().toISOString(), - jobsDiscovered, - jobsProcessed, errorMessage: message, }); - progressHelpers.cancelled(message); - pipelineLogger.info("Pipeline run cancelled", { - jobsDiscovered, - jobsProcessed, + + progressHelpers.failed(message); + pipelineLogger.error("Pipeline run failed", error); + + await notifyPipelineWebhookStep("pipeline.failed", { + pipelineRunId: pipelineRun.id, + error: message, }); + return { success: false, jobsDiscovered, jobsProcessed, error: message, }; + } finally { + isPipelineRunning = false; + activePipelineRunId = null; + cancelRequestedAt = null; } - - const message = error instanceof Error ? error.message : "Unknown error"; - - await pipelineRepo.updatePipelineRun(pipelineRun.id, { - status: "failed", - completedAt: new Date().toISOString(), - errorMessage: message, - }); - - progressHelpers.failed(message); - pipelineLogger.error("Pipeline run failed", error); - - await notifyPipelineWebhookStep("pipeline.failed", { - pipelineRunId: pipelineRun.id, - error: message, - }); - - return { - success: false, - jobsDiscovered, - jobsProcessed, - error: message, - }; - } finally { - isPipelineRunning = false; - activePipelineRunId = null; - cancelRequestedAt = null; - } - }); + }, + ); } export type ProcessJobOptions = { @@ -240,7 +261,10 @@ export async function summarizeJob( const jobLogger = logger.child({ jobId }); jobLogger.info("Summarizing job"); try { - const job = await jobsRepo.getJobById(jobId); + const job = await jobsRepo.getJobById( + jobId, + getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID, + ); if (!job) return { success: false, error: "Job not found" }; const profile = await getProfile(); @@ -303,12 +327,16 @@ export async function summarizeJob( } } - await jobsRepo.updateJob(job.id, { - tailoredSummary: tailoredSummary ?? undefined, - tailoredHeadline: tailoredHeadline ?? undefined, - tailoredSkills: tailoredSkills ?? undefined, - selectedProjectIds: selectedProjectIds ?? undefined, - }); + await jobsRepo.updateJob( + job.id, + { + tailoredSummary: tailoredSummary ?? undefined, + tailoredHeadline: tailoredHeadline ?? undefined, + tailoredSkills: tailoredSkills ?? undefined, + selectedProjectIds: selectedProjectIds ?? undefined, + }, + job.ownerProfileId, + ); return { success: true }; } catch (error) { @@ -333,11 +361,18 @@ export async function generateFinalPdf( const jobLogger = logger.child({ jobId }); jobLogger.info("Generating final PDF"); try { - const job = await jobsRepo.getJobById(jobId); + const job = await jobsRepo.getJobById( + jobId, + getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID, + ); if (!job) return { success: false, error: "Job not found" }; // Mark as processing - await jobsRepo.updateJob(job.id, { status: "processing" }); + await jobsRepo.updateJob( + job.id, + { status: "processing" }, + job.ownerProfileId, + ); const pdfResult = await generatePdf( job.id, @@ -358,14 +393,22 @@ export async function generateFinalPdf( if (!pdfResult.success) { // Revert status if failed - await jobsRepo.updateJob(job.id, { status: "discovered" }); + await jobsRepo.updateJob( + job.id, + { status: "discovered" }, + job.ownerProfileId, + ); return { success: false, error: pdfResult.error }; } - await jobsRepo.updateJob(job.id, { - status: "ready", - pdfPath: pdfResult.pdfPath, - }); + await jobsRepo.updateJob( + job.id, + { + status: "ready", + pdfPath: pdfResult.pdfPath, + }, + job.ownerProfileId, + ); return { success: true }; } catch (error) { diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts index 9eb90e3..26d7a7c 100644 --- a/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts +++ b/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts @@ -11,6 +11,10 @@ vi.mock("@server/repositories/jobs", () => ({ getAllJobUrls: vi.fn().mockResolvedValue([]), })); +vi.mock("@server/repositories/profiles", () => ({ + getProfileById: vi.fn().mockResolvedValue(null), +})); + vi.mock("@server/extractors/registry", () => ({ getExtractorRegistry: vi.fn(), })); @@ -20,6 +24,7 @@ const baseConfig: PipelineConfig = { minSuitabilityScore: 50, sources: ["indeed", "linkedin", "ukvisajobs"], outputDir: "./tmp", + ownerProfileId: "__default__", enableCrawling: true, enableScoring: true, enableImporting: true, @@ -84,6 +89,7 @@ describe("discoverJobsStep", () => { const result = await discoverJobsStep({ mergedConfig: baseConfig }); expect(result.discoveredJobs).toHaveLength(1); + expect(result.discoveredJobs[0]?.ownerProfileId).toBe("__default__"); expect(result.sourceErrors).toEqual([ "UK Visa Jobs: login failed (sources: ukvisajobs)", ]); diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.ts index 11658d2..1df99c3 100644 --- a/orchestrator/src/server/pipeline/steps/discover-jobs.ts +++ b/orchestrator/src/server/pipeline/steps/discover-jobs.ts @@ -1,7 +1,9 @@ import { logger } from "@infra/logger"; import { sanitizeUnknown } from "@infra/sanitize"; import { getExtractorRegistry } from "@server/extractors/registry"; +import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context"; import { getAllJobUrls } from "@server/repositories/jobs"; +import { getProfileById } from "@server/repositories/profiles"; import * as settingsRepo from "@server/repositories/settings"; import { asyncPool } from "@server/utils/async-pool"; import { @@ -94,32 +96,47 @@ export async function discoverJobsStep(args: { .filter(Boolean); } - const profileSetting = settings.jobSearchProfile; - if (profileSetting) { - try { - const profile = JSON.parse(profileSetting); + const ownerProfileId = + args.mergedConfig.ownerProfileId ?? DEFAULT_JOB_OWNER_PROFILE_ID; + + const mergeTargetRoles = (targetRoles: unknown) => { + if (!Array.isArray(targetRoles) || targetRoles.length === 0) return; + const existingLower = new Set(searchTerms.map((t) => t.toLowerCase())); + for (const role of targetRoles) { if ( - Array.isArray(profile.targetRoles) && - profile.targetRoles.length > 0 + typeof role === "string" && + role.trim() && + !existingLower.has(role.trim().toLowerCase()) ) { - const existingLower = new Set(searchTerms.map((t) => t.toLowerCase())); - for (const role of profile.targetRoles) { - if ( - typeof role === "string" && - role.trim() && - !existingLower.has(role.trim().toLowerCase()) - ) { - searchTerms.push(role.trim()); - existingLower.add(role.trim().toLowerCase()); - } - } - logger.info("Augmented search terms with profile target roles", { - addedRoles: profile.targetRoles.length, - totalTerms: searchTerms.length, - }); + searchTerms.push(role.trim()); + existingLower.add(role.trim().toLowerCase()); + } + } + logger.info("Augmented search terms with profile target roles", { + addedRoles: targetRoles.length, + totalTerms: searchTerms.length, + }); + }; + + if (ownerProfileId && ownerProfileId !== DEFAULT_JOB_OWNER_PROFILE_ID) { + const row = await getProfileById(ownerProfileId); + if (row?.data?.targetRoles?.length) { + mergeTargetRoles(row.data.targetRoles); + } + } else { + const profileSetting = settings.jobSearchProfile; + if (profileSetting) { + try { + const profile = JSON.parse(profileSetting); + if ( + Array.isArray(profile.targetRoles) && + profile.targetRoles.length > 0 + ) { + mergeTargetRoles(profile.targetRoles); + } + } catch { + // malformed profile JSON, continue with existing terms } - } catch { - // malformed profile JSON, continue with existing terms } } @@ -161,7 +178,7 @@ export async function discoverJobsStep(args: { let existingJobUrlsPromise: Promise | null = null; const getExistingJobUrls = (): Promise => { if (!existingJobUrlsPromise) { - existingJobUrlsPromise = getAllJobUrls(); + existingJobUrlsPromise = getAllJobUrls(ownerProfileId); } return existingJobUrlsPromise; }; @@ -399,5 +416,10 @@ export async function discoverJobsStep(args: { progressHelpers.crawlingComplete(filteredDiscoveredJobs.length); - return { discoveredJobs: filteredDiscoveredJobs, sourceErrors }; + const stamped = filteredDiscoveredJobs.map((job) => ({ + ...job, + ownerProfileId, + })); + + return { discoveredJobs: stamped, sourceErrors }; } diff --git a/orchestrator/src/server/pipeline/steps/score-jobs.test.ts b/orchestrator/src/server/pipeline/steps/score-jobs.test.ts index dea6d36..e65c50e 100644 --- a/orchestrator/src/server/pipeline/steps/score-jobs.test.ts +++ b/orchestrator/src/server/pipeline/steps/score-jobs.test.ts @@ -75,7 +75,7 @@ describe("scoreJobsStep auto-skip behavior", () => { vi.mocked(settingsRepo.getSetting).mockResolvedValue("50"); - await scoreJobsStep({ profile: {} }); + await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" }); expect(jobsRepo.updateJob).toHaveBeenCalledWith( "job-1", @@ -107,7 +107,7 @@ describe("scoreJobsStep auto-skip behavior", () => { analysis: null, }); - await scoreJobsStep({ profile: {} }); + await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" }); expect(jobsRepo.updateJob).toHaveBeenCalledWith( "job-1", @@ -131,7 +131,7 @@ describe("scoreJobsStep auto-skip behavior", () => { vi.mocked(settingsRepo.getSetting).mockResolvedValue(null); - await scoreJobsStep({ profile: {} }); + await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" }); const updatePayload = vi.mocked(jobsRepo.updateJob).mock.calls[0][1] as { status?: string; @@ -145,7 +145,7 @@ describe("scoreJobsStep auto-skip behavior", () => { vi.mocked(settingsRepo.getSetting).mockResolvedValue("not-a-number"); - await scoreJobsStep({ profile: {} }); + await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" }); const updatePayload = vi.mocked(jobsRepo.updateJob).mock.calls[0][1] as { status?: string; @@ -170,7 +170,7 @@ describe("scoreJobsStep auto-skip behavior", () => { }), ]); - await scoreJobsStep({ profile: {} }); + await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" }); expect(jobsRepo.updateJob).toHaveBeenCalledWith( "job-applied", @@ -218,7 +218,10 @@ describe("scoreJobsStep auto-skip behavior", () => { analysis: null, }); - const result = await scoreJobsStep({ profile: {} }); + const result = await scoreJobsStep({ + profile: {}, + ownerProfileId: "__default__", + }); expect(result.scoredJobs).toHaveLength(2); expect(vi.mocked(jobsRepo.updateJob)).toHaveBeenCalledTimes(2); @@ -241,6 +244,7 @@ describe("scoreJobsStep auto-skip behavior", () => { const result = await scoreJobsStep({ profile: {}, + ownerProfileId: "__default__", shouldCancel: () => true, }); diff --git a/orchestrator/src/server/pipeline/steps/score-jobs.ts b/orchestrator/src/server/pipeline/steps/score-jobs.ts index 25b4829..ed81be0 100644 --- a/orchestrator/src/server/pipeline/steps/score-jobs.ts +++ b/orchestrator/src/server/pipeline/steps/score-jobs.ts @@ -12,10 +12,14 @@ const SCORING_CONCURRENCY = 4; export async function scoreJobsStep(args: { profile: Record; + ownerProfileId: string; shouldCancel?: () => boolean; }): Promise<{ unprocessedJobs: Job[]; scoredJobs: ScoredJob[] }> { logger.info("Running scoring step"); - const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs(); + const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs( + undefined, + args.ownerProfileId, + ); // Check if auto-skip threshold is configured const autoSkipThresholdRaw = await settingsRepo.getSetting( diff --git a/orchestrator/src/server/repositories/jobs.ts b/orchestrator/src/server/repositories/jobs.ts index 62bc9c0..bbcc402 100644 --- a/orchestrator/src/server/repositories/jobs.ts +++ b/orchestrator/src/server/repositories/jobs.ts @@ -3,6 +3,8 @@ */ import { randomUUID } from "node:crypto"; +import { getJobOwnerProfileId } from "@infra/request-context"; +import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context"; import { canonicalizeJobUrl } from "@shared/job-url-canonical"; import type { CreateJobInput, @@ -29,7 +31,13 @@ function sourceJobKey(source: string, sourceJobId: string): string { return `${source}\0${sourceJobId}`; } -async function loadJobDedupIndexes(): Promise<{ +function resolveOwnerForCreate(input: CreateJobInput): string { + const fromInput = input.ownerProfileId?.trim(); + if (fromInput) return fromInput; + return getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID; +} + +async function loadJobDedupIndexes(ownerProfileId: string): Promise<{ existingCanonicalSet: Set; existingSourceJobKeySet: Set; }> { @@ -39,7 +47,8 @@ async function loadJobDedupIndexes(): Promise<{ source: jobs.source, sourceJobId: jobs.sourceJobId, }) - .from(jobs); + .from(jobs) + .where(eq(jobs.ownerProfileId, ownerProfileId)); const existingCanonicalSet = new Set( rows.map((r) => canonicalizeJobUrl(r.jobUrl)), @@ -54,14 +63,22 @@ async function loadJobDedupIndexes(): Promise<{ return { existingCanonicalSet, existingSourceJobKeySet }; } -async function findJobByCanonicalUrl(canonical: string): Promise { +async function findJobByCanonicalUrl( + canonical: string, + ownerProfileId: string, +): Promise { const [exact] = await db .select() .from(jobs) - .where(eq(jobs.jobUrl, canonical)); + .where( + and(eq(jobs.ownerProfileId, ownerProfileId), eq(jobs.jobUrl, canonical)), + ); if (exact) return mapRowToJob(exact); - const allRows = await db.select().from(jobs); + const allRows = await db + .select() + .from(jobs) + .where(eq(jobs.ownerProfileId, ownerProfileId)); for (const row of allRows) { if (canonicalizeJobUrl(row.jobUrl) === canonical) { return mapRowToJob(row); @@ -73,11 +90,18 @@ async function findJobByCanonicalUrl(canonical: string): Promise { async function getJobBySourceAndExternalId( source: string, sourceJobId: string, + ownerProfileId: string, ): Promise { const [row] = await db .select() .from(jobs) - .where(and(eq(jobs.source, source), eq(jobs.sourceJobId, sourceJobId))); + .where( + and( + eq(jobs.ownerProfileId, ownerProfileId), + eq(jobs.source, source), + eq(jobs.sourceJobId, sourceJobId), + ), + ); return row ? mapRowToJob(row) : null; } @@ -89,15 +113,24 @@ function normalizeStatusFilter(statuses?: JobStatus[]): string | null { /** * Get all jobs, optionally filtered by status. */ -export async function getAllJobs(statuses?: JobStatus[]): Promise { +export async function getAllJobs( + statuses?: JobStatus[], + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, +): Promise { + const ownerClause = eq(jobs.ownerProfileId, ownerProfileId); const query = statuses && statuses.length > 0 ? db .select() .from(jobs) - .where(inArray(jobs.status, statuses)) + .where(and(ownerClause, inArray(jobs.status, statuses))) .orderBy(desc(jobs.discoveredAt)) - : db.select().from(jobs).orderBy(desc(jobs.discoveredAt)); + : db + .select() + .from(jobs) + .where(ownerClause) + .orderBy(desc(jobs.discoveredAt)); const rows = await query; return rows.map(mapRowToJob); @@ -108,7 +141,10 @@ export async function getAllJobs(statuses?: JobStatus[]): Promise { */ export async function getJobListItems( statuses?: JobStatus[], + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, ): Promise { + const ownerClause = eq(jobs.ownerProfileId, ownerProfileId); const selection = { id: jobs.id, source: jobs.source, @@ -141,9 +177,13 @@ export async function getJobListItems( ? db .select(selection) .from(jobs) - .where(inArray(jobs.status, statuses)) + .where(and(ownerClause, inArray(jobs.status, statuses))) .orderBy(desc(jobs.discoveredAt)) - : db.select(selection).from(jobs).orderBy(desc(jobs.discoveredAt)); + : db + .select(selection) + .from(jobs) + .where(ownerClause) + .orderBy(desc(jobs.discoveredAt)); const rows = await query; return rows.map((row) => ({ @@ -158,9 +198,12 @@ export async function getJobListItems( */ export async function getJobsRevision( statuses?: JobStatus[], + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, ): Promise { const statusFilter = normalizeStatusFilter(statuses); - const whereClause = + const ownerClause = eq(jobs.ownerProfileId, ownerProfileId); + const statusClause = statuses && statuses.length > 0 ? inArray(jobs.status, statuses) : undefined; @@ -171,9 +214,9 @@ export async function getJobsRevision( total: sql`count(*)`, }) .from(jobs); - const [row] = whereClause - ? await baseQuery.where(whereClause) - : await baseQuery; + const [row] = statusClause + ? await baseQuery.where(and(ownerClause, statusClause)) + : await baseQuery.where(ownerClause); const latestUpdatedAt = row?.latestUpdatedAt ?? null; const total = row?.total ?? 0; @@ -188,14 +231,25 @@ export async function getJobsRevision( } /** - * Get a single job by ID. + * Get a single job by ID (scoped to the owning search profile). */ -export async function getJobById(id: string): Promise { - const [row] = await db.select().from(jobs).where(eq(jobs.id, id)); +export async function getJobById( + id: string, + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, +): Promise { + const [row] = await db + .select() + .from(jobs) + .where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId))); return row ? mapRowToJob(row) : null; } -export async function listJobSummariesByIds(jobIds: string[]): Promise< +export async function listJobSummariesByIds( + jobIds: string[], + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, +): Promise< Array<{ id: string; title: string; @@ -211,22 +265,34 @@ export async function listJobSummariesByIds(jobIds: string[]): Promise< employer: jobs.employer, }) .from(jobs) - .where(inArray(jobs.id, jobIds)); + .where( + and(eq(jobs.ownerProfileId, ownerProfileId), inArray(jobs.id, jobIds)), + ); } /** * Get a job by its URL (for deduplication). * Matches canonical URL equivalence, including legacy rows stored with non-canonical URLs. */ -export async function getJobByUrl(jobUrl: string): Promise { - return findJobByCanonicalUrl(canonicalizeJobUrl(jobUrl)); +export async function getJobByUrl( + jobUrl: string, + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, +): Promise { + return findJobByCanonicalUrl(canonicalizeJobUrl(jobUrl), ownerProfileId); } /** * Get all known canonical job URLs (for deduplication / crawler skip lists). */ -export async function getAllJobUrls(): Promise { - const rows = await db.select({ jobUrl: jobs.jobUrl }).from(jobs); +export async function getAllJobUrls( + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, +): Promise { + const rows = await db + .select({ jobUrl: jobs.jobUrl }) + .from(jobs) + .where(eq(jobs.ownerProfileId, ownerProfileId)); const canonicals = rows.map((r) => canonicalizeJobUrl(r.jobUrl)); return Array.from(new Set(canonicals)); } @@ -235,8 +301,11 @@ async function insertJob(input: CreateJobInput): Promise { const id = randomUUID(); const now = new Date().toISOString(); + const ownerProfileId = resolveOwnerForCreate(input); + await db.insert(jobs).values({ id, + ownerProfileId, source: input.source, sourceJobId: input.sourceJobId ?? null, jobUrlDirect: input.jobUrlDirect ?? null, @@ -292,7 +361,12 @@ async function insertJob(input: CreateJobInput): Promise { function isJobUrlUniqueViolation(error: unknown): boolean { if (!(error instanceof Error)) return false; - return /UNIQUE constraint failed: jobs\.job_url/i.test(error.message); + return ( + /UNIQUE constraint failed: jobs\.job_url/i.test(error.message) || + /UNIQUE constraint failed.*idx_jobs_owner_profile_job_url/i.test( + error.message, + ) + ); } async function tryInsertJob(input: CreateJobInput): Promise { @@ -316,8 +390,13 @@ export async function createJobs( ): Promise { if (!Array.isArray(inputOrInputs)) { const normalized = normalizeCreateJobInputForDedup(inputOrInputs); + const ownerProfileId = resolveOwnerForCreate(normalized); + const normalizedWithOwner: CreateJobInput = { + ...normalized, + ownerProfileId, + }; const { existingCanonicalSet, existingSourceJobKeySet } = - await loadJobDedupIndexes(); + await loadJobDedupIndexes(ownerProfileId); const sid = normalized.sourceJobId?.trim(); if (sid) { @@ -326,29 +405,40 @@ export async function createJobs( const existing = await getJobBySourceAndExternalId( normalized.source, sid, + ownerProfileId, ); if (existing) return existing; } } if (existingCanonicalSet.has(normalized.jobUrl)) { - const existing = await findJobByCanonicalUrl(normalized.jobUrl); + const existing = await findJobByCanonicalUrl( + normalized.jobUrl, + ownerProfileId, + ); if (existing) return existing; } - const inserted = await tryInsertJob(normalized); + const inserted = await tryInsertJob(normalizedWithOwner); if (inserted) return inserted; const existingAfterConflict = - (await findJobByCanonicalUrl(normalized.jobUrl)) ?? - (sid ? await getJobBySourceAndExternalId(normalized.source, sid) : null); + (await findJobByCanonicalUrl(normalized.jobUrl, ownerProfileId)) ?? + (sid + ? await getJobBySourceAndExternalId( + normalized.source, + sid, + ownerProfileId, + ) + : null); if (existingAfterConflict) return existingAfterConflict; throw new Error("Failed to create or resolve existing job by URL"); } + const ownerProfileId = resolveOwnerForCreate(inputOrInputs[0] ?? {}); const { existingCanonicalSet, existingSourceJobKeySet } = - await loadJobDedupIndexes(); + await loadJobDedupIndexes(ownerProfileId); const batchBuckets = new Map< string, @@ -359,9 +449,13 @@ export async function createJobs( >(); for (const raw of inputOrInputs) { - const normalized = normalizeCreateJobInputForDedup(raw); - const batchKey = normalized.sourceJobId?.trim() - ? `sid:${sourceJobKey(normalized.source, normalized.sourceJobId!)}` + const normalized = normalizeCreateJobInputForDedup({ + ...raw, + ownerProfileId, + }); + const sidForKey = normalized.sourceJobId?.trim(); + const batchKey = sidForKey + ? `sid:${sourceJobKey(normalized.source, sidForKey)}` : `url:${normalized.jobUrl}`; const prev = batchBuckets.get(batchKey); if (prev) { @@ -418,6 +512,8 @@ export async function createJob(input: CreateJobInput): Promise { export async function updateJob( id: string, input: UpdateJobInput, + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, ): Promise { const now = new Date().toISOString(); @@ -431,21 +527,25 @@ export async function updateJob( ? { appliedAt: now } : {}), }) - .where(eq(jobs.id, id)); + .where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId))); - return getJobById(id); + return getJobById(id, ownerProfileId); } /** * Get job statistics by status. */ -export async function getJobStats(): Promise> { +export async function getJobStats( + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, +): Promise> { const result = await db .select({ status: jobs.status, count: sql`count(*)`, }) .from(jobs) + .where(eq(jobs.ownerProfileId, ownerProfileId)) .groupBy(jobs.status); const stats: Record = { @@ -468,12 +568,17 @@ export async function getJobStats(): Promise> { /** * Get jobs ready for processing (discovered with description). */ -export async function getJobsForProcessing(limit: number = 10): Promise { +export async function getJobsForProcessing( + limit: number = 10, + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, +): Promise { const rows = await db .select() .from(jobs) .where( and( + eq(jobs.ownerProfileId, ownerProfileId), eq(jobs.status, "discovered"), sql`${jobs.jobDescription} IS NOT NULL`, ), @@ -489,11 +594,19 @@ export async function getJobsForProcessing(limit: number = 10): Promise { */ export async function getUnscoredDiscoveredJobs( limit?: number, + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, ): Promise { const query = db .select() .from(jobs) - .where(and(eq(jobs.status, "discovered"), isNull(jobs.suitabilityScore))) + .where( + and( + eq(jobs.ownerProfileId, ownerProfileId), + eq(jobs.status, "discovered"), + isNull(jobs.suitabilityScore), + ), + ) .orderBy(desc(jobs.discoveredAt)); const rows = @@ -504,19 +617,33 @@ export async function getUnscoredDiscoveredJobs( /** * Delete jobs by status. */ -export async function deleteJobsByStatus(status: JobStatus): Promise { - const result = await db.delete(jobs).where(eq(jobs.status, status)).run(); +export async function deleteJobsByStatus( + status: JobStatus, + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, +): Promise { + const result = await db + .delete(jobs) + .where( + and(eq(jobs.ownerProfileId, ownerProfileId), eq(jobs.status, status)), + ) + .run(); return result.changes; } /** * Delete jobs with suitability score below threshold (excluding applied and in_progress jobs). */ -export async function deleteJobsBelowScore(threshold: number): Promise { +export async function deleteJobsBelowScore( + threshold: number, + ownerProfileId: string = getJobOwnerProfileId() ?? + DEFAULT_JOB_OWNER_PROFILE_ID, +): Promise { const result = await db .delete(jobs) .where( and( + eq(jobs.ownerProfileId, ownerProfileId), lt(jobs.suitabilityScore, threshold), ne(jobs.status, "applied"), ne(jobs.status, "in_progress"), @@ -530,6 +657,7 @@ export async function deleteJobsBelowScore(threshold: number): Promise { function mapRowToJob(row: typeof jobs.$inferSelect): Job { return { id: row.id, + ownerProfileId: row.ownerProfileId ?? DEFAULT_JOB_OWNER_PROFILE_ID, source: row.source as Job["source"], sourceJobId: row.sourceJobId ?? null, jobUrlDirect: row.jobUrlDirect ?? null, diff --git a/orchestrator/src/server/repositories/profiles.ts b/orchestrator/src/server/repositories/profiles.ts index 256ca58..f36c345 100644 --- a/orchestrator/src/server/repositories/profiles.ts +++ b/orchestrator/src/server/repositories/profiles.ts @@ -42,6 +42,18 @@ export async function listProfiles(): Promise { return rows.map(mapRow); } +/** When Basic Auth maps users to profiles, each user only sees rows with matching `basicAuthUser`. */ +export async function listProfilesForBasicAuthUser( + username: string, +): Promise { + const u = username.trim().toLowerCase(); + if (!u) return []; + const all = await listProfiles(); + return all.filter( + (p) => (p.data.basicAuthUser ?? "").trim().toLowerCase() === u, + ); +} + export async function getProfileById( id: string, ): Promise { @@ -87,6 +99,18 @@ export async function updateProfile( return getProfileById(id); } +export async function getSearchProfileIdForBasicAuthUser( + username: string, +): Promise { + const profiles = await listProfiles(); + const u = username.trim().toLowerCase(); + for (const p of profiles) { + const bu = (p.data.basicAuthUser ?? "").trim().toLowerCase(); + if (bu && bu === u) return p.id; + } + return null; +} + export async function deleteProfile(id: string): Promise { const result = await db .delete(searchProfiles) diff --git a/orchestrator/src/server/services/demo-simulator.ts b/orchestrator/src/server/services/demo-simulator.ts index 1783a54..dd43211 100644 --- a/orchestrator/src/server/services/demo-simulator.ts +++ b/orchestrator/src/server/services/demo-simulator.ts @@ -1,4 +1,6 @@ import { logger } from "@infra/logger"; +import { getJobOwnerProfileId } from "@infra/request-context"; +import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context"; import * as pipeline from "@server/pipeline/index"; import * as jobsRepo from "@server/repositories/jobs"; import * as pipelineRepo from "@server/repositories/pipeline"; @@ -42,7 +44,10 @@ function samplePdfPath(job: Job): string { } async function ensureJob(jobId: string): Promise { - const job = await jobsRepo.getJobById(jobId); + const job = await jobsRepo.getJobById( + jobId, + getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID, + ); if (!job) throw new Error("Job not found"); return job; } @@ -56,6 +61,7 @@ export async function simulatePipelineRun( const isoNow = now.toISOString(); const jobUrl = `https://demo.job-ops.local/jobs/${run.id}`; await jobsRepo.createJob({ + ownerProfileId: getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID, source: source as JobSource, title: "Demo Software Engineer", employer: "Demo Systems Ltd", @@ -89,16 +95,20 @@ export async function simulateSummarizeJob( _options?: ProcessOptions, ): Promise<{ success: boolean; error?: string }> { const job = await ensureJob(jobId); - await jobsRepo.updateJob(job.id, { - tailoredSummary: makeDemoSummary(job), - tailoredHeadline: `Demo Tailored Resume - ${job.title}`, - tailoredSkills: JSON.stringify([ - "TypeScript", - "System Design", - "Communication", - ]), - selectedProjectIds: ensureProjectIds(job), - }); + await jobsRepo.updateJob( + job.id, + { + tailoredSummary: makeDemoSummary(job), + tailoredHeadline: `Demo Tailored Resume - ${job.title}`, + tailoredSkills: JSON.stringify([ + "TypeScript", + "System Design", + "Communication", + ]), + selectedProjectIds: ensureProjectIds(job), + }, + job.ownerProfileId, + ); return { success: true }; } @@ -106,10 +116,14 @@ export async function simulateGeneratePdf( jobId: string, ): Promise<{ success: boolean; error?: string }> { const job = await ensureJob(jobId); - await jobsRepo.updateJob(job.id, { - status: "ready", - pdfPath: samplePdfPath(job), - }); + await jobsRepo.updateJob( + job.id, + { + status: "ready", + pdfPath: samplePdfPath(job), + }, + job.ownerProfileId, + ); return { success: true }; } @@ -125,10 +139,14 @@ export async function simulateProcessJob( export async function simulateRescoreJob(jobId: string): Promise { const job = await ensureJob(jobId); const score = scoreFromJob(job); - const updated = await jobsRepo.updateJob(job.id, { - suitabilityScore: score, - suitabilityReason: makeDemoReason(job, score), - }); + const updated = await jobsRepo.updateJob( + job.id, + { + suitabilityScore: score, + suitabilityReason: makeDemoReason(job, score), + }, + job.ownerProfileId, + ); if (!updated) throw new Error("Job not found"); return updated; } @@ -148,10 +166,14 @@ export async function simulateApplyJob(jobId: string): Promise { null, ); - const updated = await jobsRepo.updateJob(job.id, { - status: "applied", - appliedAt: appliedAtDate.toISOString(), - }); + const updated = await jobsRepo.updateJob( + job.id, + { + status: "applied", + appliedAt: appliedAtDate.toISOString(), + }, + job.ownerProfileId, + ); if (!updated) throw new Error("Job not found"); return updated; } diff --git a/orchestrator/src/server/services/envSettings.ts b/orchestrator/src/server/services/envSettings.ts index 53a1756..a003157 100644 --- a/orchestrator/src/server/services/envSettings.ts +++ b/orchestrator/src/server/services/envSettings.ts @@ -1,3 +1,4 @@ +import { isBasicAuthEnabled } from "@server/infra/basic-auth-credentials"; import type { SettingKey } from "@server/repositories/settings"; import * as settingsRepo from "@server/repositories/settings"; import { settingsRegistry } from "@shared/settings-registry"; @@ -79,12 +80,7 @@ export async function getEnvSettingsData( } } - const basicAuthUser = - activeOverrides.basicAuthUser ?? process.env.BASIC_AUTH_USER; - const basicAuthPassword = - activeOverrides.basicAuthPassword ?? process.env.BASIC_AUTH_PASSWORD; - - values.basicAuthActive = Boolean(basicAuthUser && basicAuthPassword); + values.basicAuthActive = isBasicAuthEnabled(); return values; } diff --git a/orchestrator/src/server/services/ghostwriter.test.ts b/orchestrator/src/server/services/ghostwriter.test.ts index dc7a71e..005a0fd 100644 --- a/orchestrator/src/server/services/ghostwriter.test.ts +++ b/orchestrator/src/server/services/ghostwriter.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getRequestId: vi.fn(), + getJobOwnerProfileId: vi.fn(), buildJobChatPromptContext: vi.fn(), llmCallJson: vi.fn(), repo: { @@ -40,6 +41,7 @@ vi.mock("@infra/logger", () => ({ vi.mock("@infra/request-context", () => ({ getRequestId: mocks.getRequestId, + getJobOwnerProfileId: mocks.getJobOwnerProfileId, })); vi.mock("./ghostwriter-context", () => ({ @@ -134,6 +136,7 @@ describe("ghostwriter service", () => { vi.clearAllMocks(); mocks.getRequestId.mockReturnValue("req-123"); + mocks.getJobOwnerProfileId.mockReturnValue(undefined); mocks.settings.getAllSettings.mockResolvedValue({}); mocks.buildJobChatPromptContext.mockResolvedValue({ job: { id: "job-1" }, diff --git a/orchestrator/src/server/services/profile.ts b/orchestrator/src/server/services/profile.ts index f6c637b..c760727 100644 --- a/orchestrator/src/server/services/profile.ts +++ b/orchestrator/src/server/services/profile.ts @@ -1,6 +1,9 @@ import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import { logger } from "@infra/logger"; +import { getJobOwnerProfileId } from "@infra/request-context"; +import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context"; +import { getProfileById } from "@server/repositories/profiles"; import { getSetting } from "@server/repositories/settings"; import type { ResumeProfile } from "@shared/types"; import { getResume, RxResumeAuthConfigError } from "./rxresume"; @@ -19,7 +22,28 @@ let cachedLocalProfile: ResumeProfile | null = null; */ export async function resolveLocalResumeFilePath(): Promise { const envPath = process.env.JOBOPS_LOCAL_RESUME_PATH?.trim(); - const raw = envPath ?? (await getSetting("localResumeProfilePath"))?.trim(); + if (envPath) { + return path.isAbsolute(envPath) + ? envPath + : path.resolve(process.cwd(), envPath); + } + + const ownerId = getJobOwnerProfileId(); + if ( + ownerId && + ownerId !== DEFAULT_JOB_OWNER_PROFILE_ID && + ownerId !== "__unmapped__" + ) { + const row = await getProfileById(ownerId); + const tenantPath = row?.data?.resumeLocalPath?.trim(); + if (tenantPath) { + return path.isAbsolute(tenantPath) + ? tenantPath + : path.resolve(process.cwd(), tenantPath); + } + } + + const raw = (await getSetting("localResumeProfilePath"))?.trim(); if (!raw) return null; return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw); } diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index a3125ad..cae4672 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -1,7 +1,11 @@ import { logger } from "@infra/logger"; +import { getJobOwnerProfileId } from "@infra/request-context"; +import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context"; +import * as profilesRepo from "@server/repositories/profiles"; import * as settingsRepo from "@server/repositories/settings"; import { getDefaultModelForProvider, + jobSearchProfileSchema, settingsRegistry, } from "@shared/settings-registry"; import type { AppSettings } from "@shared/types"; @@ -128,6 +132,21 @@ export async function getEffectiveSettings(): Promise { const envSettings = await getEnvSettingsData(overrides); + const tenantJobSearchProfile = await (async () => { + const ownerId = getJobOwnerProfileId(); + if ( + !ownerId || + ownerId === DEFAULT_JOB_OWNER_PROFILE_ID || + ownerId === "__unmapped__" + ) { + return null; + } + const row = await profilesRepo.getProfileById(ownerId); + if (!row?.data) return null; + const parsed = jobSearchProfileSchema.safeParse(row.data); + return parsed.success ? parsed.data : null; + })(); + const result: Partial = { ...envSettings, }; @@ -150,6 +169,10 @@ export async function getEffectiveSettings(): Promise { let override = def.parse(rawOverride); let defaultValue = def.default(); + if (key === "jobSearchProfile" && tenantJobSearchProfile) { + override = tenantJobSearchProfile; + } + if (key === "model") { defaultValue = resolvedModelDefault; override = overrideModel; diff --git a/scripts/jobber-cron.env.example b/scripts/jobber-cron.env.example index 2e59ed2..0edb074 100644 --- a/scripts/jobber-cron.env.example +++ b/scripts/jobber-cron.env.example @@ -13,6 +13,8 @@ JOBOPS_URL="http://127.0.0.1:3005" # Example (matches typical JobSpy bundle + UK sources): # JOBBER_PIPELINE_SOURCES=gradcracker,indeed,linkedin,glassdoor,ukvisajobs -# Optional — only if BASIC_AUTH_USER / BASIC_AUTH_PASSWORD are set in Jobber .env +# Optional — only if BASIC_AUTH_USER / BASIC_AUTH_PASSWORD are set in Jobber .env (use one pair; cron runs as a single identity) # BASIC_AUTH_USER="" # BASIC_AUTH_PASSWORD="" +# BASIC_AUTH_USER_2="" +# BASIC_AUTH_PASSWORD_2="" diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts index 3b9808c..05db688 100644 --- a/shared/src/settings-registry.ts +++ b/shared/src/settings-registry.ts @@ -143,6 +143,8 @@ export const jobSearchProfileSchema = z.object({ industriesToTarget: z.array(z.string().trim().min(1).max(200)).max(20), industriesToAvoid: z.array(z.string().trim().min(1).max(200)).max(20), aboutMe: z.string().trim().max(4000), + basicAuthUser: z.string().trim().max(100).nullable().optional(), + resumeLocalPath: z.string().trim().max(500).nullable().optional(), }); export const resumeProjectsSchema = z.object({ diff --git a/shared/src/testing/factories.ts b/shared/src/testing/factories.ts index 40855cd..fa85100 100644 --- a/shared/src/testing/factories.ts +++ b/shared/src/testing/factories.ts @@ -70,6 +70,7 @@ export const createJob = (overrides: Partial = {}): Job => ({ appliedAt: null, createdAt: "2025-01-01T00:00:00Z", updatedAt: "2025-01-01T00:00:00Z", + ownerProfileId: "__default__", ...overrides, }); diff --git a/shared/src/types/jobs.ts b/shared/src/types/jobs.ts index 1ac3b97..bf146e3 100644 --- a/shared/src/types/jobs.ts +++ b/shared/src/types/jobs.ts @@ -192,6 +192,9 @@ export interface Job { appliedAt: string | null; createdAt: string; updatedAt: string; + + /** Search profile id that owns this job (multi-tenant / Basic Auth). */ + ownerProfileId: string; } export type JobListItem = Pick< @@ -223,6 +226,8 @@ export type JobListItem = Pick< >; export interface CreateJobInput { + /** When omitted, server uses the current request / pipeline owner. */ + ownerProfileId?: string; source: JobSource; title: string; employer: string; diff --git a/shared/src/types/pipeline.ts b/shared/src/types/pipeline.ts index ed2ea61..490647e 100644 --- a/shared/src/types/pipeline.ts +++ b/shared/src/types/pipeline.ts @@ -6,6 +6,8 @@ export interface PipelineConfig { minSuitabilityScore: number; // Minimum score to auto-process sources: ExtractorSourceId[]; // Job sources to crawl outputDir: string; // Directory for generated PDFs + /** Search profile that owns discovered/processed jobs (Basic Auth multi-tenant). */ + ownerProfileId?: string; enableCrawling?: boolean; enableScoring?: boolean; enableImporting?: boolean; diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts index 0eaed4c..c1b00f3 100644 --- a/shared/src/types/settings.ts +++ b/shared/src/types/settings.ts @@ -10,6 +10,10 @@ export interface JobSearchProfile { industriesToTarget: string[]; industriesToAvoid: string[]; aboutMe: string; + /** When set, logging in with this Basic Auth username activates this profile and its resume path. */ + basicAuthUser?: string | null; + /** Relative (to orchestrator cwd) or absolute path to local Reactive Resume JSON; applied on profile activate. */ + resumeLocalPath?: string | null; } export interface SearchProfile {