diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 8437329..54b9f66 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -50,6 +50,7 @@ "@types/better-sqlite3": "^7.6.8", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jsdom": "^27.0.0", "@types/node": "^22.10.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -3467,6 +3468,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3579,6 +3591,12 @@ "@types/node": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", diff --git a/orchestrator/package.json b/orchestrator/package.json index f2e2c95..cb2d9e8 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -62,6 +62,7 @@ "@types/better-sqlite3": "^7.6.8", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jsdom": "^27.0.0", "@types/node": "^22.10.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 46b8cbd..e570fb2 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -16,6 +16,7 @@ import type { CreateJobInput, ManualJobDraft, ManualJobInferenceResponse, + ManualJobFetchResponse, VisaSponsorSearchResponse, VisaSponsorStatusResponse, VisaSponsor, @@ -39,7 +40,16 @@ async function fetchApi( }, }); - const data: ApiResponse = await response.json(); + const text = await response.text(); + + let data: ApiResponse; + try { + data = JSON.parse(text); + } catch { + // If the response is not JSON, it's likely an HTML error page + console.error('API returned non-JSON response:', text.substring(0, 500)); + throw new Error(`Server error (${response.status}): Expected JSON but received HTML. Is the backend server running?`); + } if (!data.success) { throw new Error(data.error || 'API request failed'); @@ -149,6 +159,15 @@ export async function importUkVisaJobs(input: { } // Manual Job Import API +export async function fetchJobFromUrl(input: { + url: string; +}): Promise { + return fetchApi('/manual-jobs/fetch', { + method: 'POST', + body: JSON.stringify(input), + }); +} + export async function inferManualJob(input: { jobDescription: string; }): Promise { diff --git a/orchestrator/src/client/components/ManualImportSheet.test.tsx b/orchestrator/src/client/components/ManualImportSheet.test.tsx index a2f2967..8eddd81 100644 --- a/orchestrator/src/client/components/ManualImportSheet.test.tsx +++ b/orchestrator/src/client/components/ManualImportSheet.test.tsx @@ -6,6 +6,7 @@ import * as api from "../api"; import { toast } from "sonner"; vi.mock("../api", () => ({ + fetchJobFromUrl: vi.fn(), inferManualJob: vi.fn(), importManualJob: vi.fn(), })); @@ -41,7 +42,7 @@ describe("ManualImportSheet", () => { ); fireEvent.change( - screen.getByPlaceholderText("Paste the full job description here..."), + screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."), { target: { value: rawDescription } } ); fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); @@ -92,7 +93,7 @@ describe("ManualImportSheet", () => { ); fireEvent.change( - screen.getByPlaceholderText("Paste the full job description here..."), + screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."), { target: { value: rawDescription } } ); fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); @@ -122,7 +123,7 @@ describe("ManualImportSheet", () => { ); fireEvent.change( - screen.getByPlaceholderText("Paste the full job description here..."), + screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."), { target: { value: rawDescription } } ); fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); @@ -150,7 +151,7 @@ describe("ManualImportSheet", () => { ); fireEvent.change( - screen.getByPlaceholderText("Paste the full job description here..."), + screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."), { target: { value: "Backend Engineer role." } } ); fireEvent.click(screen.getByRole("button", { name: /analyze jd/i })); @@ -165,4 +166,137 @@ describe("ManualImportSheet", () => { expect(onOpenChange).not.toHaveBeenCalled(); expect(screen.getByRole("button", { name: /import job/i })).toBeEnabled(); }); + + describe("URL fetch functionality", () => { + it("shows Paste button when URL field is empty, Fetch when URL is entered", async () => { + render( + + ); + + // Initially should show Paste button + expect(screen.getByRole("button", { name: /paste/i })).toBeInTheDocument(); + + // Enter a URL + fireEvent.change( + screen.getByPlaceholderText("https://example.com/job-posting"), + { target: { value: "https://example.com/job" } } + ); + + // Should now show Fetch button + expect(screen.getByRole("button", { name: /fetch/i })).toBeInTheDocument(); + }); + + it("fetches URL and proceeds to review on successful fetch", async () => { + vi.mocked(api.fetchJobFromUrl).mockResolvedValue({ + content: "Software Engineer role at Acme Corp", + url: "https://example.com/job", + }); + vi.mocked(api.inferManualJob).mockResolvedValue({ + job: { + title: "Software Engineer", + employer: "Acme Corp", + location: "Remote", + jobDescription: "Great opportunity to join our team.", + }, + }); + + render( + + ); + + // Enter a URL + fireEvent.change( + screen.getByPlaceholderText("https://example.com/job-posting"), + { target: { value: "https://example.com/job" } } + ); + + // Click Fetch + fireEvent.click(screen.getByRole("button", { name: /fetch/i })); + + // Should show loading state then review + await screen.findByPlaceholderText("e.g. Junior Backend Engineer"); + + expect(api.fetchJobFromUrl).toHaveBeenCalledWith({ + url: "https://example.com/job", + }); + expect(api.inferManualJob).toHaveBeenCalledWith({ + jobDescription: "Software Engineer role at Acme Corp", + }); + + // Check inferred values are shown + expect(screen.getByPlaceholderText("e.g. Junior Backend Engineer")).toHaveValue("Software Engineer"); + expect(screen.getByPlaceholderText("e.g. Acme Labs")).toHaveValue("Acme Corp"); + }); + + it("preserves fetched URL in the job URL field", async () => { + vi.mocked(api.fetchJobFromUrl).mockResolvedValue({ + content: "Job description content", + url: "https://example.com/job", + }); + vi.mocked(api.inferManualJob).mockResolvedValue({ + job: { + title: "Engineer", + employer: "Company", + }, + }); + + render( + + ); + + fireEvent.change( + screen.getByPlaceholderText("https://example.com/job-posting"), + { target: { value: "https://example.com/job" } } + ); + fireEvent.click(screen.getByRole("button", { name: /fetch/i })); + + await screen.findByPlaceholderText("e.g. Junior Backend Engineer"); + + // Check the job URL field has the fetched URL (first https://... input is Job URL) + const urlInputs = screen.getAllByPlaceholderText("https://..."); + expect(urlInputs[0]).toHaveValue("https://example.com/job"); + }); + + it("shows error and returns to paste step when fetch fails", async () => { + vi.mocked(api.fetchJobFromUrl).mockRejectedValue(new Error("Failed to fetch URL")); + + render( + + ); + + fireEvent.change( + screen.getByPlaceholderText("https://example.com/job-posting"), + { target: { value: "https://example.com/bad-url" } } + ); + fireEvent.click(screen.getByRole("button", { name: /fetch/i })); + + await screen.findByText("Failed to fetch URL"); + + // Should still be on paste step + expect(screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it...")).toBeInTheDocument(); + }); + + it("shows error when inference fails after fetch", async () => { + vi.mocked(api.fetchJobFromUrl).mockResolvedValue({ + content: "Job content", + url: "https://example.com/job", + }); + vi.mocked(api.inferManualJob).mockRejectedValue(new Error("Inference failed")); + + render( + + ); + + fireEvent.change( + screen.getByPlaceholderText("https://example.com/job-posting"), + { target: { value: "https://example.com/job" } } + ); + fireEvent.click(screen.getByRole("button", { name: /fetch/i })); + + await screen.findByText("Inference failed"); + + // Should be back on paste step + expect(screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it...")).toBeInTheDocument(); + }); + }); }); diff --git a/orchestrator/src/client/components/ManualImportSheet.tsx b/orchestrator/src/client/components/ManualImportSheet.tsx index a582f5a..2ac029f 100644 --- a/orchestrator/src/client/components/ManualImportSheet.tsx +++ b/orchestrator/src/client/components/ManualImportSheet.tsx @@ -3,7 +3,7 @@ */ import React, { useEffect, useMemo, useState } from "react"; -import { ArrowLeft, FileText, Loader2, Sparkles } from "lucide-react"; +import { ArrowLeft, ClipboardPaste, FileText, Link, Loader2, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -112,6 +112,8 @@ export const ManualImportSheet: React.FC = ({ }) => { const [step, setStep] = useState("paste"); const [rawDescription, setRawDescription] = useState(""); + const [fetchUrl, setFetchUrl] = useState(""); + const [isFetching, setIsFetching] = useState(false); const [draft, setDraft] = useState(emptyDraft); const [warning, setWarning] = useState(null); const [error, setError] = useState(null); @@ -121,6 +123,8 @@ export const ManualImportSheet: React.FC = ({ if (!open) { setStep("paste"); setRawDescription(""); + setFetchUrl(""); + setIsFetching(false); setDraft(emptyDraft); setWarning(null); setError(null); @@ -132,6 +136,7 @@ export const ManualImportSheet: React.FC = ({ const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex]; const canAnalyze = rawDescription.trim().length > 0 && step !== "loading"; + const canFetch = fetchUrl.trim().length > 0 && !isFetching && step === "paste"; const canImport = useMemo(() => { if (step !== "review") return false; return ( @@ -141,6 +146,43 @@ export const ManualImportSheet: React.FC = ({ ); }, [draft, step]); + const handleFetch = async () => { + if (!fetchUrl.trim()) return; + + try { + setError(null); + setWarning(null); + setIsFetching(true); + + // Fetch the URL content + const fetchResponse = await api.fetchJobFromUrl({ url: fetchUrl.trim() }); + const fetchedContent = fetchResponse.content; + const fetchedUrl = fetchResponse.url; + + setIsFetching(false); + + // Automatically proceed to analysis + setStep("loading"); + const inferResponse = await api.inferManualJob({ jobDescription: fetchedContent }); + // Don't pass raw HTML as job description - let user fill it in or use inferred data + const normalized = normalizeDraft(inferResponse.job); + + // Preserve the fetched URL + if (!normalized.jobUrl) { + normalized.jobUrl = fetchedUrl; + } + + setDraft(normalized); + setWarning(inferResponse.warning ?? null); + setStep("review"); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fetch URL"; + setError(message); + setIsFetching(false); + setStep("paste"); + } + }; + const handleAnalyze = async () => { if (!rawDescription.trim()) { setError("Paste a job description to continue."); @@ -152,7 +194,12 @@ export const ManualImportSheet: React.FC = ({ setWarning(null); setStep("loading"); const response = await api.inferManualJob({ jobDescription: rawDescription }); - setDraft(normalizeDraft(response.job, rawDescription.trim())); + const normalized = normalizeDraft(response.job, rawDescription.trim()); + // Preserve the fetched URL if we fetched from a URL + if (draft.jobUrl && !normalized.jobUrl) { + normalized.jobUrl = draft.jobUrl; + } + setDraft(normalized); setWarning(response.warning ?? null); setStep("review"); } catch (err) { @@ -217,6 +264,53 @@ export const ManualImportSheet: React.FC = ({
{step === "paste" && (
+
+ +
+ setFetchUrl(event.target.value)} + placeholder="https://example.com/job-posting" + className="flex-1" + onKeyDown={(event) => { + if (event.key === "Enter" && canFetch) { + event.preventDefault(); + handleFetch(); + } + }} + /> + +
+
+