Edit job details in UI (#119)

* api(jobs): normalize PATCH /jobs/:id response contract and error mapping

* api(jobs): support core job detail edits in update schema

* feat(client): add JobDetailsEditDrawer with core metadata form

* feat(orchestrator): open edit drawer from JobDetailPanel more actions

* feat(orchestrator): add edit drawer trigger to ready and discovered more actions
This commit is contained in:
Shaheer Sarfaraz 2026-02-09 21:25:00 +00:00 committed by GitHub
parent b456ab1951
commit d82c69b4b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 889 additions and 10 deletions

View File

@ -0,0 +1,154 @@
import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div>{children}</div> : null,
SheetContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SheetHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SheetTitle: ({ children }: { children: React.ReactNode }) => (
<h2>{children}</h2>
),
SheetDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
),
}));
vi.mock("../api", () => ({
updateJob: vi.fn(),
checkSponsor: vi.fn(),
rescoreJob: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
const createJob = (overrides: Partial<Job> = {}): Job =>
({
id: "job-1",
title: "Backend Engineer",
employer: "Acme",
jobUrl: "https://example.com/job",
applicationLink: null,
location: "London",
salary: null,
deadline: null,
jobDescription: "Build APIs",
...overrides,
}) as Job;
describe("JobDetailsEditDrawer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("saves details and reruns sponsor check when employer changes", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
const onOpenChange = vi.fn();
vi.mocked(api.updateJob).mockResolvedValue({} as Job);
vi.mocked(api.checkSponsor).mockResolvedValue({} as Job);
render(
<JobDetailsEditDrawer
open
onOpenChange={onOpenChange}
job={createJob()}
onJobUpdated={onJobUpdated}
/>,
);
fireEvent.change(screen.getByLabelText("Employer *"), {
target: { value: "NewCo" },
});
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
await waitFor(() =>
expect(api.updateJob).toHaveBeenCalledWith(
"job-1",
expect.objectContaining({
employer: "NewCo",
title: "Backend Engineer",
}),
),
);
expect(api.checkSponsor).toHaveBeenCalledWith("job-1");
expect(onJobUpdated).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("validates required fields before saving", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
const onOpenChange = vi.fn();
render(
<JobDetailsEditDrawer
open
onOpenChange={onOpenChange}
job={createJob()}
onJobUpdated={onJobUpdated}
/>,
);
fireEvent.change(screen.getByLabelText("Title *"), {
target: { value: " " },
});
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
expect(await screen.findByText("Title is required.")).toBeInTheDocument();
expect(api.updateJob).not.toHaveBeenCalled();
expect(onJobUpdated).not.toHaveBeenCalled();
});
it("offers a rescore action after successful save", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
const onOpenChange = vi.fn();
const { toast } = await import("sonner");
vi.mocked(api.updateJob).mockResolvedValue({} as Job);
vi.mocked(api.rescoreJob).mockResolvedValue({} as Job);
render(
<JobDetailsEditDrawer
open
onOpenChange={onOpenChange}
job={createJob()}
onJobUpdated={onJobUpdated}
/>,
);
fireEvent.change(screen.getByLabelText("Salary"), {
target: { value: "GBP 90k" },
});
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
await waitFor(() =>
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
"Job details updated",
expect.any(Object),
),
);
const successCalls = vi.mocked(toast.success).mock.calls;
const [, payload] =
successCalls.find((call) => call[0] === "Job details updated") ?? [];
expect(payload).toBeTruthy();
(payload as { action?: { onClick?: () => void } }).action?.onClick?.();
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
expect(onJobUpdated).toHaveBeenCalledTimes(2);
});
});

View File

