Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6387b87c2 | ||
|
|
12e021dca0 | ||
|
|
972f0c8278 | ||
|
|
23416a8273 | ||
|
|
2c930ba40f | ||
|
|
529f0a7b02 |
@@ -1,6 +1,15 @@
|
||||
# Homelab bootstrap — gitleaks allowlist (tests, examples, placeholders)
|
||||
#
|
||||
# IMPORTANT: `useDefault = true` is required — without it gitleaks loads ONLY
|
||||
# this file (title + allowlist) with ZERO detection rules, so it would never
|
||||
# flag a real secret. Fixed 2026-07 (security-hardening track); if you're
|
||||
# re-pushing this template to a repo that already had the old version, that
|
||||
# repo's secret scanning was a no-op until this lands.
|
||||
title = "homelab gitea bootstrap"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Test fixtures and example configs are not production secrets"
|
||||
paths = [
|
||||
@@ -11,9 +20,13 @@ paths = [
|
||||
'''(?i).*\.example\.(yml|yaml|env|json|toml)$''',
|
||||
'''(?i).*vault\.example\.(yml|yaml)$''',
|
||||
'''(?i).*\.env\.example$''',
|
||||
'''(?i)docs-site/docs/extractors/.*''',
|
||||
'''(?i)extractors/golangjobs/.*''',
|
||||
'''(?i)orchestrator/src/server/infra/product-analytics\.ts$''',
|
||||
]
|
||||
regexes = [
|
||||
'''(?i)(invalid|fake|dummy|placeholder|example|changeme|change_me|not-a-real)''',
|
||||
'''(?i)sk-or-invalid''',
|
||||
'''(?i)msk-or-invalid''',
|
||||
'''(?i)YOUR_API_KEY''',
|
||||
]
|
||||
|
||||
@@ -71,6 +71,7 @@ Defaults and constraints:
|
||||
- Every card carries Google's own `data-share-url` permalink for that specific listing, which the extractor uses as `jobUrl` — so a job stays unique and reviewable even if the "click into the card for a detail pane" step below fails for it.
|
||||
- Because resolving a real application link requires clicking each card in a real browser, this extractor is slower per job than API-backed sources — keep `googleJobsMaxJobsPerTerm` modest for frequent runs.
|
||||
- If no external "Apply on <site>" link is found for a card, the extractor falls back to Google's own permalink for that listing so it's still reviewable, but not directly one-click-applyable.
|
||||
- After scraping, JobOps probes each apply URL (preferring the external link over Google's share permalink). Listings that return **404/410** or pages that say the job expired / is no longer available are **dropped before import**. Network failures fail-open (job is kept).
|
||||
- Automated, frequent, or high-volume scraping of Google search results may be against Google's Terms of Service; treat this extractor as best-effort and keep run frequency/volume conservative.
|
||||
- See [Dealing with Google's CAPTCHA / bot detection](#dealing-with-googles-captcha--bot-detection) above for proxy and interactive-solve options.
|
||||
|
||||
|
||||
@@ -53,13 +53,23 @@ Use **Filters → Employer keywords → Hide roles you already skipped** to togg
|
||||
|
||||
Uncheck the filter to temporarily review rediscovered rows that match older skips.
|
||||
|
||||
### Clean up rediscovered rows already in Discovered
|
||||
|
||||
If older pipeline runs left Discovered copies of roles you already skipped or applied:
|
||||
|
||||
1. Open **Settings → Danger Zone**.
|
||||
2. Use **Clear Rediscovered Archive Matches**.
|
||||
3. Confirm. JobOps permanently deletes matching **Discovered** rows only (Ready / Applied / Skipped stay).
|
||||
|
||||
Matching uses the same employer+title (and employer+description) keys as skip/import dedup.
|
||||
|
||||
## Defaults and constraints
|
||||
|
||||
- Prior-skip hiding uses employer + title only (not description text).
|
||||
- The 90-day window applies to both the Jobs list filter and pipeline import suppression for skipped/applied rows.
|
||||
- Description matching during import still requires at least **80 characters** of normalized text; short or empty descriptions fall back to employer+title only.
|
||||
- Matching is **per profile** (`ownerProfileId`); different login profiles do not share dedup state.
|
||||
- Dedup does **not** delete existing rows retroactively when you change skip list or country filters — run discovery again or skip manually for old data.
|
||||
- Dedup does **not** delete existing rows retroactively when you change skip list or country filters — use **Clear Rediscovered Archive Matches** in Danger Zone, run discovery again, or skip manually for old data.
|
||||
- Very different titles at the same company (for example `SDET` vs `Product Designer`) are **not** collapsed.
|
||||
|
||||
## Common problems
|
||||
|
||||
@@ -186,6 +186,8 @@ Readiness requires:
|
||||

|
||||
|
||||
- Clear jobs by selected statuses
|
||||
- Clear rediscovered archive matches (Discovered jobs that match skipped/applied roles)
|
||||
- Mark expired listings (probe Discovered URLs; mark 404/410 / “job expired” as expired)
|
||||
- Clear jobs below a score threshold
|
||||
- Clear the full database
|
||||
|
||||
|
||||
@@ -146,7 +146,21 @@ export async function runGoogleJobs(
|
||||
jobs.push(mapped);
|
||||
}
|
||||
|
||||
return { success: true, jobs };
|
||||
const { filterExpiredJobListings } = await import(
|
||||
"@shared/job-listing-probe.js"
|
||||
);
|
||||
const { kept, dropped } = await filterExpiredJobListings(jobs, {
|
||||
concurrency: 4,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
if (dropped > 0) {
|
||||
console.info(
|
||||
`[google-jobs] Dropped ${dropped} expired listing(s) after URL probe`,
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true, jobs: kept };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, jobs: [], error: message };
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time installer for the local pre-commit gitleaks hook.
|
||||
# Run once per clone: bash scripts/git-hooks/install.sh
|
||||
#
|
||||
# Respects `core.hooksPath` if you've set one (local or global) — some setups
|
||||
# point git at a hooks dir outside `.git/hooks/` (e.g. a machine-wide
|
||||
# `~/.git-hooks/`), and installing to `.git/hooks/` in that case would be a
|
||||
# silent no-op. If an existing hook is already at that path, this chains to
|
||||
# it so nothing already relying on it breaks.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
HOOKS_DIR="$(git config --get core.hooksPath || true)"
|
||||
if [ -z "$HOOKS_DIR" ]; then
|
||||
HOOKS_DIR=".git/hooks"
|
||||
elif [[ "$HOOKS_DIR" != /* ]]; then
|
||||
HOOKS_DIR="$REPO_ROOT/$HOOKS_DIR"
|
||||
fi
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
|
||||
TARGET="$HOOKS_DIR/pre-commit"
|
||||
if [ -f "$TARGET" ] && ! grep -q "gitleaks" "$TARGET" 2>/dev/null; then
|
||||
echo "⚠️ Existing pre-commit hook found at $TARGET that isn't ours — chaining instead of overwriting."
|
||||
CHAINED="$HOOKS_DIR/pre-commit.d-gitleaks"
|
||||
cp scripts/git-hooks/pre-commit "$CHAINED"
|
||||
chmod +x "$CHAINED"
|
||||
if ! grep -q "pre-commit.d-gitleaks" "$TARGET" 2>/dev/null; then
|
||||
printf '\n# Added by levkinops ansible repo (scripts/git-hooks/install.sh)\n"%s"\n' "$CHAINED" >> "$TARGET"
|
||||
fi
|
||||
else
|
||||
cp scripts/git-hooks/pre-commit "$TARGET"
|
||||
chmod +x "$TARGET"
|
||||
fi
|
||||
|
||||
echo "✓ Installed pre-commit gitleaks hook → $TARGET"
|
||||
if [ "$HOOKS_DIR" != ".git/hooks" ] && [ "$HOOKS_DIR" != "$REPO_ROOT/.git/hooks" ]; then
|
||||
echo " (using core.hooksPath=$HOOKS_DIR — applies to every repo that shares this hooksPath)"
|
||||
fi
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo " Note: gitleaks isn't installed locally yet. Install it for the hook to actually run:"
|
||||
echo " macOS: brew install gitleaks"
|
||||
echo " Linux: see https://github.com/gitleaks/gitleaks#installing"
|
||||
echo " Until then this hook is a no-op locally (CI still scans every push)."
|
||||
fi
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local pre-commit secret scan — mirrors the `secret-scanning` CI job (gitleaks)
|
||||
# so leaked secrets are caught before they ever leave your machine, not just at
|
||||
# CI time. Installed via `make install-git-hooks`.
|
||||
#
|
||||
# Uses the same .gitleaks.toml allowlist as CI. Only scans staged content.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "⚠️ gitleaks not installed locally — skipping local secret scan."
|
||||
echo " Install: brew install gitleaks (CI will still catch it either way)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🔐 Running gitleaks on staged changes..."
|
||||
if ! gitleaks protect --staged --config .gitleaks.toml --no-banner --redact; then
|
||||
echo ""
|
||||
echo "❌ gitleaks found a potential secret in your staged changes."
|
||||
echo " Fix it, or if it's a false positive, add an allowlist entry to .gitleaks.toml."
|
||||
echo " Bypass (not recommended): git commit --no-verify"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ gitleaks: no secrets found in staged changes"
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./extractors";
|
||||
export * from "./job-fingerprint";
|
||||
export * from "./job-listing-probe";
|
||||
export * from "./job-url-canonical";
|
||||
export * from "./keyword-match";
|
||||
export * from "./location-support";
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterExpiredJobListings,
|
||||
isGoogleJobsHost,
|
||||
listingLooksExpired,
|
||||
probeJobListingUrl,
|
||||
resolveJobListingProbeUrl,
|
||||
} from "./job-listing-probe.js";
|
||||
|
||||
describe("resolveJobListingProbeUrl", () => {
|
||||
it("prefers non-Google application links", () => {
|
||||
expect(
|
||||
resolveJobListingProbeUrl({
|
||||
jobUrl: "https://www.google.com/search?q=jobs",
|
||||
applicationLink: "https://bebee.com/ca/jobs/example",
|
||||
}),
|
||||
).toBe("https://bebee.com/ca/jobs/example");
|
||||
});
|
||||
|
||||
it("falls back to jobUrl when apply link is Google", () => {
|
||||
expect(
|
||||
resolveJobListingProbeUrl({
|
||||
jobUrl: "https://boards.greenhouse.io/acme/jobs/1",
|
||||
applicationLink: "https://www.google.com/search?q=jobs",
|
||||
}),
|
||||
).toBe("https://boards.greenhouse.io/acme/jobs/1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGoogleJobsHost", () => {
|
||||
it("detects google hosts", () => {
|
||||
expect(isGoogleJobsHost("https://www.google.com/search?q=x")).toBe(true);
|
||||
expect(isGoogleJobsHost("https://www.google.ca/search?q=x")).toBe(true);
|
||||
expect(isGoogleJobsHost("https://bebee.com/jobs/1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listingLooksExpired", () => {
|
||||
it("treats 410 and expired body text as expired", () => {
|
||||
expect(
|
||||
listingLooksExpired({
|
||||
status: 410,
|
||||
finalUrl: "https://bebee.com/jobs/1",
|
||||
body: "Job expired\nThis job listing is no longer available.",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps healthy 200 pages", () => {
|
||||
expect(
|
||||
listingLooksExpired({
|
||||
status: 200,
|
||||
finalUrl: "https://example.com/jobs/1",
|
||||
body: "<html><body>Apply now for Software Engineer</body></html>",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeJobListingUrl", () => {
|
||||
it("marks 410 responses expired", async () => {
|
||||
const fetchImpl = async () =>
|
||||
({
|
||||
url: "https://bebee.com/jobs/1",
|
||||
status: 410,
|
||||
text: async () => "Job expired",
|
||||
}) as Response;
|
||||
|
||||
const result = await probeJobListingUrl("https://bebee.com/jobs/1", {
|
||||
fetchImpl,
|
||||
});
|
||||
expect(result?.expired).toBe(true);
|
||||
expect(result?.status).toBe(410);
|
||||
});
|
||||
|
||||
it("returns null on network failure (fail-open)", async () => {
|
||||
const fetchImpl = async () => {
|
||||
throw new Error("network down");
|
||||
};
|
||||
const result = await probeJobListingUrl("https://example.com/jobs/1", {
|
||||
fetchImpl,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterExpiredJobListings", () => {
|
||||
it("drops expired listings and keeps the rest", async () => {
|
||||
const fetchImpl = async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("dead")) {
|
||||
return {
|
||||
url,
|
||||
status: 410,
|
||||
text: async () => "Job expired",
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
status: 200,
|
||||
text: async () => "Apply now",
|
||||
} as Response;
|
||||
};
|
||||
|
||||
const { kept, dropped } = await filterExpiredJobListings(
|
||||
[
|
||||
{
|
||||
jobUrl: "https://example.com/alive",
|
||||
applicationLink: "https://example.com/alive",
|
||||
},
|
||||
{
|
||||
jobUrl: "https://example.com/dead",
|
||||
applicationLink: "https://example.com/dead",
|
||||
},
|
||||
],
|
||||
{ fetchImpl, concurrency: 2 },
|
||||
);
|
||||
|
||||
expect(dropped).toBe(1);
|
||||
expect(kept).toHaveLength(1);
|
||||
expect(kept[0]?.jobUrl).toContain("alive");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Lightweight job listing URL health check.
|
||||
*
|
||||
* Fail-open on network/timeout/5xx so flaky sites do not drop good jobs.
|
||||
* Hard-drop on 404/410 and common "expired / no longer available" page text.
|
||||
*/
|
||||
|
||||
export type JobListingProbeResult = {
|
||||
expired: boolean;
|
||||
status: number | null;
|
||||
finalUrl: string;
|
||||
};
|
||||
|
||||
const EXPIRED_URL_RE =
|
||||
/expired_jd_redirect|unavailable|no[_-]longer[_-]available|job[_-]expired|listing[_-]closed/i;
|
||||
|
||||
const EXPIRED_BODY_RE =
|
||||
/\bjob expired\b|\bthis job has expired\b|\bthis job listing is no longer available\b|\bno longer accepting applications\b|\bjob you were looking for is no longer available\b|\bthis job is no longer available\b|\bposition has been filled\b|\bjob posting is closed\b|\bthis posting has expired\b/i;
|
||||
|
||||
const GOOGLE_HOST_RE = /(?:^|\.)google\.[a-z.]+$/i;
|
||||
|
||||
export const DEFAULT_JOB_LISTING_PROBE_TIMEOUT_MS = 5_000;
|
||||
|
||||
export function isGoogleJobsHost(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
return GOOGLE_HOST_RE.test(host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the real apply URL over Google's share permalink (share pages often
|
||||
* stay 200 after the underlying listing dies).
|
||||
*/
|
||||
export function resolveJobListingProbeUrl(args: {
|
||||
jobUrl?: string | null;
|
||||
applicationLink?: string | null;
|
||||
}): string | null {
|
||||
const apply = args.applicationLink?.trim() || "";
|
||||
const jobUrl = args.jobUrl?.trim() || "";
|
||||
|
||||
if (apply && !isGoogleJobsHost(apply)) return apply;
|
||||
if (jobUrl && !isGoogleJobsHost(jobUrl)) return jobUrl;
|
||||
if (apply) return apply;
|
||||
return jobUrl || null;
|
||||
}
|
||||
|
||||
export function listingLooksExpired(args: {
|
||||
status: number;
|
||||
finalUrl: string;
|
||||
body: string;
|
||||
}): boolean {
|
||||
if (args.status === 404 || args.status === 410 || args.status === 451) {
|
||||
return true;
|
||||
}
|
||||
if (EXPIRED_URL_RE.test(args.finalUrl)) return true;
|
||||
// Only scan a prefix — expiry banners are near the top.
|
||||
const sample = args.body.slice(0, 12_000);
|
||||
return EXPIRED_BODY_RE.test(sample);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetchImpl(url, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml",
|
||||
"User-Agent": "JobOps/1.0 (+job-listing-probe)",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a listing URL. Returns `null` on transport failure (caller should keep).
|
||||
*/
|
||||
export async function probeJobListingUrl(
|
||||
url: string,
|
||||
options: {
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
} = {},
|
||||
): Promise<JobListingProbeResult | null> {
|
||||
const trimmed = url?.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_JOB_LISTING_PROBE_TIMEOUT_MS;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(trimmed, timeoutMs, fetchImpl);
|
||||
const finalUrl = response.url || trimmed;
|
||||
// 5xx: fail-open
|
||||
if (response.status >= 500) {
|
||||
return { expired: false, status: response.status, finalUrl };
|
||||
}
|
||||
const body = await response.text();
|
||||
const expired = listingLooksExpired({
|
||||
status: response.status,
|
||||
finalUrl,
|
||||
body,
|
||||
});
|
||||
return { expired, status: response.status, finalUrl };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function filterExpiredJobListings<
|
||||
T extends { jobUrl?: string | null; applicationLink?: string | null },
|
||||
>(
|
||||
jobs: readonly T[],
|
||||
options: {
|
||||
concurrency?: number;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
shouldStop?: () => boolean;
|
||||
} = {},
|
||||
): Promise<{ kept: T[]; dropped: number }> {
|
||||
if (jobs.length === 0) return { kept: [], dropped: 0 };
|
||||
|
||||
const concurrency = Math.max(
|
||||
1,
|
||||
Math.min(6, Math.floor(options.concurrency ?? 4)),
|
||||
);
|
||||
const keptFlags = Array.from({ length: jobs.length }, () => true);
|
||||
let nextIndex = 0;
|
||||
let dropped = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
if (options.shouldStop?.()) return;
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (index >= jobs.length) return;
|
||||
|
||||
const job = jobs[index];
|
||||
const probeUrl = resolveJobListingProbeUrl(job);
|
||||
if (!probeUrl) continue;
|
||||
|
||||
const probe = await probeJobListingUrl(probeUrl, {
|
||||
timeoutMs: options.timeoutMs,
|
||||
fetchImpl: options.fetchImpl,
|
||||
});
|
||||
if (probe?.expired) {
|
||||
keptFlags[index] = false;
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, jobs.length) }, () => worker()),
|
||||
);
|
||||
|
||||
return {
|
||||
kept: jobs.filter((_, index) => keptFlags[index]),
|
||||
dropped,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user