diff --git a/.env.example b/.env.example index aedda52..8598d5e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/orchestrator/.env.example b/orchestrator/.env.example index 39a12e5..a12357a 100644 --- a/orchestrator/.env.example +++ b/orchestrator/.env.example @@ -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 diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 4e2171b..bc7de2f 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -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", diff --git a/orchestrator/package.json b/orchestrator/package.json index 7184a32..a0d7e35 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -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", diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 60a363c..59a643c 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -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(null); - const [isProcessingAll, setIsProcessingAll] = useState(false); const [pipelineSources, setPipelineSources] = useState(() => { 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 ( <>
{ onPipelineSourcesChange={setPipelineSources} /> -
- - - + + + + +
+ } /> - + } /> + diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 5f26054..b01f939 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -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 { + return fetchApi('/settings'); +} + +export async function updateSettings(update: { + model?: string | null + pipelineWebhookUrl?: string | null + jobCompleteWebhookUrl?: string | null +}): Promise { + return fetchApi('/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) diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx index 605e3f9..5498dd0 100644 --- a/orchestrator/src/client/components/Header.tsx +++ b/orchestrator/src/client/components/Header.tsx @@ -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 = ({ }; return ( -
-
-
-
- +
+
+ +
+
-
-
Job Ops
-
Orchestrator
+
+
Job Ops
+
Orchestrator
-
+ -
+
@@ -107,26 +119,42 @@ export const Header: React.FC = ({ - -
+ + +
- + Sources {orderedSources.map((source) => ( toggleSource(source, Boolean(checked))} + onCheckedChange={(checked) => + toggleSource(source, Boolean(checked)) + } > {sourceLabel[source]} ))} - onPipelineSourcesChange(orderedSources)}> + onPipelineSourcesChange(orderedSources)} + > All sources - onPipelineSourcesChange(["gradcracker"])}> + onPipelineSourcesChange(["gradcracker"])} + > Gradcracker only - onPipelineSourcesChange(["indeed", "linkedin"])}> + + onPipelineSourcesChange(["indeed", "linkedin"]) + } + > Indeed + LinkedIn only diff --git a/orchestrator/src/client/components/JobCard.tsx b/orchestrator/src/client/components/JobCard.tsx index 21aa1e1..cde1b99 100644 --- a/orchestrator/src/client/components/JobCard.tsx +++ b/orchestrator/src/client/components/JobCard.tsx @@ -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; + onReject: (id: string) => void | Promise; + onProcess: (id: string) => void | Promise; isProcessing: boolean; } @@ -68,6 +71,15 @@ export const JobCard: React.FC = ({ 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 ( @@ -132,6 +144,11 @@ export const JobCard: React.FC = ({ + + {hasPdf && ( + +
+
+
+ +
+
+ + setSearchQuery(event.target.value)} + placeholder="Filter jobs..." + className="pl-9" + /> +
+ +
+ + + + + + Sort by + + + setSort({ + key: value as JobSort["key"], + direction: + value === "score" || value === "discoveredAt" + ? "desc" + : "asc", + }) + } + > + {(Object.keys(sortLabels) as Array).map((key) => ( + + {sortLabels[key]} + + ))} + + + + setSort((current) => ({ + ...current, + direction: current.direction === "asc" ? "desc" : "asc", + })) + } + > + Direction: {sort.direction === "asc" ? "Ascending" : "Descending"} + + + + + {activeResultsCount} jobs + {hasActiveFilters && ( + )} - - )} +
+
{tabs.map((tab) => { - const filteredJobs = jobsForTab.get(tab.id) ?? []; + const filteredJobs = visibleJobsForTab.get(tab.id) ?? []; + const trimmedQuery = searchQuery.trim(); return ( @@ -123,22 +437,88 @@ export const JobList: React.FC = ({
No jobs found
-

{emptyStateCopy[tab.id]}

+

+ {trimmedQuery ? `No jobs match "${trimmedQuery}".` : emptyStateCopy[tab.id]} +

) : ( -
- {filteredJobs.map((job) => ( - - ))} -
+ <> + {viewMode === "table" ? ( +
+ {tab.id === activeTab && selectedCount > 0 && ( +
+
+ {selectedCount}{" "} + selected +
+
+ + + + +
+
+ )} + + + + + + +
+ ) : ( +
+ {filteredJobs.map((job) => ( + + ))} +
+ )} + )}
); @@ -146,4 +526,3 @@ export const JobList: React.FC = ({ ); }; - diff --git a/orchestrator/src/client/components/JobTable.tsx b/orchestrator/src/client/components/JobTable.tsx new file mode 100644 index 0000000..0817444 --- /dev/null +++ b/orchestrator/src/client/components/JobTable.tsx @@ -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; + onSelectedJobIdsChange: (ids: Set) => void; + onApply: (id: string) => void | Promise; + onReject: (id: string) => void | Promise; + onProcess: (id: string) => void | Promise; + processingJobId: string | null; +} + +const sourceLabel: Record = { + gradcracker: "Gradcracker", + indeed: "Indeed", + linkedin: "LinkedIn", +}; + +const defaultSortDirection: Record = { + 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 ( + + ); +}; + +export const JobTable: React.FC = ({ + 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 ( + + + + + { + 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); + }} + /> + + + + + + + + + + + + + + + + + + + + + + + Actions + + + + + {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 ( + + + { + const next = new Set(selectedJobIds); + if (checked) next.add(job.id); + else next.delete(job.id); + onSelectedJobIdsChange(next); + }} + /> + + + + + + + {job.employer} + + + + + {sourceLabel[job.source]} + + + + + {job.location || "—"} + + + + + + + + {job.suitabilityScore ?? "—"} + + + + {formatDate(job.discoveredAt)} + + + + + + + + + + + + + View Job + + + + void handleCopyInfo(job)}> + + Copy info + + + {hasPdf && ( + <> + + + + View PDF + + + + + + Download PDF + + + + )} + + {(canProcess || canReject || canApply) && } + + {canProcess && ( + onProcess(job.id)} + disabled={isProcessing} + > + + {isProcessing ? "Processing..." : "Generate Resume"} + + )} + + {canReject && ( + onReject(job.id)} + > + + Skip + + )} + + {canApply && ( + onApply(job.id)} + > + + Mark Applied + + )} + + + + + ); + })} + +
+ ); +}; diff --git a/orchestrator/src/client/components/index.ts b/orchestrator/src/client/components/index.ts index b8adf8b..b5baf1a 100644 --- a/orchestrator/src/client/components/index.ts +++ b/orchestrator/src/client/components/index.ts @@ -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'; diff --git a/orchestrator/src/client/lib/jobCopy.ts b/orchestrator/src/client/lib/jobCopy.ts new file mode 100644 index 0000000..3c00ad8 --- /dev/null +++ b/orchestrator/src/client/lib/jobCopy.ts @@ -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"); + } +} diff --git a/orchestrator/src/client/main.tsx b/orchestrator/src/client/main.tsx index ac226f6..95149dd 100644 --- a/orchestrator/src/client/main.tsx +++ b/orchestrator/src/client/main.tsx @@ -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( - + + + ); diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx new file mode 100644 index 0000000..92b34bc --- /dev/null +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -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(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 ( +
+
+

