diff --git a/src/app/(app)/settings/actions.db.test.ts b/src/app/(app)/settings/actions.db.test.ts index a0145fa7..198c2132 100644 --- a/src/app/(app)/settings/actions.db.test.ts +++ b/src/app/(app)/settings/actions.db.test.ts @@ -159,6 +159,7 @@ describe("job search settings (E14-4)", () => { jsearch: { host: "openwebninja", keyState: "none" }, adzuna: { appId: "", keyState: "none" }, arbeitsagentur: { offered: true, acceptedAt: null }, + blockedCompanies: [], }, }); await saveJobSource({ source: "OFF" }); diff --git a/src/app/(app)/settings/actions.ts b/src/app/(app)/settings/actions.ts index 4133cee4..7303f60c 100644 --- a/src/app/(app)/settings/actions.ts +++ b/src/app/(app)/settings/actions.ts @@ -171,6 +171,34 @@ export const testJobSource = action(z.object({}), async () => { } }); +/** The most companies Settings → Job search keeps blocked at once; past that, ask the user to trim the list rather than letting it grow without bound. */ +const MAX_BLOCKED_COMPANIES = 50; + +/** + * Adds a company to the blocklist (E14-8): no search shows, scores or drafts + * for it again. Case-insensitive against what's already there, so "Best Buy" + * twice stays one entry. + */ +export const blockCompany = action( + z.object({ name: z.string().trim().min(1, "Type a company name first.").max(200) }), + async ({ name }) => { + const { blockedCompanies } = await loadSettings(); + if (blockedCompanies.some((entry) => entry.toLocaleLowerCase() === name.toLocaleLowerCase())) return { blockedCompanies }; + if (blockedCompanies.length >= MAX_BLOCKED_COMPANIES) throw new ActionError(`You can block up to ${MAX_BLOCKED_COMPANIES} companies; remove one first.`); + const next = [...blockedCompanies, name]; + await saveSettings({ blockedCompanies: next }); + return { blockedCompanies: next }; + }, +); + +/** Removes a company from the blocklist. */ +export const unblockCompany = action(z.object({ name: z.string().min(1) }), async ({ name }) => { + const { blockedCompanies } = await loadSettings(); + const next = blockedCompanies.filter((entry) => entry !== name); + await saveSettings({ blockedCompanies: next }); + return { blockedCompanies: next }; +}); + /** * Saves the settings that are a plain choice or switch, each as it changes * (E10-2, E10-3). Only the fields given are touched. diff --git a/src/app/(app)/settings/job-search-section.test.tsx b/src/app/(app)/settings/job-search-section.test.tsx index d74b9681..195b5ee8 100644 --- a/src/app/(app)/settings/job-search-section.test.tsx +++ b/src/app/(app)/settings/job-search-section.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { JobSearchSettingsView } from "@/lib/job-search/sources"; -import { saveJobSearchDetails, saveJobSource, saveJobSourceKey, testJobSource } from "./actions"; +import { blockCompany, saveJobSearchDetails, saveJobSource, saveJobSourceKey, testJobSource, unblockCompany } from "./actions"; import { JobSearchSection } from "./job-search-section"; vi.mock("./actions", () => ({ @@ -12,6 +12,8 @@ vi.mock("./actions", () => ({ saveJobSearchDetails: vi.fn(), saveJobSourceKey: vi.fn(), testJobSource: vi.fn(), + blockCompany: vi.fn(), + unblockCompany: vi.fn(), })); vi.mock("@/components/toast", () => ({ toast: vi.fn(), toastResult: (result: unknown) => result })); @@ -20,6 +22,7 @@ const off: JobSearchSettingsView = { jsearch: { host: "openwebninja", keyState: "none" }, adzuna: { appId: "", keyState: "none" }, arbeitsagentur: { offered: true, acceptedAt: null }, + blockedCompanies: [], }; const ACCEPTED = "2026-10-23T09:00:00.000Z"; const withSource = (view: Partial): JobSearchSettingsView => ({ ...off, ...view }); @@ -32,6 +35,10 @@ beforeEach(() => { })); vi.mocked(saveJobSourceKey).mockReset().mockResolvedValue({ ok: true, data: withSource({ source: "JSEARCH", jsearch: { host: "openwebninja", keyState: "set" } }) }); vi.mocked(testJobSource).mockReset().mockResolvedValue({ ok: true, data: { connected: true, seconds: 0.8 } }); + vi.mocked(blockCompany) + .mockReset() + .mockImplementation(async ({ name }) => ({ ok: true, data: { blockedCompanies: [name] } })); + vi.mocked(unblockCompany).mockReset().mockResolvedValue({ ok: true, data: { blockedCompanies: [] } }); }); describe("JobSearchSection", () => { @@ -129,6 +136,17 @@ describe("JobSearchSection", () => { expect(screen.getAllByRole("radio").map((radio) => radio.getAttribute("aria-label"))).toEqual(["JSearch", "Adzuna", "Off"]); }); + it("blocks and unblocks a company, kept regardless of the chosen source", async () => { + render(); + await userEvent.type(screen.getByLabelText("Block a company"), "Best Buy{Enter}"); + expect(blockCompany).toHaveBeenCalledWith({ name: "Best Buy" }); + expect(await screen.findByText("Best Buy")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Unblock Best Buy" })); + expect(unblockCompany).toHaveBeenCalledWith({ name: "Best Buy" }); + await waitFor(() => expect(screen.queryByText("Best Buy")).not.toBeInTheDocument()); + }); + it("shows keys from the environment and keys that can't be read", () => { const { unmount } = render(); expect(screen.getByLabelText("API key")).toHaveAttribute("placeholder", "Using DOSSIER_JSEARCH_KEY · type one to save it here"); diff --git a/src/app/(app)/settings/job-search-section.tsx b/src/app/(app)/settings/job-search-section.tsx index 49d3f9a8..9fcdb714 100644 --- a/src/app/(app)/settings/job-search-section.tsx +++ b/src/app/(app)/settings/job-search-section.tsx @@ -1,20 +1,21 @@ "use client"; import { RadioGroup } from "radix-ui"; -import { useId, useState } from "react"; +import { useId, useState, type KeyboardEvent } from "react"; import { ArbeitsagenturNote } from "@/components/arbeitsagentur-note"; import { useTrackSave } from "@/components/save-tracker"; -import { toastResult } from "@/components/toast"; +import { toast, toastResult } from "@/components/toast"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { RemovableChip } from "@/components/ui/chip"; import { Input } from "@/components/ui/input"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { JOB_SOURCES, type JobSource, type JSearchHost } from "@/lib/job-search/search"; import { JOB_KEY_UNREADABLE, JSEARCH_KEY_URLS, SOURCE_INFO, type JobKeyState, type JobSearchSettingsView } from "@/lib/job-search/sources"; import { cn } from "@/lib/cn"; -import { saveJobSearchDetails, saveJobSource, saveJobSourceKey, testJobSource } from "./actions"; +import { blockCompany, saveJobSearchDetails, saveJobSource, saveJobSourceKey, testJobSource, unblockCompany } from "./actions"; import { SettingsSectionIntro } from "./settings-screen"; type Choice = JobSource | "OFF"; @@ -183,10 +184,97 @@ export function JobSearchSection({ initial }: { initial: JobSearchSettingsView } )} + + ); } +/** + * Blocked companies (E14-8): Find jobs, the job agent and the Claude + * connector never show, score or draft for a posting from one of these, by + * any source. Kept regardless of which source is chosen, or Off, so the list + * is ready before a source is. + */ +function BlockedCompaniesCard({ initial }: { initial: readonly string[] }) { + const track = useTrackSave(); + const [blocked, setBlocked] = useState(initial); + const [draft, setDraft] = useState(""); + + async function add() { + const name = draft.trim(); + if (!name) return; + if (blocked.some((entry) => entry.toLocaleLowerCase() === name.toLocaleLowerCase())) { + setDraft(""); + toast(`“${name}” is already blocked.`); + return; + } + setDraft(""); + const before = blocked; + setBlocked((current) => [...current, name]); + const result = await track(blockCompany({ name })); + if (!result.ok) { + setBlocked(before); + setDraft(name); + toast(result.error); + return; + } + setBlocked(result.data.blockedCompanies); + } + + async function remove(name: string) { + const before = blocked; + setBlocked((current) => current.filter((entry) => entry !== name)); + const result = await track(unblockCompany({ name })); + if (!result.ok) { + setBlocked(before); + toast(result.error); + return; + } + setBlocked(result.data.blockedCompanies); + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter") return; + event.preventDefault(); + void add(); + }; + + return ( + +
+

+ Blocked companies (optional) +

+ {blocked.length} blocked +
+

+ A past or current employer, or one you never want to apply to again. No search shows, scores or drafts for a posting that names one of these as + the company, or mentions it in the title or description — which also catches a staffing listing for a role placed there. +

+ {blocked.length > 0 && ( +
    + {blocked.map((name) => ( +
  • + void remove(name)}> + {name} + +
  • + ))} +
+ )} + setDraft(event.target.value)} + onKeyDown={onKeyDown} + enterKeyHint="done" + /> +
+ ); +} + function HostChoice({ host, onChange }: { host: JSearchHost; onChange: (host: JSearchHost) => void }) { const labelId = useId(); return ( diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index d466e749..2914b345 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/lib/env", () => ({ env: { DOSSIER_SECRET: "s".repeat(40), DOSSIER_PASSWORD: "argon2id:m=19456,t=2,p=1:c2FsdA:aGFzaA" } })); -const settings = vi.hoisted(() => ({ connectorEnabledAt: "2026-10-23T09:00:00.000Z" as string | null })); +const settings = vi.hoisted(() => ({ connectorEnabledAt: "2026-10-23T09:00:00.000Z" as string | null, blockedCompanies: [] as string[] })); vi.mock("@/lib/settings-data", () => ({ loadSettings: async () => settings })); const db = vi.hoisted(() => ({ postingSearch: { findUnique: vi.fn() }, postingScore: { upsert: vi.fn() } })); vi.mock("@/lib/prisma", () => ({ prisma: db })); diff --git a/src/lib/agent/run.db.test.ts b/src/lib/agent/run.db.test.ts index 8152986e..c4ea39bf 100644 --- a/src/lib/agent/run.db.test.ts +++ b/src/lib/agent/run.db.test.ts @@ -38,6 +38,8 @@ const fake = vi.hoisted(() => ({ ai: true, failOn: null as string | null, onScore: (() => {}) as (title: string) => void, + // Blocked companies (this fork, E14-8): empty unless a test sets it. + blockedCompanies: [] as string[], })); vi.mock("@/lib/job-search", () => ({ searchPostings: vi.fn(async () => { @@ -49,6 +51,7 @@ vi.mock("@/lib/job-search/config", () => ({ loadJobSearchConfig: async () => ({ source: fake.source, saved: true, + blockedCompanies: fake.blockedCompanies, jsearch: { key: "jsearch-key", host: "openwebninja", keyState: "set" }, adzuna: { appId: "app", key: "adzuna-key", keyState: "set" }, }), @@ -103,7 +106,7 @@ const context = (abort?: AbortController): JobContext & { steps: string[] } => { beforeEach(async () => { await resetDatabase(prisma()); - Object.assign(fake, { postings: page, searches: 0, source: "JSEARCH", ai: true, failOn: null, onScore: () => {} }); + Object.assign(fake, { postings: page, searches: 0, source: "JSEARCH", ai: true, failOn: null, onScore: () => {}, blockedCompanies: [] }); await saveSettings({ agentEnabled: true, agentQuery: query }); }); afterAll(() => prisma().$disconnect()); @@ -128,6 +131,15 @@ describe("runAgentScan (E16-3)", () => { expect((await loadSettings()).agentRuns).toEqual([{ at: now.toISOString(), manual: false, message: "2 new postings are waiting for a look." }]); }); + it("never scores or drafts a posting from a blocked company (this fork, E14-8)", async () => { + fake.blockedCompanies = ["Company a"]; + const run = context(); + const result = await runAgentScan({ manual: false }, run, now); + expect(result).toMatchObject({ found: 2, fresh: 2, scored: 2 }); + const candidates = await prisma().agentCandidate.findMany(); + expect(candidates.map((candidate) => candidate.externalId).toSorted()).toEqual(["b", "c"]); + }); + it("scores the newest new postings first, up to the cap", async () => { await saveSettings({ agentMaxScores: 2 }); const scoredTitles: string[] = []; diff --git a/src/lib/agent/run.ts b/src/lib/agent/run.ts index 54424b7d..10241017 100644 --- a/src/lib/agent/run.ts +++ b/src/lib/agent/run.ts @@ -10,6 +10,7 @@ import { AiError } from "@/lib/ai/errors"; import { loadTaskOptions } from "@/lib/ai/task-options"; import { scorePosting } from "@/lib/ai/tasks"; import { searchPostings } from "@/lib/job-search"; +import { compileBlocklist, isBlockedPosting } from "@/lib/job-search/blocklist"; import { credentialsFor, loadJobSearchConfig, requestContext } from "@/lib/job-search/config"; import { JobSearchError } from "@/lib/job-search/errors"; import { postingKey, postingText, type FoundPosting, type SearchQuery } from "@/lib/job-search/search"; @@ -117,7 +118,10 @@ export async function runAgentScan({ manual }: { manual: boolean }, context: Job let postings: FoundPosting[]; try { // The first page only: the agent never asks for a second. - postings = (await searchPostings(source, credentialsFor(config, source), settings.agentQuery, null, requestContext(source))).postings; + const page = await searchPostings(source, credentialsFor(config, source), settings.agentQuery, null, requestContext(source)); + // Blocked companies (this fork, E14-8): the agent runs unattended, so a blocked employer must never reach a score or an auto-draft. + const blocked = compileBlocklist(config.blockedCompanies); + postings = page.postings.filter((posting) => !isBlockedPosting(posting, blocked)); } catch (error) { if (error instanceof JobSearchError) return finish(stopped(error.message)); throw error; diff --git a/src/lib/connector/tools.ts b/src/lib/connector/tools.ts index 3baab677..ca9ea046 100644 --- a/src/lib/connector/tools.ts +++ b/src/lib/connector/tools.ts @@ -10,6 +10,7 @@ import { profileForPrompt } from "@/lib/ai/tasks/prompt-parts"; import { scoreFrom } from "@/lib/ai/tasks/score-posting"; import { unitsSchema } from "@/lib/application-data"; import { fitBand } from "@/lib/fit"; +import { blockedBy, compileBlocklist } from "@/lib/job-search/blocklist"; import { countryCodeFor, countryName } from "@/lib/job-search/countries"; import { fullText } from "@/lib/job-search/find"; import { DEFAULT_RADIUS_KM, foundPostingSchema, POSTED_WITHIN, postingText, RADII_KM, type FoundPosting, type PostedWithin, type SearchQuery, type ShownPosting } from "@/lib/job-search/search"; @@ -21,6 +22,7 @@ import { loadSearch, runSearch, showMoreFor } from "@/lib/posting-searches"; import { foundFromPosting, storePosting } from "@/lib/postings"; import { prisma } from "@/lib/prisma"; import { loadProfile } from "@/lib/profile-data"; +import { loadSettings } from "@/lib/settings-data"; import { statusInfo } from "@/lib/status"; import { applyStatusChange } from "@/lib/status-changes"; import { appVersion } from "@/lib/version"; @@ -250,8 +252,10 @@ export function createDossierServer(origin: string): McpServer { const search = search_id ? await prisma.postingSearch.findUnique({ where: { id: search_id }, select: { postings: true } }) : null; if (search_id && !search) return failure("That search is no longer kept. Search again."); const kept = foundPostingSchema.array().catch([]).parse(search?.postings ?? []); + const blocklist = compileBlocklist((await loadSettings()).blockedCompanies); const saved: { number?: number; posting_id?: string; title: string; score: number; band: string }[] = []; const missing: (number | string)[] = []; + const blocked: { number?: number; posting_id?: string; company: string }[] = []; for (const entry of scores) { const row = entry.posting_id ? await prisma.posting.findUnique({ where: { id: entry.posting_id } }) : null; const posting = row ? foundFromPosting(row) : entry.number ? kept[entry.number - 1] : undefined; @@ -259,11 +263,22 @@ export function createDossierServer(origin: string): McpServer { missing.push(entry.posting_id ?? entry.number ?? "?"); continue; } + // Blocked companies (this fork, E14-8): search_jobs already filters these out, but a posting_id can point at one saved before it was blocked. + const matched = blockedBy(posting, blocklist); + if (matched) { + blocked.push({ number: entry.number, posting_id: entry.posting_id, company: matched }); + continue; + } const fit = scoreFrom({ score: entry.score, reasons: entry.reasons, redFlags: entry.red_flags }); await saveScore(posting, fit, WRITTEN_IN_CLAUDE); saved.push({ number: entry.number, posting_id: entry.posting_id, title: posting.title, score: fit.score, band: fitBand(fit.score) }); } - return text({ saved, not_found: missing.length > 0 ? missing : undefined, link: linkTo(search_id ? `/jobs?search=${search_id}` : "/jobs?view=saved") }); + return text({ + saved, + not_found: missing.length > 0 ? missing : undefined, + blocked: blocked.length > 0 ? blocked : undefined, + link: linkTo(search_id ? `/jobs?search=${search_id}` : "/jobs?view=saved"), + }); }, ); @@ -392,6 +407,9 @@ export function createDossierServer(origin: string): McpServer { async ({ details, resume, cover_letter, match, note, ...source }) => { const posting = await resolvePosting(source); if ("error" in posting) return failure(posting.error); + // Blocked companies (this fork, E14-8): search_jobs already keeps these out, but save_application can be reached straight from a link or pasted text. + const matched = blockedBy({ company: posting.company ?? "", title: posting.title ?? "", description: posting.text }, compileBlocklist((await loadSettings()).blockedCompanies)); + if (matched) return failure(`"${matched}" is on your blocked companies list (Settings → Job search). Dossier won't save an application for it; tell the user why, in case it's a mistake.`); const saved = await saveWrittenApplication({ posting: posting.text.slice(0, 100_000), postingUrl: posting.url.slice(0, 2000), @@ -428,6 +446,8 @@ export function createDossierServer(origin: string): McpServer { } const posting = await resolvePosting(source); if ("error" in posting) return failure(posting.error); + const matched = blockedBy({ company: posting.company ?? "", title: posting.title ?? "", description: posting.text }, compileBlocklist((await loadSettings()).blockedCompanies)); + if (matched) return failure(`"${matched}" is on your blocked companies list (Settings → Job search). Dossier won't draft an application for it; tell the user why, in case it's a mistake.`); const jobId = await startJob(connectorDraftJob, { posting: posting.text.slice(0, 100_000), postingUrl: posting.url.slice(0, 2000), note: note ?? "", postingId: posting.postingId }); return text({ draft_id: jobId, status: "started", next: "Call check_draft with this draft_id in a minute or so." }); }, diff --git a/src/lib/job-search/blocklist.test.ts b/src/lib/job-search/blocklist.test.ts new file mode 100644 index 00000000..f913b8a6 --- /dev/null +++ b/src/lib/job-search/blocklist.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { blockedBy, compileBlocklist, isBlockedPosting } from "./blocklist"; + +describe("compileBlocklist", () => { + it("normalizes each name once, and drops one that normalizes to nothing", () => { + const blocked = compileBlocklist(["Best Buy", " ", "ProArch IT Solutions Pvt. Ltd."]); + expect(blocked).toEqual([ + { name: "Best Buy", normalized: "best buy" }, + { name: "ProArch IT Solutions Pvt. Ltd.", normalized: "proarch it solutions pvt ltd" }, + ]); + }); +}); + +describe("isBlockedPosting", () => { + it("is false with nothing blocked", () => { + expect(isBlockedPosting({ company: "Best Buy", title: "Data Engineer" }, [])).toBe(false); + }); + + it("matches the company field, case- and punctuation-insensitively", () => { + const blocked = compileBlocklist(["Best Buy"]); + expect(isBlockedPosting({ company: "Best Buy Co., Inc.", title: "Data Engineer" }, blocked)).toBe(true); + expect(isBlockedPosting({ company: "best buy", title: "Data Engineer" }, blocked)).toBe(true); + expect(isBlockedPosting({ company: "Target", title: "Data Engineer" }, blocked)).toBe(false); + }); + + it("matches a substring company name ('ProArch' blocks 'ProArch IT Solutions Pvt Ltd')", () => { + const blocked = compileBlocklist(["ProArch"]); + expect(isBlockedPosting({ company: "ProArch IT Solutions Pvt Ltd", title: "Sr. Data Engineer" }, blocked)).toBe(true); + }); + + it("catches a staffing listing that only names the company in the title or description", () => { + const blocked = compileBlocklist(["Best Buy"]); + expect(isBlockedPosting({ company: "Acme Staffing", title: "Data Engineer (role placed at Best Buy)" }, blocked)).toBe(true); + expect(isBlockedPosting({ company: "Acme Staffing", title: "Data Engineer", description: "Our client Best Buy is hiring." }, blocked)).toBe(true); + }); + + it("only matches a whole word, not part of a longer one", () => { + const blocked = compileBlocklist(["Best"]); + expect(isBlockedPosting({ company: "Bestbuy Inc", title: "Data Engineer" }, blocked)).toBe(false); + expect(isBlockedPosting({ company: "Best Logistics", title: "Data Engineer" }, blocked)).toBe(true); + }); +}); + +describe("blockedBy", () => { + it("names which blocked entry matched, or null", () => { + const blocked = compileBlocklist(["Best Buy", "ProArch"]); + expect(blockedBy({ company: "ProArch IT Solutions", title: "" }, blocked)).toBe("ProArch"); + expect(blockedBy({ company: "Acme Corp", title: "" }, blocked)).toBeNull(); + expect(blockedBy({ company: "Acme Corp", title: "" }, [])).toBeNull(); + }); +}); diff --git a/src/lib/job-search/blocklist.ts b/src/lib/job-search/blocklist.ts new file mode 100644 index 00000000..3bb0a333 --- /dev/null +++ b/src/lib/job-search/blocklist.ts @@ -0,0 +1,64 @@ +// Blocked companies (this fork, E14-8): postings Find jobs, the job agent +// and the Claude connector never show, score or let you draft for, by name +// — so a past or current employer, including a staffing role placed there, +// never comes back in a search. Settings → Job search keeps the list +// (settingsSchema.blockedCompanies in src/lib/settings.ts). +// +// A blocked name is matched against a posting's company, title AND +// description, not company alone: a staffing agency's own postings often +// name the client company only in the text ("role placed at Acme Corp"), +// and sources that don't give a separate company field at all (Firecrawl, +// see firecrawl.ts) only have the title and description to go on. This +// favours recall over precision on purpose — the point is never seeing the +// company again, not a perfectly worded match — so a blocked name can +// occasionally hide an unrelated posting that merely mentions it (e.g. "not +// an Acme Corp alumnus? no problem"). Keep blocked names specific for that +// reason: "Acme" blocks less precisely than "Acme Corporation". + +/** Case- and punctuation-insensitive, so "ProArch IT Solutions Pvt. Ltd." and "proarch it solutions" compare equal. */ +function normalize(value: string): string { + return value + .toLowerCase() + .normalize("NFKD") + .replace(/\p{Diacritic}/gu, "") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} + +/** A blocked name kept as given (for the list in Settings) and normalized once (for fast repeated matching). */ +export type BlockedCompany = { name: string; normalized: string }; + +/** Settings' plain strings, normalized once, so searching many postings doesn't renormalize the list every time. */ +export function compileBlocklist(names: readonly string[]): BlockedCompany[] { + return names.map((name) => ({ name, normalized: normalize(name) })).filter((entry) => entry.normalized.length > 0); +} + +/** + * Whether `haystack` contains `needle` (already space-normalized, e.g. "best + * buy") as a run of whole words: "best buy" matches "...engineer best buy + * bengaluru...", but not "bestbuy" run together as one word. A short or + * common needle ("it", "data") still matches wherever it stands alone as a + * word, which is why the module note asks for specific blocked names. + */ +function containsWords(haystack: string, needle: string): boolean { + if (!needle) return false; + return new RegExp(`(?:^|\\s)${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s|$)`).test(` ${haystack} `); +} + +/** + * Whether a posting is from, or mentions, a blocked company: checked against + * its company, title and description (see the module note for why). + */ +export function isBlockedPosting(posting: { company: string; title: string; description?: string }, blocked: readonly BlockedCompany[]): boolean { + if (blocked.length === 0) return false; + const haystacks = [normalize(posting.company), normalize(posting.title), normalize(posting.description ?? "")]; + return blocked.some((entry) => haystacks.some((haystack) => containsWords(haystack, entry.normalized))); +} + +/** The reason a posting was kept out, for a server log; never the posting's own text. */ +export function blockedBy(posting: { company: string; title: string; description?: string }, blocked: readonly BlockedCompany[]): string | null { + if (blocked.length === 0) return null; + const haystacks = [normalize(posting.company), normalize(posting.title), normalize(posting.description ?? "")]; + const hit = blocked.find((entry) => haystacks.some((haystack) => containsWords(haystack, entry.normalized))); + return hit?.name ?? null; +} diff --git a/src/lib/job-search/config.test.ts b/src/lib/job-search/config.test.ts index 713477ce..8290d0f6 100644 --- a/src/lib/job-search/config.test.ts +++ b/src/lib/job-search/config.test.ts @@ -13,6 +13,7 @@ const nothingSaved = { adzunaAppId: null, adzunaKeyEnc: null, arbeitsagenturAcceptedAt: null, + blockedCompanies: [], }; const ACCEPTED = "2026-10-23T09:00:00.000Z"; const noEnv = {}; @@ -26,9 +27,17 @@ describe("resolveJobSearchConfig", () => { jsearch: { host: "openwebninja", keyState: "none" }, adzuna: { appId: "", keyState: "none" }, arbeitsagentur: { offered: true, acceptedAt: null }, + blockedCompanies: [], }); }); + it("carries the blocked companies list through untouched", () => { + expect(resolveJobSearchConfig(nothingSaved, noEnv).blockedCompanies).toEqual([]); + const blocked = resolveJobSearchConfig({ ...nothingSaved, blockedCompanies: ["Best Buy", "ProArch"] }, noEnv); + expect(blocked.blockedCompanies).toEqual(["Best Buy", "ProArch"]); + expect(jobSearchView(blocked).blockedCompanies).toEqual(["Best Buy", "ProArch"]); + }); + it("uses the environment until something is saved", () => { const config = resolveJobSearchConfig(nothingSaved, { DOSSIER_JOBS_SOURCE: "jsearch", @@ -54,6 +63,7 @@ describe("resolveJobSearchConfig", () => { adzunaAppId: "app-saved", adzunaKeyEnc: encryptSecret(JOB_SOURCE_KEY, "adz-saved"), arbeitsagenturAcceptedAt: null, + blockedCompanies: ["Best Buy"], }; const config = resolveJobSearchConfig(saved, { DOSSIER_JOBS_SOURCE: "jsearch", DOSSIER_JSEARCH_KEY: "ak_env", DOSSIER_JSEARCH_HOST: "rapidapi" }); expect(config).toMatchObject({ @@ -61,6 +71,7 @@ describe("resolveJobSearchConfig", () => { saved: true, jsearch: { key: "ak_saved", host: "openwebninja", keyState: "set" }, adzuna: { appId: "app-saved", key: "adz-saved", keyState: "set" }, + blockedCompanies: ["Best Buy"], }); expect(resolveJobSearchConfig({ ...saved, jobSource: "OFF" }, { DOSSIER_JOBS_SOURCE: "jsearch" })).toMatchObject({ source: null, saved: true }); }); diff --git a/src/lib/job-search/config.ts b/src/lib/job-search/config.ts index b9e9d71d..c1c1e650 100644 --- a/src/lib/job-search/config.ts +++ b/src/lib/job-search/config.ts @@ -21,6 +21,8 @@ export type JobSearchConfig = { saved: boolean; jsearch: { key: string | null; host: JSearchHost; keyState: JobKeyState }; adzuna: { appId: string; key: string | null; keyState: JobKeyState }; + /** Companies no search ever shows a posting from (E14-8); see blocklist.ts. */ + blockedCompanies: readonly string[]; /** * The Bundesagentur für Arbeit (#223): whether this server offers it, and * when its note was accepted. Its searches wait until then. @@ -28,7 +30,10 @@ export type JobSearchConfig = { arbeitsagentur: { offered: boolean; acceptedAt: string | null }; }; -type SavedJobSettings = Pick; +type SavedJobSettings = Pick< + SettingsData, + "jobSource" | "jsearchKeyEnc" | "jsearchHost" | "adzunaAppId" | "adzunaKeyEnc" | "arbeitsagenturAcceptedAt" | "blockedCompanies" +>; type JobEnv = Pick< Env, "DOSSIER_JOBS_SOURCE" | "DOSSIER_JSEARCH_KEY" | "DOSSIER_JSEARCH_HOST" | "DOSSIER_ADZUNA_APP_ID" | "DOSSIER_ADZUNA_KEY" | "DOSSIER_JOBS_ARBEITSAGENTUR" @@ -71,6 +76,7 @@ export function resolveJobSearchConfig(saved: SavedJobSettings, source: JobEnv, appId: saved.adzunaAppId ?? source.DOSSIER_ADZUNA_APP_ID ?? "", ...keyOf(saved.adzunaKeyEnc, source.DOSSIER_ADZUNA_KEY, secret), }, + blockedCompanies: saved.blockedCompanies, arbeitsagentur: { offered, acceptedAt: saved.arbeitsagenturAcceptedAt }, }; } @@ -97,6 +103,7 @@ export function jobSearchView(config: JobSearchConfig): JobSearchSettingsView { jsearch: { host: config.jsearch.host, keyState: config.jsearch.keyState }, adzuna: { appId: config.adzuna.appId, keyState: config.adzuna.keyState }, arbeitsagentur: config.arbeitsagentur, + blockedCompanies: config.blockedCompanies, }; } diff --git a/src/lib/job-search/find.ts b/src/lib/job-search/find.ts index bc2b89ce..a7b75555 100644 --- a/src/lib/job-search/find.ts +++ b/src/lib/job-search/find.ts @@ -1,5 +1,6 @@ import "server-only"; +import { compileBlocklist, isBlockedPosting } from "./blocklist"; import { credentialsFor, loadJobSearchConfig, requestContext } from "./config"; import { JobSearchError } from "./errors"; import { postingDetails, searchPostings } from "./index"; @@ -11,7 +12,10 @@ import type { FoundPosting, JobSource, SearchPage, SearchProblem, SearchQuery } /** * One page for `query` from `source`, the one chosen in Settings unless - * given (a kept search goes on with the source it ran on). + * given (a kept search goes on with the source it ran on). Postings from a + * blocked company (this fork, E14-8) never reach the caller — Find jobs, the + * job agent and the Claude connector's search_jobs all call this, so a + * blocked name stays out everywhere a search runs, not just on screen. */ export async function askSource( query: SearchQuery, @@ -23,7 +27,8 @@ export async function askSource( if (!query.country) return { ok: false, problem: "no-country", message: "Choose a country to search in." }; try { const page = await searchPostings(from, credentialsFor(config, from), query, next, requestContext(from)); - return { ok: true, source: from, ...page }; + const blocked = compileBlocklist(config.blockedCompanies); + return { ok: true, source: from, ...page, postings: page.postings.filter((posting) => !isBlockedPosting(posting, blocked)) }; } catch (error) { if (error instanceof JobSearchError) return { ok: false, problem: error.problem, message: error.message }; throw error; diff --git a/src/lib/job-search/sources.ts b/src/lib/job-search/sources.ts index 037ce461..d2e4b1d6 100644 --- a/src/lib/job-search/sources.ts +++ b/src/lib/job-search/sources.ts @@ -10,6 +10,8 @@ export type JobSearchSettingsView = { adzuna: { appId: string; keyState: JobKeyState }; /** Whether this server offers the Bundesagentur für Arbeit, and when its note was accepted (#223). */ arbeitsagentur: { offered: boolean; acceptedAt: string | null }; + /** Companies no search shows a posting from (E14-8). */ + blockedCompanies: readonly string[]; }; export const JOB_KEY_UNREADABLE = "Your saved key can't be read. Please enter it again."; diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index b59bccc7..93e0ac86 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -62,6 +62,7 @@ describe("stored settings", () => { adzunaAppId: null, adzunaKeyEnc: null, arbeitsagenturAcceptedAt: null, + blockedCompanies: [], agentEnabled: false, agentQuery: { keywords: "", place: "", country: "", radiusKm: 25, remoteOnly: false, postedWithin: "any" }, agentHour: 7, @@ -110,11 +111,13 @@ describe("stored settings", () => { docLanguage: "Dutch", quietDays: 14, jobRetentionHours: 168, + blockedCompanies: ["Best Buy", "ProArch"], } satisfies Partial; const rows = Object.entries(saved).map(([key, value]) => ({ key, value: settingValue(value)! })); expect(rows).toContainEqual({ key: "strict", value: "false" }); expect(rows).toContainEqual({ key: "customLanguages", value: '["Farsi","Hebrew"]' }); expect(rows).toContainEqual({ key: "quietDays", value: "14" }); + expect(rows).toContainEqual({ key: "blockedCompanies", value: '["Best Buy","ProArch"]' }); expect(settingsFromRows(rows)).toMatchObject(saved); expect(settingValue(null)).toBeNull(); }); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 9745f374..e1b0139e 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -155,6 +155,14 @@ export const settingsSchema = z.object({ adzunaKeyEnc: z.string().nullable().catch(null), /** When "I understand" was ticked under the Bundesagentur für Arbeit's note (#223); its searches wait for it. */ arbeitsagenturAcceptedAt: z.iso.datetime().nullable().catch(null), + /** + * Companies Find jobs, the job agent and the Claude connector never show + * or save a posting for (E14-8): past or current employers, so a search + * never resurfaces one by name. Matched against a posting's company, + * title and description, case-insensitively, so a staffing listing that + * only names the company in the description is still caught. + */ + blockedCompanies: stringList.catch([]), // Job agent (E16): one search, run once a day. Off until it's turned on. // It asks the source in Settings → Job search, with its key. agentEnabled: flag(false),