feat(orchestrator): blocked company filters and keyword parsing

- Add shared helper to parse blocked company keywords from JSON or legacy text
- Wire discover-jobs to resolved keywords for employer blocking; extend filters UI
- Include vite/client in TS types for import.meta.env; fix JobListItem test fixture

Made-with: Cursor
This commit is contained in:
ilia 2026-04-11 12:04:38 -04:00
parent 60b61ffe03
commit e39341258a
13 changed files with 685 additions and 24 deletions

View File

@ -44,6 +44,7 @@ const makeJob = (overrides: Partial<JobListItem>): JobListItem => ({
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
discoveredAt: "2026-01-01T00:00:00.000Z",
appliedAt: null,
updatedAt: "2026-01-01T00:00:00.000Z",

View File

@ -48,6 +48,14 @@ export const OrchestratorPage: React.FC = () => {
setWorkplaceFilter,
salaryFilter,
setSalaryFilter,
foundAfterYmd,
foundBeforeYmd,
setFoundDateRange,
employerIncludeFilter,
employerExcludeFilter,
setEmployerFilterTokens,
applySettingsCompanySkipList,
setApplySettingsCompanySkipList,
sort,
setSort,
resetFilters,
@ -146,6 +154,31 @@ export const OrchestratorPage: React.FC = () => {
navigateWithContext,
});
const settingsSkipEmployerKeywords = useMemo(
() => settings?.blockedCompanyKeywords?.value ?? [],
[settings],
);
const jobListFilterExtras = useMemo(
() => ({
foundAfterYmd,
foundBeforeYmd,
employerInclude: employerIncludeFilter,
employerExclude: employerExcludeFilter,
settingsBlockedEmployerKeywords: applySettingsCompanySkipList
? settingsSkipEmployerKeywords
: [],
}),
[
foundAfterYmd,
foundBeforeYmd,
employerIncludeFilter,
employerExcludeFilter,
applySettingsCompanySkipList,
settingsSkipEmployerKeywords,
],
);
const activeJobs = useFilteredJobs(
jobs,
activeTab,
@ -157,6 +190,21 @@ export const OrchestratorPage: React.FC = () => {
workplaceFilter,
salaryFilter,
sort,
jobListFilterExtras,
);
const handleEmployerIncludeChange = useCallback(
(nextInclude: string[]) => {
setEmployerFilterTokens(nextInclude, employerExcludeFilter);
},
[employerExcludeFilter, setEmployerFilterTokens],
);
const handleEmployerExcludeChange = useCallback(
(nextExclude: string[]) => {
setEmployerFilterTokens(employerIncludeFilter, nextExclude);
},
[employerIncludeFilter, setEmployerFilterTokens],
);
const setActiveTab = useCallback(
(newTab: FilterTab) => {
@ -444,6 +492,17 @@ export const OrchestratorPage: React.FC = () => {
countriesFilter={countriesFilter}
countriesExcludeFilter={countriesExcludeFilter}
onCountrySelectionChange={setCountrySelection}
foundAfterYmd={foundAfterYmd}
foundBeforeYmd={foundBeforeYmd}
onFoundDateRangeChange={setFoundDateRange}
employerIncludeFilter={employerIncludeFilter}
employerExcludeFilter={employerExcludeFilter}
onEmployerIncludeChange={handleEmployerIncludeChange}
onEmployerExcludeChange={handleEmployerExcludeChange}
applySettingsCompanySkipList={applySettingsCompanySkipList}
onApplySettingsCompanySkipListChange={
setApplySettingsCompanySkipList
}
sponsorFilter={sponsorFilter}
onSponsorFilterChange={setSponsorFilter}
workplaceFilter={workplaceFilter}

View File

@ -10,6 +10,15 @@ interface JobRowContentProps {
className?: string;
}
function formatDiscoveredAt(iso: string): string {
const parsed = Date.parse(iso);
if (!Number.isFinite(parsed)) return iso;
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
}
function getSuitabilityScoreTone(score: number): string {
if (score >= 70) return "text-emerald-400/90";
if (score >= 50) return "text-foreground/60";
@ -60,6 +69,14 @@ export const JobRowContent = ({
{job.salary}
</div>
)}
{job.discoveredAt?.trim() && (
<div
className="truncate text-xs text-muted-foreground mt-0.5"
title={`Found in JobOps: ${job.discoveredAt}`}
>
Found {formatDiscoveredAt(job.discoveredAt)}
</div>
)}
</div>
{hasScore && (

View File

@ -45,6 +45,15 @@ const renderFilters = (overrides?: Partial<FiltersProps>) => {
countriesFilter: [] as string[],
countriesExcludeFilter: [] as string[],
onCountrySelectionChange: vi.fn(),
foundAfterYmd: null,
foundBeforeYmd: null,
onFoundDateRangeChange: vi.fn(),
employerIncludeFilter: [],
employerExcludeFilter: [],
onEmployerIncludeChange: vi.fn(),
onEmployerExcludeChange: vi.fn(),
applySettingsCompanySkipList: true,
onApplySettingsCompanySkipListChange: vi.fn(),
sponsorFilter: "all" as SponsorFilter,
onSponsorFilterChange: vi.fn(),
workplaceFilter: "all" as WorkplaceFilter,
@ -92,7 +101,7 @@ describe("OrchestratorFilters", () => {
[],
);
props.onSourceSelectionChange.mockClear();
vi.mocked(props.onSourceSelectionChange).mockClear();
rerender(<OrchestratorFilters {...props} sourcesFilter={["linkedin"]} />);
fireEvent.click(screen.getByRole("button", { name: /linkedin/i }));
expect(props.onSourceSelectionChange).toHaveBeenCalledWith(

View File

@ -7,6 +7,7 @@ import type React from "react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@ -36,6 +37,7 @@ import type {
WorkplaceFilter,
} from "./constants";
import { defaultSortDirection, orderedFilterSources, tabs } from "./constants";
import { TokenizedInput } from "./TokenizedInput";
interface OrchestratorFiltersProps {
activeTab: FilterTab;
@ -48,6 +50,18 @@ interface OrchestratorFiltersProps {
countriesFilter: string[];
countriesExcludeFilter: string[];
onCountrySelectionChange: (include: string[], exclude: string[]) => void;
foundAfterYmd: string | null;
foundBeforeYmd: string | null;
onFoundDateRangeChange: (
foundAfter: string | null,
foundBefore: string | null,
) => void;
employerIncludeFilter: string[];
employerExcludeFilter: string[];
onEmployerIncludeChange: (values: string[]) => void;
onEmployerExcludeChange: (values: string[]) => void;
applySettingsCompanySkipList: boolean;
onApplySettingsCompanySkipListChange: (value: boolean) => void;
sponsorFilter: SponsorFilter;
onSponsorFilterChange: (value: SponsorFilter) => void;
workplaceFilter: WorkplaceFilter;
@ -110,6 +124,13 @@ const sortFieldLabels: Record<JobSort["key"], string> = {
employer: "Company",
};
function parseEmployerTokenInput(input: string): string[] {
return input
.split(/[\n,]/g)
.map((value) => value.trim())
.filter(Boolean);
}
const getDirectionOptions = (
key: JobSort["key"],
): Array<{ value: JobSort["direction"]; label: string }> => {
@ -142,6 +163,15 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
countriesFilter,
countriesExcludeFilter,
onCountrySelectionChange,
foundAfterYmd,
foundBeforeYmd,
onFoundDateRangeChange,
employerIncludeFilter,
employerExcludeFilter,
onEmployerIncludeChange,
onEmployerExcludeChange,
applySettingsCompanySkipList,
onApplySettingsCompanySkipListChange,
sponsorFilter,
onSponsorFilterChange,
workplaceFilter,
@ -160,6 +190,8 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
const [internalOpen, setInternalOpen] = useState(false);
const isFiltersOpen = isFiltersOpenProp ?? internalOpen;
const onFiltersOpenChange = onFiltersOpenChangeProp ?? setInternalOpen;
const [employerIncludeDraft, setEmployerIncludeDraft] = useState("");
const [employerExcludeDraft, setEmployerExcludeDraft] = useState("");
const visibleSources = orderedFilterSources.filter((source) =>
sourcesWithJobs.includes(source),
@ -201,6 +233,11 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
() =>
Number(sourcesFilter.length > 0 || sourcesExcludeFilter.length > 0) +
Number(countriesFilter.length > 0 || countriesExcludeFilter.length > 0) +
Number(Boolean(foundAfterYmd) || Boolean(foundBeforeYmd)) +
Number(
employerIncludeFilter.length > 0 || employerExcludeFilter.length > 0,
) +
Number(!applySettingsCompanySkipList) +
Number(sponsorFilter !== "all") +
Number(workplaceFilter !== "all") +
Number(
@ -212,6 +249,11 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
sourcesExcludeFilter.length,
countriesFilter.length,
countriesExcludeFilter.length,
foundAfterYmd,
foundBeforeYmd,
employerIncludeFilter.length,
employerExcludeFilter.length,
applySettingsCompanySkipList,
sponsorFilter,
workplaceFilter,
salaryFilter.min,
@ -295,7 +337,9 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
<SheetDescription>
Refine sources and country: each button cycles off include
exclude (red). Remote listings always bypass country
filters. Sponsor, workplace, salary, and sort below.
filters. Filter by when the job was discovered in JobOps,
employer keywords, sponsor, workplace, salary, and sort
below.
</SheetDescription>
</SheetHeader>
@ -394,6 +438,117 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>Found in JobOps</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
When this listing was first saved during discovery (your
local JobOps clock). Leave blank to show all dates.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="found-after-filter">
On or after
</Label>
<Input
id="found-after-filter"
type="date"
value={foundAfterYmd ?? ""}
onChange={(event) => {
const next = event.target.value;
onFoundDateRangeChange(
next ? next : null,
foundBeforeYmd,
);
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor="found-before-filter">
On or before
</Label>
<Input
id="found-before-filter"
type="date"
value={foundBeforeYmd ?? ""}
onChange={(event) => {
const next = event.target.value;
onFoundDateRangeChange(
foundAfterYmd,
next ? next : null,
);
}}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>Employer keywords</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-xs text-muted-foreground">
Case-insensitive substring match on employer name.
Include narrows to rows that match any token; exclude
hides matching rows. When enabled, your Settings company
skip list is applied here too (not only during
discovery).
</p>
<div className="flex items-start gap-3 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<Checkbox
id="apply-settings-company-skip"
checked={applySettingsCompanySkipList}
onCheckedChange={(checked) => {
onApplySettingsCompanySkipListChange(
checked === true,
);
}}
/>
<div className="space-y-1">
<label
htmlFor="apply-settings-company-skip"
className="text-sm font-medium leading-none cursor-pointer"
>
Apply Settings company skip list
</label>
<p className="text-xs text-muted-foreground">
Uncheck to temporarily show employers blocked in
Settings Scoring (e.g. to clean up old rows).
</p>
</div>
</div>
<TokenizedInput
id="employer-include-filter"
values={employerIncludeFilter}
draft={employerIncludeDraft}
parseInput={parseEmployerTokenInput}
onDraftChange={setEmployerIncludeDraft}
onValuesChange={onEmployerIncludeChange}
placeholder="e.g. partial company name"
helperText="Only show jobs whose employer contains at least one of these tokens."
removeLabelPrefix="Remove include token"
/>
<TokenizedInput
id="employer-exclude-filter"
values={employerExcludeFilter}
draft={employerExcludeDraft}
parseInput={parseEmployerTokenInput}
onDraftChange={setEmployerExcludeDraft}
onValuesChange={onEmployerExcludeChange}
placeholder="e.g. agency name"
helperText="Hide jobs whose employer contains any of these tokens (in addition to the Settings skip list when enabled above)."
removeLabelPrefix="Remove exclude token"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>Sponsor status</CardTitle>

View File

@ -362,4 +362,119 @@ describe("useFilteredJobs", () => {
);
expect(result.current.map((j) => j.id)).toEqual(["in"]);
});
it("filters by discovered date range in local time", () => {
const jobs: Job[] = [
{
...baseJob,
id: "early",
discoveredAt: "2026-01-10T12:00:00.000Z",
},
{
...baseJob,
id: "late",
discoveredAt: "2026-02-20T12:00:00.000Z",
},
];
const { result } = renderHook(() =>
useFilteredJobs(
jobs,
"all",
[],
[],
[],
[],
"all",
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
{
foundAfterYmd: "2026-02-01",
foundBeforeYmd: "2026-02-28",
employerInclude: [],
employerExclude: [],
settingsBlockedEmployerKeywords: [],
},
),
);
expect(result.current.map((j) => j.id)).toEqual(["late"]);
});
it("hides employers matching exclude tokens or settings skip list", () => {
const jobs: Job[] = [
{ ...baseJob, id: "agency", employer: "Acme Staffing Ltd" },
{ ...baseJob, id: "good", employer: "Contoso" },
];
const { result: localExclude } = renderHook(() =>
useFilteredJobs(
jobs,
"all",
[],
[],
[],
[],
"all",
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
{
foundAfterYmd: null,
foundBeforeYmd: null,
employerInclude: [],
employerExclude: ["staffing"],
settingsBlockedEmployerKeywords: [],
},
),
);
expect(localExclude.current.map((j) => j.id)).toEqual(["good"]);
const { result: settingsSkip } = renderHook(() =>
useFilteredJobs(
jobs,
"all",
[],
[],
[],
[],
"all",
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
{
foundAfterYmd: null,
foundBeforeYmd: null,
employerInclude: [],
employerExclude: [],
settingsBlockedEmployerKeywords: ["staffing"],
},
),
);
expect(settingsSkip.current.map((j) => j.id)).toEqual(["good"]);
const { result: includeOnly } = renderHook(() =>
useFilteredJobs(
jobs,
"all",
[],
[],
[],
[],
"all",
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
{
foundAfterYmd: null,
foundBeforeYmd: null,
employerInclude: ["contoso"],
employerExclude: [],
settingsBlockedEmployerKeywords: [],
},
),
);
expect(includeOnly.current.map((j) => j.id)).toEqual(["good"]);
});
});

View File

@ -10,6 +10,29 @@ import type {
} from "./constants";
import { compareJobs, parseSalaryBounds } from "./utils";
export type JobListFilterExtras = {
foundAfterYmd: string | null;
foundBeforeYmd: string | null;
employerInclude: string[];
employerExclude: string[];
settingsBlockedEmployerKeywords: string[];
};
const startOfLocalDayMs = (ymd: string): number =>
new Date(`${ymd}T00:00:00`).getTime();
const endOfLocalDayMs = (ymd: string): number =>
new Date(`${ymd}T23:59:59.999`).getTime();
function employerMatchesAnyKeyword(
employer: string,
keywordsLower: string[],
): boolean {
if (keywordsLower.length === 0) return false;
const normalized = employer.toLowerCase();
return keywordsLower.some((keyword) => normalized.includes(keyword));
}
const getSponsorCategory = (score: number | null): SponsorFilter => {
if (score == null) return "unknown";
if (score >= 95) return "confirmed";
@ -28,8 +51,26 @@ export const useFilteredJobs = (
workplaceFilter: WorkplaceFilter,
salaryFilter: SalaryFilter,
sort: JobSort,
listExtras: JobListFilterExtras = {
foundAfterYmd: null,
foundBeforeYmd: null,
employerInclude: [],
employerExclude: [],
settingsBlockedEmployerKeywords: [],
},
) =>
useMemo(() => {
const employerIncludeLower = listExtras.employerInclude.map((value) =>
value.toLowerCase(),
);
const employerExcludeLower = [
...listExtras.settingsBlockedEmployerKeywords.map((value) =>
value.toLowerCase(),
),
...listExtras.employerExclude.map((value) => value.toLowerCase()),
];
const uniqueEmployerExcludeLower = [...new Set(employerExcludeLower)];
let filtered = [...jobs];
if (activeTab === "ready") {
@ -101,6 +142,39 @@ export const useFilteredJobs = (
Number.isFinite(salaryFilter.max) &&
salaryFilter.max > 0;
if (listExtras.foundAfterYmd || listExtras.foundBeforeYmd) {
filtered = filtered.filter((job) => {
const jobMs = Date.parse(job.discoveredAt);
if (!Number.isFinite(jobMs)) return false;
if (
listExtras.foundAfterYmd &&
jobMs < startOfLocalDayMs(listExtras.foundAfterYmd)
) {
return false;
}
if (
listExtras.foundBeforeYmd &&
jobMs > endOfLocalDayMs(listExtras.foundBeforeYmd)
) {
return false;
}
return true;
});
}
if (employerIncludeLower.length > 0) {
filtered = filtered.filter((job) =>
employerMatchesAnyKeyword(job.employer, employerIncludeLower),
);
}
if (uniqueEmployerExcludeLower.length > 0) {
filtered = filtered.filter(
(job) =>
!employerMatchesAnyKeyword(job.employer, uniqueEmployerExcludeLower),
);
}
if (
(salaryFilter.mode === "at_least" && hasMin) ||
(salaryFilter.mode === "at_most" && hasMax) ||
@ -142,4 +216,5 @@ export const useFilteredJobs = (
workplaceFilter,
salaryFilter,
sort,
listExtras,
]);

View File

@ -89,4 +89,26 @@ describe("useOrchestratorFilters", () => {
expect(params.get("countries")).toBe("canada");
expect(params.get("countriesExclude")).toBe("united kingdom");
});
it("parses found date, employer tokens, skip list toggle, and reset clears them", () => {
const { result } = renderHook(() => useOrchestratorFilters(), {
wrapper: createWrapper(
"/ready?foundAfter=2026-04-01&foundBefore=2026-04-30&employer=acme&employerExclude=temp&skipList=0",
),
});
expect(result.current.foundAfterYmd).toBe("2026-04-01");
expect(result.current.foundBeforeYmd).toBe("2026-04-30");
expect(result.current.employerIncludeFilter).toEqual(["acme"]);
expect(result.current.employerExcludeFilter).toEqual(["temp"]);
expect(result.current.applySettingsCompanySkipList).toBe(false);
act(() => {
result.current.resetFilters();
});
const params = result.current.searchParams;
expect(params.get("foundAfter")).toBeNull();
expect(params.get("employer")).toBeNull();
expect(params.get("skipList")).toBeNull();
});
});

View File

@ -46,6 +46,43 @@ const allowedWorkplaceFilters: WorkplaceFilter[] = [
const allowedJobSources = new Set<string>(EXTRACTOR_SOURCE_IDS);
const allowedCountryKeys = new Set(SUPPORTED_COUNTRY_KEYS);
const MAX_EMPLOYER_FILTER_TOKENS = 25;
const MAX_EMPLOYER_TOKEN_LENGTH = 120;
function normalizeEmployerFilterToken(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.length > MAX_EMPLOYER_TOKEN_LENGTH) return null;
return trimmed;
}
function parseEmployerFilterList(
searchParams: URLSearchParams,
key: string,
): string[] {
const raw = searchParams.getAll(key);
const out: string[] = [];
const seen = new Set<string>();
for (const item of raw) {
const normalized = normalizeEmployerFilterToken(item);
if (!normalized) continue;
const dedupeKey = normalized.toLowerCase();
if (seen.has(dedupeKey)) continue;
if (out.length >= MAX_EMPLOYER_FILTER_TOKENS) break;
seen.add(dedupeKey);
out.push(normalized);
}
return out;
}
function parseYmdFilter(raw: string | null): string | null {
if (!raw?.trim()) return null;
const candidate = raw.trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) return null;
if (!Number.isFinite(Date.parse(`${candidate}T12:00:00`))) return null;
return candidate;
}
export const useOrchestratorFilters = () => {
const [searchParams, setSearchParams] = useSearchParams();
@ -72,12 +109,11 @@ export const useOrchestratorFilters = () => {
const rawExc = searchParams.getAll("sourceExclude");
const exclude = rawExc
.map((value) => value.trim())
.filter(
(value): value is JobSource =>
Boolean(value) &&
allowedJobSources.has(value) &&
!includeSet.has(value),
);
.filter((value): value is JobSource => {
if (!value || !allowedJobSources.has(value)) return false;
const source = value as JobSource;
return !includeSet.has(source);
});
return {
sourcesFilter: [...new Set(include)],
sourcesExcludeFilter: [...new Set(exclude)],
@ -253,6 +289,109 @@ export const useOrchestratorFilters = () => {
[setSearchParams],
);
const { foundAfterYmd, foundBeforeYmd } = useMemo(() => {
return {
foundAfterYmd: parseYmdFilter(searchParams.get("foundAfter")),
foundBeforeYmd: parseYmdFilter(searchParams.get("foundBefore")),
};
}, [searchParams]);
const setFoundDateRange = useCallback(
(foundAfter: string | null, foundBefore: string | null) => {
setSearchParams(
(prev) => {
const nextAfter = parseYmdFilter(foundAfter);
const nextBefore = parseYmdFilter(foundBefore);
if (nextAfter) prev.set("foundAfter", nextAfter);
else prev.delete("foundAfter");
if (nextBefore) prev.set("foundBefore", nextBefore);
else prev.delete("foundBefore");
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
const { employerIncludeFilter, employerExcludeFilter } = useMemo(() => {
const include = parseEmployerFilterList(searchParams, "employer");
const includeSet = new Set(include.map((value) => value.toLowerCase()));
const exclude = parseEmployerFilterList(
searchParams,
"employerExclude",
).filter((value) => !includeSet.has(value.toLowerCase()));
return {
employerIncludeFilter: include,
employerExcludeFilter: exclude,
};
}, [searchParams]);
const setEmployerFilterTokens = useCallback(
(include: string[], exclude: string[]) => {
setSearchParams(
(prev) => {
prev.delete("employer");
prev.delete("employerExclude");
const normalizedInclude: string[] = [];
const includeSeen = new Set<string>();
for (const item of include) {
const normalized = normalizeEmployerFilterToken(item);
if (!normalized) continue;
const key = normalized.toLowerCase();
if (includeSeen.has(key)) continue;
if (normalizedInclude.length >= MAX_EMPLOYER_FILTER_TOKENS) break;
includeSeen.add(key);
normalizedInclude.push(normalized);
}
const includeKeySet = new Set(
normalizedInclude.map((value) => value.toLowerCase()),
);
const normalizedExclude: string[] = [];
const excludeSeenLow = new Set<string>();
for (const item of exclude) {
const normalized = normalizeEmployerFilterToken(item);
if (!normalized) continue;
const key = normalized.toLowerCase();
if (includeKeySet.has(key)) continue;
if (excludeSeenLow.has(key)) continue;
if (normalizedExclude.length >= MAX_EMPLOYER_FILTER_TOKENS) break;
excludeSeenLow.add(key);
normalizedExclude.push(normalized);
}
for (const value of normalizedInclude) prev.append("employer", value);
for (const value of normalizedExclude)
prev.append("employerExclude", value);
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
const applySettingsCompanySkipList = useMemo(
() => searchParams.get("skipList") !== "0",
[searchParams],
);
const setApplySettingsCompanySkipList = useCallback(
(value: boolean) => {
setSearchParams(
(prev) => {
if (value) prev.delete("skipList");
else prev.set("skipList", "0");
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
const sort = useMemo((): JobSort => {
const sortValue = searchParams.get("sort");
if (!sortValue) return DEFAULT_SORT;
@ -305,6 +444,11 @@ export const useOrchestratorFilters = () => {
prev.delete("salaryMax");
prev.delete("minSalary");
prev.delete("sort");
prev.delete("foundAfter");
prev.delete("foundBefore");
prev.delete("employer");
prev.delete("employerExclude");
prev.delete("skipList");
return prev;
},
{ replace: true },
@ -325,6 +469,14 @@ export const useOrchestratorFilters = () => {
setWorkplaceFilter,
salaryFilter,
setSalaryFilter,
foundAfterYmd,
foundBeforeYmd,
setFoundDateRange,
employerIncludeFilter,
employerExcludeFilter,
setEmployerFilterTokens,
applySettingsCompanySkipList,
setApplySettingsCompanySkipList,
sort,
setSort,
resetFilters,

View File

@ -285,6 +285,48 @@ describe("discoverJobsStep", () => {
expect(result.discoveredJobs[0]?.employer).toBe("Contoso");
});
it("drops discovered jobs when blocked keywords use legacy comma-separated storage", async () => {
const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("@server/extractors/registry");
const jobspyManifest = {
id: "jobspy",
displayName: "JobSpy",
providesSources: ["linkedin"],
run: vi.fn().mockResolvedValue({
success: true,
jobs: [
{
source: "linkedin",
title: "Engineer",
employer: "Acme Staffing",
jobUrl: "https://example.com/job-legacy",
},
],
}),
};
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
blockedCompanyKeywords: "staffing, irrelevant",
} as any);
vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue({
manifests: new Map([["jobspy", jobspyManifest as any]]),
manifestBySource: new Map([["linkedin", jobspyManifest as any]]),
availableSources: ["linkedin"],
} as any);
const result = await discoverJobsStep({
mergedConfig: {
...baseConfig,
sources: ["linkedin"],
},
});
expect(result.discoveredJobs).toHaveLength(0);
});
it("applies shared city filtering for sources without native city filtering", async () => {
const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("@server/extractors/registry");

View File

@ -9,7 +9,7 @@ import {
isSourceAllowedForCountry,
normalizeCountryKey,
} from "@shared/location-support.js";
import { normalizeStringArray } from "@shared/normalize-string-array.js";
import { resolveBlockedCompanyKeywordsFromStoredString } from "@shared/resolve-blocked-company-keywords.js";
import {
inferCountryKeyFromSearchGeography,
matchesRequestedCity,
@ -33,19 +33,6 @@ type DiscoverySourceTask = {
run: () => Promise<DiscoveryTaskResult>;
};
function parseBlockedCompanyKeywords(raw: string | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return normalizeStringArray(
parsed.filter((value): value is string => typeof value === "string"),
);
} catch {
return [];
}
}
function isBlockedEmployer(
employer: string | null | undefined,
blockedKeywordsLowerCase: string[],
@ -368,7 +355,7 @@ export async function discoverJobsStep(args: {
});
}
const blockedCompanyKeywords = parseBlockedCompanyKeywords(
const blockedCompanyKeywords = resolveBlockedCompanyKeywordsFromStoredString(
settings.blockedCompanyKeywords,
);
const blockedKeywordsLowerCase = blockedCompanyKeywords.map((value) =>

View File

@ -9,7 +9,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"types": ["vitest/globals", "@testing-library/jest-dom"],
"types": ["vitest/globals", "@testing-library/jest-dom", "vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],

View File

@ -0,0 +1,27 @@
import { normalizeStringArray } from "./normalize-string-array";
/**
* Parse stored settings value for blocked company keywords.
* Accepts JSON string array (normal) or legacy plain comma/newline-separated text.
*/
export function resolveBlockedCompanyKeywordsFromStoredString(
raw: string | undefined,
): string[] {
if (!raw?.trim()) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
return normalizeStringArray(
parsed.filter((item): item is string => typeof item === "string"),
);
}
} catch {
// Legacy or corrupted JSON: treat as token list
}
return normalizeStringArray(
raw
.split(/[\n,]/g)
.map((segment) => segment.trim())
.filter(Boolean),
);
}