@ -0,0 +1,360 @@
import type { Job } from "@shared/types.js";
import { Loader2, Save } from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import * as api from "../api";
interface JobDetailsEditDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
job: Job | null;
onJobUpdated: () => void | Promise<void>;
}
type JobDetailsDraft = {
title: string;
employer: string;
jobUrl: string;
applicationLink: string;
location: string;
salary: string;
deadline: string;
jobDescription: string;
};
const emptyDraft: JobDetailsDraft = {
title: "",
employer: "",
jobUrl: "",
applicationLink: "",
location: "",
salary: "",
deadline: "",
jobDescription: "",
};
const normalizeOptional = (value: string): string | null => {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const normalizeFromJob = (job: Job | null): JobDetailsDraft => {
if (!job) return emptyDraft;
return {
title: job.title ?? "",
employer: job.employer ?? "",
jobUrl: job.jobUrl ?? "",
applicationLink: job.applicationLink ?? "",
location: job.location ?? "",
salary: job.salary ?? "",
deadline: job.deadline ?? "",
jobDescription: job.jobDescription ?? "",
};
};
function isValidUrl(value: string): boolean {
try {
new URL(value);
return true;
} catch {
return false;
}
}
export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
open,
onOpenChange,
job,
onJobUpdated,
}) => {
const [draft, setDraft] = useState<JobDetailsDraft>(emptyDraft);
const [validationError, setValidationError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!open) return;
setDraft(normalizeFromJob(job));
setValidationError(null);
setIsSaving(false);
}, [job, open]);
const hasJob = !!job;
const isDirty = useMemo(() => {
if (!job) return false;
const current = normalizeFromJob(job);
return (
draft.title !== current.title ||
draft.employer !== current.employer ||
draft.jobUrl !== current.jobUrl ||
draft.applicationLink !== current.applicationLink ||
draft.location !== current.location ||
draft.salary !== current.salary ||
draft.deadline !== current.deadline ||
draft.jobDescription !== current.jobDescription
);
}, [draft, job]);
const handleSave = async () => {
if (!job) return;
const title = draft.title.trim();
const employer = draft.employer.trim();
const jobUrl = draft.jobUrl.trim();
const applicationLink = draft.applicationLink.trim();
if (!title) {
setValidationError("Title is required.");
return;
}
if (!employer) {
setValidationError("Employer is required.");
return;
}
if (!jobUrl) {
setValidationError("Job URL is required.");
return;
}
if (!isValidUrl(jobUrl)) {
setValidationError("Job URL must be a valid URL.");
return;
}
if (applicationLink && !isValidUrl(applicationLink)) {
setValidationError("Application URL must be a valid URL.");
return;
}
try {
setValidationError(null);
setIsSaving(true);
const employerChanged =
employer.toLowerCase() !== job.employer.trim().toLowerCase();
await api.updateJob(job.id, {
title,
employer,
jobUrl,
applicationLink: normalizeOptional(draft.applicationLink),
location: normalizeOptional(draft.location),
salary: normalizeOptional(draft.salary),
deadline: normalizeOptional(draft.deadline),
jobDescription: normalizeOptional(draft.jobDescription),
});
if (employerChanged) {
try {
await api.checkSponsor(job.id);
} catch (error) {
const message =
error instanceof Error
? error.message
: "Job updated, but sponsor check failed";
toast.error(message);
}
}
await onJobUpdated();
toast.success("Job details updated", {
action: {
label: "Rescore now",
onClick: () => {
void (async () => {
try {
await api.rescoreJob(job.id);
await onJobUpdated();
toast.success("Match recalculated");
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to recalculate match";
toast.error(message);
}
})();
},
},
});
onOpenChange(false);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to update job details";
toast.error(message);
} finally {
setIsSaving(false);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-2xl">
<div className="flex h-full flex-col">
<SheetHeader>
<SheetTitle>Edit job details</SheetTitle>
<SheetDescription>
Correct extracted metadata before continuing with this role.
</SheetDescription>
</SheetHeader>
{!hasJob ? (
<div className="mt-6 rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
Select a job to edit.
</div>
) : (
<>
<div className="mt-4 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 sm:grid-cols-2">
<FieldInput
id="edit-job-title"
label="Title *"
value={draft.title}
onChange={(value) =>
setDraft((prev) => ({ ...prev, title: value }))
}
placeholder="e.g. Full Stack Engineer"
/>
<FieldInput
id="edit-job-employer"
label="Employer *"
value={draft.employer}
onChange={(value) =>
setDraft((prev) => ({ ...prev, employer: value }))
}
placeholder="e.g. Acme Labs"
/>
<FieldInput
id="edit-job-url"
label="Job URL *"
value={draft.jobUrl}
onChange={(value) =>
setDraft((prev) => ({ ...prev, jobUrl: value }))
}
placeholder="https://..."
/>
<FieldInput
id="edit-application-url"
label="Application URL"
value={draft.applicationLink}
onChange={(value) =>
setDraft((prev) => ({ ...prev, applicationLink: value }))
}
placeholder="https://..."
/>
<FieldInput
id="edit-location"
label="Location"
value={draft.location}
onChange={(value) =>
setDraft((prev) => ({ ...prev, location: value }))
}
placeholder="e.g. London, UK"
/>
<FieldInput
id="edit-salary"
label="Salary"
value={draft.salary}
onChange={(value) =>
setDraft((prev) => ({ ...prev, salary: value }))
}
placeholder="e.g. GBP 90k-110k"
/>
<FieldInput
id="edit-deadline"
label="Deadline"
value={draft.deadline}
onChange={(value) =>
setDraft((prev) => ({ ...prev, deadline: value }))
}
placeholder="e.g. 31 Mar 2026"
/>
</div>
<div className="mt-3 space-y-1">
<label
htmlFor="edit-job-description"
className="text-xs font-medium text-muted-foreground"
>
Job description
</label>
<Textarea
id="edit-job-description"
value={draft.jobDescription}
onChange={(event) =>
setDraft((prev) => ({
...prev,
jobDescription: event.target.value,
}))
}
placeholder="Paste or refine the job description..."
className="min-h-[220px] font-mono text-sm leading-relaxed"
/>
</div>
{validationError && (
<div className="mt-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{validationError}
</div>
)}
</div>
<div className="mt-4 flex items-center justify-end gap-2 border-t pt-4">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void handleSave()}
disabled={isSaving || !isDirty}
>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save details
</Button>
</div>
</>
)}
</div>
</SheetContent>
</Sheet>
);
};
const FieldInput: React.FC<{
id: string;
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
}> = ({ id, label, value, onChange, placeholder }) => (
<div className="space-y-1">
<label htmlFor={id} className="text-xs font-medium text-muted-foreground">
{label}
</label>
<Input
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
</div>
);

