Slim product to Jobs+Settings and drop expired/rediscovered noise.
Hide unused nav surfaces, probe listing URLs before import, add Danger Zone cleanup for archive matches and dead links, and stop rebuilding jobs on every migrate boot.
This commit is contained in:
@@ -9,7 +9,6 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script defer src="https://umami.dakheera47.com/script.js" data-website-id="0dc42ed1-87c3-4ac0-9409-5a9b9588fe66"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("App demo banner", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/overview"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/discovered"]}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@@ -114,7 +114,7 @@ describe("App demo banner", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/overview"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/discovered"]}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@@ -135,7 +135,7 @@ describe("App demo banner", () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/overview"]}>
|
||||
<MemoryRouter initialEntries={["/jobs/discovered"]}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -14,29 +14,31 @@ import { BasicAuthPrompt } from "./components/BasicAuthPrompt";
|
||||
import { OnboardingGate } from "./components/OnboardingGate";
|
||||
import { useDemoInfo } from "./hooks/useDemoInfo";
|
||||
import { GmailOauthCallbackPage } from "./pages/GmailOauthCallbackPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { InProgressBoardPage } from "./pages/InProgressBoardPage";
|
||||
import { JobPage } from "./pages/JobPage";
|
||||
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { TracerLinksPage } from "./pages/TracerLinksPage";
|
||||
import { TrackingInboxPage } from "./pages/TrackingInboxPage";
|
||||
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
||||
|
||||
/** Backwards-compatibility redirects: old URL paths -> new URL paths */
|
||||
const REDIRECTS: Array<{ from: string; to: string }> = [
|
||||
{ from: "/", to: "/jobs/ready" },
|
||||
{ from: "/home", to: "/overview" },
|
||||
{ from: "/", to: "/jobs/discovered" },
|
||||
{ from: "/home", to: "/jobs/discovered" },
|
||||
{ from: "/overview", to: "/jobs/discovered" },
|
||||
{ from: "/ready", to: "/jobs/ready" },
|
||||
{ from: "/ready/:jobId", to: "/jobs/ready/:jobId" },
|
||||
{ from: "/discovered", to: "/jobs/discovered" },
|
||||
{ from: "/discovered/:jobId", to: "/jobs/discovered/:jobId" },
|
||||
{ from: "/applied", to: "/jobs/applied" },
|
||||
{ from: "/applied/:jobId", to: "/jobs/applied/:jobId" },
|
||||
{ from: "/in-progress", to: "/applications/in-progress" },
|
||||
{ from: "/in-progress/:jobId", to: "/applications/in-progress" },
|
||||
{ from: "/jobs/in_progress", to: "/applications/in-progress" },
|
||||
{ from: "/jobs/in_progress/:jobId", to: "/applications/in-progress" },
|
||||
// Slim product: kanban / inbox / tracer / visa browser redirect into Jobs.
|
||||
{ from: "/in-progress", to: "/jobs/discovered" },
|
||||
{ from: "/in-progress/:jobId", to: "/jobs/discovered" },
|
||||
{ from: "/jobs/in_progress", to: "/jobs/discovered" },
|
||||
{ from: "/jobs/in_progress/:jobId", to: "/jobs/discovered" },
|
||||
{ from: "/applications/in-progress", to: "/jobs/discovered" },
|
||||
{ from: "/applications/in-progress/:jobId", to: "/jobs/discovered" },
|
||||
{ from: "/tracking-inbox", to: "/jobs/discovered" },
|
||||
{ from: "/tracer-links", to: "/jobs/discovered" },
|
||||
{ from: "/visa-sponsors", to: "/jobs/discovered" },
|
||||
{ from: "/all", to: "/jobs/all" },
|
||||
{ from: "/all/:jobId", to: "/jobs/all/:jobId" },
|
||||
];
|
||||
@@ -134,24 +136,13 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Application routes */}
|
||||
<Route path="/overview" element={<HomePage />} />
|
||||
{/* Application routes — slim product: Jobs + Settings */}
|
||||
<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"
|
||||
|
||||
@@ -1618,6 +1618,30 @@ export async function deleteJobsByStatus(status: string): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDiscoveredMatchingArchived(): Promise<{
|
||||
message: string;
|
||||
count: number;
|
||||
}> {
|
||||
return fetchApi<{
|
||||
message: string;
|
||||
count: number;
|
||||
}>("/jobs/duplicates/discovered", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function expireDeadListings(): Promise<{
|
||||
message: string;
|
||||
count: number;
|
||||
}> {
|
||||
return fetchApi<{
|
||||
message: string;
|
||||
count: number;
|
||||
}>("/jobs/expire-dead-listings", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteJobsBelowScore(threshold: number): Promise<{
|
||||
message: string;
|
||||
count: number;
|
||||
|
||||
@@ -51,7 +51,6 @@ import { useRescoreJob } from "../hooks/useRescoreJob";
|
||||
import { FitAssessment, JobHeader, JobNotes, TailoredSummary } from ".";
|
||||
import { CoverLetterDisplay } from "./CoverLetterDisplay";
|
||||
import { TailorMode } from "./discovered-panel/TailorMode";
|
||||
import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer";
|
||||
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
||||
import { KbdHint } from "./KbdHint";
|
||||
import { OpenJobListingButton } from "./OpenJobListingButton";
|
||||
@@ -380,11 +379,6 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
───────────────────────────────────────────────────────────────────── */}
|
||||
<div className="pb-4 border-b border-border/40">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<GhostwriterDrawer
|
||||
job={job}
|
||||
triggerClassName="h-9 w-full justify-center gap-1 px-2 text-xs"
|
||||
/>
|
||||
|
||||
{/* Download PDF - primary artifact action */}
|
||||
<Button
|
||||
asChild
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import {
|
||||
Columns3,
|
||||
Home,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { LayoutDashboard, Settings } from "lucide-react";
|
||||
|
||||
export type NavLink = {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: typeof Home;
|
||||
icon: typeof LayoutDashboard;
|
||||
activePaths?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Slim product nav: pipeline + scoring only.
|
||||
* Overview / kanban / inbox / tracer / visa browser stay routable for
|
||||
* bookmarks but are intentionally omitted from the chrome.
|
||||
*/
|
||||
export const NAV_LINKS: NavLink[] = [
|
||||
{ to: "/overview", label: "Overview", icon: Home },
|
||||
{
|
||||
to: "/jobs/ready",
|
||||
to: "/jobs/discovered",
|
||||
label: "Jobs",
|
||||
icon: LayoutDashboard,
|
||||
activePaths: [
|
||||
@@ -28,20 +24,6 @@ export const NAV_LINKS: NavLink[] = [
|
||||
"/jobs/all",
|
||||
],
|
||||
},
|
||||
{
|
||||
to: "/applications/in-progress",
|
||||
label: "In Progress",
|
||||
icon: Columns3,
|
||||
activePaths: ["/applications/in-progress"],
|
||||
},
|
||||
{ to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox },
|
||||
{
|
||||
to: "/tracer-links",
|
||||
label: "Tracer Links",
|
||||
icon: Link2,
|
||||
activePaths: ["/tracer-links"],
|
||||
},
|
||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
} from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
import { ConfirmDelete } from "../components/ConfirmDelete";
|
||||
import { GhostwriterDrawer } from "../components/ghostwriter/GhostwriterDrawer";
|
||||
import { JobDetailsEditDrawer } from "../components/JobDetailsEditDrawer";
|
||||
import { JobHeader } from "../components/JobHeader";
|
||||
import {
|
||||
@@ -587,7 +586,6 @@ export const JobPage: React.FC = () => {
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
Application details
|
||||
</CardTitle>
|
||||
<GhostwriterDrawer job={job} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -75,7 +75,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
if (tab && validTabs.includes(tab as FilterTab)) {
|
||||
return tab as FilterTab;
|
||||
}
|
||||
return "ready";
|
||||
return "discovered";
|
||||
}, [tab]);
|
||||
|
||||
// Helper to change URL while preserving search params
|
||||
@@ -96,7 +96,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
// Effect to sync URL if it was invalid
|
||||
useEffect(() => {
|
||||
if (tab === "in_progress") {
|
||||
navigate("/applications/in-progress", { replace: true });
|
||||
navigate("/jobs/discovered", { replace: true });
|
||||
return;
|
||||
}
|
||||
const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"];
|
||||
|
||||
@@ -2,9 +2,8 @@ import { createAppSettings } from "@shared/testing/factories.js";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
|
||||
@@ -19,10 +18,6 @@ vi.mock("../api", () => ({
|
||||
updateSettings: vi.fn(),
|
||||
clearDatabase: vi.fn(),
|
||||
deleteJobsByStatus: vi.fn(),
|
||||
getTracerReadiness: vi.fn(),
|
||||
getBackups: vi.fn().mockResolvedValue({ backups: [], nextScheduled: null }),
|
||||
createManualBackup: vi.fn(),
|
||||
deleteBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@@ -65,13 +60,6 @@ const openModelSection = async () => {
|
||||
fireEvent.click(modelTrigger);
|
||||
};
|
||||
|
||||
const openWritingStyleSection = async () => {
|
||||
const chatTrigger = await screen.findByRole("button", {
|
||||
name: /writing style & language/i,
|
||||
});
|
||||
fireEvent.click(chatTrigger);
|
||||
};
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -79,16 +67,6 @@ describe("SettingsPage", () => {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
_resetTracerReadinessCache();
|
||||
vi.mocked(api.getTracerReadiness).mockResolvedValue({
|
||||
status: "ready",
|
||||
canEnable: true,
|
||||
publicBaseUrl: "https://my-jobops.example.com",
|
||||
healthUrl: "https://my-jobops.example.com/health",
|
||||
checkedAt: Date.now(),
|
||||
lastSuccessAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -248,7 +226,7 @@ describe("SettingsPage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("hides pipeline tuning sections that moved to run modal", async () => {
|
||||
it("hides pipeline tuning and unused settings sections", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
renderPage();
|
||||
|
||||
@@ -265,57 +243,21 @@ describe("SettingsPage", () => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /jobspy scraper/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables save button when display setting is changed", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
renderPage();
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||
|
||||
const displayTrigger = await screen.findByRole("button", {
|
||||
name: /display settings/i,
|
||||
});
|
||||
fireEvent.click(displayTrigger);
|
||||
const sponsorCheckbox = screen.getByLabelText(
|
||||
/show visa sponsor information/i,
|
||||
);
|
||||
fireEvent.click(sponsorCheckbox);
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
});
|
||||
|
||||
it("saves the writing language mode through the settings page", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(api.updateSettings).mockResolvedValue(
|
||||
createAppSettings({
|
||||
chatStyleLanguageMode: {
|
||||
value: "match-resume",
|
||||
default: "manual",
|
||||
override: "match-resume",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
await openWritingStyleSection();
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: /output language/i }));
|
||||
fireEvent.click(await screen.findByText("Match current resume language"));
|
||||
|
||||
expect(
|
||||
screen.queryByRole("combobox", { name: /specific language/i }),
|
||||
screen.queryByRole("button", { name: /display settings/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /writing style & language/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /backup/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /tracer/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /resume projects/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatStyleLanguageMode: "match-resume",
|
||||
chatStyleManualLanguage: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("enables save button when basic auth toggle is changed", async () => {
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import * as api from "@client/api";
|
||||
import { PageHeader } from "@client/components/layout";
|
||||
import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation";
|
||||
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
|
||||
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
||||
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
|
||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
||||
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
|
||||
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
|
||||
import { JobSearchProfileSection } from "@client/pages/settings/components/JobSearchProfileSection";
|
||||
import { JobSourcesSettingsSection } from "@client/pages/settings/components/JobSourcesSettingsSection";
|
||||
import { KeywordSetsSettingsSection } from "@client/pages/settings/components/KeywordSetsSettingsSection";
|
||||
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
|
||||
import { ResumeSettingsSection } from "@client/pages/settings/components/ResumeSettingsSection";
|
||||
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
|
||||
import { TracerLinksSettingsSection } from "@client/pages/settings/components/TracerLinksSettingsSection";
|
||||
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
|
||||
import {
|
||||
type LlmProviderId,
|
||||
@@ -27,22 +21,12 @@ import {
|
||||
type UpdateSettingsInput,
|
||||
updateSettingsSchema,
|
||||
} from "@shared/settings-schema.js";
|
||||
import type {
|
||||
AppSettings,
|
||||
JobStatus,
|
||||
ResumeProjectCatalogItem,
|
||||
ResumeProjectsSettings,
|
||||
} from "@shared/types.js";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AppSettings, JobStatus } from "@shared/types.js";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Settings } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
FormProvider,
|
||||
type Resolver,
|
||||
useForm,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||
import { queryKeys } from "@/client/lib/queryKeys";
|
||||
@@ -236,43 +220,7 @@ const stringArraysEqual = (left: string[], right: string[]): boolean => {
|
||||
const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
|
||||
value === defaultValue ? null : (value ?? null);
|
||||
|
||||
const normalizeResumeProjectsForCatalog = (
|
||||
catalog: ResumeProjectCatalogItem[],
|
||||
current: ResumeProjectsSettings | null,
|
||||
): ResumeProjectsSettings | null => {
|
||||
const allowed = new Set(catalog.map((project) => project.id));
|
||||
|
||||
const base = current ?? {
|
||||
maxProjects: 0,
|
||||
lockedProjectIds: catalog
|
||||
.filter((project) => project.isVisibleInBase)
|
||||
.map((project) => project.id),
|
||||
aiSelectableProjectIds: [],
|
||||
};
|
||||
|
||||
const lockedProjectIds = base.lockedProjectIds.filter((id) =>
|
||||
allowed.has(id),
|
||||
);
|
||||
const lockedSet = new Set(lockedProjectIds);
|
||||
const aiSelectableProjectIds = (
|
||||
current ? base.aiSelectableProjectIds : catalog.map((project) => project.id)
|
||||
)
|
||||
.filter((id) => allowed.has(id))
|
||||
.filter((id) => !lockedSet.has(id));
|
||||
const maxProjectsRaw = Number.isFinite(base.maxProjects)
|
||||
? base.maxProjects
|
||||
: 0;
|
||||
const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw));
|
||||
const maxProjects = Math.min(
|
||||
catalog.length,
|
||||
Math.max(lockedProjectIds.length, maxProjectsInt, 3),
|
||||
);
|
||||
return { maxProjects, lockedProjectIds, aiSelectableProjectIds };
|
||||
};
|
||||
|
||||
const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
const profileProjects = settings?.profileProjects ?? [];
|
||||
|
||||
return {
|
||||
model: {
|
||||
effective: settings?.model?.value ?? "",
|
||||
@@ -302,32 +250,6 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
default: settings?.renderMarkdownInJobDescriptions?.default ?? true,
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
tone: {
|
||||
effective: settings?.chatStyleTone?.value ?? "professional",
|
||||
default: settings?.chatStyleTone?.default ?? "professional",
|
||||
},
|
||||
formality: {
|
||||
effective: settings?.chatStyleFormality?.value ?? "medium",
|
||||
default: settings?.chatStyleFormality?.default ?? "medium",
|
||||
},
|
||||
constraints: {
|
||||
effective: settings?.chatStyleConstraints?.value ?? "",
|
||||
default: settings?.chatStyleConstraints?.default ?? "",
|
||||
},
|
||||
doNotUse: {
|
||||
effective: settings?.chatStyleDoNotUse?.value ?? "",
|
||||
default: settings?.chatStyleDoNotUse?.default ?? "",
|
||||
},
|
||||
languageMode: {
|
||||
effective: settings?.chatStyleLanguageMode?.value ?? "manual",
|
||||
default: settings?.chatStyleLanguageMode?.default ?? "manual",
|
||||
},
|
||||
manualLanguage: {
|
||||
effective: settings?.chatStyleManualLanguage?.value ?? "english",
|
||||
default: settings?.chatStyleManualLanguage?.default ?? "english",
|
||||
},
|
||||
},
|
||||
envSettings: {
|
||||
readable: {
|
||||
ukvisajobsEmail: settings?.ukvisajobsEmail ?? "",
|
||||
@@ -343,10 +265,6 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
basicAuthActive: settings?.basicAuthActive ?? false,
|
||||
},
|
||||
defaultResumeProjects: settings?.resumeProjects?.default ?? null,
|
||||
|
||||
profileProjects,
|
||||
maxProjectsTotal: profileProjects.length,
|
||||
|
||||
backup: {
|
||||
backupEnabled: {
|
||||
effective: settings?.backupEnabled?.value ?? false,
|
||||
@@ -441,21 +359,11 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
};
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
|
||||
"discovered",
|
||||
]);
|
||||
// Backup state
|
||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
||||
const [isDeletingBackup, setIsDeletingBackup] = useState(false);
|
||||
const {
|
||||
readiness: tracerReadiness,
|
||||
isLoading: isTracerReadinessLoading,
|
||||
isChecking: isTracerReadinessChecking,
|
||||
refreshReadiness,
|
||||
} = useTracerReadiness();
|
||||
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
resolver: zodResolver(
|
||||
@@ -466,33 +374,17 @@ export const SettingsPage: React.FC = () => {
|
||||
});
|
||||
|
||||
const {
|
||||
clearErrors,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
setValue,
|
||||
control,
|
||||
formState: { isDirty, errors, isValid, dirtyFields },
|
||||
} = methods;
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: queryKeys.settings.current(),
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
const backupsQuery = useQuery({
|
||||
queryKey: queryKeys.backups.list(),
|
||||
queryFn: api.getBackups,
|
||||
});
|
||||
const updateSettingsMutation = useUpdateSettingsMutation();
|
||||
const isLoading = settingsQuery.isLoading;
|
||||
const backups = backupsQuery.data?.backups ?? [];
|
||||
const nextScheduled = backupsQuery.data?.nextScheduled ?? null;
|
||||
const isLoadingBackups = backupsQuery.isLoading;
|
||||
useQueryErrorToast(backupsQuery.error, "Failed to load backups");
|
||||
|
||||
const resumeProjectsValue = useWatch({
|
||||
control,
|
||||
name: "resumeProjects",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsQuery.data) return;
|
||||
@@ -508,77 +400,14 @@ export const SettingsPage: React.FC = () => {
|
||||
pipelineWebhook,
|
||||
jobCompleteWebhook,
|
||||
display,
|
||||
chat,
|
||||
envSettings,
|
||||
defaultResumeProjects,
|
||||
profileProjects,
|
||||
backup,
|
||||
scoring,
|
||||
jobSearchProfile,
|
||||
jobSources,
|
||||
} = derived;
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
setIsCreatingBackup(true);
|
||||
try {
|
||||
await api.createManualBackup();
|
||||
toast.success("Backup created successfully");
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.backups.all });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to create backup";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsCreatingBackup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBackup = async (filename: string) => {
|
||||
const confirmed = window.confirm(
|
||||
`Delete backup "${filename}"? This action cannot be undone.`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setIsDeletingBackup(true);
|
||||
try {
|
||||
await api.deleteBackup(filename);
|
||||
toast.success("Backup deleted successfully");
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.backups.all });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to delete backup";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsDeletingBackup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyTracerReadiness = useCallback(async () => {
|
||||
try {
|
||||
const readiness = await refreshReadiness(true);
|
||||
if (!readiness) {
|
||||
toast.error("Tracer links are unavailable. Verify your public URL.");
|
||||
} else if (readiness.canEnable) {
|
||||
toast.success("Tracer links are ready");
|
||||
} else {
|
||||
toast.error(
|
||||
readiness.reason ??
|
||||
"Tracer links are unavailable. Verify your public URL.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to verify tracer-link readiness";
|
||||
toast.error(message);
|
||||
}
|
||||
}, [refreshReadiness]);
|
||||
|
||||
const effectiveMaxProjectsTotal = profileProjects.length;
|
||||
const lockedCount = resumeProjectsValue?.lockedProjectIds.length ?? 0;
|
||||
|
||||
const canSave = isDirty && isValid;
|
||||
|
||||
const onSave = async (data: UpdateSettingsInput) => {
|
||||
@@ -959,6 +788,57 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearDiscoveredMatchingArchived = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const result = await api.deleteDiscoveredMatchingArchived();
|
||||
|
||||
if (result.count > 0) {
|
||||
toast.success("Rediscovered matches cleared", {
|
||||
description: `Deleted ${result.count} discovered job${result.count === 1 ? "" : "s"} matching skipped or applied roles.`,
|
||||
});
|
||||
} else {
|
||||
toast.info("No matches found", {
|
||||
description:
|
||||
"No discovered jobs matched skipped or applied archive roles.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to clear rediscovered archive matches";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpireDeadListings = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const result = await api.expireDeadListings();
|
||||
|
||||
if (result.count > 0) {
|
||||
toast.success("Expired listings marked", {
|
||||
description: `Marked ${result.count} discovered job${result.count === 1 ? "" : "s"} as expired.`,
|
||||
});
|
||||
} else {
|
||||
toast.info("No expired listings found", {
|
||||
description: "All probed discovered job links still look live.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to mark expired listings";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatusToClear = (status: JobStatus) => {
|
||||
setStatusesToClear((prev) =>
|
||||
prev.includes(status)
|
||||
@@ -999,36 +879,8 @@ export const SettingsPage: React.FC = () => {
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<WebhooksSection
|
||||
pipelineWebhook={pipelineWebhook}
|
||||
jobCompleteWebhook={jobCompleteWebhook}
|
||||
webhookSecretHint={envSettings.private.webhookSecretHint}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ResumeSettingsSection
|
||||
profileProjects={profileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={effectiveMaxProjectsTotal}
|
||||
localResumeFileConfigured={Boolean(
|
||||
settings?.localResumeFileConfigured,
|
||||
)}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<TracerLinksSettingsSection
|
||||
readiness={tracerReadiness}
|
||||
isLoading={isLoading || isTracerReadinessLoading}
|
||||
isChecking={isTracerReadinessChecking}
|
||||
onVerifyNow={handleVerifyTracerReadiness}
|
||||
/>
|
||||
<DisplaySettingsSection
|
||||
values={display}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ChatSettingsSection
|
||||
values={chat}
|
||||
<ScoringSettingsSection
|
||||
values={scoring}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
@@ -1047,8 +899,10 @@ export const SettingsPage: React.FC = () => {
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ScoringSettingsSection
|
||||
values={scoring}
|
||||
<WebhooksSection
|
||||
pipelineWebhook={pipelineWebhook}
|
||||
jobCompleteWebhook={jobCompleteWebhook}
|
||||
webhookSecretHint={envSettings.private.webhookSecretHint}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
@@ -1057,23 +911,16 @@ export const SettingsPage: React.FC = () => {
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<BackupSettingsSection
|
||||
values={backup}
|
||||
backups={backups}
|
||||
nextScheduled={nextScheduled}
|
||||
isLoading={isLoading || isLoadingBackups}
|
||||
isSaving={isSaving}
|
||||
onCreateBackup={handleCreateBackup}
|
||||
onDeleteBackup={handleDeleteBackup}
|
||||
isCreatingBackup={isCreatingBackup}
|
||||
isDeletingBackup={isDeletingBackup}
|
||||
/>
|
||||
<DangerZoneSection
|
||||
statusesToClear={statusesToClear}
|
||||
toggleStatusToClear={toggleStatusToClear}
|
||||
handleClearByStatuses={handleClearByStatuses}
|
||||
handleClearDatabase={handleClearDatabase}
|
||||
handleClearByScore={handleClearByScore}
|
||||
handleClearDiscoveredMatchingArchived={
|
||||
handleClearDiscoveredMatchingArchived
|
||||
}
|
||||
handleExpireDeadListings={handleExpireDeadListings}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
@@ -14,11 +14,15 @@ export const sponsorshipSignalFilterOptions = [
|
||||
...SPONSORSHIP_SIGNAL_IDS,
|
||||
] as const;
|
||||
|
||||
/** Defaults aligned with the live cron set on jobs.levkin.ca. */
|
||||
export const DEFAULT_PIPELINE_SOURCES: JobSource[] = [
|
||||
"gradcracker",
|
||||
"indeed",
|
||||
"linkedin",
|
||||
"ukvisajobs",
|
||||
"indeed",
|
||||
"glassdoor",
|
||||
"qajobsboard",
|
||||
"arcdev",
|
||||
"eluta",
|
||||
"bctenet",
|
||||
];
|
||||
export const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ type DangerZoneSectionProps = {
|
||||
handleClearByStatuses: () => void;
|
||||
handleClearDatabase: () => void;
|
||||
handleClearByScore?: (threshold: number) => void;
|
||||
handleClearDiscoveredMatchingArchived?: () => void;
|
||||
handleExpireDeadListings?: () => void;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
};
|
||||
@@ -44,6 +46,8 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
||||
handleClearByStatuses,
|
||||
handleClearDatabase,
|
||||
handleClearByScore,
|
||||
handleClearDiscoveredMatchingArchived,
|
||||
handleExpireDeadListings,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
@@ -154,6 +158,106 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
||||
|
||||
<Separator />
|
||||
|
||||
{handleClearDiscoveredMatchingArchived && (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-semibold text-destructive">
|
||||
Clear Rediscovered Archive Matches
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Delete Discovered jobs that match roles you already skipped or
|
||||
applied (same employer + title, including cross-source
|
||||
reposts).
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isLoading || isSaving}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Matches
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Clear rediscovered archive matches?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This permanently deletes Discovered jobs that match
|
||||
skipped or applied roles. Ready, applied, and skipped jobs
|
||||
are not deleted. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleClearDiscoveredMatchingArchived}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Clear matches
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{handleExpireDeadListings && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-semibold text-destructive">
|
||||
Mark Expired Listings
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Open each Discovered job URL. If the page is gone (404/410
|
||||
or “job expired”), mark it expired so it leaves the queue.
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isLoading || isSaving}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Mark Expired
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Mark expired listings?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This probes Discovered job links and marks dead ones as
|
||||
expired. Network failures are ignored (jobs are kept).
|
||||
This can take a few minutes for large queues.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleExpireDeadListings}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Mark expired
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Clear Jobs Below Score */}
|
||||
{handleClearByScore && (
|
||||
<div className="p-3 rounded-md space-y-4">
|
||||
|
||||
@@ -860,6 +860,122 @@ describe.sequential("Jobs API routes", () => {
|
||||
expect(remainingJobIds).toContain(appliedLowScoreJob.id); // Applied job preserved
|
||||
});
|
||||
|
||||
it("deletes discovered jobs that match skipped or applied archive roles", async () => {
|
||||
const { createJob, updateJob } = await import("@server/repositories/jobs");
|
||||
const { buildJobContentFingerprint } = await import(
|
||||
"@shared/job-fingerprint"
|
||||
);
|
||||
const { db, schema } = await import("@server/db/index");
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
|
||||
const skipped = await createJob({
|
||||
source: "manual",
|
||||
title: "SDET (Remote)",
|
||||
employer: "Acme Inc.",
|
||||
jobUrl: "https://example.com/job/archive-skipped",
|
||||
jobDescription: "Test description for skipped archive role",
|
||||
});
|
||||
await updateJob(skipped.id, { status: "skipped" });
|
||||
|
||||
const applied = await createJob({
|
||||
source: "manual",
|
||||
title: "Platform Engineer",
|
||||
employer: "Beta Corp",
|
||||
jobUrl: "https://example.com/job/archive-applied",
|
||||
jobDescription: "Test description for applied archive role",
|
||||
});
|
||||
await updateJob(applied.id, { status: "applied" });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const rediscoveredSkippedId = randomUUID();
|
||||
const rediscoveredAppliedId = randomUUID();
|
||||
const readyMatchId = randomUUID();
|
||||
|
||||
// Insert rediscovered rows directly so createJob dedup does not collapse them.
|
||||
await db.insert(schema.jobs).values([
|
||||
{
|
||||
id: rediscoveredSkippedId,
|
||||
ownerProfileId: skipped.ownerProfileId,
|
||||
source: "linkedin",
|
||||
title: "SDET",
|
||||
employer: "Acme",
|
||||
jobUrl: "https://example.com/job/rediscovered-skipped",
|
||||
jobDescription: "Different board copy of the skipped role",
|
||||
contentFingerprint: buildJobContentFingerprint({
|
||||
employer: "Acme",
|
||||
title: "SDET",
|
||||
}),
|
||||
status: "discovered",
|
||||
discoveredAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: rediscoveredAppliedId,
|
||||
ownerProfileId: applied.ownerProfileId,
|
||||
source: "indeed",
|
||||
title: "Platform Engineer - Toronto, ON",
|
||||
employer: "Beta Corporation",
|
||||
jobUrl: "https://example.com/job/rediscovered-applied",
|
||||
jobDescription: "Different board copy of the applied role",
|
||||
contentFingerprint: buildJobContentFingerprint({
|
||||
employer: "Beta Corporation",
|
||||
title: "Platform Engineer - Toronto, ON",
|
||||
}),
|
||||
status: "discovered",
|
||||
discoveredAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: readyMatchId,
|
||||
ownerProfileId: skipped.ownerProfileId,
|
||||
source: "manual",
|
||||
title: "SDET",
|
||||
employer: "Acme",
|
||||
jobUrl: "https://example.com/job/ready-match",
|
||||
jobDescription: "Ready sibling should not be deleted",
|
||||
contentFingerprint: buildJobContentFingerprint({
|
||||
employer: "Acme",
|
||||
title: "SDET",
|
||||
}),
|
||||
status: "ready",
|
||||
discoveredAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
const uniqueDiscovered = await createJob({
|
||||
source: "manual",
|
||||
title: "Unrelated Designer",
|
||||
employer: "Gamma",
|
||||
jobUrl: "https://example.com/job/unique-discovered",
|
||||
jobDescription: "Should remain after cleanup",
|
||||
});
|
||||
|
||||
const deleteRes = await fetch(`${baseUrl}/api/jobs/duplicates/discovered`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const deleteBody = await deleteRes.json();
|
||||
|
||||
expect(deleteRes.status).toBe(200);
|
||||
expect(deleteBody.ok).toBe(true);
|
||||
expect(deleteBody.data.count).toBe(2);
|
||||
expect(typeof deleteBody.meta.requestId).toBe("string");
|
||||
|
||||
const listRes = await fetch(`${baseUrl}/api/jobs`);
|
||||
const listBody = await listRes.json();
|
||||
const remainingIds = listBody.data.jobs.map((j: { id: string }) => j.id);
|
||||
|
||||
expect(remainingIds).not.toContain(rediscoveredSkippedId);
|
||||
expect(remainingIds).not.toContain(rediscoveredAppliedId);
|
||||
expect(remainingIds).toContain(skipped.id);
|
||||
expect(remainingIds).toContain(applied.id);
|
||||
expect(remainingIds).toContain(uniqueDiscovered.id);
|
||||
expect(remainingIds).toContain(readyMatchId);
|
||||
});
|
||||
|
||||
it("rejects invalid score thresholds", async () => {
|
||||
// Test invalid threshold (above 100)
|
||||
const invalidRes = await fetch(`${baseUrl}/api/jobs/score/150`, {
|
||||
|
||||
@@ -1447,6 +1447,109 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/jobs/duplicates/discovered - Remove discovered jobs that match
|
||||
* skipped/applied archive rows (same employer+title / description keys).
|
||||
*/
|
||||
jobsRouter.delete(
|
||||
"/duplicates/discovered",
|
||||
async (_req: Request, res: Response) => {
|
||||
const requestId = String(res.getHeader("x-request-id") || "unknown");
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
return sendDemoBlocked(
|
||||
res,
|
||||
"Cleaning discovered duplicates is disabled to keep the demo stable.",
|
||||
{ route: "DELETE /api/jobs/duplicates/discovered" },
|
||||
);
|
||||
}
|
||||
|
||||
const count = await jobsRepo.deleteDiscoveredJobsMatchingArchived();
|
||||
|
||||
logger.info("Discovered archive duplicates cleaned", {
|
||||
requestId,
|
||||
route: "DELETE /api/jobs/duplicates/discovered",
|
||||
count,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
data: {
|
||||
message: `Deleted ${count} discovered job${count === 1 ? "" : "s"} matching skipped or applied roles`,
|
||||
count,
|
||||
},
|
||||
meta: { requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("Failed to clean discovered archive duplicates", {
|
||||
requestId,
|
||||
route: "DELETE /api/jobs/duplicates/discovered",
|
||||
error: message,
|
||||
});
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message,
|
||||
},
|
||||
meta: { requestId },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/jobs/expire-dead-listings - Probe discovered URLs and mark expired.
|
||||
*/
|
||||
jobsRouter.post(
|
||||
"/expire-dead-listings",
|
||||
async (_req: Request, res: Response) => {
|
||||
const requestId = String(res.getHeader("x-request-id") || "unknown");
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
return sendDemoBlocked(
|
||||
res,
|
||||
"Expiring dead listings is disabled to keep the demo stable.",
|
||||
{ route: "POST /api/jobs/expire-dead-listings" },
|
||||
);
|
||||
}
|
||||
|
||||
const count = await jobsRepo.expireDiscoveredJobsWithDeadListings();
|
||||
|
||||
logger.info("Expired discovered jobs with dead listing URLs", {
|
||||
requestId,
|
||||
route: "POST /api/jobs/expire-dead-listings",
|
||||
count,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
data: {
|
||||
message: `Marked ${count} discovered job${count === 1 ? "" : "s"} as expired`,
|
||||
count,
|
||||
},
|
||||
meta: { requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("Failed to expire dead listings", {
|
||||
requestId,
|
||||
route: "POST /api/jobs/expire-dead-listings",
|
||||
error: message,
|
||||
});
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message,
|
||||
},
|
||||
meta: { requestId },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/jobs/status/:status - Clear jobs with a specific status
|
||||
*/
|
||||
|
||||
@@ -1146,7 +1146,50 @@ const migrations = [
|
||||
|
||||
console.log("🔧 Running database migrations...");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
const jobsReownerApplied = Boolean(
|
||||
(
|
||||
sqlite
|
||||
.prepare(`SELECT 1 AS ok FROM schema_migrations WHERE id = ?`)
|
||||
.get("jobs_reowner_v1") as { ok?: number } | undefined
|
||||
)?.ok,
|
||||
);
|
||||
|
||||
const jobsTableSql = (
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT sql AS sql FROM sqlite_master WHERE type = 'table' AND name = 'jobs'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined
|
||||
)?.sql;
|
||||
const jobsAlreadyReowned =
|
||||
jobsReownerApplied ||
|
||||
Boolean(jobsTableSql?.includes("UNIQUE(owner_profile_id, job_url)"));
|
||||
|
||||
const REOWNER_MIGRATION_MARKERS = [
|
||||
"DROP TABLE IF EXISTS jobs_reowner_v1",
|
||||
"CREATE TABLE jobs_reowner_v1 (",
|
||||
"INSERT OR REPLACE INTO jobs_reowner_v1 (",
|
||||
"ALTER TABLE jobs_reowner_v1 RENAME TO jobs",
|
||||
] as const;
|
||||
|
||||
for (const migration of migrations) {
|
||||
// One-time table rebuild: do not DROP/recreate jobs on every boot.
|
||||
if (
|
||||
jobsAlreadyReowned &&
|
||||
(REOWNER_MIGRATION_MARKERS.some((marker) => migration.includes(marker)) ||
|
||||
migration.trim() === "DROP TABLE jobs")
|
||||
) {
|
||||
console.log("↩️ Migration skipped (jobs already reowned)");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.exec(migration);
|
||||
console.log("✅ Migration applied");
|
||||
@@ -1197,5 +1240,22 @@ for (const migration of migrations) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!jobsReownerApplied) {
|
||||
const sqlAfter = (
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT sql AS sql FROM sqlite_master WHERE type = 'table' AND name = 'jobs'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined
|
||||
)?.sql;
|
||||
if (sqlAfter?.includes("UNIQUE(owner_profile_id, job_url)")) {
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, datetime('now'))`,
|
||||
)
|
||||
.run("jobs_reowner_v1");
|
||||
}
|
||||
}
|
||||
|
||||
sqlite.close();
|
||||
console.log("🎉 Database migrations complete!");
|
||||
|
||||
@@ -42,12 +42,21 @@ const DEFAULT_CONFIG: PipelineConfig = {
|
||||
topN: 10,
|
||||
minSuitabilityScore: 50,
|
||||
// Glassdoor runs inside JobSpy with Indeed/LinkedIn; upstream often errors without failing the run.
|
||||
sources: ["gradcracker", "indeed", "linkedin", "glassdoor", "ukvisajobs"],
|
||||
sources: [
|
||||
"linkedin",
|
||||
"indeed",
|
||||
"glassdoor",
|
||||
"qajobsboard",
|
||||
"arcdev",
|
||||
"eluta",
|
||||
"bctenet",
|
||||
],
|
||||
outputDir: join(getDataDir(), "pdfs"),
|
||||
enableCrawling: true,
|
||||
enableScoring: true,
|
||||
enableImporting: true,
|
||||
enableAutoTailoring: true,
|
||||
// Slim product: discover + score only (no PDF / RxResume tailoring).
|
||||
enableAutoTailoring: false,
|
||||
};
|
||||
|
||||
// Track if pipeline is currently running
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
jobMatchesBlockedCountries,
|
||||
resolveBlockedCountriesFromStoredString,
|
||||
} from "@shared/blocked-countries.js";
|
||||
import { filterExpiredJobListings } from "@shared/job-listing-probe.js";
|
||||
import {
|
||||
textMatchesAnyKeyword,
|
||||
textMatchesKeyword,
|
||||
@@ -742,9 +743,28 @@ export async function discoverJobsStep(args: {
|
||||
logger.warn("Some discovery sources failed", { sourceErrors });
|
||||
}
|
||||
|
||||
progressHelpers.crawlingComplete(profileFiltered.jobs.length);
|
||||
if (args.shouldCancel?.()) {
|
||||
return { discoveredJobs: profileFiltered.jobs, sourceErrors };
|
||||
}
|
||||
|
||||
const stamped = profileFiltered.jobs.map((job) => ({
|
||||
const { kept: liveJobs, dropped: expiredDropped } =
|
||||
await filterExpiredJobListings(profileFiltered.jobs, {
|
||||
concurrency: 4,
|
||||
timeoutMs: 5_000,
|
||||
shouldStop: args.shouldCancel,
|
||||
});
|
||||
|
||||
if (expiredDropped > 0) {
|
||||
logger.info("Dropped discovered jobs with expired listing URLs", {
|
||||
step: "discover-jobs",
|
||||
droppedCount: expiredDropped,
|
||||
keptCount: liveJobs.length,
|
||||
});
|
||||
}
|
||||
|
||||
progressHelpers.crawlingComplete(liveJobs.length);
|
||||
|
||||
const stamped = liveJobs.map((job) => ({
|
||||
...job,
|
||||
ownerProfileId,
|
||||
}));
|
||||
|
||||
@@ -67,81 +67,101 @@ export async function scoreJobsStep(args: {
|
||||
task: async (job) => {
|
||||
if (args.shouldCancel?.()) return;
|
||||
|
||||
const hasCachedScore =
|
||||
typeof job.suitabilityScore === "number" &&
|
||||
!Number.isNaN(job.suitabilityScore);
|
||||
try {
|
||||
const hasCachedScore =
|
||||
typeof job.suitabilityScore === "number" &&
|
||||
!Number.isNaN(job.suitabilityScore);
|
||||
|
||||
if (hasCachedScore) {
|
||||
if (job.sponsorshipSignals == null) {
|
||||
await jobsRepo.updateJob(job.id, buildSponsorshipSignalsUpdate(job));
|
||||
if (hasCachedScore) {
|
||||
if (job.sponsorshipSignals == null) {
|
||||
await jobsRepo.updateJob(
|
||||
job.id,
|
||||
buildSponsorshipSignalsUpdate(job),
|
||||
);
|
||||
}
|
||||
completed += 1;
|
||||
progressHelpers.scoringJob(
|
||||
completed,
|
||||
unprocessedJobs.length,
|
||||
`${job.title} (cached)`,
|
||||
);
|
||||
scoredJobs.push({
|
||||
...job,
|
||||
suitabilityScore: job.suitabilityScore as number,
|
||||
suitabilityReason: job.suitabilityReason ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { score, reason, analysis } = await scoreJobSuitability(
|
||||
job,
|
||||
args.profile,
|
||||
);
|
||||
if (args.shouldCancel?.()) return;
|
||||
|
||||
let sponsorMatchScore = 0;
|
||||
let sponsorMatchNames: string | undefined;
|
||||
|
||||
if (job.employer) {
|
||||
const sponsorResults = await visaSponsors.searchSponsors(
|
||||
job.employer,
|
||||
{
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
},
|
||||
);
|
||||
|
||||
const summary =
|
||||
visaSponsors.calculateSponsorMatchSummary(sponsorResults);
|
||||
sponsorMatchScore = summary.sponsorMatchScore;
|
||||
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
||||
}
|
||||
|
||||
const shouldAutoSkip =
|
||||
job.status !== "applied" &&
|
||||
autoSkipThreshold !== null &&
|
||||
!Number.isNaN(autoSkipThreshold) &&
|
||||
score < autoSkipThreshold;
|
||||
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
|
||||
sponsorMatchScore,
|
||||
sponsorMatchNames,
|
||||
...buildSponsorshipSignalsUpdate(job),
|
||||
...(shouldAutoSkip ? { status: "skipped" } : {}),
|
||||
});
|
||||
|
||||
if (shouldAutoSkip) {
|
||||
logger.info("Auto-skipped job due to low score", {
|
||||
jobId: job.id,
|
||||
title: job.title,
|
||||
score,
|
||||
threshold: autoSkipThreshold,
|
||||
});
|
||||
}
|
||||
|
||||
completed += 1;
|
||||
progressHelpers.scoringJob(
|
||||
completed,
|
||||
unprocessedJobs.length,
|
||||
`${job.title} (cached)`,
|
||||
job.title,
|
||||
);
|
||||
scoredJobs.push({
|
||||
...job,
|
||||
suitabilityScore: job.suitabilityScore as number,
|
||||
suitabilityReason: job.suitabilityReason ?? "",
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { score, reason, analysis } = await scoreJobSuitability(
|
||||
job,
|
||||
args.profile,
|
||||
);
|
||||
if (args.shouldCancel?.()) return;
|
||||
|
||||
let sponsorMatchScore = 0;
|
||||
let sponsorMatchNames: string | undefined;
|
||||
|
||||
if (job.employer) {
|
||||
const sponsorResults = await visaSponsors.searchSponsors(job.employer, {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
|
||||
const summary =
|
||||
visaSponsors.calculateSponsorMatchSummary(sponsorResults);
|
||||
sponsorMatchScore = summary.sponsorMatchScore;
|
||||
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
||||
}
|
||||
|
||||
const shouldAutoSkip =
|
||||
job.status !== "applied" &&
|
||||
autoSkipThreshold !== null &&
|
||||
!Number.isNaN(autoSkipThreshold) &&
|
||||
score < autoSkipThreshold;
|
||||
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
|
||||
sponsorMatchScore,
|
||||
sponsorMatchNames,
|
||||
...buildSponsorshipSignalsUpdate(job),
|
||||
...(shouldAutoSkip ? { status: "skipped" } : {}),
|
||||
});
|
||||
|
||||
if (shouldAutoSkip) {
|
||||
logger.info("Auto-skipped job due to low score", {
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown scoring error";
|
||||
logger.error("Scoring task failed; continuing with remaining jobs", {
|
||||
jobId: job.id,
|
||||
title: job.title,
|
||||
score,
|
||||
threshold: autoSkipThreshold,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
completed += 1;
|
||||
progressHelpers.scoringJob(completed, unprocessedJobs.length, job.title);
|
||||
scoredJobs.push({
|
||||
...job,
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
|
||||
import {
|
||||
buildJobContentFingerprint,
|
||||
collectJobDedupKeys,
|
||||
PRIOR_SKIP_DISMISS_LOOKBACK_MS,
|
||||
} from "@shared/job-fingerprint";
|
||||
import { canonicalizeJobUrl } from "@shared/job-url-canonical";
|
||||
import type {
|
||||
@@ -884,6 +885,180 @@ export async function deleteJobsByStatus(
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete discovered jobs that match skipped/applied ("archive") rows.
|
||||
*
|
||||
* Matching uses the same keys as import dismiss / auto-skip:
|
||||
* - employer+title content fingerprint against any skipped/applied row
|
||||
* - employer+title / employer+description keys for skips/applies in the
|
||||
* prior-skip lookback window
|
||||
*/
|
||||
export async function deleteDiscoveredJobsMatchingArchived(
|
||||
ownerProfileId: string = getJobOwnerProfileId() ??
|
||||
DEFAULT_JOB_OWNER_PROFILE_ID,
|
||||
): Promise<number> {
|
||||
const archivedRows = await db
|
||||
.select({
|
||||
id: jobs.id,
|
||||
employer: jobs.employer,
|
||||
title: jobs.title,
|
||||
jobDescription: jobs.jobDescription,
|
||||
contentFingerprint: jobs.contentFingerprint,
|
||||
updatedAt: jobs.updatedAt,
|
||||
})
|
||||
.from(jobs)
|
||||
.where(
|
||||
and(
|
||||
eq(jobs.ownerProfileId, ownerProfileId),
|
||||
inArray(jobs.status, ["skipped", "applied"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (archivedRows.length === 0) return 0;
|
||||
|
||||
const archivedFingerprints = new Set<string>();
|
||||
const archivedDedupKeys = new Set<string>();
|
||||
const dismissCutoffMs = Date.now() - PRIOR_SKIP_DISMISS_LOOKBACK_MS;
|
||||
|
||||
for (const row of archivedRows) {
|
||||
const fingerprint =
|
||||
row.contentFingerprint?.trim() ||
|
||||
buildJobContentFingerprint({
|
||||
employer: row.employer,
|
||||
title: row.title,
|
||||
});
|
||||
if (fingerprint) {
|
||||
archivedFingerprints.add(fingerprint);
|
||||
}
|
||||
|
||||
const updatedMs = Date.parse(row.updatedAt);
|
||||
if (Number.isFinite(updatedMs) && updatedMs >= dismissCutoffMs) {
|
||||
for (const key of collectJobDedupKeys({
|
||||
employer: row.employer,
|
||||
title: row.title,
|
||||
jobDescription: row.jobDescription,
|
||||
})) {
|
||||
archivedDedupKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (archivedFingerprints.size === 0 && archivedDedupKeys.size === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const discoveredRows = await db
|
||||
.select({
|
||||
id: jobs.id,
|
||||
employer: jobs.employer,
|
||||
title: jobs.title,
|
||||
jobDescription: jobs.jobDescription,
|
||||
contentFingerprint: jobs.contentFingerprint,
|
||||
})
|
||||
.from(jobs)
|
||||
.where(
|
||||
and(
|
||||
eq(jobs.ownerProfileId, ownerProfileId),
|
||||
eq(jobs.status, "discovered"),
|
||||
),
|
||||
);
|
||||
|
||||
const idsToDelete: string[] = [];
|
||||
for (const row of discoveredRows) {
|
||||
const fingerprint =
|
||||
row.contentFingerprint?.trim() ||
|
||||
buildJobContentFingerprint({
|
||||
employer: row.employer,
|
||||
title: row.title,
|
||||
});
|
||||
if (fingerprint && archivedFingerprints.has(fingerprint)) {
|
||||
idsToDelete.push(row.id);
|
||||
continue;
|
||||
}
|
||||
if (archivedDedupKeys.size === 0) continue;
|
||||
const keys = collectJobDedupKeys({
|
||||
employer: row.employer,
|
||||
title: row.title,
|
||||
jobDescription: row.jobDescription,
|
||||
});
|
||||
if (keys.some((key) => archivedDedupKeys.has(key))) {
|
||||
idsToDelete.push(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToDelete.length === 0) return 0;
|
||||
|
||||
const result = await db
|
||||
.delete(jobs)
|
||||
.where(
|
||||
and(
|
||||
eq(jobs.ownerProfileId, ownerProfileId),
|
||||
inArray(jobs.id, idsToDelete),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe discovered listing URLs and mark expired ones as `expired`.
|
||||
* Fail-open on network errors so flaky sites are not mass-expired.
|
||||
*/
|
||||
export async function expireDiscoveredJobsWithDeadListings(
|
||||
ownerProfileId: string = getJobOwnerProfileId() ??
|
||||
DEFAULT_JOB_OWNER_PROFILE_ID,
|
||||
options: {
|
||||
concurrency?: number;
|
||||
timeoutMs?: number;
|
||||
shouldStop?: () => boolean;
|
||||
} = {},
|
||||
): Promise<number> {
|
||||
const { filterExpiredJobListings } = await import(
|
||||
"@shared/job-listing-probe"
|
||||
);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: jobs.id,
|
||||
jobUrl: jobs.jobUrl,
|
||||
applicationLink: jobs.applicationLink,
|
||||
})
|
||||
.from(jobs)
|
||||
.where(
|
||||
and(
|
||||
eq(jobs.ownerProfileId, ownerProfileId),
|
||||
eq(jobs.status, "discovered"),
|
||||
),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
const { kept } = await filterExpiredJobListings(rows, {
|
||||
concurrency: options.concurrency ?? 4,
|
||||
timeoutMs: options.timeoutMs ?? 5_000,
|
||||
shouldStop: options.shouldStop,
|
||||
});
|
||||
const keptIds = new Set(kept.map((row) => row.id));
|
||||
const expiredIds = rows
|
||||
.filter((row) => !keptIds.has(row.id))
|
||||
.map((row) => row.id);
|
||||
|
||||
if (expiredIds.length === 0) return 0;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.update(jobs)
|
||||
.set({ status: "expired", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(jobs.ownerProfileId, ownerProfileId),
|
||||
inArray(jobs.id, expiredIds),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete jobs with suitability score below threshold (excluding applied and in_progress jobs).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user