Tailor all ai fields (#106)
* initial commit * accordionising * merge extra cards * visual highlight * everybody becomes an accordion * bugbashing * text area * split up tailoring editor * feat: enhance `parseTailoredSkills` to support legacy string arrays and add comprehensive tests for parsing logic.
This commit is contained in:
parent
cf7032ce5e
commit
9b80c2e05d
@ -18,7 +18,7 @@ There are two main ways a job becomes Ready:
|
||||
1) **Manual flow (most common)**
|
||||
- A job starts in `discovered`.
|
||||
- You open it in the Discovered panel, decide to Tailor.
|
||||
- In Tailor mode you edit the job description (optional), summary, and project picks.
|
||||
- In Tailor mode you can edit job description (optional), tailored summary, tailored headline, tailored skills, and project picks.
|
||||
- You click **Finalize & Move to Ready**.
|
||||
- This runs summarization (if needed), generates the PDF, and sets status to `ready`.
|
||||
|
||||
@ -40,7 +40,7 @@ The PDF is generated from:
|
||||
|
||||
- The base resume selected from your v4.rxresu.me account (via Onboarding or Settings).
|
||||
- The job description (used for AI tailoring and project selection).
|
||||
- Your tailored summary/headline/skills and selected projects.
|
||||
- Your tailored summary, tailored headline, tailored skills, and selected projects.
|
||||
|
||||
Paths:
|
||||
|
||||
@ -71,6 +71,8 @@ PATCH /api/jobs/:id
|
||||
{
|
||||
"jobDescription": "<new JD>",
|
||||
"tailoredSummary": "<optional>",
|
||||
"tailoredHeadline": "<optional>",
|
||||
"tailoredSkills": "[{\"name\":\"Backend\",\"keywords\":[\"TypeScript\",\"Node.js\"]}]",
|
||||
"selectedProjectIds": "p1,p2"
|
||||
}
|
||||
```
|
||||
|
||||
@ -22,11 +22,22 @@ const createJob = (overrides: Partial<Job> = {}): Job =>
|
||||
({
|
||||
id: "job-1",
|
||||
tailoredSummary: "Saved summary",
|
||||
tailoredHeadline: "Saved headline",
|
||||
tailoredSkills: JSON.stringify([
|
||||
{ name: "Core", keywords: ["React", "TypeScript"] },
|
||||
]),
|
||||
jobDescription: "Saved description",
|
||||
selectedProjectIds: "p1",
|
||||
...overrides,
|
||||
}) as Job;
|
||||
|
||||
const ensureAccordionOpen = (name: string) => {
|
||||
const trigger = screen.getByRole("button", { name });
|
||||
if (trigger.getAttribute("aria-expanded") !== "true") {
|
||||
fireEvent.click(trigger);
|
||||
}
|
||||
};
|
||||
|
||||
describe("TailoringEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@ -39,6 +50,7 @@ describe("TailoringEditor", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
|
||||
target: { value: "Local draft" },
|
||||
@ -50,6 +62,7 @@ describe("TailoringEditor", () => {
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Local draft",
|
||||
@ -63,6 +76,7 @@ describe("TailoringEditor", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
|
||||
target: { value: "Local draft" },
|
||||
@ -73,16 +87,28 @@ describe("TailoringEditor", () => {
|
||||
job={createJob({
|
||||
id: "job-2",
|
||||
tailoredSummary: "New job summary",
|
||||
tailoredHeadline: "New job headline",
|
||||
tailoredSkills: JSON.stringify([
|
||||
{ name: "Backend", keywords: ["Node.js", "Postgres"] },
|
||||
]),
|
||||
jobDescription: "New job description",
|
||||
selectedProjectIds: "",
|
||||
})}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
ensureAccordionOpen("Headline");
|
||||
ensureAccordionOpen("Tailored Skills");
|
||||
ensureAccordionOpen("Backend");
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"New job summary",
|
||||
);
|
||||
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
|
||||
"New job headline",
|
||||
);
|
||||
expect(screen.getByDisplayValue("Node.js, Postgres")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("emits dirty state changes", async () => {
|
||||
@ -97,6 +123,7 @@ describe("TailoringEditor", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
|
||||
target: { value: "Local draft" },
|
||||
@ -112,6 +139,7 @@ describe("TailoringEditor", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
const summary = screen.getByLabelText("Tailored Summary");
|
||||
fireEvent.focus(summary);
|
||||
@ -122,9 +150,92 @@ describe("TailoringEditor", () => {
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Saved summary",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not clobber local headline edits from same-job prop updates", async () => {
|
||||
const { rerender } = render(
|
||||
<TailoringEditor job={createJob()} onUpdate={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Headline");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Headline"), {
|
||||
target: { value: "Local headline draft" },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<TailoringEditor
|
||||
job={createJob({ tailoredHeadline: "Incoming headline from poll" })}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
ensureAccordionOpen("Headline");
|
||||
|
||||
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
|
||||
"Local headline draft",
|
||||
);
|
||||
});
|
||||
|
||||
it("saves headline and skills in update payload", async () => {
|
||||
render(<TailoringEditor job={createJob()} onUpdate={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Headline");
|
||||
ensureAccordionOpen("Tailored Skills");
|
||||
ensureAccordionOpen("Core");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Headline"), {
|
||||
target: { value: "Updated headline" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Keywords (comma-separated)"), {
|
||||
target: { value: "Node.js, TypeScript" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Selection" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.updateJob).toHaveBeenCalledWith(
|
||||
"job-1",
|
||||
expect.objectContaining({
|
||||
tailoredHeadline: "Updated headline",
|
||||
tailoredSkills:
|
||||
'[{"name":"Core","keywords":["Node.js","TypeScript"]}]',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("hydrates headline and skills after AI summarize", async () => {
|
||||
vi.mocked(api.summarizeJob).mockResolvedValueOnce({
|
||||
...createJob(),
|
||||
tailoredSummary: "AI summary",
|
||||
tailoredHeadline: "AI headline",
|
||||
tailoredSkills: JSON.stringify([
|
||||
{ name: "Backend", keywords: ["Node.js", "Kafka"] },
|
||||
]),
|
||||
} as Job);
|
||||
|
||||
render(<TailoringEditor job={createJob()} onUpdate={vi.fn()} />);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "AI Summarize" }));
|
||||
|
||||
await waitFor(() => ensureAccordionOpen("Headline"));
|
||||
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
|
||||
"AI headline",
|
||||
);
|
||||
ensureAccordionOpen("Tailored Skills");
|
||||
ensureAccordionOpen("Backend");
|
||||
expect(screen.getByDisplayValue("Backend")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,18 +1,6 @@
|
||||
import type { Job, ResumeProjectCatalogItem } from "@shared/types.js";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
FileText,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import * as api from "../api";
|
||||
import { TailoringWorkspace } from "./tailoring/TailoringWorkspace";
|
||||
|
||||
interface TailoringEditorProps {
|
||||
job: Job;
|
||||
@ -22,17 +10,6 @@ interface TailoringEditorProps {
|
||||
onBeforeGenerate?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
const parseSelectedIds = (value: string | null | undefined) =>
|
||||
new Set(value?.split(",").filter(Boolean) ?? []);
|
||||
|
||||
const hasSelectionDiff = (current: Set<string>, saved: Set<string>) => {
|
||||
if (current.size !== saved.size) return true;
|
||||
for (const id of current) {
|
||||
if (!saved.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
job,
|
||||
onUpdate,
|
||||
@ -40,327 +17,14 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
onRegisterSave,
|
||||
onBeforeGenerate,
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
const [jobDescription, setJobDescription] = useState(
|
||||
job.jobDescription || "",
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
|
||||
parseSelectedIds(job.selectedProjectIds),
|
||||
);
|
||||
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
|
||||
const [savedDescription, setSavedDescription] = useState(
|
||||
job.jobDescription || "",
|
||||
);
|
||||
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
|
||||
parseSelectedIds(job.selectedProjectIds),
|
||||
);
|
||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeField, setActiveField] = useState<
|
||||
"summary" | "description" | null
|
||||
>(null);
|
||||
const lastJobIdRef = useRef(job.id);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (summary !== savedSummary) return true;
|
||||
if (jobDescription !== savedDescription) return true;
|
||||
return hasSelectionDiff(selectedIds, savedSelectedIds);
|
||||
}, [
|
||||
summary,
|
||||
savedSummary,
|
||||
jobDescription,
|
||||
savedDescription,
|
||||
selectedIds,
|
||||
savedSelectedIds,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onDirtyChange?.(false);
|
||||
}, [onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const incomingSummary = job.tailoredSummary || "";
|
||||
const incomingDescription = job.jobDescription || "";
|
||||
const incomingSelectedIds = parseSelectedIds(job.selectedProjectIds);
|
||||
|
||||
if (job.id !== lastJobIdRef.current) {
|
||||
lastJobIdRef.current = job.id;
|
||||
setSummary(incomingSummary);
|
||||
setJobDescription(incomingDescription);
|
||||
setSelectedIds(incomingSelectedIds);
|
||||
setSavedSummary(incomingSummary);
|
||||
setSavedDescription(incomingDescription);
|
||||
setSavedSelectedIds(incomingSelectedIds);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDirty || activeField !== null) return;
|
||||
|
||||
setSummary(incomingSummary);
|
||||
setJobDescription(incomingDescription);
|
||||
setSelectedIds(incomingSelectedIds);
|
||||
setSavedSummary(incomingSummary);
|
||||
setSavedDescription(incomingDescription);
|
||||
setSavedSelectedIds(incomingSelectedIds);
|
||||
}, [
|
||||
job.id,
|
||||
job.tailoredSummary,
|
||||
job.jobDescription,
|
||||
job.selectedProjectIds,
|
||||
isDirty,
|
||||
activeField,
|
||||
]);
|
||||
|
||||
const syncSavedSnapshot = useCallback(
|
||||
(
|
||||
nextSummary: string,
|
||||
nextDescription: string,
|
||||
nextSelectedIds: Set<string>,
|
||||
) => {
|
||||
setSavedSummary(nextSummary);
|
||||
setSavedDescription(nextDescription);
|
||||
setSavedSelectedIds(new Set(nextSelectedIds));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedIdsCsv = useMemo(
|
||||
() => Array.from(selectedIds).join(","),
|
||||
[selectedIds],
|
||||
);
|
||||
|
||||
const saveChanges = useCallback(
|
||||
async ({ showToast = true }: { showToast?: boolean } = {}) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription,
|
||||
selectedProjectIds: selectedIdsCsv,
|
||||
});
|
||||
syncSavedSnapshot(summary, jobDescription, selectedIds);
|
||||
if (showToast) toast.success("Changes saved");
|
||||
await onUpdate();
|
||||
} catch (error) {
|
||||
if (showToast) toast.error("Failed to save changes");
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
job.id,
|
||||
onUpdate,
|
||||
selectedIdsCsv,
|
||||
selectedIds,
|
||||
summary,
|
||||
jobDescription,
|
||||
syncSavedSnapshot,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterSave?.(() => saveChanges({ showToast: false }));
|
||||
}, [onRegisterSave, saveChanges]);
|
||||
|
||||
const handleToggleProject = (id: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveChanges();
|
||||
} catch {
|
||||
// Toast handled in saveChanges
|
||||
}
|
||||
};
|
||||
|
||||
const handleSummarize = async () => {
|
||||
try {
|
||||
setIsSummarizing(true);
|
||||
if (isDirty) {
|
||||
await saveChanges({ showToast: false });
|
||||
}
|
||||
const updatedJob = await api.summarizeJob(job.id, { force: true });
|
||||
const nextSummary = updatedJob.tailoredSummary || "";
|
||||
const nextDescription = updatedJob.jobDescription || "";
|
||||
const nextSelectedIds = parseSelectedIds(updatedJob.selectedProjectIds);
|
||||
setSummary(nextSummary);
|
||||
setJobDescription(nextDescription);
|
||||
setSelectedIds(nextSelectedIds);
|
||||
syncSavedSnapshot(nextSummary, nextDescription, nextSelectedIds);
|
||||
toast.success("AI Summary & Projects generated");
|
||||
await onUpdate();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "AI summarization failed";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSummarizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeneratePdf = async () => {
|
||||
try {
|
||||
const shouldProceed = onBeforeGenerate ? await onBeforeGenerate() : true;
|
||||
if (shouldProceed === false) return;
|
||||
|
||||
setIsGeneratingPdf(true);
|
||||
await saveChanges({ showToast: false });
|
||||
|
||||
await api.generateJobPdf(job.id);
|
||||
toast.success("Resume PDF generated");
|
||||
await onUpdate();
|
||||
} catch (_error) {
|
||||
toast.error("PDF generation failed");
|
||||
} finally {
|
||||
setIsGeneratingPdf(false);
|
||||
}
|
||||
};
|
||||
|
||||
const maxProjects = 3;
|
||||
const tooManyProjects = selectedIds.size > maxProjects;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Editor</h3>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSummarize}
|
||||
disabled={isSummarizing || isGeneratingPdf || isSaving}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSummarizing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
AI Summarize
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGeneratePdf}
|
||||
disabled={isSummarizing || isGeneratingPdf || isSaving || !summary}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isGeneratingPdf ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Generate PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="tailor-jd" className="text-sm font-medium">
|
||||
Job Description (Edit to help AI tailoring)
|
||||
</label>
|
||||
<textarea
|
||||
id="tailor-jd"
|
||||
className="w-full min-h-[120px] max-h-[250px] 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={jobDescription}
|
||||
onChange={(e) => setJobDescription(e.target.value)}
|
||||
onFocus={() => setActiveField("description")}
|
||||
onBlur={() =>
|
||||
setActiveField((prev) => (prev === "description" ? null : prev))
|
||||
}
|
||||
placeholder="The raw job description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="tailor-summary" className="text-sm font-medium">
|
||||
Tailored Summary
|
||||
</label>
|
||||
<textarea
|
||||
id="tailor-summary"
|
||||
className="w-full min-h-[120px] 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={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
onFocus={() => setActiveField("summary")}
|
||||
onBlur={() =>
|
||||
setActiveField((prev) => (prev === "summary" ? null : prev))
|
||||
}
|
||||
placeholder="AI will generate this, or you can write your own..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
|
||||
<span className="text-sm font-medium">Selected Projects</span>
|
||||
{tooManyProjects && (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Warning: More than {maxProjects} projects might make the resume
|
||||
too long.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2 max-h-[300px] overflow-auto pr-2">
|
||||
{catalog.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-start gap-3 rounded-lg border p-3 text-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`project-${project.id}`}
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() => handleToggleProject(project.id)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`project-${project.id}`}
|
||||
className="flex flex-1 flex-col gap-1 cursor-pointer"
|
||||
>
|
||||
<span className="font-semibold">{project.name}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{project.description}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Selection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TailoringWorkspace
|
||||
mode="editor"
|
||||
job={job}
|
||||
onUpdate={onUpdate}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onRegisterSave={onRegisterSave}
|
||||
onBeforeGenerate={onBeforeGenerate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -21,11 +21,22 @@ const createJob = (overrides: Partial<Job> = {}): Job =>
|
||||
({
|
||||
id: "job-1",
|
||||
tailoredSummary: "Saved summary",
|
||||
tailoredHeadline: "Saved headline",
|
||||
tailoredSkills: JSON.stringify([
|
||||
{ name: "Core", keywords: ["React", "TypeScript"] },
|
||||
]),
|
||||
jobDescription: "Saved description",
|
||||
selectedProjectIds: "p1",
|
||||
...overrides,
|
||||
}) as Job;
|
||||
|
||||
const ensureAccordionOpen = (name: string) => {
|
||||
const trigger = screen.getByRole("button", { name });
|
||||
if (trigger.getAttribute("aria-expanded") !== "true") {
|
||||
fireEvent.click(trigger);
|
||||
}
|
||||
};
|
||||
|
||||
describe("TailorMode", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@ -43,6 +54,7 @@ describe("TailorMode", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
|
||||
target: { value: "Local draft" },
|
||||
@ -56,6 +68,7 @@ describe("TailorMode", () => {
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Local draft",
|
||||
@ -74,6 +87,7 @@ describe("TailorMode", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
|
||||
target: { value: "Local draft" },
|
||||
@ -84,6 +98,10 @@ describe("TailorMode", () => {
|
||||
job={createJob({
|
||||
id: "job-2",
|
||||
tailoredSummary: "New job summary",
|
||||
tailoredHeadline: "New job headline",
|
||||
tailoredSkills: JSON.stringify([
|
||||
{ name: "Backend", keywords: ["Node.js", "Postgres"] },
|
||||
]),
|
||||
jobDescription: "New job description",
|
||||
selectedProjectIds: "",
|
||||
})}
|
||||
@ -92,10 +110,18 @@ describe("TailorMode", () => {
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
ensureAccordionOpen("Headline");
|
||||
ensureAccordionOpen("Tailored Skills");
|
||||
ensureAccordionOpen("Backend");
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"New job summary",
|
||||
);
|
||||
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
|
||||
"New job headline",
|
||||
);
|
||||
expect(screen.getByDisplayValue("Node.js, Postgres")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not sync same-job props while summary field is focused", async () => {
|
||||
@ -110,6 +136,7 @@ describe("TailorMode", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
const summary = screen.getByLabelText("Tailored Summary");
|
||||
fireEvent.focus(summary);
|
||||
@ -122,9 +149,116 @@ describe("TailorMode", () => {
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
ensureAccordionOpen("Summary");
|
||||
|
||||
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
|
||||
"Saved summary",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not clobber local headline edits from same-job prop updates", async () => {
|
||||
const { rerender } = render(
|
||||
<TailorMode
|
||||
job={createJob()}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Headline");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Headline"), {
|
||||
target: { value: "Local headline draft" },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<TailorMode
|
||||
job={createJob({ tailoredHeadline: "Incoming headline from poll" })}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
ensureAccordionOpen("Headline");
|
||||
|
||||
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
|
||||
"Local headline draft",
|
||||
);
|
||||
});
|
||||
|
||||
it("autosaves headline and skills in update payload", async () => {
|
||||
vi.mocked(api.updateJob).mockResolvedValue({} as Job);
|
||||
|
||||
render(
|
||||
<TailorMode
|
||||
job={createJob()}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
ensureAccordionOpen("Headline");
|
||||
ensureAccordionOpen("Tailored Skills");
|
||||
ensureAccordionOpen("Core");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Tailored Headline"), {
|
||||
target: { value: "Updated headline" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Keywords (comma-separated)"), {
|
||||
target: { value: "Node.js, TypeScript" },
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(api.updateJob).toHaveBeenCalledWith(
|
||||
"job-1",
|
||||
expect.objectContaining({
|
||||
tailoredHeadline: "Updated headline",
|
||||
tailoredSkills:
|
||||
'[{"name":"Core","keywords":["Node.js","TypeScript"]}]',
|
||||
}),
|
||||
),
|
||||
{ timeout: 3500 },
|
||||
);
|
||||
});
|
||||
|
||||
it("hydrates headline and skills after AI draft generation", async () => {
|
||||
vi.mocked(api.summarizeJob).mockResolvedValueOnce({
|
||||
...createJob(),
|
||||
tailoredSummary: "AI summary",
|
||||
tailoredHeadline: "AI headline",
|
||||
tailoredSkills: JSON.stringify([
|
||||
{ name: "Backend", keywords: ["Node.js", "Kafka"] },
|
||||
]),
|
||||
} as Job);
|
||||
|
||||
render(
|
||||
<TailorMode
|
||||
job={createJob()}
|
||||
onBack={vi.fn()}
|
||||
onFinalize={vi.fn()}
|
||||
isFinalizing={false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Generate draft" }));
|
||||
|
||||
await waitFor(() => ensureAccordionOpen("Headline"));
|
||||
expect(screen.getByLabelText("Tailored Headline")).toHaveValue(
|
||||
"AI headline",
|
||||
);
|
||||
ensureAccordionOpen("Tailored Skills");
|
||||
ensureAccordionOpen("Backend");
|
||||
expect(screen.getByDisplayValue("Backend")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,6 @@
|
||||
import type { Job, ResumeProjectCatalogItem } from "@shared/types.js";
|
||||
import { ArrowLeft, Check, Loader2, Sparkles } from "lucide-react";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import * as api from "../../api";
|
||||
import { CollapsibleSection } from "./CollapsibleSection";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
import { TailoringWorkspace } from "../tailoring/TailoringWorkspace";
|
||||
|
||||
interface TailorModeProps {
|
||||
job: Job;
|
||||
@ -15,21 +8,9 @@ interface TailorModeProps {
|
||||
onFinalize: () => void;
|
||||
isFinalizing: boolean;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
/** Variant controls the finalize button text. Default is 'discovered'. */
|
||||
variant?: "discovered" | "ready";
|
||||
}
|
||||
|
||||
const parseSelectedIds = (value: string | null | undefined) =>
|
||||
new Set(value?.split(",").filter(Boolean) ?? []);
|
||||
|
||||
const hasSelectionDiff = (current: Set<string>, saved: Set<string>) => {
|
||||
if (current.size !== saved.size) return true;
|
||||
for (const id of current) {
|
||||
if (!saved.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
job,
|
||||
onBack,
|
||||
@ -38,377 +19,15 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
onDirtyChange,
|
||||
variant = "discovered",
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
const [jobDescription, setJobDescription] = useState(
|
||||
job.jobDescription || "",
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
|
||||
parseSelectedIds(job.selectedProjectIds),
|
||||
);
|
||||
|
||||
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
|
||||
const [savedDescription, setSavedDescription] = useState(
|
||||
job.jobDescription || "",
|
||||
);
|
||||
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
|
||||
parseSelectedIds(job.selectedProjectIds),
|
||||
);
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [draftStatus, setDraftStatus] = useState<
|
||||
"unsaved" | "saving" | "saved"
|
||||
>("saved");
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
const [activeField, setActiveField] = useState<
|
||||
"summary" | "description" | null
|
||||
>(null);
|
||||
const lastJobIdRef = useRef(job.id);
|
||||
|
||||
useEffect(() => {
|
||||
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (summary !== savedSummary) return true;
|
||||
if (jobDescription !== savedDescription) return true;
|
||||
return hasSelectionDiff(selectedIds, savedSelectedIds);
|
||||
}, [
|
||||
summary,
|
||||
savedSummary,
|
||||
jobDescription,
|
||||
savedDescription,
|
||||
selectedIds,
|
||||
savedSelectedIds,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onDirtyChange?.(false);
|
||||
}, [onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const incomingSummary = job.tailoredSummary || "";
|
||||
const incomingDescription = job.jobDescription || "";
|
||||
const incomingSelectedIds = parseSelectedIds(job.selectedProjectIds);
|
||||
|
||||
if (job.id !== lastJobIdRef.current) {
|
||||
lastJobIdRef.current = job.id;
|
||||
setSummary(incomingSummary);
|
||||
setJobDescription(incomingDescription);
|
||||
setSelectedIds(incomingSelectedIds);
|
||||
setSavedSummary(incomingSummary);
|
||||
setSavedDescription(incomingDescription);
|
||||
setSavedSelectedIds(incomingSelectedIds);
|
||||
setDraftStatus("saved");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDirty || activeField !== null) return;
|
||||
|
||||
setSummary(incomingSummary);
|
||||
setJobDescription(incomingDescription);
|
||||
setSelectedIds(incomingSelectedIds);
|
||||
setSavedSummary(incomingSummary);
|
||||
setSavedDescription(incomingDescription);
|
||||
setSavedSelectedIds(incomingSelectedIds);
|
||||
setDraftStatus("saved");
|
||||
}, [
|
||||
job.id,
|
||||
job.tailoredSummary,
|
||||
job.jobDescription,
|
||||
job.selectedProjectIds,
|
||||
isDirty,
|
||||
activeField,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDirty && draftStatus === "saved") {
|
||||
setDraftStatus("unsaved");
|
||||
}
|
||||
if (!isDirty && draftStatus === "unsaved") {
|
||||
setDraftStatus("saved");
|
||||
}
|
||||
}, [isDirty, draftStatus]);
|
||||
|
||||
const selectedIdsCsv = useMemo(
|
||||
() => Array.from(selectedIds).join(","),
|
||||
[selectedIds],
|
||||
);
|
||||
|
||||
const syncSavedSnapshot = useCallback(
|
||||
(
|
||||
nextSummary: string,
|
||||
nextDescription: string,
|
||||
nextSelectedIds: Set<string>,
|
||||
) => {
|
||||
setSavedSummary(nextSummary);
|
||||
setSavedDescription(nextDescription);
|
||||
setSavedSelectedIds(new Set(nextSelectedIds));
|
||||
setDraftStatus("saved");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const persistCurrent = useCallback(async () => {
|
||||
await api.updateJob(job.id, {
|
||||
tailoredSummary: summary,
|
||||
jobDescription,
|
||||
selectedProjectIds: selectedIdsCsv,
|
||||
});
|
||||
syncSavedSnapshot(summary, jobDescription, selectedIds);
|
||||
}, [
|
||||
job.id,
|
||||
summary,
|
||||
jobDescription,
|
||||
selectedIdsCsv,
|
||||
selectedIds,
|
||||
syncSavedSnapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty || draftStatus !== "unsaved") return;
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
setDraftStatus("saving");
|
||||
await persistCurrent();
|
||||
} catch {
|
||||
setDraftStatus("unsaved");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isDirty, draftStatus, persistCurrent]);
|
||||
|
||||
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 (isDirty) {
|
||||
await persistCurrent();
|
||||
}
|
||||
|
||||
const updatedJob = await api.summarizeJob(job.id, { force: true });
|
||||
const nextSummary = updatedJob.tailoredSummary || "";
|
||||
const nextDescription = updatedJob.jobDescription || "";
|
||||
const nextSelectedIds = parseSelectedIds(updatedJob.selectedProjectIds);
|
||||
setSummary(nextSummary);
|
||||
setJobDescription(nextDescription);
|
||||
setSelectedIds(nextSelectedIds);
|
||||
syncSavedSnapshot(nextSummary, nextDescription, nextSelectedIds);
|
||||
toast.success("Draft generated with AI", {
|
||||
description: "Review and edit before finalizing.",
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to generate AI draft";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (isDirty) {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await persistCurrent();
|
||||
} 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" && !isDirty && (
|
||||
<>
|
||||
<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
|
||||
htmlFor="tailor-jd-edit"
|
||||
className="text-[10px] font-medium text-muted-foreground/70"
|
||||
>
|
||||
Edit to help AI tailoring
|
||||
</label>
|
||||
<textarea
|
||||
id="tailor-jd-edit"
|
||||
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)}
|
||||
onFocus={() => setActiveField("description")}
|
||||
onBlur={() =>
|
||||
setActiveField((prev) => (prev === "description" ? null : prev))
|
||||
}
|
||||
placeholder="The raw job description..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="tailor-summary-edit"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Tailored Summary
|
||||
</label>
|
||||
<textarea
|
||||
id="tailor-summary-edit"
|
||||
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)}
|
||||
onFocus={() => setActiveField("summary")}
|
||||
onBlur={() =>
|
||||
setActiveField((prev) => (prev === "summary" ? null : prev))
|
||||
}
|
||||
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{" "}
|
||||
{variant === "ready" ? "regenerate" : "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" />
|
||||
{variant === "ready"
|
||||
? "Regenerating PDF..."
|
||||
: "Finalizing & generating PDF..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{variant === "ready"
|
||||
? "Regenerate PDF"
|
||||
: "Finalize & Move to Ready"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-[10px] text-center text-muted-foreground/70">
|
||||
{variant === "ready"
|
||||
? "This will save your changes and regenerate the tailored PDF."
|
||||
: "This will generate your tailored PDF and move the job to Ready."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TailoringWorkspace
|
||||
mode="tailor"
|
||||
job={job}
|
||||
onBack={onBack}
|
||||
onFinalize={onFinalize}
|
||||
isFinalizing={isFinalizing}
|
||||
onDirtyChange={onDirtyChange}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
47
orchestrator/src/client/components/tailoring-utils.test.ts
Normal file
47
orchestrator/src/client/components/tailoring-utils.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTailoredSkills } from "./tailoring-utils";
|
||||
|
||||
describe("parseTailoredSkills", () => {
|
||||
it("parses object-based tailored skills payload", () => {
|
||||
const parsed = parseTailoredSkills(
|
||||
JSON.stringify([
|
||||
{ name: "Backend", keywords: ["Node.js", " TypeScript "] },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(parsed).toEqual([
|
||||
{ name: "Backend", keywords: ["Node.js", "TypeScript"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps legacy string arrays into a default skills group", () => {
|
||||
const parsed = parseTailoredSkills(
|
||||
JSON.stringify(["React", " TypeScript ", "", "Vitest"]),
|
||||
);
|
||||
|
||||
expect(parsed).toEqual([
|
||||
{ name: "Skills", keywords: ["React", "TypeScript", "Vitest"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps object groups and legacy string values in mixed arrays", () => {
|
||||
const parsed = parseTailoredSkills(
|
||||
JSON.stringify([
|
||||
{ name: "Platform", keywords: ["APIs"] },
|
||||
"Observability",
|
||||
]),
|
||||
);
|
||||
|
||||
expect(parsed).toEqual([
|
||||
{ name: "Platform", keywords: ["APIs"] },
|
||||
{ name: "Skills", keywords: ["Observability"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list for invalid or non-array JSON", () => {
|
||||
expect(parseTailoredSkills("{")).toEqual([]);
|
||||
expect(parseTailoredSkills(JSON.stringify({ name: "Backend" }))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
95
orchestrator/src/client/components/tailoring-utils.ts
Normal file
95
orchestrator/src/client/components/tailoring-utils.ts
Normal file
@ -0,0 +1,95 @@
|
||||
export interface TailoredSkillGroup {
|
||||
name: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface EditableSkillGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
keywordsText: string;
|
||||
}
|
||||
|
||||
let skillDraftCounter = 0;
|
||||
|
||||
export function createTailoredSkillDraftId(): string {
|
||||
skillDraftCounter += 1;
|
||||
return `skill-group-${skillDraftCounter}`;
|
||||
}
|
||||
|
||||
export function parseTailoredSkills(
|
||||
raw: string | null | undefined,
|
||||
): TailoredSkillGroup[] {
|
||||
if (!raw || raw.trim().length === 0) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const groups: TailoredSkillGroup[] = [];
|
||||
const legacyKeywords: string[] = [];
|
||||
for (const item of parsed) {
|
||||
if (typeof item === "string") {
|
||||
const keyword = item.trim();
|
||||
if (keyword.length > 0) legacyKeywords.push(keyword);
|
||||
continue;
|
||||
}
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
const name = typeof record.name === "string" ? record.name.trim() : "";
|
||||
const keywordsRaw = Array.isArray(record.keywords)
|
||||
? record.keywords
|
||||
: typeof record.keywords === "string"
|
||||
? record.keywords.split(",")
|
||||
: [];
|
||||
const keywords = keywordsRaw
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!name && keywords.length === 0) continue;
|
||||
groups.push({ name, keywords });
|
||||
}
|
||||
|
||||
if (legacyKeywords.length > 0) {
|
||||
groups.push({ name: "Skills", keywords: legacyKeywords });
|
||||
}
|
||||
|
||||
return groups;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeTailoredSkills(groups: TailoredSkillGroup[]): string {
|
||||
if (groups.length === 0) return "";
|
||||
return JSON.stringify(groups);
|
||||
}
|
||||
|
||||
export function toEditableSkillGroups(
|
||||
groups: TailoredSkillGroup[],
|
||||
): EditableSkillGroup[] {
|
||||
return groups.map((group) => ({
|
||||
id: createTailoredSkillDraftId(),
|
||||
name: group.name,
|
||||
keywordsText: group.keywords.join(", "),
|
||||
}));
|
||||
}
|
||||
|
||||
export function fromEditableSkillGroups(
|
||||
groups: EditableSkillGroup[],
|
||||
): TailoredSkillGroup[] {
|
||||
const normalized: TailoredSkillGroup[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const name = group.name.trim();
|
||||
const keywords = group.keywordsText
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!name && keywords.length === 0) continue;
|
||||
normalized.push({ name, keywords });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
import type { ResumeProjectCatalogItem } from "@shared/types.js";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProjectSelector } from "../discovered-panel/ProjectSelector";
|
||||
import type { EditableSkillGroup } from "../tailoring-utils";
|
||||
import type { TailoringActiveField } from "./useTailoringDraft";
|
||||
|
||||
type FocusableField = Exclude<TailoringActiveField, null>;
|
||||
|
||||
interface TailoringSectionsProps {
|
||||
catalog: ResumeProjectCatalogItem[];
|
||||
summary: string;
|
||||
headline: string;
|
||||
jobDescription: string;
|
||||
skillsDraft: EditableSkillGroup[];
|
||||
selectedIds: Set<string>;
|
||||
openSkillGroupId: string;
|
||||
disableInputs: boolean;
|
||||
onSummaryChange: (value: string) => void;
|
||||
onHeadlineChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onSkillGroupOpenChange: (value: string) => void;
|
||||
onAddSkillGroup: () => void;
|
||||
onUpdateSkillGroup: (
|
||||
id: string,
|
||||
key: "name" | "keywordsText",
|
||||
value: string,
|
||||
) => void;
|
||||
onRemoveSkillGroup: (id: string) => void;
|
||||
onToggleProject: (id: string) => void;
|
||||
onFieldFocus: (field: FocusableField) => void;
|
||||
onFieldBlur: (field: FocusableField) => void;
|
||||
}
|
||||
|
||||
const sectionClass = "rounded-lg border border-border/60 bg-muted/20 px-0";
|
||||
const triggerClass =
|
||||
"px-3 py-2 text-xs font-medium text-muted-foreground hover:no-underline";
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border/60 bg-background/60 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
export const TailoringSections: React.FC<TailoringSectionsProps> = ({
|
||||
catalog,
|
||||
summary,
|
||||
headline,
|
||||
jobDescription,
|
||||
skillsDraft,
|
||||
selectedIds,
|
||||
openSkillGroupId,
|
||||
disableInputs,
|
||||
onSummaryChange,
|
||||
onHeadlineChange,
|
||||
onDescriptionChange,
|
||||
onSkillGroupOpenChange,
|
||||
onAddSkillGroup,
|
||||
onUpdateSkillGroup,
|
||||
onRemoveSkillGroup,
|
||||
onToggleProject,
|
||||
onFieldFocus,
|
||||
onFieldBlur,
|
||||
}) => {
|
||||
return (
|
||||
<Accordion type="multiple" className="space-y-3">
|
||||
<AccordionItem value="job-description" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>
|
||||
Job Description
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<label htmlFor="tailor-jd-edit" className="sr-only">
|
||||
Job Description
|
||||
</label>
|
||||
<textarea
|
||||
id="tailor-jd-edit"
|
||||
className={`${inputClass} min-h-[120px] max-h-[250px]`}
|
||||
value={jobDescription}
|
||||
onChange={(event) => onDescriptionChange(event.target.value)}
|
||||
onFocus={() => onFieldFocus("description")}
|
||||
onBlur={() => onFieldBlur("description")}
|
||||
placeholder="The raw job description..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="summary" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>Summary</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<label htmlFor="tailor-summary-edit" className="sr-only">
|
||||
Tailored Summary
|
||||
</label>
|
||||
<textarea
|
||||
id="tailor-summary-edit"
|
||||
className={`${inputClass} min-h-[120px]`}
|
||||
value={summary}
|
||||
onChange={(event) => onSummaryChange(event.target.value)}
|
||||
onFocus={() => onFieldFocus("summary")}
|
||||
onBlur={() => onFieldBlur("summary")}
|
||||
placeholder="Write a tailored summary for this role, or generate with AI..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="headline" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>Headline</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<label htmlFor="tailor-headline-edit" className="sr-only">
|
||||
Tailored Headline
|
||||
</label>
|
||||
<input
|
||||
id="tailor-headline-edit"
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={headline}
|
||||
onChange={(event) => onHeadlineChange(event.target.value)}
|
||||
onFocus={() => onFieldFocus("headline")}
|
||||
onBlur={() => onFieldBlur("headline")}
|
||||
placeholder="Write a concise headline tailored to this role..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="skills" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>
|
||||
Tailored Skills
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pb-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-[11px]"
|
||||
onClick={onAddSkillGroup}
|
||||
disabled={disableInputs}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add Skill Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{skillsDraft.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 px-3 py-4 text-center text-[11px] text-muted-foreground">
|
||||
No skill groups yet. Add one to tailor keywords for this role.
|
||||
</div>
|
||||
) : (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={openSkillGroupId}
|
||||
onValueChange={onSkillGroupOpenChange}
|
||||
className="space-y-2"
|
||||
>
|
||||
{skillsDraft.map((group, index) => (
|
||||
<AccordionItem
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
className="rounded-lg border border-border/60 bg-background/40 px-0"
|
||||
>
|
||||
<AccordionTrigger className="px-3 py-2 text-[11px] font-medium hover:no-underline">
|
||||
{group.name.trim() || `Skill Group ${index + 1}`}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={`tailor-skill-group-name-${group.id}`}
|
||||
className="text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
id={`tailor-skill-group-name-${group.id}`}
|
||||
type="text"
|
||||
className={inputClass}
|
||||
value={group.name}
|
||||
onChange={(event) =>
|
||||
onUpdateSkillGroup(
|
||||
group.id,
|
||||
"name",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
onFocus={() => onFieldFocus("skills")}
|
||||
onBlur={() => onFieldBlur("skills")}
|
||||
placeholder="Backend, Frontend, Infrastructure..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={`tailor-skill-group-keywords-${group.id}`}
|
||||
className="text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
Keywords (comma-separated)
|
||||
</label>
|
||||
<textarea
|
||||
id={`tailor-skill-group-keywords-${group.id}`}
|
||||
className={`${inputClass} min-h-[88px]`}
|
||||
value={group.keywordsText}
|
||||
onChange={(event) =>
|
||||
onUpdateSkillGroup(
|
||||
group.id,
|
||||
"keywordsText",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
onFocus={() => onFieldFocus("skills")}
|
||||
onBlur={() => onFieldBlur("skills")}
|
||||
placeholder="TypeScript, Node.js, REST APIs..."
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => onRemoveSkillGroup(group.id)}
|
||||
disabled={disableInputs}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="projects" className={sectionClass}>
|
||||
<AccordionTrigger className={triggerClass}>
|
||||
Selected Projects
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 pt-1">
|
||||
<ProjectSelector
|
||||
catalog={catalog}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={onToggleProject}
|
||||
maxProjects={3}
|
||||
disabled={disableInputs}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,455 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { ArrowLeft, Check, FileText, Loader2, Sparkles } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import * as api from "../../api";
|
||||
import { TailoringSections } from "./TailoringSections";
|
||||
import { useTailoringDraft } from "./useTailoringDraft";
|
||||
|
||||
interface TailoringWorkspaceBaseProps {
|
||||
job: Job;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
}
|
||||
|
||||
interface TailoringWorkspaceEditorProps extends TailoringWorkspaceBaseProps {
|
||||
mode: "editor";
|
||||
onUpdate: () => void | Promise<void>;
|
||||
onRegisterSave?: (save: () => Promise<void>) => void;
|
||||
onBeforeGenerate?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
interface TailoringWorkspaceTailorProps extends TailoringWorkspaceBaseProps {
|
||||
mode: "tailor";
|
||||
onBack: () => void;
|
||||
onFinalize: () => void;
|
||||
isFinalizing: boolean;
|
||||
variant?: "discovered" | "ready";
|
||||
}
|
||||
|
||||
type TailoringWorkspaceProps =
|
||||
| TailoringWorkspaceEditorProps
|
||||
| TailoringWorkspaceTailorProps;
|
||||
|
||||
export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
props,
|
||||
) => {
|
||||
const editorProps = props.mode === "editor" ? props : null;
|
||||
const tailorProps = props.mode === "tailor" ? props : null;
|
||||
|
||||
const {
|
||||
catalog,
|
||||
summary,
|
||||
setSummary,
|
||||
headline,
|
||||
setHeadline,
|
||||
jobDescription,
|
||||
setJobDescription,
|
||||
selectedIds,
|
||||
selectedIdsCsv,
|
||||
skillsDraft,
|
||||
openSkillGroupId,
|
||||
setOpenSkillGroupId,
|
||||
skillsJson,
|
||||
isDirty,
|
||||
setActiveField,
|
||||
markCurrentAsSaved,
|
||||
applyIncomingDraft,
|
||||
handleToggleProject,
|
||||
handleAddSkillGroup,
|
||||
handleUpdateSkillGroup,
|
||||
handleRemoveSkillGroup,
|
||||
} = useTailoringDraft({
|
||||
job: props.job,
|
||||
onDirtyChange: props.onDirtyChange,
|
||||
});
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [draftStatus, setDraftStatus] = useState<
|
||||
"unsaved" | "saving" | "saved"
|
||||
>("saved");
|
||||
|
||||
const savePayload = useMemo(
|
||||
() => ({
|
||||
tailoredSummary: summary,
|
||||
tailoredHeadline: headline,
|
||||
tailoredSkills: skillsJson,
|
||||
jobDescription,
|
||||
selectedProjectIds: selectedIdsCsv,
|
||||
}),
|
||||
[summary, headline, skillsJson, jobDescription, selectedIdsCsv],
|
||||
);
|
||||
|
||||
const persistCurrent = useCallback(async () => {
|
||||
await api.updateJob(props.job.id, savePayload);
|
||||
markCurrentAsSaved();
|
||||
if (tailorProps) {
|
||||
setDraftStatus("saved");
|
||||
}
|
||||
}, [props.job.id, savePayload, markCurrentAsSaved, tailorProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tailorProps) return;
|
||||
if (isDirty && draftStatus === "saved") {
|
||||
setDraftStatus("unsaved");
|
||||
}
|
||||
if (!isDirty && draftStatus === "unsaved") {
|
||||
setDraftStatus("saved");
|
||||
}
|
||||
}, [tailorProps, isDirty, draftStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tailorProps) return;
|
||||
if (!isDirty || draftStatus !== "unsaved") return;
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
setDraftStatus("saving");
|
||||
await persistCurrent();
|
||||
} catch {
|
||||
setDraftStatus("unsaved");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [tailorProps, isDirty, draftStatus, persistCurrent]);
|
||||
|
||||
const saveChanges = useCallback(
|
||||
async ({ showToast = true }: { showToast?: boolean } = {}) => {
|
||||
if (!editorProps) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await api.updateJob(props.job.id, savePayload);
|
||||
markCurrentAsSaved();
|
||||
if (showToast) toast.success("Changes saved");
|
||||
await editorProps.onUpdate();
|
||||
} catch (error) {
|
||||
if (showToast) toast.error("Failed to save changes");
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[editorProps, props.job.id, savePayload, markCurrentAsSaved],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorProps?.onRegisterSave) return;
|
||||
editorProps.onRegisterSave(() => saveChanges({ showToast: false }));
|
||||
}, [editorProps, saveChanges]);
|
||||
|
||||
const handleFieldBlur = useCallback(
|
||||
(field: "summary" | "headline" | "description" | "skills") => {
|
||||
setActiveField((prev) => (prev === field ? null : prev));
|
||||
},
|
||||
[setActiveField],
|
||||
);
|
||||
|
||||
const handleSummarizeEditor = useCallback(async () => {
|
||||
if (!editorProps) return;
|
||||
|
||||
try {
|
||||
setIsSummarizing(true);
|
||||
if (isDirty) {
|
||||
await saveChanges({ showToast: false });
|
||||
}
|
||||
|
||||
const updatedJob = await api.summarizeJob(props.job.id, { force: true });
|
||||
applyIncomingDraft(updatedJob);
|
||||
toast.success("AI Summary & Projects generated");
|
||||
await editorProps.onUpdate();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "AI summarization failed";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSummarizing(false);
|
||||
}
|
||||
}, [editorProps, isDirty, saveChanges, props.job.id, applyIncomingDraft]);
|
||||
|
||||
const handleGenerateWithAi = useCallback(async () => {
|
||||
if (!tailorProps) return;
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
if (isDirty) {
|
||||
await persistCurrent();
|
||||
}
|
||||
|
||||
const updatedJob = await api.summarizeJob(props.job.id, { force: true });
|
||||
applyIncomingDraft(updatedJob);
|
||||
setDraftStatus("saved");
|
||||
|
||||
toast.success("Draft generated with AI", {
|
||||
description: "Review and edit before finalizing.",
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to generate AI draft";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [tailorProps, isDirty, persistCurrent, props.job.id, applyIncomingDraft]);
|
||||
|
||||
const handleGeneratePdf = useCallback(async () => {
|
||||
if (!editorProps) return;
|
||||
|
||||
try {
|
||||
const shouldProceed = editorProps.onBeforeGenerate
|
||||
? await editorProps.onBeforeGenerate()
|
||||
: true;
|
||||
if (shouldProceed === false) return;
|
||||
|
||||
setIsGeneratingPdf(true);
|
||||
await saveChanges({ showToast: false });
|
||||
await api.generateJobPdf(props.job.id);
|
||||
toast.success("Resume PDF generated");
|
||||
await editorProps.onUpdate();
|
||||
} catch {
|
||||
toast.error("PDF generation failed");
|
||||
} finally {
|
||||
setIsGeneratingPdf(false);
|
||||
}
|
||||
}, [editorProps, saveChanges, props.job.id]);
|
||||
|
||||
const handleFinalize = useCallback(async () => {
|
||||
if (!tailorProps) return;
|
||||
|
||||
if (isDirty) {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await persistCurrent();
|
||||
} catch {
|
||||
toast.error("Failed to save draft before finalizing");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
tailorProps.onFinalize();
|
||||
}, [tailorProps, isDirty, persistCurrent]);
|
||||
|
||||
const disableInputs = editorProps
|
||||
? isSummarizing || isGeneratingPdf || isSaving
|
||||
: isGenerating || Boolean(tailorProps?.isFinalizing) || isSaving;
|
||||
|
||||
const canFinalize = summary.trim().length > 0 && selectedIds.size > 0;
|
||||
|
||||
if (editorProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
Editor
|
||||
</h3>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSummarizeEditor}
|
||||
disabled={isSummarizing || isGeneratingPdf || isSaving}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSummarizing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
AI Summarize
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGeneratePdf}
|
||||
disabled={
|
||||
isSummarizing || isGeneratingPdf || isSaving || !summary
|
||||
}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isGeneratingPdf ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Generate PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
|
||||
<TailoringSections
|
||||
catalog={catalog}
|
||||
summary={summary}
|
||||
headline={headline}
|
||||
jobDescription={jobDescription}
|
||||
skillsDraft={skillsDraft}
|
||||
selectedIds={selectedIds}
|
||||
openSkillGroupId={openSkillGroupId}
|
||||
disableInputs={disableInputs}
|
||||
onSummaryChange={setSummary}
|
||||
onHeadlineChange={setHeadline}
|
||||
onDescriptionChange={setJobDescription}
|
||||
onSkillGroupOpenChange={setOpenSkillGroupId}
|
||||
onAddSkillGroup={handleAddSkillGroup}
|
||||
onUpdateSkillGroup={handleUpdateSkillGroup}
|
||||
onRemoveSkillGroup={handleRemoveSkillGroup}
|
||||
onToggleProject={handleToggleProject}
|
||||
onFieldFocus={setActiveField}
|
||||
onFieldBlur={handleFieldBlur}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void saveChanges()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Selection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tailorProps) return null;
|
||||
|
||||
const finalizeVariant = tailorProps.variant ?? "discovered";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={tailorProps.onBack}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<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" && !isDirty && (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-emerald-400" />
|
||||
Saved
|
||||
</>
|
||||
)}
|
||||
{draftStatus === "unsaved" && (
|
||||
<span className="text-amber-400">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||
<span className="text-xs font-medium text-amber-300">
|
||||
Draft tailoring for this role
|
||||
</span>
|
||||
</div>
|
||||
<p className="ml-4 mt-1 text-[10px] text-muted-foreground">
|
||||
AI can draft summary, headline, skills, and project selection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleGenerateWithAi}
|
||||
disabled={isGenerating || tailorProps.isFinalizing || isSaving}
|
||||
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>
|
||||
|
||||
<TailoringSections
|
||||
catalog={catalog}
|
||||
summary={summary}
|
||||
headline={headline}
|
||||
jobDescription={jobDescription}
|
||||
skillsDraft={skillsDraft}
|
||||
selectedIds={selectedIds}
|
||||
openSkillGroupId={openSkillGroupId}
|
||||
disableInputs={disableInputs}
|
||||
onSummaryChange={setSummary}
|
||||
onHeadlineChange={setHeadline}
|
||||
onDescriptionChange={setJobDescription}
|
||||
onSkillGroupOpenChange={setOpenSkillGroupId}
|
||||
onAddSkillGroup={handleAddSkillGroup}
|
||||
onUpdateSkillGroup={handleUpdateSkillGroup}
|
||||
onRemoveSkillGroup={handleRemoveSkillGroup}
|
||||
onToggleProject={handleToggleProject}
|
||||
onFieldFocus={setActiveField}
|
||||
onFieldBlur={handleFieldBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4 opacity-50" />
|
||||
|
||||
<div className="space-y-2">
|
||||
{!canFinalize && (
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
Add a summary and select at least one project to{" "}
|
||||
{finalizeVariant === "ready" ? "regenerate" : "finalize"}.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => void handleFinalize()}
|
||||
disabled={tailorProps.isFinalizing || !canFinalize || isGenerating}
|
||||
className="h-10 w-full bg-emerald-600 text-white hover:bg-emerald-500"
|
||||
>
|
||||
{tailorProps.isFinalizing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{finalizeVariant === "ready"
|
||||
? "Regenerating PDF..."
|
||||
: "Finalizing & generating PDF..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{finalizeVariant === "ready"
|
||||
? "Regenerate PDF"
|
||||
: "Finalize & Move to Ready"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-center text-[10px] text-muted-foreground/70">
|
||||
{finalizeVariant === "ready"
|
||||
? "This will save your changes and regenerate the tailored PDF."
|
||||
: "This will generate your tailored PDF and move the job to Ready."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,277 @@
|
||||
import type { Job, ResumeProjectCatalogItem } from "@shared/types.js";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as api from "../../api";
|
||||
import {
|
||||
createTailoredSkillDraftId,
|
||||
type EditableSkillGroup,
|
||||
fromEditableSkillGroups,
|
||||
parseTailoredSkills,
|
||||
serializeTailoredSkills,
|
||||
toEditableSkillGroups,
|
||||
} from "../tailoring-utils";
|
||||
|
||||
export type TailoringActiveField =
|
||||
| "summary"
|
||||
| "headline"
|
||||
| "description"
|
||||
| "skills"
|
||||
| null;
|
||||
|
||||
const parseSelectedIds = (value: string | null | undefined) =>
|
||||
new Set(value?.split(",").filter(Boolean) ?? []);
|
||||
|
||||
const hasSelectionDiff = (current: Set<string>, saved: Set<string>) => {
|
||||
if (current.size !== saved.size) return true;
|
||||
for (const id of current) {
|
||||
if (!saved.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const parseIncomingDraft = (incomingJob: Job) => {
|
||||
const summary = incomingJob.tailoredSummary || "";
|
||||
const headline = incomingJob.tailoredHeadline || "";
|
||||
const description = incomingJob.jobDescription || "";
|
||||
const selectedIds = parseSelectedIds(incomingJob.selectedProjectIds);
|
||||
const skillsDraft = toEditableSkillGroups(
|
||||
parseTailoredSkills(incomingJob.tailoredSkills),
|
||||
);
|
||||
const skillsJson = serializeTailoredSkills(
|
||||
fromEditableSkillGroups(skillsDraft),
|
||||
);
|
||||
|
||||
return {
|
||||
summary,
|
||||
headline,
|
||||
description,
|
||||
selectedIds,
|
||||
skillsDraft,
|
||||
skillsJson,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseTailoringDraftParams {
|
||||
job: Job;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function useTailoringDraft({
|
||||
job,
|
||||
onDirtyChange,
|
||||
}: UseTailoringDraftParams) {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
const [headline, setHeadline] = useState(job.tailoredHeadline || "");
|
||||
const [jobDescription, setJobDescription] = useState(
|
||||
job.jobDescription || "",
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
|
||||
parseSelectedIds(job.selectedProjectIds),
|
||||
);
|
||||
const [skillsDraft, setSkillsDraft] = useState<EditableSkillGroup[]>(() =>
|
||||
toEditableSkillGroups(parseTailoredSkills(job.tailoredSkills)),
|
||||
);
|
||||
const [openSkillGroupId, setOpenSkillGroupId] = useState<string>("");
|
||||
|
||||
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
|
||||
const [savedHeadline, setSavedHeadline] = useState(
|
||||
job.tailoredHeadline || "",
|
||||
);
|
||||
const [savedDescription, setSavedDescription] = useState(
|
||||
job.jobDescription || "",
|
||||
);
|
||||
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
|
||||
parseSelectedIds(job.selectedProjectIds),
|
||||
);
|
||||
const [savedSkillsJson, setSavedSkillsJson] = useState(() =>
|
||||
serializeTailoredSkills(parseTailoredSkills(job.tailoredSkills)),
|
||||
);
|
||||
|
||||
const [activeField, setActiveField] = useState<TailoringActiveField>(null);
|
||||
const lastJobIdRef = useRef(job.id);
|
||||
|
||||
const skillsJson = useMemo(
|
||||
() => serializeTailoredSkills(fromEditableSkillGroups(skillsDraft)),
|
||||
[skillsDraft],
|
||||
);
|
||||
|
||||
const selectedIdsCsv = useMemo(
|
||||
() => Array.from(selectedIds).join(","),
|
||||
[selectedIds],
|
||||
);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (summary !== savedSummary) return true;
|
||||
if (headline !== savedHeadline) return true;
|
||||
if (jobDescription !== savedDescription) return true;
|
||||
if (skillsJson !== savedSkillsJson) return true;
|
||||
return hasSelectionDiff(selectedIds, savedSelectedIds);
|
||||
}, [
|
||||
summary,
|
||||
savedSummary,
|
||||
headline,
|
||||
savedHeadline,
|
||||
jobDescription,
|
||||
savedDescription,
|
||||
skillsJson,
|
||||
savedSkillsJson,
|
||||
selectedIds,
|
||||
savedSelectedIds,
|
||||
]);
|
||||
|
||||
const syncSavedSnapshot = useCallback(
|
||||
(
|
||||
nextSummary: string,
|
||||
nextHeadline: string,
|
||||
nextDescription: string,
|
||||
nextSelectedIds: Set<string>,
|
||||
nextSkillsDraft: EditableSkillGroup[],
|
||||
) => {
|
||||
setSavedSummary(nextSummary);
|
||||
setSavedHeadline(nextHeadline);
|
||||
setSavedDescription(nextDescription);
|
||||
setSavedSelectedIds(new Set(nextSelectedIds));
|
||||
setSavedSkillsJson(
|
||||
serializeTailoredSkills(fromEditableSkillGroups(nextSkillsDraft)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const markCurrentAsSaved = useCallback(() => {
|
||||
syncSavedSnapshot(
|
||||
summary,
|
||||
headline,
|
||||
jobDescription,
|
||||
selectedIds,
|
||||
skillsDraft,
|
||||
);
|
||||
}, [
|
||||
syncSavedSnapshot,
|
||||
summary,
|
||||
headline,
|
||||
jobDescription,
|
||||
selectedIds,
|
||||
skillsDraft,
|
||||
]);
|
||||
|
||||
const applyIncomingDraft = useCallback((incomingJob: Job) => {
|
||||
const next = parseIncomingDraft(incomingJob);
|
||||
setSummary(next.summary);
|
||||
setHeadline(next.headline);
|
||||
setJobDescription(next.description);
|
||||
setSelectedIds(next.selectedIds);
|
||||
setSkillsDraft(next.skillsDraft);
|
||||
setSavedSummary(next.summary);
|
||||
setSavedHeadline(next.headline);
|
||||
setSavedDescription(next.description);
|
||||
setSavedSelectedIds(next.selectedIds);
|
||||
setSavedSkillsJson(next.skillsJson);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onDirtyChange?.(false);
|
||||
}, [onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getResumeProjectsCatalog()
|
||||
.then(setCatalog)
|
||||
.catch(() => setCatalog([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (job.id !== lastJobIdRef.current) {
|
||||
lastJobIdRef.current = job.id;
|
||||
applyIncomingDraft(job);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDirty || activeField !== null) return;
|
||||
|
||||
applyIncomingDraft(job);
|
||||
}, [
|
||||
job,
|
||||
job.id,
|
||||
job.tailoredSummary,
|
||||
job.tailoredHeadline,
|
||||
job.tailoredSkills,
|
||||
job.jobDescription,
|
||||
job.selectedProjectIds,
|
||||
isDirty,
|
||||
activeField,
|
||||
applyIncomingDraft,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
openSkillGroupId.length > 0 &&
|
||||
!skillsDraft.some((group) => group.id === openSkillGroupId)
|
||||
) {
|
||||
setOpenSkillGroupId("");
|
||||
}
|
||||
}, [skillsDraft, openSkillGroupId]);
|
||||
|
||||
const handleToggleProject = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddSkillGroup = useCallback(() => {
|
||||
const nextId = createTailoredSkillDraftId();
|
||||
setSkillsDraft((prev) => [
|
||||
...prev,
|
||||
{ id: nextId, name: "", keywordsText: "" },
|
||||
]);
|
||||
setOpenSkillGroupId(nextId);
|
||||
}, []);
|
||||
|
||||
const handleUpdateSkillGroup = useCallback(
|
||||
(id: string, key: "name" | "keywordsText", value: string) => {
|
||||
setSkillsDraft((prev) =>
|
||||
prev.map((group) =>
|
||||
group.id === id ? { ...group, [key]: value } : group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRemoveSkillGroup = useCallback((id: string) => {
|
||||
setSkillsDraft((prev) => prev.filter((group) => group.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
catalog,
|
||||
summary,
|
||||
setSummary,
|
||||
headline,
|
||||
setHeadline,
|
||||
jobDescription,
|
||||
setJobDescription,
|
||||
selectedIds,
|
||||
selectedIdsCsv,
|
||||
skillsDraft,
|
||||
openSkillGroupId,
|
||||
setOpenSkillGroupId,
|
||||
skillsJson,
|
||||
isDirty,
|
||||
activeField,
|
||||
setActiveField,
|
||||
markCurrentAsSaved,
|
||||
applyIncomingDraft,
|
||||
syncSavedSnapshot,
|
||||
handleToggleProject,
|
||||
handleAddSkillGroup,
|
||||
handleUpdateSkillGroup,
|
||||
handleRemoveSkillGroup,
|
||||
};
|
||||
}
|
||||
96
orchestrator/src/server/api/routes/jobs-tailoring.test.ts
Normal file
96
orchestrator/src/server/api/routes/jobs-tailoring.test.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import type { Server } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { startServer, stopServer } from "./test-utils";
|
||||
|
||||
describe.sequential("Jobs tailoring PATCH route", () => {
|
||||
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 });
|
||||
});
|
||||
|
||||
async function createManualJobId(): Promise<string> {
|
||||
const response = await fetch(`${baseUrl}/api/manual-jobs/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
job: {
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
jobDescription: "Build backend systems",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
ok: boolean;
|
||||
data?: { id: string };
|
||||
};
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data?.id).toBeTruthy();
|
||||
const jobId = body.data?.id;
|
||||
if (!jobId) {
|
||||
throw new Error("Expected manual job import to return job id");
|
||||
}
|
||||
return jobId;
|
||||
}
|
||||
|
||||
it("accepts tailoredHeadline and tailoredSkills when JSON shape is valid", async () => {
|
||||
const jobId = await createManualJobId();
|
||||
const skills = JSON.stringify([
|
||||
{ name: "Backend", keywords: ["TypeScript", "Node.js"] },
|
||||
]);
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/jobs/${jobId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tailoredHeadline: "Senior Backend Engineer",
|
||||
tailoredSkills: skills,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
ok: boolean;
|
||||
data?: { tailoredHeadline: string; tailoredSkills: string };
|
||||
};
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data?.tailoredHeadline).toBe("Senior Backend Engineer");
|
||||
expect(body.data?.tailoredSkills).toBe(skills);
|
||||
});
|
||||
|
||||
it("rejects malformed tailoredSkills payload with 400", async () => {
|
||||
const jobId = await createManualJobId();
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/jobs/${jobId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tailoredHeadline: "Senior Backend Engineer",
|
||||
tailoredSkills: '{"name":"Backend","keywords":["TypeScript"]}',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
error?: { message?: string } | string;
|
||||
};
|
||||
if (typeof body.error === "string") {
|
||||
expect(body.error).toContain("JSON array");
|
||||
return;
|
||||
}
|
||||
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error?.message || "").toContain("JSON array");
|
||||
});
|
||||
});
|
||||
@ -45,6 +45,13 @@ import * as visaSponsors from "../../services/visa-sponsors/index";
|
||||
|
||||
export const jobsRouter = Router();
|
||||
|
||||
const tailoredSkillsPayloadSchema = z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
keywords: z.array(z.string()),
|
||||
}),
|
||||
);
|
||||
|
||||
async function notifyJobCompleteWebhook(job: Job) {
|
||||
const overrideWebhookUrl = await settingsRepo.getSetting(
|
||||
"jobCompleteWebhookUrl",
|
||||
@ -115,6 +122,35 @@ const updateJobSchema = z.object({
|
||||
suitabilityScore: z.number().min(0).max(100).optional(),
|
||||
suitabilityReason: z.string().optional(),
|
||||
tailoredSummary: z.string().optional(),
|
||||
tailoredHeadline: z.string().optional(),
|
||||
tailoredSkills: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value === undefined || value.trim().length === 0) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"tailoredSkills must be a JSON array of { name, keywords } objects",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = tailoredSkillsPayloadSchema.safeParse(parsed);
|
||||
|
||||
if (!parseResult.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"tailoredSkills must be a JSON array of { name, keywords } objects",
|
||||
});
|
||||
}
|
||||
}),
|
||||
selectedProjectIds: z.string().optional(),
|
||||
pdfPath: z.string().optional(),
|
||||
sponsorMatchScore: z.number().min(0).max(100).optional(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user