Merge pull request #2 from DaKheera47/ui-improvements

UI improvements
This commit is contained in:
Shaheer Sarfaraz 2025-12-15 17:59:49 +00:00 committed by GitHub
commit 3b4845c232
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1776 additions and 268 deletions

View File

@ -23,6 +23,8 @@ NOTION_DATABASE_ID=
# Optional: Webhook secret for n8n automation
WEBHOOK_SECRET=
PIPELINE_WEBHOOK_URL=
JOB_COMPLETE_WEBHOOK_URL=
# =============================================================================
# JobSpy (Indeed/LinkedIn scraping) - optional

View File

@ -11,6 +11,8 @@ NOTION_DATABASE_ID=
# Webhook security (optional)
WEBHOOK_SECRET=
PIPELINE_WEBHOOK_URL=
JOB_COMPLETE_WEBHOOK_URL=
# Pipeline configuration
PIPELINE_TOP_N=10

View File

@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8",
@ -1392,6 +1393,35 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@ -2068,6 +2098,20 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",

View File

@ -18,6 +18,7 @@
"pipeline:run": "tsx src/server/pipeline/run.ts"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",

View File

@ -4,11 +4,13 @@
import React, { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Route, Routes } from "react-router-dom";
import { Toaster } from "@/components/ui/sonner";
import type { Job, JobSource, JobStatus } from "../shared/types";
import { Header, JobList, PipelineProgress, Stats } from "./components";
import * as api from "./api";
import { SettingsPage } from "./pages/SettingsPage";
const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin"];
const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
@ -26,7 +28,6 @@ export const App: React.FC = () => {
const [isLoading, setIsLoading] = useState(true);
const [isPipelineRunning, setIsPipelineRunning] = useState(false);
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
const [isProcessingAll, setIsProcessingAll] = useState(false);
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
try {
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
@ -159,35 +160,6 @@ export const App: React.FC = () => {
}
};
const handleProcessAll = async () => {
try {
setIsProcessingAll(true);
const result = await api.processAllDiscovered();
toast.message("Processing jobs", { description: `Processing ${result.count} jobs in background...` });
const pollInterval = setInterval(async () => {
try {
const data = await api.getJobs();
setJobs(data.jobs);
setStats(data.byStatus);
const stillDiscovered = data.byStatus.discovered + data.byStatus.processing;
if (stillDiscovered === 0) {
clearInterval(pollInterval);
setIsProcessingAll(false);
toast.success("All jobs processed");
}
} catch {
// Ignore errors
}
}, 3000);
} catch (error) {
setIsProcessingAll(false);
const message = error instanceof Error ? error.message : "Failed to process jobs";
toast.error(message);
}
};
return (
<>
<Header
@ -200,19 +172,25 @@ export const App: React.FC = () => {
onPipelineSourcesChange={setPipelineSources}
/>
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<PipelineProgress isRunning={isPipelineRunning} />
<Stats stats={stats} />
<JobList
jobs={jobs}
onApply={handleApply}
onReject={handleReject}
onProcess={handleProcess}
onProcessAll={handleProcessAll}
processingJobId={processingJobId}
isProcessingAll={isProcessingAll}
<Routes>
<Route
path="/"
element={
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<PipelineProgress isRunning={isPipelineRunning} />
<Stats stats={stats} />
<JobList
jobs={jobs}
onApply={handleApply}
onReject={handleReject}
onProcess={handleProcess}
processingJobId={processingJobId}
/>
</main>
}
/>
</main>
<Route path="/settings" element={<SettingsPage />} />
</Routes>
<Toaster position="bottom-right" richColors closeButton />
</>

View File

@ -8,7 +8,8 @@ import type {
JobsListResponse,
PipelineStatusResponse,
JobSource,
PipelineRun
PipelineRun,
AppSettings,
} from '../../shared/types';
const API_BASE = '/api';
@ -92,6 +93,22 @@ export async function runPipeline(config?: {
});
}
// Settings API
export async function getSettings(): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings');
}
export async function updateSettings(update: {
model?: string | null
pipelineWebhookUrl?: string | null
jobCompleteWebhookUrl?: string | null
}): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings', {
method: 'PATCH',
body: JSON.stringify(update),
});
}
// Database API
export async function clearDatabase(): Promise<{
message: string;
@ -107,15 +124,4 @@ export async function clearDatabase(): Promise<{
});
}
// Bulk operations
export async function processAllDiscovered(): Promise<{
message: string;
count: number;
}> {
return fetchApi<{
message: string;
count: number;
}>('/jobs/process-discovered', {
method: 'POST',
});
}
// Bulk operations (intentionally none - processing is manual)

View File