Settings

+

Configure runtime behavior for this app.

+
+ + + + Model + + + +
+
Override model
+ setModelDraft(event.target.value)} + placeholder={defaultModel || "openai/gpt-4o-mini"} + disabled={isLoading || isSaving} + /> +
+ Leave blank to use the default from server env (`MODEL`). +
+
+ + + +
+
+
Effective
+
{effectiveModel || "—"}
+
+
+
Default (env)
+
{defaultModel || "—"}
+
+
+
+
+ + + + Pipeline Webhook + + + +
+
Pipeline status webhook URL
+ setPipelineWebhookUrlDraft(event.target.value)} + placeholder={defaultPipelineWebhookUrl || "https://..."} + disabled={isLoading || isSaving} + /> +
+ When set, the server sends a POST on pipeline completion/failure. Leave blank to disable. +
+
+ + + +
+
+
Effective
+
{effectivePipelineWebhookUrl || "—"}
+
+
+
Default (env)
+
{defaultPipelineWebhookUrl || "—"}
+
+
+
+
+ + + + Job Complete Webhook + + + +
+
Job completion webhook URL
+ setJobCompleteWebhookUrlDraft(event.target.value)} + placeholder={defaultJobCompleteWebhookUrl || "https://..."} + disabled={isLoading || isSaving} + /> +
+ When set, the server sends a POST when you mark a job as applied (includes the job description). +
+
+ + + +
+
+
Effective
+
{effectiveJobCompleteWebhookUrl || "—"}
+
+
+
Default (env)
+
{defaultJobCompleteWebhookUrl || "—"}
+
+
+
+
+ +
+ + +
+
+ ) +} diff --git a/orchestrator/src/components/ui/checkbox.tsx b/orchestrator/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..812033c --- /dev/null +++ b/orchestrator/src/components/ui/checkbox.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = "Checkbox" + +export { Checkbox } + diff --git a/orchestrator/src/components/ui/input.tsx b/orchestrator/src/components/ui/input.tsx new file mode 100644 index 0000000..04aefeb --- /dev/null +++ b/orchestrator/src/components/ui/input.tsx @@ -0,0 +1,23 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } + diff --git a/orchestrator/src/components/ui/table.tsx b/orchestrator/src/components/ui/table.tsx new file mode 100644 index 0000000..84287ad --- /dev/null +++ b/orchestrator/src/components/ui/table.tsx @@ -0,0 +1,113 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ) +) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", className)} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", className)} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} + diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts index 0ca9845..0a77a63 100644 --- a/orchestrator/src/server/api/routes.ts +++ b/orchestrator/src/server/api/routes.ts @@ -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 = { '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 diff --git a/orchestrator/src/server/db/migrate.ts b/orchestrator/src/server/db/migrate.ts index 4792170..fd0ef71 100644 --- a/orchestrator/src/server/db/migrate.ts +++ b/orchestrator/src/server/db/migrate.ts @@ -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 = ''`, diff --git a/orchestrator/src/server/db/schema.ts b/orchestrator/src/server/db/schema.ts index 3d66498..f317983 100644 --- a/orchestrator/src/server/db/schema.ts +++ b/orchestrator/src/server/db/schema.ts @@ -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; diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index 5ec4f02..6c9b571 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -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 +) { + 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 = { '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 = {}): 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 = {}): 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 = []; @@ -175,106 +218,34 @@ export async function runPipeline(config: Partial = {}): 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 = {}): Promise }); progressHelpers.failed(message); + + await notifyPipelineWebhook('pipeline.failed', { + pipelineRunId: pipelineRun.id, + error: message, + }) isPipelineRunning = false; console.error('\n❌ Pipeline failed:', message); diff --git a/orchestrator/src/server/pipeline/progress.ts b/orchestrator/src/server/pipeline/progress.ts index 0815e5d..52aec5f 100644 --- a/orchestrator/src/server/pipeline/progress.ts +++ b/orchestrator/src/server/pipeline/progress.ts @@ -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({ diff --git a/orchestrator/src/server/repositories/jobs.ts b/orchestrator/src/server/repositories/jobs.ts index 98e469f..403d336 100644 --- a/orchestrator/src/server/repositories/jobs.ts +++ b/orchestrator/src/server/repositories/jobs.ts @@ -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 { return rows.map(mapRowToJob); } +/** + * Get discovered jobs missing a suitability score. + */ +export async function getUnscoredDiscoveredJobs(limit?: number): Promise { + 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 { diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts new file mode 100644 index 0000000..a4517e1 --- /dev/null +++ b/orchestrator/src/server/repositories/settings.ts @@ -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 { + 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 { + 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, + }) +} diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 814739e..71428b5 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -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); diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 235969c..b8004d8 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -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; +}