Add Basic Auth gate, job owner context, and multi-tenant job isolation.

- Server: auth routes, owner profile on requests/jobs/pipeline, migrations
- Client: BasicAuthAppGate, SSE/API session handling, profile quick switch
- Tests: tracer-links, ghostwriter request-context mock, pipeline coverage
- Env examples for cron and optional basic auth credentials

Made-with: Cursor
This commit is contained in:
ilia 2026-04-20 21:34:42 -04:00
parent e39341258a
commit b72612fd06
56 changed files with 2259 additions and 501 deletions

View File

@ -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=

View File

@ -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", () => {
</MemoryRouter>,
);
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", () => {
</MemoryRouter>,
);
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();

View File

@ -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 (
<>
<OnboardingGate />
<BasicAuthPrompt />
{demoInfo?.demoMode && !demoWaitlistBannerDismissed && (
<div className="sticky top-0 z-50 w-full border-b border-orange-400/60 bg-orange-500 px-4 py-2 text-xs text-orange-950 shadow-sm">
<div className="mx-auto flex max-w-7xl items-center justify-center gap-3">
<p className="flex-1 text-center font-medium">
This is a read-only demo. Want JobOps without the Docker setup? {" "}
Cloud version coming soon join the waitlist at{" "}
<a
className="font-semibold underline underline-offset-2 hover:text-orange-900"
href="https://try.jobops.app?utm_source=demo&utm_medium=banner&utm_campaign=waitlist"
target="_blank"
rel="noreferrer"
<BasicAuthAppGate>
<OnboardingGate />
{demoInfo?.demoMode && !demoWaitlistBannerDismissed && (
<div className="sticky top-0 z-50 w-full border-b border-orange-400/60 bg-orange-500 px-4 py-2 text-xs text-orange-950 shadow-sm">
<div className="mx-auto flex max-w-7xl items-center justify-center gap-3">
<p className="flex-1 text-center font-medium">
This is a read-only demo. Want JobOps without the Docker setup?
Cloud version coming soon join the waitlist at{" "}
<a
className="font-semibold underline underline-offset-2 hover:text-orange-900"
href="https://try.jobops.app?utm_source=demo&utm_medium=banner&utm_campaign=waitlist"
target="_blank"
rel="noreferrer"
>
try.jobops.app
</a>
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 rounded-full text-orange-950 hover:bg-orange-400/30 hover:text-orange-950"
onClick={() => {
setDemoWaitlistBannerDismissed(true);
try {
localStorage.setItem(
DEMO_WAITLIST_BANNER_DISMISSED_KEY,
"1",
);
} catch {
// Ignore storage errors in restricted browser contexts.
}
}}
>
try.jobops.app
</a>
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 rounded-full text-orange-950 hover:bg-orange-400/30 hover:text-orange-950"
onClick={() => {
setDemoWaitlistBannerDismissed(true);
try {
localStorage.setItem(DEMO_WAITLIST_BANNER_DISMISSED_KEY, "1");
} catch {
// Ignore storage errors in restricted browser contexts.
}
}}
>
<X className="h-4 w-4" />
<span className="sr-only">Dismiss demo waitlist banner</span>
</Button>
</div>
</div>
)}
{demoInfo?.demoMode && (
<div className="w-full border-b border-amber-400/50 bg-amber-500/20 px-4 py-2 text-center text-xs text-amber-100 backdrop-blur">
Demo mode: integrations are simulated and data resets every{" "}
{demoInfo.resetCadenceHours} hours.
</div>
)}
<div>
<SwitchTransition mode="out-in">
<CSSTransition
key={pageKey}
nodeRef={nodeRef}
timeout={100}
classNames="page"
unmountOnExit
>
<div ref={nodeRef}>
<Routes location={location}>
{/* Backwards-compatibility redirects */}
{REDIRECTS.map(({ from, to }) => (
<Route
key={from}
path={from}
element={<Navigate to={to} replace />}
/>
))}
{/* Application routes */}
<Route path="/overview" element={<HomePage />} />
<Route
path="/oauth/gmail/callback"
element={<GmailOauthCallbackPage />}
/>
<Route path="/job/:id" element={<JobPage />} />
<Route
path="/applications/in-progress"
element={<InProgressBoardPage />}
/>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/tracer-links" element={<TracerLinksPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
<Route path="/tracking-inbox" element={<TrackingInboxPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route
path="/jobs/:tab/:jobId"
element={<OrchestratorPage />}
/>
</Routes>
<X className="h-4 w-4" />
<span className="sr-only">Dismiss demo waitlist banner</span>
</Button>
</div>
</CSSTransition>
</SwitchTransition>
</div>
</div>
)}
{demoInfo?.demoMode && (
<div className="w-full border-b border-amber-400/50 bg-amber-500/20 px-4 py-2 text-center text-xs text-amber-100 backdrop-blur">
Demo mode: integrations are simulated and data resets every{" "}
{demoInfo.resetCadenceHours} hours.
</div>
)}
<div>
<SwitchTransition mode="out-in">
<CSSTransition
key={pageKey}
nodeRef={nodeRef}
timeout={100}
classNames="page"
unmountOnExit
>
<div ref={nodeRef}>
<Routes location={location}>
{/* Backwards-compatibility redirects */}
{REDIRECTS.map(({ from, to }) => (
<Route
key={from}
path={from}
element={<Navigate to={to} replace />}
/>
))}
<Toaster position="bottom-right" richColors closeButton />
{/* Application routes */}
<Route path="/overview" element={<HomePage />} />
<Route
path="/oauth/gmail/callback"
element={<GmailOauthCallbackPage />}
/>
<Route path="/job/:id" element={<JobPage />} />
<Route
path="/applications/in-progress"
element={<InProgressBoardPage />}
/>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/tracer-links" element={<TracerLinksPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
<Route
path="/tracking-inbox"
element={<TrackingInboxPage />}
/>
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route
path="/jobs/:tab/:jobId"
element={<OrchestratorPage />}
/>
</Routes>
</div>
</CSSTransition>
</SwitchTransition>
</div>
<Toaster position="bottom-right" richColors closeButton />
</BasicAuthAppGate>
</>
);
};

