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). # 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. # 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). # 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 # JOBOPS_LOCAL_RESUME_PATH=../data/resumes/ilia-dobkin.json
# RXResume credentials for PDF generation # RXResume credentials for PDF generation
@ -32,6 +33,14 @@ RXRESUME_PASSWORD=your_password_here
# Optional: Basic Auth for write access # Optional: Basic Auth for write access
# the app is fully unauthenticated if this isn't set, which is the default # 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. # 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_USER=
BASIC_AUTH_PASSWORD= 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 type React from "react";
import { MemoryRouter } from "react-router-dom"; 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 { App } from "./App";
import { useDemoInfo } from "./hooks/useDemoInfo"; import { useDemoInfo } from "./hooks/useDemoInfo";
@ -58,13 +58,29 @@ vi.mock("./pages/VisaSponsorsPage", () => ({
VisaSponsorsPage: () => null, VisaSponsorsPage: () => null,
})); }));
const originalFetch = globalThis.fetch;
describe("App demo banner", () => { describe("App demo banner", () => {
beforeEach(() => { afterAll(() => {
vi.clearAllMocks(); globalThis.fetch = originalFetch;
localStorage.clear();
}); });
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({ vi.mocked(useDemoInfo).mockReturnValue({
demoMode: true, demoMode: true,
resetCadenceHours: 6, resetCadenceHours: 6,
@ -80,14 +96,14 @@ describe("App demo banner", () => {
</MemoryRouter>, </MemoryRouter>,
); );
const link = screen.getByRole("link", { name: "try.jobops.app" }); const link = await screen.findByRole("link", { name: "try.jobops.app" });
expect(link).toHaveAttribute( expect(link).toHaveAttribute(
"href", "href",
"https://try.jobops.app?utm_source=demo&utm_medium=banner&utm_campaign=waitlist", "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({ vi.mocked(useDemoInfo).mockReturnValue({
demoMode: false, demoMode: false,
resetCadenceHours: 6, resetCadenceHours: 6,
@ -103,10 +119,12 @@ describe("App demo banner", () => {
</MemoryRouter>, </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({ vi.mocked(useDemoInfo).mockReturnValue({
demoMode: true, demoMode: true,
resetCadenceHours: 6, resetCadenceHours: 6,
@ -123,7 +141,9 @@ describe("App demo banner", () => {
); );
fireEvent.click( 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(); 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 { Button } from "@/components/ui/button";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { BasicAuthAppGate } from "./components/BasicAuthAppGate";
import { BasicAuthPrompt } from "./components/BasicAuthPrompt"; import { BasicAuthPrompt } from "./components/BasicAuthPrompt";
import { OnboardingGate } from "./components/OnboardingGate"; import { OnboardingGate } from "./components/OnboardingGate";
import { useDemoInfo } from "./hooks/useDemoInfo"; import { useDemoInfo } from "./hooks/useDemoInfo";
@ -66,96 +67,104 @@ export const App: React.FC = () => {
return ( return (
<> <>
<OnboardingGate />
<BasicAuthPrompt /> <BasicAuthPrompt />
{demoInfo?.demoMode && !demoWaitlistBannerDismissed && ( <BasicAuthAppGate>
<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"> <OnboardingGate />
<div className="mx-auto flex max-w-7xl items-center justify-center gap-3"> {demoInfo?.demoMode && !demoWaitlistBannerDismissed && (
<p className="flex-1 text-center font-medium"> <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">
This is a read-only demo. Want JobOps without the Docker setup? {" "} <div className="mx-auto flex max-w-7xl items-center justify-center gap-3">
Cloud version coming soon join the waitlist at{" "} <p className="flex-1 text-center font-medium">
<a This is a read-only demo. Want JobOps without the Docker setup?
className="font-semibold underline underline-offset-2 hover:text-orange-900" Cloud version coming soon join the waitlist at{" "}
href="https://try.jobops.app?utm_source=demo&utm_medium=banner&utm_campaign=waitlist" <a
target="_blank" className="font-semibold underline underline-offset-2 hover:text-orange-900"
rel="noreferrer" 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 <X className="h-4 w-4" />
</a> <span className="sr-only">Dismiss demo waitlist banner</span>
</p> </Button>
<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>
</div> </div>
</CSSTransition> </div>
</SwitchTransition> )}
</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 { export function clearBasicAuthCredentials(): void {
cachedBasicAuthCredentials = null; 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 { export function __resetApiClientAuthForTests(): void {
basicAuthPromptHandler = null; basicAuthPromptHandler = null;
basicAuthPromptInFlight = null; basicAuthPromptInFlight = null;
cachedBasicAuthCredentials = null; cachedBasicAuthCredentials = null;
clearBasicAuthSessionStorage();
} }
function normalizeApiResponse<T>( function normalizeApiResponse<T>(
@ -172,10 +188,63 @@ function describeAction(endpoint: string, method?: string): string {
return "This action ran in demo simulation mode."; 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}`)}`; 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> { function normalizeHeaders(headers?: HeadersInit): Record<string, string> {
if (!headers) return {}; if (!headers) return {};
if (headers instanceof Headers) { if (headers instanceof Headers) {
@ -248,6 +317,152 @@ async function requestBasicAuthCredentials(
return basicAuthPromptInFlight; 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>( async function fetchAndParse<T>(
endpoint: string, endpoint: string,
options: RequestInit | undefined, options: RequestInit | undefined,
@ -288,7 +503,7 @@ async function fetchApi<T>(
): Promise<T> { ): Promise<T> {
const method = (options?.method || "GET").toUpperCase(); const method = (options?.method || "GET").toUpperCase();
let authHeader = cachedBasicAuthCredentials let authHeader = cachedBasicAuthCredentials
? encodeBasicAuth(cachedBasicAuthCredentials) ? encodeBasicAuthHeaderValue(cachedBasicAuthCredentials)
: undefined; : undefined;
let authAttempt = 0; let authAttempt = 0;
let usernameHint = cachedBasicAuthCredentials?.username; let usernameHint = cachedBasicAuthCredentials?.username;
@ -319,9 +534,9 @@ async function fetchApi<T>(
if (!credentials) { if (!credentials) {
throw toApiError(response, parsed); throw toApiError(response, parsed);
} }
cachedBasicAuthCredentials = credentials; setCachedBasicAuthCredentials(credentials);
usernameHint = credentials.username; usernameHint = credentials.username;
authHeader = encodeBasicAuth(credentials); authHeader = encodeBasicAuthHeaderValue(credentials);
authAttempt += 1; authAttempt += 1;
continue; continue;
} }
@ -481,7 +696,9 @@ async function streamSseEvents<TEvent>(
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
if (cachedBasicAuthCredentials) { if (cachedBasicAuthCredentials) {
headers.Authorization = encodeBasicAuth(cachedBasicAuthCredentials); headers.Authorization = encodeBasicAuthHeaderValue(
cachedBasicAuthCredentials,
);
} }
const response = await fetch(`${API_BASE}${endpoint}`, { 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 * 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 type { ManualJobDraft } from "@shared/types.js";
import { import {
ArrowLeft, 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..." 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" 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> </div>
{error && ( {error && (

View File

@ -2,13 +2,15 @@
* Shared layout components for consistent page structure. * 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 type React from "react";
import { useState } from "react"; import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -23,6 +25,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useBasicAuthNavSession } from "../hooks/useBasicAuthNavSession";
import { useVersionCheck } from "../hooks/useVersionCheck"; import { useVersionCheck } from "../hooks/useVersionCheck";
import { isNavActive, NAV_LINKS } from "./navigation"; import { isNavActive, NAV_LINKS } from "./navigation";
import { StatusBadgeIndicator } from "./StatusIndicator"; import { StatusBadgeIndicator } from "./StatusIndicator";
@ -60,6 +63,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
const navOpen = controlledNavOpen ?? internalNavOpen; const navOpen = controlledNavOpen ?? internalNavOpen;
const setNavOpen = onNavOpenChange ?? setInternalNavOpen; const setNavOpen = onNavOpenChange ?? setInternalNavOpen;
const { version, updateAvailable } = useVersionCheck(); const { version, updateAvailable } = useVersionCheck();
const basicAuthSession = useBasicAuthNavSession();
const handleNavClick = (to: string, activePaths?: string[]) => { const handleNavClick = (to: string, activePaths?: string[]) => {
if (isNavActive(location.pathname, to, activePaths)) { if (isNavActive(location.pathname, to, activePaths)) {
@ -103,6 +107,45 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
</button> </button>
))} ))}
</nav> </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 && ( {showVersionFooter && (
<div className="mt-auto pt-6 pb-2"> <div className="mt-auto pt-6 pb-2">
<TooltipProvider> <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"; import type { JobStatus, PostApplicationProvider } from "@shared/types";
export const queryKeys = { export const queryKeys = {
searchProfiles: {
all: ["search-profiles"] as const,
list: () => [...queryKeys.searchProfiles.all, "list"] as const,
},
settings: { settings: {
all: ["settings"] as const, all: ["settings"] as const,
current: () => [...queryKeys.settings.all, "current"] 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> { interface EventSourceSubscriptionHandlers<T> {
onOpen?: () => void; onOpen?: () => void;
onMessage: (payload: T) => void; onMessage: (payload: T) => void;
onError?: () => 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>( export function subscribeToEventSource<T>(
url: string, url: string,
handlers: EventSourceSubscriptionHandlers<T>, handlers: EventSourceSubscriptionHandlers<T>,
): () => void { ): () => void {
const authHeaders = buildAuthHeaders();
if (authHeaders) {
return subscribeViaFetch(url, handlers, authHeaders);
}
const eventSource = new EventSource(url); const eventSource = new EventSource(url);
eventSource.onopen = () => { eventSource.onopen = () => {

View File

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

View File

@ -15,6 +15,7 @@ import { JobListPanel } from "./orchestrator/JobListPanel";
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters"; import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader"; import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader";
import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary"; import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
import { ProfileQuickSwitch } from "./orchestrator/ProfileQuickSwitch";
import { RunModeModal } from "./orchestrator/RunModeModal"; import { RunModeModal } from "./orchestrator/RunModeModal";
import { useFilteredJobs } from "./orchestrator/useFilteredJobs"; import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useJobSelectionActions } from "./orchestrator/useJobSelectionActions"; import { useJobSelectionActions } from "./orchestrator/useJobSelectionActions";
@ -470,6 +471,8 @@ export const OrchestratorPage: React.FC = () => {
isPipelineRunning={isPipelineRunning} isPipelineRunning={isPipelineRunning}
/> />
<ProfileQuickSwitch />
{/* Main content: tabs/filters -> list/detail */} {/* Main content: tabs/filters -> list/detail */}
<section className="space-y-4"> <section className="space-y-4">
<JobCommandBar <JobCommandBar
@ -524,6 +527,8 @@ export const OrchestratorPage: React.FC = () => {
isLoading={isLoading} isLoading={isLoading}
jobs={jobs} jobs={jobs}
activeJobs={activeJobs} activeJobs={activeJobs}
unfilteredTabCount={counts[activeTab]}
onResetFilters={resetFilters}
selectedJobId={selectedJobId} selectedJobId={selectedJobId}
selectedJobIds={selectedJobIds} selectedJobIds={selectedJobIds}
activeTab={activeTab} 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, type AutomaticRunValues,
calculateAutomaticEstimate, calculateAutomaticEstimate,
loadAutomaticRunMemory, loadAutomaticRunMemory,
mergeDiscoverySearchTerms,
normalizeWorkplaceTypes, normalizeWorkplaceTypes,
parseCityLocationsInput, parseCityLocationsInput,
parseCityLocationsSetting, parseCityLocationsSetting,
@ -229,6 +230,21 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
settings?.workplaceTypes?.value, 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({ reset({
topN: String(topN), topN: String(topN),
minSuitabilityScore: String(minSuitabilityScore), minSuitabilityScore: String(minSuitabilityScore),
@ -237,7 +253,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
cityLocations: rememberedLocations, cityLocations: rememberedLocations,
cityLocationDraft: "", cityLocationDraft: "",
workplaceTypes: rememberedWorkplaceTypes, workplaceTypes: rememberedWorkplaceTypes,
searchTerms: settings?.searchTerms?.value ?? DEFAULT_VALUES.searchTerms, searchTerms: initialSearchTerms,
searchTermDraft: "", searchTermDraft: "",
}); });
setAdvancedOpen(false); setAdvancedOpen(false);
@ -319,13 +335,22 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
pipelineSources, pipelineSources,
]); ]);
const mergedDiscoveryTerms = useMemo(
() =>
mergeDiscoverySearchTerms(
values.searchTerms,
settings?.jobSearchProfile?.value?.targetRoles,
),
[values.searchTerms, settings?.jobSearchProfile?.value?.targetRoles],
);
const estimate = useMemo( const estimate = useMemo(
() => () =>
calculateAutomaticEstimate({ calculateAutomaticEstimate({
values, values: { ...values, searchTerms: mergedDiscoveryTerms },
sources: compatiblePipelineSources, sources: compatiblePipelineSources,
}), }),
[values, compatiblePipelineSources], [values, mergedDiscoveryTerms, compatiblePipelineSources],
); );
const activePreset = useMemo<AutomaticPresetSelection>( const activePreset = useMemo<AutomaticPresetSelection>(
@ -337,7 +362,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
isPipelineRunning || isPipelineRunning ||
isSaving || isSaving ||
compatiblePipelineSources.length === 0 || compatiblePipelineSources.length === 0 ||
values.searchTerms.length === 0 || mergedDiscoveryTerms.length === 0 ||
workplaceTypeSelectionInvalid; workplaceTypeSelectionInvalid;
const toggleWorkplaceType = ( const toggleWorkplaceType = (

View File

@ -10,6 +10,7 @@ describe("JobListPanel", () => {
isLoading isLoading
jobs={[]} jobs={[]}
activeJobs={[]} activeJobs={[]}
unfilteredTabCount={0}
selectedJobId={null} selectedJobId={null}
selectedJobIds={new Set()} selectedJobIds={new Set()}
activeTab="ready" activeTab="ready"
@ -28,6 +29,7 @@ describe("JobListPanel", () => {
isLoading={false} isLoading={false}
jobs={[]} jobs={[]}
activeJobs={[]} activeJobs={[]}
unfilteredTabCount={0}
selectedJobId={null} selectedJobId={null}
selectedJobIds={new Set()} selectedJobIds={new Set()}
activeTab="ready" activeTab="ready"
@ -43,6 +45,33 @@ describe("JobListPanel", () => {
).toBeInTheDocument(); ).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", () => { it("renders jobs and notifies when a job is selected", () => {
const onSelectJob = vi.fn(); const onSelectJob = vi.fn();
const onToggleSelectJob = vi.fn(); const onToggleSelectJob = vi.fn();
@ -61,6 +90,7 @@ describe("JobListPanel", () => {
isLoading={false} isLoading={false}
jobs={jobs} jobs={jobs}
activeJobs={jobs} activeJobs={jobs}
unfilteredTabCount={2}
selectedJobId="job-1" selectedJobId="job-1"
selectedJobIds={new Set()} selectedJobIds={new Set()}
activeTab="ready" activeTab="ready"
@ -91,6 +121,7 @@ describe("JobListPanel", () => {
isLoading={false} isLoading={false}
jobs={jobs} jobs={jobs}
activeJobs={jobs} activeJobs={jobs}
unfilteredTabCount={2}
selectedJobId="job-1" selectedJobId="job-1"
selectedJobIds={new Set(["job-1"])} selectedJobIds={new Set(["job-1"])}
activeTab="ready" activeTab="ready"
@ -114,6 +145,7 @@ describe("JobListPanel", () => {
isLoading={false} isLoading={false}
jobs={jobs} jobs={jobs}
activeJobs={jobs} activeJobs={jobs}
unfilteredTabCount={1}
selectedJobId={null} selectedJobId={null}
selectedJobIds={new Set()} selectedJobIds={new Set()}
activeTab="ready" activeTab="ready"
@ -132,6 +164,7 @@ describe("JobListPanel", () => {
isLoading={false} isLoading={false}
jobs={jobs} jobs={jobs}
activeJobs={jobs} activeJobs={jobs}
unfilteredTabCount={1}
selectedJobId="job-1" selectedJobId="job-1"
selectedJobIds={new Set()} selectedJobIds={new Set()}
activeTab="ready" activeTab="ready"
@ -150,6 +183,7 @@ describe("JobListPanel", () => {
isLoading={false} isLoading={false}
jobs={jobs} jobs={jobs}
activeJobs={jobs} activeJobs={jobs}
unfilteredTabCount={1}
selectedJobId={null} selectedJobId={null}
selectedJobIds={new Set(["job-1"])} selectedJobIds={new Set(["job-1"])}
activeTab="ready" activeTab="ready"

View File

@ -1,6 +1,7 @@
import type { JobListItem } from "@shared/types.js"; import type { JobListItem } from "@shared/types.js";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type React from "react"; import type React from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { FilterTab } from "./constants"; import type { FilterTab } from "./constants";
@ -11,6 +12,9 @@ interface JobListPanelProps {
isLoading: boolean; isLoading: boolean;
jobs: JobListItem[]; jobs: JobListItem[];
activeJobs: JobListItem[]; activeJobs: JobListItem[];
/** Jobs in this tab before list filters (search, sources, etc.). */
unfilteredTabCount: number;
onResetFilters?: () => void;
selectedJobId: string | null; selectedJobId: string | null;
selectedJobIds: Set<string>; selectedJobIds: Set<string>;
activeTab: FilterTab; activeTab: FilterTab;
@ -23,6 +27,8 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
isLoading, isLoading,
jobs, jobs,
activeJobs, activeJobs,
unfilteredTabCount,
onResetFilters,
selectedJobId, selectedJobId,
selectedJobIds, selectedJobIds,
activeTab, activeTab,
@ -37,11 +43,27 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
<div className="text-sm text-muted-foreground">Loading jobs...</div> <div className="text-sm text-muted-foreground">Loading jobs...</div>
</div> </div>
) : activeJobs.length === 0 ? ( ) : activeJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center"> <div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<div className="text-base font-semibold">No jobs found</div> <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"> <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> </p>
{unfilteredTabCount > 0 && onResetFilters ? (
<Button
type="button"
variant="secondary"
size="sm"
onClick={onResetFilters}
>
Clear filters
</Button>
) : null}
</div> </div>
) : ( ) : (
<div className="divide-y divide-border/40"> <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, AUTOMATIC_PRESETS,
calculateAutomaticEstimate, calculateAutomaticEstimate,
deriveExtractorLimits, deriveExtractorLimits,
mergeDiscoverySearchTerms,
parseSearchTermsInput, parseSearchTermsInput,
} from "./automatic-run"; } 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", () => { it("includes adzuna in estimate caps", () => {
const estimate = calculateAutomaticEstimate({ const estimate = calculateAutomaticEstimate({
values: { values: {

View File

@ -4,6 +4,29 @@ import {
} from "@shared/search-cities.js"; } from "@shared/search-cities.js";
import type { JobSource } from "@shared/types"; 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 AutomaticPresetId = "fast" | "balanced" | "detailed";
export type WorkplaceType = "remote" | "hybrid" | "onsite"; export type WorkplaceType = "remote" | "hybrid" | "onsite";
export const WORKPLACE_TYPE_OPTIONS: WorkplaceType[] = [ export const WORKPLACE_TYPE_OPTIONS: WorkplaceType[] = [

View File

@ -11,6 +11,7 @@ import { trackProductEvent } from "@/lib/analytics";
import type { AutomaticRunValues } from "./automatic-run"; import type { AutomaticRunValues } from "./automatic-run";
import { import {
deriveExtractorLimits, deriveExtractorLimits,
mergeDiscoverySearchTerms,
serializeCityLocationsSetting, serializeCityLocationsSetting,
} from "./automatic-run"; } from "./automatic-run";
import type { RunMode } from "./run-mode"; import type { RunMode } from "./run-mode";
@ -57,7 +58,7 @@ export function usePipelineControls(
const [runMode, setRunMode] = useState<RunMode>("automatic"); const [runMode, setRunMode] = useState<RunMode>("automatic");
const [isCancelling, setIsCancelling] = useState(false); const [isCancelling, setIsCancelling] = useState(false);
const { refreshSettings } = useSettings(); const { settings, refreshSettings } = useSettings();
useEffect(() => { useEffect(() => {
if (!pipelineTerminalEvent) return; if (!pipelineTerminalEvent) return;
@ -168,9 +169,20 @@ export function usePipelineControls(
return; 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({ const limits = deriveExtractorLimits({
budget: values.runBudget, budget: values.runBudget,
searchTerms: values.searchTerms, searchTerms: mergedSearchTerms,
sources: compatibleSources, sources: compatibleSources,
}); });
const hasJobSpySite = compatibleSources.some( const hasJobSpySite = compatibleSources.some(
@ -210,12 +222,12 @@ export function usePipelineControls(
mode: "automatic", mode: "automatic",
country: values.country, country: values.country,
hasCityLocations: values.cityLocations.length > 0, hasCityLocations: values.cityLocations.length > 0,
searchTermsCount: values.searchTerms.length, searchTermsCount: mergedSearchTerms.length,
}, },
}); });
setIsRunModeModalOpen(false); setIsRunModeModalOpen(false);
}, },
[pipelineSources, refreshSettings, startPipelineRun], [pipelineSources, refreshSettings, settings, startPipelineRun],
); );
const handleManualImported = useCallback( const handleManualImported = useCallback(

View File

@ -67,5 +67,11 @@ describe("EnvironmentSettingsSection", () => {
expect(screen.getByText("Service Accounts")).toBeInTheDocument(); expect(screen.getByText("Service Accounts")).toBeInTheDocument();
expect(screen.getByText("Security")).toBeInTheDocument(); expect(screen.getByText("Security")).toBeInTheDocument();
expect(screen.queryByText("RxResume")).not.toBeInTheDocument(); expect(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 { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { EnvSettingsValues } from "@client/pages/settings/types"; import type { EnvSettingsValues } from "@client/pages/settings/types";
import { formatSecretHint } from "@client/pages/settings/utils"; import { formatSecretHint } from "@client/pages/settings/utils";
@ -9,6 +10,7 @@ import {
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@ -27,10 +29,14 @@ export const EnvironmentSettingsSection: React.FC<
watch, watch,
formState: { errors }, formState: { errors },
} = useFormContext<UpdateSettingsInput>(); } = useFormContext<UpdateSettingsInput>();
const { private: privateValues } = values; const { private: privateValues, basicAuthActive } = values;
const isBasicAuthEnabled = watch("enableBasicAuth"); const isBasicAuthEnabled = watch("enableBasicAuth");
const handleSignOutBasicAuth = () => {
signOutBasicAuthAndReload();
};
return ( return (
<AccordionItem value="environment" className="border rounded-lg px-4"> <AccordionItem value="environment" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4"> <AccordionTrigger className="hover:no-underline py-4">
@ -151,6 +157,24 @@ export const EnvironmentSettingsSection: React.FC<
/> />
</div> </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>
</div> </div>
</AccordionContent> </AccordionContent>

View File

@ -3,6 +3,7 @@
*/ */
import { Router } from "express"; import { Router } from "express";
import { authRouter } from "./routes/auth";
import { backupRouter } from "./routes/backup"; import { backupRouter } from "./routes/backup";
import { databaseRouter } from "./routes/database"; import { databaseRouter } from "./routes/database";
import { demoRouter } from "./routes/demo"; import { demoRouter } from "./routes/demo";
@ -22,6 +23,7 @@ import { webhookRouter } from "./routes/webhook";
export const apiRouter = Router(); export const apiRouter = Router();
apiRouter.use("/auth", authRouter);
apiRouter.use("/jobs", jobsRouter); apiRouter.use("/jobs", jobsRouter);
apiRouter.use("/jobs/:id/chat", ghostwriterRouter); apiRouter.use("/jobs/:id/chat", ghostwriterRouter);
apiRouter.use("/demo", demoRouter); 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 { function isJobUrlConflictError(error: unknown): boolean {
if (!(error instanceof Error)) return false; let current: unknown = error;
return /UNIQUE constraint failed: jobs\.job_url/i.test(error.message); 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({ 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) { if (!updated) {
throw new AppError({ throw new AppError({
status: 404, status: 404,
@ -451,11 +474,15 @@ async function executeJobActionForJob(
profile, profile,
); );
const updated = await jobsRepo.updateJob(job.id, { const updated = await jobsRepo.updateJob(
suitabilityScore: score, job.id,
suitabilityReason: reason, {
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined, suitabilityScore: score,
}); suitabilityReason: reason,
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
},
job.ownerProfileId,
);
if (!updated) { if (!updated) {
throw new AppError({ throw new AppError({
status: 404, status: 404,
@ -503,7 +530,11 @@ async function executeJobActionForJob(
searchProfile, searchProfile,
); );
const updated = await jobsRepo.updateJob(job.id, { coverLetter }); const updated = await jobsRepo.updateJob(
job.id,
{ coverLetter },
job.ownerProfileId,
);
if (!updated) { if (!updated) {
throw new AppError({ throw new AppError({
status: 404, status: 404,
@ -1053,10 +1084,18 @@ jobsRouter.patch("/:id/outcome", async (req: Request, res: Response) => {
const closedAt = input.outcome const closedAt = input.outcome
? (input.closedAt ?? Math.floor(Date.now() / 1000)) ? (input.closedAt ?? Math.floor(Date.now() / 1000))
: null; : null;
const job = await jobsRepo.updateJob(req.params.id, { const existing = await jobsRepo.getJobById(req.params.id);
outcome: input.outcome, if (!existing) {
closedAt, return fail(res, notFound("Job not found"));
}); }
const job = await jobsRepo.updateJob(
req.params.id,
{
outcome: input.outcome,
closedAt,
},
existing.ownerProfileId,
);
if (!job) { if (!job) {
return fail(res, notFound("Job not found")); 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) { if (!job) {
const err = new AppError({ const err = new AppError({
@ -1235,10 +1278,14 @@ jobsRouter.post("/:id/check-sponsor", async (req: Request, res: Response) => {
visaSponsors.calculateSponsorMatchSummary(sponsorResults); visaSponsors.calculateSponsorMatchSummary(sponsorResults);
// Update job with sponsor match info // Update job with sponsor match info
const updatedJob = await jobsRepo.updateJob(job.id, { const updatedJob = await jobsRepo.updateJob(
sponsorMatchScore: sponsorMatchScore, job.id,
sponsorMatchNames: sponsorMatchNames ?? undefined, {
}); sponsorMatchScore: sponsorMatchScore,
sponsorMatchNames: sponsorMatchNames ?? undefined,
},
job.ownerProfileId,
);
res.json({ res.json({
success: true, success: true,
@ -1317,10 +1364,14 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
null, null,
); );
const updatedJob = await jobsRepo.updateJob(job.id, { const updatedJob = await jobsRepo.updateJob(
status: "applied", job.id,
appliedAt, {
}); status: "applied",
appliedAt,
},
job.ownerProfileId,
);
if (updatedJob) { if (updatedJob) {
notifyJobCompleteWebhook(updatedJob).catch((error) => { notifyJobCompleteWebhook(updatedJob).catch((error) => {

View File

@ -40,10 +40,13 @@ describe.sequential("Pipeline API routes", () => {
}); });
const runBody = await runRes.json(); const runBody = await runRes.json();
expect(runBody.ok).toBe(true); expect(runBody.ok).toBe(true);
expect(runPipeline).toHaveBeenCalledWith({ expect(runPipeline).toHaveBeenCalledWith(
topN: 5, expect.objectContaining({
sources: ["gradcracker"], topN: 5,
}); sources: ["gradcracker"],
ownerProfileId: "__default__",
}),
);
const glassdoorRunRes = await fetch(`${baseUrl}/api/pipeline/run`, { const glassdoorRunRes = await fetch(`${baseUrl}/api/pipeline/run`, {
method: "POST", method: "POST",
@ -52,9 +55,13 @@ describe.sequential("Pipeline API routes", () => {
}); });
const glassdoorRunBody = await glassdoorRunRes.json(); const glassdoorRunBody = await glassdoorRunRes.json();
expect(glassdoorRunBody.ok).toBe(true); expect(glassdoorRunBody.ok).toBe(true);
expect(runPipeline).toHaveBeenNthCalledWith(2, { expect(runPipeline).toHaveBeenNthCalledWith(
sources: ["glassdoor"], 2,
}); expect.objectContaining({
sources: ["glassdoor"],
ownerProfileId: "__default__",
}),
);
const adzunaRunRes = await fetch(`${baseUrl}/api/pipeline/run`, { const adzunaRunRes = await fetch(`${baseUrl}/api/pipeline/run`, {
method: "POST", method: "POST",
@ -63,9 +70,13 @@ describe.sequential("Pipeline API routes", () => {
}); });
const adzunaRunBody = await adzunaRunRes.json(); const adzunaRunBody = await adzunaRunRes.json();
expect(adzunaRunBody.ok).toBe(true); expect(adzunaRunBody.ok).toBe(true);
expect(runPipeline).toHaveBeenNthCalledWith(3, { expect(runPipeline).toHaveBeenNthCalledWith(
sources: ["adzuna"], 3,
}); expect.objectContaining({
sources: ["adzuna"],
ownerProfileId: "__default__",
}),
);
}); });
it("returns conflict when cancelling with no active pipeline", async () => { it("returns conflict when cancelling with no active pipeline", async () => {

View File

@ -7,13 +7,17 @@ import {
} from "@infra/errors"; } from "@infra/errors";
import { fail, ok, okWithMeta } from "@infra/http"; import { fail, ok, okWithMeta } from "@infra/http";
import { logger } from "@infra/logger"; 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 { setupSse, startSseHeartbeat, writeSseData } from "@infra/sse";
import { isDemoMode } from "@server/config/demo"; import { isDemoMode } from "@server/config/demo";
import { import {
type ExtractorRegistry, type ExtractorRegistry,
getExtractorRegistry, getExtractorRegistry,
} from "@server/extractors/registry"; } from "@server/extractors/registry";
import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import { import {
getPipelineStatus, getPipelineStatus,
requestPipelineCancel, requestPipelineCancel,
@ -157,9 +161,11 @@ pipelineRouter.post("/run", async (req: Request, res: Response) => {
return okWithMeta(res, simulated, { simulated: true }); return okWithMeta(res, simulated, { simulated: true });
} }
// Start pipeline in background // Start pipeline in background (preserve tenant for async pipeline work).
runWithRequestContext({}, () => { const ownerProfileId =
runPipeline(config).catch((error) => { getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID;
runWithRequestContext({ ownerProfileId }, () => {
runPipeline({ ...config, ownerProfileId }).catch((error) => {
logger.error("Background pipeline run failed", 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 { asyncRoute, fail, ok } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import {
isBasicAuthEnabled,
parseBasicAuthUsername,
} from "@server/infra/basic-auth-credentials";
import * as profilesRepo from "@server/repositories/profiles"; import * as profilesRepo from "@server/repositories/profiles";
import { setSetting } from "@server/repositories/settings"; 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 { generateProfileFromResume } from "@server/services/profile-generator";
import { jobSearchProfileSchema } from "@shared/settings-registry"; import { jobSearchProfileSchema } from "@shared/settings-registry";
import type { SearchProfile } from "@shared/types";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
export const profilesRouter = Router(); export const profilesRouter = Router();
function profileMatchesBasicAuthUser(
profile: SearchProfile,
username: string | null,
): boolean {
if (!username?.trim()) return false;
const u = username.trim().toLowerCase();
const bu = (profile.data.basicAuthUser ?? "").trim().toLowerCase();
return Boolean(bu) && bu === u;
}
function assertProfileVisibleToRequest(
req: Request,
profile: SearchProfile,
): boolean {
if (!isBasicAuthEnabled()) return true;
const username = parseBasicAuthUsername(req.headers.authorization);
return profileMatchesBasicAuthUser(profile, username);
}
profilesRouter.get( profilesRouter.get(
"/", "/",
asyncRoute(async (_req: Request, res: Response) => { asyncRoute(async (req: Request, res: Response) => {
const profiles = await profilesRepo.listProfiles(); const username = parseBasicAuthUsername(req.headers.authorization);
const profiles =
isBasicAuthEnabled() && username?.trim()
? await profilesRepo.listProfilesForBasicAuthUser(username)
: await profilesRepo.listProfiles();
return ok(res, profiles); return ok(res, profiles);
}), }),
); );
@ -25,6 +53,9 @@ profilesRouter.get(
if (!profile) { if (!profile) {
return fail(res, badRequest("Profile not found")); return fail(res, badRequest("Profile not found"));
} }
if (!assertProfileVisibleToRequest(req, profile)) {
return fail(res, forbidden("You cannot access this profile"));
}
return ok(res, profile); return ok(res, profile);
}), }),
); );
@ -42,9 +73,14 @@ profilesRouter.post(
issues: parsed.error.issues, 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({ const profile = await profilesRepo.createProfile({
name: name.trim(), name: name.trim(),
data: parsed.data, data: dataWithOwner,
}); });
return ok(res, profile); return ok(res, profile);
}), }),
@ -53,6 +89,13 @@ profilesRouter.post(
profilesRouter.patch( profilesRouter.patch(
"/:id", "/:id",
asyncRoute(async (req: Request, res: Response) => { 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 { name, data } = req.body;
const updates: { name?: string; data?: typeof data } = {}; const updates: { name?: string; data?: typeof data } = {};
if (name !== undefined) { if (name !== undefined) {
@ -68,7 +111,18 @@ profilesRouter.patch(
issues: parsed.error.issues, 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); const profile = await profilesRepo.updateProfile(req.params.id, updates);
if (!profile) { if (!profile) {
@ -81,6 +135,13 @@ profilesRouter.patch(
profilesRouter.delete( profilesRouter.delete(
"/:id", "/:id",
asyncRoute(async (req: Request, res: Response) => { 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); const deleted = await profilesRepo.deleteProfile(req.params.id);
if (!deleted) { if (!deleted) {
return fail(res, badRequest("Profile not found")); return fail(res, badRequest("Profile not found"));
@ -96,8 +157,23 @@ profilesRouter.post(
if (!profile) { if (!profile) {
return fail(res, badRequest("Profile not found")); 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("activeProfileId", profile.id);
await setSetting("jobSearchProfile", JSON.stringify(profile.data)); await setSetting("jobSearchProfile", JSON.stringify(profile.data));
if (resumePath) {
await setSetting("localResumeProfilePath", resumePath);
clearProfileCache();
}
return ok(res, { activated: true, profileId: profile.id }); return ok(res, { activated: true, profileId: profile.id });
}), }),
); );

View File

@ -175,7 +175,7 @@ describe.sequential("Stats proxy routes", () => {
await stopServer({ server, closeDb, tempDir }); await stopServer({ server, closeDb, tempDir });
({ server, baseUrl, closeDb, tempDir } = await startServer({ ({ server, baseUrl, closeDb, tempDir } = await startServer({
env: { env: {
BASIC_AUTH_USER: "admin", BASIC_AUTH_USER: "ilia",
BASIC_AUTH_PASSWORD: "secret", BASIC_AUTH_PASSWORD: "secret",
}, },
})); }));

View File

@ -227,7 +227,8 @@ describe.sequential("Tracer links routes", () => {
await stopServer({ server, closeDb, tempDir }); await stopServer({ server, closeDb, tempDir });
({ server, baseUrl, closeDb, tempDir } = await startServer({ ({ server, baseUrl, closeDb, tempDir } = await startServer({
env: { env: {
BASIC_AUTH_USER: "admin", // Must match a seeded search profile `basicAuthUser` (see db/migrate.ts).
BASIC_AUTH_USER: "ilia",
BASIC_AUTH_PASSWORD: "secret", BASIC_AUTH_PASSWORD: "secret",
}, },
})); }));
@ -235,7 +236,7 @@ describe.sequential("Tracer links routes", () => {
const unauthorized = await fetch(`${baseUrl}/api/tracer-links/analytics`); const unauthorized = await fetch(`${baseUrl}/api/tracer-links/analytics`);
expect(unauthorized.status).toBe(401); 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`, { const authorized = await fetch(`${baseUrl}/api/tracer-links/analytics`, {
headers: { headers: {
Authorization: `Basic ${credentials}`, Authorization: `Basic ${credentials}`,

View File

@ -19,6 +19,12 @@ import {
} from "@infra/http"; } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize"; 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 cors from "cors";
import express from "express"; import express from "express";
import { apiRouter } from "./api/index"; import { apiRouter } from "./api/index";
@ -132,33 +138,21 @@ function buildUmamiProxyHeaders(req: express.Request): Headers {
} }
export function createBasicAuthGuard() { export function createBasicAuthGuard() {
function getAuthConfig() { function isAuthorized(req: express.Request): boolean {
const user = process.env.BASIC_AUTH_USER || ""; if (!isBasicAuthEnabled()) return false;
const pass = process.env.BASIC_AUTH_PASSWORD || ""; const parsed = parseBasicAuthCredentials(req.headers.authorization);
return { if (!parsed) return false;
user, return basicAuthMatchesDecodedUserPass(parsed.user, parsed.pass);
pass,
enabled: user.length > 0 && pass.length > 0,
};
} }
function isAuthorized(req: express.Request): boolean { function isPublicApiGet(path: string): boolean {
const { user: authUser, pass: authPass, enabled } = getAuthConfig(); const normalizedPath = path.split("?")[0] || path;
if (!enabled) return false; if (normalizedPath === "/api/auth/basic-status") return true;
const authHeader = req.headers.authorization || ""; if (normalizedPath === "/api/demo/info") return true;
if (!authHeader.startsWith("Basic ")) return false; if (normalizedPath === "/api/visa-sponsors/status") return true;
const encoded = authHeader.slice("Basic ".length).trim(); if (normalizedPath === "/api/profile/status") return true;
let decoded = ""; if (normalizedPath === "/api/pipeline/status") return true;
try { return false;
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 isPublicReadOnlyRoute(method: string, path: string): boolean { function isPublicReadOnlyRoute(method: string, path: string): boolean {
@ -175,10 +169,26 @@ export function createBasicAuthGuard() {
function requiresAuth(method: string, path: string): boolean { function requiresAuth(method: string, path: string): boolean {
if (isPublicReadOnlyRoute(method, path)) return false; if (isPublicReadOnlyRoute(method, path)) return false;
if (isStatsRoute(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")) { if (path.startsWith("/api/tracer-links")) {
return method.toUpperCase() !== "OPTIONS"; 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 = ( const middleware = (
@ -186,8 +196,8 @@ export function createBasicAuthGuard() {
res: express.Response, res: express.Response,
next: express.NextFunction, next: express.NextFunction,
) => { ) => {
const { enabled } = getAuthConfig(); if (!isBasicAuthEnabled() || !requiresAuth(req.method, req.path))
if (!enabled || !requiresAuth(req.method, req.path)) return next(); return next();
if (isAuthorized(req)) return next(); if (isAuthorized(req)) return next();
fail(res, unauthorized("Authentication required")); fail(res, unauthorized("Authentication required"));
}; };
@ -195,7 +205,7 @@ export function createBasicAuthGuard() {
return { return {
middleware, middleware,
isAuthorized, isAuthorized,
basicAuthEnabled: getAuthConfig().enabled, basicAuthEnabled: isBasicAuthEnabled(),
}; };
} }
@ -277,6 +287,7 @@ export function createApp() {
// Optional Basic Auth for write access (read-only by default) // Optional Basic Auth for write access (read-only by default)
app.use(authGuard.middleware); app.use(authGuard.middleware);
app.use(jobOwnerContextMiddleware());
// API routes // API routes
app.use("/api", apiRouter); app.use("/api", apiRouter);

View File

@ -114,9 +114,32 @@ describe.sequential("Basic Auth read-only enforcement", () => {
expect(res.status).not.toHaveBeenCalled(); 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", () => { it("does not require auth when Basic Auth is disabled", () => {
delete process.env.BASIC_AUTH_USER; delete process.env.BASIC_AUTH_USER;
delete process.env.BASIC_AUTH_PASSWORD; delete process.env.BASIC_AUTH_PASSWORD;
delete process.env.BASIC_AUTH_USER_2;
delete process.env.BASIC_AUTH_PASSWORD_2;
const { middleware } = createBasicAuthGuard(); const { middleware } = createBasicAuthGuard();
const req = createMockRequest({ const req = createMockRequest({

View File

@ -18,6 +18,17 @@ if (!existsSync(dataDir)) {
const sqlite = new Database(DB_PATH); 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 = [ const migrations = [
`CREATE TABLE IF NOT EXISTS jobs ( `CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -666,6 +677,183 @@ const migrations = [
ORDER BY se.occurred_at DESC, se.id DESC ORDER BY se.occurred_at DESC, se.id DESC
LIMIT 1 LIMIT 1
), 'applied') = 'closed'`, ), '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..."); console.log("🔧 Running database migrations...");

View File

@ -28,92 +28,106 @@ import {
uniqueIndex, uniqueIndex,
} from "drizzle-orm/sqlite-core"; } from "drizzle-orm/sqlite-core";
export const jobs = sqliteTable("jobs", { export const jobs = sqliteTable(
id: text("id").primaryKey(), "jobs",
{
id: text("id").primaryKey(),
// From crawler /** Search profile that owns this row (multi-tenant). */
source: text("source").notNull().default("gradcracker"), ownerProfileId: text("owner_profile_id").notNull().default("__default__"),
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"),
// JobSpy fields (nullable for other sources) // From crawler
jobType: text("job_type"), source: text("source").notNull().default("gradcracker"),
salarySource: text("salary_source"), sourceJobId: text("source_job_id"),
salaryInterval: text("salary_interval"), jobUrlDirect: text("job_url_direct"),
salaryMinAmount: real("salary_min_amount"), datePosted: text("date_posted"),
salaryMaxAmount: real("salary_max_amount"), title: text("title").notNull(),
salaryCurrency: text("salary_currency"), employer: text("employer").notNull(),
isRemote: integer("is_remote", { mode: "boolean" }), employerUrl: text("employer_url"),
jobLevel: text("job_level"), jobUrl: text("job_url").notNull(),
jobFunction: text("job_function"), applicationLink: text("application_link"),
listingType: text("listing_type"), disciplines: text("disciplines"),
emails: text("emails"), deadline: text("deadline"),
companyIndustry: text("company_industry"), salary: text("salary"),
companyLogo: text("company_logo"), location: text("location"),
companyUrlDirect: text("company_url_direct"), degreeRequired: text("degree_required"),
companyAddresses: text("company_addresses"), starting: text("starting"),
companyNumEmployees: text("company_num_employees"), jobDescription: text("job_description"),
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"),
// Orchestrator enrichments // JobSpy fields (nullable for other sources)
status: text("status", { jobType: text("job_type"),
enum: [ salarySource: text("salary_source"),
"discovered", salaryInterval: text("salary_interval"),
"processing", salaryMinAmount: real("salary_min_amount"),
"ready", salaryMaxAmount: real("salary_max_amount"),
"applied", salaryCurrency: text("salary_currency"),
"in_progress", isRemote: integer("is_remote", { mode: "boolean" }),
"skipped", jobLevel: text("job_level"),
"expired", jobFunction: text("job_function"),
], listingType: text("listing_type"),
}) emails: text("emails"),
.notNull() companyIndustry: text("company_industry"),
.default("discovered"), companyLogo: text("company_logo"),
outcome: text("outcome", { enum: APPLICATION_OUTCOMES }), companyUrlDirect: text("company_url_direct"),
closedAt: integer("closed_at", { mode: "number" }), companyAddresses: text("company_addresses"),
suitabilityScore: real("suitability_score"), companyNumEmployees: text("company_num_employees"),
suitabilityReason: text("suitability_reason"), companyRevenue: text("company_revenue"),
suitabilityAnalysis: text("suitability_analysis"), companyDescription: text("company_description"),
tailoredSummary: text("tailored_summary"), skills: text("skills"),
tailoredHeadline: text("tailored_headline"), experienceRange: text("experience_range"),
tailoredSkills: text("tailored_skills"), companyRating: real("company_rating"),
selectedProjectIds: text("selected_project_ids"), companyReviewsCount: integer("company_reviews_count"),
pdfPath: text("pdf_path"), vacancyCount: integer("vacancy_count"),
tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" }) workFromHomeType: text("work_from_home_type"),
.notNull()
.default(false),
coverLetter: text("cover_letter"),
sponsorMatchScore: real("sponsor_match_score"),
sponsorMatchNames: text("sponsor_match_names"),
notes: text("notes"),
// Timestamps // Orchestrator enrichments
discoveredAt: text("discovered_at").notNull().default(sql`(datetime('now'))`), status: text("status", {
processedAt: text("processed_at"), enum: [
appliedAt: text("applied_at"), "discovered",
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), "processing",
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), "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", { export const stageEvents = sqliteTable("stage_events", {
id: text("id").primaryKey(), 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; requestId: string;
pipelineRunId?: string; pipelineRunId?: string;
jobId?: string; jobId?: string;
/** Search profile id for job list / pipeline isolation (Basic Auth). */
ownerProfileId?: string;
}; };
const storage = new AsyncLocalStorage<RequestContext>(); const storage = new AsyncLocalStorage<RequestContext>();
@ -28,3 +30,7 @@ export function runWithRequestContext<T>(
export function getRequestId(): string | undefined { export function getRequestId(): string | undefined {
return storage.getStore()?.requestId; 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 { join } from "node:path";
import { logger } from "@infra/logger"; 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 type { PipelineConfig } from "@shared/types";
import { getDataDir } from "../config/dataDir"; import { getDataDir } from "../config/dataDir";
import * as jobsRepo from "../repositories/jobs"; import * as jobsRepo from "../repositories/jobs";
@ -88,137 +92,154 @@ export async function runPipeline(
activePipelineRunId = "pending"; activePipelineRunId = "pending";
cancelRequestedAt = null; cancelRequestedAt = null;
resetProgress(); 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(); const pipelineRun = await pipelineRepo.createPipelineRun();
activePipelineRunId = pipelineRun.id; activePipelineRunId = pipelineRun.id;
return runWithRequestContext({ pipelineRunId: pipelineRun.id }, async () => { return runWithRequestContext(
const pipelineLogger = logger.child({ pipelineRunId: pipelineRun.id }); {
let jobsDiscovered = 0; pipelineRunId: pipelineRun.id,
let jobsProcessed = 0; ownerProfileId,
pipelineLogger.info("Starting pipeline run", { },
topN: mergedConfig.topN, async () => {
minSuitabilityScore: mergedConfig.minSuitabilityScore, const pipelineLogger = logger.child({ pipelineRunId: pipelineRun.id });
sources: mergedConfig.sources, let jobsDiscovered = 0;
}); let jobsProcessed = 0;
pipelineLogger.info("Starting pipeline run", {
try { topN: mergedConfig.topN,
ensureNotCancelled(); minSuitabilityScore: mergedConfig.minSuitabilityScore,
const profile = await loadProfileStep(); sources: mergedConfig.sources,
ensureNotCancelled();
const { discoveredJobs } = await discoverJobsStep({
mergedConfig,
shouldCancel: () => cancelRequestedAt !== null,
}); });
ensureNotCancelled(); try {
const { created } = await importJobsStep({ discoveredJobs }); ensureNotCancelled();
jobsDiscovered = created; const profile = await loadProfileStep();
await pipelineRepo.updatePipelineRun(pipelineRun.id, { ensureNotCancelled();
jobsDiscovered: created, const { discoveredJobs } = await discoverJobsStep({
}); mergedConfig,
shouldCancel: () => cancelRequestedAt !== null,
});
ensureNotCancelled(); ensureNotCancelled();
const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ const { created } = await importJobsStep({ discoveredJobs });
profile, jobsDiscovered = created;
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, { 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(), completedAt: new Date().toISOString(),
jobsDiscovered,
jobsProcessed,
errorMessage: message, errorMessage: message,
}); });
progressHelpers.cancelled(message);
pipelineLogger.info("Pipeline run cancelled", { progressHelpers.failed(message);
jobsDiscovered, pipelineLogger.error("Pipeline run failed", error);
jobsProcessed,
await notifyPipelineWebhookStep("pipeline.failed", {
pipelineRunId: pipelineRun.id,
error: message,
}); });
return { return {
success: false, success: false,
jobsDiscovered, jobsDiscovered,
jobsProcessed, jobsProcessed,
error: message, 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 = { export type ProcessJobOptions = {
@ -240,7 +261,10 @@ export async function summarizeJob(
const jobLogger = logger.child({ jobId }); const jobLogger = logger.child({ jobId });
jobLogger.info("Summarizing job"); jobLogger.info("Summarizing job");
try { 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" }; if (!job) return { success: false, error: "Job not found" };
const profile = await getProfile(); const profile = await getProfile();
@ -303,12 +327,16 @@ export async function summarizeJob(
} }
} }
await jobsRepo.updateJob(job.id, { await jobsRepo.updateJob(
tailoredSummary: tailoredSummary ?? undefined, job.id,
tailoredHeadline: tailoredHeadline ?? undefined, {
tailoredSkills: tailoredSkills ?? undefined, tailoredSummary: tailoredSummary ?? undefined,
selectedProjectIds: selectedProjectIds ?? undefined, tailoredHeadline: tailoredHeadline ?? undefined,
}); tailoredSkills: tailoredSkills ?? undefined,
selectedProjectIds: selectedProjectIds ?? undefined,
},
job.ownerProfileId,
);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@ -333,11 +361,18 @@ export async function generateFinalPdf(
const jobLogger = logger.child({ jobId }); const jobLogger = logger.child({ jobId });
jobLogger.info("Generating final PDF"); jobLogger.info("Generating final PDF");
try { 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" }; if (!job) return { success: false, error: "Job not found" };
// Mark as processing // Mark as processing
await jobsRepo.updateJob(job.id, { status: "processing" }); await jobsRepo.updateJob(
job.id,
{ status: "processing" },
job.ownerProfileId,
);
const pdfResult = await generatePdf( const pdfResult = await generatePdf(
job.id, job.id,
@ -358,14 +393,22 @@ export async function generateFinalPdf(
if (!pdfResult.success) { if (!pdfResult.success) {
// Revert status if failed // 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 }; return { success: false, error: pdfResult.error };
} }
await jobsRepo.updateJob(job.id, { await jobsRepo.updateJob(
status: "ready", job.id,
pdfPath: pdfResult.pdfPath, {
}); status: "ready",
pdfPath: pdfResult.pdfPath,
},
job.ownerProfileId,
);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@ -11,6 +11,10 @@ vi.mock("@server/repositories/jobs", () => ({
getAllJobUrls: vi.fn().mockResolvedValue([]), getAllJobUrls: vi.fn().mockResolvedValue([]),
})); }));
vi.mock("@server/repositories/profiles", () => ({
getProfileById: vi.fn().mockResolvedValue(null),
}));
vi.mock("@server/extractors/registry", () => ({ vi.mock("@server/extractors/registry", () => ({
getExtractorRegistry: vi.fn(), getExtractorRegistry: vi.fn(),
})); }));
@ -20,6 +24,7 @@ const baseConfig: PipelineConfig = {
minSuitabilityScore: 50, minSuitabilityScore: 50,
sources: ["indeed", "linkedin", "ukvisajobs"], sources: ["indeed", "linkedin", "ukvisajobs"],
outputDir: "./tmp", outputDir: "./tmp",
ownerProfileId: "__default__",
enableCrawling: true, enableCrawling: true,
enableScoring: true, enableScoring: true,
enableImporting: true, enableImporting: true,
@ -84,6 +89,7 @@ describe("discoverJobsStep", () => {
const result = await discoverJobsStep({ mergedConfig: baseConfig }); const result = await discoverJobsStep({ mergedConfig: baseConfig });
expect(result.discoveredJobs).toHaveLength(1); expect(result.discoveredJobs).toHaveLength(1);
expect(result.discoveredJobs[0]?.ownerProfileId).toBe("__default__");
expect(result.sourceErrors).toEqual([ expect(result.sourceErrors).toEqual([
"UK Visa Jobs: login failed (sources: ukvisajobs)", "UK Visa Jobs: login failed (sources: ukvisajobs)",
]); ]);

View File

@ -1,7 +1,9 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize"; import { sanitizeUnknown } from "@infra/sanitize";
import { getExtractorRegistry } from "@server/extractors/registry"; import { getExtractorRegistry } from "@server/extractors/registry";
import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import { getAllJobUrls } from "@server/repositories/jobs"; import { getAllJobUrls } from "@server/repositories/jobs";
import { getProfileById } from "@server/repositories/profiles";
import * as settingsRepo from "@server/repositories/settings"; import * as settingsRepo from "@server/repositories/settings";
import { asyncPool } from "@server/utils/async-pool"; import { asyncPool } from "@server/utils/async-pool";
import { import {
@ -94,32 +96,47 @@ export async function discoverJobsStep(args: {
.filter(Boolean); .filter(Boolean);
} }
const profileSetting = settings.jobSearchProfile; const ownerProfileId =
if (profileSetting) { args.mergedConfig.ownerProfileId ?? DEFAULT_JOB_OWNER_PROFILE_ID;
try {
const profile = JSON.parse(profileSetting); 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 ( if (
Array.isArray(profile.targetRoles) && typeof role === "string" &&
profile.targetRoles.length > 0 role.trim() &&
!existingLower.has(role.trim().toLowerCase())
) { ) {
const existingLower = new Set(searchTerms.map((t) => t.toLowerCase())); searchTerms.push(role.trim());
for (const role of profile.targetRoles) { existingLower.add(role.trim().toLowerCase());
if ( }
typeof role === "string" && }
role.trim() && logger.info("Augmented search terms with profile target roles", {
!existingLower.has(role.trim().toLowerCase()) addedRoles: targetRoles.length,
) { totalTerms: searchTerms.length,
searchTerms.push(role.trim()); });
existingLower.add(role.trim().toLowerCase()); };
}
} if (ownerProfileId && ownerProfileId !== DEFAULT_JOB_OWNER_PROFILE_ID) {
logger.info("Augmented search terms with profile target roles", { const row = await getProfileById(ownerProfileId);
addedRoles: profile.targetRoles.length, if (row?.data?.targetRoles?.length) {
totalTerms: searchTerms.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; let existingJobUrlsPromise: Promise<string[]> | null = null;
const getExistingJobUrls = (): Promise<string[]> => { const getExistingJobUrls = (): Promise<string[]> => {
if (!existingJobUrlsPromise) { if (!existingJobUrlsPromise) {
existingJobUrlsPromise = getAllJobUrls(); existingJobUrlsPromise = getAllJobUrls(ownerProfileId);
} }
return existingJobUrlsPromise; return existingJobUrlsPromise;
}; };
@ -399,5 +416,10 @@ export async function discoverJobsStep(args: {
progressHelpers.crawlingComplete(filteredDiscoveredJobs.length); 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"); vi.mocked(settingsRepo.getSetting).mockResolvedValue("50");
await scoreJobsStep({ profile: {} }); await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" });
expect(jobsRepo.updateJob).toHaveBeenCalledWith( expect(jobsRepo.updateJob).toHaveBeenCalledWith(
"job-1", "job-1",
@ -107,7 +107,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
analysis: null, analysis: null,
}); });
await scoreJobsStep({ profile: {} }); await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" });
expect(jobsRepo.updateJob).toHaveBeenCalledWith( expect(jobsRepo.updateJob).toHaveBeenCalledWith(
"job-1", "job-1",
@ -131,7 +131,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
vi.mocked(settingsRepo.getSetting).mockResolvedValue(null); 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 { const updatePayload = vi.mocked(jobsRepo.updateJob).mock.calls[0][1] as {
status?: string; status?: string;
@ -145,7 +145,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
vi.mocked(settingsRepo.getSetting).mockResolvedValue("not-a-number"); 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 { const updatePayload = vi.mocked(jobsRepo.updateJob).mock.calls[0][1] as {
status?: string; status?: string;
@ -170,7 +170,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
}), }),
]); ]);
await scoreJobsStep({ profile: {} }); await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" });
expect(jobsRepo.updateJob).toHaveBeenCalledWith( expect(jobsRepo.updateJob).toHaveBeenCalledWith(
"job-applied", "job-applied",
@ -218,7 +218,10 @@ describe("scoreJobsStep auto-skip behavior", () => {
analysis: null, analysis: null,
}); });
const result = await scoreJobsStep({ profile: {} }); const result = await scoreJobsStep({
profile: {},
ownerProfileId: "__default__",
});
expect(result.scoredJobs).toHaveLength(2); expect(result.scoredJobs).toHaveLength(2);
expect(vi.mocked(jobsRepo.updateJob)).toHaveBeenCalledTimes(2); expect(vi.mocked(jobsRepo.updateJob)).toHaveBeenCalledTimes(2);
@ -241,6 +244,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
const result = await scoreJobsStep({ const result = await scoreJobsStep({
profile: {}, profile: {},
ownerProfileId: "__default__",
shouldCancel: () => true, shouldCancel: () => true,
}); });

View File

@ -12,10 +12,14 @@ const SCORING_CONCURRENCY = 4;
export async function scoreJobsStep(args: { export async function scoreJobsStep(args: {
profile: Record<string, unknown>; profile: Record<string, unknown>;
ownerProfileId: string;
shouldCancel?: () => boolean; shouldCancel?: () => boolean;
}): Promise<{ unprocessedJobs: Job[]; scoredJobs: ScoredJob[] }> { }): Promise<{ unprocessedJobs: Job[]; scoredJobs: ScoredJob[] }> {
logger.info("Running scoring step"); 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 // Check if auto-skip threshold is configured
const autoSkipThresholdRaw = await settingsRepo.getSetting( const autoSkipThresholdRaw = await settingsRepo.getSetting(

View File

@ -3,6 +3,8 @@
*/ */
import { randomUUID } from "node:crypto"; 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 { canonicalizeJobUrl } from "@shared/job-url-canonical";
import type { import type {
CreateJobInput, CreateJobInput,
@ -29,7 +31,13 @@ function sourceJobKey(source: string, sourceJobId: string): string {
return `${source}\0${sourceJobId}`; 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>; existingCanonicalSet: Set<string>;
existingSourceJobKeySet: Set<string>; existingSourceJobKeySet: Set<string>;
}> { }> {
@ -39,7 +47,8 @@ async function loadJobDedupIndexes(): Promise<{
source: jobs.source, source: jobs.source,
sourceJobId: jobs.sourceJobId, sourceJobId: jobs.sourceJobId,
}) })
.from(jobs); .from(jobs)
.where(eq(jobs.ownerProfileId, ownerProfileId));
const existingCanonicalSet = new Set( const existingCanonicalSet = new Set(
rows.map((r) => canonicalizeJobUrl(r.jobUrl)), rows.map((r) => canonicalizeJobUrl(r.jobUrl)),
@ -54,14 +63,22 @@ async function loadJobDedupIndexes(): Promise<{
return { existingCanonicalSet, existingSourceJobKeySet }; 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 const [exact] = await db
.select() .select()
.from(jobs) .from(jobs)
.where(eq(jobs.jobUrl, canonical)); .where(
and(eq(jobs.ownerProfileId, ownerProfileId), eq(jobs.jobUrl, canonical)),
);
if (exact) return mapRowToJob(exact); 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) { for (const row of allRows) {
if (canonicalizeJobUrl(row.jobUrl) === canonical) { if (canonicalizeJobUrl(row.jobUrl) === canonical) {
return mapRowToJob(row); return mapRowToJob(row);
@ -73,11 +90,18 @@ async function findJobByCanonicalUrl(canonical: string): Promise<Job | null> {
async function getJobBySourceAndExternalId( async function getJobBySourceAndExternalId(
source: string, source: string,
sourceJobId: string, sourceJobId: string,
ownerProfileId: string,
): Promise<Job | null> { ): Promise<Job | null> {
const [row] = await db const [row] = await db
.select() .select()
.from(jobs) .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; return row ? mapRowToJob(row) : null;
} }
@ -89,15 +113,24 @@ function normalizeStatusFilter(statuses?: JobStatus[]): string | null {
/** /**
* Get all jobs, optionally filtered by status. * 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 = const query =
statuses && statuses.length > 0 statuses && statuses.length > 0
? db ? db
.select() .select()
.from(jobs) .from(jobs)
.where(inArray(jobs.status, statuses)) .where(and(ownerClause, inArray(jobs.status, statuses)))
.orderBy(desc(jobs.discoveredAt)) .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; const rows = await query;
return rows.map(mapRowToJob); return rows.map(mapRowToJob);
@ -108,7 +141,10 @@ export async function getAllJobs(statuses?: JobStatus[]): Promise<Job[]> {
*/ */
export async function getJobListItems( export async function getJobListItems(
statuses?: JobStatus[], statuses?: JobStatus[],
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<JobListItem[]> { ): Promise<JobListItem[]> {
const ownerClause = eq(jobs.ownerProfileId, ownerProfileId);
const selection = { const selection = {
id: jobs.id, id: jobs.id,
source: jobs.source, source: jobs.source,
@ -141,9 +177,13 @@ export async function getJobListItems(
? db ? db
.select(selection) .select(selection)
.from(jobs) .from(jobs)
.where(inArray(jobs.status, statuses)) .where(and(ownerClause, inArray(jobs.status, statuses)))
.orderBy(desc(jobs.discoveredAt)) .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; const rows = await query;
return rows.map((row) => ({ return rows.map((row) => ({
@ -158,9 +198,12 @@ export async function getJobListItems(
*/ */
export async function getJobsRevision( export async function getJobsRevision(
statuses?: JobStatus[], statuses?: JobStatus[],
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<JobsRevisionResponse> { ): Promise<JobsRevisionResponse> {
const statusFilter = normalizeStatusFilter(statuses); const statusFilter = normalizeStatusFilter(statuses);
const whereClause = const ownerClause = eq(jobs.ownerProfileId, ownerProfileId);
const statusClause =
statuses && statuses.length > 0 statuses && statuses.length > 0
? inArray(jobs.status, statuses) ? inArray(jobs.status, statuses)
: undefined; : undefined;
@ -171,9 +214,9 @@ export async function getJobsRevision(
total: sql<number>`count(*)`, total: sql<number>`count(*)`,
}) })
.from(jobs); .from(jobs);
const [row] = whereClause const [row] = statusClause
? await baseQuery.where(whereClause) ? await baseQuery.where(and(ownerClause, statusClause))
: await baseQuery; : await baseQuery.where(ownerClause);
const latestUpdatedAt = row?.latestUpdatedAt ?? null; const latestUpdatedAt = row?.latestUpdatedAt ?? null;
const total = row?.total ?? 0; 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> { export async function getJobById(
const [row] = await db.select().from(jobs).where(eq(jobs.id, id)); 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; 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<{ Array<{
id: string; id: string;
title: string; title: string;
@ -211,22 +265,34 @@ export async function listJobSummariesByIds(jobIds: string[]): Promise<
employer: jobs.employer, employer: jobs.employer,
}) })
.from(jobs) .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). * Get a job by its URL (for deduplication).
* Matches canonical URL equivalence, including legacy rows stored with non-canonical URLs. * Matches canonical URL equivalence, including legacy rows stored with non-canonical URLs.
*/ */
export async function getJobByUrl(jobUrl: string): Promise<Job | null> { export async function getJobByUrl(
return findJobByCanonicalUrl(canonicalizeJobUrl(jobUrl)); 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). * Get all known canonical job URLs (for deduplication / crawler skip lists).
*/ */
export async function getAllJobUrls(): Promise<string[]> { export async function getAllJobUrls(
const rows = await db.select({ jobUrl: jobs.jobUrl }).from(jobs); 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)); const canonicals = rows.map((r) => canonicalizeJobUrl(r.jobUrl));
return Array.from(new Set(canonicals)); return Array.from(new Set(canonicals));
} }
@ -235,8 +301,11 @@ async function insertJob(input: CreateJobInput): Promise<Job> {
const id = randomUUID(); const id = randomUUID();
const now = new Date().toISOString(); const now = new Date().toISOString();
const ownerProfileId = resolveOwnerForCreate(input);
await db.insert(jobs).values({ await db.insert(jobs).values({
id, id,
ownerProfileId,
source: input.source, source: input.source,
sourceJobId: input.sourceJobId ?? null, sourceJobId: input.sourceJobId ?? null,
jobUrlDirect: input.jobUrlDirect ?? null, jobUrlDirect: input.jobUrlDirect ?? null,
@ -292,7 +361,12 @@ async function insertJob(input: CreateJobInput): Promise<Job> {
function isJobUrlUniqueViolation(error: unknown): boolean { function isJobUrlUniqueViolation(error: unknown): boolean {
if (!(error instanceof Error)) return false; 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> { async function tryInsertJob(input: CreateJobInput): Promise<Job | null> {
@ -316,8 +390,13 @@ export async function createJobs(
): Promise<Job | { created: number; skipped: number }> { ): Promise<Job | { created: number; skipped: number }> {
if (!Array.isArray(inputOrInputs)) { if (!Array.isArray(inputOrInputs)) {
const normalized = normalizeCreateJobInputForDedup(inputOrInputs); const normalized = normalizeCreateJobInputForDedup(inputOrInputs);
const ownerProfileId = resolveOwnerForCreate(normalized);
const normalizedWithOwner: CreateJobInput = {
...normalized,
ownerProfileId,
};
const { existingCanonicalSet, existingSourceJobKeySet } = const { existingCanonicalSet, existingSourceJobKeySet } =
await loadJobDedupIndexes(); await loadJobDedupIndexes(ownerProfileId);
const sid = normalized.sourceJobId?.trim(); const sid = normalized.sourceJobId?.trim();
if (sid) { if (sid) {
@ -326,29 +405,40 @@ export async function createJobs(
const existing = await getJobBySourceAndExternalId( const existing = await getJobBySourceAndExternalId(
normalized.source, normalized.source,
sid, sid,
ownerProfileId,
); );
if (existing) return existing; if (existing) return existing;
} }
} }
if (existingCanonicalSet.has(normalized.jobUrl)) { if (existingCanonicalSet.has(normalized.jobUrl)) {
const existing = await findJobByCanonicalUrl(normalized.jobUrl); const existing = await findJobByCanonicalUrl(
normalized.jobUrl,
ownerProfileId,
);
if (existing) return existing; if (existing) return existing;
} }
const inserted = await tryInsertJob(normalized); const inserted = await tryInsertJob(normalizedWithOwner);
if (inserted) return inserted; if (inserted) return inserted;
const existingAfterConflict = const existingAfterConflict =
(await findJobByCanonicalUrl(normalized.jobUrl)) ?? (await findJobByCanonicalUrl(normalized.jobUrl, ownerProfileId)) ??
(sid ? await getJobBySourceAndExternalId(normalized.source, sid) : null); (sid
? await getJobBySourceAndExternalId(
normalized.source,
sid,
ownerProfileId,
)
: null);
if (existingAfterConflict) return existingAfterConflict; if (existingAfterConflict) return existingAfterConflict;
throw new Error("Failed to create or resolve existing job by URL"); throw new Error("Failed to create or resolve existing job by URL");
} }
const ownerProfileId = resolveOwnerForCreate(inputOrInputs[0] ?? {});
const { existingCanonicalSet, existingSourceJobKeySet } = const { existingCanonicalSet, existingSourceJobKeySet } =
await loadJobDedupIndexes(); await loadJobDedupIndexes(ownerProfileId);
const batchBuckets = new Map< const batchBuckets = new Map<
string, string,
@ -359,9 +449,13 @@ export async function createJobs(
>(); >();
for (const raw of inputOrInputs) { for (const raw of inputOrInputs) {
const normalized = normalizeCreateJobInputForDedup(raw); const normalized = normalizeCreateJobInputForDedup({
const batchKey = normalized.sourceJobId?.trim() ...raw,
? `sid:${sourceJobKey(normalized.source, normalized.sourceJobId!)}` ownerProfileId,
});
const sidForKey = normalized.sourceJobId?.trim();
const batchKey = sidForKey
? `sid:${sourceJobKey(normalized.source, sidForKey)}`
: `url:${normalized.jobUrl}`; : `url:${normalized.jobUrl}`;
const prev = batchBuckets.get(batchKey); const prev = batchBuckets.get(batchKey);
if (prev) { if (prev) {
@ -418,6 +512,8 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
export async function updateJob( export async function updateJob(
id: string, id: string,
input: UpdateJobInput, input: UpdateJobInput,
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<Job | null> { ): Promise<Job | null> {
const now = new Date().toISOString(); const now = new Date().toISOString();
@ -431,21 +527,25 @@ export async function updateJob(
? { appliedAt: now } ? { 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. * 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 const result = await db
.select({ .select({
status: jobs.status, status: jobs.status,
count: sql<number>`count(*)`, count: sql<number>`count(*)`,
}) })
.from(jobs) .from(jobs)
.where(eq(jobs.ownerProfileId, ownerProfileId))
.groupBy(jobs.status); .groupBy(jobs.status);
const stats: Record<JobStatus, number> = { 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). * 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 const rows = await db
.select() .select()
.from(jobs) .from(jobs)
.where( .where(
and( and(
eq(jobs.ownerProfileId, ownerProfileId),
eq(jobs.status, "discovered"), eq(jobs.status, "discovered"),
sql`${jobs.jobDescription} IS NOT NULL`, sql`${jobs.jobDescription} IS NOT NULL`,
), ),
@ -489,11 +594,19 @@ export async function getJobsForProcessing(limit: number = 10): Promise<Job[]> {
*/ */
export async function getUnscoredDiscoveredJobs( export async function getUnscoredDiscoveredJobs(
limit?: number, limit?: number,
ownerProfileId: string = getJobOwnerProfileId() ??
DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise<Job[]> { ): Promise<Job[]> {
const query = db const query = db
.select() .select()
.from(jobs) .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)); .orderBy(desc(jobs.discoveredAt));
const rows = const rows =
@ -504,19 +617,33 @@ export async function getUnscoredDiscoveredJobs(
/** /**
* Delete jobs by status. * Delete jobs by status.
*/ */
export async function deleteJobsByStatus(status: JobStatus): Promise<number> { export async function deleteJobsByStatus(
const result = await db.delete(jobs).where(eq(jobs.status, status)).run(); 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; return result.changes;
} }
/** /**
* Delete jobs with suitability score below threshold (excluding applied and in_progress jobs). * 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 const result = await db
.delete(jobs) .delete(jobs)
.where( .where(
and( and(
eq(jobs.ownerProfileId, ownerProfileId),
lt(jobs.suitabilityScore, threshold), lt(jobs.suitabilityScore, threshold),
ne(jobs.status, "applied"), ne(jobs.status, "applied"),
ne(jobs.status, "in_progress"), ne(jobs.status, "in_progress"),
@ -530,6 +657,7 @@ export async function deleteJobsBelowScore(threshold: number): Promise<number> {
function mapRowToJob(row: typeof jobs.$inferSelect): Job { function mapRowToJob(row: typeof jobs.$inferSelect): Job {
return { return {
id: row.id, id: row.id,
ownerProfileId: row.ownerProfileId ?? DEFAULT_JOB_OWNER_PROFILE_ID,
source: row.source as Job["source"], source: row.source as Job["source"],
sourceJobId: row.sourceJobId ?? null, sourceJobId: row.sourceJobId ?? null,
jobUrlDirect: row.jobUrlDirect ?? null, jobUrlDirect: row.jobUrlDirect ?? null,

View File

@ -42,6 +42,18 @@ export async function listProfiles(): Promise<SearchProfile[]> {
return rows.map(mapRow); 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( export async function getProfileById(
id: string, id: string,
): Promise<SearchProfile | null> { ): Promise<SearchProfile | null> {
@ -87,6 +99,18 @@ export async function updateProfile(
return getProfileById(id); 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> { export async function deleteProfile(id: string): Promise<boolean> {
const result = await db const result = await db
.delete(searchProfiles) .delete(searchProfiles)

View File

@ -1,4 +1,6 @@
import { logger } from "@infra/logger"; 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 pipeline from "@server/pipeline/index";
import * as jobsRepo from "@server/repositories/jobs"; import * as jobsRepo from "@server/repositories/jobs";
import * as pipelineRepo from "@server/repositories/pipeline"; import * as pipelineRepo from "@server/repositories/pipeline";
@ -42,7 +44,10 @@ function samplePdfPath(job: Job): string {
} }
async function ensureJob(jobId: string): Promise<Job> { 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"); if (!job) throw new Error("Job not found");
return job; return job;
} }
@ -56,6 +61,7 @@ export async function simulatePipelineRun(
const isoNow = now.toISOString(); const isoNow = now.toISOString();
const jobUrl = `https://demo.job-ops.local/jobs/${run.id}`; const jobUrl = `https://demo.job-ops.local/jobs/${run.id}`;
await jobsRepo.createJob({ await jobsRepo.createJob({
ownerProfileId: getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID,
source: source as JobSource, source: source as JobSource,
title: "Demo Software Engineer", title: "Demo Software Engineer",
employer: "Demo Systems Ltd", employer: "Demo Systems Ltd",
@ -89,16 +95,20 @@ export async function simulateSummarizeJob(
_options?: ProcessOptions, _options?: ProcessOptions,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const job = await ensureJob(jobId); const job = await ensureJob(jobId);
await jobsRepo.updateJob(job.id, { await jobsRepo.updateJob(
tailoredSummary: makeDemoSummary(job), job.id,
tailoredHeadline: `Demo Tailored Resume - ${job.title}`, {
tailoredSkills: JSON.stringify([ tailoredSummary: makeDemoSummary(job),
"TypeScript", tailoredHeadline: `Demo Tailored Resume - ${job.title}`,
"System Design", tailoredSkills: JSON.stringify([
"Communication", "TypeScript",
]), "System Design",
selectedProjectIds: ensureProjectIds(job), "Communication",
}); ]),
selectedProjectIds: ensureProjectIds(job),
},
job.ownerProfileId,
);
return { success: true }; return { success: true };
} }
@ -106,10 +116,14 @@ export async function simulateGeneratePdf(
jobId: string, jobId: string,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const job = await ensureJob(jobId); const job = await ensureJob(jobId);
await jobsRepo.updateJob(job.id, { await jobsRepo.updateJob(
status: "ready", job.id,
pdfPath: samplePdfPath(job), {
}); status: "ready",
pdfPath: samplePdfPath(job),
},
job.ownerProfileId,
);
return { success: true }; return { success: true };
} }
@ -125,10 +139,14 @@ export async function simulateProcessJob(
export async function simulateRescoreJob(jobId: string): Promise<Job> { export async function simulateRescoreJob(jobId: string): Promise<Job> {
const job = await ensureJob(jobId); const job = await ensureJob(jobId);
const score = scoreFromJob(job); const score = scoreFromJob(job);
const updated = await jobsRepo.updateJob(job.id, { const updated = await jobsRepo.updateJob(
suitabilityScore: score, job.id,
suitabilityReason: makeDemoReason(job, score), {
}); suitabilityScore: score,
suitabilityReason: makeDemoReason(job, score),
},
job.ownerProfileId,
);
if (!updated) throw new Error("Job not found"); if (!updated) throw new Error("Job not found");
return updated; return updated;
} }
@ -148,10 +166,14 @@ export async function simulateApplyJob(jobId: string): Promise<Job> {
null, null,
); );
const updated = await jobsRepo.updateJob(job.id, { const updated = await jobsRepo.updateJob(
status: "applied", job.id,
appliedAt: appliedAtDate.toISOString(), {
}); status: "applied",
appliedAt: appliedAtDate.toISOString(),
},
job.ownerProfileId,
);
if (!updated) throw new Error("Job not found"); if (!updated) throw new Error("Job not found");
return updated; return updated;
} }

View File

@ -1,3 +1,4 @@
import { isBasicAuthEnabled } from "@server/infra/basic-auth-credentials";
import type { SettingKey } from "@server/repositories/settings"; import type { SettingKey } from "@server/repositories/settings";
import * as settingsRepo from "@server/repositories/settings"; import * as settingsRepo from "@server/repositories/settings";
import { settingsRegistry } from "@shared/settings-registry"; import { settingsRegistry } from "@shared/settings-registry";
@ -79,12 +80,7 @@ export async function getEnvSettingsData(
} }
} }
const basicAuthUser = values.basicAuthActive = isBasicAuthEnabled();
activeOverrides.basicAuthUser ?? process.env.BASIC_AUTH_USER;
const basicAuthPassword =
activeOverrides.basicAuthPassword ?? process.env.BASIC_AUTH_PASSWORD;
values.basicAuthActive = Boolean(basicAuthUser && basicAuthPassword);
return values; return values;
} }

View File

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
getRequestId: vi.fn(), getRequestId: vi.fn(),
getJobOwnerProfileId: vi.fn(),
buildJobChatPromptContext: vi.fn(), buildJobChatPromptContext: vi.fn(),
llmCallJson: vi.fn(), llmCallJson: vi.fn(),
repo: { repo: {
@ -40,6 +41,7 @@ vi.mock("@infra/logger", () => ({
vi.mock("@infra/request-context", () => ({ vi.mock("@infra/request-context", () => ({
getRequestId: mocks.getRequestId, getRequestId: mocks.getRequestId,
getJobOwnerProfileId: mocks.getJobOwnerProfileId,
})); }));
vi.mock("./ghostwriter-context", () => ({ vi.mock("./ghostwriter-context", () => ({
@ -134,6 +136,7 @@ describe("ghostwriter service", () => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.getRequestId.mockReturnValue("req-123"); mocks.getRequestId.mockReturnValue("req-123");
mocks.getJobOwnerProfileId.mockReturnValue(undefined);
mocks.settings.getAllSettings.mockResolvedValue({}); mocks.settings.getAllSettings.mockResolvedValue({});
mocks.buildJobChatPromptContext.mockResolvedValue({ mocks.buildJobChatPromptContext.mockResolvedValue({
job: { id: "job-1" }, job: { id: "job-1" },

View File

@ -1,6 +1,9 @@
import { readFile, stat } from "node:fs/promises"; import { readFile, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { logger } from "@infra/logger"; 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 { getSetting } from "@server/repositories/settings";
import type { ResumeProfile } from "@shared/types"; import type { ResumeProfile } from "@shared/types";
import { getResume, RxResumeAuthConfigError } from "./rxresume"; import { getResume, RxResumeAuthConfigError } from "./rxresume";
@ -19,7 +22,28 @@ let cachedLocalProfile: ResumeProfile | null = null;
*/ */
export async function resolveLocalResumeFilePath(): Promise<string | null> { export async function resolveLocalResumeFilePath(): Promise<string | null> {
const envPath = process.env.JOBOPS_LOCAL_RESUME_PATH?.trim(); 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; if (!raw) return null;
return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw); return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
} }

View File

@ -1,7 +1,11 @@
import { logger } from "@infra/logger"; 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 * as settingsRepo from "@server/repositories/settings";
import { import {
getDefaultModelForProvider, getDefaultModelForProvider,
jobSearchProfileSchema,
settingsRegistry, settingsRegistry,
} from "@shared/settings-registry"; } from "@shared/settings-registry";
import type { AppSettings } from "@shared/types"; import type { AppSettings } from "@shared/types";
@ -128,6 +132,21 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const envSettings = await getEnvSettingsData(overrides); 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> = { const result: Partial<AppSettings> = {
...envSettings, ...envSettings,
}; };
@ -150,6 +169,10 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
let override = def.parse(rawOverride); let override = def.parse(rawOverride);
let defaultValue = def.default(); let defaultValue = def.default();
if (key === "jobSearchProfile" && tenantJobSearchProfile) {
override = tenantJobSearchProfile;
}
if (key === "model") { if (key === "model") {
defaultValue = resolvedModelDefault; defaultValue = resolvedModelDefault;
override = overrideModel; override = overrideModel;

View File

@ -13,6 +13,8 @@ JOBOPS_URL="http://127.0.0.1:3005"
# Example (matches typical JobSpy bundle + UK sources): # Example (matches typical JobSpy bundle + UK sources):
# JOBBER_PIPELINE_SOURCES=gradcracker,indeed,linkedin,glassdoor,ukvisajobs # 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_USER=""
# BASIC_AUTH_PASSWORD="" # 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), industriesToTarget: z.array(z.string().trim().min(1).max(200)).max(20),
industriesToAvoid: 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), 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({ export const resumeProjectsSchema = z.object({

View File

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

View File

@ -192,6 +192,9 @@ export interface Job {
appliedAt: string | null; appliedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** Search profile id that owns this job (multi-tenant / Basic Auth). */
ownerProfileId: string;
} }
export type JobListItem = Pick< export type JobListItem = Pick<
@ -223,6 +226,8 @@ export type JobListItem = Pick<
>; >;
export interface CreateJobInput { export interface CreateJobInput {
/** When omitted, server uses the current request / pipeline owner. */
ownerProfileId?: string;
source: JobSource; source: JobSource;
title: string; title: string;
employer: string; employer: string;

View File

@ -6,6 +6,8 @@ export interface PipelineConfig {
minSuitabilityScore: number; // Minimum score to auto-process minSuitabilityScore: number; // Minimum score to auto-process
sources: ExtractorSourceId[]; // Job sources to crawl sources: ExtractorSourceId[]; // Job sources to crawl
outputDir: string; // Directory for generated PDFs outputDir: string; // Directory for generated PDFs
/** Search profile that owns discovered/processed jobs (Basic Auth multi-tenant). */
ownerProfileId?: string;
enableCrawling?: boolean; enableCrawling?: boolean;
enableScoring?: boolean; enableScoring?: boolean;
enableImporting?: boolean; enableImporting?: boolean;

View File

@ -10,6 +10,10 @@ export interface JobSearchProfile {
industriesToTarget: string[]; industriesToTarget: string[];
industriesToAvoid: string[]; industriesToAvoid: string[];
aboutMe: 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 { export interface SearchProfile {