Merge pull request #9 from DaKheera47/refactoring-large-files
Refactoring large files
This commit is contained in:
commit
97984be84f
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { Route, Routes, useLocation } from "react-router-dom";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
@ -16,11 +16,20 @@ export const App: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Determine a stable key for transitions to avoid unnecessary unmounts when switching sub-tabs
|
||||
const pageKey = React.useMemo(() => {
|
||||
const firstSegment = location.pathname.split("/")[1] || "ready";
|
||||
if (["ready", "discovered", "applied", "all"].includes(firstSegment)) {
|
||||
return "orchestrator";
|
||||
}
|
||||
return firstSegment;
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
key={location.pathname}
|
||||
key={pageKey}
|
||||
nodeRef={nodeRef}
|
||||
timeout={100}
|
||||
classNames="page"
|
||||
@ -28,10 +37,12 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<div ref={nodeRef}>
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<OrchestratorPage />} />
|
||||
<Route path="/" element={<Navigate to="/ready" replace />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
|
||||
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
|
||||
@ -21,7 +21,7 @@ import type {
|
||||
VisaSponsorStatusResponse,
|
||||
VisaSponsor,
|
||||
} from '../../shared/types';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import { trackEvent } from "@/lib/analytics";
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
|
||||
@ -1,699 +0,0 @@
|
||||
/**
|
||||
* DiscoveredPanel - Two-mode triage workspace for Discovered jobs.
|
||||
*
|
||||
* Mode A: Decide (default) - Quick assessment to Skip or Tailor
|
||||
* Mode B: Tailor - Draft tailoring data before moving to Ready
|
||||
*
|
||||
* Moving to Ready generates the PDF using the current tailored draft.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
DollarSign,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Sparkles,
|
||||
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 { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate } from "../lib/dateUtils";
|
||||
import * as api from "../api";
|
||||
import { FitAssessment } from ".";
|
||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type PanelMode = "decide" | "tailor";
|
||||
|
||||
interface DiscoveredPanelProps {
|
||||
job: Job | null;
|
||||
onJobUpdated: () => void | Promise<void>;
|
||||
onJobMoved: (jobId: string) => void;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const stripHtml = (value: string) =>
|
||||
value
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const sourceLabel: Record<Job["source"], string> = {
|
||||
gradcracker: "Gradcracker",
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Decide Mode Panel
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DecideModeProps {
|
||||
job: Job;
|
||||
onTailor: () => void;
|
||||
onSkip: () => void;
|
||||
isSkipping: boolean;
|
||||
}
|
||||
|
||||
const DecideMode: React.FC<DecideModeProps> = ({
|
||||
job,
|
||||
onTailor,
|
||||
onSkip,
|
||||
isSkipping,
|
||||
}) => {
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
const deadline = formatDate(job.deadline);
|
||||
const jobLink = job.applicationLink || job.jobUrl;
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (!job.jobDescription) return "No description available.";
|
||||
const jd = job.jobDescription;
|
||||
if (jd.includes("<") && jd.includes(">")) return stripHtml(jd);
|
||||
return jd;
|
||||
}, [job.jobDescription]);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
{/* Header */}
|
||||
<div className='space-y-3 pb-4'>
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h2 className='text-base font-semibold text-foreground/90 leading-tight'>
|
||||
{job.title}
|
||||
</h2>
|
||||
<p className='text-sm text-muted-foreground mt-0.5'>
|
||||
{job.employer}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='text-[10px] uppercase tracking-wide text-muted-foreground border-border/50 shrink-0'
|
||||
>
|
||||
{sourceLabel[job.source]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 justify-between'>
|
||||
{job.location && (
|
||||
<span className='flex items-center gap-1'>
|
||||
<MapPin className='h-3 w-3' />
|
||||
{job.location}
|
||||
</span>
|
||||
)}
|
||||
{deadline && (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Calendar className='h-3 w-3' />
|
||||
{deadline}
|
||||
</span>
|
||||
)}
|
||||
{job.salary && (
|
||||
<span className='flex items-center gap-1'>
|
||||
<DollarSign className='h-3 w-3' />
|
||||
{job.salary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Primary/Secondary actions */}
|
||||
<div className='flex flex-col gap-2 pt-2 sm:flex-row'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='default'
|
||||
onClick={onSkip}
|
||||
disabled={isSkipping}
|
||||
className='flex-1 h-11 text-sm text-muted-foreground hover:text-foreground hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs'
|
||||
>
|
||||
{isSkipping ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<XCircle className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
size='default'
|
||||
onClick={onTailor}
|
||||
className='flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs'
|
||||
>
|
||||
<Sparkles className='mr-2 h-4 w-4' />
|
||||
Tailor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className='opacity-50' />
|
||||
|
||||
{/* Fit Summary - the core content */}
|
||||
<div className='flex-1 py-4 space-y-4 overflow-y-auto'>
|
||||
<FitAssessment job={job} />
|
||||
|
||||
{/* Collapsible full description */}
|
||||
<div className='space-y-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowDescription(!showDescription)}
|
||||
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full'
|
||||
>
|
||||
{showDescription ? (
|
||||
<ChevronUp className='h-3.5 w-3.5' />
|
||||
) : (
|
||||
<ChevronDown className='h-3.5 w-3.5' />
|
||||
)}
|
||||
{showDescription ? "Hide" : "View"} full job description
|
||||
</button>
|
||||
|
||||
{showDescription && (
|
||||
<div className='rounded-lg border border-border/40 bg-muted/5 p-3 max-h-[300px] overflow-y-auto'>
|
||||
<p className='text-xs text-muted-foreground/80 whitespace-pre-wrap leading-relaxed'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className='opacity-50' />
|
||||
|
||||
{/* Actions - clear hierarchy */}
|
||||
<div className='pt-4 pb-2'>
|
||||
{/* External link - tertiary */}
|
||||
<div className='flex justify-center'>
|
||||
<a
|
||||
href={jobLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
View original listing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tailor Mode Panel
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TailorModeProps {
|
||||
job: Job;
|
||||
onBack: () => void;
|
||||
onFinalize: () => void;
|
||||
isFinalizing: boolean;
|
||||
}
|
||||
|
||||
const TailorMode: React.FC<TailorModeProps> = ({
|
||||
job,
|
||||
onBack,
|
||||
onFinalize,
|
||||
isFinalizing,
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
|
||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
return new Set(saved);
|
||||
});
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [draftStatus, setDraftStatus] = useState<
|
||||
"unsaved" | "saving" | "saved"
|
||||
>("saved");
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
|
||||
// Load project catalog
|
||||
useEffect(() => {
|
||||
api.getProfileProjects().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Reset form when job changes
|
||||
useEffect(() => {
|
||||
setSummary(job.tailoredSummary || "");
|
||||
setJobDescription(job.jobDescription || "");
|
||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
setSelectedIds(new Set(saved));
|
||||
setDraftStatus("saved");
|
||||
}, [job.id, job.tailoredSummary, job.selectedProjectIds, job.jobDescription]);
|
||||
|
||||
// Track unsaved changes
|
||||
const savedSummary = job.tailoredSummary || "";
|
||||
const savedDescription = job.jobDescription || "";
|
||||
const savedIds = useMemo(() => {
|
||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
return new Set(saved);
|
||||
}, [job.selectedProjectIds]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (summary !== savedSummary) return true;
|
||||
if (jobDescription !== savedDescription) return true;
|
||||
if (selectedIds.size !== savedIds.size) return true;
|
||||
for (const id of selectedIds) {
|
||||
if (!savedIds.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedIds]);
|
||||
|
||||
// Update draft status when changes are made
|
||||
useEffect(() => {
|
||||
if (hasChanges && draftStatus === "saved") {
|
||||
setDraftStatus("unsaved");
|
||||
}
|
||||
}, [hasChanges, draftStatus]);
|
||||
|
||||
// Auto-save draft (debounced)
|
||||
useEffect(() => {
|
||||
if (!hasChanges || draftStatus !== "unsaved") return;
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
setDraftStatus("saving");
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription: jobDescription,
|
||||
selectedProjectIds: Array.from(selectedIds).join(","),
|
||||
});
|
||||
setDraftStatus("saved");
|
||||
} catch {
|
||||
setDraftStatus("unsaved");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [summary, jobDescription, selectedIds, hasChanges, draftStatus, job.id]);
|
||||
|
||||
const handleToggleProject = (id: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const handleGenerateWithAI = async () => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
// Save any pending changes first so AI uses the latest description
|
||||
if (hasChanges) {
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription: jobDescription,
|
||||
selectedProjectIds: Array.from(selectedIds).join(","),
|
||||
});
|
||||
}
|
||||
|
||||
const updatedJob = await api.summarizeJob(job.id, { force: true });
|
||||
setSummary(updatedJob.tailoredSummary || "");
|
||||
setJobDescription(updatedJob.jobDescription || "");
|
||||
if (updatedJob.selectedProjectIds) {
|
||||
setSelectedIds(
|
||||
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean))
|
||||
);
|
||||
}
|
||||
setDraftStatus("saved"); // AI response is saved server-side
|
||||
toast.success("Draft generated with AI", {
|
||||
description: "Review and edit before finalizing.",
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to generate AI draft");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
// Save any pending changes first
|
||||
if (hasChanges) {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription: jobDescription,
|
||||
selectedProjectIds: Array.from(selectedIds).join(","),
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to save draft before finalizing");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Now finalize (which generates PDF and moves to Ready)
|
||||
onFinalize();
|
||||
};
|
||||
|
||||
const maxProjects = 3;
|
||||
const tooManyProjects = selectedIds.size > maxProjects;
|
||||
const canFinalize = summary.trim().length > 0 && selectedIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
{/* Header with back navigation */}
|
||||
<div className='flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onBack}
|
||||
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
<ArrowLeft className='h-3.5 w-3.5' />
|
||||
Back to overview
|
||||
</button>
|
||||
|
||||
{/* Draft status indicator */}
|
||||
<div className='flex items-center gap-1.5 text-[10px] text-muted-foreground'>
|
||||
{draftStatus === "saving" && (
|
||||
<>
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
Saving...
|
||||
</>
|
||||
)}
|
||||
{draftStatus === "saved" && !hasChanges && (
|
||||
<>
|
||||
<Check className='h-3 w-3 text-emerald-400' />
|
||||
Saved
|
||||
</>
|
||||
)}
|
||||
{draftStatus === "unsaved" && (
|
||||
<span className='text-amber-400'>Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Draft framing */}
|
||||
<div className='rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='h-2 w-2 rounded-full bg-amber-400 animate-pulse' />
|
||||
<span className='text-xs font-medium text-amber-300'>
|
||||
Draft tailoring for this role
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1 ml-4'>
|
||||
Edit below, then finalize to generate your PDF and move to Ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className='flex-1 overflow-y-auto space-y-4 pr-1'>
|
||||
{/* AI Generate option */}
|
||||
<div className='flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div>
|
||||
<div className='text-xs font-medium'>
|
||||
Need help getting started?
|
||||
</div>
|
||||
<div className='text-[10px] text-muted-foreground'>
|
||||
AI can draft a summary and select projects for you
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={handleGenerateWithAI}
|
||||
disabled={isGenerating || isFinalizing}
|
||||
className='h-8 w-full text-xs sm:w-auto'
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<Sparkles className='mr-1.5 h-3.5 w-3.5' />
|
||||
)}
|
||||
Generate draft
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Job Description - collapsible */}
|
||||
<div className='space-y-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowDescription(!showDescription)}
|
||||
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full'
|
||||
>
|
||||
{showDescription ? (
|
||||
<ChevronUp className='h-3.5 w-3.5' />
|
||||
) : (
|
||||
<ChevronDown className='h-3.5 w-3.5' />
|
||||
)}
|
||||
{showDescription ? "Hide" : "Edit"} job description
|
||||
</button>
|
||||
|
||||
{showDescription && (
|
||||
<div className='space-y-1'>
|
||||
<label className='text-[10px] font-medium text-muted-foreground/70'>
|
||||
Edit to help AI tailoring
|
||||
</label>
|
||||
<textarea
|
||||
className='w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
|
||||
value={jobDescription}
|
||||
onChange={(e) => setJobDescription(e.target.value)}
|
||||
placeholder='The raw job description...'
|
||||
disabled={isGenerating || isFinalizing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tailored Summary */}
|
||||
<div className='space-y-2'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
Tailored Summary
|
||||
</label>
|
||||
<textarea
|
||||
className='w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder='Write a tailored summary for this role, or generate with AI...'
|
||||
disabled={isGenerating || isFinalizing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected Projects */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-start gap-2 sm:items-center sm:justify-between'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
Selected Projects
|
||||
</label>
|
||||
{tooManyProjects && (
|
||||
<span className='flex items-center gap-1 text-[10px] text-amber-500 font-medium'>
|
||||
<AlertTriangle className='h-3 w-3' />
|
||||
Max {maxProjects} recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5 max-h-[200px] overflow-y-auto pr-1'>
|
||||
{catalog.length === 0 ? (
|
||||
<div className='text-xs text-muted-foreground text-center py-4'>
|
||||
Loading projects...
|
||||
</div>
|
||||
) : (
|
||||
catalog.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"flex items-start gap-2.5 rounded-lg border p-2.5 text-xs transition-colors cursor-pointer",
|
||||
selectedIds.has(project.id)
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border/40 bg-muted/5 hover:bg-muted/10"
|
||||
)}
|
||||
onClick={() =>
|
||||
!isGenerating &&
|
||||
!isFinalizing &&
|
||||
handleToggleProject(project.id)
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
id={`project-${project.id}`}
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() => handleToggleProject(project.id)}
|
||||
disabled={isGenerating || isFinalizing}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium truncate'>{project.name}</div>
|
||||
<div className='text-[10px] text-muted-foreground line-clamp-1 mt-0.5'>
|
||||
{project.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className='opacity-50 my-4' />
|
||||
|
||||
{/* Actions */}
|
||||
<div className='space-y-2'>
|
||||
{!canFinalize && (
|
||||
<p className='text-[10px] text-center text-muted-foreground'>
|
||||
Add a summary and select at least one project to finalize.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleFinalize}
|
||||
disabled={isFinalizing || !canFinalize || isGenerating}
|
||||
className='w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white'
|
||||
>
|
||||
{isFinalizing ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Finalizing & generating PDF...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className='mr-2 h-4 w-4' />
|
||||
Finalize & Move to Ready
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className='text-[10px] text-center text-muted-foreground/70'>
|
||||
This will generate your tailored PDF and move the job to Ready.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main Panel Component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
job,
|
||||
onJobUpdated,
|
||||
onJobMoved,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<PanelMode>("decide");
|
||||
const [isSkipping, setIsSkipping] = useState(false);
|
||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||
|
||||
// Reset mode when job changes
|
||||
useEffect(() => {
|
||||
setMode("decide");
|
||||
setIsSkipping(false);
|
||||
setIsFinalizing(false);
|
||||
}, [job?.id]);
|
||||
|
||||
const handleSkip = async () => {
|
||||
if (!job) return;
|
||||
try {
|
||||
setIsSkipping(true);
|
||||
await api.skipJob(job.id);
|
||||
toast.message("Job skipped");
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to skip job";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSkipping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (!job) return;
|
||||
try {
|
||||
setIsFinalizing(true);
|
||||
|
||||
// Generate PDF - this also transitions to Ready status
|
||||
await api.processJob(job.id);
|
||||
|
||||
toast.success("Job moved to Ready", {
|
||||
description: "Your tailored PDF has been generated.",
|
||||
});
|
||||
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to finalize job";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsFinalizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Empty state
|
||||
if (!job) {
|
||||
return (
|
||||
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center px-4'>
|
||||
<div className='h-10 w-10 rounded-full border border-border/40 bg-muted/20 flex items-center justify-center'>
|
||||
<Sparkles className='h-4 w-4 text-muted-foreground/50' />
|
||||
</div>
|
||||
<div className='text-sm font-medium text-muted-foreground'>
|
||||
No job selected
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground/70 max-w-[200px]'>
|
||||
Select a job from the list to see details and decide whether to
|
||||
tailor.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Processing state (job is being processed by pipeline)
|
||||
if (job.status === "processing") {
|
||||
return (
|
||||
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-3 text-center px-4'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-amber-400' />
|
||||
<div className='text-sm font-medium text-foreground/80'>
|
||||
Processing job...
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground max-w-[220px]'>
|
||||
This job is currently being analyzed by the pipeline. Please wait.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-full'>
|
||||
{mode === "decide" ? (
|
||||
<DecideMode
|
||||
job={job}
|
||||
onTailor={() => setMode("tailor")}
|
||||
onSkip={handleSkip}
|
||||
isSkipping={isSkipping}
|
||||
/>
|
||||
) : (
|
||||
<TailorMode
|
||||
job={job}
|
||||
onBack={() => setMode("decide")}
|
||||
onFinalize={handleFinalize}
|
||||
isFinalizing={isFinalizing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoveredPanel;
|
||||
@ -33,6 +33,7 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { sourceLabel } from "@/lib/utils";
|
||||
import type { JobSource } from "../../shared/types";
|
||||
|
||||
interface HeaderProps {
|
||||
@ -55,14 +56,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const location = useLocation();
|
||||
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||
|
||||
const sourceLabel: Record<JobSource, string> = {
|
||||
gradcracker: "Gradcracker",
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
|
||||
const navLinks = [
|
||||
|
||||
90
orchestrator/src/client/components/JobHeader.tsx
Normal file
90
orchestrator/src/client/components/JobHeader.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { Calendar, DollarSign, MapPin } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn, formatDate, sourceLabel } from "@/lib/utils";
|
||||
import type { Job, JobStatus } from "../../shared/types";
|
||||
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
|
||||
|
||||
interface JobHeaderProps {
|
||||
job: Job;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
|
||||
const tokens = statusTokens[status] ?? defaultStatusToken;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80",
|
||||
)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", tokens.dot)} />
|
||||
{tokens.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
|
||||
if (score == null) {
|
||||
return <span className="text-[10px] text-muted-foreground/60">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/70">
|
||||
<div className="h-1 w-12 rounded-full bg-muted/30">
|
||||
<div
|
||||
className="h-1 rounded-full bg-primary/50"
|
||||
style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums">{score}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||
const deadline = formatDate(job.deadline);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Detail header: lighter weight than list items */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold text-foreground/90">{job.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{job.employer}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
|
||||
{sourceLabel[job.source]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tertiary metadata - subdued */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground/70">
|
||||
{job.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{job.location}
|
||||
</span>
|
||||
)}
|
||||
{deadline && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{deadline}
|
||||
</span>
|
||||
)}
|
||||
{job.salary && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{job.salary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status and score: single line, subdued */}
|
||||
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
||||
<StatusPill status={job.status} />
|
||||
<ScoreMeter score={job.suitabilityScore} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -39,10 +39,9 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy";
|
||||
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
import { FitAssessment } from ".";
|
||||
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||
|
||||
interface ReadyPanelProps {
|
||||
@ -96,8 +95,6 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
.filter(Boolean) as string[];
|
||||
}, [catalog, selectedProjectIds]);
|
||||
|
||||
const tailoredSummary = job?.tailoredSummary || null;
|
||||
|
||||
// Handle mark as applied with undo capability
|
||||
const handleMarkApplied = useCallback(async () => {
|
||||
if (!job) return;
|
||||
@ -218,6 +215,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<JobHeader job={job} className="pb-4 border-b border-border/40" />
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────
|
||||
PRIMARY ACTION CLUSTER
|
||||
All actions in one line: View, Save, Open, and Mark Applied
|
||||
@ -276,18 +275,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
{/* Job identity - confirm this is the right role */}
|
||||
<div className="space-y-3">
|
||||
<FitAssessment job={job} />
|
||||
|
||||
{/* Tailored summary snippet - shows what's in the PDF */}
|
||||
{tailoredSummary && (
|
||||
<div className="rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-1.5">
|
||||
Tailored Summary
|
||||
</div>
|
||||
<p className="text-xs text-foreground/80 leading-relaxed italic whitespace-pre-wrap">
|
||||
"{tailoredSummary}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<TailoredSummary job={job} />
|
||||
|
||||
{/* Project selection - expandable accordion */}
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
|
||||
23
orchestrator/src/client/components/TailoredSummary.tsx
Normal file
23
orchestrator/src/client/components/TailoredSummary.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Job } from "../../shared/types";
|
||||
|
||||
interface TailoredSummaryProps {
|
||||
job: Job;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TailoredSummary: React.FC<TailoredSummaryProps> = ({ job, className }) => {
|
||||
if (!job.tailoredSummary) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5", className)}>
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-1.5">
|
||||
Tailored Summary
|
||||
</div>
|
||||
<p className="text-xs text-foreground/80 leading-relaxed italic whitespace-pre-wrap">
|
||||
"{job.tailoredSummary}"
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
isOpen,
|
||||
label,
|
||||
onToggle,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onToggle}
|
||||
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full'
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronUp className='h-3.5 w-3.5' />
|
||||
) : (
|
||||
<ChevronDown className='h-3.5 w-3.5' />
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
{isOpen ? children : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,103 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { ExternalLink, Loader2, Sparkles, XCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
import { FitAssessment, JobHeader, TailoredSummary } from "..";
|
||||
import type { Job } from "../../../shared/types";
|
||||
import { CollapsibleSection } from "./CollapsibleSection";
|
||||
import { getPlainDescription } from "./helpers";
|
||||
|
||||
interface DecideModeProps {
|
||||
job: Job;
|
||||
onTailor: () => void;
|
||||
onSkip: () => void;
|
||||
isSkipping: boolean;
|
||||
}
|
||||
|
||||
export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
job,
|
||||
onTailor,
|
||||
onSkip,
|
||||
isSkipping,
|
||||
}) => {
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
const jobLink = job.applicationLink || job.jobUrl;
|
||||
|
||||
const description = useMemo(
|
||||
() => getPlainDescription(job.jobDescription),
|
||||
[job.jobDescription]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
<div className='space-y-4 pb-4'>
|
||||
<JobHeader job={job} />
|
||||
|
||||
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='default'
|
||||
onClick={onSkip}
|
||||
disabled={isSkipping}
|
||||
className='flex-1 h-11 text-sm text-muted-foreground hover:text-rose-500 hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs'
|
||||
>
|
||||
{isSkipping ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<XCircle className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
Skip Job
|
||||
</Button>
|
||||
<Button
|
||||
size='default'
|
||||
onClick={onTailor}
|
||||
className='flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs shadow-sm'
|
||||
>
|
||||
<Sparkles className='mr-2 h-4 w-4' />
|
||||
Start Tailoring
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className='opacity-40' />
|
||||
|
||||
<div className='flex-1 py-6 space-y-6 overflow-y-auto'>
|
||||
<FitAssessment job={job} />
|
||||
<TailoredSummary job={job} />
|
||||
|
||||
<CollapsibleSection
|
||||
isOpen={showDescription}
|
||||
onToggle={() => setShowDescription((prev) => !prev)}
|
||||
label={`${showDescription ? "Hide" : "View"} Full Job Description`}
|
||||
>
|
||||
<div className='rounded-xl border border-border/40 bg-muted/5 p-4 mt-2 max-h-[400px] overflow-y-auto shadow-inner'>
|
||||
<p className='text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
<Separator className='opacity-40' />
|
||||
|
||||
<div className='pt-6 pb-2'>
|
||||
{jobLink ? (
|
||||
<div className='flex justify-center'>
|
||||
<a
|
||||
href={jobLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
<ExternalLink className='h-3.5 w-3.5' />
|
||||
Original Job Listing
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import * as api from "../../api";
|
||||
import type { Job } from "../../../shared/types";
|
||||
import { DecideMode } from "./DecideMode";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { ProcessingState } from "./ProcessingState";
|
||||
import { TailorMode } from "./TailorMode";
|
||||
|
||||
type PanelMode = "decide" | "tailor";
|
||||
|
||||
interface DiscoveredPanelProps {
|
||||
job: Job | null;
|
||||
onJobUpdated: () => void | Promise<void>;
|
||||
onJobMoved: (jobId: string) => void;
|
||||
}
|
||||
|
||||
export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
job,
|
||||
onJobUpdated,
|
||||
onJobMoved,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<PanelMode>("decide");
|
||||
const [isSkipping, setIsSkipping] = useState(false);
|
||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMode("decide");
|
||||
setIsSkipping(false);
|
||||
setIsFinalizing(false);
|
||||
}, [job?.id]);
|
||||
|
||||
const handleSkip = async () => {
|
||||
if (!job) return;
|
||||
try {
|
||||
setIsSkipping(true);
|
||||
await api.skipJob(job.id);
|
||||
toast.message("Job skipped");
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to skip job";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSkipping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (!job) return;
|
||||
try {
|
||||
setIsFinalizing(true);
|
||||
await api.processJob(job.id);
|
||||
|
||||
toast.success("Job moved to Ready", {
|
||||
description: "Your tailored PDF has been generated.",
|
||||
});
|
||||
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to finalize job";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsFinalizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!job) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
if (job.status === "processing") {
|
||||
return <ProcessingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-full'>
|
||||
{mode === "decide" ? (
|
||||
<DecideMode
|
||||
job={job}
|
||||
onTailor={() => setMode("tailor")}
|
||||
onSkip={handleSkip}
|
||||
isSkipping={isSkipping}
|
||||
/>
|
||||
) : (
|
||||
<TailorMode
|
||||
job={job}
|
||||
onBack={() => setMode("decide")}
|
||||
onFinalize={handleFinalize}
|
||||
isFinalizing={isFinalizing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoveredPanel;
|
||||
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
export const EmptyState: React.FC = () => {
|
||||
return (
|
||||
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center px-4'>
|
||||
<div className='h-10 w-10 rounded-full border border-border/40 bg-muted/20 flex items-center justify-center'>
|
||||
<Sparkles className='h-4 w-4 text-muted-foreground/50' />
|
||||
</div>
|
||||
<div className='text-sm font-medium text-muted-foreground'>
|
||||
No job selected
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground/70 max-w-[200px]'>
|
||||
Select a job from the list to see details and decide whether to tailor.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export const ProcessingState: React.FC = () => {
|
||||
return (
|
||||
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-3 text-center px-4'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-amber-400' />
|
||||
<div className='text-sm font-medium text-foreground/80'>
|
||||
Processing job...
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground max-w-[220px]'>
|
||||
This job is currently being analyzed by the pipeline. Please wait.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ResumeProjectCatalogItem } from "../../../shared/types";
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
catalog: ResumeProjectCatalogItem[];
|
||||
selectedIds: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
maxProjects: number;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
|
||||
catalog,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
maxProjects,
|
||||
disabled,
|
||||
}) => {
|
||||
const tooManyProjects = selectedIds.size > maxProjects;
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-start gap-2 sm:items-center sm:justify-between'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
Selected Projects
|
||||
</label>
|
||||
{tooManyProjects && (
|
||||
<span className='flex items-center gap-1 text-[10px] text-amber-500 font-medium'>
|
||||
<AlertTriangle className='h-3 w-3' />
|
||||
Max {maxProjects} recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5 max-h-[200px] overflow-y-auto pr-1'>
|
||||
{catalog.length === 0 ? (
|
||||
<div className='text-xs text-muted-foreground text-center py-4'>
|
||||
Loading projects...
|
||||
</div>
|
||||
) : (
|
||||
catalog.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"flex items-start gap-2.5 rounded-lg border p-2.5 text-xs transition-colors cursor-pointer",
|
||||
selectedIds.has(project.id)
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border/40 bg-muted/5 hover:bg-muted/10"
|
||||
)}
|
||||
onClick={() => !disabled && onToggle(project.id)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`project-${project.id}`}
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() => onToggle(project.id)}
|
||||
disabled={disabled}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium truncate'>{project.name}</div>
|
||||
<div className='text-[10px] text-muted-foreground line-clamp-1 mt-0.5'>
|
||||
{project.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,303 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Check, Loader2, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
import * as api from "../../api";
|
||||
import type { Job, ResumeProjectCatalogItem } from "../../../shared/types";
|
||||
import { CollapsibleSection } from "./CollapsibleSection";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
|
||||
interface TailorModeProps {
|
||||
job: Job;
|
||||
onBack: () => void;
|
||||
onFinalize: () => void;
|
||||
isFinalizing: boolean;
|
||||
}
|
||||
|
||||
export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
job,
|
||||
onBack,
|
||||
onFinalize,
|
||||
isFinalizing,
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
|
||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
return new Set(saved);
|
||||
});
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [draftStatus, setDraftStatus] = useState<
|
||||
"unsaved" | "saving" | "saved"
|
||||
>("saved");
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getProfileProjects().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSummary(job.tailoredSummary || "");
|
||||
setJobDescription(job.jobDescription || "");
|
||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
setSelectedIds(new Set(saved));
|
||||
setDraftStatus("saved");
|
||||
}, [job.id, job.tailoredSummary, job.selectedProjectIds, job.jobDescription]);
|
||||
|
||||
const savedSummary = job.tailoredSummary || "";
|
||||
const savedDescription = job.jobDescription || "";
|
||||
const savedIds = useMemo(() => {
|
||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||
return new Set(saved);
|
||||
}, [job.selectedProjectIds]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (summary !== savedSummary) return true;
|
||||
if (jobDescription !== savedDescription) return true;
|
||||
if (selectedIds.size !== savedIds.size) return true;
|
||||
for (const id of selectedIds) {
|
||||
if (!savedIds.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasChanges && draftStatus === "saved") {
|
||||
setDraftStatus("unsaved");
|
||||
}
|
||||
}, [hasChanges, draftStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasChanges || draftStatus !== "unsaved") return;
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
setDraftStatus("saving");
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription: jobDescription,
|
||||
selectedProjectIds: Array.from(selectedIds).join(","),
|
||||
});
|
||||
setDraftStatus("saved");
|
||||
} catch {
|
||||
setDraftStatus("unsaved");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [summary, jobDescription, selectedIds, hasChanges, draftStatus, job.id]);
|
||||
|
||||
const handleToggleProject = useCallback(
|
||||
(id: string) => {
|
||||
if (isGenerating || isFinalizing) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[isGenerating, isFinalizing]
|
||||
);
|
||||
|
||||
const handleGenerateWithAI = async () => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
if (hasChanges) {
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription: jobDescription,
|
||||
selectedProjectIds: Array.from(selectedIds).join(","),
|
||||
});
|
||||
}
|
||||
|
||||
const updatedJob = await api.summarizeJob(job.id, { force: true });
|
||||
setSummary(updatedJob.tailoredSummary || "");
|
||||
setJobDescription(updatedJob.jobDescription || "");
|
||||
if (updatedJob.selectedProjectIds) {
|
||||
setSelectedIds(
|
||||
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean))
|
||||
);
|
||||
}
|
||||
setDraftStatus("saved");
|
||||
toast.success("Draft generated with AI", {
|
||||
description: "Review and edit before finalizing.",
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to generate AI draft");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (hasChanges) {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription: jobDescription,
|
||||
selectedProjectIds: Array.from(selectedIds).join(","),
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to save draft before finalizing");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
onFinalize();
|
||||
};
|
||||
|
||||
const maxProjects = 3;
|
||||
const canFinalize = summary.trim().length > 0 && selectedIds.size > 0;
|
||||
const disableInputs = isGenerating || isFinalizing || isSaving;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
<div className='flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onBack}
|
||||
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
<ArrowLeft className='h-3.5 w-3.5' />
|
||||
Back to overview
|
||||
</button>
|
||||
|
||||
<div className='flex items-center gap-1.5 text-[10px] text-muted-foreground'>
|
||||
{draftStatus === "saving" && (
|
||||
<>
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
Saving...
|
||||
</>
|
||||
)}
|
||||
{draftStatus === "saved" && !hasChanges && (
|
||||
<>
|
||||
<Check className='h-3 w-3 text-emerald-400' />
|
||||
Saved
|
||||
</>
|
||||
)}
|
||||
{draftStatus === "unsaved" && (
|
||||
<span className='text-amber-400'>Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='h-2 w-2 rounded-full bg-amber-400 animate-pulse' />
|
||||
<span className='text-xs font-medium text-amber-300'>
|
||||
Draft tailoring for this role
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1 ml-4'>
|
||||
Edit below, then finalize to generate your PDF and move to Ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-y-auto space-y-4 pr-1'>
|
||||
<div className='flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div>
|
||||
<div className='text-xs font-medium'>Need help getting started?</div>
|
||||
<div className='text-[10px] text-muted-foreground'>
|
||||
AI can draft a summary and select projects for you
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={handleGenerateWithAI}
|
||||
disabled={isGenerating || isFinalizing}
|
||||
className='h-8 w-full text-xs sm:w-auto'
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<Sparkles className='mr-1.5 h-3.5 w-3.5' />
|
||||
)}
|
||||
Generate draft
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CollapsibleSection
|
||||
isOpen={showDescription}
|
||||
onToggle={() => setShowDescription((prev) => !prev)}
|
||||
label={`${showDescription ? "Hide" : "Edit"} job description`}
|
||||
>
|
||||
<div className='space-y-1'>
|
||||
<label className='text-[10px] font-medium text-muted-foreground/70'>
|
||||
Edit to help AI tailoring
|
||||
</label>
|
||||
<textarea
|
||||
className='w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
|
||||
value={jobDescription}
|
||||
onChange={(event) => setJobDescription(event.target.value)}
|
||||
placeholder='The raw job description...'
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
Tailored Summary
|
||||
</label>
|
||||
<textarea
|
||||
className='w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
placeholder='Write a tailored summary for this role, or generate with AI...'
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProjectSelector
|
||||
catalog={catalog}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={handleToggleProject}
|
||||
maxProjects={maxProjects}
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className='opacity-50 my-4' />
|
||||
|
||||
<div className='space-y-2'>
|
||||
{!canFinalize && (
|
||||
<p className='text-[10px] text-center text-muted-foreground'>
|
||||
Add a summary and select at least one project to finalize.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleFinalize}
|
||||
disabled={isFinalizing || !canFinalize || isGenerating}
|
||||
className='w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white'
|
||||
>
|
||||
{isFinalizing ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Finalizing & generating PDF...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className='mr-2 h-4 w-4' />
|
||||
Finalize & Move to Ready
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className='text-[10px] text-center text-muted-foreground/70'>
|
||||
This will generate your tailored PDF and move the job to Ready.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { stripHtml } from "@/lib/utils";
|
||||
import type { Job } from "../../../shared/types";
|
||||
|
||||
export const getPlainDescription = (jobDescription?: string | null) => {
|
||||
if (!jobDescription) return "No description available.";
|
||||
if (jobDescription.includes("<") && jobDescription.includes(">")) {
|
||||
return stripHtml(jobDescription);
|
||||
}
|
||||
return jobDescription;
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export { DiscoveredPanel } from "./DiscoveredPanel";
|
||||
@ -2,10 +2,12 @@ export { Header } from './Header';
|
||||
export { Stats } from './Stats';
|
||||
export { StatusBadge } from './StatusBadge';
|
||||
export { ScoreIndicator } from './ScoreIndicator';
|
||||
export { JobHeader } from './JobHeader';
|
||||
export { TailoredSummary } from './TailoredSummary';
|
||||
export { FitAssessment } from './FitAssessment';
|
||||
export { PipelineProgress } from './PipelineProgress';
|
||||
export { TailoringEditor } from './TailoringEditor';
|
||||
export { DiscoveredPanel } from './DiscoveredPanel';
|
||||
export { DiscoveredPanel } from './discovered-panel';
|
||||
export { ReadyPanel } from './ReadyPanel';
|
||||
export { ManualImportSheet } from './ManualImportSheet';
|
||||
export * from './layout';
|
||||
|
||||
@ -81,7 +81,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
onClick={() => handleNavClick(to)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
||||
location.pathname === to
|
||||
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname))
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
export const formatDate = (dateStr?: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||
return parsed.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatDateTime = (dateStr?: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||
const date = parsed.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = parsed.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `${date} ${time}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
@ -1,39 +0,0 @@
|
||||
import type { Job } from "@shared/types";
|
||||
|
||||
export const formatJobForWebhook = (job: Job) => {
|
||||
return JSON.stringify(
|
||||
{
|
||||
event: "job.completed",
|
||||
sentAt: new Date().toISOString(),
|
||||
job,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
275
orchestrator/src/client/pages/OrchestratorPage.test.tsx
Normal file
275
orchestrator/src/client/pages/OrchestratorPage.test.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { OrchestratorPage } from "./OrchestratorPage";
|
||||
import type { Job } from "../../shared/types";
|
||||
import type { FilterTab } from "./orchestrator/constants";
|
||||
|
||||
const jobFixture: Job = {
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
const job2: Job = { ...jobFixture, id: "job-2", status: "discovered" };
|
||||
|
||||
const createMatchMedia = (matches: boolean) =>
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./orchestrator/useOrchestratorData", () => ({
|
||||
useOrchestratorData: () => ({
|
||||
jobs: [jobFixture, job2],
|
||||
stats: {
|
||||
discovered: 1,
|
||||
processing: 0,
|
||||
ready: 1,
|
||||
applied: 0,
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
},
|
||||
isLoading: false,
|
||||
isPipelineRunning: false,
|
||||
setIsPipelineRunning: vi.fn(),
|
||||
loadJobs: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./orchestrator/usePipelineSources", () => ({
|
||||
usePipelineSources: () => ({
|
||||
pipelineSources: ["linkedin"],
|
||||
setPipelineSources: vi.fn(),
|
||||
toggleSource: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./orchestrator/OrchestratorHeader", () => ({
|
||||
OrchestratorHeader: () => <div data-testid="header" />,
|
||||
}));
|
||||
|
||||
vi.mock("./orchestrator/OrchestratorSummary", () => ({
|
||||
OrchestratorSummary: () => <div data-testid="summary" />,
|
||||
}));
|
||||
|
||||
vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
OrchestratorFilters: ({
|
||||
onTabChange,
|
||||
onSearchQueryChange,
|
||||
onSortChange,
|
||||
}: {
|
||||
onTabChange: (t: FilterTab) => void;
|
||||
onSearchQueryChange: (q: string) => void;
|
||||
onSortChange: (s: any) => void;
|
||||
}) => (
|
||||
<div data-testid="filters">
|
||||
<button onClick={() => onTabChange("discovered")}>To Discovered</button>
|
||||
<button onClick={() => onSearchQueryChange("test search")}>Set Search</button>
|
||||
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>Set Sort</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./orchestrator/JobDetailPanel", () => ({
|
||||
JobDetailPanel: () => <div data-testid="detail-panel" />,
|
||||
}));
|
||||
|
||||
vi.mock("./orchestrator/JobListPanel", () => ({
|
||||
JobListPanel: ({ onSelectJob, selectedJobId }: { onSelectJob: (id: string) => void; selectedJobId: string | null }) => (
|
||||
<div>
|
||||
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
|
||||
<button data-testid="select-job-1" type="button" onClick={() => onSelectJob("job-1")}>
|
||||
Select job 1
|
||||
</button>
|
||||
<button data-testid="select-job-2" type="button" onClick={() => onSelectJob("job-2")}>
|
||||
Select job 2
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../components", () => ({
|
||||
ManualImportSheet: () => <div data-testid="manual-import" />,
|
||||
}));
|
||||
|
||||
const LocationWatcher = () => {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location">{location.pathname + location.search}</div>;
|
||||
};
|
||||
|
||||
describe("OrchestratorPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("syncs tab selection to the URL", () => {
|
||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("To Discovered"));
|
||||
expect(screen.getByTestId("location").textContent).toContain("/discovered");
|
||||
});
|
||||
|
||||
it("syncs job selection to the URL", async () => {
|
||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/all"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Initial load will auto-select the first matching job (job-1 for all tab)
|
||||
const locationText = () => screen.getByTestId("location").textContent;
|
||||
expect(locationText()).toContain("/all/job-1");
|
||||
|
||||
// Clicking job-2 should update URL
|
||||
const job2Button = screen.getByTestId("select-job-2");
|
||||
fireEvent.click(job2Button);
|
||||
|
||||
// Wait for URL to update
|
||||
await waitFor(() => {
|
||||
expect(locationText()).toContain("/all/job-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs search query to URL as a parameter", () => {
|
||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Set Search"));
|
||||
expect(screen.getByTestId("location").textContent).toContain("q=test+search");
|
||||
});
|
||||
|
||||
it("syncs sorting to URL and removes it when default", () => {
|
||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Set Sort"));
|
||||
expect(screen.getByTestId("location").textContent).toContain("sort=title-asc");
|
||||
});
|
||||
|
||||
it("opens the detail drawer on mobile when a job is selected", () => {
|
||||
window.matchMedia = createMatchMedia(false) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("select-job-1"));
|
||||
|
||||
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the detail panel inline on desktop", () => {
|
||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
164
orchestrator/src/client/pages/SettingsPage.test.tsx
Normal file
164
orchestrator/src/client/pages/SettingsPage.test.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
import { SettingsPage } from "./SettingsPage"
|
||||
import * as api from "../api"
|
||||
import { toast } from "sonner"
|
||||
import type { AppSettings } from "@shared/types"
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
clearDatabase: vi.fn(),
|
||||
deleteJobsByStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const baseSettings: AppSettings = {
|
||||
model: "openai/gpt-4o-mini",
|
||||
defaultModel: "openai/gpt-4o-mini",
|
||||
overrideModel: null,
|
||||
modelScorer: "openai/gpt-4o-mini",
|
||||
overrideModelScorer: null,
|
||||
modelTailoring: "openai/gpt-4o-mini",
|
||||
overrideModelTailoring: null,
|
||||
modelProjectSelection: "openai/gpt-4o-mini",
|
||||
overrideModelProjectSelection: null,
|
||||
pipelineWebhookUrl: "",
|
||||
defaultPipelineWebhookUrl: "",
|
||||
overridePipelineWebhookUrl: null,
|
||||
jobCompleteWebhookUrl: "",
|
||||
defaultJobCompleteWebhookUrl: "",
|
||||
overrideJobCompleteWebhookUrl: null,
|
||||
profileProjects: [
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
description: "Desc 1",
|
||||
date: "2024",
|
||||
isVisibleInBase: true,
|
||||
},
|
||||
{
|
||||
id: "proj-2",
|
||||
name: "Project Two",
|
||||
description: "Desc 2",
|
||||
date: "2023",
|
||||
isVisibleInBase: false,
|
||||
},
|
||||
],
|
||||
resumeProjects: {
|
||||
maxProjects: 2,
|
||||
lockedProjectIds: [],
|
||||
aiSelectableProjectIds: ["proj-1", "proj-2"],
|
||||
},
|
||||
defaultResumeProjects: {
|
||||
maxProjects: 2,
|
||||
lockedProjectIds: [],
|
||||
aiSelectableProjectIds: ["proj-1", "proj-2"],
|
||||
},
|
||||
overrideResumeProjects: null,
|
||||
ukvisajobsMaxJobs: 50,
|
||||
defaultUkvisajobsMaxJobs: 50,
|
||||
overrideUkvisajobsMaxJobs: null,
|
||||
gradcrackerMaxJobsPerTerm: 50,
|
||||
defaultGradcrackerMaxJobsPerTerm: 50,
|
||||
overrideGradcrackerMaxJobsPerTerm: null,
|
||||
searchTerms: ["engineer"],
|
||||
defaultSearchTerms: ["engineer"],
|
||||
overrideSearchTerms: null,
|
||||
jobspyLocation: "UK",
|
||||
defaultJobspyLocation: "UK",
|
||||
overrideJobspyLocation: null,
|
||||
jobspyResultsWanted: 200,
|
||||
defaultJobspyResultsWanted: 200,
|
||||
overrideJobspyResultsWanted: null,
|
||||
jobspyHoursOld: 72,
|
||||
defaultJobspyHoursOld: 72,
|
||||
overrideJobspyHoursOld: null,
|
||||
jobspyCountryIndeed: "UK",
|
||||
defaultJobspyCountryIndeed: "UK",
|
||||
overrideJobspyCountryIndeed: null,
|
||||
jobspySites: ["indeed", "linkedin"],
|
||||
defaultJobspySites: ["indeed", "linkedin"],
|
||||
overrideJobspySites: null,
|
||||
jobspyLinkedinFetchDescription: true,
|
||||
defaultJobspyLinkedinFetchDescription: true,
|
||||
overrideJobspyLinkedinFetchDescription: null,
|
||||
}
|
||||
|
||||
const renderPage = () => {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<SettingsPage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("saves trimmed model overrides", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
vi.mocked(api.updateSettings).mockResolvedValue({
|
||||
...baseSettings,
|
||||
overrideModel: "gpt-4",
|
||||
model: "gpt-4",
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
const modelTrigger = await screen.findByRole("button", { name: /model/i })
|
||||
fireEvent.click(modelTrigger)
|
||||
|
||||
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main")
|
||||
const modelInput = within(modelField).getByRole("textbox")
|
||||
fireEvent.change(modelInput, { target: { value: " gpt-4 " } })
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
await waitFor(() => expect(saveButton).toBeEnabled())
|
||||
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled())
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "gpt-4",
|
||||
})
|
||||
)
|
||||
expect(toast.success).toHaveBeenCalledWith("Settings saved")
|
||||
})
|
||||
|
||||
it("clears jobs by status and summarizes results", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 })
|
||||
|
||||
renderPage()
|
||||
|
||||
const dangerTrigger = await screen.findByRole("button", { name: /danger zone/i })
|
||||
fireEvent.click(dangerTrigger)
|
||||
|
||||
const clearSelectedButton = await screen.findByRole("button", { name: /clear selected/i })
|
||||
fireEvent.click(clearSelectedButton)
|
||||
|
||||
const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => expect(api.deleteJobsByStatus).toHaveBeenCalledWith("discovered"))
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
"Jobs cleared",
|
||||
expect.objectContaining({
|
||||
description: "Deleted 2 jobs: 2 discovered",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -3,70 +3,25 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { AlertTriangle, Settings, Trash2 } from "lucide-react"
|
||||
import { Settings } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { PageHeader } from "../components/layout"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion"
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types"
|
||||
import * as api from "../api"
|
||||
|
||||
/** All available job statuses for clearing */
|
||||
const ALL_JOB_STATUSES: JobStatus[] = ['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']
|
||||
|
||||
/** Status descriptions for UI */
|
||||
const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
|
||||
discovered: 'Crawled but not processed',
|
||||
processing: 'Currently generating resume',
|
||||
ready: 'PDF generated, waiting for user to apply',
|
||||
applied: 'User marked as applied',
|
||||
skipped: 'User skipped this job',
|
||||
expired: 'Deadline passed',
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
|
||||
return (
|
||||
a.maxProjects === b.maxProjects &&
|
||||
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
|
||||
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
|
||||
)
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
const int = Math.floor(value)
|
||||
if (Number.isNaN(int)) return min
|
||||
return Math.min(max, Math.max(min, int))
|
||||
}
|
||||
import { arraysEqual } from "@/lib/utils"
|
||||
import { resumeProjectsEqual } from "./settings/utils"
|
||||
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
|
||||
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
|
||||
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
|
||||
import { JobspySection } from "./settings/components/JobspySection"
|
||||
import { ModelSettingsSection } from "./settings/components/ModelSettingsSection"
|
||||
import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSection"
|
||||
import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection"
|
||||
import { SearchTermsSection } from "./settings/components/SearchTermsSection"
|
||||
import { UkvisajobsSection } from "./settings/components/UkvisajobsSection"
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||
@ -332,7 +287,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setIsSaving(true)
|
||||
let totalDeleted = 0
|
||||
const results: string[] = []
|
||||
|
||||
|
||||
for (const status of statusesToClear) {
|
||||
const result = await api.deleteJobsByStatus(status)
|
||||
totalDeleted += result.count
|
||||
@ -340,14 +295,14 @@ export const SettingsPage: React.FC = () => {
|
||||
results.push(`${result.count} ${status}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (totalDeleted > 0) {
|
||||
toast.success("Jobs cleared", {
|
||||
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`
|
||||
toast.success("Jobs cleared", {
|
||||
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`,
|
||||
})
|
||||
} else {
|
||||
toast.info("No jobs found", {
|
||||
description: `No jobs with selected statuses found`
|
||||
toast.info("No jobs found", {
|
||||
description: `No jobs with selected statuses found`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@ -359,8 +314,8 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const toggleStatusToClear = (status: JobStatus) => {
|
||||
setStatusesToClear(prev =>
|
||||
prev.includes(status)
|
||||
setStatusesToClear(prev =>
|
||||
prev.includes(status)
|
||||
? prev.filter(s => s !== status)
|
||||
: [...prev, status]
|
||||
)
|
||||
@ -422,707 +377,119 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
|
||||
<Accordion type="multiple" className="w-full space-y-4">
|
||||
<AccordionItem value="model" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Model</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div 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>
|
||||
<ModelSettingsSection
|
||||
modelDraft={modelDraft}
|
||||
setModelDraft={setModelDraft}
|
||||
modelScorerDraft={modelScorerDraft}
|
||||
setModelScorerDraft={setModelScorerDraft}
|
||||
modelTailoringDraft={modelTailoringDraft}
|
||||
setModelTailoringDraft={setModelTailoringDraft}
|
||||
modelProjectSelectionDraft={modelProjectSelectionDraft}
|
||||
setModelProjectSelectionDraft={setModelProjectSelectionDraft}
|
||||
effectiveModel={effectiveModel}
|
||||
effectiveModelScorer={effectiveModelScorer}
|
||||
effectiveModelTailoring={effectiveModelTailoring}
|
||||
effectiveModelProjectSelection={effectiveModelProjectSelection}
|
||||
defaultModel={defaultModel}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<PipelineWebhookSection
|
||||
pipelineWebhookUrlDraft={pipelineWebhookUrlDraft}
|
||||
setPipelineWebhookUrlDraft={setPipelineWebhookUrlDraft}
|
||||
defaultPipelineWebhookUrl={defaultPipelineWebhookUrl}
|
||||
effectivePipelineWebhookUrl={effectivePipelineWebhookUrl}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<JobCompleteWebhookSection
|
||||
jobCompleteWebhookUrlDraft={jobCompleteWebhookUrlDraft}
|
||||
setJobCompleteWebhookUrlDraft={setJobCompleteWebhookUrlDraft}
|
||||
defaultJobCompleteWebhookUrl={defaultJobCompleteWebhookUrl}
|
||||
effectiveJobCompleteWebhookUrl={effectiveJobCompleteWebhookUrl}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<UkvisajobsSection
|
||||
ukvisajobsMaxJobsDraft={ukvisajobsMaxJobsDraft}
|
||||
setUkvisajobsMaxJobsDraft={setUkvisajobsMaxJobsDraft}
|
||||
defaultUkvisajobsMaxJobs={defaultUkvisajobsMaxJobs}
|
||||
effectiveUkvisajobsMaxJobs={effectiveUkvisajobsMaxJobs}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<GradcrackerSection
|
||||
gradcrackerMaxJobsPerTermDraft={gradcrackerMaxJobsPerTermDraft}
|
||||
setGradcrackerMaxJobsPerTermDraft={setGradcrackerMaxJobsPerTermDraft}
|
||||
defaultGradcrackerMaxJobsPerTerm={defaultGradcrackerMaxJobsPerTerm}
|
||||
effectiveGradcrackerMaxJobsPerTerm={effectiveGradcrackerMaxJobsPerTerm}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<SearchTermsSection
|
||||
searchTermsDraft={searchTermsDraft}
|
||||
setSearchTermsDraft={setSearchTermsDraft}
|
||||
defaultSearchTerms={defaultSearchTerms}
|
||||
effectiveSearchTerms={effectiveSearchTerms}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<JobspySection
|
||||
jobspySitesDraft={jobspySitesDraft}
|
||||
setJobspySitesDraft={setJobspySitesDraft}
|
||||
defaultJobspySites={defaultJobspySites}
|
||||
effectiveJobspySites={effectiveJobspySites}
|
||||
jobspyLocationDraft={jobspyLocationDraft}
|
||||
setJobspyLocationDraft={setJobspyLocationDraft}
|
||||
defaultJobspyLocation={defaultJobspyLocation}
|
||||
effectiveJobspyLocation={effectiveJobspyLocation}
|
||||
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
|
||||
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
|
||||
defaultJobspyResultsWanted={defaultJobspyResultsWanted}
|
||||
effectiveJobspyResultsWanted={effectiveJobspyResultsWanted}
|
||||
jobspyHoursOldDraft={jobspyHoursOldDraft}
|
||||
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
|
||||
defaultJobspyHoursOld={defaultJobspyHoursOld}
|
||||
effectiveJobspyHoursOld={effectiveJobspyHoursOld}
|
||||
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
|
||||
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
|
||||
defaultJobspyCountryIndeed={defaultJobspyCountryIndeed}
|
||||
effectiveJobspyCountryIndeed={effectiveJobspyCountryIndeed}
|
||||
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
|
||||
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
|
||||
defaultJobspyLinkedinFetchDescription={defaultJobspyLinkedinFetchDescription}
|
||||
effectiveJobspyLinkedinFetchDescription={effectiveJobspyLinkedinFetchDescription}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ResumeProjectsSection
|
||||
resumeProjectsDraft={resumeProjectsDraft}
|
||||
setResumeProjectsDraft={setResumeProjectsDraft}
|
||||
profileProjects={profileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={maxProjectsTotal}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<DangerZoneSection
|
||||
statusesToClear={statusesToClear}
|
||||
toggleStatusToClear={toggleStatusToClear}
|
||||
handleClearByStatuses={handleClearByStatuses}
|
||||
handleClearDatabase={handleClearDatabase}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</Accordion>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">Task-Specific Overrides</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Scoring Model</div>
|
||||
<Input
|
||||
value={modelScorerDraft}
|
||||
onChange={(event) => setModelScorerDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Tailoring Model</div>
|
||||
<Input
|
||||
value={modelTailoringDraft}
|
||||
onChange={(event) => setModelTailoringDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Project Selection Model</div>
|
||||
<Input
|
||||
value={modelProjectSelectionDraft}
|
||||
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Global 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>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Pipeline Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div 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>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Job Complete Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div 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>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">UKVisaJobs Extractor</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max jobs to fetch</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setUkvisajobsMaxJobsDraft(null)
|
||||
} else {
|
||||
setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
|
||||
</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">{effectiveUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Gradcracker Extractor</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max jobs per search term</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setGradcrackerMaxJobsPerTermDraft(null)
|
||||
} else {
|
||||
setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
|
||||
</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">{effectiveGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="search-terms" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Search Terms</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Global search terms</div>
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value
|
||||
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
|
||||
setSearchTermsDraft(terms)
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Clean up on blur
|
||||
if (searchTermsDraft) {
|
||||
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. web developer"
|
||||
disabled={isLoading || isSaving}
|
||||
rows={5}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
One term per line. Applies to UKVisaJobs and other supported extractors.
|
||||
</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">{(effectiveSearchTerms || []).join(', ') || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{(defaultSearchTerms || []).join(', ') || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="jobspy" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">JobSpy Scraper</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Scraped Sites</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="site-indeed"
|
||||
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = jobspySitesDraft ?? defaultJobspySites
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('indeed')) next.push('indeed')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'indeed')
|
||||
}
|
||||
setJobspySitesDraft(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="site-linkedin"
|
||||
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = jobspySitesDraft ?? defaultJobspySites
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('linkedin')) next.push('linkedin')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'linkedin')
|
||||
}
|
||||
setJobspySitesDraft(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Select which sites JobSpy should scrape.
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {(effectiveJobspySites || []).join(', ') || "None"}</span>
|
||||
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Location</div>
|
||||
<Input
|
||||
value={jobspyLocationDraft ?? defaultJobspyLocation}
|
||||
onChange={(event) => setJobspyLocationDraft(event.target.value)}
|
||||
placeholder={defaultJobspyLocation || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Location to search for jobs (e.g. "UK", "London", "Remote").
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLocation || "—"}</span>
|
||||
<span>Default: {defaultJobspyLocation || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Results Wanted</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={500}
|
||||
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setJobspyResultsWantedDraft(null)
|
||||
} else {
|
||||
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Number of results to fetch per term per site. Max 500.
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyResultsWanted}</span>
|
||||
<span>Default: {defaultJobspyResultsWanted}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Hours Old</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={168}
|
||||
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setJobspyHoursOldDraft(null)
|
||||
} else {
|
||||
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Max age of jobs in hours (e.g. 72 for 3 days).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyHoursOld}h</span>
|
||||
<span>Default: {defaultJobspyHoursOld}h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Indeed Country</div>
|
||||
<Input
|
||||
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
|
||||
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
|
||||
placeholder={defaultJobspyCountryIndeed || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyCountryIndeed || "—"}</span>
|
||||
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="linkedin-desc"
|
||||
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
|
||||
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="linkedin-desc"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Fetch LinkedIn Description
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
|
||||
</p>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Resume Projects</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max projects included</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={resumeProjectsDraft?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const next = Number(event.target.value)
|
||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
|
||||
}}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
|
||||
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="w-[110px]">Base visible</TableHead>
|
||||
<TableHead className="w-[90px]">Locked</TableHead>
|
||||
<TableHead className="w-[140px]">AI selectable</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
|
||||
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
|
||||
const excluded = !locked && !aiSelectable
|
||||
|
||||
return (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{project.name || project.id}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[project.description, project.date].filter(Boolean).join(" · ")}
|
||||
{excluded ? " · Excluded" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
|
||||
if (isChecked) {
|
||||
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
|
||||
const nextSelectable = selectableIds.filter((id) => id !== project.id)
|
||||
const minCap = lockedIds.length
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: lockedIds,
|
||||
aiSelectableProjectIds: nextSelectable,
|
||||
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: nextLocked,
|
||||
aiSelectableProjectIds: selectableIds,
|
||||
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
const nextSelectable = isChecked
|
||||
? selectableIds.includes(project.id)
|
||||
? selectableIds
|
||||
: [...selectableIds, project.id]
|
||||
: selectableIds.filter((id) => id !== project.id)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="danger-zone" className="border rounded-lg px-4 border-destructive/30 mt-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="text-base font-semibold tracking-wider">Danger Zone</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="p-3 rounded-md space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-semibold text-destructive">Clear Jobs by Status</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Select which job statuses you want to clear.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{ALL_JOB_STATUSES.map((status) => {
|
||||
const isSelected = statusesToClear.includes(status)
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => toggleStatusToClear(status)}
|
||||
disabled={isLoading || isSaving}
|
||||
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isSelected ? 'border-destructive bg-destructive/10' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${
|
||||
isSelected ? 'border-destructive' : 'border-muted-foreground'
|
||||
}`}>
|
||||
{isSelected && <div className="h-2 w-2 rounded-full bg-destructive" />}
|
||||
</div>
|
||||
<div className="grid gap-0.5">
|
||||
<span className="text-sm font-medium capitalize">{status}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{STATUS_DESCRIPTIONS[status]}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isLoading || isSaving || statusesToClear.length === 0}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Selected ({statusesToClear.length})
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete all jobs with the following statuses: {statusesToClear.join(', ')}.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClearByStatuses} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Clear {statusesToClear.length} status{statusesToClear.length !== 1 ? 'es' : ''}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-semibold text-destructive">Clear Entire Database</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Delete all jobs and pipeline runs from the database.
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" disabled={isLoading || isSaving}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Database
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This deletes all jobs and pipeline runs from the database. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClearDatabase} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Clear database
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -37,13 +37,10 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatDateTime } from "../lib/dateUtils";
|
||||
import { cn, formatDate, formatDateTime, stripHtml } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
import type { CreateJobInput } from "../../shared/types";
|
||||
|
||||
const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const clampText = (value: string, max = 160) => (value.length > max ? `${value.slice(0, max).trim()}...` : value);
|
||||
|
||||
const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl;
|
||||
@ -347,7 +344,7 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
||||
location.pathname === to
|
||||
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname))
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
|
||||
@ -24,7 +24,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import {
|
||||
PageHeader,
|
||||
StatusIndicator,
|
||||
@ -43,26 +43,6 @@ import type {
|
||||
VisaSponsorStatusResponse,
|
||||
} from "../../shared/types";
|
||||
|
||||
const formatDateTime = (dateStr?: string | null) => {
|
||||
if (!dateStr) return "Never";
|
||||
try {
|
||||
const parsed = new Date(dateStr);
|
||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||
const date = parsed.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = parsed.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `${date} ${time}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreTokens = (score: number) => {
|
||||
if (score >= 90)
|
||||
return { badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200" };
|
||||
@ -329,7 +309,7 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatDateTime(status.lastUpdated)}
|
||||
{formatDateTime(status.lastUpdated) || "Never"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -0,0 +1,278 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { JobDetailPanel } from "./JobDetailPanel";
|
||||
import type { Job } from "../../../shared/types";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
}) => (
|
||||
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <div role="separator" />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../components", () => ({
|
||||
DiscoveredPanel: ({ job }: { job: Job | null }) => (
|
||||
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ReadyPanel", () => ({
|
||||
ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => (
|
||||
<div>
|
||||
<div data-testid="ready-panel" />
|
||||
<button type="button" onClick={() => onEditDescription?.()}>
|
||||
Edit description
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/TailoringEditor", () => ({
|
||||
TailoringEditor: () => <div data-testid="tailoring-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/utils")>();
|
||||
return {
|
||||
...actual,
|
||||
copyTextToClipboard: vi.fn().mockResolvedValue(undefined),
|
||||
formatJobForWebhook: vi.fn(() => "payload"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
updateJob: vi.fn(),
|
||||
processJob: vi.fn(),
|
||||
generateJobPdf: vi.fn(),
|
||||
markAsApplied: vi.fn(),
|
||||
skipJob: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
message: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job",
|
||||
applicationLink: "https://example.com/apply",
|
||||
disciplines: null,
|
||||
deadline: "2025-02-01",
|
||||
salary: "GBP 50k",
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
suitabilityScore: 82,
|
||||
suitabilityReason: "Strong fit",
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("JobDetailPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the discovered panel when active tab is discovered", () => {
|
||||
const job = createJob({ id: "job-99", status: "discovered" });
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="discovered"
|
||||
activeJobs={[job]}
|
||||
selectedJob={job}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||
onSetActiveTab={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
|
||||
});
|
||||
|
||||
it("wires ready panel edit actions back to the page", () => {
|
||||
const onSetActiveTab = vi.fn();
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="ready"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ status: "ready" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||
onSetActiveTab={onSetActiveTab}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit description/i }));
|
||||
expect(onSetActiveTab).toHaveBeenCalledWith("discovered");
|
||||
});
|
||||
|
||||
it("shows an empty state when no job is selected", () => {
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="all"
|
||||
activeJobs={[]}
|
||||
selectedJob={null}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||
onSetActiveTab={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("No job selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a stripped description preview for html content", () => {
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="all"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ jobDescription: "<p>Hello <strong>world</strong></p>" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||
onSetActiveTab={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves an edited description", async () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.updateJob).mockResolvedValue(undefined as any);
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="all"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ jobDescription: "Original" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onSetActiveTab={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^edit$/i }));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Enter job description..."), {
|
||||
target: { value: "Updated description" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.updateJob).toHaveBeenCalledWith("job-1", { jobDescription: "Updated description" })
|
||||
);
|
||||
expect(onJobUpdated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks a job as applied from the action button", async () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any);
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="all"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ status: "ready" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onSetActiveTab={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /applied/i }));
|
||||
|
||||
await waitFor(() => expect(api.markAsApplied).toHaveBeenCalledWith("job-1"));
|
||||
expect(onJobUpdated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a job from the menu", async () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.skipJob).mockResolvedValue(undefined as any);
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="all"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ status: "ready" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onSetActiveTab={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /more actions/i }));
|
||||
const skipItem = await screen.findByRole("menuitem", { name: /skip job/i });
|
||||
fireEvent.click(skipItem);
|
||||
|
||||
await waitFor(() => expect(api.skipJob).toHaveBeenCalledWith("job-1"));
|
||||
expect(onJobUpdated).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
568
orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx
Normal file
568
orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx
Normal file
@ -0,0 +1,568 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Edit2,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
RefreshCcw,
|
||||
Save,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml } from "@/lib/utils";
|
||||
|
||||
import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components";
|
||||
import { ReadyPanel } from "../../components/ReadyPanel";
|
||||
import { TailoringEditor } from "../../components/TailoringEditor";
|
||||
import * as api from "../../api";
|
||||
import type { Job } from "../../../shared/types";
|
||||
import type { FilterTab } from "./constants";
|
||||
|
||||
interface JobDetailPanelProps {
|
||||
activeTab: FilterTab;
|
||||
activeJobs: Job[];
|
||||
selectedJob: Job | null;
|
||||
onSelectJobId: (jobId: string | null) => void;
|
||||
onJobUpdated: () => Promise<void>;
|
||||
onSetActiveTab: (tab: FilterTab) => void;
|
||||
}
|
||||
|
||||
export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
activeTab,
|
||||
activeJobs,
|
||||
selectedJob,
|
||||
onSelectJobId,
|
||||
onJobUpdated,
|
||||
onSetActiveTab,
|
||||
}) => {
|
||||
const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview");
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||
const [editedDescription, setEditedDescription] = useState("");
|
||||
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
||||
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
|
||||
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
|
||||
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasUnsavedTailoring(false);
|
||||
saveTailoringRef.current = null;
|
||||
}, [selectedJob?.id]);
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (!selectedJob?.jobDescription) return "No description available.";
|
||||
const jd = selectedJob.jobDescription;
|
||||
if (jd.includes("<") && jd.includes(">")) return stripHtml(jd);
|
||||
return jd;
|
||||
}, [selectedJob]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedJob) {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription("");
|
||||
return;
|
||||
}
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}, [selectedJob?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedJob) return;
|
||||
if (!isEditingDescription) {
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}
|
||||
}, [selectedJob?.jobDescription, isEditingDescription, selectedJob]);
|
||||
|
||||
const handleSaveDescription = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
setIsSavingDescription(true);
|
||||
await api.updateJob(selectedJob.id, { jobDescription: editedDescription });
|
||||
toast.success("Job description updated");
|
||||
setIsEditingDescription(false);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update description";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSavingDescription(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasUnsavedDescription =
|
||||
!!selectedJob &&
|
||||
isEditingDescription &&
|
||||
editedDescription !== (selectedJob.jobDescription || "");
|
||||
|
||||
const confirmAndSaveEdits = useCallback(
|
||||
async ({ includeTailoring = true }: { includeTailoring?: boolean } = {}) => {
|
||||
const pendingDescription = hasUnsavedDescription;
|
||||
const pendingTailoring = includeTailoring && hasUnsavedTailoring;
|
||||
|
||||
if (!pendingDescription && !pendingTailoring) return true;
|
||||
|
||||
const parts = [];
|
||||
if (pendingDescription) parts.push("job description");
|
||||
if (pendingTailoring) parts.push("tailoring changes");
|
||||
|
||||
const message = `You have unsaved ${parts.join(" and ")}. Save before generating the PDF?`;
|
||||
if (!window.confirm(message)) return false;
|
||||
|
||||
try {
|
||||
if (pendingDescription && selectedJob) {
|
||||
await api.updateJob(selectedJob.id, { jobDescription: editedDescription });
|
||||
}
|
||||
|
||||
if (pendingTailoring) {
|
||||
const saveTailoring = saveTailoringRef.current;
|
||||
if (!saveTailoring) {
|
||||
toast.error("Could not save tailoring changes");
|
||||
return false;
|
||||
}
|
||||
await saveTailoring();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to save changes";
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[editedDescription, hasUnsavedDescription, hasUnsavedTailoring, selectedJob],
|
||||
);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
const shouldProceed = await confirmAndSaveEdits({ includeTailoring: true });
|
||||
if (!shouldProceed) return;
|
||||
|
||||
setProcessingJobId(selectedJob.id);
|
||||
|
||||
if (selectedJob.status === "ready") {
|
||||
await api.generateJobPdf(selectedJob.id);
|
||||
toast.success("Resume regenerated successfully");
|
||||
} else {
|
||||
await api.processJob(selectedJob.id);
|
||||
toast.success("Resume generated successfully");
|
||||
}
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to process job";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setProcessingJobId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await api.markAsApplied(selectedJob.id);
|
||||
toast.success("Marked as applied");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to mark as applied";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await api.skipJob(selectedJob.id);
|
||||
toast.message("Job skipped");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to skip job";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInfo = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await copyTextToClipboard(formatJobForWebhook(selectedJob));
|
||||
toast.success("Copied job info", { description: "Webhook payload copied to clipboard." });
|
||||
} catch {
|
||||
toast.error("Could not copy job info");
|
||||
}
|
||||
};
|
||||
|
||||
const handleJobMoved = useCallback(
|
||||
(jobId: string) => {
|
||||
const currentIndex = activeJobs.findIndex((job) => job.id === jobId);
|
||||
const nextJob = activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1];
|
||||
onSelectJobId(nextJob?.id ?? null);
|
||||
},
|
||||
[activeJobs, onSelectJobId],
|
||||
);
|
||||
|
||||
const selectedHasPdf = !!selectedJob?.pdfPath;
|
||||
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#";
|
||||
const selectedPdfHref = selectedJob
|
||||
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
|
||||
: "#";
|
||||
const canApply = selectedJob?.status === "ready";
|
||||
const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
||||
const canSkip = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
||||
const showReadyPdf = activeTab === "ready";
|
||||
const showGeneratePdf = activeTab === "discovered";
|
||||
const isProcessingSelected =
|
||||
selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false;
|
||||
|
||||
if (activeTab === "discovered") {
|
||||
return (
|
||||
<DiscoveredPanel
|
||||
job={selectedJob}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={handleJobMoved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "ready") {
|
||||
return (
|
||||
<ReadyPanel
|
||||
job={selectedJob}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={handleJobMoved}
|
||||
onEditTailoring={() => {
|
||||
onSetActiveTab("discovered");
|
||||
setTimeout(() => setDetailTab("tailoring"), 50);
|
||||
}}
|
||||
onEditDescription={() => {
|
||||
onSetActiveTab("discovered");
|
||||
setTimeout(() => {
|
||||
setDetailTab("description");
|
||||
setIsEditingDescription(true);
|
||||
}, 50);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedJob) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 text-center">
|
||||
<div className="text-sm font-medium text-muted-foreground">No job selected</div>
|
||||
<p className="text-xs text-muted-foreground/70">Select a job to view details</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<JobHeader job={selectedJob} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
View
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
{showReadyPdf &&
|
||||
(selectedHasPdf ? (
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
PDF
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-xs" disabled>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
PDF
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{showGeneratePdf && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={handleProcess}
|
||||
disabled={!canProcess || isProcessingSelected}
|
||||
>
|
||||
{isProcessingSelected ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isProcessingSelected ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canApply && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs bg-emerald-600/20 text-emerald-300 hover:bg-emerald-600/30 border border-emerald-500/30"
|
||||
onClick={handleApply}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Applied
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" aria-label="More actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canProcess && !showGeneratePdf && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void handleProcess()}
|
||||
disabled={isProcessingSelected}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
{isProcessingSelected
|
||||
? "Processing..."
|
||||
: selectedJob.status === "ready"
|
||||
? "Regenerate PDF"
|
||||
: "Generate PDF"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDetailTab("description");
|
||||
setIsEditingDescription(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit description
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void handleCopyInfo()}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy info
|
||||
</DropdownMenuItem>
|
||||
{selectedHasPdf && (
|
||||
<>
|
||||
{!showReadyPdf && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View PDF
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={selectedPdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canSkip && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void handleSkip()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Skip job
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Tabs value={detailTab} onValueChange={(value) => setDetailTab(value as typeof detailTab)}>
|
||||
<TabsList className="h-auto flex-wrap justify-start gap-1 text-xs">
|
||||
<TabsTrigger value="overview" className="text-xs">Overview</TabsTrigger>
|
||||
<TabsTrigger value="tailoring" className="text-xs">Tailoring</TabsTrigger>
|
||||
<TabsTrigger value="description" className="text-xs">Description</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-3 pt-2">
|
||||
<FitAssessment job={selectedJob} />
|
||||
<TailoredSummary job={selectedJob} />
|
||||
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Discipline</div>
|
||||
<div className="text-foreground/80">{selectedJob.disciplines || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Function</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobFunction || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Level</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobLevel || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Type</div>
|
||||
<div className="text-foreground/80">{selectedJob.jobType || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left rounded border border-border/30 bg-muted/5 px-2.5 py-2 text-[11px] text-muted-foreground/80 line-clamp-4 whitespace-pre-wrap leading-relaxed hover:bg-muted/10 transition-colors"
|
||||
onClick={() => setDetailTab("description")}
|
||||
>
|
||||
{description}
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
onClick={() => setDetailTab("description")}
|
||||
>
|
||||
View full description
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tailoring" className="pt-3">
|
||||
<TailoringEditor
|
||||
job={selectedJob}
|
||||
onUpdate={onJobUpdated}
|
||||
onDirtyChange={setHasUnsavedTailoring}
|
||||
onRegisterSave={(save) => {
|
||||
saveTailoringRef.current = save;
|
||||
}}
|
||||
onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="description" className="space-y-3 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Job description
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isEditingDescription ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditingDescription(true)}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}}
|
||||
className="h-8 px-2 text-xs text-muted-foreground"
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleSaveDescription}
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
{isSavingDescription ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8" aria-label="Description actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void copyTextToClipboard(selectedJob.jobDescription || "");
|
||||
toast.success("Copied raw description");
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy raw text
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-sm text-muted-foreground">
|
||||
{isEditingDescription ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={editedDescription}
|
||||
onChange={(event) => setEditedDescription(event.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm leading-relaxed focus-visible:ring-1"
|
||||
placeholder="Enter job description..."
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditingDescription(false);
|
||||
setEditedDescription(selectedJob.jobDescription || "");
|
||||
}}
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDescription}
|
||||
disabled={isSavingDescription}
|
||||
>
|
||||
{isSavingDescription ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Description
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
140
orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
Normal file
140
orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { JobListPanel } from "./JobListPanel";
|
||||
import type { Job } from "../../../shared/types";
|
||||
|
||||
const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
suitabilityScore: 72,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("JobListPanel", () => {
|
||||
it("shows a loading state when fetching jobs", () => {
|
||||
render(
|
||||
<JobListPanel
|
||||
isLoading
|
||||
jobs={[]}
|
||||
activeJobs={[]}
|
||||
selectedJobId={null}
|
||||
activeTab="ready"
|
||||
searchQuery=""
|
||||
onSelectJob={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading jobs...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the tab empty state copy when no jobs exist", () => {
|
||||
render(
|
||||
<JobListPanel
|
||||
isLoading={false}
|
||||
jobs={[]}
|
||||
activeJobs={[]}
|
||||
selectedJobId={null}
|
||||
activeTab="ready"
|
||||
searchQuery=""
|
||||
onSelectJob={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("No jobs found")).toBeInTheDocument();
|
||||
expect(screen.getByText("Run the pipeline to discover and process new jobs.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the query-specific empty state when searching", () => {
|
||||
render(
|
||||
<JobListPanel
|
||||
isLoading={false}
|
||||
jobs={[]}
|
||||
activeJobs={[]}
|
||||
selectedJobId={null}
|
||||
activeTab="ready"
|
||||
searchQuery="iOS"
|
||||
onSelectJob={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders jobs and notifies when a job is selected", () => {
|
||||
const onSelectJob = vi.fn();
|
||||
const jobs = [
|
||||
createJob({ id: "job-1", title: "Backend Engineer" }),
|
||||
createJob({ id: "job-2", title: "Frontend Engineer", employer: "Globex" }),
|
||||
];
|
||||
|
||||
render(
|
||||
<JobListPanel
|
||||
isLoading={false}
|
||||
jobs={jobs}
|
||||
activeJobs={jobs}
|
||||
selectedJobId="job-1"
|
||||
activeTab="ready"
|
||||
searchQuery=""
|
||||
onSelectJob={onSelectJob}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Backend Engineer/i })).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i }));
|
||||
expect(onSelectJob).toHaveBeenCalledWith("job-2");
|
||||
});
|
||||
});
|
||||
111
orchestrator/src/client/pages/orchestrator/JobListPanel.tsx
Normal file
111
orchestrator/src/client/pages/orchestrator/JobListPanel.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { Job } from "../../../shared/types";
|
||||
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
|
||||
import type { FilterTab } from "./constants";
|
||||
|
||||
interface JobListPanelProps {
|
||||
isLoading: boolean;
|
||||
jobs: Job[];
|
||||
activeJobs: Job[];
|
||||
selectedJobId: string | null;
|
||||
activeTab: FilterTab;
|
||||
searchQuery: string;
|
||||
onSelectJob: (jobId: string) => void;
|
||||
}
|
||||
|
||||
export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
isLoading,
|
||||
jobs,
|
||||
activeJobs,
|
||||
selectedJobId,
|
||||
activeTab,
|
||||
searchQuery,
|
||||
onSelectJob,
|
||||
}) => (
|
||||
<div className="min-w-0 rounded-xl border border-border bg-card shadow-sm">
|
||||
{isLoading && jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm text-muted-foreground">Loading jobs...</div>
|
||||
</div>
|
||||
) : activeJobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
|
||||
<div className="text-base font-semibold">No jobs found</div>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{activeJobs.map((job) => {
|
||||
const isSelected = job.id === selectedJobId;
|
||||
const hasScore = job.suitabilityScore != null;
|
||||
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
|
||||
return (
|
||||
<button
|
||||
key={job.id}
|
||||
type="button"
|
||||
onClick={() => onSelectJob(job.id)}
|
||||
data-testid={`select-${job.id}`}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/5 border-l-2 border-l-primary"
|
||||
: "hover:bg-muted/20 border-l-2 border-l-transparent",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{/* Single status indicator: subtle dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
statusToken.dot,
|
||||
!isSelected && "opacity-70",
|
||||
)}
|
||||
title={statusToken.label}
|
||||
/>
|
||||
|
||||
{/* Primary content: title strongest, company secondary */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm leading-tight",
|
||||
isSelected ? "font-semibold" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{job.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.employer}
|
||||
{job.location && <span className="before:content-['_in_']">{job.location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Single triage cue: score only (status shown via dot) */}
|
||||
{hasScore && (
|
||||
<div className="shrink-0 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
job.suitabilityScore! >= 70
|
||||
? "text-emerald-400/90"
|
||||
: job.suitabilityScore! >= 50
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{job.suitabilityScore}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { OrchestratorFilters } from "./OrchestratorFilters";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
const React = require("react") as typeof import("react");
|
||||
const RadioGroupContext = React.createContext<((value: string) => void) | null>(null);
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
}) => (
|
||||
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <div role="separator" />,
|
||||
DropdownMenuRadioGroup: ({
|
||||
children,
|
||||
onValueChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onValueChange?: (value: string) => void;
|
||||
}) => (
|
||||
<RadioGroupContext.Provider value={onValueChange ?? null}>
|
||||
<div role="radiogroup">{children}</div>
|
||||
</RadioGroupContext.Provider>
|
||||
),
|
||||
DropdownMenuRadioItem: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => {
|
||||
const onValueChange = React.useContext(RadioGroupContext);
|
||||
return (
|
||||
<button type="button" role="menuitemradio" onClick={() => onValueChange?.(value)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const renderFilters = (overrides?: Partial<ComponentProps<typeof OrchestratorFilters>>) => {
|
||||
const props = {
|
||||
activeTab: "ready" as FilterTab,
|
||||
onTabChange: vi.fn(),
|
||||
counts: {
|
||||
ready: 2,
|
||||
discovered: 1,
|
||||
applied: 3,
|
||||
all: 6,
|
||||
},
|
||||
searchQuery: "",
|
||||
onSearchQueryChange: vi.fn(),
|
||||
sourceFilter: "all" as const,
|
||||
onSourceFilterChange: vi.fn(),
|
||||
sort: { key: "score", direction: "desc" } as JobSort,
|
||||
onSortChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
props,
|
||||
...render(<OrchestratorFilters {...props} />),
|
||||
};
|
||||
};
|
||||
|
||||
describe("OrchestratorFilters", () => {
|
||||
it("notifies when tabs and search are updated", () => {
|
||||
const { props } = renderFilters();
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i }));
|
||||
expect(props.onTabChange).toHaveBeenCalledWith("applied");
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search..."), { target: { value: "Design" } });
|
||||
expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
|
||||
});
|
||||
|
||||
it("updates source and sort selections", async () => {
|
||||
const { props } = renderFilters();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /LinkedIn/i }));
|
||||
expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /score/i }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i }));
|
||||
expect(props.onSortChange).toHaveBeenCalledWith({ key: "score", direction: "asc" });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import { ArrowUpDown, Filter, Search } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { sourceLabel } from "@/lib/utils";
|
||||
import type { JobSource } from "../../../shared/types";
|
||||
import { defaultSortDirection, sortLabels, tabs } from "./constants";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
|
||||
interface OrchestratorFiltersProps {
|
||||
activeTab: FilterTab;
|
||||
onTabChange: (value: FilterTab) => void;
|
||||
counts: Record<FilterTab, number>;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
sourceFilter: JobSource | "all";
|
||||
onSourceFilterChange: (value: JobSource | "all") => void;
|
||||
sort: JobSort;
|
||||
onSortChange: (sort: JobSort) => void;
|
||||
}
|
||||
|
||||
export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
counts,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
sourceFilter,
|
||||
onSourceFilterChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
}) => (
|
||||
<Tabs value={activeTab} onValueChange={(value) => onTabChange(value as FilterTab)}>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 flex items-center lg:flex-none gap-1.5">
|
||||
<span>{tab.label}</span>
|
||||
{counts[tab.id] > 0 && (
|
||||
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">{counts[tab.id]}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex lg:flex-nowrap flex-wrap items-center justify-end gap-2">
|
||||
<div className="relative w-full flex-1 min-w-[180px] lg:max-w-[240px] lg:flex-none">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchQueryChange(event.target.value)}
|
||||
placeholder="Search..."
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
{sourceFilter === "all" ? "All sources" : sourceLabel[sourceFilter]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sourceFilter}
|
||||
onValueChange={(value) => onSourceFilterChange(value as JobSource | "all")}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
|
||||
{(Object.keys(sourceLabel) as JobSource[]).map((key) => (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
{sourceLabel[key]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
||||
>
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
{sortLabels[sort.key]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sort.key}
|
||||
onValueChange={(value) =>
|
||||
onSortChange({
|
||||
key: value as JobSort["key"],
|
||||
direction: defaultSortDirection[value as JobSort["key"]],
|
||||
})
|
||||
}
|
||||
>
|
||||
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
{sortLabels[key]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
onSortChange({
|
||||
...sort,
|
||||
direction: sort.direction === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
>
|
||||
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
@ -0,0 +1,207 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Briefcase,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Home,
|
||||
Loader2,
|
||||
Menu,
|
||||
Play,
|
||||
Settings,
|
||||
Shield,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
import { sourceLabel } from "@/lib/utils";
|
||||
import type { JobSource } from "../../../shared/types";
|
||||
import { orderedSources } from "./constants";
|
||||
|
||||
interface OrchestratorHeaderProps {
|
||||
navOpen: boolean;
|
||||
onNavOpenChange: (open: boolean) => void;
|
||||
isPipelineRunning: boolean;
|
||||
pipelineSources: JobSource[];
|
||||
onToggleSource: (source: JobSource, checked: boolean) => void;
|
||||
onSetPipelineSources: (sources: JobSource[]) => void;
|
||||
onRunPipeline: () => void;
|
||||
onOpenManualImport: () => void;
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Dashboard", icon: Home },
|
||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
||||
navOpen,
|
||||
onNavOpenChange,
|
||||
isPipelineRunning,
|
||||
pipelineSources,
|
||||
onToggleSource,
|
||||
onSetPipelineSources,
|
||||
onRunPipeline,
|
||||
onOpenManualImport,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
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">
|
||||
<Sheet open={navOpen} onOpenChange={onNavOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Open navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64">
|
||||
<SheetHeader>
|
||||
<SheetTitle>JobOps</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{navLinks.map(({ to, label, icon: Icon }) => (
|
||||
<button
|
||||
key={to}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (location.pathname === to) {
|
||||
onNavOpenChange(false);
|
||||
return;
|
||||
}
|
||||
onNavOpenChange(false);
|
||||
setTimeout(() => navigate(to), 150);
|
||||
}}
|
||||
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left ${
|
||||
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname))
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight">Job Ops</div>
|
||||
<div className="text-xs text-muted-foreground">Orchestrator</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPipelineRunning && (
|
||||
<span className="hidden sm:inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-200">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
Pipeline running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOpenManualImport}
|
||||
className="gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Manual import</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onRunPipeline}
|
||||
disabled={isPipelineRunning}
|
||||
className="gap-2"
|
||||
>
|
||||
{isPipelineRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">{isPipelineRunning ? "Running" : "Run pipeline"}</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={isPipelineRunning}
|
||||
aria-label="Select pipeline sources"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sources</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{orderedSources.map((source) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={source}
|
||||
checked={pipelineSources.includes(source)}
|
||||
onCheckedChange={(checked) => onToggleSource(source, Boolean(checked))}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onSetPipelineSources(orderedSources);
|
||||
}}
|
||||
>
|
||||
All sources
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onSetPipelineSources(["gradcracker"]);
|
||||
}}
|
||||
>
|
||||
Gradcracker only
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onSetPipelineSources(["indeed", "linkedin"]);
|
||||
}}
|
||||
>
|
||||
Indeed + LinkedIn only
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
|
||||
import { PipelineProgress } from "../../components";
|
||||
import type { JobStatus } from "../../../shared/types";
|
||||
|
||||
interface OrchestratorSummaryProps {
|
||||
stats: Record<JobStatus, number>;
|
||||
isPipelineRunning: boolean;
|
||||
}
|
||||
|
||||
export const OrchestratorSummary: React.FC<OrchestratorSummaryProps> = ({
|
||||
stats,
|
||||
isPipelineRunning,
|
||||
}) => {
|
||||
const totalJobs = Object.values(stats).reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Jobs</h1>
|
||||
</div>
|
||||
|
||||
{isPipelineRunning && (
|
||||
<div className="max-w-3xl">
|
||||
<PipelineProgress isRunning={isPipelineRunning} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact metrics summary - demoted visual weight */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80">
|
||||
<span><span className="tabular-nums">{stats.ready}</span> ready</span>
|
||||
<span className="text-border">•</span>
|
||||
<span><span className="tabular-nums">{stats.discovered + stats.processing}</span> discovered</span>
|
||||
<span className="text-border">•</span>
|
||||
<span><span className="tabular-nums">{stats.applied}</span> applied</span>
|
||||
<span className="text-border">•</span>
|
||||
<span className="font-medium text-foreground/60">{totalJobs} jobs total</span>
|
||||
{(stats.skipped > 0 || stats.expired > 0) && (
|
||||
<>
|
||||
<span className="text-border">•</span>
|
||||
<span className="text-muted-foreground/60"><span className="tabular-nums">{stats.skipped + stats.expired}</span> skipped</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
85
orchestrator/src/client/pages/orchestrator/constants.ts
Normal file
85
orchestrator/src/client/pages/orchestrator/constants.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type { JobSource, JobStatus } from "../../../shared/types";
|
||||
|
||||
export const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
export const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
|
||||
|
||||
export const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
|
||||
export const statusTokens: Record<JobStatus, { label: string; badge: string; dot: string }> = {
|
||||
discovered: {
|
||||
label: "Discovered",
|
||||
badge: "border-sky-500/30 bg-sky-500/10 text-sky-200",
|
||||
dot: "bg-sky-400",
|
||||
},
|
||||
processing: {
|
||||
label: "Processing",
|
||||
badge: "border-amber-500/30 bg-amber-500/10 text-amber-200",
|
||||
dot: "bg-amber-400",
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
applied: {
|
||||
label: "Applied",
|
||||
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
skipped: {
|
||||
label: "Skipped",
|
||||
badge: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||
dot: "bg-rose-400",
|
||||
},
|
||||
expired: {
|
||||
label: "Expired",
|
||||
badge: "border-muted-foreground/20 bg-muted/30 text-muted-foreground",
|
||||
dot: "bg-muted-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultStatusToken = {
|
||||
label: "Unknown",
|
||||
badge: "border-muted-foreground/20 bg-muted/30 text-muted-foreground",
|
||||
dot: "bg-muted-foreground",
|
||||
};
|
||||
|
||||
export type FilterTab = "ready" | "discovered" | "applied" | "all";
|
||||
|
||||
export type SortKey = "discoveredAt" | "score" | "title" | "employer";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export interface JobSort {
|
||||
key: SortKey;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
export const DEFAULT_SORT: JobSort = { key: "score", direction: "desc" };
|
||||
|
||||
export const sortLabels: Record<JobSort["key"], string> = {
|
||||
discoveredAt: "Discovered",
|
||||
score: "Score",
|
||||
title: "Title",
|
||||
employer: "Company",
|
||||
};
|
||||
|
||||
export const defaultSortDirection: Record<JobSort["key"], SortDirection> = {
|
||||
discoveredAt: "desc",
|
||||
score: "desc",
|
||||
title: "asc",
|
||||
employer: "asc",
|
||||
};
|
||||
|
||||
export const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [
|
||||
{ id: "ready", label: "Ready", statuses: ["ready"] },
|
||||
{ id: "discovered", label: "Discovered", statuses: ["discovered", "processing"] },
|
||||
{ id: "applied", label: "Applied", statuses: ["applied"] },
|
||||
{ id: "all", label: "All Jobs", statuses: [] },
|
||||
];
|
||||
|
||||
export const emptyStateCopy: Record<FilterTab, string> = {
|
||||
ready: "Run the pipeline to discover and process new jobs.",
|
||||
discovered: "All discovered jobs have been processed.",
|
||||
applied: "You have not applied to any jobs yet.",
|
||||
all: "No jobs in the system yet. Run the pipeline to get started.",
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { Job, JobSource } from "../../../shared/types";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
import { compareJobs, jobMatchesQuery } from "./utils";
|
||||
|
||||
export const useFilteredJobs = (
|
||||
jobs: Job[],
|
||||
activeTab: FilterTab,
|
||||
sourceFilter: JobSource | "all",
|
||||
searchQuery: string,
|
||||
sort: JobSort,
|
||||
) =>
|
||||
useMemo(() => {
|
||||
let filtered = jobs;
|
||||
|
||||
if (activeTab === "ready") {
|
||||
filtered = filtered.filter((job) => job.status === "ready");
|
||||
} else if (activeTab === "discovered") {
|
||||
filtered = filtered.filter((job) => job.status === "discovered" || job.status === "processing");
|
||||
} else if (activeTab === "applied") {
|
||||
filtered = filtered.filter((job) => job.status === "applied");
|
||||
}
|
||||
|
||||
if (sourceFilter !== "all") {
|
||||
filtered = filtered.filter((job) => job.source === sourceFilter);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
filtered = filtered.filter((job) => jobMatchesQuery(job, searchQuery));
|
||||
}
|
||||
|
||||
return [...filtered].sort((a, b) => compareJobs(a, b, sort));
|
||||
}, [jobs, activeTab, sourceFilter, searchQuery, sort]);
|
||||
@ -0,0 +1,58 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { Job, JobStatus } from "../../../shared/types";
|
||||
import * as api from "../../api";
|
||||
|
||||
const initialStats: Record<JobStatus, number> = {
|
||||
discovered: 0,
|
||||
processing: 0,
|
||||
ready: 0,
|
||||
applied: 0,
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
};
|
||||
|
||||
export const useOrchestratorData = () => {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [stats, setStats] = useState<Record<JobStatus, number>>(initialStats);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPipelineRunning, setIsPipelineRunning] = useState(false);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getJobs();
|
||||
setJobs(data.jobs);
|
||||
setStats(data.byStatus);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load jobs";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkPipelineStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await api.getPipelineStatus();
|
||||
setIsPipelineRunning(status.isRunning);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
checkPipelineStatus();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadJobs();
|
||||
checkPipelineStatus();
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadJobs, checkPipelineStatus]);
|
||||
|
||||
return { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus };
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { JobSource } from "../../../shared/types";
|
||||
import {
|
||||
DEFAULT_PIPELINE_SOURCES,
|
||||
PIPELINE_SOURCES_STORAGE_KEY,
|
||||
orderedSources,
|
||||
} from "./constants";
|
||||
|
||||
export const usePipelineSources = () => {
|
||||
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_PIPELINE_SOURCES;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return DEFAULT_PIPELINE_SOURCES;
|
||||
const next = parsed.filter((value): value is JobSource => orderedSources.includes(value as JobSource));
|
||||
return next.length > 0 ? next : DEFAULT_PIPELINE_SOURCES;
|
||||
} catch {
|
||||
return DEFAULT_PIPELINE_SOURCES;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, [pipelineSources]);
|
||||
|
||||
const toggleSource = useCallback((source: JobSource, checked: boolean) => {
|
||||
setPipelineSources((current) => {
|
||||
const next = checked
|
||||
? Array.from(new Set([...current, source]))
|
||||
: current.filter((value) => value !== source);
|
||||
|
||||
return next.length === 0 ? current : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { pipelineSources, setPipelineSources, toggleSource };
|
||||
};
|
||||
89
orchestrator/src/client/pages/orchestrator/utils.ts
Normal file
89
orchestrator/src/client/pages/orchestrator/utils.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import type { Job } from "../../../shared/types";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
|
||||
const dateValue = (value: string | null) => {
|
||||
if (!value) return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const compareString = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" });
|
||||
const compareNumber = (a: number, b: number) => a - b;
|
||||
|
||||
export 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 "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": {
|
||||
const aDate = dateValue(a.discoveredAt);
|
||||
const bDate = dateValue(b.discoveredAt);
|
||||
if (aDate == null && bDate == null) {
|
||||
value = 0;
|
||||
break;
|
||||
}
|
||||
if (aDate == null) return 1;
|
||||
if (bDate == null) return -1;
|
||||
value = compareNumber(aDate, bDate);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
value = 0;
|
||||
}
|
||||
|
||||
if (value !== 0) return sort.direction === "asc" ? value : -value;
|
||||
return a.id.localeCompare(b.id);
|
||||
};
|
||||
|
||||
export const jobMatchesQuery = (job: Job, query: string) => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
const haystack = [
|
||||
job.title,
|
||||
job.employer,
|
||||
job.location,
|
||||
job.source,
|
||||
job.status,
|
||||
job.jobType,
|
||||
job.jobFunction,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
};
|
||||
|
||||
export const getJobCounts = (jobs: Job[]): Record<FilterTab, number> => {
|
||||
const byTab: Record<FilterTab, number> = {
|
||||
ready: 0,
|
||||
discovered: 0,
|
||||
applied: 0,
|
||||
all: jobs.length,
|
||||
};
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === "ready") byTab.ready += 1;
|
||||
if (job.status === "applied") byTab.applied += 1;
|
||||
if (job.status === "discovered" || job.status === "processing") byTab.discovered += 1;
|
||||
}
|
||||
|
||||
return byTab;
|
||||
};
|
||||
@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { DangerZoneSection } from "./DangerZoneSection"
|
||||
import type { JobStatus } from "@shared/types"
|
||||
|
||||
const DangerZoneHarness = ({ initialStatuses = [] as JobStatus[], onClear }: { initialStatuses?: JobStatus[]; onClear?: () => void }) => {
|
||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(initialStatuses)
|
||||
|
||||
const toggleStatusToClear = (status: JobStatus) => {
|
||||
setStatusesToClear((prev) =>
|
||||
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" defaultValue={["danger-zone"]}>
|
||||
<DangerZoneSection
|
||||
statusesToClear={statusesToClear}
|
||||
toggleStatusToClear={toggleStatusToClear}
|
||||
handleClearByStatuses={onClear ?? (() => {})}
|
||||
handleClearDatabase={() => {}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
describe("DangerZoneSection", () => {
|
||||
it("disables clear when no statuses are selected", () => {
|
||||
render(<DangerZoneHarness initialStatuses={[]} />)
|
||||
|
||||
const clearButton = screen.getByRole("button", { name: /clear selected/i })
|
||||
expect(clearButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it("toggles status selection and confirms clear", async () => {
|
||||
const onClear = vi.fn()
|
||||
render(<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />)
|
||||
|
||||
const appliedButton = screen.getByRole("button", { name: /applied/i })
|
||||
const clearButton = screen.getByRole("button", { name: /clear selected/i })
|
||||
|
||||
expect(clearButton).toBeEnabled()
|
||||
|
||||
fireEvent.click(clearButton)
|
||||
const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(appliedButton)
|
||||
expect(clearButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,150 @@
|
||||
import React from "react"
|
||||
import { AlertTriangle, Trash2 } from "lucide-react"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import type { JobStatus } from "@shared/types"
|
||||
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "../constants"
|
||||
|
||||
type DangerZoneSectionProps = {
|
||||
statusesToClear: JobStatus[]
|
||||
toggleStatusToClear: (status: JobStatus) => void
|
||||
handleClearByStatuses: () => void
|
||||
handleClearDatabase: () => void
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
||||
statusesToClear,
|
||||
toggleStatusToClear,
|
||||
handleClearByStatuses,
|
||||
handleClearDatabase,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="danger-zone" className="border rounded-lg px-4 border-destructive/30 mt-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="text-base font-semibold tracking-wider">Danger Zone</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="p-3 rounded-md space-y-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-semibold text-destructive">Clear Jobs by Status</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Select which job statuses you want to clear.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{ALL_JOB_STATUSES.map((status) => {
|
||||
const isSelected = statusesToClear.includes(status)
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => toggleStatusToClear(status)}
|
||||
disabled={isLoading || isSaving}
|
||||
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
isSelected ? 'border-destructive bg-destructive/10' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${
|
||||
isSelected ? 'border-destructive' : 'border-muted-foreground'
|
||||
}`}>
|
||||
{isSelected && <div className="h-2 w-2 rounded-full bg-destructive" />}
|
||||
</div>
|
||||
<div className="grid gap-0.5">
|
||||
<span className="text-sm font-medium capitalize">{status}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{STATUS_DESCRIPTIONS[status]}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isLoading || isSaving || statusesToClear.length === 0}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Selected ({statusesToClear.length})
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete all jobs with the following statuses: {statusesToClear.join(', ')}.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClearByStatuses} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Clear {statusesToClear.length} status{statusesToClear.length !== 1 ? 'es' : ''}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-semibold text-destructive">Clear Entire Database</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Delete all jobs and pipeline runs from the database.
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" disabled={isLoading || isSaving}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Database
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This deletes all jobs and pipeline runs from the database. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClearDatabase} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Clear database
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type GradcrackerSectionProps = {
|
||||
gradcrackerMaxJobsPerTermDraft: number | null
|
||||
setGradcrackerMaxJobsPerTermDraft: (value: number | null) => void
|
||||
defaultGradcrackerMaxJobsPerTerm: number
|
||||
effectiveGradcrackerMaxJobsPerTerm: number
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
||||
gradcrackerMaxJobsPerTermDraft,
|
||||
setGradcrackerMaxJobsPerTermDraft,
|
||||
defaultGradcrackerMaxJobsPerTerm,
|
||||
effectiveGradcrackerMaxJobsPerTerm,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Gradcracker Extractor</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max jobs per search term</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setGradcrackerMaxJobsPerTermDraft(null)
|
||||
} else {
|
||||
setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
|
||||
</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">{effectiveGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type JobCompleteWebhookSectionProps = {
|
||||
jobCompleteWebhookUrlDraft: string
|
||||
setJobCompleteWebhookUrlDraft: (value: string) => void
|
||||
defaultJobCompleteWebhookUrl: string
|
||||
effectiveJobCompleteWebhookUrl: string
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps> = ({
|
||||
jobCompleteWebhookUrlDraft,
|
||||
setJobCompleteWebhookUrlDraft,
|
||||
defaultJobCompleteWebhookUrl,
|
||||
effectiveJobCompleteWebhookUrl,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Job Complete Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div 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>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { JobspySection } from "./JobspySection"
|
||||
|
||||
const JobspyHarness = () => {
|
||||
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
|
||||
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
|
||||
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
|
||||
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
|
||||
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
|
||||
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" defaultValue={["jobspy"]}>
|
||||
<JobspySection
|
||||
jobspySitesDraft={jobspySitesDraft}
|
||||
setJobspySitesDraft={setJobspySitesDraft}
|
||||
defaultJobspySites={["indeed", "linkedin"]}
|
||||
effectiveJobspySites={["indeed", "linkedin"]}
|
||||
jobspyLocationDraft={jobspyLocationDraft}
|
||||
setJobspyLocationDraft={setJobspyLocationDraft}
|
||||
defaultJobspyLocation="UK"
|
||||
effectiveJobspyLocation="UK"
|
||||
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
|
||||
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
|
||||
defaultJobspyResultsWanted={200}
|
||||
effectiveJobspyResultsWanted={200}
|
||||
jobspyHoursOldDraft={jobspyHoursOldDraft}
|
||||
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
|
||||
defaultJobspyHoursOld={72}
|
||||
effectiveJobspyHoursOld={72}
|
||||
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
|
||||
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
|
||||
defaultJobspyCountryIndeed="UK"
|
||||
effectiveJobspyCountryIndeed="UK"
|
||||
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
|
||||
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
|
||||
defaultJobspyLinkedinFetchDescription={true}
|
||||
effectiveJobspyLinkedinFetchDescription={true}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
describe("JobspySection", () => {
|
||||
it("toggles scraped sites and keeps checkboxes in sync", () => {
|
||||
render(<JobspyHarness />)
|
||||
|
||||
const indeedCheckbox = screen.getByLabelText("Indeed")
|
||||
const linkedinCheckbox = screen.getByLabelText("LinkedIn")
|
||||
|
||||
expect(indeedCheckbox).toBeChecked()
|
||||
expect(linkedinCheckbox).toBeChecked()
|
||||
|
||||
fireEvent.click(indeedCheckbox)
|
||||
expect(indeedCheckbox).not.toBeChecked()
|
||||
expect(linkedinCheckbox).toBeChecked()
|
||||
|
||||
fireEvent.click(indeedCheckbox)
|
||||
expect(indeedCheckbox).toBeChecked()
|
||||
})
|
||||
|
||||
it("clamps numeric inputs to allowed ranges", () => {
|
||||
render(<JobspyHarness />)
|
||||
|
||||
const numericInputs = screen.getAllByRole("spinbutton")
|
||||
const resultsWantedInput = numericInputs[0]
|
||||
const hoursOldInput = numericInputs[1]
|
||||
|
||||
fireEvent.change(resultsWantedInput, { target: { value: "999" } })
|
||||
expect(resultsWantedInput).toHaveValue(500)
|
||||
|
||||
fireEvent.change(hoursOldInput, { target: { value: "0" } })
|
||||
expect(hoursOldInput).toHaveValue(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,240 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type JobspySectionProps = {
|
||||
jobspySitesDraft: string[] | null
|
||||
setJobspySitesDraft: (value: string[] | null) => void
|
||||
defaultJobspySites: string[]
|
||||
effectiveJobspySites: string[]
|
||||
jobspyLocationDraft: string | null
|
||||
setJobspyLocationDraft: (value: string | null) => void
|
||||
defaultJobspyLocation: string
|
||||
effectiveJobspyLocation: string
|
||||
jobspyResultsWantedDraft: number | null
|
||||
setJobspyResultsWantedDraft: (value: number | null) => void
|
||||
defaultJobspyResultsWanted: number
|
||||
effectiveJobspyResultsWanted: number
|
||||
jobspyHoursOldDraft: number | null
|
||||
setJobspyHoursOldDraft: (value: number | null) => void
|
||||
defaultJobspyHoursOld: number
|
||||
effectiveJobspyHoursOld: number
|
||||
jobspyCountryIndeedDraft: string | null
|
||||
setJobspyCountryIndeedDraft: (value: string | null) => void
|
||||
defaultJobspyCountryIndeed: string
|
||||
effectiveJobspyCountryIndeed: string
|
||||
jobspyLinkedinFetchDescriptionDraft: boolean | null
|
||||
setJobspyLinkedinFetchDescriptionDraft: (value: boolean | null) => void
|
||||
defaultJobspyLinkedinFetchDescription: boolean
|
||||
effectiveJobspyLinkedinFetchDescription: boolean
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||
jobspySitesDraft,
|
||||
setJobspySitesDraft,
|
||||
defaultJobspySites,
|
||||
effectiveJobspySites,
|
||||
jobspyLocationDraft,
|
||||
setJobspyLocationDraft,
|
||||
defaultJobspyLocation,
|
||||
effectiveJobspyLocation,
|
||||
jobspyResultsWantedDraft,
|
||||
setJobspyResultsWantedDraft,
|
||||
defaultJobspyResultsWanted,
|
||||
effectiveJobspyResultsWanted,
|
||||
jobspyHoursOldDraft,
|
||||
setJobspyHoursOldDraft,
|
||||
defaultJobspyHoursOld,
|
||||
effectiveJobspyHoursOld,
|
||||
jobspyCountryIndeedDraft,
|
||||
setJobspyCountryIndeedDraft,
|
||||
defaultJobspyCountryIndeed,
|
||||
effectiveJobspyCountryIndeed,
|
||||
jobspyLinkedinFetchDescriptionDraft,
|
||||
setJobspyLinkedinFetchDescriptionDraft,
|
||||
defaultJobspyLinkedinFetchDescription,
|
||||
effectiveJobspyLinkedinFetchDescription,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="jobspy" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">JobSpy Scraper</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Scraped Sites</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="site-indeed"
|
||||
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = jobspySitesDraft ?? defaultJobspySites
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('indeed')) next.push('indeed')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'indeed')
|
||||
}
|
||||
setJobspySitesDraft(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="site-linkedin"
|
||||
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = jobspySitesDraft ?? defaultJobspySites
|
||||
let next = [...current]
|
||||
if (checked) {
|
||||
if (!next.includes('linkedin')) next.push('linkedin')
|
||||
} else {
|
||||
next = next.filter(s => s !== 'linkedin')
|
||||
}
|
||||
setJobspySitesDraft(next)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Select which sites JobSpy should scrape.
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {(effectiveJobspySites || []).join(', ') || "None"}</span>
|
||||
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Location</div>
|
||||
<Input
|
||||
value={jobspyLocationDraft ?? defaultJobspyLocation}
|
||||
onChange={(event) => setJobspyLocationDraft(event.target.value)}
|
||||
placeholder={defaultJobspyLocation || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Location to search for jobs (e.g. "UK", "London", "Remote").
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLocation || "—"}</span>
|
||||
<span>Default: {defaultJobspyLocation || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Results Wanted</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={500}
|
||||
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setJobspyResultsWantedDraft(null)
|
||||
} else {
|
||||
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Number of results to fetch per term per site. Max 500.
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyResultsWanted}</span>
|
||||
<span>Default: {defaultJobspyResultsWanted}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Hours Old</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={168}
|
||||
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setJobspyHoursOldDraft(null)
|
||||
} else {
|
||||
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Max age of jobs in hours (e.g. 72 for 3 days).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyHoursOld}h</span>
|
||||
<span>Default: {defaultJobspyHoursOld}h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Indeed Country</div>
|
||||
<Input
|
||||
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
|
||||
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
|
||||
placeholder={defaultJobspyCountryIndeed || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyCountryIndeed || "—"}</span>
|
||||
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="linkedin-desc"
|
||||
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
|
||||
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="linkedin-desc"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Fetch LinkedIn Description
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
|
||||
</p>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type ModelSettingsSectionProps = {
|
||||
modelDraft: string
|
||||
setModelDraft: (value: string) => void
|
||||
modelScorerDraft: string
|
||||
setModelScorerDraft: (value: string) => void
|
||||
modelTailoringDraft: string
|
||||
setModelTailoringDraft: (value: string) => void
|
||||
modelProjectSelectionDraft: string
|
||||
setModelProjectSelectionDraft: (value: string) => void
|
||||
effectiveModel: string
|
||||
effectiveModelScorer: string
|
||||
effectiveModelTailoring: string
|
||||
effectiveModelProjectSelection: string
|
||||
defaultModel: string
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
modelDraft,
|
||||
setModelDraft,
|
||||
modelScorerDraft,
|
||||
setModelScorerDraft,
|
||||
modelTailoringDraft,
|
||||
setModelTailoringDraft,
|
||||
modelProjectSelectionDraft,
|
||||
setModelProjectSelectionDraft,
|
||||
effectiveModel,
|
||||
effectiveModelScorer,
|
||||
effectiveModelTailoring,
|
||||
effectiveModelProjectSelection,
|
||||
defaultModel,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="model" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Model</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div 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="space-y-4">
|
||||
<div className="text-sm font-medium">Task-Specific Overrides</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Scoring Model</div>
|
||||
<Input
|
||||
value={modelScorerDraft}
|
||||
onChange={(event) => setModelScorerDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Tailoring Model</div>
|
||||
<Input
|
||||
value={modelTailoringDraft}
|
||||
onChange={(event) => setModelTailoringDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Project Selection Model</div>
|
||||
<Input
|
||||
value={modelProjectSelectionDraft}
|
||||
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Global 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>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type PipelineWebhookSectionProps = {
|
||||
pipelineWebhookUrlDraft: string
|
||||
setPipelineWebhookUrlDraft: (value: string) => void
|
||||
defaultPipelineWebhookUrl: string
|
||||
effectivePipelineWebhookUrl: string
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
|
||||
pipelineWebhookUrlDraft,
|
||||
setPipelineWebhookUrlDraft,
|
||||
defaultPipelineWebhookUrl,
|
||||
effectivePipelineWebhookUrl,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Pipeline Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div 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>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { ResumeProjectsSection } from "./ResumeProjectsSection"
|
||||
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
|
||||
|
||||
const profileProjects: ResumeProjectCatalogItem[] = [
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
description: "Desc 1",
|
||||
date: "2024",
|
||||
isVisibleInBase: true,
|
||||
},
|
||||
{
|
||||
id: "proj-2",
|
||||
name: "Project Two",
|
||||
description: "Desc 2",
|
||||
date: "2023",
|
||||
isVisibleInBase: false,
|
||||
},
|
||||
]
|
||||
|
||||
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: ResumeProjectsSettings | null }) => {
|
||||
const [draft, setDraft] = useState<ResumeProjectsSettings | null>(initialDraft)
|
||||
const lockedCount = draft?.lockedProjectIds.length ?? 0
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" defaultValue={["resume-projects"]}>
|
||||
<ResumeProjectsSection
|
||||
resumeProjectsDraft={draft}
|
||||
setResumeProjectsDraft={setDraft}
|
||||
profileProjects={profileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={profileProjects.length}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
describe("ResumeProjectsSection", () => {
|
||||
it("clamps max projects to the locked count", async () => {
|
||||
render(
|
||||
<ResumeProjectsHarness
|
||||
initialDraft={{
|
||||
maxProjects: 2,
|
||||
lockedProjectIds: ["proj-1"],
|
||||
aiSelectableProjectIds: ["proj-2"],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole("spinbutton")
|
||||
fireEvent.change(input, { target: { value: "0" } })
|
||||
|
||||
await waitFor(() => expect(input).toHaveValue(1))
|
||||
})
|
||||
|
||||
it("locks projects and enforces maxProjects >= locked count", () => {
|
||||
render(
|
||||
<ResumeProjectsHarness
|
||||
initialDraft={{
|
||||
maxProjects: 0,
|
||||
lockedProjectIds: [],
|
||||
aiSelectableProjectIds: ["proj-1"],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const checkboxes = screen.getAllByRole("checkbox")
|
||||
const lockedCheckbox = checkboxes[0]
|
||||
const aiSelectableCheckbox = checkboxes[1]
|
||||
|
||||
fireEvent.click(lockedCheckbox)
|
||||
|
||||
expect(lockedCheckbox).toBeChecked()
|
||||
expect(aiSelectableCheckbox).toBeChecked()
|
||||
expect(aiSelectableCheckbox).toBeDisabled()
|
||||
|
||||
const input = screen.getByRole("spinbutton")
|
||||
expect(input).toHaveValue(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,148 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
|
||||
import { clampInt } from "@/lib/utils"
|
||||
|
||||
type ResumeProjectsSectionProps = {
|
||||
resumeProjectsDraft: ResumeProjectsSettings | null
|
||||
setResumeProjectsDraft: (value: ResumeProjectsSettings | null) => void
|
||||
profileProjects: ResumeProjectCatalogItem[]
|
||||
lockedCount: number
|
||||
maxProjectsTotal: number
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
|
||||
resumeProjectsDraft,
|
||||
setResumeProjectsDraft,
|
||||
profileProjects,
|
||||
lockedCount,
|
||||
maxProjectsTotal,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Resume Projects</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max projects included</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={resumeProjectsDraft?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const next = Number(event.target.value)
|
||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
|
||||
}}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
|
||||
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="w-[110px]">Base visible</TableHead>
|
||||
<TableHead className="w-[90px]">Locked</TableHead>
|
||||
<TableHead className="w-[140px]">AI selectable</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
|
||||
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
|
||||
const excluded = !locked && !aiSelectable
|
||||
|
||||
return (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{project.name || project.id}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[project.description, project.date].filter(Boolean).join(" · ")}
|
||||
{excluded ? " · Excluded" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
|
||||
if (isChecked) {
|
||||
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
|
||||
const nextSelectable = selectableIds.filter((id) => id !== project.id)
|
||||
const minCap = lockedIds.length
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: lockedIds,
|
||||
aiSelectableProjectIds: nextSelectable,
|
||||
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: nextLocked,
|
||||
aiSelectableProjectIds: selectableIds,
|
||||
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
const nextSelectable = isChecked
|
||||
? selectableIds.includes(project.id)
|
||||
? selectableIds
|
||||
: [...selectableIds, project.id]
|
||||
: selectableIds.filter((id) => id !== project.id)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type SearchTermsSectionProps = {
|
||||
searchTermsDraft: string[] | null
|
||||
setSearchTermsDraft: (value: string[] | null) => void
|
||||
defaultSearchTerms: string[]
|
||||
effectiveSearchTerms: string[]
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
||||
searchTermsDraft,
|
||||
setSearchTermsDraft,
|
||||
defaultSearchTerms,
|
||||
effectiveSearchTerms,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="search-terms" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Search Terms</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Global search terms</div>
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value
|
||||
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
|
||||
setSearchTermsDraft(terms)
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Clean up on blur
|
||||
if (searchTermsDraft) {
|
||||
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. web developer"
|
||||
disabled={isLoading || isSaving}
|
||||
rows={5}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
One term per line. Applies to UKVisaJobs and other supported extractors.
|
||||
</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">{(effectiveSearchTerms || []).join(', ') || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{(defaultSearchTerms || []).join(', ') || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type UkvisajobsSectionProps = {
|
||||
ukvisajobsMaxJobsDraft: number | null
|
||||
setUkvisajobsMaxJobsDraft: (value: number | null) => void
|
||||
defaultUkvisajobsMaxJobs: number
|
||||
effectiveUkvisajobsMaxJobs: number
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
||||
ukvisajobsMaxJobsDraft,
|
||||
setUkvisajobsMaxJobsDraft,
|
||||
defaultUkvisajobsMaxJobs,
|
||||
effectiveUkvisajobsMaxJobs,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">UKVisaJobs Extractor</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max jobs to fetch</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
|
||||
onChange={(event) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (Number.isNaN(value)) {
|
||||
setUkvisajobsMaxJobsDraft(null)
|
||||
} else {
|
||||
setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value)))
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
|
||||
</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">{effectiveUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
18
orchestrator/src/client/pages/settings/constants.ts
Normal file
18
orchestrator/src/client/pages/settings/constants.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Settings page constants.
|
||||
*/
|
||||
|
||||
import type { JobStatus } from "@shared/types"
|
||||
|
||||
/** All available job statuses for clearing */
|
||||
export const ALL_JOB_STATUSES: JobStatus[] = ['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']
|
||||
|
||||
/** Status descriptions for UI */
|
||||
export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
|
||||
discovered: 'Crawled but not processed',
|
||||
processing: 'Currently generating resume',
|
||||
ready: 'PDF generated, waiting for user to apply',
|
||||
applied: 'User marked as applied',
|
||||
skipped: 'User skipped this job',
|
||||
expired: 'Deadline passed',
|
||||
}
|
||||
14
orchestrator/src/client/pages/settings/utils.ts
Normal file
14
orchestrator/src/client/pages/settings/utils.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Settings page helpers.
|
||||
*/
|
||||
|
||||
import { arraysEqual } from "@/lib/utils"
|
||||
import type { ResumeProjectsSettings } from "@shared/types"
|
||||
|
||||
export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
|
||||
return (
|
||||
a.maxProjects === b.maxProjects &&
|
||||
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
|
||||
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
|
||||
)
|
||||
}
|
||||
@ -9,6 +9,6 @@ declare global {
|
||||
}
|
||||
|
||||
export function trackEvent(event: string, data?: Record<string, unknown>) {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof window === "undefined") return;
|
||||
window.umami?.track(event, data);
|
||||
}
|
||||
@ -1,6 +1,118 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { Job } from "@shared/types"
|
||||
|
||||
// --- CSS ---
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
// --- Dates ---
|
||||
export const formatDate = (dateStr?: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||
return parsed.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatDateTime = (dateStr?: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||
const date = parsed.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = parsed.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `${date} ${time}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// --- DOM & Clipboard ---
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Text Processing ---
|
||||
export const stripHtml = (value: string) =>
|
||||
value
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
export const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_");
|
||||
|
||||
// --- Comparisons & Math ---
|
||||
export function arraysEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clampInt(value: number, min: number, max: number) {
|
||||
const int = Math.floor(value);
|
||||
if (Number.isNaN(int)) return min;
|
||||
return Math.min(max, Math.max(min, int));
|
||||
}
|
||||
|
||||
// --- Job Specific Helpers ---
|
||||
export const formatJobForWebhook = (job: Job) => {
|
||||
return JSON.stringify(
|
||||
{
|
||||
event: "job.completed",
|
||||
sentAt: new Date().toISOString(),
|
||||
job,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
};
|
||||
|
||||
export const sourceLabel: Record<Job["source"], string> = {
|
||||
gradcracker: "Gradcracker",
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
34
orchestrator/src/server/api/routes/database.test.ts
Normal file
34
orchestrator/src/server/api/routes/database.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Database API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
it('clears jobs and pipeline runs', async () => {
|
||||
const { createJob } = await import('../../repositories/jobs.js');
|
||||
await createJob({
|
||||
source: 'manual',
|
||||
title: 'Cleanup Role',
|
||||
employer: 'Acme',
|
||||
jobUrl: 'https://example.com/job/cleanup',
|
||||
jobDescription: 'Test description',
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/database`, { method: 'DELETE' });
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.jobsDeleted).toBe(1);
|
||||
});
|
||||
});
|
||||
25
orchestrator/src/server/api/routes/database.ts
Normal file
25
orchestrator/src/server/api/routes/database.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { clearDatabase } from '../../db/clear.js';
|
||||
|
||||
export const databaseRouter = Router();
|
||||
|
||||
/**
|
||||
* DELETE /api/database - Clear all data from the database
|
||||
*/
|
||||
databaseRouter.delete('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = clearDatabase();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Database cleared',
|
||||
jobsDeleted: result.jobsDeleted,
|
||||
runsDeleted: result.runsDeleted,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
98
orchestrator/src/server/api/routes/jobs.test.ts
Normal file
98
orchestrator/src/server/api/routes/jobs.test.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Jobs API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
it('lists jobs and supports status filtering', async () => {
|
||||
const { createJob } = await import('../../repositories/jobs.js');
|
||||
const job = await createJob({
|
||||
source: 'manual',
|
||||
title: 'Test Role',
|
||||
employer: 'Acme',
|
||||
jobUrl: 'https://example.com/job/1',
|
||||
jobDescription: 'Test description',
|
||||
});
|
||||
|
||||
const listRes = await fetch(`${baseUrl}/api/jobs`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.success).toBe(true);
|
||||
expect(listBody.data.total).toBe(1);
|
||||
expect(listBody.data.jobs[0].id).toBe(job.id);
|
||||
|
||||
const filteredRes = await fetch(`${baseUrl}/api/jobs?status=skipped`);
|
||||
const filteredBody = await filteredRes.json();
|
||||
expect(filteredBody.data.total).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 404 for missing jobs', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/jobs/missing-id`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('validates job updates and supports skip/delete flow', async () => {
|
||||
const { createJob } = await import('../../repositories/jobs.js');
|
||||
const job = await createJob({
|
||||
source: 'manual',
|
||||
title: 'Test Role',
|
||||
employer: 'Acme',
|
||||
jobUrl: 'https://example.com/job/2',
|
||||
jobDescription: 'Test description',
|
||||
});
|
||||
|
||||
const badRes = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ suitabilityScore: 1000 }),
|
||||
});
|
||||
expect(badRes.status).toBe(400);
|
||||
|
||||
const skipRes = await fetch(`${baseUrl}/api/jobs/${job.id}/skip`, { method: 'POST' });
|
||||
const skipBody = await skipRes.json();
|
||||
expect(skipBody.data.status).toBe('skipped');
|
||||
|
||||
const deleteRes = await fetch(`${baseUrl}/api/jobs/status/skipped`, { method: 'DELETE' });
|
||||
const deleteBody = await deleteRes.json();
|
||||
expect(deleteBody.data.count).toBe(1);
|
||||
});
|
||||
|
||||
it('applies a job and syncs to Notion', async () => {
|
||||
const { createNotionEntry } = await import('../../services/notion.js');
|
||||
vi.mocked(createNotionEntry).mockResolvedValue({ pageId: 'page-123' });
|
||||
|
||||
const { createJob } = await import('../../repositories/jobs.js');
|
||||
const job = await createJob({
|
||||
source: 'manual',
|
||||
title: 'Test Role',
|
||||
employer: 'Acme',
|
||||
jobUrl: 'https://example.com/job/3',
|
||||
jobDescription: 'Test description',
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/apply`, { method: 'POST' });
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.status).toBe('applied');
|
||||
expect(body.data.notionPageId).toBe('page-123');
|
||||
expect(body.data.appliedAt).toBeTruthy();
|
||||
expect(createNotionEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: job.id,
|
||||
title: job.title,
|
||||
employer: job.employer,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
261
orchestrator/src/server/api/routes/jobs.ts
Normal file
261
orchestrator/src/server/api/routes/jobs.ts
Normal file
@ -0,0 +1,261 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as jobsRepo from '../../repositories/jobs.js';
|
||||
import * as settingsRepo from '../../repositories/settings.js';
|
||||
import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js';
|
||||
import { createNotionEntry } from '../../services/notion.js';
|
||||
import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.js';
|
||||
|
||||
export const jobsRouter = 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(`ƒsÿ‹,? Job complete webhook POST failed (${response.status}): ${await response.text()}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ƒsÿ‹,? Job complete webhook POST failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/jobs/:id - Update a job
|
||||
*/
|
||||
const updateJobSchema = z.object({
|
||||
status: z.enum(['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']).optional(),
|
||||
jobDescription: z.string().optional(),
|
||||
suitabilityScore: z.number().min(0).max(100).optional(),
|
||||
suitabilityReason: z.string().optional(),
|
||||
tailoredSummary: z.string().optional(),
|
||||
selectedProjectIds: z.string().optional(),
|
||||
pdfPath: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jobs - List all jobs
|
||||
* Query params: status (comma-separated list of statuses to filter)
|
||||
*/
|
||||
jobsRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const statusFilter = req.query.status as string | undefined;
|
||||
const statuses = statusFilter?.split(',').filter(Boolean) as JobStatus[] | undefined;
|
||||
|
||||
const jobs = await jobsRepo.getAllJobs(statuses);
|
||||
const stats = await jobsRepo.getJobStats();
|
||||
|
||||
const response: ApiResponse<JobsListResponse> = {
|
||||
success: true,
|
||||
data: {
|
||||
jobs,
|
||||
total: jobs.length,
|
||||
byStatus: stats,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jobs/:id - Get a single job
|
||||
*/
|
||||
jobsRouter.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const job = await jobsRepo.getJobById(req.params.id);
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: job });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
jobsRouter.patch('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = updateJobSchema.parse(req.body);
|
||||
const job = await jobsRepo.updateJob(req.params.id, input);
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: job });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/summarize - Generate AI summary and suggest projects
|
||||
*/
|
||||
jobsRouter.post('/:id/summarize', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const forceRaw = req.query.force as string | undefined;
|
||||
const force = forceRaw === '1' || forceRaw === 'true';
|
||||
|
||||
const result = await summarizeJob(req.params.id, { force });
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ success: false, error: result.error });
|
||||
}
|
||||
|
||||
const job = await jobsRepo.getJobById(req.params.id);
|
||||
res.json({ success: true, data: job });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/generate-pdf - Generate PDF using current manual overrides
|
||||
*/
|
||||
jobsRouter.post('/:id/generate-pdf', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await generateFinalPdf(req.params.id);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ success: false, error: result.error });
|
||||
}
|
||||
|
||||
const job = await jobsRepo.getJobById(req.params.id);
|
||||
res.json({ success: true, data: job });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/process - Process a single job (generate summary + PDF)
|
||||
*/
|
||||
jobsRouter.post('/:id/process', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const forceRaw = req.query.force as string | undefined;
|
||||
const force = forceRaw === '1' || forceRaw === 'true';
|
||||
|
||||
const result = await processJob(req.params.id, { force });
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ success: false, error: result.error });
|
||||
}
|
||||
|
||||
const job = await jobsRepo.getJobById(req.params.id);
|
||||
res.json({ success: true, data: job });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/apply - Mark a job as applied and sync to Notion
|
||||
*/
|
||||
jobsRouter.post('/:id/apply', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const job = await jobsRepo.getJobById(req.params.id);
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||
}
|
||||
|
||||
const appliedAt = new Date().toISOString();
|
||||
|
||||
// Sync to Notion
|
||||
const notionResult = await createNotionEntry({
|
||||
id: job.id,
|
||||
title: job.title,
|
||||
employer: job.employer,
|
||||
applicationLink: job.applicationLink,
|
||||
deadline: job.deadline,
|
||||
salary: job.salary,
|
||||
location: job.location,
|
||||
pdfPath: job.pdfPath,
|
||||
appliedAt,
|
||||
});
|
||||
|
||||
// Update job status
|
||||
const updatedJob = await jobsRepo.updateJob(job.id, {
|
||||
status: 'applied',
|
||||
appliedAt,
|
||||
notionPageId: notionResult.pageId,
|
||||
});
|
||||
|
||||
if (updatedJob) {
|
||||
notifyJobCompleteWebhook(updatedJob).catch(console.warn)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: updatedJob });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/skip - Mark a job as skipped
|
||||
*/
|
||||
jobsRouter.post('/:id/skip', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const job = await jobsRepo.updateJob(req.params.id, { status: 'skipped' });
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: job });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/jobs/status/:status - Clear jobs with a specific status
|
||||
*/
|
||||
jobsRouter.delete('/status/:status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const status = req.params.status as JobStatus;
|
||||
const count = await jobsRepo.deleteJobsByStatus(status);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: `Cleared ${count} ${status} jobs`,
|
||||
count,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
64
orchestrator/src/server/api/routes/manual-jobs.test.ts
Normal file
64
orchestrator/src/server/api/routes/manual-jobs.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Manual jobs API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
it('infers manual jobs and rejects empty payloads', async () => {
|
||||
const badRes = await fetch(`${baseUrl}/api/manual-jobs/infer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(badRes.status).toBe(400);
|
||||
|
||||
const { inferManualJobDetails } = await import('../../services/manualJob.js');
|
||||
vi.mocked(inferManualJobDetails).mockResolvedValue({
|
||||
job: { title: 'Backend Engineer', employer: 'Acme' },
|
||||
warning: null,
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/manual-jobs/infer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jobDescription: 'Role description' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.job.title).toBe('Backend Engineer');
|
||||
});
|
||||
|
||||
it('imports manual jobs and generates a fallback URL', async () => {
|
||||
const { scoreJobSuitability } = await import('../../services/scorer.js');
|
||||
vi.mocked(scoreJobSuitability).mockResolvedValue({ score: 88, reason: 'Strong fit' });
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/manual-jobs/import`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
job: {
|
||||
title: 'Backend Engineer',
|
||||
employer: 'Acme',
|
||||
jobDescription: 'Great role',
|
||||
},
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.source).toBe('manual');
|
||||
expect(body.data.jobUrl).toMatch(/^manual:\/\//);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
});
|
||||
});
|
||||
126
orchestrator/src/server/api/routes/manual-jobs.ts
Normal file
126
orchestrator/src/server/api/routes/manual-jobs.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import * as jobsRepo from '../../repositories/jobs.js';
|
||||
import { inferManualJobDetails } from '../../services/manualJob.js';
|
||||
import { scoreJobSuitability } from '../../services/scorer.js';
|
||||
import { loadResumeProfile } from '../../services/resumeProjects.js';
|
||||
import type { ApiResponse, ManualJobInferenceResponse } from '../../../shared/types.js';
|
||||
|
||||
export const manualJobsRouter = Router();
|
||||
|
||||
const manualJobInferenceSchema = z.object({
|
||||
jobDescription: z.string().trim().min(1).max(40000),
|
||||
});
|
||||
|
||||
const manualJobImportSchema = z.object({
|
||||
job: z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
employer: z.string().trim().min(1).max(500),
|
||||
jobUrl: z.string().trim().url().max(2000).optional(),
|
||||
applicationLink: z.string().trim().url().max(2000).optional(),
|
||||
location: z.string().trim().max(200).optional(),
|
||||
salary: z.string().trim().max(200).optional(),
|
||||
deadline: z.string().trim().max(100).optional(),
|
||||
jobDescription: z.string().trim().min(1).max(40000),
|
||||
jobType: z.string().trim().max(200).optional(),
|
||||
jobLevel: z.string().trim().max(200).optional(),
|
||||
jobFunction: z.string().trim().max(200).optional(),
|
||||
disciplines: z.string().trim().max(200).optional(),
|
||||
degreeRequired: z.string().trim().max(200).optional(),
|
||||
starting: z.string().trim().max(200).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const cleanOptional = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/manual-jobs/infer - Infer job details from a pasted description
|
||||
*/
|
||||
manualJobsRouter.post('/infer', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = manualJobInferenceSchema.parse(req.body ?? {});
|
||||
const result = await inferManualJobDetails(input.jobDescription);
|
||||
|
||||
const response: ApiResponse<ManualJobInferenceResponse> = {
|
||||
success: true,
|
||||
data: {
|
||||
job: result.job,
|
||||
warning: result.warning ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/manual-jobs/import - Import a manually curated job into the DB
|
||||
*/
|
||||
manualJobsRouter.post('/import', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = manualJobImportSchema.parse(req.body ?? {});
|
||||
const job = input.job;
|
||||
|
||||
const jobUrl =
|
||||
cleanOptional(job.jobUrl) ||
|
||||
cleanOptional(job.applicationLink) ||
|
||||
`manual://${randomUUID()}`;
|
||||
|
||||
const createdJob = await jobsRepo.createJob({
|
||||
source: 'manual',
|
||||
title: job.title.trim(),
|
||||
employer: job.employer.trim(),
|
||||
jobUrl,
|
||||
applicationLink: cleanOptional(job.applicationLink) ?? undefined,
|
||||
location: cleanOptional(job.location) ?? undefined,
|
||||
salary: cleanOptional(job.salary) ?? undefined,
|
||||
deadline: cleanOptional(job.deadline) ?? undefined,
|
||||
jobDescription: job.jobDescription.trim(),
|
||||
jobType: cleanOptional(job.jobType) ?? undefined,
|
||||
jobLevel: cleanOptional(job.jobLevel) ?? undefined,
|
||||
jobFunction: cleanOptional(job.jobFunction) ?? undefined,
|
||||
disciplines: cleanOptional(job.disciplines) ?? undefined,
|
||||
degreeRequired: cleanOptional(job.degreeRequired) ?? undefined,
|
||||
starting: cleanOptional(job.starting) ?? undefined,
|
||||
});
|
||||
|
||||
// Score asynchronously so the import returns immediately.
|
||||
(async () => {
|
||||
try {
|
||||
const rawProfile = await loadResumeProfile();
|
||||
if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format');
|
||||
}
|
||||
const profile = rawProfile as Record<string, unknown>;
|
||||
const { score, reason } = await scoreJobSuitability(createdJob, profile);
|
||||
await jobsRepo.updateJob(createdJob.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Manual job scoring failed:', error);
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.warn('Manual job scoring task failed to start:', error);
|
||||
});
|
||||
|
||||
res.json({ success: true, data: createdJob });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
66
orchestrator/src/server/api/routes/pipeline.test.ts
Normal file
66
orchestrator/src/server/api/routes/pipeline.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Pipeline API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
it('reports pipeline status', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/pipeline/status`);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.isRunning).toBe(false);
|
||||
expect(body.data.lastRun).toBeNull();
|
||||
});
|
||||
|
||||
it('validates pipeline run payloads', async () => {
|
||||
const badRun = await fetch(`${baseUrl}/api/pipeline/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ minSuitabilityScore: 120 }),
|
||||
});
|
||||
expect(badRun.status).toBe(400);
|
||||
|
||||
const { runPipeline } = await import('../../pipeline/index.js');
|
||||
const runRes = await fetch(`${baseUrl}/api/pipeline/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topN: 5, sources: ['gradcracker'] }),
|
||||
});
|
||||
const runBody = await runRes.json();
|
||||
expect(runBody.success).toBe(true);
|
||||
expect(runPipeline).toHaveBeenCalledWith({ topN: 5, sources: ['gradcracker'] });
|
||||
});
|
||||
|
||||
it('streams pipeline progress over SSE', async () => {
|
||||
const controller = new AbortController();
|
||||
const res = await fetch(`${baseUrl}/api/pipeline/progress`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (reader) {
|
||||
const chunk = await reader.read();
|
||||
controller.abort();
|
||||
await reader.cancel();
|
||||
const text = new TextDecoder().decode(chunk.value);
|
||||
expect(text).toContain('data:');
|
||||
} else {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
});
|
||||
103
orchestrator/src/server/api/routes/pipeline.ts
Normal file
103
orchestrator/src/server/api/routes/pipeline.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as pipelineRepo from '../../repositories/pipeline.js';
|
||||
import { runPipeline, getPipelineStatus, subscribeToProgress } from '../../pipeline/index.js';
|
||||
import type { ApiResponse, PipelineStatusResponse } from '../../../shared/types.js';
|
||||
|
||||
export const pipelineRouter = Router();
|
||||
|
||||
/**
|
||||
* GET /api/pipeline/status - Get pipeline status
|
||||
*/
|
||||
pipelineRouter.get('/status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { isRunning } = getPipelineStatus();
|
||||
const lastRun = await pipelineRepo.getLatestPipelineRun();
|
||||
|
||||
const response: ApiResponse<PipelineStatusResponse> = {
|
||||
success: true,
|
||||
data: {
|
||||
isRunning,
|
||||
lastRun,
|
||||
nextScheduledRun: null, // Would come from n8n
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/pipeline/progress - Server-Sent Events endpoint for live progress
|
||||
*/
|
||||
pipelineRouter.get('/progress', (req: Request, res: Response) => {
|
||||
// Set headers for SSE
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable Nginx buffering
|
||||
|
||||
// Send initial progress
|
||||
const sendProgress = (data: unknown) => {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
// Subscribe to progress updates
|
||||
const unsubscribe = subscribeToProgress(sendProgress);
|
||||
|
||||
// Send heartbeat every 30 seconds to keep connection alive
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(': heartbeat\n\n');
|
||||
}, 30000);
|
||||
|
||||
// Cleanup on close
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/pipeline/runs - Get recent pipeline runs
|
||||
*/
|
||||
pipelineRouter.get('/runs', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const runs = await pipelineRepo.getRecentPipelineRuns(20);
|
||||
res.json({ success: true, data: runs });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pipeline/run - Trigger the pipeline manually
|
||||
*/
|
||||
const runPipelineSchema = z.object({
|
||||
topN: z.number().min(1).max(50).optional(),
|
||||
minSuitabilityScore: z.number().min(0).max(100).optional(),
|
||||
sources: z.array(z.enum(['gradcracker', 'indeed', 'linkedin', 'ukvisajobs'])).min(1).optional(),
|
||||
});
|
||||
|
||||
pipelineRouter.post('/run', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const config = runPipelineSchema.parse(req.body);
|
||||
|
||||
// Start pipeline in background
|
||||
runPipeline(config).catch(console.error);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { message: 'Pipeline started' }
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
25
orchestrator/src/server/api/routes/profile.test.ts
Normal file
25
orchestrator/src/server/api/routes/profile.test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Profile API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
it('returns base resume projects', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/profile/projects`);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
});
|
||||
18
orchestrator/src/server/api/routes/profile.ts
Normal file
18
orchestrator/src/server/api/routes/profile.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { extractProjectsFromProfile, loadResumeProfile } from '../../services/resumeProjects.js';
|
||||
|
||||
export const profileRouter = Router();
|
||||
|
||||
/**
|
||||
* GET /api/profile/projects - Get all projects available in the base resume
|
||||
*/
|
||||
profileRouter.get('/projects', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const profile = await loadResumeProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
res.json({ success: true, data: catalog });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
45
orchestrator/src/server/api/routes/settings.test.ts
Normal file
45
orchestrator/src/server/api/routes/settings.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Settings API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
it('returns settings with defaults', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/settings`);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.defaultModel).toBe('test-model');
|
||||
expect(Array.isArray(body.data.searchTerms)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid settings updates and persists overrides', async () => {
|
||||
const badPatch = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jobspyResultsWanted: 9999 }),
|
||||
});
|
||||
expect(badPatch.status).toBe(400);
|
||||
|
||||
const patchRes = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ searchTerms: ['engineer'] }),
|
||||
});
|
||||
const patchBody = await patchRes.json();
|
||||
expect(patchBody.success).toBe(true);
|
||||
expect(patchBody.data.searchTerms).toEqual(['engineer']);
|
||||
expect(patchBody.data.overrideSearchTerms).toEqual(['engineer']);
|
||||
});
|
||||
});
|
||||
394
orchestrator/src/server/api/routes/settings.ts
Normal file
394
orchestrator/src/server/api/routes/settings.ts
Normal file
@ -0,0 +1,394 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as settingsRepo from '../../repositories/settings.js';
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
loadResumeProfile,
|
||||
normalizeResumeProjectsSettings,
|
||||
resolveResumeProjectsSettings,
|
||||
} from '../../services/resumeProjects.js';
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
/**
|
||||
* GET /api/settings - Get app settings (effective + defaults)
|
||||
*/
|
||||
settingsRouter.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const overrideModel = await settingsRepo.getSetting('model');
|
||||
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const model = overrideModel || defaultModel;
|
||||
|
||||
// Specific AI models
|
||||
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
|
||||
const modelScorer = overrideModelScorer || model;
|
||||
|
||||
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
|
||||
const modelTailoring = overrideModelTailoring || model;
|
||||
|
||||
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
|
||||
const modelProjectSelection = overrideModelProjectSelection || model;
|
||||
|
||||
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;
|
||||
|
||||
const profile = await loadResumeProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||
|
||||
const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs');
|
||||
const defaultUkvisajobsMaxJobs = 50;
|
||||
const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null;
|
||||
const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs;
|
||||
|
||||
const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm');
|
||||
const defaultGradcrackerMaxJobsPerTerm = 50;
|
||||
const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null;
|
||||
const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm;
|
||||
|
||||
const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms');
|
||||
const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer';
|
||||
const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean);
|
||||
const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null;
|
||||
const searchTerms = overrideSearchTerms ?? defaultSearchTerms;
|
||||
|
||||
// JobSpy settings (GET)
|
||||
const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation');
|
||||
const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK';
|
||||
const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation;
|
||||
|
||||
const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted');
|
||||
const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10);
|
||||
const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null;
|
||||
const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted;
|
||||
|
||||
const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld');
|
||||
const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10);
|
||||
const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null;
|
||||
const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld;
|
||||
|
||||
const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed');
|
||||
const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK';
|
||||
const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed;
|
||||
|
||||
const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites');
|
||||
const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null;
|
||||
const jobspySites = overrideJobspySites ?? defaultJobspySites;
|
||||
|
||||
const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription');
|
||||
const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1';
|
||||
const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw
|
||||
? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1'
|
||||
: null;
|
||||
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
model,
|
||||
defaultModel,
|
||||
overrideModel,
|
||||
modelScorer,
|
||||
overrideModelScorer,
|
||||
modelTailoring,
|
||||
overrideModelTailoring,
|
||||
modelProjectSelection,
|
||||
overrideModelProjectSelection,
|
||||
pipelineWebhookUrl,
|
||||
defaultPipelineWebhookUrl,
|
||||
overridePipelineWebhookUrl,
|
||||
jobCompleteWebhookUrl,
|
||||
defaultJobCompleteWebhookUrl,
|
||||
overrideJobCompleteWebhookUrl,
|
||||
...resumeProjectsData,
|
||||
ukvisajobsMaxJobs,
|
||||
defaultUkvisajobsMaxJobs,
|
||||
overrideUkvisajobsMaxJobs,
|
||||
gradcrackerMaxJobsPerTerm,
|
||||
defaultGradcrackerMaxJobsPerTerm,
|
||||
overrideGradcrackerMaxJobsPerTerm,
|
||||
searchTerms,
|
||||
defaultSearchTerms,
|
||||
overrideSearchTerms,
|
||||
jobspyLocation,
|
||||
defaultJobspyLocation,
|
||||
overrideJobspyLocation,
|
||||
jobspyResultsWanted,
|
||||
defaultJobspyResultsWanted,
|
||||
overrideJobspyResultsWanted,
|
||||
jobspyHoursOld,
|
||||
defaultJobspyHoursOld,
|
||||
overrideJobspyHoursOld,
|
||||
jobspyCountryIndeed,
|
||||
defaultJobspyCountryIndeed,
|
||||
overrideJobspyCountryIndeed,
|
||||
jobspySites,
|
||||
defaultJobspySites,
|
||||
overrideJobspySites,
|
||||
jobspyLinkedinFetchDescription,
|
||||
defaultJobspyLinkedinFetchDescription,
|
||||
overrideJobspyLinkedinFetchDescription,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
const updateSettingsSchema = z.object({
|
||||
model: z.string().trim().min(1).max(200).nullable().optional(),
|
||||
modelScorer: z.string().trim().min(1).max(200).nullable().optional(),
|
||||
modelTailoring: z.string().trim().min(1).max(200).nullable().optional(),
|
||||
modelProjectSelection: 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(),
|
||||
resumeProjects: z.object({
|
||||
maxProjects: z.number().int().min(0).max(50),
|
||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
}).nullable().optional(),
|
||||
ukvisajobsMaxJobs: z.number().int().min(1).max(200).nullable().optional(),
|
||||
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(200).nullable().optional(),
|
||||
searchTerms: z.array(z.string().trim().min(1).max(200)).max(50).nullable().optional(),
|
||||
jobspyLocation: z.string().trim().min(1).max(100).nullable().optional(),
|
||||
jobspyResultsWanted: z.number().int().min(1).max(500).nullable().optional(),
|
||||
jobspyHoursOld: z.number().int().min(1).max(168).nullable().optional(),
|
||||
jobspyCountryIndeed: z.string().trim().min(1).max(100).nullable().optional(),
|
||||
jobspySites: z.array(z.string().trim().min(1).max(50)).max(10).nullable().optional(),
|
||||
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/settings - Update settings overrides
|
||||
*/
|
||||
settingsRouter.patch('/', 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 ('modelScorer' in input) {
|
||||
await settingsRepo.setSetting('modelScorer', input.modelScorer ?? null);
|
||||
}
|
||||
if ('modelTailoring' in input) {
|
||||
await settingsRepo.setSetting('modelTailoring', input.modelTailoring ?? null);
|
||||
}
|
||||
if ('modelProjectSelection' in input) {
|
||||
await settingsRepo.setSetting('modelProjectSelection', input.modelProjectSelection ?? null);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if ('resumeProjects' in input) {
|
||||
const resumeProjects = input.resumeProjects ?? null;
|
||||
|
||||
if (resumeProjects === null) {
|
||||
await settingsRepo.setSetting('resumeProjects', null);
|
||||
} else {
|
||||
const rawProfile = await loadResumeProfile();
|
||||
|
||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
||||
}
|
||||
|
||||
const profile = rawProfile as Record<string, unknown>;
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((p) => p.id));
|
||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||
await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized));
|
||||
}
|
||||
}
|
||||
|
||||
if ('ukvisajobsMaxJobs' in input) {
|
||||
const ukvisajobsMaxJobs = input.ukvisajobsMaxJobs ?? null;
|
||||
await settingsRepo.setSetting('ukvisajobsMaxJobs', ukvisajobsMaxJobs !== null ? String(ukvisajobsMaxJobs) : null);
|
||||
}
|
||||
|
||||
if ('gradcrackerMaxJobsPerTerm' in input) {
|
||||
const gradcrackerMaxJobsPerTerm = input.gradcrackerMaxJobsPerTerm ?? null;
|
||||
await settingsRepo.setSetting('gradcrackerMaxJobsPerTerm', gradcrackerMaxJobsPerTerm !== null ? String(gradcrackerMaxJobsPerTerm) : null);
|
||||
}
|
||||
|
||||
if ('searchTerms' in input) {
|
||||
const searchTerms = input.searchTerms ?? null;
|
||||
await settingsRepo.setSetting('searchTerms', searchTerms !== null ? JSON.stringify(searchTerms) : null);
|
||||
}
|
||||
|
||||
if ('jobspyLocation' in input) {
|
||||
const value = input.jobspyLocation ?? null;
|
||||
await settingsRepo.setSetting('jobspyLocation', value);
|
||||
}
|
||||
|
||||
if ('jobspyResultsWanted' in input) {
|
||||
const value = input.jobspyResultsWanted ?? null;
|
||||
await settingsRepo.setSetting('jobspyResultsWanted', value !== null ? String(value) : null);
|
||||
}
|
||||
|
||||
if ('jobspyHoursOld' in input) {
|
||||
const value = input.jobspyHoursOld ?? null;
|
||||
await settingsRepo.setSetting('jobspyHoursOld', value !== null ? String(value) : null);
|
||||
}
|
||||
|
||||
if ('jobspyCountryIndeed' in input) {
|
||||
const value = input.jobspyCountryIndeed ?? null;
|
||||
await settingsRepo.setSetting('jobspyCountryIndeed', value);
|
||||
}
|
||||
|
||||
if ('jobspySites' in input) {
|
||||
const value = input.jobspySites ?? null;
|
||||
await settingsRepo.setSetting('jobspySites', value !== null ? JSON.stringify(value) : null);
|
||||
}
|
||||
|
||||
if ('jobspyLinkedinFetchDescription' in input) {
|
||||
const value = input.jobspyLinkedinFetchDescription ?? null;
|
||||
await settingsRepo.setSetting('jobspyLinkedinFetchDescription', value !== null ? (value ? '1' : '0') : null);
|
||||
}
|
||||
|
||||
const overrideModel = await settingsRepo.getSetting('model');
|
||||
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const model = overrideModel || defaultModel;
|
||||
|
||||
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
|
||||
const modelScorer = overrideModelScorer || model;
|
||||
|
||||
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
|
||||
const modelTailoring = overrideModelTailoring || model;
|
||||
|
||||
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
|
||||
const modelProjectSelection = overrideModelProjectSelection || model;
|
||||
|
||||
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;
|
||||
|
||||
const profile = await loadResumeProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||
|
||||
const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs');
|
||||
const defaultUkvisajobsMaxJobs = 50;
|
||||
const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null;
|
||||
const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs;
|
||||
|
||||
const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm');
|
||||
const defaultGradcrackerMaxJobsPerTerm = 50;
|
||||
const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null;
|
||||
const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm;
|
||||
|
||||
// Search terms - stored as JSON array, default from env var (pipe-separated)
|
||||
const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms');
|
||||
const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer';
|
||||
const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean);
|
||||
const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null;
|
||||
const searchTerms = overrideSearchTerms ?? defaultSearchTerms;
|
||||
|
||||
// JobSpy settings (re-fetch to update response)
|
||||
const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation');
|
||||
const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK';
|
||||
const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation;
|
||||
|
||||
const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted');
|
||||
const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10);
|
||||
const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null;
|
||||
const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted;
|
||||
|
||||
const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld');
|
||||
const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10);
|
||||
const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null;
|
||||
const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld;
|
||||
|
||||
const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed');
|
||||
const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK';
|
||||
const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed;
|
||||
|
||||
const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites');
|
||||
const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null;
|
||||
const jobspySites = overrideJobspySites ?? defaultJobspySites;
|
||||
|
||||
const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription');
|
||||
const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1';
|
||||
const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw
|
||||
? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1'
|
||||
: null;
|
||||
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
model,
|
||||
defaultModel,
|
||||
overrideModel,
|
||||
modelScorer,
|
||||
overrideModelScorer,
|
||||
modelTailoring,
|
||||
overrideModelTailoring,
|
||||
modelProjectSelection,
|
||||
overrideModelProjectSelection,
|
||||
pipelineWebhookUrl,
|
||||
defaultPipelineWebhookUrl,
|
||||
overridePipelineWebhookUrl,
|
||||
jobCompleteWebhookUrl,
|
||||
defaultJobCompleteWebhookUrl,
|
||||
overrideJobCompleteWebhookUrl,
|
||||
...resumeProjectsData,
|
||||
ukvisajobsMaxJobs,
|
||||
defaultUkvisajobsMaxJobs,
|
||||
overrideUkvisajobsMaxJobs,
|
||||
gradcrackerMaxJobsPerTerm,
|
||||
defaultGradcrackerMaxJobsPerTerm,
|
||||
overrideGradcrackerMaxJobsPerTerm,
|
||||
searchTerms,
|
||||
defaultSearchTerms,
|
||||
overrideSearchTerms,
|
||||
jobspyLocation,
|
||||
defaultJobspyLocation,
|
||||
overrideJobspyLocation,
|
||||
jobspyResultsWanted,
|
||||
defaultJobspyResultsWanted,
|
||||
overrideJobspyResultsWanted,
|
||||
jobspyHoursOld,
|
||||
defaultJobspyHoursOld,
|
||||
overrideJobspyHoursOld,
|
||||
jobspyCountryIndeed,
|
||||
defaultJobspyCountryIndeed,
|
||||
overrideJobspyCountryIndeed,
|
||||
jobspySites,
|
||||
defaultJobspySites,
|
||||
overrideJobspySites,
|
||||
jobspyLinkedinFetchDescription,
|
||||
defaultJobspyLinkedinFetchDescription,
|
||||
overrideJobspyLinkedinFetchDescription,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
// PATCH usually returns 500 for unknown, but let's stick to what was there (400?)
|
||||
// Wait, the file said 400? Let's verify line 608.
|
||||
res.status(400).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
112
orchestrator/src/server/api/routes/test-utils.ts
Normal file
112
orchestrator/src/server/api/routes/test-utils.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import type { Server } from 'http';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.mock('../../pipeline/index.js', () => {
|
||||
const progress = {
|
||||
step: 'idle',
|
||||
message: 'Ready',
|
||||
crawlingListPagesProcessed: 0,
|
||||
crawlingListPagesTotal: 0,
|
||||
crawlingJobCardsFound: 0,
|
||||
crawlingJobPagesEnqueued: 0,
|
||||
crawlingJobPagesSkipped: 0,
|
||||
crawlingJobPagesProcessed: 0,
|
||||
jobsDiscovered: 0,
|
||||
jobsScored: 0,
|
||||
jobsProcessed: 0,
|
||||
totalToProcess: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
runPipeline: vi.fn().mockResolvedValue({ success: true, jobsDiscovered: 0, jobsProcessed: 0 }),
|
||||
processJob: vi.fn().mockResolvedValue({ success: true }),
|
||||
summarizeJob: vi.fn().mockResolvedValue({ success: true }),
|
||||
generateFinalPdf: vi.fn().mockResolvedValue({ success: true }),
|
||||
getPipelineStatus: vi.fn(() => ({ isRunning: false })),
|
||||
subscribeToProgress: vi.fn((listener: (data: unknown) => void) => {
|
||||
listener(progress);
|
||||
return () => {};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/notion.js', () => ({
|
||||
createNotionEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/manualJob.js', () => ({
|
||||
inferManualJobDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/scorer.js', () => ({
|
||||
scoreJobSuitability: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/ukvisajobs.js', () => ({
|
||||
fetchUkVisaJobsPage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/visa-sponsors/index.js', () => ({
|
||||
getStatus: vi.fn(),
|
||||
searchSponsors: vi.fn(),
|
||||
getOrganizationDetails: vi.fn(),
|
||||
downloadLatestCsv: vi.fn(),
|
||||
}));
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
export async function startServer(options?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
}): Promise<{
|
||||
server: Server;
|
||||
baseUrl: string;
|
||||
closeDb: () => void;
|
||||
tempDir: string;
|
||||
}> {
|
||||
vi.resetModules();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'job-ops-api-test-'));
|
||||
const envOverrides = options?.env ?? {};
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DATA_DIR: tempDir,
|
||||
NODE_ENV: 'test',
|
||||
MODEL: 'test-model',
|
||||
JOBSPY_SEARCH_TERMS: 'alpha|beta',
|
||||
...envOverrides,
|
||||
};
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const { createApp } = await import('../../app.js');
|
||||
const { closeDb } = await import('../../db/index.js');
|
||||
const { getPipelineStatus } = await import('../../pipeline/index.js');
|
||||
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false });
|
||||
|
||||
const app = createApp();
|
||||
const server = app.listen(0);
|
||||
await new Promise<void>((resolve) => server.once('listening', () => resolve()));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to resolve server address');
|
||||
}
|
||||
return {
|
||||
server,
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
closeDb,
|
||||
tempDir,
|
||||
};
|
||||
}
|
||||
|
||||
export async function stopServer(args: {
|
||||
server: Server;
|
||||
closeDb: () => void;
|
||||
tempDir: string;
|
||||
}) {
|
||||
await new Promise<void>((resolve) => args.server.close(() => resolve()));
|
||||
args.closeDb();
|
||||
await rm(args.tempDir, { recursive: true, force: true });
|
||||
process.env = { ...originalEnv };
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
91
orchestrator/src/server/api/routes/ukvisajobs.test.ts
Normal file
91
orchestrator/src/server/api/routes/ukvisajobs.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('UK Visa Jobs API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
it('enforces pagination rules for search', async () => {
|
||||
const badRes = await fetch(`${baseUrl}/api/ukvisajobs/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ searchTerms: ['one', 'two'] }),
|
||||
});
|
||||
expect(badRes.status).toBe(400);
|
||||
});
|
||||
|
||||
it('searches UK Visa Jobs with valid payloads', async () => {
|
||||
const { fetchUkVisaJobsPage } = await import('../../services/ukvisajobs.js');
|
||||
vi.mocked(fetchUkVisaJobsPage).mockResolvedValue({
|
||||
jobs: [
|
||||
{
|
||||
source: 'ukvisajobs',
|
||||
title: 'Engineer',
|
||||
employer: 'Acme',
|
||||
jobUrl: 'https://example.com/visa/1',
|
||||
},
|
||||
],
|
||||
totalJobs: 3,
|
||||
page: 1,
|
||||
pageSize: 2,
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/ukvisajobs/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: 'engineer' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.totalPages).toBe(2);
|
||||
expect(fetchUkVisaJobsPage).toHaveBeenCalledWith({ searchKeyword: 'engineer', page: 1 });
|
||||
});
|
||||
|
||||
it('blocks search when pipeline is running', async () => {
|
||||
const { getPipelineStatus } = await import('../../pipeline/index.js');
|
||||
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: true });
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/ukvisajobs/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: 'engineer' }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('imports UK Visa Jobs and reports created vs skipped', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/ukvisajobs/import`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jobs: [
|
||||
{
|
||||
title: 'Engineer',
|
||||
employer: 'Acme',
|
||||
jobUrl: 'https://example.com/visa/2',
|
||||
},
|
||||
{
|
||||
title: 'Engineer Duplicate',
|
||||
employer: 'Acme',
|
||||
jobUrl: 'https://example.com/visa/2',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.created).toBe(1);
|
||||
expect(body.data.skipped).toBe(1);
|
||||
});
|
||||
});
|
||||
128
orchestrator/src/server/api/routes/ukvisajobs.ts
Normal file
128
orchestrator/src/server/api/routes/ukvisajobs.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as jobsRepo from '../../repositories/jobs.js';
|
||||
import { fetchUkVisaJobsPage } from '../../services/ukvisajobs.js';
|
||||
import { getPipelineStatus } from '../../pipeline/index.js';
|
||||
import type { ApiResponse, UkVisaJobsSearchResponse, UkVisaJobsImportResponse } from '../../../shared/types.js';
|
||||
|
||||
export const ukVisaJobsRouter = Router();
|
||||
let isUkVisaJobsSearchRunning = false;
|
||||
|
||||
const ukVisaJobsSearchSchema = z.object({
|
||||
query: z.string().trim().min(1).max(200).optional(),
|
||||
searchTerm: z.string().trim().min(1).max(200).optional(),
|
||||
searchTerms: z.array(z.string().trim().min(1).max(200)).max(20).optional(),
|
||||
page: z.number().int().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ukvisajobs/search - Run a UKVisaJobs search without importing into the DB
|
||||
*/
|
||||
ukVisaJobsRouter.post('/search', async (req: Request, res: Response) => {
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const input = ukVisaJobsSearchSchema.parse(req.body ?? {});
|
||||
|
||||
if (isUkVisaJobsSearchRunning) {
|
||||
return res.status(409).json({ success: false, error: 'UK Visa Jobs search is already running' });
|
||||
}
|
||||
|
||||
const { isRunning } = getPipelineStatus();
|
||||
if (isRunning) {
|
||||
return res.status(409).json({ success: false, error: 'Pipeline is running. Stop it before running UK Visa Jobs search.' });
|
||||
}
|
||||
|
||||
isUkVisaJobsSearchRunning = true;
|
||||
lockAcquired = true;
|
||||
|
||||
const rawTerms = input.searchTerms ?? [];
|
||||
if (rawTerms.length > 1) {
|
||||
return res.status(400).json({ success: false, error: 'Pagination supports a single search term.' });
|
||||
}
|
||||
|
||||
const searchTerm = input.searchTerm ?? input.query ?? rawTerms[0];
|
||||
const page = input.page ?? 1;
|
||||
|
||||
const result = await fetchUkVisaJobsPage({
|
||||
searchKeyword: searchTerm,
|
||||
page,
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(result.totalJobs / result.pageSize));
|
||||
|
||||
const response: ApiResponse<UkVisaJobsSearchResponse> = {
|
||||
success: true,
|
||||
data: {
|
||||
jobs: result.jobs,
|
||||
totalJobs: result.totalJobs,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
isUkVisaJobsSearchRunning = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const ukVisaJobsImportSchema = z.object({
|
||||
jobs: z.array(z.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
employer: z.string().trim().min(1).max(500),
|
||||
jobUrl: z.string().trim().min(1).max(2000),
|
||||
sourceJobId: z.string().trim().min(1).max(200).optional(),
|
||||
employerUrl: z.string().trim().min(1).max(2000).optional(),
|
||||
applicationLink: z.string().trim().min(1).max(2000).optional(),
|
||||
location: z.string().trim().max(200).optional(),
|
||||
deadline: z.string().trim().max(100).optional(),
|
||||
salary: z.string().trim().max(200).optional(),
|
||||
jobDescription: z.string().trim().max(20000).optional(),
|
||||
datePosted: z.string().trim().max(100).optional(),
|
||||
degreeRequired: z.string().trim().max(200).optional(),
|
||||
jobType: z.string().trim().max(200).optional(),
|
||||
jobLevel: z.string().trim().max(200).optional(),
|
||||
})).min(1).max(200),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ukvisajobs/import - Import selected UKVisaJobs results into the DB
|
||||
*/
|
||||
ukVisaJobsRouter.post('/import', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = ukVisaJobsImportSchema.parse(req.body ?? {});
|
||||
|
||||
const jobs = input.jobs.map((job) => ({
|
||||
...job,
|
||||
source: 'ukvisajobs' as const,
|
||||
}));
|
||||
|
||||
const result = await jobsRepo.bulkCreateJobs(jobs);
|
||||
|
||||
const response: ApiResponse<UkVisaJobsImportResponse> = {
|
||||
success: true,
|
||||
data: {
|
||||
created: result.created,
|
||||
skipped: result.skipped,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
76
orchestrator/src/server/api/routes/visa-sponsors.test.ts
Normal file
76
orchestrator/src/server/api/routes/visa-sponsors.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Visa sponsors API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
it('returns status and surfaces update errors', async () => {
|
||||
const { getStatus, downloadLatestCsv } = await import('../../services/visa-sponsors/index.js');
|
||||
vi.mocked(getStatus).mockReturnValue({
|
||||
lastUpdated: null,
|
||||
csvPath: null,
|
||||
totalSponsors: 0,
|
||||
isUpdating: false,
|
||||
nextScheduledUpdate: null,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(downloadLatestCsv).mockResolvedValue({ success: false, message: 'failed' });
|
||||
|
||||
const statusRes = await fetch(`${baseUrl}/api/visa-sponsors/status`);
|
||||
const statusBody = await statusRes.json();
|
||||
expect(statusBody.success).toBe(true);
|
||||
expect(statusBody.data.totalSponsors).toBe(0);
|
||||
|
||||
const updateRes = await fetch(`${baseUrl}/api/visa-sponsors/update`, { method: 'POST' });
|
||||
expect(updateRes.status).toBe(500);
|
||||
});
|
||||
|
||||
it('validates search payloads and handles missing organizations', async () => {
|
||||
const { searchSponsors, getOrganizationDetails } = await import('../../services/visa-sponsors/index.js');
|
||||
vi.mocked(searchSponsors).mockReturnValue([
|
||||
{
|
||||
sponsor: {
|
||||
organisationName: 'Acme',
|
||||
townCity: 'London',
|
||||
county: 'London',
|
||||
typeRating: 'Worker',
|
||||
route: 'Skilled',
|
||||
},
|
||||
score: 95,
|
||||
matchedName: 'acme',
|
||||
},
|
||||
]);
|
||||
vi.mocked(getOrganizationDetails).mockReturnValue([]);
|
||||
|
||||
const badRes = await fetch(`${baseUrl}/api/visa-sponsors/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(badRes.status).toBe(400);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/visa-sponsors/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: 'Acme' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.total).toBe(1);
|
||||
|
||||
const orgRes = await fetch(`${baseUrl}/api/visa-sponsors/organization/Acme`);
|
||||
expect(orgRes.status).toBe(404);
|
||||
});
|
||||
});
|
||||
109
orchestrator/src/server/api/routes/visa-sponsors.ts
Normal file
109
orchestrator/src/server/api/routes/visa-sponsors.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as visaSponsors from '../../services/visa-sponsors/index.js';
|
||||
import type {
|
||||
ApiResponse,
|
||||
VisaSponsorSearchResponse,
|
||||
VisaSponsorStatusResponse,
|
||||
} from '../../../shared/types.js';
|
||||
|
||||
export const visaSponsorsRouter = Router();
|
||||
|
||||
/**
|
||||
* GET /api/visa-sponsors/status - Get status of the visa sponsor service
|
||||
*/
|
||||
visaSponsorsRouter.get('/status', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const status = visaSponsors.getStatus();
|
||||
const response: ApiResponse<VisaSponsorStatusResponse> = {
|
||||
success: true,
|
||||
data: status,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/visa-sponsors/search - Search for visa sponsors
|
||||
*/
|
||||
const visaSponsorSearchSchema = z.object({
|
||||
query: z.string().min(1),
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
minScore: z.number().int().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
visaSponsorsRouter.post('/search', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = visaSponsorSearchSchema.parse(req.body);
|
||||
|
||||
const results = visaSponsors.searchSponsors(input.query, {
|
||||
limit: input.limit,
|
||||
minScore: input.minScore,
|
||||
});
|
||||
|
||||
const response: ApiResponse<VisaSponsorSearchResponse> = {
|
||||
success: true,
|
||||
data: {
|
||||
results,
|
||||
query: input.query,
|
||||
total: results.length,
|
||||
},
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/visa-sponsors/organization/:name - Get all entries for an organization
|
||||
*/
|
||||
visaSponsorsRouter.get('/organization/:name', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const name = decodeURIComponent(req.params.name);
|
||||
const entries = visaSponsors.getOrganizationDetails(name);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return res.status(404).json({ success: false, error: 'Organization not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: entries,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/visa-sponsors/update - Trigger a manual update of the visa sponsor list
|
||||
*/
|
||||
visaSponsorsRouter.post('/update', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await visaSponsors.downloadLatestCsv();
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ success: false, error: result.message });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: result.message,
|
||||
status: visaSponsors.getStatus(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
35
orchestrator/src/server/api/routes/webhook.test.ts
Normal file
35
orchestrator/src/server/api/routes/webhook.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { Server } from 'http';
|
||||
import { startServer, stopServer } from './test-utils.js';
|
||||
|
||||
describe.sequential('Webhook API routes', () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let closeDb: () => void;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer({
|
||||
env: { WEBHOOK_SECRET: 'secret' },
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopServer({ server, closeDb, tempDir });
|
||||
});
|
||||
|
||||
it('rejects invalid webhook credentials and accepts valid ones', async () => {
|
||||
const badRes = await fetch(`${baseUrl}/api/webhook/trigger`, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(badRes.status).toBe(401);
|
||||
|
||||
const goodRes = await fetch(`${baseUrl}/api/webhook/trigger`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer secret' },
|
||||
});
|
||||
const goodBody = await goodRes.json();
|
||||
expect(goodBody.success).toBe(true);
|
||||
expect(goodBody.data.message).toBe('Pipeline triggered');
|
||||
});
|
||||
});
|
||||
33
orchestrator/src/server/api/routes/webhook.ts
Normal file
33
orchestrator/src/server/api/routes/webhook.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { runPipeline } from '../../pipeline/index.js';
|
||||
|
||||
export const webhookRouter = Router();
|
||||
|
||||
/**
|
||||
* POST /api/webhook/trigger - Webhook endpoint for n8n to trigger the pipeline
|
||||
*/
|
||||
webhookRouter.post('/trigger', async (req: Request, res: Response) => {
|
||||
// Optional: Add authentication check
|
||||
const authHeader = req.headers.authorization;
|
||||
const expectedToken = process.env.WEBHOOK_SECRET;
|
||||
|
||||
if (expectedToken && authHeader !== `Bearer ${expectedToken}`) {
|
||||
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Start pipeline in background
|
||||
runPipeline().catch(console.error);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Pipeline triggered',
|
||||
triggeredAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
@ -85,7 +85,7 @@ Keys:
|
||||
- starting
|
||||
|
||||
JOB DESCRIPTION:
|
||||
${jd.slice(0, 8000)}${jd.length > 8000 ? '... (truncated)' : ''}
|
||||
${jd}
|
||||
|
||||
OUTPUT FORMAT (JSON ONLY):
|
||||
{
|
||||
|
||||
@ -3,3 +3,13 @@
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserver;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user