commit
3b4845c232
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
44
orchestrator/package-lock.json
generated
44
orchestrator/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
351
orchestrator/src/client/components/JobTable.tsx
Normal file
351
orchestrator/src/client/components/JobTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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';
|
||||
|
||||
110
orchestrator/src/client/lib/jobCopy.ts
Normal file
110
orchestrator/src/client/lib/jobCopy.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
243
orchestrator/src/client/pages/SettingsPage.tsx
Normal file
243
orchestrator/src/client/pages/SettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
orchestrator/src/components/ui/checkbox.tsx
Normal file
27
orchestrator/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
|
||||
23
orchestrator/src/components/ui/input.tsx
Normal file
23
orchestrator/src/components/ui/input.tsx
Normal 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 }
|
||||
|
||||
113
orchestrator/src/components/ui/table.tsx
Normal file
113
orchestrator/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = ''`,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 {
|
||||
|
||||
43
orchestrator/src/server/repositories/settings.ts
Normal file
43
orchestrator/src/server/repositories/settings.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user