View File

@ -57,6 +57,31 @@ vi.mock("../api", () => ({
updateJob: vi.fn(),
}));
vi.mock("./JobDetailsEditDrawer", () => ({
JobDetailsEditDrawer: ({
open,
onOpenChange,
onJobUpdated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onJobUpdated: () => void | Promise<void>;
}) =>
open ? (
<div data-testid="job-details-edit-drawer">
<button
type="button"
onClick={() => {
void onJobUpdated();
onOpenChange(false);
}}
>
Save details
</button>
</div>
) : null,
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
@ -156,4 +181,28 @@ describe("ReadyPanel", () => {
expect(onJobUpdated).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
});
it("opens edit details drawer from more actions", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
const job = createJob();
render(
<MemoryRouter>
<ReadyPanel
job={job}
onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()}
/>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("menuitem", { name: /edit details/i }));
expect(screen.getByTestId("job-details-edit-drawer")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
await waitFor(() => expect(onJobUpdated).toHaveBeenCalled());
expect(
screen.queryByTestId("job-details-edit-drawer"),
).not.toBeInTheDocument();
});
});

View File

@ -45,6 +45,7 @@ import { useProfile } from "../hooks/useProfile";
import { useRescoreJob } from "../hooks/useRescoreJob";
import { FitAssessment, JobHeader, TailoredSummary } from ".";
import { TailorMode } from "./discovered-panel/TailorMode";
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
type PanelMode = "ready" | "tailor";
@ -67,6 +68,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
const [mode, setMode] = useState<PanelMode>("ready");
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const [isEditDetailsOpen, setIsEditDetailsOpen] = useState(false);
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [recentlyApplied, setRecentlyApplied] = useState<{
@ -90,6 +92,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
if (previousJobIdRef.current === currentJobId) return;
previousJobIdRef.current = currentJobId;
setMode("ready");
setIsEditDetailsOpen(false);
onTailoringDirtyChange?.(false);
}, [job?.id, onTailoringDirtyChange]);
@ -413,6 +416,10 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
<Edit2 className="mr-2 h-4 w-4" />
Edit tailoring
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsEditDetailsOpen(true)}>
<Edit2 className="mr-2 h-4 w-4" />
Edit details
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleRegenerate}
@ -453,6 +460,13 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</DropdownMenu>
</div>
<JobDetailsEditDrawer
open={isEditDetailsOpen}
onOpenChange={setIsEditDetailsOpen}
job={job}
onJobUpdated={onJobUpdated}
/>
{/*
UNDO BAR (conditional)
Lightweight undo option after marking applied

View File

@ -1,6 +1,7 @@
import type { Job } from "@shared/types.js";
import {
ChevronUp,
Edit2,
ExternalLink,
Loader2,
RefreshCcw,
@ -28,6 +29,7 @@ interface DecideModeProps {
isSkipping: boolean;
onRescore: () => void;
isRescoring: boolean;
onEditDetails: () => void;
onCheckSponsor?: () => Promise<void>;
}
@ -38,6 +40,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
isSkipping,
onRescore,
isRescoring,
onEditDetails,
onCheckSponsor,
}) => {
const [showDescription, setShowDescription] = useState(false);
@ -113,6 +116,10 @@ export const DecideMode: React.FC<DecideModeProps> = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<DropdownMenuItem onSelect={onEditDetails}>
<Edit2 className="mr-2 h-4 w-4" />
Edit details
</DropdownMenuItem>
<DropdownMenuItem onSelect={onRescore} disabled={isRescoring}>
<RefreshCcw
className={

View File

@ -50,6 +50,31 @@ vi.mock("../../api", () => ({
checkSponsor: vi.fn(),
}));
vi.mock("../JobDetailsEditDrawer", () => ({
JobDetailsEditDrawer: ({
open,
onOpenChange,
onJobUpdated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onJobUpdated: () => void | Promise<void>;
}) =>
open ? (
<div data-testid="job-details-edit-drawer">
<button
type="button"
onClick={() => {
void onJobUpdated();
onOpenChange(false);
}}
>
Save details
</button>
</div>
) : null,
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
@ -149,4 +174,28 @@ describe("DiscoveredPanel", () => {
expect(onJobUpdated).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Match recalculated");
});
it("opens edit details drawer from more actions", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
const job = createJob();
render(
<MemoryRouter>
<DiscoveredPanel
job={job}
onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()}
/>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("menuitem", { name: /edit details/i }));
expect(screen.getByTestId("job-details-edit-drawer")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
await waitFor(() => expect(onJobUpdated).toHaveBeenCalled());
expect(
screen.queryByTestId("job-details-edit-drawer"),
).not.toBeInTheDocument();
});
});

View File

@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import * as api from "../../api";
import { useRescoreJob } from "../../hooks/useRescoreJob";
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
import { DecideMode } from "./DecideMode";
import { EmptyState } from "./EmptyState";
import { ProcessingState } from "./ProcessingState";
@ -27,6 +28,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
const [mode, setMode] = useState<PanelMode>("decide");
const [isSkipping, setIsSkipping] = useState(false);
const [isFinalizing, setIsFinalizing] = useState(false);
const [isEditDetailsOpen, setIsEditDetailsOpen] = useState(false);
const previousJobIdRef = useRef<string | null>(null);
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
@ -37,6 +39,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
setMode("decide");
setIsSkipping(false);
setIsFinalizing(false);
setIsEditDetailsOpen(false);
onTailoringDirtyChange?.(false);
}, [job?.id, onTailoringDirtyChange]);
@ -108,6 +111,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
isSkipping={isSkipping}
onRescore={handleRescore}
isRescoring={isRescoring}
onEditDetails={() => setIsEditDetailsOpen(true)}
onCheckSponsor={async () => {
await api.checkSponsor(job.id);
await onJobUpdated();
@ -122,6 +126,13 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
onDirtyChange={onTailoringDirtyChange}
/>
)}
<JobDetailsEditDrawer
open={isEditDetailsOpen}
onOpenChange={setIsEditDetailsOpen}
job={job}
onJobUpdated={onJobUpdated}
/>
</div>
);
};

View File

@ -80,6 +80,34 @@ vi.mock("../../components/TailoringEditor", () => ({
),
}));
vi.mock("../../components/JobDetailsEditDrawer", () => ({
JobDetailsEditDrawer: ({
open,
onOpenChange,
onJobUpdated,
job,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onJobUpdated: () => Promise<void>;
job: Job | null;
}) =>
open ? (
<div data-testid="job-details-edit-drawer">
<div>{job?.id}</div>
<button
type="button"
onClick={() => {
void onJobUpdated();
onOpenChange(false);
}}
>
Save details
</button>
</div>
) : null,
}));
vi.mock("@/lib/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/utils")>();
return {
@ -253,6 +281,28 @@ describe("JobDetailPanel", () => {
expect(onJobUpdated).toHaveBeenCalled();
});
it("opens edit details drawer from menu and saves", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
await renderJobDetailPanel({
activeTab: "all",
activeJobs: [],
selectedJob: createJob({ jobDescription: "Original" }),
onSelectJobId: vi.fn(),
onJobUpdated,
});
fireEvent.click(screen.getByRole("menuitem", { name: /edit details/i }));
expect(screen.getByTestId("job-details-edit-drawer")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
await waitFor(() => expect(onJobUpdated).toHaveBeenCalled());
expect(
screen.queryByTestId("job-details-edit-drawer"),
).not.toBeInTheDocument();
});
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);

View File

@ -39,6 +39,7 @@ import {
JobHeader,
TailoredSummary,
} from "../../components";
import { JobDetailsEditDrawer } from "../../components/JobDetailsEditDrawer";
import { ReadyPanel } from "../../components/ReadyPanel";
import { TailoringEditor } from "../../components/TailoringEditor";
import { useProfile } from "../../hooks/useProfile";
@ -69,6 +70,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const [isSavingDescription, setIsSavingDescription] = useState(false);
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
const [isEditDetailsOpen, setIsEditDetailsOpen] = useState(false);
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
const previousSelectedJobIdRef = useRef<string | null>(null);
@ -106,10 +108,12 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) {
setIsEditingDescription(false);
setEditedDescription("");
setIsEditDetailsOpen(false);
return;
}
setIsEditingDescription(false);
setEditedDescription(selectedJob.jobDescription || "");
setIsEditDetailsOpen(false);
}, [selectedJob?.id, selectedJob]);
useEffect(() => {
@ -432,6 +436,10 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<Edit2 className="mr-2 h-4 w-4" />
Edit description
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsEditDetailsOpen(true)}>
<Edit2 className="mr-2 h-4 w-4" />
Edit details
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleCopyInfo()}>
<Copy className="mr-2 h-4 w-4" />
Copy info
@ -684,6 +692,13 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
</div>
</TabsContent>
</Tabs>
<JobDetailsEditDrawer
open={isEditDetailsOpen}
onOpenChange={setIsEditDetailsOpen}
job={selectedJob}
onJobUpdated={onJobUpdated}
/>
</div>
);
};

View File

@ -132,6 +132,93 @@ describe.sequential("Jobs API routes", () => {
expect(res.status).toBe(404);
});
it("updates core job detail fields", async () => {
const { createJob } = await import("../../repositories/jobs");
const job = await createJob({
source: "manual",
title: "Original Title",
employer: "Original Employer",
jobUrl: "https://example.com/job/core-fields",
jobDescription: "Original description",
});
const res = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Updated Title",
employer: "Updated Employer",
jobUrl: "https://example.com/job/core-fields-updated",
applicationLink: "https://example.com/apply/core-fields-updated",
location: "London, UK",
salary: "GBP 100k",
deadline: "2026-03-31",
jobDescription: "Updated description",
}),
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.ok).toBe(true);
expect(body.data.title).toBe("Updated Title");
expect(body.data.employer).toBe("Updated Employer");
expect(body.data.jobUrl).toBe(
"https://example.com/job/core-fields-updated",
);
expect(body.data.applicationLink).toBe(
"https://example.com/apply/core-fields-updated",
);
expect(body.data.location).toBe("London, UK");
expect(body.data.salary).toBe("GBP 100k");
expect(body.data.deadline).toBe("2026-03-31");
expect(body.data.jobDescription).toBe("Updated description");
expect(typeof body.meta.requestId).toBe("string");
});
it("returns 404 when patching a missing job", async () => {
const res = await fetch(`${baseUrl}/api/jobs/missing-id`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Updated Title" }),
});
const body = await res.json();
expect(res.status).toBe(404);
expect(body.ok).toBe(false);
expect(body.error.code).toBe("NOT_FOUND");
expect(typeof body.meta.requestId).toBe("string");
});
it("returns 409 when patching to a duplicate job URL", async () => {
const { createJob } = await import("../../repositories/jobs");
const first = await createJob({
source: "manual",
title: "First",
employer: "Acme",
jobUrl: "https://example.com/job/first",
jobDescription: "First description",
});
const second = await createJob({
source: "manual",
title: "Second",
employer: "Acme",
jobUrl: "https://example.com/job/second",
jobDescription: "Second description",
});
const res = await fetch(`${baseUrl}/api/jobs/${second.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobUrl: first.jobUrl }),
});
const body = await res.json();
expect(res.status).toBe(409);
expect(body.ok).toBe(false);
expect(body.error.code).toBe("CONFLICT");
expect(typeof body.meta.requestId).toBe("string");
});
it("validates job updates and supports skip/delete flow", async () => {
const { createJob } = await import("../../repositories/jobs");
const job = await createJob({
@ -147,7 +234,33 @@ describe.sequential("Jobs API routes", () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suitabilityScore: 1000 }),
});
const badBody = await badRes.json();
expect(badRes.status).toBe(400);
expect(badBody.ok).toBe(false);
expect(badBody.error.code).toBe("INVALID_REQUEST");
expect(typeof badBody.meta.requestId).toBe("string");
const invalidCoreRes = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ employer: " " }),
});
const invalidCoreBody = await invalidCoreRes.json();
expect(invalidCoreRes.status).toBe(400);
expect(invalidCoreBody.ok).toBe(false);
expect(invalidCoreBody.error.code).toBe("INVALID_REQUEST");
expect(typeof invalidCoreBody.meta.requestId).toBe("string");
const patchRes = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suitabilityScore: 77 }),
});
const patchBody = await patchRes.json();
expect(patchRes.status).toBe(200);
expect(patchBody.ok).toBe(true);
expect(patchBody.data.suitabilityScore).toBe(77);
expect(typeof patchBody.meta.requestId).toBe("string");
const skipRes = await fetch(`${baseUrl}/api/jobs/${job.id}/skip`, {
method: "POST",

View File

@ -16,7 +16,7 @@ import {
import { type Request, type Response, Router } from "express";
import { z } from "zod";
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
import { AppError, badRequest } from "../../infra/errors";
import { AppError, badRequest, conflict } from "../../infra/errors";
import {
generateFinalPdf,
processJob,
@ -107,6 +107,13 @@ async function notifyJobCompleteWebhook(job: Job) {
* PATCH /api/jobs/:id - Update a job
*/
const updateJobSchema = z.object({
title: z.string().trim().min(1).max(500).optional(),
employer: z.string().trim().min(1).max(500).optional(),
jobUrl: z.string().trim().min(1).max(2000).url().optional(),
applicationLink: z.string().trim().max(2000).url().nullable().optional(),
location: z.string().trim().max(200).nullable().optional(),
salary: z.string().trim().max(200).nullable().optional(),
deadline: z.string().trim().max(100).nullable().optional(),
status: z
.enum([
"discovered",
@ -119,7 +126,7 @@ const updateJobSchema = z.object({
.optional(),
outcome: z.enum(APPLICATION_OUTCOMES).nullable().optional(),
closedAt: z.number().int().nullable().optional(),
jobDescription: z.string().optional(),
jobDescription: z.string().trim().max(40000).nullable().optional(),
suitabilityScore: z.number().min(0).max(100).optional(),
suitabilityReason: z.string().optional(),
tailoredSummary: z.string().optional(),
@ -158,6 +165,11 @@ const updateJobSchema = z.object({
sponsorMatchNames: z.string().optional(),
});
function isJobUrlConflictError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return /UNIQUE constraint failed: jobs\.job_url/i.test(error.message);
}
const transitionStageSchema = z.object({
toStage: z.enum([...APPLICATION_STAGES, "no_change"]),
occurredAt: z.number().int().nullable().optional(),
@ -649,16 +661,54 @@ jobsRouter.patch("/:id", async (req: Request, res: Response) => {
const job = await jobsRepo.updateJob(req.params.id, input);
if (!job) {
return res.status(404).json({ success: false, error: "Job not found" });
const err = new AppError({
status: 404,
code: "NOT_FOUND",
message: "Job not found",
});
logger.warn("Job update failed", {
route: "PATCH /api/jobs/:id",
jobId: req.params.id,
status: err.status,
code: err.code,
});
return fail(res, err);
}
res.json({ success: true, data: job });
logger.info("Job updated", {
route: "PATCH /api/jobs/:id",
jobId: req.params.id,
updatedFields: Object.keys(input),
});
ok(res, 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 });
const err =
error instanceof z.ZodError
? badRequest(
error.issues[0]?.message ?? "Invalid job update request",
error.flatten(),
)
: isJobUrlConflictError(error)
? conflict("Another job already uses that job URL")
: error instanceof AppError
? error
: new AppError({
status: 500,
code: "INTERNAL_ERROR",
message:
error instanceof Error ? error.message : "Unknown error",
});
logger.error("Job update failed", {
route: "PATCH /api/jobs/:id",
jobId: req.params.id,
status: err.status,
code: err.code,
details: err.details,
});
fail(res, err);
}
});

View File

@ -295,10 +295,17 @@ export interface ManualJobFetchResponse {
}
export interface UpdateJobInput {
title?: string;
employer?: string;
jobUrl?: string;
applicationLink?: string | null;
location?: string | null;
salary?: string | null;
deadline?: string | null;
status?: JobStatus;
outcome?: JobOutcome | null;
closedAt?: number | null;
jobDescription?: string;
jobDescription?: string | null;
suitabilityScore?: number;
suitabilityReason?: string;
tailoredSummary?: string;