View File

@ -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<T>(
@ -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<string, unknown>;
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<string, string> {
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<Omit<BasicAuthPromptRequest, "attempt">>,
): Promise<BasicAuthCredentials | null> {
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<BasicAuthBasicStatusResponse> {
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<BasicAuthBasicStatusResponse>(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<boolean> {
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<void> {
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<boolean> {
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<void> {
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<void> {
if (await tryBootstrapBasicAuthFromSessionOnly(preloaded)) return;
await bootstrapBasicAuthWithPromptOnly();
}
async function fetchAndParse<T>(
endpoint: string,
options: RequestInit | undefined,
@ -288,7 +503,7 @@ async function fetchApi<T>(
): Promise<T> {
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<T>(
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<TEvent>(
"Content-Type": "application/json",
};
if (cachedBasicAuthCredentials) {
headers.Authorization = encodeBasicAuth(cachedBasicAuthCredentials);
headers.Authorization = encodeBasicAuthHeaderValue(
cachedBasicAuthCredentials,
);
}
const response = await fetch(`${API_BASE}${endpoint}`, {

View File

@ -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<Phase>("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 (
<div className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-3 bg-background/95 backdrop-blur-sm">
<Loader2
className="h-8 w-8 animate-spin text-muted-foreground"
aria-hidden
/>
<p className="text-sm text-muted-foreground">
Checking authentication
</p>
</div>
);
}
if (phase === "prompt") {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-2 bg-background px-4 text-center text-sm text-muted-foreground">
<p className="font-medium text-foreground">Sign in</p>
<p>Use the dialog above to enter your username and password.</p>
<p className="max-w-sm text-xs text-muted-foreground">
After you sign in, open the menu () Account &quot;Sign out /
switch user&quot; to log in as someone else. Job lists are separate
per login.
</p>
</div>
);
}
return <>{children}</>;
};

View File

@ -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<ManualImportFlowProps> = ({
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"
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => {
setRawDescription(MANUAL_IMPORT_SAMPLE_SDET_JD);
setError(null);
}}
>
Sample JD SDET / automation
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => {
setRawDescription(MANUAL_IMPORT_SAMPLE_SENIOR_QA_JD);
setError(null);
}}
>
Sample JD Senior QA / Guidewire
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Synthetic postings for quick manual-import testing (Ilia vs
Cherepaha-style roles).
</p>
</div>
{error && (

View File

@ -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<PageHeaderProps> = ({
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<PageHeaderProps> = ({
</button>
))}
</nav>
{basicAuthSession.kind === "active" && (
<>
<Separator className="my-4" />
<div className="space-y-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
<p className="text-xs font-medium text-foreground">
Account
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Signed in as{" "}
<span className="font-mono text-foreground">
{basicAuthSession.username ?? "…"}
</span>
. Jobs and pipeline runs for this deployment are{" "}
<span className="text-foreground">
only visible to this login
</span>
sign out to use another user (e.g.{" "}
<span className="font-mono text-foreground">ilia</span> vs{" "}
<span className="font-mono text-foreground">
cherepaha
</span>
).
</p>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 w-full gap-2 text-xs"
onClick={() => {
setNavOpen(false);
signOutBasicAuthAndReload();
}}
>
<LogOut className="h-3.5 w-3.5 shrink-0" />
Sign out / switch user
</Button>
</div>
</>
)}
{showVersionFooter && (
<div className="mt-auto pt-6 pb-2">
<TooltipProvider>

View File

@ -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<BasicAuthNavSession>({ 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;
}

View File

@ -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.
`;

View File

@ -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,

View File

@ -1,13 +1,106 @@
import {
encodeBasicAuthHeaderValue,
getActiveBasicAuthCredentials,
} from "@client/api/client";
interface EventSourceSubscriptionHandlers<T> {
onOpen?: () => void;
onMessage: (payload: T) => void;
onError?: () => void;
}
function buildAuthHeaders(): Record<string, string> | 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<T>(
url: string,
handlers: EventSourceSubscriptionHandlers<T>,
authHeaders: Record<string, string>,
): () => 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<T>(
url: string,
handlers: EventSourceSubscriptionHandlers<T>,
): () => void {
const authHeaders = buildAuthHeaders();
if (authHeaders) {
return subscribeViaFetch(url, handlers, authHeaders);
}
const eventSource = new EventSource(url);
eventSource.onopen = () => {

View File

@ -178,6 +178,10 @@ vi.mock("./orchestrator/OrchestratorSummary", () => ({
OrchestratorSummary: () => <div data-testid="summary" />,
}));
vi.mock("./orchestrator/ProfileQuickSwitch", () => ({
ProfileQuickSwitch: () => null,
}));
vi.mock("./orchestrator/JobCommandBar", () => ({
JobCommandBar: ({
onSelectJob,

View File

@ -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}
/>
<ProfileQuickSwitch />
{/* Main content: tabs/filters -> list/detail */}
<section className="space-y-4">
<JobCommandBar
@ -524,6 +527,8 @@ export const OrchestratorPage: React.FC = () => {
isLoading={isLoading}
jobs={jobs}
activeJobs={activeJobs}
unfilteredTabCount={counts[activeTab]}
onResetFilters={resetFilters}
selectedJobId={selectedJobId}
selectedJobIds={selectedJobIds}
activeTab={activeTab}

View File

@ -457,4 +457,34 @@ describe("AutomaticRunTab", () => {
);
});
});
it("enables automatic run when search terms are empty but profile has target roles", () => {
const base = createAppSettings();
render(
<AutomaticRunTab
open
settings={createAppSettings({
searchTerms: { ...base.searchTerms, value: [], override: null },
jobSearchProfile: {
...base.jobSearchProfile,
value: {
...base.jobSearchProfile.value,
targetRoles: ["Platform engineer"],
},
},
})}
enabledSources={["linkedin"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(
screen.getByRole("button", { name: "Start run now" }),
).not.toBeDisabled();
expect(screen.getByText("Platform engineer")).toBeInTheDocument();
});
});

View File

@ -36,6 +36,7 @@ import {
type AutomaticRunValues,
calculateAutomaticEstimate,
loadAutomaticRunMemory,
mergeDiscoverySearchTerms,
normalizeWorkplaceTypes,
parseCityLocationsInput,
parseCityLocationsSetting,
@ -229,6 +230,21 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
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<AutomaticRunTabProps> = ({
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<AutomaticRunTabProps> = ({
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<AutomaticPresetSelection>(
@ -337,7 +362,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
isPipelineRunning ||
isSaving ||
compatiblePipelineSources.length === 0 ||
values.searchTerms.length === 0 ||
mergedDiscoveryTerms.length === 0 ||
workplaceTypeSelectionInvalid;
const toggleWorkplaceType = (

View File

@ -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(
<JobListPanel
isLoading={false}
jobs={jobs}
activeJobs={[]}
unfilteredTabCount={2}
onResetFilters={onReset}
selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready"
onSelectJob={vi.fn()}
onToggleSelectJob={vi.fn()}
onToggleSelectAll={vi.fn()}
/>,
);
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"

View File

@ -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<string>;
activeTab: FilterTab;
@ -23,6 +27,8 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
isLoading,
jobs,
activeJobs,
unfilteredTabCount,
onResetFilters,
selectedJobId,
selectedJobIds,
activeTab,
@ -37,11 +43,27 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
<div className="text-sm text-muted-foreground">Loading jobs...</div>
</div>
) : activeJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
<div className="text-base font-semibold">No jobs found</div>
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<div className="text-base font-semibold">
{unfilteredTabCount > 0
? "No jobs match your filters"
: "No jobs found"}
</div>
<p className="max-w-md text-sm text-muted-foreground">
{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]}
</p>
{unfilteredTabCount > 0 && onResetFilters ? (
<Button
type="button"
variant="secondary"
size="sm"
onClick={onResetFilters}
>
Clear filters
</Button>
) : null}
</div>
) : (
<div className="divide-y divide-border/40">

View File

@ -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<SearchProfile[]>({
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 (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
Profiles
</div>
);
}
if (profiles.length === 0) return null;
if (profiles.length === 1) {
const p = profiles[0] as SearchProfile;
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border/60 bg-muted/15 px-3 py-2 text-xs">
<span className="text-muted-foreground">Search profile</span>
<span className="font-medium text-foreground">{p.name}</span>
<Button variant="link" className="h-auto p-0 text-xs" asChild>
<a href="/settings">Edit in Settings</a>
</Button>
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border/60 bg-muted/15 px-3 py-2">
<span className="text-xs font-medium text-foreground">
Search profile
</span>
<Select
value={activeId}
onValueChange={(id) => {
if (!id || id === activeId) return;
activateMutation.mutate(id);
}}
disabled={activateMutation.isPending}
>
<SelectTrigger className="h-8 w-[min(100%,16rem)] text-xs">
<SelectValue placeholder="Choose profile" />
</SelectTrigger>
<SelectContent>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-xs">
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="link" className="h-auto p-0 text-xs" asChild>
<a href="/settings">Open Settings</a>
</Button>
</div>
);
};

View File

@ -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: {

View File

@ -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[] = [

View File

@ -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<RunMode>("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(

View File

@ -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();
});
});

View File

@ -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<UpdateSettingsInput>();
const { private: privateValues } = values;
const { private: privateValues, basicAuthActive } = values;
const isBasicAuthEnabled = watch("enableBasicAuth");
const handleSignOutBasicAuth = () => {
signOutBasicAuthAndReload();
};
return (
<AccordionItem value="environment" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -151,6 +157,24 @@ export const EnvironmentSettingsSection: React.FC<
/>
</div>
)}
{basicAuthActive && (
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3 space-y-2">
<p className="text-xs text-muted-foreground">
Credentials live in session storage. Sign out to switch login;
each user only sees jobs stored under their profile.
</p>
<Button
type="button"
variant="secondary"
size="sm"
disabled={isLoading || isSaving}
onClick={handleSignOutBasicAuth}
>
Sign out / switch user
</Button>
</div>
)}
</div>
</div>
</AccordionContent>

View File

@ -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);

View File

@ -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 });
}),
);

View File

@ -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) => {

View File

@ -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 () => {

View File

@ -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);
});
});

View File

@ -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 });
}),
);

View File

@ -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",
},
}));

View File

@ -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}`,

View File

@ -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);

View File

@ -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({

View File

@ -18,6 +18,17 @@ if (!existsSync(dataDir)) {
const sqlite = new Database(DB_PATH);
function sqlInsertSearchProfileSeed(input: {
id: string;
name: string;
profile: Record<string, unknown>;
}): 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...");

View File

@ -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(),

View File

@ -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;
}

View File

@ -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<string> {
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);
}
})();
};
}

View File

@ -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<RequestContext>();
@ -28,3 +30,7 @@ export function runWithRequestContext<T>(
export function getRequestId(): string | undefined {
return storage.getStore()?.requestId;
}
export function getJobOwnerProfileId(): string | undefined {
return storage.getStore()?.ownerProfileId;
}

View File

@ -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) {

View File

@ -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)",
]);

View File

@ -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<string[]> | null = null;
const getExistingJobUrls = (): Promise<string[]> => {
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 };
}

View File

@ -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,
});

View File

@ -12,10 +12,14 @@ const SCORING_CONCURRENCY = 4;
export async function scoreJobsStep(args: {
profile: Record<string, unknown>;
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(

View File

@ -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<string>;
existingSourceJobKeySet: Set<string>;
}> {
@ -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<Job | null> {
async function findJobByCanonicalUrl(
canonical: string,
ownerProfileId: string,
): Promise<Job | null> {
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<Job | null> {
async function getJobBySourceAndExternalId(
source: string,
sourceJobId: string,
ownerProfileId: string,
): Promise<Job | null> {
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<Job[]> {
export async function getAllJobs(
statuses?: JobStatus[],
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<Job[]> {
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<Job[]> {
*/
export async function getJobListItems(
statuses?: JobStatus[],
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<JobListItem[]> {
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<JobsRevisionResponse> {
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<number>`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<Job | null> {
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<Job | null> {
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<Job | null> {
return findJobByCanonicalUrl(canonicalizeJobUrl(jobUrl));
export async function getJobByUrl(
jobUrl: string,
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<Job | null> {
return findJobByCanonicalUrl(canonicalizeJobUrl(jobUrl), ownerProfileId);
}
/**
* Get all known canonical job URLs (for deduplication / crawler skip lists).
*/
export async function getAllJobUrls(): Promise<string[]> {
const rows = await db.select({ jobUrl: jobs.jobUrl }).from(jobs);
export async function getAllJobUrls(
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<string[]> {
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<Job> {
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<Job> {
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<Job | null> {
@ -316,8 +390,13 @@ export async function createJobs(
): Promise<Job | { created: number; skipped: number }> {
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<Job> {
export async function updateJob(
id: string,
input: UpdateJobInput,
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<Job | null> {
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<Record<JobStatus, number>> {
export async function getJobStats(
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<Record<JobStatus, number>> {
const result = await db
.select({
status: jobs.status,
count: sql<number>`count(*)`,
})
.from(jobs)
.where(eq(jobs.ownerProfileId, ownerProfileId))
.groupBy(jobs.status);
const stats: Record<JobStatus, number> = {
@ -468,12 +568,17 @@ export async function getJobStats(): Promise<Record<JobStatus, number>> {
/**
* Get jobs ready for processing (discovered with description).
*/
export async function getJobsForProcessing(limit: number = 10): Promise<Job[]> {
export async function getJobsForProcessing(
limit: number = 10,
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<Job[]> {
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<Job[]> {
*/
export async function getUnscoredDiscoveredJobs(
limit?: number,
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<Job[]> {
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<number> {
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<number> {
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<number> {
export async function deleteJobsBelowScore(
threshold: number,
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<number> {
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<number> {
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,

View File

@ -42,6 +42,18 @@ export async function listProfiles(): Promise<SearchProfile[]> {
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<SearchProfile[]> {
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<SearchProfile | null> {
@ -87,6 +99,18 @@ export async function updateProfile(
return getProfileById(id);
}
export async function getSearchProfileIdForBasicAuthUser(
username: string,
): Promise<string | null> {
const profiles = await listProfiles();
const u = username.trim().toLowerCase();
for (const p of profiles) {
const bu = (p.data.basicAuthUser ?? "").trim().toLowerCase();
if (bu && bu === u) return p.id;
}
return null;
}
export async function deleteProfile(id: string): Promise<boolean> {
const result = await db
.delete(searchProfiles)

View File

@ -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<Job> {
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<Job> {
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<Job> {
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;
}

View File

@ -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;
}

View File

@ -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" },

View File

@ -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<string | null> {
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);
}

View File

@ -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<AppSettings> {
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<AppSettings> = {
...envSettings,
};
@ -150,6 +169,10 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
let override = def.parse(rawOverride);
let defaultValue = def.default();
if (key === "jobSearchProfile" && tenantJobSearchProfile) {
override = tenantJobSearchProfile;
}
if (key === "model") {
defaultValue = resolvedModelDefault;
override = overrideModel;

View File

@ -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=""

View File

@ -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({

View File

@ -70,6 +70,7 @@ export const createJob = (overrides: Partial<Job> = {}): Job => ({
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
ownerProfileId: "__default__",
...overrides,
});

View File

@ -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;

View File

@ -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;

View File

@ -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 {