Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/(app)/settings/actions.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
28 changes: 28 additions & 0 deletions src/app/(app)/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion src/app/(app)/settings/job-search-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ 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", () => ({
saveJobSource: vi.fn(),
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 }));

Expand All @@ -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>): JobSearchSettingsView => ({ ...off, ...view });
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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(<JobSearchSection initial={off} />);
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(<JobSearchSection initial={withSource({ source: "JSEARCH", jsearch: { host: "openwebninja", keyState: "env" } })} />);
expect(screen.getByLabelText("API key")).toHaveAttribute("placeholder", "Using DOSSIER_JSEARCH_KEY · type one to save it here");
Expand Down
94 changes: 91 additions & 3 deletions src/app/(app)/settings/job-search-section.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -183,10 +184,97 @@ export function JobSearchSection({ initial }: { initial: JobSearchSettingsView }
</Card>
</>
)}

<BlockedCompaniesCard initial={view.blockedCompanies} />
</>
);
}

/**
* 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<readonly string[]>(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<HTMLInputElement>) => {
if (event.key !== "Enter") return;
event.preventDefault();
void add();
};

return (
<Card className="flex flex-col gap-4 px-7 py-6">
<div className="flex items-baseline justify-between">
<h3 className="text-lead font-medium">
Blocked companies<span className="font-normal text-muted"> (optional)</span>
</h3>
<span className="text-meta text-muted">{blocked.length} blocked</span>
</div>
<p className="text-meta text-muted">
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.
</p>
{blocked.length > 0 && (
<ul className="flex flex-wrap gap-2" aria-label="Blocked companies">
{blocked.map((name) => (
<li key={name}>
<RemovableChip removeLabel={`Unblock ${name}`} onRemove={() => void remove(name)}>
{name}
</RemovableChip>
</li>
))}
</ul>
)}
<Input
aria-label="Block a company"
placeholder="e.g. Best Buy"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={onKeyDown}
enterKeyHint="done"
/>
</Card>
);
}

function HostChoice({ host, onChange }: { host: JSearchHost; onChange: (host: JSearchHost) => void }) {
const labelId = useId();
return (
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/mcp/route.test.ts
Original file line number Diff line number Diff line change
@@ -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 }));
Expand Down
14 changes: 13 additions & 1 deletion src/lib/agent/run.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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" },
}),
Expand Down Expand Up @@ -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());
Expand All @@ -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[] = [];
Expand Down
6 changes: 5 additions & 1 deletion src/lib/agent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 21 additions & 1 deletion src/lib/connector/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -250,20 +252,33 @@ 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;
if (!posting) {
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"),
});
},
);

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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." });
},
Expand Down
Loading