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:
parent
e39341258a
commit
b72612fd06
@ -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=
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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}`, {
|
||||||
|
|||||||
78
orchestrator/src/client/components/BasicAuthAppGate.tsx
Normal file
78
orchestrator/src/client/components/BasicAuthAppGate.tsx
Normal 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 → "Sign out /
|
||||||
|
switch user" to log in as someone else. Job lists are separate
|
||||||
|
per login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@ -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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
38
orchestrator/src/client/hooks/useBasicAuthNavSession.ts
Normal file
38
orchestrator/src/client/hooks/useBasicAuthNavSession.ts
Normal 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;
|
||||||
|
}
|
||||||
38
orchestrator/src/client/lib/manual-import-samples.ts
Normal file
38
orchestrator/src/client/lib/manual-import-samples.ts
Normal 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.
|
||||||
|
`;
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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: {
|
||||||
|
|||||||
@ -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[] = [
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
25
orchestrator/src/server/api/routes/auth.ts
Normal file
25
orchestrator/src/server/api/routes/auth.ts
Normal 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 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
@ -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) => {
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -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}`,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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...");
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
56
orchestrator/src/server/infra/basic-auth-credentials.ts
Normal file
56
orchestrator/src/server/infra/basic-auth-credentials.ts
Normal 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;
|
||||||
|
}
|
||||||
80
orchestrator/src/server/infra/job-owner-context.ts
Normal file
80
orchestrator/src/server/infra/job-owner-context.ts
Normal 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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" },
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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=""
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user