@ -3,7 +3,16 @@
*/
import React from "react";
import { ChevronDown, Loader2, Play, RefreshCcw, Rocket, Trash2 } from "lucide-react";
import {
ChevronDown,
Loader2,
Play,
RefreshCcw,
Rocket,
Settings,
Trash2,
} from "lucide-react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
@ -65,29 +74,32 @@ export const Header: React.FC<HeaderProps> = ({
};
return (
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
<Rocket className="h-5 w-5" />
<header className='sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60'>
<div className='container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4'>
<Link
to='/'
className='flex items-center gap-3 hover:opacity-80 transition-opacity'
>
<div className='flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm'>
<Rocket className='h-5 w-5' />
</div>
<div className="leading-tight">
<div className="text-sm font-semibold tracking-tight">Job Ops</div>
<div className="text-xs text-muted-foreground">Orchestrator</div>
<div className='leading-tight'>
<div className='text-sm font-semibold tracking-tight'>Job Ops</div>
<div className='text-xs text-muted-foreground'>Orchestrator</div>
</div>
</div>
</Link>
<div className="flex flex-wrap items-center gap-2">
<div className='flex flex-wrap items-center gap-1.5'>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
variant='outline'
size='sm'
disabled={isLoading}
title="Clear all jobs from database"
title='Clear all jobs from database'
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear DB</span>
<Trash2 className='h-4 w-4' />
<span className='hidden sm:inline'>Clear DB</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@ -107,26 +119,42 @@ export const Header: React.FC<HeaderProps> = ({
</AlertDialogContent>
</AlertDialog>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCcw className="h-4 w-4" />
<span className="hidden sm:inline">Refresh</span>
<Button
variant='outline'
size='sm'
onClick={onRefresh}
disabled={isLoading}
>
<RefreshCcw className='h-4 w-4' />
<span className='hidden sm:inline'>Refresh</span>
</Button>
<div className="flex items-center">
<Button
asChild
variant='outline'
size='sm'
>
<Link to='/settings'>
<Settings className='h-4 w-4' />
<span className='hidden sm:inline'>Settings</span>
</Link>
</Button>
<div>
<Button
size="sm"
size='sm'
onClick={onRunPipeline}
disabled={isPipelineRunning}
className="rounded-r-none"
className='rounded-r-none'
>
{isPipelineRunning ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className='h-4 w-4 animate-spin' />
Running...
</>
) : (
<>
<Play className="h-4 w-4" />
<Play className='h-4 w-4' />
Run Pipeline
</>
)}
@ -135,34 +163,47 @@ export const Header: React.FC<HeaderProps> = ({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
size='sm'
disabled={isPipelineRunning}
className="rounded-l-none border-l border-primary-foreground/20 px-2"
aria-label="Select pipeline sources"
className='rounded-l-none border-l border-primary-foreground/20'
aria-label='Select pipeline sources'
>
<ChevronDown className="h-4 w-4" />
<ChevronDown className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuContent
align='end'
className='w-56'
>
<DropdownMenuLabel>Sources</DropdownMenuLabel>
<DropdownMenuSeparator />
{orderedSources.map((source) => (
<DropdownMenuCheckboxItem
key={source}
checked={pipelineSources.includes(source)}
onCheckedChange={(checked) => toggleSource(source, Boolean(checked))}
onCheckedChange={(checked) =>
toggleSource(source, Boolean(checked))
}
>
{sourceLabel[source]}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onPipelineSourcesChange(orderedSources)}>
<DropdownMenuItem
onSelect={() => onPipelineSourcesChange(orderedSources)}
>
All sources
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onPipelineSourcesChange(["gradcracker"])}>
<DropdownMenuItem
onSelect={() => onPipelineSourcesChange(["gradcracker"])}
>
Gradcracker only
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onPipelineSourcesChange(["indeed", "linkedin"])}>
<DropdownMenuItem
onSelect={() =>
onPipelineSourcesChange(["indeed", "linkedin"])
}
>
Indeed + LinkedIn only
</DropdownMenuItem>
</DropdownMenuContent>

View File

@ -6,6 +6,7 @@ import React from "react";
import {
Calendar,
CheckCircle2,
Copy,
DollarSign,
Download,
ExternalLink,
@ -15,19 +16,21 @@ import {
RefreshCcw,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { copyTextToClipboard, formatJobForLlmContext } from "@client/lib/jobCopy";
import type { Job } from "../../shared/types";
import { ScoreIndicator } from "./ScoreIndicator";
import { StatusBadge } from "./StatusBadge";
interface JobCardProps {
job: Job;
onApply: (id: string) => void;
onReject: (id: string) => void;
onProcess: (id: string) => void;
onApply: (id: string) => void | Promise<void>;
onReject: (id: string) => void | Promise<void>;
onProcess: (id: string) => void | Promise<void>;
isProcessing: boolean;
}
@ -68,6 +71,15 @@ export const JobCard: React.FC<JobCardProps> = ({
const pdfHref = `/pdfs/resume_${job.id}.pdf`;
const deadline = formatDate(job.deadline);
const handleCopyInfo = async () => {
try {
await copyTextToClipboard(formatJobForLlmContext(job));
toast.success("Copied job info", { description: "LLM-ready context copied to clipboard." });
} catch {
toast.error("Could not copy job info");
}
};
return (
<Card>
<CardHeader className="space-y-3">
@ -132,6 +144,11 @@ export const JobCard: React.FC<JobCardProps> = ({
</a>
</Button>
<Button variant="outline" size="sm" onClick={handleCopyInfo}>
<Copy className="mr-2 h-4 w-4" />
Copy info
</Button>
{hasPdf && (
<Button asChild variant="outline" size="sm">
<a href={pdfHref} target="_blank" rel="noopener noreferrer">

View File

@ -2,26 +2,52 @@
* Job list with filtering tabs.
*/
import React, { useMemo, useState } from "react";
import { Loader2, RefreshCcw } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { ArrowUpDown, LayoutGrid, Search, Table2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import type { Job, JobStatus } from "../../shared/types";
import { JobCard } from "./JobCard";
import { JobTable, type JobSort } from "./JobTable";
interface JobListProps {
jobs: Job[];
onApply: (id: string) => void;
onReject: (id: string) => void;
onProcess: (id: string) => void;
onProcessAll: () => void;
onApply: (id: string) => void | Promise<void>;
onReject: (id: string) => void | Promise<void>;
onProcess: (id: string) => void | Promise<void>;
processingJobId: string | null;
isProcessingAll: boolean;
}
type FilterTab = "ready" | "discovered" | "applied" | "all";
type ViewMode = "cards" | "table";
const JOB_LIST_VIEW_STORAGE_KEY = "jobops.jobs.viewMode";
const DEFAULT_SORT: JobSort = { key: "discoveredAt", direction: "desc" };
const sortLabels: Record<JobSort["key"], string> = {
discoveredAt: "Discovered",
score: "Score",
title: "Title",
employer: "Company",
source: "Source",
location: "Location",
status: "Status",
};
const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [
{ id: "ready", label: "Ready", statuses: ["ready"] },
@ -37,16 +63,146 @@ const emptyStateCopy: Record<FilterTab, string> = {
all: "No jobs in the system yet. Run the pipeline to get started!",
};
const statusRank: Record<JobStatus, number> = {
discovered: 0,
processing: 1,
ready: 2,
applied: 3,
rejected: 4,
expired: 5,
};
const dateValue = (value: string | null) => {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
};
const compareNullable = <T,>(
a: T | null | undefined,
b: T | null | undefined,
compare: (left: T, right: T) => number,
) => {
const left = a ?? null;
const right = b ?? null;
if (left === null && right === null) return 0;
if (left === null) return 1;
if (right === null) return -1;
return compare(left, right);
};
const compareString = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" });
const compareNumber = (a: number, b: number) => a - b;
const compareJobs = (a: Job, b: Job, sort: JobSort) => {
let value = 0;
switch (sort.key) {
case "title":
value = compareString(a.title, b.title);
break;
case "employer":
value = compareString(a.employer, b.employer);
break;
case "source":
value = compareString(a.source, b.source);
break;
case "location":
value = compareNullable(a.location, b.location, compareString);
break;
case "status":
value = statusRank[a.status] - statusRank[b.status];
break;
case "score":
const aScore = a.suitabilityScore;
const bScore = b.suitabilityScore;
if (aScore == null && bScore == null) {
value = 0;
break;
}
if (aScore == null) return 1;
if (bScore == null) return -1;
value = compareNumber(aScore, bScore);
break;
case "discoveredAt":
value = compareNullable(dateValue(a.discoveredAt), dateValue(b.discoveredAt), compareNumber);
break;
default:
value = 0;
}
if (value !== 0) return sort.direction === "asc" ? value : -value;
const tieByDiscovered = compareNullable(
dateValue(b.discoveredAt),
dateValue(a.discoveredAt),
compareNumber,
);
if (tieByDiscovered !== 0) return tieByDiscovered;
return a.id.localeCompare(b.id);
};
const jobMatchesQuery = (job: Job, query: string) => {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
const haystack = [
job.title,
job.employer,
job.location,
job.disciplines,
job.salary,
job.degreeRequired,
job.starting,
job.source,
job.status,
job.jobType,
job.jobFunction,
job.jobLevel,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(normalized);
};
export const JobList: React.FC<JobListProps> = ({
jobs,
onApply,
onReject,
onProcess,
onProcessAll,
processingJobId,
isProcessingAll,
}) => {
const [activeTab, setActiveTab] = useState<FilterTab>("ready");
const [searchQuery, setSearchQuery] = useState("");
const [sort, setSort] = useState<JobSort>(DEFAULT_SORT);
const [selectedJobIds, setSelectedJobIds] = useState<Set<string>>(() => new Set());
const [batchAction, setBatchAction] = useState<null | "process" | "reject" | "apply">(null);
const [viewMode, setViewMode] = useState<ViewMode>(() => {
try {
const raw = localStorage.getItem(JOB_LIST_VIEW_STORAGE_KEY);
if (raw === "cards" || raw === "table") return raw;
return "cards";
} catch {
return "cards";
}
});
useEffect(() => {
try {
localStorage.setItem(JOB_LIST_VIEW_STORAGE_KEY, viewMode);
} catch {
// Ignore localStorage errors
}
}, [viewMode]);
useEffect(() => {
setSelectedJobIds(new Set());
}, [activeTab, viewMode]);
const counts = useMemo(() => {
const byTab: Record<FilterTab, number> = {
@ -79,43 +235,201 @@ export const JobList: React.FC<JobListProps> = ({
return map;
}, [jobs]);
const visibleJobsForTab = useMemo(() => {
const map = new Map<FilterTab, Job[]>();
const normalizedQuery = searchQuery.trim().toLowerCase();
for (const tab of tabs) {
const base = jobsForTab.get(tab.id) ?? [];
const filtered = normalizedQuery ? base.filter((job) => jobMatchesQuery(job, normalizedQuery)) : base;
const sorted = [...filtered].sort((a, b) => compareJobs(a, b, sort));
map.set(tab.id, sorted);
}
return map;
}, [jobsForTab, searchQuery, sort]);
const activeTabJobs = visibleJobsForTab.get(activeTab) ?? [];
useEffect(() => {
setSelectedJobIds((current) => {
const visibleIds = new Set(activeTabJobs.map((job) => job.id));
const next = new Set<string>();
for (const id of current) {
if (visibleIds.has(id)) next.add(id);
}
return next.size === current.size ? current : next;
});
}, [activeTabJobs]);
const activeResultsCount = visibleJobsForTab.get(activeTab)?.length ?? 0;
const hasActiveFilters =
searchQuery.trim().length > 0 ||
sort.key !== DEFAULT_SORT.key ||
sort.direction !== DEFAULT_SORT.direction;
const selectedJobs = useMemo(() => {
if (selectedJobIds.size === 0) return [];
return activeTabJobs.filter((job) => selectedJobIds.has(job.id));
}, [activeTabJobs, selectedJobIds]);
const selectedCount = selectedJobIds.size;
const runBatch = async (action: "process" | "reject" | "apply") => {
if (selectedJobs.length === 0) return;
const eligible = selectedJobs.filter((job) => {
if (action === "process") return job.status === "discovered";
if (action === "apply") return job.status === "ready";
return job.status === "discovered" || job.status === "ready";
});
const skipped = selectedJobs.length - eligible.length;
if (eligible.length === 0) {
toast.message("No eligible jobs selected");
return;
}
setBatchAction(action);
try {
for (const job of eligible) {
if (action === "process") await Promise.resolve(onProcess(job.id));
if (action === "apply") await Promise.resolve(onApply(job.id));
if (action === "reject") await Promise.resolve(onReject(job.id));
}
setSelectedJobIds(new Set());
const actionLabel = action === "process" ? "Processed" : action === "apply" ? "Applied" : "Skipped";
toast.success(`${actionLabel} ${eligible.length} jobs`, skipped > 0 ? { description: `Skipped ${skipped} ineligible.` } : undefined);
} finally {
setBatchAction(null);
}
};
return (
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as FilterTab)}
className="space-y-4"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<TabsList className="w-full sm:w-auto">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 sm:flex-none">
{tab.label}
<span className="ml-2 text-xs tabular-nums text-muted-foreground">
({counts[tab.id]})
</span>
</TabsTrigger>
))}
</TabsList>
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<TabsList className="w-full sm:w-auto h-9">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 sm:flex-none">
{tab.label}
<span className="ml-2 text-xs tabular-nums text-muted-foreground">
({counts[tab.id]})
</span>
</TabsTrigger>
))}
</TabsList>
{activeTab === "discovered" && counts.discovered > 0 && (
<Button onClick={onProcessAll} disabled={isProcessingAll} size="sm">
{isProcessingAll ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<RefreshCcw className="mr-2 h-4 w-4" />
Process All ({counts.discovered})
</>
<div className="flex items-center justify-between gap-1.5 sm:justify-end">
<div className="flex items-center rounded-md border bg-muted/20 p-0.5 h-9">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setViewMode("cards")}
aria-pressed={viewMode === "cards"}
className={cn("h-8 w-8", viewMode === "cards" && "bg-background shadow-sm")}
title="Card view"
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setViewMode("table")}
aria-pressed={viewMode === "table"}
className={cn("h-8 w-8", viewMode === "table" && "bg-background shadow-sm")}
title="List view"
>
<Table2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Filter jobs..."
className="pl-9"
/>
</div>
<div className="flex flex-wrap items-center gap-2 self-start sm:self-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
<ArrowUpDown className="h-4 w-4" />
<span className="hidden sm:inline">
Sort: {sortLabels[sort.key]} {sort.direction === "asc" ? "↑" : "↓"}
</span>
<span className="sm:hidden">Sort</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sort.key}
onValueChange={(value) =>
setSort({
key: value as JobSort["key"],
direction:
value === "score" || value === "discoveredAt"
? "desc"
: "asc",
})
}
>
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sortLabels[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() =>
setSort((current) => ({
...current,
direction: current.direction === "asc" ? "desc" : "asc",
}))
}
>
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="text-sm tabular-nums text-muted-foreground">{activeResultsCount} jobs</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchQuery("");
setSort(DEFAULT_SORT);
}}
>
Reset filters
</Button>
)}
</Button>
)}
</div>
</div>
</div>
{tabs.map((tab) => {
const filteredJobs = jobsForTab.get(tab.id) ?? [];
const filteredJobs = visibleJobsForTab.get(tab.id) ?? [];
const trimmedQuery = searchQuery.trim();
return (
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
@ -123,22 +437,88 @@ export const JobList: React.FC<JobListProps> = ({
<Card className="border-dashed bg-muted/20">
<CardContent className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<div className="text-base font-semibold">No jobs found</div>
<p className="max-w-xl text-sm text-muted-foreground">{emptyStateCopy[tab.id]}</p>
<p className="max-w-xl text-sm text-muted-foreground">
{trimmedQuery ? `No jobs match "${trimmedQuery}".` : emptyStateCopy[tab.id]}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{filteredJobs.map((job) => (
<JobCard
key={job.id}
job={job}
onApply={onApply}
onReject={onReject}
onProcess={onProcess}
isProcessing={processingJobId === job.id}
/>
))}
</div>
<>
{viewMode === "table" ? (
<div className="space-y-2">
{tab.id === activeTab && selectedCount > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/20 px-3 py-2">
<div className="text-sm">
<span className="font-medium">{selectedCount}</span>{" "}
<span className="text-muted-foreground">selected</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => runBatch("process")}
disabled={batchAction !== null}
>
Generate Resumes
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => runBatch("reject")}
disabled={batchAction !== null}
>
Skip
</Button>
<Button
size="sm"
onClick={() => runBatch("apply")}
disabled={batchAction !== null}
>
Mark Applied
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setSelectedJobIds(new Set())}
disabled={batchAction !== null}
>
Clear
</Button>
</div>
</div>
)}
<Card>
<CardContent className="p-0">
<JobTable
jobs={filteredJobs}
sort={sort}
onSortChange={setSort}
selectedJobIds={selectedJobIds}
onSelectedJobIdsChange={setSelectedJobIds}
onApply={onApply}
onReject={onReject}
onProcess={onProcess}
processingJobId={processingJobId}
/>
</CardContent>
</Card>
</div>
) : (
<div className="grid gap-4">
{filteredJobs.map((job) => (
<JobCard
key={job.id}
job={job}
onApply={onApply}
onReject={onReject}
onProcess={onProcess}
isProcessing={processingJobId === job.id}
/>
))}
</div>
)}
</>
)}
</TabsContent>
);
@ -146,4 +526,3 @@ export const JobList: React.FC<JobListProps> = ({
</Tabs>
);
};

View File

@ -0,0 +1,351 @@
/**
* Table-based job list view.
*/
import React from "react";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
CheckCircle2,
Copy,
Download,
ExternalLink,
MoreHorizontal,
RefreshCcw,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { copyTextToClipboard, formatJobForLlmContext } from "@client/lib/jobCopy";
import type { Job } from "../../shared/types";
import { StatusBadge } from "./StatusBadge";
export type JobSortKey =
| "title"
| "employer"
| "source"
| "location"
| "status"
| "score"
| "discoveredAt";
export type JobSortDirection = "asc" | "desc";
export interface JobSort {
key: JobSortKey;
direction: JobSortDirection;
}
export interface JobTableProps {
jobs: Job[];
sort: JobSort;
onSortChange: (sort: JobSort) => void;
selectedJobIds: Set<string>;
onSelectedJobIdsChange: (ids: Set<string>) => void;
onApply: (id: string) => void | Promise<void>;
onReject: (id: string) => void | Promise<void>;
onProcess: (id: string) => void | Promise<void>;
processingJobId: string | null;
}
const sourceLabel: Record<Job["source"], string> = {
gradcracker: "Gradcracker",
indeed: "Indeed",
linkedin: "LinkedIn",
};
const defaultSortDirection: Record<JobSortKey, JobSortDirection> = {
title: "asc",
employer: "asc",
source: "asc",
location: "asc",
status: "asc",
score: "desc",
discoveredAt: "desc",
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return null;
try {
return new Date(dateStr).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return dateStr;
}
};
const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_");
const SortButton: React.FC<{
label: string;
sortKey: JobSortKey;
sort: JobSort;
onSortChange: (sort: JobSort) => void;
className?: string;
}> = ({ label, sortKey, sort, onSortChange, className }) => {
const isActive = sort.key === sortKey;
const Icon = isActive ? (sort.direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (!isActive) {
onSortChange({ key: sortKey, direction: defaultSortDirection[sortKey] });
return;
}
onSortChange({
key: sortKey,
direction: sort.direction === "asc" ? "desc" : "asc",
});
}}
className={cn("h-8 w-full justify-start -mx-2 px-2 font-medium", className)}
>
{label}
<Icon className={cn("ml-1 h-3.5 w-3.5", !isActive && "opacity-60")} />
</Button>
);
};
export const JobTable: React.FC<JobTableProps> = ({
jobs,
sort,
onSortChange,
selectedJobIds,
onSelectedJobIdsChange,
onApply,
onReject,
onProcess,
processingJobId,
}) => {
const selectedCount = jobs.reduce((count, job) => count + (selectedJobIds.has(job.id) ? 1 : 0), 0);
const allSelected = jobs.length > 0 && selectedCount === jobs.length;
const someSelected = selectedCount > 0 && selectedCount < jobs.length;
const handleCopyInfo = async (job: Job) => {
try {
await copyTextToClipboard(formatJobForLlmContext(job));
toast.success("Copied job info", { description: "LLM-ready context copied to clipboard." });
} catch {
toast.error("Could not copy job info");
}
};
return (
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
aria-label="Select all rows"
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={(checked) => {
const next = new Set(selectedJobIds);
if (checked) {
for (const job of jobs) next.add(job.id);
} else {
for (const job of jobs) next.delete(job.id);
}
onSelectedJobIdsChange(next);
}}
/>
</TableHead>
<TableHead className="w-[28%]">
<SortButton label="Title" sortKey="title" sort={sort} onSortChange={onSortChange} />
</TableHead>
<TableHead className="w-[18%]">
<SortButton label="Company" sortKey="employer" sort={sort} onSortChange={onSortChange} />
</TableHead>
<TableHead>
<SortButton label="Source" sortKey="source" sort={sort} onSortChange={onSortChange} />
</TableHead>
<TableHead>
<SortButton label="Location" sortKey="location" sort={sort} onSortChange={onSortChange} />
</TableHead>
<TableHead>
<SortButton label="Status" sortKey="status" sort={sort} onSortChange={onSortChange} />
</TableHead>
<TableHead className="w-[10%] text-right">
<SortButton
label="Score"
sortKey="score"
sort={sort}
onSortChange={onSortChange}
className="justify-end"
/>
</TableHead>
<TableHead>
<SortButton
label="Discovered"
sortKey="discoveredAt"
sort={sort}
onSortChange={onSortChange}
/>
</TableHead>
<TableHead className="w-[1%] pr-3 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => {
const jobLink = job.applicationLink || job.jobUrl;
const hasPdf = !!job.pdfPath;
const pdfHref = `/pdfs/resume_${job.id}.pdf`;
const canApply = job.status === "ready";
const canProcess = job.status === "discovered";
const canReject = ["discovered", "ready"].includes(job.status);
const isProcessing = processingJobId === job.id;
const isSelected = selectedJobIds.has(job.id);
return (
<TableRow key={job.id} data-state={isSelected ? "selected" : undefined}>
<TableCell className="align-middle">
<Checkbox
aria-label={`Select ${job.title}`}
checked={isSelected}
onCheckedChange={(checked) => {
const next = new Set(selectedJobIds);
if (checked) next.add(job.id);
else next.delete(job.id);
onSelectedJobIdsChange(next);
}}
/>
</TableCell>
<TableCell className="align-middle">
<Button
asChild
variant="link"
size="sm"
className="h-auto justify-start p-0 text-left leading-snug whitespace-normal break-words"
>
<a href={jobLink} target="_blank" rel="noopener noreferrer">
{job.title}
</a>
</Button>
</TableCell>
<TableCell className="align-middle whitespace-normal break-words">
{job.employer}
</TableCell>
<TableCell>
<Badge variant="outline" className="uppercase tracking-wide">
{sourceLabel[job.source]}
</Badge>
</TableCell>
<TableCell className="align-middle whitespace-normal break-words text-muted-foreground">
{job.location || "—"}
</TableCell>
<TableCell>
<StatusBadge status={job.status} />
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{job.suitabilityScore ?? "—"}
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{formatDate(job.discoveredAt)}
</TableCell>
<TableCell className="pr-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open actions menu">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<a href={jobLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View Job
</a>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleCopyInfo(job)}>
<Copy className="mr-2 h-4 w-4" />
Copy info
</DropdownMenuItem>
{hasPdf && (
<>
<DropdownMenuItem asChild>
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View PDF
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href={pdfHref}
download={`resume_${safeFilenamePart(job.employer)}_${safeFilenamePart(job.title)}.pdf`}
>
<Download className="mr-2 h-4 w-4" />
Download PDF
</a>
</DropdownMenuItem>
</>
)}
{(canProcess || canReject || canApply) && <DropdownMenuSeparator />}
{canProcess && (
<DropdownMenuItem
onSelect={() => onProcess(job.id)}
disabled={isProcessing}
>
<RefreshCcw className="mr-2 h-4 w-4" />
{isProcessing ? "Processing..." : "Generate Resume"}
</DropdownMenuItem>
)}
{canReject && (
<DropdownMenuItem
onSelect={() => onReject(job.id)}
>
<XCircle className="mr-2 h-4 w-4" />
Skip
</DropdownMenuItem>
)}
{canApply && (
<DropdownMenuItem
onSelect={() => onApply(job.id)}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Mark Applied
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
};

View File

@ -3,5 +3,6 @@ export { Stats } from './Stats';
export { StatusBadge } from './StatusBadge';
export { ScoreIndicator } from './ScoreIndicator';
export { JobCard } from './JobCard';
export { JobTable } from './JobTable';
export { JobList } from './JobList';
export { PipelineProgress } from './PipelineProgress';

View File

@ -0,0 +1,110 @@
import type { Job } from "@shared/types";
const pushLine = (lines: string[], label: string, value: unknown) => {
if (value == null) return;
const normalized = typeof value === "string" ? value.trim() : String(value);
if (!normalized) return;
lines.push(`${label}: ${normalized}`);
};
const pushBlock = (lines: string[], heading: string, value: string | null | undefined) => {
const normalized = value?.trim();
if (!normalized) return;
lines.push("");
lines.push(`${heading}:`);
lines.push(normalized);
};
export const formatJobForLlmContext = (job: Job) => {
const jobLink = job.applicationLink || job.jobUrl;
const lines: string[] = [];
lines.push("JOB CONTEXT");
pushLine(lines, "Title", job.title);
pushLine(lines, "Company", job.employer);
pushLine(lines, "Source", job.source);
pushLine(lines, "Status", job.status);
pushLine(lines, "Job URL", job.jobUrl);
pushLine(lines, "Application link", job.applicationLink);
pushLine(lines, "Best link", jobLink);
pushLine(lines, "Direct URL", job.jobUrlDirect);
pushLine(lines, "Source job id", job.sourceJobId);
pushLine(lines, "Location", job.location);
pushLine(lines, "Remote", job.isRemote);
pushLine(lines, "Disciplines", job.disciplines);
pushLine(lines, "Job type", job.jobType);
pushLine(lines, "Job level", job.jobLevel);
pushLine(lines, "Job function", job.jobFunction);
pushLine(lines, "Listing type", job.listingType);
pushLine(lines, "Salary", job.salary);
if (job.salaryMinAmount != null || job.salaryMaxAmount != null) {
pushLine(
lines,
"Salary range",
[
job.salaryMinAmount != null ? String(job.salaryMinAmount) : null,
job.salaryMaxAmount != null ? String(job.salaryMaxAmount) : null,
]
.filter(Boolean)
.join(" - "),
);
}
pushLine(lines, "Salary interval", job.salaryInterval);
pushLine(lines, "Salary currency", job.salaryCurrency);
pushLine(lines, "Salary source", job.salarySource);
pushLine(lines, "Degree required", job.degreeRequired);
pushLine(lines, "Starting", job.starting);
pushLine(lines, "Deadline", job.deadline);
pushLine(lines, "Date posted", job.datePosted);
pushLine(lines, "Skills", job.skills);
pushLine(lines, "Experience", job.experienceRange);
pushLine(lines, "Emails", job.emails);
pushLine(lines, "Company industry", job.companyIndustry);
pushLine(lines, "Company URL", job.companyUrlDirect || job.employerUrl);
pushLine(lines, "Company employees", job.companyNumEmployees);
pushLine(lines, "Company revenue", job.companyRevenue);
pushLine(lines, "Company rating", job.companyRating);
pushLine(lines, "Company reviews", job.companyReviewsCount);
pushLine(lines, "Company addresses", job.companyAddresses);
pushLine(lines, "Discovered", job.discoveredAt);
pushLine(lines, "Processed", job.processedAt);
pushBlock(lines, "Job description", job.jobDescription);
pushBlock(lines, "Company description", job.companyDescription);
return lines.join("\n").trim() + "\n";
};
export async function copyTextToClipboard(text: string) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
if (!ok) {
throw new Error("Copy failed");
}
}

View File

@ -1,10 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import '../index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,243 @@
/**
* Settings page.
*/
import React, { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import type { AppSettings } from "../../shared/types"
import * as api from "../api"
export const SettingsPage: React.FC = () => {
const [settings, setSettings] = useState<AppSettings | null>(null)
const [modelDraft, setModelDraft] = useState("")
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("")
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let isMounted = true
setIsLoading(true)
api
.getSettings()
.then((data) => {
if (!isMounted) return
setSettings(data)
setModelDraft(data.overrideModel ?? "")
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "")
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Failed to load settings"
toast.error(message)
})
.finally(() => {
if (!isMounted) return
setIsLoading(false)
})
return () => {
isMounted = false
}
}, [])
const effectiveModel = settings?.model ?? ""
const defaultModel = settings?.defaultModel ?? ""
const overrideModel = settings?.overrideModel
const effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? ""
const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? ""
const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl
const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? ""
const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? ""
const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl
const canSave = useMemo(() => {
if (!settings) return false
const next = modelDraft.trim()
const current = (overrideModel ?? "").trim()
const nextWebhook = pipelineWebhookUrlDraft.trim()
const currentWebhook = (overridePipelineWebhookUrl ?? "").trim()
const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim()
const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim()
return (
next !== current ||
nextWebhook !== currentWebhook ||
nextJobCompleteWebhook !== currentJobCompleteWebhook
)
}, [
settings,
modelDraft,
pipelineWebhookUrlDraft,
jobCompleteWebhookUrlDraft,
overrideModel,
overridePipelineWebhookUrl,
overrideJobCompleteWebhookUrl,
])
const handleSave = async () => {
if (!settings) return
try {
setIsSaving(true)
const trimmed = modelDraft.trim()
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim()
const updated = await api.updateSettings({
model: trimmed.length > 0 ? trimmed : null,
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null,
})
setSettings(updated)
setModelDraft(updated.overrideModel ?? "")
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "")
toast.success("Settings saved")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save settings"
toast.error(message)
} finally {
setIsSaving(false)
}
}
const handleReset = async () => {
try {
setIsSaving(true)
const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null })
setSettings(updated)
setModelDraft("")
setPipelineWebhookUrlDraft("")
setJobCompleteWebhookUrlDraft("")
toast.success("Reset to default")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to reset settings"
toast.error(message)
} finally {
setIsSaving(false)
}
}
return (
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
<div className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground">Configure runtime behavior for this app.</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Model</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Override model</div>
<Input
value={modelDraft}
onChange={(event) => setModelDraft(event.target.value)}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
Leave blank to use the default from server env (`MODEL`).
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveModel || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultModel || "—"}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Pipeline Webhook</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Pipeline status webhook URL</div>
<Input
value={pipelineWebhookUrlDraft}
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectivePipelineWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultPipelineWebhookUrl || "—"}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Job Complete Webhook</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Job completion webhook URL</div>
<Input
value={jobCompleteWebhookUrlDraft}
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
<div className="text-xs text-muted-foreground">
When set, the server sends a POST when you mark a job as applied (includes the job description).
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveJobCompleteWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
{isSaving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
Reset to default
</Button>
</div>
</main>
)
}

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = "Checkbox"
export { Checkbox }

View File

@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,113 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
)
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
)
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -6,13 +6,42 @@ import { Router, Request, Response } from 'express';
import { z } from 'zod';
import * as jobsRepo from '../repositories/jobs.js';
import * as pipelineRepo from '../repositories/pipeline.js';
import * as settingsRepo from '../repositories/settings.js';
import { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
import { createNotionEntry } from '../services/notion.js';
import { clearDatabase } from '../db/clear.js';
import type { JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
export const apiRouter = Router();
async function notifyJobCompleteWebhook(job: Job) {
const overrideWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl')
const webhookUrl = (overrideWebhookUrl || process.env.JOB_COMPLETE_WEBHOOK_URL || '').trim()
if (!webhookUrl) return
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const secret = process.env.WEBHOOK_SECRET
if (secret) headers.Authorization = `Bearer ${secret}`
const response = await fetch(webhookUrl, {
method: 'POST',
headers,
body: JSON.stringify({
event: 'job.completed',
sentAt: new Date().toISOString(),
job,
}),
})
if (!response.ok) {
console.warn(`⚠️ Job complete webhook POST failed (${response.status}): ${await response.text()}`)
}
} catch (error) {
console.warn('⚠️ Job complete webhook POST failed:', error)
}
}
// ============================================================================
// Jobs API
// ============================================================================
@ -144,6 +173,10 @@ apiRouter.post('/jobs/:id/apply', async (req: Request, res: Response) => {
appliedAt,
notionPageId: notionResult.pageId,
});
if (updatedJob) {
notifyJobCompleteWebhook(updatedJob).catch(console.warn)
}
res.json({ success: true, data: updatedJob });
} catch (error) {
@ -170,32 +203,40 @@ apiRouter.post('/jobs/:id/reject', async (req: Request, res: Response) => {
}
});
// ============================================================================
// Pipeline API
// ============================================================================
/**
* POST /api/jobs/process-discovered - Process all discovered jobs (generate PDFs)
* GET /api/settings - Get app settings (effective + defaults)
*/
apiRouter.post('/jobs/process-discovered', async (req: Request, res: Response) => {
apiRouter.get('/settings', async (_req: Request, res: Response) => {
try {
const discoveredJobs = await jobsRepo.getAllJobs(['discovered']);
// Process each job in background
const processInBackground = async () => {
for (const job of discoveredJobs.filter(j => j.status === 'discovered')) {
try {
await processJob(job.id);
} catch (error) {
console.error(`Failed to process job ${job.id}:`, error);
}
}
};
processInBackground().catch(console.error);
res.json({
success: true,
data: {
message: `Processing ${discoveredJobs.length} jobs`,
count: discoveredJobs.length,
}
const overrideModel = await settingsRepo.getSetting('model');
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
const model = overrideModel || defaultModel;
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl');
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl');
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
res.json({
success: true,
data: {
model,
defaultModel,
overrideModel,
pipelineWebhookUrl,
defaultPipelineWebhookUrl,
overridePipelineWebhookUrl,
jobCompleteWebhookUrl,
defaultJobCompleteWebhookUrl,
overrideJobCompleteWebhookUrl,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@ -203,9 +244,65 @@ apiRouter.post('/jobs/process-discovered', async (req: Request, res: Response) =
}
});
// ============================================================================
// Pipeline API
// ============================================================================
const updateSettingsSchema = z.object({
model: z.string().trim().min(1).max(200).nullable().optional(),
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
jobCompleteWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
});
/**
* PATCH /api/settings - Update settings overrides
*/
apiRouter.patch('/settings', async (req: Request, res: Response) => {
try {
const input = updateSettingsSchema.parse(req.body);
if ('model' in input) {
const model = input.model ?? null;
await settingsRepo.setSetting('model', model);
}
if ('pipelineWebhookUrl' in input) {
const pipelineWebhookUrl = input.pipelineWebhookUrl ?? null;
await settingsRepo.setSetting('pipelineWebhookUrl', pipelineWebhookUrl);
}
if ('jobCompleteWebhookUrl' in input) {
const webhookUrl = input.jobCompleteWebhookUrl ?? null;
await settingsRepo.setSetting('jobCompleteWebhookUrl', webhookUrl);
}
const overrideModel = await settingsRepo.getSetting('model');
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
const model = overrideModel || defaultModel;
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl');
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl');
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
res.json({
success: true,
data: {
model,
defaultModel,
overrideModel,
pipelineWebhookUrl,
defaultPipelineWebhookUrl,
overridePipelineWebhookUrl,
jobCompleteWebhookUrl,
defaultJobCompleteWebhookUrl,
overrideJobCompleteWebhookUrl,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(400).json({ success: false, error: message });
}
});
/**
* GET /api/pipeline/status - Get pipeline status

View File

@ -88,6 +88,18 @@ const migrations = [
error_message TEXT
)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
// Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run)
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
`DELETE FROM settings WHERE key = 'webhookUrl'`,
// Add source column for existing databases (safe to skip if already present)
`ALTER TABLE jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'gradcracker'`,
`UPDATE jobs SET source = 'gradcracker' WHERE source IS NULL OR source = ''`,

View File

@ -82,7 +82,16 @@ export const pipelineRuns = sqliteTable('pipeline_runs', {
errorMessage: text('error_message'),
});
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
createdAt: text('created_at').notNull().default(sql`(datetime('now'))`),
updatedAt: text('updated_at').notNull().default(sql`(datetime('now'))`),
});
export type JobRow = typeof jobs.$inferSelect;
export type NewJobRow = typeof jobs.$inferInsert;
export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
export type SettingsRow = typeof settings.$inferSelect;
export type NewSettingsRow = typeof settings.$inferInsert;

View File

@ -4,10 +4,7 @@
* Flow:
* 1. Run crawler to discover new jobs
* 2. Score jobs for suitability
* 3. Pick top N jobs
* 4. Generate tailored summaries
* 5. Generate PDF resumes
* 6. Mark as "ready" for user review
* 3. Leave all jobs in "discovered" for manual processing
*/
import { readFile } from 'fs/promises';
@ -15,11 +12,12 @@ import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { runCrawler } from '../services/crawler.js';
import { runJobSpy } from '../services/jobspy.js';
import { scoreAndRankJobs, scoreJobSuitability } from '../services/scorer.js';
import { scoreJobSuitability } from '../services/scorer.js';
import { generateSummary } from '../services/summary.js';
import { generatePdf } from '../services/pdf.js';
import * as jobsRepo from '../repositories/jobs.js';
import * as pipelineRepo from '../repositories/pipeline.js';
import * as settingsRepo from '../repositories/settings.js';
import { progressHelpers, resetProgress, updateProgress } from './progress.js';
import type { CreateJobInput, Job, JobSource, PipelineConfig } from '../../shared/types.js';
@ -37,6 +35,42 @@ const DEFAULT_CONFIG: PipelineConfig = {
// Track if pipeline is currently running
let isPipelineRunning = false;
async function notifyPipelineWebhook(
event: 'pipeline.completed' | 'pipeline.failed',
payload: Record<string, unknown>
) {
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl')
const pipelineWebhookUrl = (
overridePipelineWebhookUrl ||
process.env.PIPELINE_WEBHOOK_URL ||
process.env.WEBHOOK_URL ||
''
).trim()
if (!pipelineWebhookUrl) return
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const secret = process.env.WEBHOOK_SECRET
if (secret) headers.Authorization = `Bearer ${secret}`
const response = await fetch(pipelineWebhookUrl, {
method: 'POST',
headers,
body: JSON.stringify({
event,
sentAt: new Date().toISOString(),
...payload,
}),
})
if (!response.ok) {
console.warn(`⚠️ Pipeline webhook POST failed (${response.status}): ${await response.text()}`)
}
} catch (error) {
console.warn('⚠️ Pipeline webhook POST failed:', error)
}
}
/**
* Run the full job discovery and processing pipeline.
*/
@ -63,7 +97,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
const pipelineRun = await pipelineRepo.createPipelineRun();
console.log('🚀 Starting job pipeline...');
console.log(` Config: topN=${mergedConfig.topN}, minScore=${mergedConfig.minSuitabilityScore}`);
console.log(` Config: topN=${mergedConfig.topN}, minScore=${mergedConfig.minSuitabilityScore} (manual processing)`);
try {
// Step 1: Load profile
@ -141,9 +175,18 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
jobsDiscovered: created,
});
// Step 4: Get unprocessed jobs and score them
// Step 4: Score all discovered jobs missing a score
console.log('\n🎯 Scoring jobs for suitability...');
const unprocessedJobs = await jobsRepo.getJobsForProcessing(50);
const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs();
updateProgress({
step: 'scoring',
jobsDiscovered: unprocessedJobs.length,
jobsScored: 0,
jobsProcessed: 0,
totalToProcess: 0,
currentJob: undefined,
});
// Score jobs with progress updates
const scoredJobs: Array<Job & { suitabilityScore: number; suitabilityReason: string }> = [];
@ -175,106 +218,34 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
});
}
// Sort by score
scoredJobs.sort((a, b) => b.suitabilityScore - a.suitabilityScore);
// Step 5: Pick top N jobs above threshold
const topJobs = scoredJobs
.filter(j => j.suitabilityScore >= mergedConfig.minSuitabilityScore)
.slice(0, mergedConfig.topN);
progressHelpers.scoringComplete(scoredJobs.length, topJobs.length);
console.log(`\n📊 Selected ${topJobs.length} top jobs for processing:`);
for (const job of topJobs) {
console.log(` - ${job.title} @ ${job.employer} (score: ${job.suitabilityScore})`);
}
// Step 6: Process each top job
let processed = 0;
for (let i = 0; i < topJobs.length; i++) {
const job = topJobs[i];
console.log(`\n📝 Processing: ${job.title} @ ${job.employer}`);
progressHelpers.processingJob(i + 1, topJobs.length, {
id: job.id,
title: job.title,
employer: job.employer,
});
try {
// Mark as processing
await jobsRepo.updateJob(job.id, { status: 'processing' });
// Generate tailored summary
console.log(' Generating summary...');
progressHelpers.generatingSummary({ title: job.title, employer: job.employer });
const summaryResult = await generateSummary(
job.jobDescription || '',
profile
);
if (!summaryResult.success) {
console.warn(` ⚠️ Summary generation failed: ${summaryResult.error}`);
continue;
}
// Update job with summary
await jobsRepo.updateJob(job.id, {
tailoredSummary: summaryResult.summary,
});
// Generate PDF
console.log(' Generating PDF...');
progressHelpers.generatingPdf({ title: job.title, employer: job.employer });
const pdfResult = await generatePdf(
job.id,
summaryResult.summary!,
mergedConfig.profilePath
);
if (!pdfResult.success) {
console.warn(` ⚠️ PDF generation failed: ${pdfResult.error}`);
// Still mark as ready even if PDF failed - user can regenerate
}
// Mark as ready
await jobsRepo.updateJob(job.id, {
status: 'ready',
pdfPath: pdfResult.pdfPath ?? undefined,
});
processed++;
progressHelpers.jobComplete(processed, topJobs.length);
console.log(` ✅ Ready for review!`);
} catch (error) {
console.error(` ❌ Failed to process job: ${error}`);
// Continue with next job
}
}
progressHelpers.scoringComplete(scoredJobs.length);
console.log(`\n📊 Scored ${scoredJobs.length} jobs. Ready for manual processing.`);
// Update pipeline run as completed
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
status: 'completed',
completedAt: new Date().toISOString(),
jobsProcessed: processed,
jobsProcessed: 0,
});
console.log('\n🎉 Pipeline completed!');
console.log(` Jobs discovered: ${created}`);
console.log(` Jobs processed: ${processed}`);
console.log(' Jobs processed: 0 (manual)');
progressHelpers.complete(created, processed);
progressHelpers.complete(created, 0);
await notifyPipelineWebhook('pipeline.completed', {
pipelineRunId: pipelineRun.id,
jobsDiscovered: created,
jobsScored: unprocessedJobs.length,
jobsProcessed: 0,
})
isPipelineRunning = false;
return {
success: true,
jobsDiscovered: created,
jobsProcessed: processed,
jobsProcessed: 0,
};
} catch (error) {
@ -287,6 +258,11 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
});
progressHelpers.failed(message);
await notifyPipelineWebhook('pipeline.failed', {
pipelineRunId: pipelineRun.id,
error: message,
})
isPipelineRunning = false;
console.error('\n❌ Pipeline failed:', message);

View File

@ -215,12 +215,14 @@ export const progressHelpers = {
jobsScored: index,
}),
scoringComplete: (totalScored: number, topN: number) => updateProgress({
step: 'processing',
message: `Scored ${totalScored} jobs. Processing top ${topN}...`,
detail: 'Generating tailored resumes',
scoringComplete: (totalScored: number) => updateProgress({
step: 'scoring',
message: `Scored ${totalScored} jobs.`,
detail: 'Ready for manual processing',
jobsScored: totalScored,
totalToProcess: topN,
totalToProcess: 0,
jobsProcessed: 0,
currentJob: undefined,
}),
processingJob: (index: number, total: number, job: { id: string; title: string; employer: string }) => updateProgress({

View File

@ -2,7 +2,7 @@
* Job repository - data access layer for jobs.
*/
import { eq, desc, sql, and, inArray } from 'drizzle-orm';
import { eq, desc, sql, and, inArray, isNull } from 'drizzle-orm';
import { randomUUID } from 'crypto';
import { db, schema } from '../db/index.js';
import type { Job, CreateJobInput, UpdateJobInput, JobStatus } from '../../shared/types.js';
@ -195,6 +195,20 @@ export async function getJobsForProcessing(limit: number = 10): Promise<Job[]> {
return rows.map(mapRowToJob);
}
/**
* Get discovered jobs missing a suitability score.
*/
export async function getUnscoredDiscoveredJobs(limit?: number): Promise<Job[]> {
const query = db
.select()
.from(jobs)
.where(and(eq(jobs.status, 'discovered'), isNull(jobs.suitabilityScore)))
.orderBy(desc(jobs.discoveredAt));
const rows = typeof limit === 'number' ? await query.limit(limit) : await query;
return rows.map(mapRowToJob);
}
// Helper to map database row to Job type
function mapRowToJob(row: typeof jobs.$inferSelect): Job {
return {

View File

@ -0,0 +1,43 @@
/**
* Settings repository - key/value storage for runtime configuration.
*/
import { eq } from 'drizzle-orm'
import { db, schema } from '../db/index.js'
const { settings } = schema
export type SettingKey = 'model'
| 'pipelineWebhookUrl'
| 'jobCompleteWebhookUrl'
export async function getSetting(key: SettingKey): Promise<string | null> {
const [row] = await db.select().from(settings).where(eq(settings.key, key))
return row?.value ?? null
}
export async function setSetting(key: SettingKey, value: string | null): Promise<void> {
const now = new Date().toISOString()
if (value === null) {
await db.delete(settings).where(eq(settings.key, key))
return
}
const [existing] = await db.select({ key: settings.key }).from(settings).where(eq(settings.key, key))
if (existing) {
await db
.update(settings)
.set({ value, updatedAt: now })
.where(eq(settings.key, key))
return
}
await db.insert(settings).values({
key,
value,
createdAt: now,
updatedAt: now,
})
}

View File

@ -3,6 +3,7 @@
*/
import type { Job } from '../../shared/types.js';
import { getSetting } from '../repositories/settings.js';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
@ -24,7 +25,8 @@ export async function scoreJobSuitability(
return mockScore(job);
}
const model = process.env.MODEL || 'openai/gpt-4o-mini';
const overrideModel = await getSetting('model');
const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
const prompt = buildScoringPrompt(job, profile);

View File

@ -171,3 +171,15 @@ export interface PipelineStatusResponse {
lastRun: PipelineRun | null;
nextScheduledRun: string | null;
}
export interface AppSettings {
model: string;
defaultModel: string;
overrideModel: string | null;
pipelineWebhookUrl: string;
defaultPipelineWebhookUrl: string;
overridePipelineWebhookUrl: string | null;
jobCompleteWebhookUrl: string;
defaultJobCompleteWebhookUrl: string;
overrideJobCompleteWebhookUrl: string | null;
}