diff --git a/__tests__/AddPrepQuestionsDialog.spec.tsx b/__tests__/AddPrepQuestionsDialog.spec.tsx new file mode 100644 index 000000000..2158ca939 --- /dev/null +++ b/__tests__/AddPrepQuestionsDialog.spec.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AddPrepQuestionsDialog } from "@/components/myjobs/job-details/timeline/AddPrepQuestionsDialog"; +import { addStagePrepQuestions } from "@/actions/jobStage.actions"; +import { getQuestionsList } from "@/actions/question.actions"; + +vi.mock("@/actions/jobStage.actions", () => ({ + addStagePrepQuestions: vi.fn().mockResolvedValue({ success: true, data: [] }), +})); +vi.mock("@/actions/question.actions", () => ({ + getQuestionsList: vi.fn(), + createQuestion: vi.fn().mockResolvedValue({ + success: true, + data: { id: "q9", question: "New one", tags: [] }, + }), +})); +vi.mock("@/actions/tag.actions", () => ({ getAllTags: vi.fn().mockResolvedValue([]) })); +vi.mock("@/lib/toast", () => ({ + toastActionResult: vi.fn((res, opts) => res.success && opts.onSuccess?.()), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +const stage = { + id: "st1", + occurredAt: new Date(2026, 8, 24, 10, 0), + StageType: { label: "Final / Onsite Interview", Status: { value: "interview" } }, + prepQuestions: [{ id: "p1", questionId: "q1" }], +} as any; + +beforeEach(() => { + vi.clearAllMocks(); + (getQuestionsList as any).mockResolvedValue({ + success: true, + total: 2, + data: [ + { id: "q1", question: "Walk through your caching strategy", tags: [{ id: "tg1", label: "System Design", value: "system design", createdBy: "u1" }] }, + { id: "q2", question: "Tell me about a conflict with a teammate", tags: [] }, + ], + }); +}); + +describe("AddPrepQuestionsDialog", () => { + it("heads the dialog with the stage and explains the asked workflow", async () => { + render( {}} onAdded={() => {}} />); + + expect(await screen.findByText("Final / Onsite Interview · Sep 24, 2026")).toBeInTheDocument(); + expect( + screen.getByText(/check them off as "asked" from the Timeline tab/), + ).toBeInTheDocument(); + }); + + // The same question cannot be added twice to one stage. + it("shows a question already on the prep list as such and does not allow selecting it", async () => { + render( {}} onAdded={() => {}} />); + + const already = await screen.findByRole("checkbox", { + name: /Walk through your caching strategy/, + }); + expect(already).toBeDisabled(); + expect(already).toBeChecked(); + }); + + it("shows a selection as a removable chip and submits it", async () => { + render( {}} onAdded={() => {}} />); + + await userEvent.click( + await screen.findByRole("checkbox", { name: /Tell me about a conflict/ }), + ); + expect( + screen.getByRole("button", { name: /Remove Tell me about a conflict/ }), + ).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { name: "Done — 1 question added to prep list" }), + ); + + expect(addStagePrepQuestions).toHaveBeenCalledWith("st1", ["q2"]); + }); + + it("renders the question's tags as category badges", async () => { + render( {}} onAdded={() => {}} />); + + expect(await screen.findByText("System Design")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/AddStageDialog.spec.tsx b/__tests__/AddStageDialog.spec.tsx new file mode 100644 index 000000000..9ced9f838 --- /dev/null +++ b/__tests__/AddStageDialog.spec.tsx @@ -0,0 +1,118 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AddStageDialog } from "@/components/myjobs/job-details/timeline/AddStageDialog"; +import { addJobStage } from "@/actions/jobStage.actions"; + +vi.mock("@/actions/jobStage.actions", () => ({ + addJobStage: vi.fn().mockResolvedValue({ success: true, data: { id: "st1" } }), + updateJobStage: vi.fn().mockResolvedValue({ success: true, data: { id: "st1" } }), +})); +vi.mock("@/lib/toast", () => ({ + toastActionResult: vi.fn((res, opts) => res.success && opts.onSuccess?.()), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +// jsdom lacks scrollIntoView, which cmdk calls when selecting an item +Element.prototype.scrollIntoView = vi.fn(); + +const stageTypes = [ + { id: "t-off", label: "Offer", value: "offer", statusId: "s-off", sortOrder: 7, Status: { id: "s-off", label: "Offer", value: "offer" } }, + { id: "t-int", label: "Final / Onsite Interview", value: "final / onsite interview", statusId: "s-int", sortOrder: 6, Status: { id: "s-int", label: "Interview", value: "interview" } }, +] as any; + +const jobStatuses = [ + { id: "s-off", label: "Offer", value: "offer" }, + { id: "s-int", label: "Interview", value: "interview" }, +] as any; + +const props = { + open: true, + jobId: "j1", + jobLabel: "Senior Frontend Engineer · Acme Corp", + stage: null, + stageTypes, + jobStatuses, + onOpenChange: () => {}, + onSaved: vi.fn(), +}; + +// The shared Combobox takes no data-testid, so the picker is wrapped in one +// and the trigger reached through it (Playwright's centre click lands on the +// button; a jsdom click on the wrapper would not). +const openStageTypePicker = async () => + userEvent.click( + within(screen.getByTestId("stage-type-select")).getByRole("combobox"), + ); + +describe("AddStageDialog", () => { + beforeEach(() => vi.clearAllMocks()); + + it("heads the dialog with the job it belongs to", () => { + render(); + expect(screen.getByText("Senior Frontend Engineer · Acme Corp")).toBeInTheDocument(); + }); + + it("ticks Set as current stage by default", () => { + render(); + expect(screen.getByRole("checkbox", { name: /Set as current stage/ })).toBeChecked(); + }); + + // Nothing demotes a stage, so a live checkbox here would silently do nothing. + it("locks the checkbox when editing the stage that is already current", () => { + render( + , + ); + + const box = screen.getByRole("checkbox", { name: /Set as current stage/ }); + expect(box).toBeChecked(); + expect(box).toBeDisabled(); + expect(screen.getByText(/tick the box on another stage to move it/)).toBeInTheDocument(); + }); + + it("hides the parent-status picker until a custom name is typed", async () => { + render(); + expect(screen.queryByText(/PARENT STATUS/i)).toBeNull(); + + await userEvent.type( + screen.getByPlaceholderText("e.g. Panel Interview, Reference Check…"), + "Panel Interview", + ); + + expect(screen.getByText(/PARENT STATUS/i)).toBeInTheDocument(); + }); + + // Adding a stage records something that just happened, so the date and time + // open on now rather than empty. + it("saves with the picked type, today's date and time, and set-as-current", async () => { + render(); + + await openStageTypePicker(); + await userEvent.click(screen.getByRole("option", { name: "Offer" })); + await userEvent.click(screen.getByRole("button", { name: "Add Stage" })); + + expect(addJobStage).toHaveBeenCalledWith( + expect.objectContaining({ + jobId: "j1", + stageTypeId: "t-off", + setAsCurrent: true, + date: expect.any(Date), + time: expect.stringMatching(/^(0[1-9]|1[0-2]):[0-5][0-9] (AM|PM)$/), + }), + ); + }); + + it("refuses to save with neither a type nor a custom name", async () => { + render(); + + await userEvent.click(screen.getByRole("button", { name: "Add Stage" })); + + expect(addJobStage).not.toHaveBeenCalled(); + expect( + await screen.findByText(/Pick a stage or enter a custom stage name/), + ).toBeInTheDocument(); + }); +}); diff --git a/__tests__/AdminTabsContainer.spec.tsx b/__tests__/AdminTabsContainer.spec.tsx index 854e5bb3b..334b2f7ad 100644 --- a/__tests__/AdminTabsContainer.spec.tsx +++ b/__tests__/AdminTabsContainer.spec.tsx @@ -46,6 +46,13 @@ vi.mock("@/actions/contactRole.actions", () => ({ deleteContactRoleById: vi.fn(), })); +vi.mock("@/actions/jobStageType.actions", () => ({ + getJobStageTypeList: vi.fn().mockResolvedValue({ data: [], total: 0 }), + createJobStageType: vi.fn(), + updateJobStageType: vi.fn(), + deleteJobStageTypeById: vi.fn(), +})); + describe("AdminTabsContainer", () => { const user = userEvent.setup({ skipHover: true }); @@ -70,6 +77,12 @@ describe("AdminTabsContainer", () => { expect(screen.getByRole("tab", { name: "Roles" })).toBeInTheDocument(); }); + it("offers a Stages tab", () => { + render(); + + expect(screen.getByRole("tab", { name: "Stages" })).toBeInTheDocument(); + }); + it("should default to companies tab", () => { render(); diff --git a/__tests__/ComboBox.spec.tsx b/__tests__/ComboBox.spec.tsx index 87916e057..a14e9fac2 100644 --- a/__tests__/ComboBox.spec.tsx +++ b/__tests__/ComboBox.spec.tsx @@ -37,6 +37,10 @@ vi.mock("@/actions/contactRole.actions", () => ({ createContactRole: vi.fn(), })); +vi.mock("@/actions/tag.actions", () => ({ + createTag: vi.fn(), +})); + vi.mock("@/lib/toast", () => ({ toastError: vi.fn(), })); diff --git a/__tests__/JobDetails.spec.tsx b/__tests__/JobDetails.spec.tsx index ef891b174..8cdacdfda 100644 --- a/__tests__/JobDetails.spec.tsx +++ b/__tests__/JobDetails.spec.tsx @@ -1,12 +1,17 @@ import React from "react"; import JobDetails from "@/components/myjobs/JobDetails"; import { JobResponse, Tag } from "@/models/job.model"; -import { render, screen, act, waitFor } from "@testing-library/react"; +import { render, screen, act, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; // The active tab lives in the URL, so a click only calls router.replace — // what the user sees next comes from the re-render with the new params. -const router = { back: vi.fn(), push: vi.fn(), replace: vi.fn() }; +const router = { + back: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), +}; let searchParams = new URLSearchParams(); const onTab = (tab: string) => { searchParams = new URLSearchParams(`tab=${tab}`); @@ -65,6 +70,47 @@ vi.mock("@/components/CircularScore", () => ({ ), })); +vi.mock("@/actions/jobStage.actions", () => ({ + getJobStages: vi.fn().mockResolvedValue([]), + setPrepQuestionAsked: vi.fn(), + removeStagePrepQuestion: vi.fn(), + unlinkStageInterviewer: vi.fn(), + setStageNotes: vi.fn(), + addJobStage: vi.fn(), + updateJobStage: vi.fn(), +})); + +const makeStage = (id: string, label: string, over: any = {}) => ({ + id, + jobId: "job-1", + stageTypeId: `t-${id}`, + occurredAt: new Date(2026, 8, 3), + isCurrent: false, + outcome: null, + notes: null, + durationMins: null, + format: null, + location: null, + createdAt: new Date(2026, 8, 1), + updatedAt: new Date(2026, 8, 1), + StageType: { + id: `t-${id}`, + label, + value: label.toLowerCase(), + statusId: "s1", + sortOrder: 0, + Status: { id: "s1", label, value: "applied" }, + }, + interviewers: [], + prepQuestions: [], + ...over, +}); + +const stageA = makeStage("stage-a", "Applied", { + occurredAt: new Date(2026, 8, 1), +}); +const stageB = makeStage("stage-b", "Interview", { isCurrent: true }); + const baseProps = { jobStatuses: [], companies: [], @@ -72,6 +118,7 @@ const baseProps = { locations: [], sources: [], tags: [], + stageTypes: [], }; const makeJob = (overrides: Partial = {}): JobResponse => ({ @@ -277,6 +324,33 @@ describe("JobDetails – match data display", () => { }); }); +// The status is derived from the current stage, so it must be read from the +// stage list the timeline already refetches — a mirrored copy went stale on +// every stage add, edit and delete, and seeded Edit Job with the old status. +describe("JobDetails – status from the current stage", () => { + const statusBadge = () => + within(screen.getByText("Status").parentElement!).getAllByText( + /Applied|Interview/, + )[0]; + + it("shows the current stage's status, not the one on the job prop", () => { + render( + , + ); + + expect(statusBadge()).toHaveTextContent("Interview"); + }); + + it("keeps the job's own status when the job has no stages", () => { + render(); + + expect(statusBadge()).toHaveTextContent("Applied"); + }); +}); + describe("JobDetails – tabs", () => { beforeEach(() => { vi.clearAllMocks(); @@ -284,17 +358,54 @@ describe("JobDetails – tabs", () => { searchParams = new URLSearchParams(); }); - // The bar must not shift between jobs, so all five are always present. - it("renders all five tabs regardless of what the job has", () => { + // The bar must not shift between jobs, so all six are always present. + it("renders all six tabs regardless of what the job has", () => { render(); expect(screen.getByRole("tab", { name: /description/i })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: /ai match/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /timeline/i })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: /cover letter/i })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: /notes/i })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Contacts" })).toBeInTheDocument(); }); + it("renders a Timeline tab right after Description, with a stage count", () => { + render( + , + ); + + const tabs = screen.getAllByRole("tab").map((t) => t.textContent); + const descriptionAt = tabs.findIndex((t) => t?.startsWith("Description")); + const timelineAt = tabs.findIndex((t) => t?.startsWith("Timeline")); + + expect(timelineAt).toBe(descriptionAt + 1); + expect(screen.getByRole("tab", { name: /Timeline/ })).toHaveTextContent("2"); + }); + + it("shows an empty state for a job with no stages, keeping its status visible", () => { + onTab("timeline"); + render(); + + expect(screen.getByText(/No stages recorded/i)).toBeInTheDocument(); + expect(screen.getByTestId("timeline-add-stage-btn")).toBeInTheDocument(); + }); + + // Escape first: an open Radix menu is modal, so the second trigger would + // sit under a pointer-events:none layer. + it("keeps only one status control, in the Update Status menu", async () => { + render(); + + await userEvent.click(screen.getByTestId("job-details-actions-menu-btn")); + expect(screen.queryByRole("menuitem", { name: /Change status/ })).toBeNull(); + await userEvent.keyboard("{Escape}"); + + await userEvent.click(screen.getByTestId("update-status-menu-btn")); + expect( + screen.getByRole("menuitem", { name: /Change status/ }), + ).toBeInTheDocument(); + }); + it("opens on the Description tab", () => { render(); @@ -352,17 +463,17 @@ describe("JobDetails – tabs", () => { }); }); -describe("JobDetails – Match with AI", () => { +describe("JobDetails – AI Match", () => { beforeEach(() => { vi.clearAllMocks(); chat.clear.mockResolvedValue(undefined); searchParams = new URLSearchParams(); }); - it("opens the chat and asks for a match when Match with AI is clicked", async () => { + it("opens the chat and asks for a match when AI Match is clicked", async () => { chat.approvalPending = false; render(); - await userEvent.click(screen.getByRole("button", { name: /match with ai/i })); + await userEvent.click(screen.getByRole("button", { name: /ai match/i })); await act(async () => {}); expect(chat.open).toHaveBeenCalled(); expect(chat.clear).toHaveBeenCalled(); @@ -376,7 +487,7 @@ describe("JobDetails – Match with AI", () => { chat.approvalPending = false; chat.clear.mockRejectedValueOnce(new Error("offline")); render(); - await userEvent.click(screen.getByRole("button", { name: /match with ai/i })); + await userEvent.click(screen.getByRole("button", { name: /ai match/i })); await act(async () => {}); expect(chat.open).toHaveBeenCalled(); expect(chat.sendMessage).toHaveBeenCalled(); @@ -385,7 +496,7 @@ describe("JobDetails – Match with AI", () => { it("asks before clearing a conversation with a pending approval", async () => { chat.approvalPending = true; render(); - await userEvent.click(screen.getByRole("button", { name: /match with ai/i })); + await userEvent.click(screen.getByRole("button", { name: /ai match/i })); expect( screen.getByText(/clear the assistant conversation/i), ).toBeInTheDocument(); diff --git a/__tests__/JobDetailsPage.spec.tsx b/__tests__/JobDetailsPage.spec.tsx index 01f14ce98..85a097bd3 100644 --- a/__tests__/JobDetailsPage.spec.tsx +++ b/__tests__/JobDetailsPage.spec.tsx @@ -32,6 +32,10 @@ vi.mock("@/actions/tag.actions", () => ({ getAllTags: vi.fn(async () => []), })); +vi.mock("@/actions/jobStageType.actions", () => ({ + getAllJobStageTypes: vi.fn(async () => []), +})); + vi.mock("@/components/myjobs/JobDetails", () => ({ default: () =>
, })); diff --git a/__tests__/JobStagesContainer.spec.tsx b/__tests__/JobStagesContainer.spec.tsx new file mode 100644 index 000000000..337c5bc96 --- /dev/null +++ b/__tests__/JobStagesContainer.spec.tsx @@ -0,0 +1,117 @@ +import JobStagesContainer from "@/components/admin/JobStagesContainer"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + getJobStageTypeList, + deleteJobStageTypeById, + updateJobStageType, +} from "@/actions/jobStageType.actions"; + +vi.mock("@/actions/jobStageType.actions", () => ({ + getJobStageTypeList: vi.fn(), + createJobStageType: vi.fn(), + updateJobStageType: vi.fn(), + deleteJobStageTypeById: vi.fn(), +})); + +vi.mock("@/actions/job.actions", () => ({ + getStatusList: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@/lib/toast", () => ({ + toastSuccess: vi.fn(), + toastError: vi.fn(), + toastActionResult: vi.fn(), +})); + +const types = [ + { + id: "t-int", + label: "Final / Onsite Interview", + value: "final / onsite interview", + sortOrder: 6, + statusId: "s-int", + Status: { id: "s-int", label: "Interview", value: "interview" }, + _count: { stages: 3 }, + }, + { + id: "t-off", + label: "Offer Received", + value: "offer received", + sortOrder: 9, + statusId: "s-off", + Status: { id: "s-off", label: "Offer", value: "offer" }, + _count: { stages: 0 }, + }, +]; + +describe("JobStagesContainer", () => { + const user = userEvent.setup({ skipHover: true }); + + beforeEach(() => { + vi.clearAllMocks(); + (getJobStageTypeList as any).mockResolvedValue({ data: types, total: 2 }); + }); + + it("lists each stage type with its parent status, order and stage count", async () => { + render(); + + expect( + await screen.findByText("Final / Onsite Interview"), + ).toBeInTheDocument(); + expect(screen.getByText("Interview")).toBeInTheDocument(); + expect(screen.getByText("6")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("refuses to delete a type that is in use, naming the count", async () => { + render(); + await screen.findByText("Final / Onsite Interview"); + + await user.click(screen.getAllByRole("button", { name: /toggle menu/i })[0]); + await user.click(await screen.findByText("Delete")); + + expect(await screen.findByText(/used by 3 job stages/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Delete$/ })).toBeNull(); + expect(deleteJobStageTypeById).not.toHaveBeenCalled(); + }); + + it("moves a type down by swapping sort order with its neighbour", async () => { + (updateJobStageType as any).mockResolvedValue({ success: true }); + render(); + await screen.findByText("Final / Onsite Interview"); + + await user.click(screen.getAllByRole("button", { name: /toggle menu/i })[0]); + await user.click(await screen.findByText("Move Down")); + + await waitFor(() => + expect(updateJobStageType).toHaveBeenCalledWith( + "t-int", + "Final / Onsite Interview", + "s-int", + 9, + ), + ); + expect(updateJobStageType).toHaveBeenCalledWith( + "t-off", + "Offer Received", + "s-off", + 6, + ); + }); + + it("searches stage types by label", async () => { + render(); + await screen.findByText("Final / Onsite Interview"); + + await user.type(screen.getByPlaceholderText("Search stages..."), "onsite"); + + await waitFor(() => + expect(getJobStageTypeList).toHaveBeenCalledWith( + 1, + expect.any(Number), + "onsite", + ), + ); + }); +}); diff --git a/__tests__/LinkInterviewersDialog.spec.tsx b/__tests__/LinkInterviewersDialog.spec.tsx new file mode 100644 index 000000000..44a138301 --- /dev/null +++ b/__tests__/LinkInterviewersDialog.spec.tsx @@ -0,0 +1,77 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LinkInterviewersDialog } from "@/components/myjobs/job-details/timeline/LinkInterviewersDialog"; +import { linkStageInterviewer } from "@/actions/jobStage.actions"; +import { getAllContacts } from "@/actions/contact.actions"; + +vi.mock("@/actions/jobStage.actions", () => ({ + linkStageInterviewer: vi.fn().mockResolvedValue({ success: true, data: { id: "iv2" } }), +})); +vi.mock("@/actions/contact.actions", () => ({ + getAllContacts: vi.fn(), + createContact: vi.fn().mockResolvedValue({ success: true, data: { id: "c9", name: "New Person" } }), +})); +vi.mock("@/lib/toast", () => ({ + toastActionResult: vi.fn((res, opts) => res.success && opts.onSuccess?.()), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +const stage = { + id: "st1", + occurredAt: new Date(2026, 8, 24, 10, 0), + StageType: { label: "Final / Onsite Interview", Status: { value: "interview" } }, + interviewers: [{ id: "iv1", contactId: "c1", Contact: { id: "c1", name: "Priya Nair" } }], +} as any; + +beforeEach(() => { + vi.clearAllMocks(); + (getAllContacts as any).mockResolvedValue([ + { id: "c1", label: "Priya Nair", title: "VP Engineering", company: "Acme Corp", value: "priya nair vp engineering acme" }, + { id: "c2", label: "John Smith", title: "Engineering Manager", company: "Acme Corp", value: "john smith engineering manager acme" }, + ]); +}); + +describe("LinkInterviewersDialog", () => { + it("heads the dialog with the target stage and its date", async () => { + render( {}} onLinked={() => {}} />); + + expect( + await screen.findByText("Final / Onsite Interview · Sep 24, 2026"), + ).toBeInTheDocument(); + }); + + it("marks an already-linked contact and offers no Add button for them", async () => { + render( {}} onLinked={() => {}} />); + + expect(await screen.findByText("Priya Nair")).toBeInTheDocument(); + expect(screen.getByText("VP Engineering · Acme Corp")).toBeInTheDocument(); + expect(screen.getByLabelText(/Priya Nair is already linked/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Add John Smith/ })).toBeInTheDocument(); + }); + + it("filters the list by the search box", async () => { + render( {}} onLinked={() => {}} />); + await screen.findByText("John Smith"); + + await userEvent.type( + screen.getByPlaceholderText("Search contacts by name or company…"), + "john", + ); + + expect(screen.queryByText("Priya Nair")).toBeNull(); + expect(screen.getByText("John Smith")).toBeInTheDocument(); + }); + + it("links a contact and reports the running count on the Done button", async () => { + render( {}} onLinked={() => {}} />); + await screen.findByText("John Smith"); + + await userEvent.click(screen.getByRole("button", { name: /Add John Smith/ })); + + expect(linkStageInterviewer).toHaveBeenCalledWith("st1", "c2"); + expect( + await screen.findByRole("button", { name: "Done — 1 interviewer linked" }), + ).toBeInTheDocument(); + }); +}); diff --git a/__tests__/StageDetailPanel.spec.tsx b/__tests__/StageDetailPanel.spec.tsx new file mode 100644 index 000000000..1b40160f3 --- /dev/null +++ b/__tests__/StageDetailPanel.spec.tsx @@ -0,0 +1,258 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StageDetailPanel } from "@/components/myjobs/job-details/timeline/StageDetailPanel"; +import { + deleteJobStage, + setPrepQuestionAsked, + setStageNotes, +} from "@/actions/jobStage.actions"; + +vi.mock("@/actions/jobStage.actions", () => ({ + setPrepQuestionAsked: vi.fn().mockResolvedValue({ success: true }), + removeStagePrepQuestion: vi.fn().mockResolvedValue({ success: true }), + unlinkStageInterviewer: vi.fn().mockResolvedValue({ success: true }), + setStageNotes: vi.fn().mockResolvedValue({ success: true }), + deleteJobStage: vi.fn().mockResolvedValue({ success: true }), +})); +vi.mock("@/lib/toast", () => ({ + toastActionResult: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +const interviewStage = { + id: "st1", + jobId: "j1", + stageTypeId: "t-int", + occurredAt: new Date(2026, 8, 24, 10, 0), + isCurrent: true, + outcome: "scheduled", + notes: "Bring laptop", + durationMins: 90, + format: "On-site", + location: "Acme HQ, Bldg 3", + createdAt: new Date(2026, 8, 2), + updatedAt: new Date(2026, 8, 2), + StageType: { + id: "t-int", + label: "Final / Onsite Interview", + value: "final / onsite interview", + statusId: "s-int", + sortOrder: 6, + Status: { id: "s-int", label: "Interview", value: "interview" }, + }, + interviewers: [ + { + id: "iv1", + stageId: "st1", + contactId: "c1", + Contact: { id: "c1", name: "Priya Nair", title: "VP Engineering", email: null, phone: null, linkedinUrl: null, Company: null }, + }, + ], + prepQuestions: [ + { id: "p1", stageId: "st1", questionId: "q1", asked: true, askedAt: new Date(), Question: { id: "q1", question: "Walk through your caching strategy", tags: [{ id: "tg1", label: "System Design", value: "system design", createdBy: "u1" }] } }, + { id: "p2", stageId: "st1", questionId: "q2", asked: false, askedAt: null, Question: { id: "q2", question: "Tell me about a conflict with a teammate", tags: [] } }, + ], +} as any; + +const nonInterviewStage = { + ...interviewStage, + id: "st2", + interviewers: [], + prepQuestions: [], + StageType: { + ...interviewStage.StageType, + label: "Applied", + Status: { id: "s-app", label: "Applied", value: "applied" }, + }, +}; + +const noop = () => {}; + +describe("StageDetailPanel", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows the stage name, the current-stage marker and the scheduled line", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "Final / Onsite Interview" })).toBeInTheDocument(); + expect(screen.getByText("CURRENT STAGE")).toBeInTheDocument(); + expect(screen.getByText(/Sep 24, 2026 · 10:00 AM · 90 min/)).toBeInTheDocument(); + }); + + // Overview is the landing tab, so format, location, duration and outcome are + // on screen without a click; interviewers and questions are one tab away. + it("opens on Overview with location, format, duration and outcome", () => { + render( + , + ); + + expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("data-state", "active"); + expect(screen.getByText("Acme HQ, Bldg 3")).toBeInTheDocument(); + expect(screen.getByText("On-site")).toBeInTheDocument(); + expect(screen.getByText("90 min")).toBeInTheDocument(); + expect(screen.getByText("Scheduled")).toBeInTheDocument(); + expect(screen.queryByText("Priya Nair")).toBeNull(); + }); + + it("shows the linked interviewers on the Interviewer tab", async () => { + render( + , + ); + + await userEvent.click(screen.getByRole("tab", { name: /^Interviewer/ })); + + expect(screen.getByText("Priya Nair")).toBeInTheDocument(); + expect(screen.getByText("VP Engineering")).toBeInTheDocument(); + }); + + // The tally rides on the trigger, so the prep progress is readable from the + // Overview tab without opening the list. + it("carries the asked tally on the Prep List tab trigger", async () => { + render( + , + ); + + expect(screen.getByRole("tab", { name: /Prep List 1 of 2/ })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("tab", { name: /Prep List/ })); + + expect(screen.getByText("System Design")).toBeInTheDocument(); + }); + + it("drops the tab row on a non-interview stage and shows the overview alone", () => { + render( + , + ); + + expect(screen.queryByRole("tablist")).toBeNull(); + expect(screen.queryByText(/INTERVIEWERS/)).toBeNull(); + expect(screen.queryByText(/PREP LIST/)).toBeNull(); + // Notes are a live textarea, so this is a value, not page text. + expect(screen.getByLabelText("NOTES")).toHaveValue("Bring laptop"); + }); + + // Location, format and duration are interview-only in the Add Stage dialog, + // so a legacy row carrying them must not surface them here either. + it("hides location, format and duration on a non-interview stage", () => { + render( + , + ); + + expect(screen.queryByText("LOCATION")).toBeNull(); + expect(screen.queryByText("FORMAT")).toBeNull(); + expect(screen.queryByText("DURATION")).toBeNull(); + expect(screen.queryByText(/90 min/)).toBeNull(); + expect(screen.getByText("OUTCOME")).toBeInTheDocument(); + }); + + // Nothing remounts when another stage is selected, so the tab has to be + // reset by hand or an interview tab survives onto a stage that has none. + it("returns to Overview when another stage is selected", async () => { + const { rerender } = render( + , + ); + + await userEvent.click(screen.getByRole("tab", { name: /^Interviewer/ })); + rerender( + , + ); + + expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("data-state", "active"); + }); + + it("marks a question asked inline, with no dialog and no save step", async () => { + render( + , + ); + + await userEvent.click(screen.getByRole("tab", { name: /Prep List/ })); + await userEvent.click( + screen.getByRole("checkbox", { name: /Tell me about a conflict with a teammate/ }), + ); + + expect(setPrepQuestionAsked).toHaveBeenCalledWith("p2", true); + }); + + // The artboard's inline notes editor. Save appears only once the text has + // actually changed, so the resting panel is not cluttered with dead buttons. + it("edits notes in place, showing Save only once the text changes", async () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: "Save Changes" })).toBeNull(); + + await userEvent.type(screen.getByLabelText("NOTES"), " Ask about on-call."); + await userEvent.click(screen.getByRole("button", { name: "Save Changes" })); + + expect(setStageNotes).toHaveBeenCalledWith( + "st1", + "Bring laptop Ask about on-call.", + ); + }); + + it("deletes the stage once the alert is confirmed", async () => { + render( + , + ); + + await userEvent.click( + screen.getByRole("button", { name: "Delete Final / Onsite Interview" }), + ); + await userEvent.click(screen.getByRole("button", { name: "Delete" })); + + expect(deleteJobStage).toHaveBeenCalledWith("st1"); + }); + + it("does not delete when the alert is dismissed", async () => { + render( + , + ); + + await userEvent.click( + screen.getByRole("button", { name: "Delete Final / Onsite Interview" }), + ); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(deleteJobStage).not.toHaveBeenCalled(); + }); + + // Deleting the current stage re-derives the job's status server-side, so + // the alert has to say so rather than use the generic warning. + it("warns that the status follows when the current stage is deleted", async () => { + render( + , + ); + + await userEvent.click( + screen.getByRole("button", { name: "Delete Final / Onsite Interview" }), + ); + + expect(screen.getByText(/the job's status follows that stage/)).toBeInTheDocument(); + }); + + it("uses the generic warning for a stage that is not current", async () => { + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Delete Applied" })); + + expect(screen.getByText(/This action cannot be undone/)).toBeInTheDocument(); + }); + + it("reverts the draft on Cancel without calling the action", async () => { + render( + , + ); + + await userEvent.type(screen.getByLabelText("NOTES"), " scratch"); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.getByLabelText("NOTES")).toHaveValue("Bring laptop"); + expect(setStageNotes).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/StageHistoryList.spec.tsx b/__tests__/StageHistoryList.spec.tsx new file mode 100644 index 000000000..f3f8962cf --- /dev/null +++ b/__tests__/StageHistoryList.spec.tsx @@ -0,0 +1,91 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StageHistoryList } from "@/components/myjobs/job-details/timeline/StageHistoryList"; + +const stage = (id: string, label: string, over: any = {}) => ({ + id, + jobId: "j1", + stageTypeId: `t-${id}`, + occurredAt: null, + isCurrent: false, + outcome: null, + notes: null, + durationMins: null, + format: null, + location: null, + createdAt: new Date(2026, 8, 1), + updatedAt: new Date(2026, 8, 1), + StageType: { id: `t-${id}`, label, value: label.toLowerCase(), statusId: "s", sortOrder: 0, Status: { id: "s", label, value: "interview" } }, + interviewers: [], + prepQuestions: [], + ...over, +}); + +const stages = [ + stage("a", "New", { occurredAt: new Date(2026, 8, 1) }), + stage("b", "Applied", { occurredAt: new Date(2026, 8, 3) }), + stage("c", "Final / Onsite Interview", { occurredAt: new Date(2026, 8, 24), isCurrent: true }), +] as any; + +const terminalTypes = [ + { id: "t-off", label: "Offer", value: "offer", statusId: "s-off", sortOrder: 7, Status: { value: "offer" } }, +] as any; + +const renderList = (props: any = {}) => + render( + {}} + onAddStage={() => {}} + {...props} + />, + ); + +describe("StageHistoryList", () => { + it("renders every stage with its date, then the unreached terminal step", () => { + renderList(); + + expect(screen.getByText("New")).toBeInTheDocument(); + expect(screen.getByText("Sep 1")).toBeInTheDocument(); + expect(screen.getByText("Final / Onsite Interview")).toBeInTheDocument(); + expect(screen.getByText("Offer")).toBeInTheDocument(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("marks the current stage for assistive tech", () => { + renderList(); + + expect( + screen.getByRole("button", { name: /Final \/ Onsite Interview/ }), + ).toHaveAttribute("aria-current", "step"); + }); + + it("selects a stage on click", async () => { + const onSelect = vi.fn(); + renderList({ onSelect }); + + await userEvent.click(screen.getByRole("button", { name: /Applied/ })); + + expect(onSelect).toHaveBeenCalledWith("b"); + }); + + it("renders the unreached terminal step as a non-interactive element", () => { + renderList(); + + expect(screen.queryByRole("button", { name: /Offer/ })).toBeNull(); + }); + + it("shows an em dash for an undated stage", () => { + renderList({ + stages: [stage("a", "New")] as any, + selectedStageId: "a", + currentStageId: "a", + terminalTypes: [] as any, + }); + + expect(screen.getByText("—")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/UpdateStatusMenu.spec.tsx b/__tests__/UpdateStatusMenu.spec.tsx new file mode 100644 index 000000000..3ad3536cb --- /dev/null +++ b/__tests__/UpdateStatusMenu.spec.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { UpdateStatusMenu } from "@/components/myjobs/job-details/timeline/UpdateStatusMenu"; + +const stage = (statusValue: string, label: string) => + ({ + id: "st1", + StageType: { label, Status: { value: statusValue } }, + }) as any; + +const jobStatuses = [ + { id: "s-int", label: "Interview", value: "interview" }, + { id: "s-off", label: "Offer", value: "offer" }, +] as any; + +const base = { + jobStatuses, + currentStatusId: "s-int", + onChangeStatus: () => {}, + onAddStage: () => {}, + onLinkInterviewers: () => {}, + onAddPrepQuestions: () => {}, +}; + +const open = async () => + userEvent.click(screen.getByRole("button", { name: /Update Status/ })); + +describe("UpdateStatusMenu", () => { + // The old ⋮ submenu moved in here, so this menu is the single answer to + // "where is this job now". + it("carries the status picker as its first item", async () => { + render( + , + ); + await open(); + + expect( + screen.getByRole("menuitem", { name: /Change status/ }), + ).toBeInTheDocument(); + }); + + it("enables all three items on an interview stage", async () => { + render( + , + ); + await open(); + + expect(screen.getByRole("menuitem", { name: /Add Stage/ })).not.toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("menuitem", { name: /Add Interviewers/ })).not.toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("menuitem", { name: /Add to Prep List/ })).not.toHaveAttribute("aria-disabled", "true"); + }); + + // Disabled with a visible reason, not hidden: a menu whose items come and + // go as you change tabs is harder to learn than one that greys out. + it("disables the stage-scoped items with a reason on a non-interview stage", async () => { + render(); + await open(); + + expect(screen.getByRole("menuitem", { name: /Add Interviewers/ })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("menuitem", { name: /Add to Prep List/ })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getAllByText(/Applied is not an interview stage/).length).toBeGreaterThan(0); + }); + + it("keeps Add Stage enabled with no stage at all", async () => { + render(); + await open(); + + expect(screen.getByRole("menuitem", { name: /Add Stage/ })).not.toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("menuitem", { name: /Add Interviewers/ })).toHaveAttribute("aria-disabled", "true"); + }); + + it("fires the handler for the item clicked", async () => { + const onAddStage = vi.fn(); + render( + , + ); + await open(); + await userEvent.click(screen.getByRole("menuitem", { name: /Add Stage/ })); + + expect(onAddStage).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/ats-runner.spec.ts b/__tests__/ats-runner.spec.ts index d68e16703..d9786720e 100644 --- a/__tests__/ats-runner.spec.ts +++ b/__tests__/ats-runner.spec.ts @@ -17,7 +17,13 @@ vi.mock("@prisma/client", () => { location: { findUnique: vi.fn(), create: vi.fn() }, company: { findUnique: vi.fn(), create: vi.fn() }, jobSource: { findUnique: vi.fn(), create: vi.fn() }, - jobStatus: { findFirst: vi.fn(), create: vi.fn() }, + jobStatus: { findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn() }, + jobStage: { create: vi.fn() }, + jobStageType: { + findUnique: vi.fn(), + aggregate: vi.fn(), + create: vi.fn(), + }, }; return { PrismaClient: vi.fn(function () { @@ -118,6 +124,17 @@ describe("runAutomation (lever)", () => { (prisma.company.findUnique as any).mockResolvedValue({ id: "co" }); (prisma.jobSource.findUnique as any).mockResolvedValue({ id: "src" }); (prisma.jobStatus.findFirst as any).mockResolvedValue({ id: "st" }); + // Every saved job now gets its first current stage, which resolves the + // status-named stage type through the same Prisma mock. + (prisma.jobStatus.findUnique as any).mockResolvedValue({ + id: "st", + label: "New", + }); + (prisma.jobStageType.findUnique as any).mockResolvedValue({ + id: "stage-type-new", + label: "New", + }); + (prisma.jobStage.create as any).mockResolvedValue({ id: "stage-1" }); (generateText as any).mockResolvedValue({ text: "SCORES: match=90 recommendation=strong match\n\n## Summary\nGreat fit", diff --git a/__tests__/auth.actions.spec.ts b/__tests__/auth.actions.spec.ts index b0a9863ec..90a13a79c 100644 --- a/__tests__/auth.actions.spec.ts +++ b/__tests__/auth.actions.spec.ts @@ -22,6 +22,10 @@ vi.mock("@/lib/db", () => { }, jobStatus: { upsert: vi.fn(), + findMany: vi.fn(), + }, + jobStageType: { + createMany: vi.fn(), }, }; return { default: mockPrisma }; @@ -61,6 +65,10 @@ vi.mock("next-auth", () => { describe("Auth Actions", () => { beforeEach(() => { vi.clearAllMocks(); + // Stage-type seeding reads the statuses back to map value -> id. + (prisma.jobStatus.findMany as any).mockResolvedValue( + JOB_STATUSES.map((s) => ({ id: `id-${s.value}`, value: s.value })), + ); }); describe("signup", () => { @@ -217,6 +225,30 @@ describe("Auth Actions", () => { }); }); + it("seeds the fourteen stage types after the statuses exist", async () => { + (prisma.user.findUnique as any).mockResolvedValue(null); + (bcrypt.hash as any).mockResolvedValue("hashed"); + (prisma.user.create as any).mockResolvedValue({ id: "new-user-id" }); + + await signup({ + name: "Test", + email: "test@example.com", + password: "Password1!", + }); + + const rows = (prisma.jobStageType.createMany as any).mock.calls[0][0].data; + expect(rows).toHaveLength(14); + expect(rows.every((r: any) => r.createdBy === "new-user-id")).toBe(true); + expect(rows.every((r: any) => typeof r.statusId === "string")).toBe(true); + expect(rows.find((r: any) => r.value === "offer accepted")).toBeTruthy(); + + const statusOrder = (prisma.jobStatus.upsert as any).mock + .invocationCallOrder[0]; + const stageOrder = (prisma.jobStageType.createMany as any).mock + .invocationCallOrder[0]; + expect(statusOrder).toBeLessThan(stageOrder); + }); + it("uses the canonical form of each label as the role value", () => { for (const role of CONTACT_ROLES) { expect(role.value).toBe(canonicalizeEntityValue(role.label)); diff --git a/__tests__/backupExport.spec.ts b/__tests__/backupExport.spec.ts index 3757df0de..27676ee60 100644 --- a/__tests__/backupExport.spec.ts +++ b/__tests__/backupExport.spec.ts @@ -20,8 +20,10 @@ vi.mock("@/lib/db", () => { "company", "jobTitle", "location", "jobSource", "tag", "activityType", "profile", "file", "resume", "contactInfo", "summary", "resumeSection", "workExperience", "education", "licenseOrCertification", "otherSection", - "skill", "coverLetter", "automation", "job", "note", "interview", - "contact", "contactRole", "jobContact", "task", "activity", "question", "automationRun", "userSettings", + "skill", "coverLetter", "automation", "job", "note", + "contact", "contactRole", "jobContact", "jobStageType", "jobStage", + "jobStageInterviewer", "jobStagePrepQuestion", + "task", "activity", "question", "automationRun", "userSettings", ]) { mock[key] = delegate(); } @@ -115,6 +117,42 @@ describe("collectBackupData", () => { expect(data.Question[0]).not.toHaveProperty("tags"); }); + // D3: a raw statusId points at nothing after a restore into a fresh database. + it("replaces JobStageType.statusId with the status value and drops createdBy", async () => { + mockDb.jobStageType.findMany.mockResolvedValueOnce([ + { + id: "t1", + label: "Final / Onsite Interview", + value: "final / onsite interview", + statusId: "status-row-1", + Status: { value: "interview" }, + sortOrder: 6, + createdBy: "user-1", + }, + ]); + + const data = await collectBackupData("user-1"); + + expect(data.JobStageType[0].statusValue).toBe("interview"); + expect(data.JobStageType[0]).not.toHaveProperty("statusId"); + expect(data.JobStageType[0]).not.toHaveProperty("Status"); + expect(data.JobStageType[0]).not.toHaveProperty("createdBy"); + }); + + // A seeded Withdrawn stage type predates any withdrawn job, so scoping this + // read to the user's jobs alone would leave its statusValue unresolvable. + it("reads the statuses reached by a stage type as well as by a job", async () => { + await collectBackupData("user-1"); + const call = (db as unknown as { jobStatus: { findMany: ReturnType } }) + .jobStatus.findMany.mock.calls[0][0]; + expect(call.where).toEqual({ + OR: [ + { jobs: { some: { userId: "user-1" } } }, + { stageTypes: { some: { createdBy: "user-1" } } }, + ], + }); + }); + it("carries the referenced job statuses as label/value pairs", async () => { (db as unknown as { jobStatus: { findMany: ReturnType } }) .jobStatus.findMany.mockResolvedValueOnce([ diff --git a/__tests__/backupImportGuards.spec.ts b/__tests__/backupImportGuards.spec.ts index d63c8001c..b25992f21 100644 --- a/__tests__/backupImportGuards.spec.ts +++ b/__tests__/backupImportGuards.spec.ts @@ -30,8 +30,10 @@ vi.mock("@/lib/db", () => { "company", "jobTitle", "location", "jobSource", "tag", "activityType", "profile", "file", "resume", "contactInfo", "summary", "resumeSection", "workExperience", "education", "licenseOrCertification", "otherSection", - "skill", "coverLetter", "automation", "job", "note", "interview", - "contact", "contactRole", "jobContact", "task", "activity", "question", "userSettings", + "skill", "coverLetter", "automation", "job", "note", + "contact", "contactRole", "jobContact", "jobStageType", "jobStage", + "jobStageInterviewer", "jobStagePrepQuestion", + "task", "activity", "question", "userSettings", "chatConversation", ]) { mock[key] = counted(); diff --git a/__tests__/backupOrdering.spec.ts b/__tests__/backupOrdering.spec.ts index 6ddb3d5df..272a4cee8 100644 --- a/__tests__/backupOrdering.spec.ts +++ b/__tests__/backupOrdering.spec.ts @@ -66,7 +66,6 @@ describe("backup ordering", () => { it("remaps both of Contact's company FKs, not just the current employer", () => { expect(MODEL_SPECS.Contact.fks).toEqual({ - interviewId: "Interview", companyId: "Company", locationId: "Location", workedAtCompanyId: "Company", @@ -74,12 +73,13 @@ describe("backup ordering", () => { }); }); - it("marks exactly the seven lookup models, all owned by createdBy", () => { + it("marks exactly the eight lookup models, all owned by createdBy", () => { expect([...LOOKUP_MODELS].sort()).toEqual([ "ActivityType", "Company", "ContactRole", "JobSource", + "JobStageType", "JobTitle", "Location", "Tag", @@ -107,11 +107,41 @@ describe("backup ordering", () => { expect(MODEL_SPECS.ContactInfo.scope("u1")).toEqual({ resume: { profile: { userId: "u1" } }, }); - expect(MODEL_SPECS.Interview.scope("u1")).toEqual({ job: { userId: "u1" } }); expect(MODEL_SPECS.AutomationRun.scope("u1")).toEqual({ automation: { userId: "u1" }, }); }); + + it("has dropped the vestigial Interview model entirely", () => { + expect(MODEL_SPECS).not.toHaveProperty("Interview"); + expect(INSERT_ORDER).not.toContain("Interview" as BackupModel); + expect(MODEL_SPECS.Contact.fks).not.toHaveProperty("interviewId"); + }); + + // JobStageType is a lookup like ContactRole: without that flag a restore + // into an account that already holds the seeded types would double them. + it("treats JobStageType as a lookup and the rest as owned-through-Job", () => { + expect(MODEL_SPECS.JobStageType.lookup).toBe(true); + expect(LOOKUP_MODELS).toContain("JobStageType" as BackupModel); + expect(MODEL_SPECS.JobStage.lookup).toBeUndefined(); + expect(MODEL_SPECS.JobStage.scope("u1")).toEqual({ Job: { userId: "u1" } }); + expect(MODEL_SPECS.JobStageInterviewer.scope("u1")).toEqual({ + Stage: { Job: { userId: "u1" } }, + }); + expect(MODEL_SPECS.JobStagePrepQuestion.scope("u1")).toEqual({ + Stage: { Job: { userId: "u1" } }, + }); + }); + + it("inserts the stage models after Job, Contact and Question", () => { + const at = (m: BackupModel) => INSERT_ORDER.indexOf(m); + expect(at("JobStageType")).toBeLessThan(at("JobStage")); + expect(at("Job")).toBeLessThan(at("JobStage")); + expect(at("Contact")).toBeLessThan(at("JobStageInterviewer")); + expect(at("Question")).toBeLessThan(at("JobStagePrepQuestion")); + expect(at("JobStage")).toBeLessThan(at("JobStageInterviewer")); + expect(at("JobStage")).toBeLessThan(at("JobStagePrepQuestion")); + }); }); describe("emptiness models", () => { diff --git a/__tests__/backupRoundTrip.spec.ts b/__tests__/backupRoundTrip.spec.ts index be4cb189b..e50ae4903 100644 --- a/__tests__/backupRoundTrip.spec.ts +++ b/__tests__/backupRoundTrip.spec.ts @@ -2,7 +2,7 @@ import fs from "fs"; import path from "path"; import { PrismaClient } from "@prisma/client"; -import { APP_CONSTANTS, CONTACT_ROLES } from "@/lib/constants"; +import { APP_CONSTANTS, CONTACT_ROLES, JOB_STAGES } from "@/lib/constants"; import { buildBackupZip } from "@/lib/backup/export"; import { importBackup } from "@/lib/backup/import"; import { listSnapshots, readSnapshot } from "@/lib/backup/snapshot"; @@ -149,7 +149,29 @@ async function seedFullAccount() { const question = await prisma.question.create({ data: { question: "Why us?", createdBy: userId, tags: { connect: { id: tag.id } } }, }); - expect(question.id).toBeTruthy(); + + const onsiteType = await prisma.jobStageType.findFirstOrThrow({ + where: { createdBy: userId, value: "final / onsite interview" }, + }); + const stage = await prisma.jobStage.create({ + data: { + jobId: job.id, + stageTypeId: onsiteType.id, + occurredAt: new Date("2026-09-24T14:00:00.000Z"), + isCurrent: true, + outcome: "scheduled", + notes: "Bring laptop", + durationMins: 90, + format: "On-site", + location: "Acme HQ, Bldg 3", + }, + }); + await prisma.jobStageInterviewer.create({ + data: { stageId: stage.id, contactId: contact.id }, + }); + await prisma.jobStagePrepQuestion.create({ + data: { stageId: stage.id, questionId: question.id, asked: true, askedAt: new Date() }, + }); const activityType = await prisma.activityType.create({ data: { label: "Applying", value: "applying", createdBy: userId }, @@ -339,15 +361,70 @@ describe("backup round trip", () => { expect(await prisma.contactRole.count({ where: { createdBy: userId } })).toBe(5); }); + it("round-trips a job's timeline, its interviewer and its prep question", async () => { + const restored = await prisma.jobStage.findFirstOrThrow({ + where: { Job: { userId } }, + include: { + StageType: { include: { Status: true } }, + interviewers: { include: { Contact: true } }, + prepQuestions: { include: { Question: true } }, + Job: true, + }, + }); + + expect(restored.StageType.value).toBe("final / onsite interview"); + expect(restored.StageType.Status.value).toBe("interview"); + expect(restored.isCurrent).toBe(true); + expect(restored.durationMins).toBe(90); + expect(restored.location).toBe("Acme HQ, Bldg 3"); + expect(restored.Job.userId).toBe(userId); + expect(restored.interviewers[0].Contact.name).toBe("Pat Lee"); + expect(restored.prepQuestions[0].asked).toBe(true); + expect(restored.prepQuestions[0].Question.question).toBe("Why us?"); + + // Lookup, like ContactRole: the seeded types came back once, not doubled. + expect( + await prisma.jobStageType.count({ where: { createdBy: userId } }), + ).toBe(14); + }); + + // D3: the file carries no JobStatus ids, so every restored type has to have + // been re-pointed at this database's own status rows. + it("re-points every restored stage type at a real local status", async () => { + const types = await prisma.jobStageType.findMany({ + where: { createdBy: userId }, + include: { Status: true }, + }); + expect(types).toHaveLength(14); + for (const type of types) { + expect(type.Status).not.toBeNull(); + } + const withdrawn = types.find((t) => t.value === "withdrawn")!; + // Withdrawn is the case the export's widened jobStatuses query exists for: + // no job holds it, so it would otherwise never reach the file. + expect(withdrawn.Status.value).toBe("withdrawn"); + }); + it("reseeds the default contact roles when restoring a pre-contacts backup", async () => { const { buffer } = await buildBackupZip(userId, "owner@example.com"); // Reshape into what v1.1.19 wrote: no contact groups, no counts for them. + // The stage groups go too — that release predates stages as well, and + // JobStageInterviewer.contactId is a required FK, so leaving it behind + // would abort the import on a contact the file no longer carries. const JSZip = (await import("jszip")).default; const zip = await JSZip.loadAsync(buffer); const data = JSON.parse(await zip.file("data.json")!.async("string")); const manifest = JSON.parse(await zip.file("manifest.json")!.async("string")); - for (const group of ["Contact", "ContactRole", "JobContact"]) { + for (const group of [ + "Contact", + "ContactRole", + "JobContact", + "JobStageType", + "JobStage", + "JobStageInterviewer", + "JobStagePrepQuestion", + ]) { delete data[group]; delete manifest.counts[group]; } @@ -363,6 +440,36 @@ describe("backup round trip", () => { ); }, 120_000); + it("reseeds the default stage types when restoring a pre-stages backup", async () => { + const { buffer } = await buildBackupZip(userId, "owner@example.com"); + + const JSZip = (await import("jszip")).default; + const zip = await JSZip.loadAsync(buffer); + const data = JSON.parse(await zip.file("data.json")!.async("string")); + const manifest = JSON.parse(await zip.file("manifest.json")!.async("string")); + for (const group of [ + "JobStageType", + "JobStage", + "JobStageInterviewer", + "JobStagePrepQuestion", + ]) { + delete data[group]; + delete manifest.counts[group]; + } + zip.file("data.json", JSON.stringify(data)); + zip.file("manifest.json", JSON.stringify(manifest)); + const legacy = await zip.generateAsync({ type: "nodebuffer" }); + + await importBackup(legacy, userId, "owner@example.com", true); + + const types = await prisma.jobStageType.findMany({ where: { createdBy: userId } }); + expect(types.map((t) => t.value).sort()).toEqual( + JOB_STAGES.map((s) => s.value).sort(), + ); + // Decision 22: restored jobs come back stageless, and stay that way. + expect(await prisma.jobStage.count({ where: { Job: { userId } } })).toBe(0); + }, 120_000); + it("does not demand confirmWipe on a freshly signed-up account", async () => { const freshUserId = await seedAccount(prisma, "fresh@example.com"); const { buffer } = await buildBackupZip(userId, "owner@example.com"); diff --git a/__tests__/backupSchema.spec.ts b/__tests__/backupSchema.spec.ts index 9a2031970..744e2a4c7 100644 --- a/__tests__/backupSchema.spec.ts +++ b/__tests__/backupSchema.spec.ts @@ -102,3 +102,80 @@ describe("data schema", () => { expect(parsed.jobStatuses[0].value).toBe("new"); }); }); + +describe("stage groups", () => { + // Zod strips unknown keys, so an older file carrying Interview and + // interviewId still imports — which is right for a model with zero reads. + it("silently drops a legacy Interview group and Contact.interviewId", () => { + const parsed = BackupDataSchema.parse({ + Interview: [{ id: "i1", createdAt: "2026-01-01T00:00:00.000Z", jobId: "j1" }], + Contact: [ + { + id: "c1", + name: "Pat", + email: null, + createdAt: "2026-01-01T00:00:00.000Z", + interviewId: "i1", + }, + ], + }); + expect(parsed).not.toHaveProperty("Interview"); + expect(parsed.Contact[0]).not.toHaveProperty("interviewId"); + }); + + it("defaults every stage group to an empty array", () => { + const parsed = BackupDataSchema.parse({}); + expect(parsed.JobStageType).toEqual([]); + expect(parsed.JobStage).toEqual([]); + expect(parsed.JobStageInterviewer).toEqual([]); + expect(parsed.JobStagePrepQuestion).toEqual([]); + }); + + // D3: JobStatus ids differ between installs, so the status crosses by name. + it("carries a stage type's parent status by value, not by id", () => { + const parsed = BackupDataSchema.parse({ + JobStageType: [ + { + id: "t1", + label: "Final / Onsite Interview", + value: "final / onsite interview", + statusValue: "interview", + sortOrder: 6, + }, + ], + }); + expect(parsed.JobStageType[0].statusValue).toBe("interview"); + expect(parsed.JobStageType[0]).not.toHaveProperty("statusId"); + }); + + it("rejects a stage type that carries no statusValue", () => { + expect(() => + BackupDataSchema.parse({ + JobStageType: [{ id: "t1", label: "X", value: "x", sortOrder: 0 }], + }), + ).toThrow(); + }); + + it("accepts a stage with a null occurredAt and a full stage alike", () => { + const parsed = BackupDataSchema.parse({ + JobStage: [ + { + id: "s1", + jobId: "j1", + stageTypeId: "t1", + occurredAt: null, + isCurrent: true, + outcome: null, + notes: null, + durationMins: null, + format: null, + location: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + expect(parsed.JobStage[0].occurredAt).toBeNull(); + expect(parsed.JobStage[0].isCurrent).toBe(true); + }); +}); diff --git a/__tests__/contact.actions.spec.ts b/__tests__/contact.actions.spec.ts index 86e2da732..462097150 100644 --- a/__tests__/contact.actions.spec.ts +++ b/__tests__/contact.actions.spec.ts @@ -80,15 +80,34 @@ describe("contact actions", () => { describe("getAllContacts", () => { it("returns picker rows whose value carries the email, so search finds it", async () => { db.contact.findMany.mockResolvedValue([ - { id: "c1", name: "Dave Patel", email: "dave@x.com", Company: { label: "Shopify" } }, + { id: "c1", name: "Dave Patel", title: "CTO", email: "dave@x.com", Company: { label: "Shopify" } }, ]); const res = await getAllContacts(); expect(res).toEqual([ - { id: "c1", label: "Dave Patel", value: "dave patel dave@x.com shopify" }, + { + id: "c1", + label: "Dave Patel", + title: "CTO", + company: "Shopify", + value: "dave patel dave@x.com shopify", + }, ]); }); + + // The Link Interviewers dialog shows " · <company>" beneath the + // name, which the search blob alone cannot be taken apart into. + it("carries the title and company as their own fields", async () => { + db.contact.findMany.mockResolvedValue([ + { id: "c2", name: "Priya Nair", title: null, email: null, Company: null }, + ]); + + const res = await getAllContacts(); + + expect(res[0].title).toBeNull(); + expect(res[0].company).toBeNull(); + }); }); describe("createContact", () => { diff --git a/__tests__/createJobStage.spec.ts b/__tests__/createJobStage.spec.ts new file mode 100644 index 000000000..ab33582ef --- /dev/null +++ b/__tests__/createJobStage.spec.ts @@ -0,0 +1,63 @@ +import { createJobRecord, createFirstStage } from "@/lib/jobs/createJobRecord"; +import prisma from "@/lib/db"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => ({ + default: { + job: { create: vi.fn() }, + jobStage: { create: vi.fn() }, + }, +})); +vi.mock("@/lib/jobs/resolve", () => ({ + resolveStageTypeForStatusId: vi.fn(), +})); + +const db = prisma as any; + +beforeEach(() => { + vi.clearAllMocks(); + (resolveStageTypeForStatusId as any).mockResolvedValue("t-draft"); + db.job.create.mockResolvedValue({ id: "j1", createdAt: new Date("2026-09-19T00:00:00Z") }); +}); + +describe("createFirstStage", () => { + it("writes one current stage dated at the job's creation", async () => { + const created = new Date("2026-09-19T00:00:00Z"); + + await createFirstStage(db, "j1", "s-draft", "u1", created); + + expect(resolveStageTypeForStatusId).toHaveBeenCalledWith("s-draft", "u1"); + expect(db.jobStage.create).toHaveBeenCalledWith({ + data: { + jobId: "j1", + stageTypeId: "t-draft", + occurredAt: created, + isCurrent: true, + }, + }); + }); +}); + +describe("createJobRecord", () => { + it("gives every new job exactly one current stage", async () => { + await createJobRecord({ + jobTitleId: "t1", + companyId: "c1", + statusId: "s-draft", + description: "<p>x</p>", + jobType: "full-time", + userId: "u1", + } as any); + + // Nested in the job.create, never a second statement: a job must not be + // able to commit without its first stage. + expect(db.jobStage.create).not.toHaveBeenCalled(); + expect(db.job.create.mock.calls[0][0].data.stages).toEqual({ + create: { + stageTypeId: "t-draft", + occurredAt: expect.any(Date), + isCurrent: true, + }, + }); + }); +}); diff --git a/__tests__/greenhouse-runner.spec.ts b/__tests__/greenhouse-runner.spec.ts index fa80de3ee..39e907eb0 100644 --- a/__tests__/greenhouse-runner.spec.ts +++ b/__tests__/greenhouse-runner.spec.ts @@ -17,7 +17,10 @@ vi.mock("@prisma/client", () => { location: { findUnique: vi.fn(), create: vi.fn() }, company: { findUnique: vi.fn(), create: vi.fn() }, jobSource: { findUnique: vi.fn(), create: vi.fn() }, - jobStatus: { findFirst: vi.fn(), create: vi.fn() }, + jobStatus: { findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn() }, + // A saved job gets its first timeline stage here too (createFirstStage). + jobStageType: { findUnique: vi.fn(), aggregate: vi.fn(), create: vi.fn() }, + jobStage: { create: vi.fn() }, }; return { PrismaClient: vi.fn(function () { @@ -139,6 +142,17 @@ describe("runAutomation (greenhouse)", () => { (prisma.company.findUnique as any).mockResolvedValue({ id: "co" }); (prisma.jobSource.findUnique as any).mockResolvedValue({ id: "src" }); (prisma.jobStatus.findFirst as any).mockResolvedValue({ id: "st" }); + // resolveStageTypeForStatusId looks the status up by id, then resolves the + // stage type from its LABEL — an existing type, so no create path here. + (prisma.jobStatus.findUnique as any).mockResolvedValue({ + id: "st", + label: "New", + }); + (prisma.jobStageType.findUnique as any).mockResolvedValue({ + id: "stg", + label: "New", + }); + (prisma.jobStage.create as any).mockResolvedValue({ id: "stage1" }); (generateText as any).mockResolvedValue({ text: "SCORES: match=90 recommendation=strong match\n\n## Summary\nGreat fit", diff --git a/__tests__/helpers/backupTestDb.ts b/__tests__/helpers/backupTestDb.ts index 2ab64814b..4838c1ff0 100644 --- a/__tests__/helpers/backupTestDb.ts +++ b/__tests__/helpers/backupTestDb.ts @@ -4,7 +4,12 @@ import path from "path"; import { execFileSync } from "child_process"; import { randomUUID } from "crypto"; import type { PrismaClient } from "@prisma/client"; -import { CONTACT_ROLES, JOB_SOURCES, JOB_STATUSES } from "@/lib/constants"; +import { + CONTACT_ROLES, + JOB_SOURCES, + JOB_STAGES, + JOB_STATUSES, +} from "@/lib/constants"; export function makeTestDbUrl(): { url: string; dir: string } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "jobsync-backup-")); @@ -53,5 +58,19 @@ export async function seedAccount( })), }); + const statusRows = await prisma.jobStatus.findMany({ + select: { id: true, value: true }, + }); + const statusIdByValue = new Map(statusRows.map((s) => [s.value, s.id])); + await prisma.jobStageType.createMany({ + data: JOB_STAGES.map((stage) => ({ + label: stage.label, + value: stage.value, + statusId: statusIdByValue.get(stage.status)!, + sortOrder: stage.sortOrder, + createdBy: user.id, + })), + }); + return user.id; } diff --git a/__tests__/job.actions.spec.ts b/__tests__/job.actions.spec.ts index 20703bf27..0f8576f15 100644 --- a/__tests__/job.actions.spec.ts +++ b/__tests__/job.actions.spec.ts @@ -14,6 +14,7 @@ import { import { getMockJobDetails, getMockJobsList } from "@/lib/mock.utils"; import { JobResponse } from "@/models/job.model"; import { getCurrentUser } from "@/utils/user.utils"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); @@ -52,7 +53,17 @@ vi.mock("@prisma/client", () => { resume: { count: vi.fn() }, coverLetter: { count: vi.fn() }, tag: { count: vi.fn() }, + jobStage: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(), }; + (mPrismaClient.$transaction as any).mockImplementation((fn: any) => + fn(mPrismaClient), + ); return { PrismaClient: vi.fn(function () { return mPrismaClient; @@ -60,6 +71,13 @@ vi.mock("@prisma/client", () => { }; }); +// Only the stage-type resolver is stubbed: the other resolvers in that module +// are used by addJob and the reference-create actions in this same spec. +vi.mock("@/lib/jobs/resolve", async (importOriginal) => ({ + ...((await importOriginal()) as object), + resolveStageTypeForStatusId: vi.fn(), +})); + vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn(), })); @@ -103,6 +121,12 @@ describe("jobActions", () => { for (const model of refModels) { (prisma as any)[model].count.mockResolvedValue(1); } + // Both status write paths now append a stage before updating the job. + (prisma as any).$transaction.mockImplementation((fn: any) => fn(prisma)); + (prisma as any).job.count.mockResolvedValue(1); + (prisma as any).jobStage.create.mockResolvedValue({ id: "stage-id" }); + (prisma as any).jobStage.updateMany.mockResolvedValue({ count: 1 }); + (resolveStageTypeForStatusId as any).mockResolvedValue("stage-type-id"); }); describe("getStatusList", () => { it("should return status list on successful query", async () => { @@ -826,6 +850,40 @@ describe("jobActions", () => { }, orderBy: { createdAt: "asc" }, }, + stages: { + include: { + StageType: { include: { Status: true } }, + interviewers: { + include: { + Contact: { + select: { + id: true, + name: true, + title: true, + email: true, + phone: true, + linkedinUrl: true, + Company: { select: { id: true, label: true } }, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }, + prepQuestions: { + include: { + Question: { + select: { + id: true, + question: true, + answer: true, + tags: true, + }, + }, + }, + orderBy: { createdAt: "asc" }, + }, + }, + }, }, }); }); @@ -1008,6 +1066,13 @@ describe("jobActions", () => { jobUrl: jobData.jobUrl, applied: jobData.applied, resumeId: jobData.resume, + stages: { + create: { + stageTypeId: "stage-type-id", + occurredAt: expect.any(Date), + isCurrent: true, + }, + }, }, }); }); @@ -1037,6 +1102,13 @@ describe("jobActions", () => { userId: mockUser.id, applied: jobData.applied, resumeId: jobData.resume, + stages: { + create: { + stageTypeId: "stage-type-id", + occurredAt: expect.any(Date), + isCurrent: true, + }, + }, }, }); expect(result).toEqual({ data: jobData, success: true }); @@ -1212,6 +1284,18 @@ describe("jobActions", () => { expect(result).toStrictEqual({ data: jobData, success: true }); expect(prisma.job.update).toHaveBeenCalledTimes(1); }); + it("appends a status stage when the Edit Job dialog changes the status", async () => { + (getCurrentUser as any).mockResolvedValue(mockUser); + (prisma.job.update as any).mockResolvedValue(jobData); + (prisma as any).jobStage.findFirst.mockResolvedValue({ + id: "st-old", + StageType: { statusId: "s-old", Status: { value: "applied" } }, + }); + + await updateJob({ ...jobData, status: "s-new" }); + + expect((prisma as any).jobStage.create).toHaveBeenCalled(); + }); it("should handle unexpected errors", async () => { (getCurrentUser as any).mockResolvedValue(mockUser); diff --git a/__tests__/jobStage.actions.spec.ts b/__tests__/jobStage.actions.spec.ts new file mode 100644 index 000000000..077274006 --- /dev/null +++ b/__tests__/jobStage.actions.spec.ts @@ -0,0 +1,408 @@ +import { + getJobStages, + addJobStage, + deleteJobStage, + setCurrentJobStage, + setStageNotes, +} from "@/actions/jobStage.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import prisma from "@/lib/db"; +import { resolveJobStageType } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => { + const jobStage = { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + delete: vi.fn(), + }; + const job = { count: vi.fn(), findFirst: vi.fn(), update: vi.fn() }; + const jobStageType = { findFirst: vi.fn() }; + const client = { + jobStage, + job, + jobStageType, + $transaction: vi.fn(async (fn: any) => fn(client)), + }; + return { default: client }; +}); + +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@/lib/jobs/resolve", () => ({ + resolveJobStageType: vi.fn(), + resolveStageTypeForStatusId: vi.fn(), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +const db = prisma as any; +const user = { id: "user-1" }; +const at = (iso: string) => new Date(iso); + +const interviewType = { + id: "t-int", + label: "Final / Onsite Interview", + statusId: "s-int", + Status: { value: "interview" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + (getCurrentUser as any).mockResolvedValue(user); + db.job.count.mockResolvedValue(1); + db.job.findFirst.mockResolvedValue({ appliedDate: null }); + db.jobStage.updateMany.mockResolvedValue({ count: 1 }); +}); + +describe("getJobStages", () => { + it("scopes the read through the job's owner and sorts undated stages last", async () => { + db.jobStage.findMany.mockResolvedValue([ + { id: "c", occurredAt: null, createdAt: at("2026-01-01T00:00:00Z"), StageType: { sortOrder: 3 } }, + { id: "a", occurredAt: at("2026-09-01T00:00:00Z"), createdAt: at("2026-01-01T00:00:00Z"), StageType: { sortOrder: 3 } }, + ]); + + const stages = await getJobStages("j1"); + + expect(db.jobStage.findMany.mock.calls[0][0].where).toEqual({ + jobId: "j1", + Job: { userId: user.id }, + }); + expect(stages.map((s: any) => s.id)).toEqual(["a", "c"]); + }); +}); + +describe("addJobStage", () => { + it("refuses a job the caller does not own", async () => { + db.job.count.mockResolvedValue(0); + + const res = await addJobStage({ + jobId: "someone-elses", + stageTypeId: "t-int", + setAsCurrent: true, + } as any); + + expect(res.success).toBe(false); + expect(db.jobStage.create).not.toHaveBeenCalled(); + }); + + it("refuses a stage type the caller does not own", async () => { + db.jobStageType.findFirst.mockResolvedValue(null); + + const res = await addJobStage({ + jobId: "j1", + stageTypeId: "someone-elses-type", + setAsCurrent: true, + } as any); + + expect(res.success).toBe(false); + expect(db.jobStage.create).not.toHaveBeenCalled(); + }); + + it("creates a custom type through the resolver and uses its id", async () => { + (resolveJobStageType as any).mockResolvedValue({ + id: "t-new", + label: "Panel Interview", + created: true, + }); + db.jobStageType.findFirst.mockResolvedValue({ + id: "t-new", + Status: { value: "interview" }, + }); + db.jobStage.create.mockResolvedValue({ id: "st1" }); + db.jobStage.findFirst.mockResolvedValue({ + id: "st1", + occurredAt: null, + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + const res = await addJobStage({ + jobId: "j1", + customLabel: "Panel Interview", + customStatusId: "s-int", + setAsCurrent: true, + } as any); + + expect(resolveJobStageType).toHaveBeenCalledWith( + "Panel Interview", + user.id, + "s-int", + ); + expect(res.success).toBe(true); + expect(db.jobStage.create.mock.calls[0][0].data.stageTypeId).toBe("t-new"); + }); + + // The dialog hides format/duration/location for a non-interview type, so a + // value typed before switching the type must not reach the row. + it("blanks the interview-only fields for a non-interview stage", async () => { + db.jobStageType.findFirst.mockResolvedValue({ + id: "t-rej", + Status: { value: "rejected" }, + }); + db.jobStage.create.mockResolvedValue({ id: "st-rej" }); + db.jobStage.findFirst.mockResolvedValue({ id: "st-rej" }); + + const res = await addJobStage({ + jobId: "j1", + stageTypeId: "t-rej", + format: "Video", + durationMins: 45, + location: "Zoom", + setAsCurrent: false, + } as any); + + expect(res.success).toBe(true); + const data = db.jobStage.create.mock.calls[0][0].data; + expect(data.format).toBeNull(); + expect(data.durationMins).toBeNull(); + expect(data.location).toBeNull(); + }); + + it("clears the old current stage and rewrites the job status when set as current", async () => { + db.jobStageType.findFirst.mockResolvedValue(interviewType); + db.jobStage.create.mockResolvedValue({ id: "st2" }); + db.jobStage.findFirst.mockResolvedValue({ + id: "st2", + occurredAt: null, + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + const res = await addJobStage({ + jobId: "j1", + stageTypeId: "t-int", + setAsCurrent: true, + } as any); + + expect(res.success).toBe(true); + // D8: promoteStage names the owner in both of its updateMany calls. + expect(db.jobStage.updateMany).toHaveBeenCalledWith({ + where: { + jobId: "j1", + isCurrent: true, + NOT: { id: "st2" }, + Job: { userId: user.id }, + }, + data: { isCurrent: false }, + }); + expect(db.job.update.mock.calls[0][0].data).toEqual({ + statusId: "s-int", + applied: true, + }); + }); + + it("leaves the current stage and the job status alone when not set as current", async () => { + db.jobStageType.findFirst.mockResolvedValue({ + id: "t-app", + label: "Applied", + statusId: "s-app", + Status: { value: "applied" }, + }); + db.jobStage.create.mockResolvedValue({ id: "st3" }); + db.jobStage.findFirst.mockResolvedValue({ id: "st3" }); + + const res = await addJobStage({ + jobId: "j1", + stageTypeId: "t-app", + setAsCurrent: false, + } as any); + + expect(res.success).toBe(true); + expect(db.jobStage.updateMany).not.toHaveBeenCalled(); + expect(db.job.update).not.toHaveBeenCalled(); + }); + + it("sets appliedDate from a backdated applied stage", async () => { + const backdated = at("2026-08-12T00:00:00Z"); + db.jobStageType.findFirst.mockResolvedValue({ + id: "t-app", + label: "Applied", + statusId: "s-app", + Status: { value: "applied" }, + }); + db.jobStage.create.mockResolvedValue({ id: "st4" }); + db.jobStage.findFirst.mockResolvedValue({ + id: "st4", + occurredAt: backdated, + StageType: { statusId: "s-app", Status: { value: "applied" } }, + }); + + await addJobStage({ + jobId: "j1", + stageTypeId: "t-app", + date: backdated, + setAsCurrent: true, + } as any); + + expect(db.job.update.mock.calls[0][0].data.appliedDate).toEqual(backdated); + }); +}); + +describe("deleteJobStage", () => { + it("promotes the previous stage and rewrites the status when deleting the current one", async () => { + db.jobStage.findFirst + // assertStageOwned + .mockResolvedValueOnce({ + id: "st-cur", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: interviewType, + }) + // syncJobFromCurrentStage's own scoped read + .mockResolvedValue({ + occurredAt: at("2026-09-03T00:00:00Z"), + StageType: { statusId: "s-app", Status: { value: "applied" } }, + }); + db.jobStage.findMany.mockResolvedValue([ + { id: "st-prev", occurredAt: at("2026-09-03T00:00:00Z"), createdAt: at("2026-09-03T00:00:00Z") }, + ]); + + const res = await deleteJobStage("st-cur"); + + expect(res.success).toBe(true); + expect(db.jobStage.delete).toHaveBeenCalledWith({ where: { id: "st-cur" } }); + expect(db.jobStage.updateMany).toHaveBeenCalledWith({ + where: { id: "st-prev", Job: { userId: user.id } }, + data: { isCurrent: true }, + }); + expect(db.job.update.mock.calls[0][0].data.statusId).toBe("s-app"); + }); + + // Undated stages sort last, so the tail of the sorted list is the wrong + // successor whenever the job holds a dateless stage. + it("promotes the furthest-along dated stage, not a dateless one", async () => { + db.jobStage.findFirst + .mockResolvedValueOnce({ + id: "st-cur", + jobId: "j1", + occurredAt: at("2026-10-01T00:00:00Z"), + isCurrent: true, + StageType: interviewType, + }) + .mockResolvedValue({ + occurredAt: at("2026-09-03T00:00:00Z"), + StageType: { statusId: "s-app", Status: { value: "applied" } }, + }); + db.jobStage.findMany.mockResolvedValue([ + { id: "st-applied", occurredAt: at("2026-09-03T00:00:00Z"), createdAt: at("2026-09-03T00:00:00Z"), StageType: { sortOrder: 2 } }, + { id: "st-undated", occurredAt: null, createdAt: at("2026-09-20T00:00:00Z"), StageType: { sortOrder: 3 } }, + ]); + + await deleteJobStage("st-cur"); + + expect(db.jobStage.updateMany).toHaveBeenCalledWith({ + where: { id: "st-applied", Job: { userId: user.id } }, + data: { isCurrent: true }, + }); + }); + + it("leaves the status untouched when the deleted stage was the only one", async () => { + db.jobStage.findFirst.mockResolvedValueOnce({ + id: "st-only", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: interviewType, + }); + db.jobStage.findMany.mockResolvedValue([]); + + const res = await deleteJobStage("st-only"); + + expect(res.success).toBe(true); + expect(db.job.update).not.toHaveBeenCalled(); + }); + + it("leaves the status untouched when deleting a non-current stage", async () => { + db.jobStage.findFirst.mockResolvedValueOnce({ + id: "st-old", + jobId: "j1", + occurredAt: at("2026-09-01T00:00:00Z"), + isCurrent: false, + StageType: interviewType, + }); + + const res = await deleteJobStage("st-old"); + + expect(res.success).toBe(true); + expect(db.jobStage.findMany).not.toHaveBeenCalled(); + expect(db.job.update).not.toHaveBeenCalled(); + }); + + it("refuses a stage the caller does not own", async () => { + db.jobStage.findFirst.mockResolvedValueOnce(null); + + const res = await deleteJobStage("someone-elses"); + + expect(res.success).toBe(false); + expect(db.jobStage.delete).not.toHaveBeenCalled(); + }); +}); + +describe("setCurrentJobStage", () => { + it("promotes the stage and rewrites the job status from its type", async () => { + db.jobStage.findFirst + // assertStageOwned + .mockResolvedValueOnce({ + id: "st9", + jobId: "j1", + occurredAt: null, + isCurrent: false, + StageType: interviewType, + }) + // syncJobFromCurrentStage's own scoped read + .mockResolvedValue({ + occurredAt: null, + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + const res = await setCurrentJobStage("st9"); + + expect(res.success).toBe(true); + // D8: every stage write names the owner, so this is updateMany with the + // ownership chain, not a bare update by id. + expect(db.jobStage.updateMany).toHaveBeenCalledWith({ + where: { id: "st9", Job: { userId: user.id } }, + data: { isCurrent: true }, + }); + expect(db.job.update.mock.calls[0][0].where).toEqual({ + id: "j1", + userId: user.id, + }); + expect(db.job.update.mock.calls[0][0].data.statusId).toBe("s-int"); + }); +}); + +describe("setStageNotes", () => { + it("saves a note through both the ownership check and the scoped write", async () => { + db.jobStage.findFirst.mockResolvedValueOnce({ + id: "st1", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: interviewType, + }); + + const res = await setStageNotes("st1", " They pushed hard on caching. "); + + expect(res.success).toBe(true); + const call = db.jobStage.updateMany.mock.calls.at(-1)[0]; + expect(call.where).toEqual({ id: "st1", Job: { userId: user.id } }); + expect(call.data).toEqual({ notes: "They pushed hard on caching." }); + }); + + it("stores an emptied note as null rather than an empty string", async () => { + db.jobStage.findFirst.mockResolvedValueOnce({ + id: "st1", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: interviewType, + }); + + await setStageNotes("st1", " "); + + expect(db.jobStage.updateMany.mock.calls.at(-1)[0].data).toEqual({ + notes: null, + }); + }); +}); diff --git a/__tests__/jobStageConstants.spec.ts b/__tests__/jobStageConstants.spec.ts new file mode 100644 index 000000000..d8baf8465 --- /dev/null +++ b/__tests__/jobStageConstants.spec.ts @@ -0,0 +1,47 @@ +import { JOB_STAGES, JOB_STATUSES } from "@/lib/constants"; +import { canonicalizeEntityValue } from "@/lib/jobs/canonicalize"; +import { JOB_STATUS_BADGE_COLORS } from "@/lib/badge-colors"; + +describe("JOB_STAGES", () => { + it("has fourteen entries with unique values and sequential sortOrder", () => { + expect(JOB_STAGES).toHaveLength(14); + const values = JOB_STAGES.map((s) => s.value); + expect(new Set(values).size).toBe(14); + expect(JOB_STAGES.map((s) => s.sortOrder)).toEqual( + JOB_STAGES.map((_, i) => i), + ); + }); + + it("stores each value as the canonical form of its own label", () => { + for (const stage of JOB_STAGES) { + expect(stage.value).toBe(canonicalizeEntityValue(stage.label)); + } + }); + + it("points every stage at a real JobStatus value", () => { + const statuses = new Set<string>(JOB_STATUSES.map((s) => s.value)); + for (const stage of JOB_STAGES) { + expect(statuses.has(stage.status)).toBe(true); + } + }); + + // Decision 19: the mapping must be total in both directions, so every + // status has exactly one status-named stage type to resolve back to. + it("carries exactly one status-named stage per JobStatus", () => { + for (const status of JOB_STATUSES) { + const named = JOB_STAGES.filter( + (s) => s.value === canonicalizeEntityValue(status.label), + ); + expect(named).toHaveLength(1); + expect(named[0].status).toBe(status.value); + } + }); + + it("adds withdrawn as an appended status with a slate badge", () => { + expect(JOB_STATUSES[JOB_STATUSES.length - 1]).toEqual({ + label: "Withdrawn", + value: "withdrawn", + }); + expect(JOB_STATUS_BADGE_COLORS.withdrawn).toBe("slate"); + }); +}); diff --git a/__tests__/jobStageInterviewers.spec.ts b/__tests__/jobStageInterviewers.spec.ts new file mode 100644 index 000000000..0d53694a9 --- /dev/null +++ b/__tests__/jobStageInterviewers.spec.ts @@ -0,0 +1,147 @@ +import { + linkStageInterviewer, + unlinkStageInterviewer, +} from "@/actions/jobStage.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import prisma from "@/lib/db"; +import { resolveContactRole } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => { + const mock: Record<string, unknown> = { + jobStage: { findFirst: vi.fn() }, + contact: { count: vi.fn() }, + jobStageInterviewer: { + create: vi.fn(), + findFirst: vi.fn(), + deleteMany: vi.fn(), + }, + jobContact: { findFirst: vi.fn(), create: vi.fn() }, + $transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn(mock)), + }; + return { default: mock }; +}); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@/lib/jobs/resolve", () => ({ resolveContactRole: vi.fn() })); + +const db = prisma as any; +const user = { id: "user-1" }; + +const interviewStage = { + id: "st1", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: { + id: "t-int", + label: "Final / Onsite Interview", + statusId: "s-int", + Status: { value: "interview" }, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + (getCurrentUser as any).mockResolvedValue(user); + (resolveContactRole as any).mockResolvedValue({ id: "r-int", label: "Interviewer" }); + db.jobStage.findFirst.mockResolvedValue(interviewStage); + db.contact.count.mockResolvedValue(1); + db.jobContact.findFirst.mockResolvedValue(null); + db.jobStageInterviewer.create.mockResolvedValue({ id: "link1" }); +}); + +describe("linkStageInterviewer", () => { + it("refuses a stage the caller does not own", async () => { + db.jobStage.findFirst.mockResolvedValue(null); + + const res = await linkStageInterviewer("someone-elses-stage", "c1"); + + expect(res.success).toBe(false); + expect(db.jobStageInterviewer.create).not.toHaveBeenCalled(); + }); + + // Owning the stage is not permission to attach someone else's contact. + it("refuses a contact the caller does not own", async () => { + db.contact.count.mockResolvedValue(0); + + const res = await linkStageInterviewer("st1", "someone-elses-contact"); + + expect(res.success).toBe(false); + expect(db.jobStageInterviewer.create).not.toHaveBeenCalled(); + }); + + it("refuses a stage that is not an interview stage", async () => { + db.jobStage.findFirst.mockResolvedValue({ + ...interviewStage, + StageType: { + id: "t-app", + label: "Applied", + statusId: "s-app", + Status: { value: "applied" }, + }, + }); + + const res = await linkStageInterviewer("st1", "c1"); + + expect(res.success).toBe(false); + expect(res.message).toContain("not an interview stage"); + expect(db.jobStageInterviewer.create).not.toHaveBeenCalled(); + }); + + it("links and upserts a JobContact with the interviewer role", async () => { + const res = await linkStageInterviewer("st1", "c1"); + + expect(res.success).toBe(true); + expect(db.jobStageInterviewer.create.mock.calls[0][0].data).toEqual({ + stageId: "st1", + contactId: "c1", + }); + // Resolve-or-create: the seeded role is renameable and deletable. + expect(resolveContactRole).toHaveBeenCalledWith("Interviewer", user.id); + expect(db.jobContact.create.mock.calls[0][0].data).toEqual({ + jobId: "j1", + contactId: "c1", + roleId: "r-int", + }); + }); + + it("does not duplicate an existing JobContact for that role", async () => { + db.jobContact.findFirst.mockResolvedValue({ id: "jc1" }); + + await linkStageInterviewer("st1", "c1"); + + expect(db.jobContact.create).not.toHaveBeenCalled(); + }); + + it("reports a friendly message when the contact is already on the stage", async () => { + db.jobStageInterviewer.create.mockRejectedValue({ code: "P2002" }); + + const res = await linkStageInterviewer("st1", "c1"); + + expect(res.success).toBe(false); + expect(res.message).toContain("already"); + }); +}); + +describe("unlinkStageInterviewer", () => { + it("deletes through both ownership chains and leaves the JobContact alone", async () => { + db.jobStageInterviewer.deleteMany.mockResolvedValue({ count: 1 }); + + const res = await unlinkStageInterviewer("link1"); + + expect(res.success).toBe(true); + expect(db.jobStageInterviewer.deleteMany.mock.calls[0][0].where).toEqual({ + id: "link1", + Stage: { Job: { userId: user.id } }, + Contact: { createdBy: user.id }, + }); + expect(db.jobContact.create).not.toHaveBeenCalled(); + }); + + it("fails when nothing matched", async () => { + db.jobStageInterviewer.deleteMany.mockResolvedValue({ count: 0 }); + + const res = await unlinkStageInterviewer("nope"); + + expect(res.success).toBe(false); + }); +}); diff --git a/__tests__/jobStagePrep.spec.ts b/__tests__/jobStagePrep.spec.ts new file mode 100644 index 000000000..b5e552760 --- /dev/null +++ b/__tests__/jobStagePrep.spec.ts @@ -0,0 +1,181 @@ +import { + addStagePrepQuestions, + removeStagePrepQuestion, + setPrepQuestionAsked, +} from "@/actions/jobStage.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import prisma from "@/lib/db"; + +vi.mock("@/lib/db", () => ({ + default: { + jobStage: { findFirst: vi.fn() }, + question: { count: vi.fn() }, + jobStagePrepQuestion: { + createMany: vi.fn(), + findMany: vi.fn(), + deleteMany: vi.fn(), + updateMany: vi.fn(), + findFirst: vi.fn(), + }, + }, +})); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); + +const db = prisma as any; +const user = { id: "user-1" }; + +const interviewStage = { + id: "st1", + jobId: "j1", + occurredAt: null, + isCurrent: true, + StageType: { + id: "t-int", + label: "Final / Onsite Interview", + statusId: "s-int", + Status: { value: "interview" }, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + (getCurrentUser as any).mockResolvedValue(user); + db.jobStage.findFirst.mockResolvedValue(interviewStage); + db.question.count.mockResolvedValue(2); + db.jobStagePrepQuestion.createMany.mockResolvedValue({ count: 2 }); + db.jobStagePrepQuestion.findMany.mockResolvedValue([]); +}); + +describe("addStagePrepQuestions", () => { + // The question count must match the ids passed, or this fails on ownership + // and never reaches the gate it claims to test. + it("refuses a stage that is not an interview stage", async () => { + db.jobStage.findFirst.mockResolvedValue({ + ...interviewStage, + StageType: { id: "t-app", label: "Applied", statusId: "s-app", Status: { value: "applied" } }, + }); + db.question.count.mockResolvedValue(1); + + const res = await addStagePrepQuestions("st1", ["q1"]); + + expect(res.success).toBe(false); + expect(res.message).toContain("not an interview stage"); + expect(res.message).toContain("Applied"); + expect(db.jobStagePrepQuestion.createMany).not.toHaveBeenCalled(); + }); + + it("refuses when any question belongs to someone else", async () => { + db.question.count.mockResolvedValue(1); + + const res = await addStagePrepQuestions("st1", ["q1", "someone-elses"]); + + expect(res.success).toBe(false); + expect(db.jobStagePrepQuestion.createMany).not.toHaveBeenCalled(); + }); + + it("links every question unasked", async () => { + const res = await addStagePrepQuestions("st1", ["q1", "q2"]); + + expect(res.success).toBe(true); + expect(db.jobStagePrepQuestion.createMany).toHaveBeenCalledWith({ + data: [ + { stageId: "st1", questionId: "q1", asked: false }, + { stageId: "st1", questionId: "q2", asked: false }, + ], + }); + }); + + // SQLite has no skipDuplicates, so the subtraction is ours to get right. + it("skips questions already on the stage's prep list", async () => { + db.jobStagePrepQuestion.findMany.mockResolvedValueOnce([ + { questionId: "q1" }, + ]); + + await addStagePrepQuestions("st1", ["q1", "q2"]); + + expect(db.jobStagePrepQuestion.createMany).toHaveBeenCalledWith({ + data: [{ stageId: "st1", questionId: "q2", asked: false }], + }); + }); + + it("writes nothing when every question is already linked", async () => { + db.jobStagePrepQuestion.findMany.mockResolvedValueOnce([ + { questionId: "q1" }, + { questionId: "q2" }, + ]); + + const res = await addStagePrepQuestions("st1", ["q1", "q2"]); + + expect(res.success).toBe(true); + expect(db.jobStagePrepQuestion.createMany).not.toHaveBeenCalled(); + }); + + it("rejects an empty selection", async () => { + const res = await addStagePrepQuestions("st1", []); + + expect(res.success).toBe(false); + expect(db.jobStagePrepQuestion.createMany).not.toHaveBeenCalled(); + }); +}); + +describe("setPrepQuestionAsked", () => { + it("records askedAt when marking asked", async () => { + db.jobStagePrepQuestion.updateMany.mockResolvedValue({ count: 1 }); + db.jobStagePrepQuestion.findFirst.mockResolvedValue({ + id: "p1", + asked: true, + askedAt: new Date(), + }); + + const res = await setPrepQuestionAsked("p1", true); + + expect(res.success).toBe(true); + const call = db.jobStagePrepQuestion.updateMany.mock.calls[0][0]; + expect(call.where).toEqual({ + id: "p1", + Stage: { Job: { userId: user.id } }, + Question: { createdBy: user.id }, + }); + expect(call.data.asked).toBe(true); + expect(call.data.askedAt).toBeInstanceOf(Date); + }); + + it("clears askedAt when unmarking", async () => { + db.jobStagePrepQuestion.updateMany.mockResolvedValue({ count: 1 }); + db.jobStagePrepQuestion.findFirst.mockResolvedValue({ + id: "p1", + asked: false, + askedAt: null, + }); + + await setPrepQuestionAsked("p1", false); + + expect(db.jobStagePrepQuestion.updateMany.mock.calls[0][0].data).toEqual({ + asked: false, + askedAt: null, + }); + }); + + it("fails when the row belongs to someone else", async () => { + db.jobStagePrepQuestion.updateMany.mockResolvedValue({ count: 0 }); + + const res = await setPrepQuestionAsked("someone-elses", true); + + expect(res.success).toBe(false); + }); +}); + +describe("removeStagePrepQuestion", () => { + it("deletes through both ownership chains", async () => { + db.jobStagePrepQuestion.deleteMany.mockResolvedValue({ count: 1 }); + + const res = await removeStagePrepQuestion("p1"); + + expect(res.success).toBe(true); + expect(db.jobStagePrepQuestion.deleteMany.mock.calls[0][0].where).toEqual({ + id: "p1", + Stage: { Job: { userId: user.id } }, + Question: { createdBy: user.id }, + }); + }); +}); diff --git a/__tests__/jobStageShared.spec.ts b/__tests__/jobStageShared.spec.ts new file mode 100644 index 000000000..fff0b8dd6 --- /dev/null +++ b/__tests__/jobStageShared.spec.ts @@ -0,0 +1,123 @@ +import { sortStages } from "@/lib/jobs/sortStages"; +import { jobFieldsForStage } from "@/actions/jobStage/shared"; + +const at = (iso: string) => new Date(iso); + +describe("sortStages", () => { + const stage = ( + id: string, + sortOrder: number, + occurredAt: Date | null, + createdAt: Date, + ) => ({ id, occurredAt, createdAt, StageType: { sortOrder } }); + + // The Library -> Stages order wins over chronology: a job added days after + // it was applied to has a New stage dated later than its Applied stage. + it("orders by the stage type's sortOrder, not by date", () => { + const stages = [ + stage("applied", 2, at("2026-09-14T18:00:49Z"), at("2026-09-19T00:00:00Z")), + stage("new", 1, at("2026-09-14T18:11:06Z"), at("2026-09-19T00:00:00Z")), + ]; + expect(sortStages(stages).map((s) => s.id)).toEqual(["new", "applied"]); + }); + + it("keeps New first when the job was applied to weeks earlier", () => { + const stages = [ + stage("applied", 2, at("2026-08-29T09:00:00Z"), at("2026-09-14T00:00:00Z")), + stage("new", 1, at("2026-09-14T18:11:06Z"), at("2026-09-14T00:00:00Z")), + stage("withdrawn", 13, null, at("2026-09-18T00:00:00Z")), + ]; + expect(sortStages(stages).map((s) => s.id)).toEqual([ + "new", + "applied", + "withdrawn", + ]); + }); + + // SQLite sorts NULL first on ASC and Prisma's nulls:"last" is unsupported + // there, so ordering lives here and must not migrate into the query. + it("sorts undated stages last within one sortOrder", () => { + const stages = [ + stage("c", 3, null, at("2026-01-01T00:00:00Z")), + stage("b", 3, at("2026-09-10T00:00:00Z"), at("2026-01-01T00:00:00Z")), + stage("a", 3, at("2026-09-01T00:00:00Z"), at("2026-01-01T00:00:00Z")), + ]; + expect(sortStages(stages).map((s) => s.id)).toEqual(["a", "b", "c"]); + }); + + it("breaks ties on createdAt when sortOrder and date both match", () => { + const same = at("2026-09-10T00:00:00Z"); + const stages = [ + stage("second", 3, same, at("2026-02-01T00:00:00Z")), + stage("first", 3, same, at("2026-01-01T00:00:00Z")), + ]; + expect(sortStages(stages).map((s) => s.id)).toEqual(["first", "second"]); + }); + + it("orders several undated stages of one type by createdAt", () => { + const stages = [ + stage("later", 3, null, at("2026-03-01T00:00:00Z")), + stage("earlier", 3, null, at("2026-01-01T00:00:00Z")), + ]; + expect(sortStages(stages).map((s) => s.id)).toEqual(["earlier", "later"]); + }); + + it("slots a backdated stage by its type, not by when it was entered", () => { + const stages = [ + stage("new", 1, at("2026-09-01T00:00:00Z"), at("2026-09-01T00:00:00Z")), + stage("onsite", 6, at("2026-09-24T00:00:00Z"), at("2026-09-02T00:00:00Z")), + stage("screening", 4, at("2026-09-10T00:00:00Z"), at("2026-09-30T00:00:00Z")), + ]; + expect(sortStages(stages).map((s) => s.id)).toEqual([ + "new", + "screening", + "onsite", + ]); + }); + + it("does not mutate its input", () => { + const stages = [ + stage("b", 3, at("2026-09-10T00:00:00Z"), at("2026-01-01T00:00:00Z")), + stage("a", 1, at("2026-09-01T00:00:00Z"), at("2026-01-01T00:00:00Z")), + ]; + sortStages(stages); + expect(stages.map((s) => s.id)).toEqual(["b", "a"]); + }); +}); + +describe("jobFieldsForStage", () => { + it("writes only the status for a non-applied, non-interview stage", () => { + expect(jobFieldsForStage("rejected", "s-rej", null, null)).toEqual({ + statusId: "s-rej", + }); + }); + + it("marks an interview stage as applied without touching appliedDate", () => { + expect(jobFieldsForStage("interview", "s-int", null, null)).toEqual({ + statusId: "s-int", + applied: true, + }); + }); + + it("sets appliedDate from the stage's own date when it has one", () => { + const occurred = at("2026-09-03T00:00:00Z"); + expect(jobFieldsForStage("applied", "s-app", occurred, null)).toEqual({ + statusId: "s-app", + applied: true, + appliedDate: occurred, + }); + }); + + it("falls back to now for an undated applied stage", () => { + const before = Date.now(); + const data = jobFieldsForStage("applied", "s-app", null, null) as any; + expect(data.appliedDate.getTime()).toBeGreaterThanOrEqual(before); + }); + + it("never overwrites an appliedDate already on the job", () => { + const existing = at("2026-08-01T00:00:00Z"); + expect( + jobFieldsForStage("applied", "s-app", at("2026-09-03T00:00:00Z"), existing), + ).toEqual({ statusId: "s-app", applied: true }); + }); +}); diff --git a/__tests__/jobStageType.actions.spec.ts b/__tests__/jobStageType.actions.spec.ts new file mode 100644 index 000000000..7b6da7ea1 --- /dev/null +++ b/__tests__/jobStageType.actions.spec.ts @@ -0,0 +1,180 @@ +import { + getAllJobStageTypes, + getJobStageTypeList, + createJobStageType, + updateJobStageType, + deleteJobStageTypeById, +} from "@/actions/jobStageType.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import prisma from "@/lib/db"; +import { resolveJobStageType } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => ({ + default: { + jobStageType: { + findMany: vi.fn(), + findFirst: vi.fn(), + count: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + jobStage: { count: vi.fn() }, + jobStatus: { findUnique: vi.fn(), findMany: vi.fn() }, + job: { findMany: vi.fn(), update: vi.fn() }, + }, +})); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@/lib/jobs/resolve", () => ({ resolveJobStageType: vi.fn() })); + +const db = prisma as any; +const user = { id: "user-1" }; + +beforeEach(() => { + vi.clearAllMocks(); + (getCurrentUser as any).mockResolvedValue(user); + db.jobStage.count.mockResolvedValue(0); + db.jobStatus.findUnique.mockResolvedValue({ + id: "s-int", + label: "Interview", + value: "interview", + }); + db.jobStatus.findMany.mockResolvedValue([ + { label: "Draft" }, + { label: "Applied" }, + { label: "Interview" }, + ]); + db.jobStageType.findFirst.mockResolvedValue(null); + db.job.findMany.mockResolvedValue([]); +}); + +describe("getAllJobStageTypes", () => { + it("scopes to the caller and orders by sortOrder then label", async () => { + db.jobStageType.findMany.mockResolvedValue([]); + + await getAllJobStageTypes(); + + const args = db.jobStageType.findMany.mock.calls[0][0]; + expect(args.where).toEqual({ createdBy: user.id }); + expect(args.orderBy).toEqual([{ sortOrder: "asc" }, { label: "asc" }]); + expect(args.include).toEqual({ Status: true }); + }); +}); + +describe("getJobStageTypeList", () => { + // getReferenceEntityList groups by an FK on Job, and a stage type reaches + // Job only through JobStage — so this is a dedicated read, like roles. + it("counts stages through the relation, not through a job groupBy", async () => { + db.jobStageType.findMany.mockResolvedValue([]); + db.jobStageType.count.mockResolvedValue(0); + + await getJobStageTypeList(1, 25); + + const args = db.jobStageType.findMany.mock.calls[0][0]; + expect(args.include._count).toEqual({ select: { stages: true } }); + }); +}); + +describe("createJobStageType", () => { + it("refuses a status that does not exist", async () => { + db.jobStatus.findUnique.mockResolvedValue(null); + + const res = await createJobStageType("Panel", "nope"); + + expect(res.success).toBe(false); + expect(resolveJobStageType).not.toHaveBeenCalled(); + }); + + it("delegates to the resolver so canonical-equal names collapse", async () => { + (resolveJobStageType as any).mockResolvedValue({ id: "t1", label: "Panel", created: true }); + + const res = await createJobStageType(" Panel ", "s-int"); + + expect(res.success).toBe(true); + expect(resolveJobStageType).toHaveBeenCalledWith(" Panel ", user.id, "s-int"); + }); + + // The resolver returns an existing type by name and ignores the requested + // status, so without this the call reports success having done nothing. + it("refuses a name already taken under a different status", async () => { + db.jobStageType.findFirst.mockResolvedValue({ + label: "Applied", + Status: { label: "Applied" }, + }); + + const res = await createJobStageType("Applied", "s-int"); + + expect(res.success).toBe(false); + expect(res.message).toContain("Applied"); + expect(resolveJobStageType).not.toHaveBeenCalled(); + }); +}); + +describe("updateJobStageType", () => { + // The status menus resolve through these by name, so a retarget would make + // every later status change write a stage contradicting Job.statusId. + it("refuses to move a status-named type under another status", async () => { + db.jobStageType.findFirst.mockResolvedValue({ + value: "interview", + statusId: "s-app", + }); + + const res = await updateJobStageType("t1", "Interview", "s-int", 3); + + expect(res.success).toBe(false); + expect(db.jobStageType.update).not.toHaveBeenCalled(); + }); + + it("re-derives the jobs sitting on a custom type it moves", async () => { + db.jobStageType.findFirst.mockResolvedValue({ + value: "take-home", + statusId: "s-app", + }); + db.job.findMany.mockResolvedValue([ + { + id: "j1", + appliedDate: null, + stages: [{ occurredAt: new Date("2026-09-01T00:00:00Z") }], + }, + ]); + + const res = await updateJobStageType("t1", "Take-home", "s-int", 3); + + expect(res.success).toBe(true); + expect(db.job.update.mock.calls[0][0].data.statusId).toBe("s-int"); + }); + + it("leaves jobs alone when only the label and order change", async () => { + db.jobStageType.findFirst.mockResolvedValue({ + value: "take-home", + statusId: "s-int", + }); + + const res = await updateJobStageType("t1", "Take-home Task", "s-int", 4); + + expect(res.success).toBe(true); + expect(db.job.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("deleteJobStageTypeById", () => { + it("blocks the delete with a counted message when stages use the type", async () => { + db.jobStage.count.mockResolvedValue(4); + + const res = await deleteJobStageTypeById("t1"); + + expect(res.success).toBe(false); + expect(res.message).toContain("4"); + expect(db.jobStageType.delete).not.toHaveBeenCalled(); + }); + + it("deletes an unused type, scoped to the caller", async () => { + db.jobStageType.delete.mockResolvedValue({ id: "t1" }); + + const res = await deleteJobStageTypeById("t1"); + + expect(res.success).toBe(true); + expect(db.jobStageType.delete).toHaveBeenCalledWith({ + where: { id: "t1", createdBy: user.id }, + }); + }); +}); diff --git a/__tests__/jobStatusStage.spec.ts b/__tests__/jobStatusStage.spec.ts new file mode 100644 index 000000000..1eba3002c --- /dev/null +++ b/__tests__/jobStatusStage.spec.ts @@ -0,0 +1,157 @@ +import { updateJobStatus } from "@/actions/job.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import prisma from "@/lib/db"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => { + const jobStage = { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }; + const job = { + update: vi.fn(), + findFirst: vi.fn(), + count: vi.fn(), + }; + const client = { + jobStage, + job, + $transaction: vi.fn(async (fn: any) => fn(client)), + }; + return { default: client }; +}); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@/lib/jobs/resolve", () => ({ + resolveStageTypeForStatusId: vi.fn(), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +const db = prisma as any; +const user = { id: "user-1" }; +const interview = { id: "s-int", label: "Interview", value: "interview" }; + +beforeEach(() => { + vi.clearAllMocks(); + (getCurrentUser as any).mockResolvedValue(user); + (resolveStageTypeForStatusId as any).mockResolvedValue("t-int"); + db.job.count.mockResolvedValue(1); + db.job.findFirst.mockResolvedValue({ appliedDate: null }); + db.job.update.mockResolvedValue({ id: "j1" }); + db.jobStage.updateMany.mockResolvedValue({ count: 1 }); +}); + +describe("updateJobStatus", () => { + // The comparison is on PARENT STATUS, not stage type: re-picking Interview + // while the current stage is "2nd Technical Interview" must append nothing, + // or the timeline reads as a step backwards into screening. + it("appends nothing when the current stage already carries that status", async () => { + db.jobStage.findFirst.mockResolvedValue({ + id: "st-tech", + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + const res = await updateJobStatus("j1", interview as any); + + expect(res.success).toBe(true); + expect(db.jobStage.create).not.toHaveBeenCalled(); + expect(db.job.update.mock.calls[0][0].data).toEqual({ + statusId: "s-int", + applied: true, + }); + }); + + it("appends a current stage dated now when the status genuinely changes", async () => { + db.jobStage.findFirst.mockResolvedValue({ + id: "st-app", + StageType: { statusId: "s-app", Status: { value: "applied" } }, + }); + db.jobStage.create.mockResolvedValue({ id: "st-new" }); + + const res = await updateJobStatus("j1", interview as any); + + expect(res.success).toBe(true); + expect(db.jobStage.create.mock.calls[0][0].data).toEqual({ + jobId: "j1", + stageTypeId: "t-int", + occurredAt: expect.any(Date), + isCurrent: false, + }); + // D8: the helper scopes its own writes rather than trusting the caller. + expect(db.jobStage.updateMany).toHaveBeenCalledWith({ + where: { + jobId: "j1", + isCurrent: true, + NOT: { id: "st-new" }, + Job: { userId: user.id }, + }, + data: { isCurrent: false }, + }); + }); + + // D4: resolving writes through the base client, so it must not happen while + // the transaction holds SQLite's write lock. + it("resolves the stage type before opening the transaction", async () => { + db.jobStage.findFirst.mockResolvedValue(null); + db.jobStage.create.mockResolvedValue({ id: "st-first" }); + + await updateJobStatus("j1", interview as any); + + const resolveOrder = (resolveStageTypeForStatusId as any).mock + .invocationCallOrder[0]; + const txOrder = (db.$transaction as any).mock.invocationCallOrder[0]; + expect(resolveOrder).toBeLessThan(txOrder); + }); + + it("appends a first stage for a stageless job without failing", async () => { + db.jobStage.findFirst.mockResolvedValue(null); + db.jobStage.create.mockResolvedValue({ id: "st-first" }); + + const res = await updateJobStatus("j1", interview as any); + + expect(res.success).toBe(true); + expect(db.jobStage.create).toHaveBeenCalled(); + }); + + it("refuses a job the caller does not own before writing a stage", async () => { + db.job.count.mockResolvedValue(0); + + const res = await updateJobStatus("someone-elses", interview as any); + + expect(res.success).toBe(false); + expect(db.jobStage.create).not.toHaveBeenCalled(); + }); + + it("still writes applied and appliedDate for an Applied pick", async () => { + db.jobStage.findFirst.mockResolvedValue(null); + db.jobStage.create.mockResolvedValue({ id: "st-app" }); + + await updateJobStatus("j1", { + id: "s-app", + label: "Applied", + value: "applied", + } as any); + + const data = db.job.update.mock.calls[0][0].data; + expect(data.applied).toBe(true); + expect(data.appliedDate).toBeInstanceOf(Date); + }); + + // D5: a deliberate change from today's behaviour, which re-stamped the date + // on every Applied pick and moved the job on the dashboard's weekly chart. + it("never moves an appliedDate the job already has", async () => { + const original = new Date("2026-09-03T00:00:00Z"); + db.jobStage.findFirst.mockResolvedValue(null); + db.jobStage.create.mockResolvedValue({ id: "st-app" }); + db.job.findFirst.mockResolvedValue({ appliedDate: original }); + + await updateJobStatus("j1", { + id: "s-app", + label: "Applied", + value: "applied", + } as any); + + expect(db.job.update.mock.calls[0][0].data).not.toHaveProperty("appliedDate"); + }); +}); diff --git a/__tests__/persistDiscoveredJobStage.spec.ts b/__tests__/persistDiscoveredJobStage.spec.ts new file mode 100644 index 000000000..6b022e72b --- /dev/null +++ b/__tests__/persistDiscoveredJobStage.spec.ts @@ -0,0 +1,57 @@ +import { persistDiscoveredJob } from "@/lib/scraper/automation-run/persist"; +import { mapScrapedJobToJobRecord } from "@/lib/scraper/mapper"; +import db from "@/lib/db"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; + +vi.mock("@/lib/db", () => ({ + default: { job: { create: vi.fn() }, jobStage: { create: vi.fn() } }, +})); +vi.mock("@/lib/scraper/mapper", () => ({ mapScrapedJobToJobRecord: vi.fn() })); +vi.mock("@/lib/jobs/resolve", () => ({ + resolveStageTypeForStatusId: vi.fn(), +})); + +const mockDb = db as any; +const createdAt = new Date("2026-09-19T00:00:00Z"); + +const automation = { id: "a1", userId: "u1", jobBoard: "greenhouse" } as any; +const job = { + title: "Engineer", + company: "Acme", + location: "Remote", + description: "Build things", + url: "https://boards.greenhouse.io/acme/jobs/1", +} as any; + +beforeEach(() => { + vi.clearAllMocks(); + (resolveStageTypeForStatusId as any).mockResolvedValue("t-new"); + (mapScrapedJobToJobRecord as any).mockResolvedValue({ + statusId: "s-new", + createdAt, + tags: { connect: [] }, + }); + mockDb.job.create.mockResolvedValue({ id: "j1", createdAt }); +}); + +describe("persistDiscoveredJob", () => { + it("gives a discovered job its first current stage", async () => { + const res = await persistDiscoveredJob(automation, job, 50, {}, []); + + expect(res.saved).toBe(true); + expect(resolveStageTypeForStatusId).toHaveBeenCalledWith("s-new", "u1"); + expect(mockDb.job.create.mock.calls[0][0].data.stages).toEqual({ + create: { stageTypeId: "t-new", occurredAt: createdAt, isCurrent: true }, + }); + }); + + // A concurrent run won the dedup race. The stage rides on the same + // statement, so it goes down with the job rather than orphaning. + it("saves nothing when the unique index refuses the job", async () => { + mockDb.job.create.mockRejectedValue({ code: "P2002" }); + + const res = await persistDiscoveredJob(automation, job, 50, {}, []); + + expect(res.saved).toBe(false); + }); +}); diff --git a/__tests__/question.actions.spec.ts b/__tests__/question.actions.spec.ts index 4ad1300cb..0f7a95443 100644 --- a/__tests__/question.actions.spec.ts +++ b/__tests__/question.actions.spec.ts @@ -24,6 +24,9 @@ vi.mock("@prisma/client", () => { tag: { findMany: vi.fn(), }, + jobStagePrepQuestion: { + count: vi.fn(), + }, }; return { PrismaClient: vi.fn(function() { return mPrismaClient; }) }; }); @@ -47,6 +50,7 @@ describe("Question Actions", () => { beforeEach(() => { vi.clearAllMocks(); + (prisma.jobStagePrepQuestion.count as any).mockResolvedValue(0); }); // getQuestionsList @@ -451,6 +455,28 @@ describe("Question Actions", () => { expect(result).toEqual({ success: false, message: "DB error" }); }); + + it("blocks deleting a question that sits on a prep list, naming the count", async () => { + (getCurrentUser as any).mockResolvedValue(mockUser); + (prisma.jobStagePrepQuestion.count as any).mockResolvedValue(3); + + const result = await deleteQuestion("q-1"); + + expect(result.success).toBe(false); + expect(result.message).toContain("3"); + expect(prisma.question.delete).not.toHaveBeenCalled(); + }); + + it("deletes a question that is on no prep list", async () => { + (getCurrentUser as any).mockResolvedValue(mockUser); + (prisma.jobStagePrepQuestion.count as any).mockResolvedValue(0); + (prisma.question.delete as any).mockResolvedValue(mockQuestion); + + const result = await deleteQuestion("q-1"); + + expect(result.success).toBe(true); + expect(prisma.question.delete).toHaveBeenCalled(); + }); }); // getTagsWithQuestionCounts diff --git a/__tests__/resolveJobStageType.spec.ts b/__tests__/resolveJobStageType.spec.ts new file mode 100644 index 000000000..0c7e7f276 --- /dev/null +++ b/__tests__/resolveJobStageType.spec.ts @@ -0,0 +1,170 @@ +import { + resolveJobStageType, + resolveStageTypeForStatusId, +} from "@/lib/jobs/resolve"; +import prisma from "@/lib/db"; + +vi.mock("@/lib/db", () => ({ + default: { + jobStageType: { + findUnique: vi.fn(), + create: vi.fn(), + aggregate: vi.fn(), + updateMany: vi.fn(), + }, + jobStatus: { findUnique: vi.fn() }, + }, +})); + +const db = prisma as any; + +describe("resolveJobStageType", () => { + beforeEach(() => { + vi.clearAllMocks(); + db.jobStageType.aggregate.mockImplementation(({ where }: any) => + Promise.resolve({ _max: { sortOrder: where.statusId ? 6 : 13 } }), + ); + db.jobStageType.updateMany.mockResolvedValue({ count: 0 }); + }); + + it("matches an existing type on canonical value", async () => { + db.jobStageType.findUnique.mockResolvedValue({ id: "t1", label: "On-site" }); + + const res = await resolveJobStageType(" ON-SITE ", "u1", "s1"); + + expect(db.jobStageType.findUnique).toHaveBeenCalledWith({ + where: { value_createdBy: { value: "on-site", createdBy: "u1" } }, + }); + expect(res).toEqual({ id: "t1", label: "On-site", created: false }); + expect(db.jobStageType.create).not.toHaveBeenCalled(); + }); + + // D7: canonicalizeEntityValue folds case, diacritics, commas and whitespace + // and nothing else, so "On-site" and "Onsite" are genuinely two types. This + // test pins the limit so nobody later reads the resolver as fuzzy matching. + it("does not fold hyphens, so On-site and Onsite are different types", async () => { + db.jobStageType.findUnique.mockResolvedValue(null); + db.jobStageType.create.mockResolvedValue({ id: "t9", label: "Onsite" }); + + await resolveJobStageType("Onsite", "u1", "s1"); + + expect(db.jobStageType.findUnique).toHaveBeenCalledWith({ + where: { value_createdBy: { value: "onsite", createdBy: "u1" } }, + }); + expect(db.jobStageType.create).toHaveBeenCalled(); + }); + + // Only `value` collapses internal whitespace; the label is stored trimmed + // but otherwise verbatim, exactly as resolveEntity does for the other five. + it("creates with the trimmed label, canonical value and the next sortOrder under its own status", async () => { + db.jobStageType.findUnique.mockResolvedValue(null); + db.jobStageType.create.mockResolvedValue({ + id: "t2", + label: "Panel Interview", + }); + + const res = await resolveJobStageType(" Panel Interview ", "u1", "s9"); + + expect(db.jobStageType.create).toHaveBeenCalledWith({ + data: { + label: "Panel Interview", + value: "panel interview", + statusId: "s9", + sortOrder: 7, + createdBy: "u1", + }, + }); + // The freed slot is made by pushing everything from it down one, so the + // new type lands beside its siblings instead of after the terminal stages. + expect(db.jobStageType.updateMany).toHaveBeenCalledWith({ + where: { createdBy: "u1", sortOrder: { gte: 7 } }, + data: { sortOrder: { increment: 1 } }, + }); + expect(res.created).toBe(true); + }); + + it("appends at the end when its parent status has no type yet", async () => { + db.jobStageType.findUnique.mockResolvedValue(null); + db.jobStageType.aggregate.mockImplementation(({ where }: any) => + Promise.resolve({ _max: { sortOrder: where.statusId ? null : 13 } }), + ); + db.jobStageType.create.mockResolvedValue({ id: "t8", label: "Take-home" }); + + await resolveJobStageType("Take-home", "u1", "s-new-status"); + + expect(db.jobStageType.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ sortOrder: 14 }) }), + ); + }); + + it("returns the winner's row when a concurrent create wins the unique race", async () => { + db.jobStageType.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "t3", label: "Panel" }); + db.jobStageType.create.mockRejectedValue({ code: "P2002" }); + + const res = await resolveJobStageType("Panel", "u1", "s1"); + + expect(res).toEqual({ id: "t3", label: "Panel", created: false }); + }); + + it("rejects an empty label", async () => { + await expect(resolveJobStageType(" ", "u1", "s1")).rejects.toThrow( + "A non-empty label is required", + ); + }); +}); + +describe("resolveStageTypeForStatusId", () => { + beforeEach(() => { + vi.clearAllMocks(); + db.jobStageType.aggregate.mockImplementation(({ where }: any) => + Promise.resolve({ _max: { sortOrder: where.statusId ? 6 : 13 } }), + ); + db.jobStageType.updateMany.mockResolvedValue({ count: 0 }); + }); + + // The two hyphenated offer statuses are why the key is the canonical LABEL + // and not status.value: "offer-accepted" would never match the seeded row. + it("looks the type up by the canonical form of the status label", async () => { + db.jobStatus.findUnique.mockResolvedValue({ + id: "s-oa", + label: "Offer Accepted", + value: "offer-accepted", + }); + db.jobStageType.findUnique.mockResolvedValue({ + id: "t-oa", + label: "Offer Accepted", + }); + + const id = await resolveStageTypeForStatusId("s-oa", "u1"); + + expect(db.jobStageType.findUnique).toHaveBeenCalledWith({ + where: { value_createdBy: { value: "offer accepted", createdBy: "u1" } }, + }); + expect(id).toBe("t-oa"); + }); + + it("creates the status-named type when the user has deleted it", async () => { + db.jobStatus.findUnique.mockResolvedValue({ + id: "s-w", + label: "Withdrawn", + value: "withdrawn", + }); + db.jobStageType.findUnique.mockResolvedValue(null); + db.jobStageType.create.mockResolvedValue({ id: "t-w", label: "Withdrawn" }); + + const id = await resolveStageTypeForStatusId("s-w", "u1"); + + expect(id).toBe("t-w"); + expect(db.jobStageType.create).toHaveBeenCalled(); + }); + + it("throws when the status does not exist", async () => { + db.jobStatus.findUnique.mockResolvedValue(null); + + await expect(resolveStageTypeForStatusId("nope", "u1")).rejects.toThrow( + "Job status not found", + ); + }); +}); diff --git a/__tests__/stageDisplay.spec.ts b/__tests__/stageDisplay.spec.ts new file mode 100644 index 000000000..e79c106a2 --- /dev/null +++ b/__tests__/stageDisplay.spec.ts @@ -0,0 +1,113 @@ +import { + formatStageDate, + formatStageDateTime, + stageInitials, + isInterviewStage, + terminalStagesFor, + askedTally, +} from "@/components/myjobs/job-details/timeline/stageDisplay"; + +const stage = (over: any = {}) => ({ + id: "s1", + occurredAt: null, + durationMins: null, + prepQuestions: [], + StageType: { id: "t1", value: "offer", sortOrder: 7, Status: { value: "offer" } }, + ...over, +}); + +describe("formatStageDate", () => { + it("renders an em dash for an undated stage rather than a blank", () => { + expect(formatStageDate(null)).toBe("—"); + }); + + it("renders a short month and day", () => { + expect(formatStageDate(new Date(2026, 8, 24, 10, 0))).toBe("Sep 24"); + }); +}); + +describe("formatStageDateTime", () => { + it("says so plainly when there is no date", () => { + expect(formatStageDateTime(null, null)).toBe("No date set"); + }); + + it("includes the duration only when there is one", () => { + const past = new Date(2020, 8, 24, 10, 0); + expect(formatStageDateTime(past, 90)).toBe("Sep 24, 2020 · 10:00 AM · 90 min"); + expect(formatStageDateTime(past, null)).toBe("Sep 24, 2020 · 10:00 AM"); + }); + + // The artboard's wording, kept only where it is true. A past stage that + // reads "Scheduled for" a date already gone is simply wrong, and the + // prefix doubles as the at-a-glance upcoming/done signal. + it('prefixes "Scheduled for" only when the stage is still ahead', () => { + const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + expect(formatStageDateTime(future, 90)).toMatch(/^Scheduled for /); + + const past = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + expect(formatStageDateTime(past, 90)).not.toMatch(/^Scheduled for /); + }); +}); + +describe("stageInitials", () => { + it("takes the first letter of the first two words", () => { + expect(stageInitials("Priya Nair")).toBe("PN"); + expect(stageInitials("Cher")).toBe("C"); + expect(stageInitials(" ")).toBe("?"); + }); +}); + +describe("isInterviewStage", () => { + it("discriminates on the parent status, never on the stage's timing", () => { + expect(isInterviewStage(stage({ StageType: { Status: { value: "interview" } } }) as any)).toBe(true); + expect(isInterviewStage(stage() as any)).toBe(false); + }); +}); + +describe("terminalStagesFor", () => { + const types = [ + { id: "t-off", value: "offer", sortOrder: 7, statusId: "s-off", label: "Offer", Status: { value: "offer" } }, + { id: "t-rej", value: "rejected", sortOrder: 10, statusId: "s-rej", label: "Rejected", Status: { value: "rejected" } }, + { id: "t-int", value: "interview", sortOrder: 3, statusId: "s-int", label: "Interview", Status: { value: "interview" } }, + ] as any; + + // D6: Offer alone. Rejected and Withdrawn are alternative endings, not + // next steps, and the mockup shows a single greyed tail step. + it("returns only the unreached Offer step", () => { + const result = terminalStagesFor([], types); + expect(result.map((t: any) => t.value)).toEqual(["offer"]); + }); + + it("drops the terminal type the job has already reached", () => { + const result = terminalStagesFor( + [stage({ stageTypeId: "t-off" })] as any, + types, + ); + expect(result).toEqual([]); + }); + + // A custom "Verbal Offer" type under the same parent status must not put a + // second greyed Offer step on the rail. + it("returns at most one type per parent status", () => { + const withCustom = [ + ...types, + { id: "t-verbal", value: "verbal offer", sortOrder: 14, statusId: "s-off", label: "Verbal Offer", Status: { value: "offer" } }, + ] as any; + + const result = terminalStagesFor([], withCustom); + expect(result).toHaveLength(1); + expect(result[0].value).toBe("offer"); + }); +}); + +describe("askedTally", () => { + it("counts asked out of total", () => { + expect( + askedTally( + stage({ + prepQuestions: [{ asked: true }, { asked: true }, { asked: false }, { asked: false }], + }) as any, + ), + ).toEqual({ asked: 2, total: 4 }); + }); +}); diff --git a/__tests__/updateJobFromNames.spec.ts b/__tests__/updateJobFromNames.spec.ts index 6bafaeeec..807fcf8ac 100644 --- a/__tests__/updateJobFromNames.spec.ts +++ b/__tests__/updateJobFromNames.spec.ts @@ -8,15 +8,19 @@ import { resolveWorkplaceType, resolveJobStatus, resolveTags, + resolveStageTypeForStatusId, } from "@/lib/jobs/resolve"; import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); vi.mock("@prisma/client", () => { - const mPrismaClient = { + const mPrismaClient: any = { job: { findFirst: vi.fn(), update: vi.fn() }, + jobStage: { findFirst: vi.fn(), create: vi.fn(), updateMany: vi.fn() }, + $transaction: vi.fn(), }; + mPrismaClient.$transaction.mockImplementation((fn: any) => fn(mPrismaClient)); return { PrismaClient: vi.fn(function () { return mPrismaClient; }) }; }); @@ -29,6 +33,7 @@ vi.mock("@/lib/jobs/resolve", () => ({ resolveWorkplaceType: vi.fn(), resolveJobStatus: vi.fn(), resolveTags: vi.fn(), + resolveStageTypeForStatusId: vi.fn(), })); const userId = "user-1"; @@ -50,6 +55,10 @@ describe("updateJobFromNames", () => { (resolveJobType as any).mockReturnValue("FT"); (resolveWorkplaceType as any).mockReturnValue("REMOTE"); (resolveTags as any).mockResolvedValue({ resolved: [], dropped: [] }); + (resolveStageTypeForStatusId as any).mockResolvedValue("t-offer"); + (prisma as any).$transaction.mockImplementation((fn: any) => fn(prisma)); + (prisma as any).jobStage.create.mockResolvedValue({ id: "st-new" }); + (prisma as any).jobStage.updateMany.mockResolvedValue({ count: 1 }); }); it("returns updated:false when the job is not the caller's MCP-created job", async () => { @@ -67,7 +76,7 @@ describe("updateJobFromNames", () => { expect(prisma.job.findFirst).toHaveBeenCalledWith({ where: { id: "job-1", userId, createdVia: { not: null } }, - select: { id: true, descriptionCompleteness: true }, + select: { id: true, descriptionCompleteness: true, appliedDate: true }, }); }); @@ -167,4 +176,57 @@ describe("updateJobFromNames", () => { expect(result.message).toContain("No fields to update"); expect(prisma.job.update).not.toHaveBeenCalled(); }); + + // D9: the sixth write path to Job.statusId keeps the timeline in step. + it("appends a current stage when MCP changes the job's status", async () => { + (resolveJobStatus as any).mockResolvedValue("s-offer"); + (prisma as any).jobStage.findFirst.mockResolvedValue({ + id: "st-int", + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + await updateJobFromNames({ jobId: "job-1", status: "offer" }, userId); + + expect((prisma as any).jobStage.create.mock.calls[0][0].data).toMatchObject({ + jobId: "job-1", + stageTypeId: "t-offer", + occurredAt: expect.any(Date), + }); + }); + + // Decision 16's rule, reached through MCP: the comparison is on parent + // status, so re-sending a status the job already holds is a no-op. + it("appends nothing when the current stage already carries that status", async () => { + (resolveJobStatus as any).mockResolvedValue("s-int"); + (prisma as any).jobStage.findFirst.mockResolvedValue({ + id: "st-tech", + StageType: { statusId: "s-int", Status: { value: "interview" } }, + }); + + await updateJobFromNames({ jobId: "job-1", status: "interview" }, userId); + + expect((prisma as any).jobStage.create).not.toHaveBeenCalled(); + }); + + it("leaves the timeline alone when no status field was sent", async () => { + await updateJobFromNames({ jobId: "job-1", salaryRange: "100k" }, userId); + + expect(resolveStageTypeForStatusId).not.toHaveBeenCalled(); + expect((prisma as any).jobStage.create).not.toHaveBeenCalled(); + }); + + // D5, now covering the MCP path as well. + it("never re-stamps an appliedDate the job already has", async () => { + (prisma.job.findFirst as any).mockResolvedValue({ + id: "job-1", + descriptionCompleteness: null, + appliedDate: new Date("2026-09-03T00:00:00Z"), + }); + + await updateJobFromNames({ jobId: "job-1", applied: true }, userId); + + expect((prisma.job.update as any).mock.calls[0][0].data).not.toHaveProperty( + "appliedDate", + ); + }); }); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 89d762307..3f3a36c5a 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -152,6 +152,9 @@ export type CleanupRegistry = { automation: (name: string) => void; contact: (name: string) => void; contactRole: (name: string) => void; + // Unused today (both timeline flows pick seeded stage types), but a test + // that types a custom stage name would otherwise leave a permanent row. + jobStageType: (name: string) => void; }; type Fixtures = { @@ -181,6 +184,7 @@ export const test = base.extend<Fixtures>({ const automations: string[] = []; const contacts: string[] = []; const contactRoles: string[] = []; + const jobStageTypes: string[] = []; await use({ job: (id) => jobIds.push(id), resume: (title) => resumes.push(title), @@ -196,6 +200,7 @@ export const test = base.extend<Fixtures>({ automation: (name) => automations.push(name), contact: (name) => contacts.push(name), contactRole: (name) => contactRoles.push(name), + jobStageType: (name) => jobStageTypes.push(name), }); // page.request carries the session cookie; page is still alive here // because cleanup tears down before the page fixture. @@ -215,6 +220,7 @@ export const test = base.extend<Fixtures>({ automations, contacts, contactRoles, + jobStageTypes, }, }); if (!res.ok()) { diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts new file mode 100644 index 000000000..41e629609 --- /dev/null +++ b/e2e/timeline.spec.ts @@ -0,0 +1,79 @@ +import { test, expect, createNewJob, uniqueName } from "./fixtures"; +import type { Page } from "@playwright/test"; + +// Opens the Add Stage dialog and picks a seeded type. The picker is a +// non-creatable ComboBox: filter first, then match the label exactly — +// "Offer" would otherwise also hit "Offer Accepted". +async function openStageDialogWith(page: Page, stageLabel: string) { + await page.getByTestId("timeline-add-stage-btn").click(); + await page.getByTestId("stage-type-select").click(); + const search = page.getByPlaceholder("Search stage"); + await search.fill(stageLabel); + await page.getByRole("option", { name: stageLabel, exact: true }).click(); + await expect(search).not.toBeVisible(); +} + +test("adding an Offer stage moves the job's status on the Jobs list", async ({ + page, + cleanup, +}) => { + const jobTitle = uniqueName("Timeline Engineer"); + const jobId = await createNewJob(page, jobTitle, cleanup); + + await page.goto(`/dashboard/myjobs/${jobId}?tab=timeline`); + await openStageDialogWith(page, "Offer"); + + await expect( + page.getByRole("checkbox", { name: /Set as current stage/ }), + ).toBeChecked(); + await page.getByRole("button", { name: "Add Stage", exact: true }).click(); + + // The Stage History list reflects it without a reload. + await expect(page.getByRole("button", { name: /^Offer/ }).first()).toBeVisible(); + await expect(page.getByRole("tab", { name: /Timeline/ })).toContainText("2"); + + // ...and the derived status reached the Jobs list. + await page.goto("/dashboard/myjobs"); + const row = page.getByRole("row", { name: jobTitle }).first(); + await expect(row).toBeVisible(); + await expect(row).toContainText("Offer"); +}); + +test("linking an interviewer to a stage puts them on the job's Contacts tab", async ({ + page, + cleanup, +}) => { + const jobTitle = uniqueName("Timeline Interview"); + const contactName = uniqueName("Priya Tester"); + const jobId = await createNewJob(page, jobTitle, cleanup); + + await page.goto(`/dashboard/myjobs/${jobId}?tab=timeline`); + + // An interview stage first: the stage-scoped menu items gate on stage kind. + await openStageDialogWith(page, "Final / Onsite Interview"); + await page.getByRole("button", { name: "Add Stage", exact: true }).click(); + + // Adding a stage does not move the selection, and the menu targets the + // selected stage — so pick the new one before opening it. + await page + .getByRole("button", { name: /^Final \/ Onsite Interview/ }) + .first() + .click(); + + await page.getByTestId("update-status-menu-btn").click(); + await page.getByRole("menuitem", { name: "Add Interviewers" }).click(); + + await page.getByPlaceholder("Full name").fill(contactName); + cleanup.contact(contactName); + await page.getByRole("button", { name: "Add Contact" }).click(); + await expect( + page.getByLabel(new RegExp(`${contactName} is already linked`)), + ).toBeVisible(); + await page.getByRole("button", { name: /^Done/ }).click(); + + // The job's Contacts tab is the single roster of everyone involved. Loaded + // fresh: JobContactsTab seeds its rows from the server prop on mount. + await page.goto(`/dashboard/myjobs/${jobId}?tab=contacts`); + await expect(page.getByText(contactName)).toBeVisible(); + await expect(page.getByText("Interviewer")).toBeVisible(); +}); diff --git a/prisma/migrations/20260919144828_job_stages_timeline/migration.sql b/prisma/migrations/20260919144828_job_stages_timeline/migration.sql new file mode 100644 index 000000000..53f449b48 --- /dev/null +++ b/prisma/migrations/20260919144828_job_stages_timeline/migration.sql @@ -0,0 +1,237 @@ +/* + Warnings: + + - You are about to drop the `Interview` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the column `interviewId` on the `Contact` table. All the data in the column will be lost. + +*/ +-- Data-only: JobStatus rows are seeded at signup, so existing databases need +-- the new status inserted. Shape copied from 20260904000000_job_status_offer_outcomes. +INSERT OR IGNORE INTO "JobStatus" ("id", "label", "value") VALUES + ('3f2b6c41-88d5-4a0e-9d0a-6a1c9f0b7e22', 'Withdrawn', 'withdrawn'); + +-- CreateTable +CREATE TABLE "JobStageType" ( + "id" TEXT NOT NULL PRIMARY KEY, + "label" TEXT NOT NULL, + "value" TEXT NOT NULL, + "statusId" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdBy" TEXT NOT NULL, + CONSTRAINT "JobStageType_statusId_fkey" FOREIGN KEY ("statusId") REFERENCES "JobStatus" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "JobStageType_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "JobStage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "jobId" TEXT NOT NULL, + "stageTypeId" TEXT NOT NULL, + "occurredAt" DATETIME, + "isCurrent" BOOLEAN NOT NULL DEFAULT false, + "outcome" TEXT, + "notes" TEXT, + "durationMins" INTEGER, + "format" TEXT, + "location" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "JobStage_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "JobStage_stageTypeId_fkey" FOREIGN KEY ("stageTypeId") REFERENCES "JobStageType" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "JobStageInterviewer" ( + "id" TEXT NOT NULL PRIMARY KEY, + "stageId" TEXT NOT NULL, + "contactId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "JobStageInterviewer_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "JobStage" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "JobStageInterviewer_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "JobStagePrepQuestion" ( + "id" TEXT NOT NULL PRIMARY KEY, + "stageId" TEXT NOT NULL, + "questionId" TEXT NOT NULL, + "asked" BOOLEAN NOT NULL DEFAULT false, + "askedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "JobStagePrepQuestion_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "JobStage" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "JobStagePrepQuestion_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Contact" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "title" TEXT, + "email" TEXT, + "phone" TEXT, + "linkedinUrl" TEXT, + "companyId" TEXT, + "locationId" TEXT, + "relationship" TEXT, + "workedAtCompanyId" TEXT, + "workedFrom" DATETIME, + "workedTo" DATETIME, + "roleId" TEXT, + "notes" TEXT, + "lastContactedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "createdBy" TEXT NOT NULL, + CONSTRAINT "Contact_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Contact_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Contact_workedAtCompanyId_fkey" FOREIGN KEY ("workedAtCompanyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Contact_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "ContactRole" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Contact_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Contact" ("companyId", "createdAt", "createdBy", "email", "id", "lastContactedAt", "linkedinUrl", "locationId", "name", "notes", "phone", "relationship", "roleId", "title", "updatedAt", "workedAtCompanyId", "workedFrom", "workedTo") SELECT "companyId", "createdAt", "createdBy", "email", "id", "lastContactedAt", "linkedinUrl", "locationId", "name", "notes", "phone", "relationship", "roleId", "title", "updatedAt", "workedAtCompanyId", "workedFrom", "workedTo" FROM "Contact"; +DROP TABLE "Contact"; +ALTER TABLE "new_Contact" RENAME TO "Contact"; +CREATE INDEX "Contact_createdBy_idx" ON "Contact"("createdBy"); +CREATE INDEX "Contact_companyId_idx" ON "Contact"("companyId"); +CREATE INDEX "Contact_workedAtCompanyId_idx" ON "Contact"("workedAtCompanyId"); +CREATE INDEX "Contact_roleId_idx" ON "Contact"("roleId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- DropTable +-- After the Contact redefinition: the old Contact still holds the interviewId +-- FK into Interview until the rename completes. +PRAGMA foreign_keys=off; +DROP TABLE "Interview"; +PRAGMA foreign_keys=on; + +-- CreateIndex +CREATE INDEX "JobStageType_createdBy_idx" ON "JobStageType"("createdBy"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobStageType_value_createdBy_key" ON "JobStageType"("value", "createdBy"); + +-- CreateIndex +CREATE INDEX "JobStage_jobId_idx" ON "JobStage"("jobId"); + +-- CreateIndex +CREATE INDEX "JobStage_stageTypeId_idx" ON "JobStage"("stageTypeId"); + +-- Exactly one current stage per job. Partial index (not expressible in the +-- Prisma DSL) so historical stages are unaffected. Same mechanism as +-- 20260710000001_automation_run_single_active. +CREATE UNIQUE INDEX "JobStage_jobId_current_key" +ON "JobStage"("jobId") +WHERE "isCurrent" = 1; + +-- CreateIndex +CREATE INDEX "JobStageInterviewer_contactId_idx" ON "JobStageInterviewer"("contactId"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobStageInterviewer_stageId_contactId_key" ON "JobStageInterviewer"("stageId", "contactId"); + +-- CreateIndex +CREATE INDEX "JobStagePrepQuestion_questionId_idx" ON "JobStagePrepQuestion"("questionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobStagePrepQuestion_stageId_questionId_key" ON "JobStagePrepQuestion"("stageId", "questionId"); + +-- Seed the fourteen default stage types for existing users. New accounts get +-- these from signup(). `value` is canonicalizeEntityValue(label), which is how +-- a status resolves back to its stage type (no isDefaultForStatus column). +INSERT INTO "JobStageType" ("id", "label", "value", "statusId", "sortOrder", "createdBy") +SELECT lower( + substr(hex(randomblob(16)), 1, 8) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 12) +), t."label", t."value", s."id", t."sortOrder", u."id" +FROM "User" u +CROSS JOIN ( + SELECT 'New' AS "label", 'new' AS "value", 'new' AS "status", 0 AS "sortOrder" + UNION ALL SELECT 'Draft', 'draft', 'draft', 1 + UNION ALL SELECT 'Applied', 'applied', 'applied', 2 + UNION ALL SELECT 'Interview', 'interview', 'interview', 3 + UNION ALL SELECT '1st Screening Interview', '1st screening interview', 'interview', 4 + UNION ALL SELECT '2nd Technical Interview', '2nd technical interview', 'interview', 5 + UNION ALL SELECT 'Final / Onsite Interview', 'final / onsite interview', 'interview', 6 + UNION ALL SELECT 'Offer', 'offer', 'offer', 7 + UNION ALL SELECT 'Offer Accepted', 'offer accepted', 'offer-accepted', 8 + UNION ALL SELECT 'Offer Declined', 'offer declined', 'offer-declined', 9 + UNION ALL SELECT 'Rejected', 'rejected', 'rejected', 10 + UNION ALL SELECT 'Expired', 'expired', 'expired', 11 + UNION ALL SELECT 'Archived', 'archived', 'archived', 12 + UNION ALL SELECT 'Withdrawn', 'withdrawn', 'withdrawn', 13 +) t +JOIN "JobStatus" s ON s."value" = t."status"; + +-- Backfill, part 1 of 4: a New stage at Job.createdAt for every job. The +-- createdAt offsets below are deliberate: stages tiebreak on createdAt when +-- occurredAt is null, and CURRENT_TIMESTAMP is constant within a statement. +INSERT INTO "JobStage" ("id", "jobId", "stageTypeId", "occurredAt", "isCurrent", "createdAt", "updatedAt") +SELECT lower( + substr(hex(randomblob(16)), 1, 8) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 12) +), j."id", t."id", j."createdAt", 0, + datetime('now', '-3 seconds'), datetime('now', '-3 seconds') +FROM "Job" j +JOIN "JobStageType" t ON t."createdBy" = j."userId" AND t."value" = 'new'; + +-- Backfill, part 2 of 4: an Applied stage at Job.appliedDate, only where the +-- job is actually applied. appliedDate is taken verbatim — a null one leaves +-- the stage undated rather than inventing a timestamp. +INSERT INTO "JobStage" ("id", "jobId", "stageTypeId", "occurredAt", "isCurrent", "createdAt", "updatedAt") +SELECT lower( + substr(hex(randomblob(16)), 1, 8) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 12) +), j."id", t."id", j."appliedDate", 0, + datetime('now', '-2 seconds'), datetime('now', '-2 seconds') +FROM "Job" j +JOIN "JobStageType" t ON t."createdBy" = j."userId" AND t."value" = 'applied' +WHERE j."applied" = 1; + +-- Backfill, part 3 of 4: an undated stage matching the job's current status, +-- marked current. Skipped where it would repeat one of the two stages above — +-- a job still at New, or at Applied with an Applied stage already written. +-- The CASE is the status -> stage-type reverse lookup: canonical form of the +-- status LABEL, which differs from status.value for the two offer outcomes. +INSERT INTO "JobStage" ("id", "jobId", "stageTypeId", "occurredAt", "isCurrent", "createdAt", "updatedAt") +SELECT lower( + substr(hex(randomblob(16)), 1, 8) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 4) || '-' || + substr(hex(randomblob(16)), 1, 12) +), j."id", t."id", NULL, 1, + datetime('now', '-1 seconds'), datetime('now', '-1 seconds') +FROM "Job" j +JOIN "JobStatus" s ON s."id" = j."statusId" +JOIN "JobStageType" t ON t."createdBy" = j."userId" AND t."value" = CASE s."value" + WHEN 'offer-accepted' THEN 'offer accepted' + WHEN 'offer-declined' THEN 'offer declined' + ELSE s."value" +END +WHERE s."value" <> 'new' + AND NOT (s."value" = 'applied' AND j."applied" = 1); + +-- Backfill, part 4 of 4: where part 3 was skipped, the already-written dated +-- stage becomes the current one. Every backfilled job now has exactly one. +UPDATE "JobStage" SET "isCurrent" = 1 +WHERE "id" IN ( + SELECT st."id" + FROM "JobStage" st + JOIN "Job" j ON j."id" = st."jobId" + JOIN "JobStatus" s ON s."id" = j."statusId" + JOIN "JobStageType" t ON t."id" = st."stageTypeId" + WHERE (s."value" = 'new' AND t."value" = 'new') + OR (s."value" = 'applied' AND j."applied" = 1 AND t."value" = 'applied') +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17c44665a..475c3891b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { JobSource JobSource[] Contact Contact[] ContactRole ContactRole[] + JobStageType JobStageType[] Profile Profile[] Activity Activity[] ActivityType ActivityType[] @@ -241,10 +242,8 @@ model Contact { createdBy String // userId user User @relation(fields: [createdBy], references: [id]) - Interview Interview? @relation(fields: [interviewId], references: [id]) - interviewId String? - jobLinks JobContact[] + stageLinks JobStageInterviewer[] @@index([createdBy]) @@index([companyId]) @@ -295,6 +294,7 @@ model JobStatus { label String value String @unique jobs Job[] + stageTypes JobStageType[] } model Location { @@ -356,12 +356,77 @@ model JobSource { @@unique([value, createdBy]) } -model Interview { - id String @id @default(uuid()) - createdAt DateTime - jobId String - job Job? @relation(fields: [jobId], references: [id]) - interviewers Contact[] +// The kind of stage. Mirrors ContactRole in ownership and resolution, and +// JobSource in being user-extensible. statusId is both the derived-status +// source and the interview-kind discriminator (spec decisions 3 and 10). +model JobStageType { + id String @id @default(uuid()) + label String + value String + statusId String + Status JobStatus @relation(fields: [statusId], references: [id]) + sortOrder Int @default(0) + createdBy String // userId + user User @relation(fields: [createdBy], references: [id]) + stages JobStage[] + + @@unique([value, createdBy]) + @@index([createdBy]) +} + +// One entry in a job's timeline. No ownership column: a stage has no meaning +// apart from its job, so ownership traverses Job: { userId }. +model JobStage { + id String @id @default(uuid()) + jobId String + Job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) + stageTypeId String + StageType JobStageType @relation(fields: [stageTypeId], references: [id]) + occurredAt DateTime? + isCurrent Boolean @default(false) + outcome String? + notes String? + durationMins Int? + format String? + location String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + interviewers JobStageInterviewer[] + prepQuestions JobStagePrepQuestion[] + + @@index([jobId]) + @@index([stageTypeId]) + // Also has a hand-written partial unique index (not expressible here): + // UNIQUE(jobId) WHERE isCurrent = 1 + // See migrations/20260919000000_job_stages_timeline +} + +model JobStageInterviewer { + id String @id @default(uuid()) + stageId String + Stage JobStage @relation(fields: [stageId], references: [id], onDelete: Cascade) + contactId String + Contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([stageId, contactId]) + @@index([contactId]) +} + +// questionId is deliberately NOT cascading: erasing the record that a question +// was asked in a real interview is worse than a delete that fails loudly. +model JobStagePrepQuestion { + id String @id @default(uuid()) + stageId String + Stage JobStage @relation(fields: [stageId], references: [id], onDelete: Cascade) + questionId String + Question Question @relation(fields: [questionId], references: [id]) + asked Boolean @default(false) + askedAt DateTime? + createdAt DateTime @default(now()) + + @@unique([stageId, questionId]) + @@index([questionId]) } model Job { @@ -387,7 +452,6 @@ model Job { salaryRange String? Location Location? @relation(fields: [locationId], references: [id]) locationId String? - Interview Interview[] Resume Resume? @relation(fields: [resumeId], references: [id]) resumeId String? CoverLetter CoverLetter? @relation(fields: [coverLetterId], references: [id]) @@ -395,6 +459,7 @@ model Job { Notes Note[] tags Tag[] contactLinks JobContact[] + stages JobStage[] // Automation discovery fields automationId String? @@ -589,6 +654,7 @@ model Question { createdBy String user User @relation(fields: [createdBy], references: [id]) tags Tag[] + prepLinks JobStagePrepQuestion[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt createdVia String? // MCP provenance: which agent/token created this, mirrors Job.createdVia diff --git a/src/actions/auth.actions.ts b/src/actions/auth.actions.ts index 47aa2b1c1..18e8cbc05 100644 --- a/src/actions/auth.actions.ts +++ b/src/actions/auth.actions.ts @@ -5,7 +5,12 @@ import { delay } from "@/utils/delay"; import prisma from "@/lib/db"; import bcrypt from "bcryptjs"; import { SignupFormSchema } from "@/models/signupForm.schema"; -import { CONTACT_ROLES, JOB_SOURCES, JOB_STATUSES } from "@/lib/constants"; +import { + CONTACT_ROLES, + JOB_SOURCES, + JOB_STAGES, + JOB_STATUSES, +} from "@/lib/constants"; export async function signup(formData: { name: string; @@ -57,6 +62,21 @@ export async function signup(formData: { }); } + // Stage types point at JobStatus, so this must follow the upsert loop. + const statusRows = await prisma.jobStatus.findMany({ + select: { id: true, value: true }, + }); + const statusIdByValue = new Map(statusRows.map((s) => [s.value, s.id])); + await prisma.jobStageType.createMany({ + data: JOB_STAGES.map((stage) => ({ + label: stage.label, + value: stage.value, + statusId: statusIdByValue.get(stage.status)!, + sortOrder: stage.sortOrder, + createdBy: newUser.id, + })), + }); + return { success: true }; } diff --git a/src/actions/contact/queries.ts b/src/actions/contact/queries.ts index 463144956..0c4af44e5 100644 --- a/src/actions/contact/queries.ts +++ b/src/actions/contact/queries.ts @@ -62,6 +62,7 @@ export const getAllContacts = async (): Promise<any | undefined> => { select: { id: true, name: true, + title: true, email: true, Company: { select: { label: true } }, }, @@ -71,6 +72,9 @@ export const getAllContacts = async (): Promise<any | undefined> => { return rows.map((row) => ({ id: row.id, label: row.name, + // For pickers that show more than a name (the Link Interviewers dialog). + title: row.title, + company: row.Company?.label ?? null, value: [row.name, row.email, row.Company?.label] .filter(Boolean) .join(" ") diff --git a/src/actions/job/mutations.ts b/src/actions/job/mutations.ts index 10ac08eb6..b08b6ff32 100644 --- a/src/actions/job/mutations.ts +++ b/src/actions/job/mutations.ts @@ -6,6 +6,8 @@ import { AddJobFormSchema } from "@/models/addJobForm.schema"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { requireUser } from "../shared"; +import { appendStatusStage } from "./shared"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; type JobRefs = { jobTitleId?: string | null; @@ -184,30 +186,38 @@ export const updateJob = async ( tagIds: tagIds.filter((tagId) => !currentTagIds.has(tagId)), }); - const job = await prisma.job.update({ - where: { - id, - userId: user.id, - }, - data: { - jobTitleId: title, - companyId: company, - locationId: location, - statusId: status, - jobSourceId: source, - salaryRange: salaryRange || null, - createdAt: new Date(), - dueDate: dueDate, - appliedDate: dateApplied, - description: jobDescription, - jobType: type, - workplaceType, - jobUrl, - applied, - resumeId: resume, - coverLetterId: coverLetter, - tags: { set: tagIds.map((id) => ({ id })) }, - }, + // Same D4 rule: resolve before the transaction opens. + const stageTypeId = await resolveStageTypeForStatusId(status, user.id); + + const job = await prisma.$transaction(async (tx: any) => { + // The Edit Job dialog's Status field is the fourth entry point into the + // timeline; without this a job and its stages go permanently out of step. + await appendStatusStage(tx, id, status, stageTypeId, user.id); + return tx.job.update({ + where: { + id, + userId: user.id, + }, + data: { + jobTitleId: title, + companyId: company, + locationId: location, + statusId: status, + jobSourceId: source, + salaryRange: salaryRange || null, + createdAt: new Date(), + dueDate: dueDate, + appliedDate: dateApplied, + description: jobDescription, + jobType: type, + workplaceType, + jobUrl, + applied, + resumeId: resume, + coverLetterId: coverLetter, + tags: { set: tagIds.map((id) => ({ id })) }, + }, + }); }); revalidatePath("/dashboard"); return { success: true, data: job }; diff --git a/src/actions/job/queries.ts b/src/actions/job/queries.ts index d0ada893d..f45892c3f 100644 --- a/src/actions/job/queries.ts +++ b/src/actions/job/queries.ts @@ -5,6 +5,7 @@ import { JOB_TYPES } from "@/models/job.model"; import { APP_CONSTANTS } from "@/lib/constants"; import { requireUser } from "../shared"; import { hideUnanalyzedScore } from "./shared"; +import { STAGE_DETAIL_INCLUDE } from "../jobStage/shared"; const JOB_LIST_SELECT = { id: true, @@ -71,6 +72,7 @@ const JOB_DETAILS_INCLUDE = { }, orderBy: { createdAt: "asc" as const }, }, + stages: { include: STAGE_DETAIL_INCLUDE }, }; type JobsListFilters = { diff --git a/src/actions/job/shared.ts b/src/actions/job/shared.ts index 90fc2949e..b6deb8056 100644 --- a/src/actions/job/shared.ts +++ b/src/actions/job/shared.ts @@ -1,6 +1,8 @@ // Not a "use server" module: job/queries.ts and company/queries.ts both map // job rows through this, and exporting it from either would make it an action. +import { promoteStage } from "@/actions/jobStage/shared"; + // An automation job saved without LLM analysis carries only its keyword // pre-rank in matchScore, which the list must not show as an AI match. The // matchData body is dropped so the list payload stays small. @@ -13,3 +15,47 @@ export function hideUnanalyzedScore< } catch {} return analyzed ? job : { ...job, matchScore: null }; } + +type StatusStageTx = Parameters<typeof promoteStage>[0] & { + jobStage: { findFirst: (args: any) => Promise<any>; create: (args: any) => Promise<any> }; +}; + +// The status dropdowns' half of the derived-status invariant. Compares PARENT +// STATUS, not stage type: re-picking Interview while the current stage is +// "2nd Technical Interview" must append nothing (spec decision 16). +// +// stageTypeId is an argument, never resolved here: resolveStageTypeForStatusId +// writes through the base prisma client, and calling it inside the caller's +// transaction contends for SQLite's write lock (D4). +export async function appendStatusStage( + tx: StatusStageTx, + jobId: string, + statusId: string, + stageTypeId: string, + userId: string, +): Promise<void> { + const current = await tx.jobStage.findFirst({ + where: { jobId, isCurrent: true, Job: { userId } }, + select: { + id: true, + StageType: { select: { statusId: true, Status: { select: { value: true } } } }, + }, + }); + // With no current stage the job's own status is the one it holds. A + // stage-less job (a pre-feature backup restore) must not collect a stage + // dated today every time Edit Job is saved with the status untouched. + const held = current + ? current.StageType.statusId + : ( + await tx.job.findFirst({ + where: { id: jobId, userId }, + select: { statusId: true }, + }) + )?.statusId; + if (held === statusId) return; + + const stage = await tx.jobStage.create({ + data: { jobId, stageTypeId, occurredAt: new Date(), isCurrent: false }, + }); + await promoteStage(tx, jobId, stage.id, userId); +} diff --git a/src/actions/job/status.ts b/src/actions/job/status.ts index 41e996a27..85e7716a3 100644 --- a/src/actions/job/status.ts +++ b/src/actions/job/status.ts @@ -4,6 +4,9 @@ import { handleError } from "@/lib/utils"; import { JobStatus } from "@/models/job.model"; import { revalidatePath } from "next/cache"; import { requireUser } from "../shared"; +import { appendStatusStage } from "./shared"; +import { jobFieldsForStage } from "../jobStage/shared"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; export const updateJobStatus = async ( jobId: string, @@ -11,33 +14,33 @@ export const updateJobStatus = async ( ): Promise<any | undefined> => { try { const user = await requireUser(); - const dataToUpdate = () => { - switch (status.value) { - case "applied": - return { - statusId: status.id, - applied: true, - appliedDate: new Date(), - }; - case "interview": - return { - statusId: status.id, - applied: true, - }; - default: - return { - statusId: status.id, - }; - } - }; - const job = await prisma.job.update({ - where: { - id: jobId, - userId: user.id, - }, - data: dataToUpdate(), + const owned = await prisma.job.count({ + where: { id: jobId, userId: user.id }, }); + if (owned === 0) throw new Error("Job not found"); + + // Outside the transaction on purpose: this resolve can create a row, and + // it writes through the base client (D4). + const stageTypeId = await resolveStageTypeForStatusId(status.id, user.id); + + const job = await prisma.$transaction(async (tx: any) => { + await appendStatusStage(tx, jobId, status.id, stageTypeId, user.id); + const existing = await tx.job.findFirst({ + where: { id: jobId, userId: user.id }, + select: { appliedDate: true }, + }); + return tx.job.update({ + where: { id: jobId, userId: user.id }, + data: jobFieldsForStage( + status.value, + status.id, + null, + existing?.appliedDate ?? null, + ), + }); + }); + revalidatePath("/dashboard"); return { job, success: true }; } catch (error) { diff --git a/src/actions/jobStage.actions.ts b/src/actions/jobStage.actions.ts new file mode 100644 index 000000000..9ea12009c --- /dev/null +++ b/src/actions/jobStage.actions.ts @@ -0,0 +1,19 @@ +// Barrel only: each module below carries its own "use server". Explicit named +// re-exports, because `export *` is not reliable from a "use server" file. +export { getJobStages } from "./jobStage/queries"; +export { + addJobStage, + updateJobStage, + deleteJobStage, + setCurrentJobStage, + setStageNotes, +} from "./jobStage/mutations"; +export { + linkStageInterviewer, + unlinkStageInterviewer, +} from "./jobStage/interviewers"; +export { + addStagePrepQuestions, + removeStagePrepQuestion, + setPrepQuestionAsked, +} from "./jobStage/prep"; diff --git a/src/actions/jobStage/interviewers.ts b/src/actions/jobStage/interviewers.ts new file mode 100644 index 000000000..734571d14 --- /dev/null +++ b/src/actions/jobStage/interviewers.ts @@ -0,0 +1,95 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "../shared"; +import { resolveContactRole } from "@/lib/jobs/resolve"; +import { assertInterviewStage, assertStageOwned } from "./shared"; + +const INTERVIEWER_ROLE_LABEL = "Interviewer"; + +const INTERVIEWER_INCLUDE = { + Contact: { + select: { + id: true, + name: true, + title: true, + email: true, + phone: true, + linkedinUrl: true, + Company: { select: { id: true, label: true } }, + }, + }, +}; + +export const linkStageInterviewer = async ( + stageId: string, + contactId: string, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + + // Two sides, checked independently: JobStageInterviewer has no owner + // column, so owning the stage is not permission to attach this contact. + const [stage, contact] = await Promise.all([ + assertStageOwned(stageId, user.id), + prisma.contact.count({ where: { id: contactId, createdBy: user.id } }), + ]); + if (contact === 0) throw new Error("Contact not found"); + assertInterviewStage(stage); + + // The job's Contacts tab stays the single roster of everyone involved. + // Resolve-or-create: the seeded role is renameable and deletable. Resolved + // outside the transaction, which it must never run inside (D4). + const role = await resolveContactRole(INTERVIEWER_ROLE_LABEL, user.id); + + // Both writes together: a stage link without its roster entry leaves the + // Contacts tab disagreeing with the stage's interviewers for good. + const data = await prisma.$transaction(async (tx: any) => { + const link = await tx.jobStageInterviewer.create({ + data: { stageId, contactId }, + include: INTERVIEWER_INCLUDE, + }); + const existing = await tx.jobContact.findFirst({ + where: { jobId: stage.jobId, contactId, roleId: role.id }, + select: { id: true }, + }); + if (!existing) { + await tx.jobContact.create({ + data: { jobId: stage.jobId, contactId, roleId: role.id }, + }); + } + return link; + }); + + return { success: true, data }; + } catch (error: any) { + if (error?.code === "P2002") { + return { + success: false, + message: "That contact is already an interviewer on this stage.", + }; + } + return handleError(error, "Failed to link interviewer."); + } +}; + +export const unlinkStageInterviewer = async ( + linkId: string, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + // The JobContact is deliberately left in place: the person may hold other + // roles on this job, and unlinking a stage is not unlinking the job. + const res = await prisma.jobStageInterviewer.deleteMany({ + where: { + id: linkId, + Stage: { Job: { userId: user.id } }, + Contact: { createdBy: user.id }, + }, + }); + if (res.count === 0) throw new Error("Interviewer link not found"); + return { success: true }; + } catch (error) { + return handleError(error, "Failed to unlink interviewer."); + } +}; diff --git a/src/actions/jobStage/mutations.ts b/src/actions/jobStage/mutations.ts new file mode 100644 index 000000000..99943b732 --- /dev/null +++ b/src/actions/jobStage/mutations.ts @@ -0,0 +1,247 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError, combineDateAndTime } from "@/lib/utils"; +import { revalidatePath } from "next/cache"; +import { requireUser } from "../shared"; +import { resolveJobStageType } from "@/lib/jobs/resolve"; +import { + AddJobStageFormSchema, + type AddJobStageValues, +} from "@/models/jobStage.model"; +import { + STAGE_DETAIL_INCLUDE, + assertStageOwned, + promoteStage, + sortStages, + syncJobFromCurrentStage, +} from "./shared"; + +// Either the picked type (checked for ownership) or a resolved custom name. +// The parent status rides back out because format, duration and location are +// interview-only. +const resolveStageType = async ( + values: AddJobStageValues, + userId: string, +): Promise<{ id: string; isInterview: boolean }> => { + const custom = values.customLabel?.trim(); + const id = custom + ? (await resolveJobStageType(custom, userId, values.customStatusId!)).id + : values.stageTypeId!; + const owned = await prisma.jobStageType.findFirst({ + where: { id, createdBy: userId }, + select: { id: true, Status: { select: { value: true } } }, + }); + if (!owned) throw new Error("Stage type not found"); + return { id: owned.id, isInterview: owned.Status.value === "interview" }; +}; + +// The dialog hides format, duration and location for a non-interview stage +// and the detail panel never renders them, so a type change has to blank +// them rather than leave values saved where nothing can show or clear them. +const stageDataFrom = ( + values: AddJobStageValues, + stageTypeId: string, + isInterview: boolean, +) => ({ + stageTypeId, + occurredAt: values.date + ? values.time + ? combineDateAndTime(values.date, values.time) + : values.date + : null, + outcome: values.outcome ?? null, + notes: values.notes?.trim() || null, + durationMins: isInterview ? (values.durationMins ?? null) : null, + format: isInterview ? values.format?.trim() || null : null, + location: isInterview ? values.location?.trim() || null : null, +}); + +export const addJobStage = async ( + values: AddJobStageValues, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + const parsed = AddJobStageFormSchema.parse(values); + + const job = await prisma.job.count({ + where: { id: parsed.jobId, userId: user.id }, + }); + if (job === 0) throw new Error("Job not found"); + + const stageType = await resolveStageType(parsed, user.id); + + const created = await prisma.$transaction(async (tx: any) => { + const stage = await tx.jobStage.create({ + data: { + jobId: parsed.jobId, + ...stageDataFrom(parsed, stageType.id, stageType.isInterview), + }, + }); + if (parsed.setAsCurrent) { + await promoteStage(tx, parsed.jobId, stage.id, user.id); + await syncJobFromCurrentStage(tx, parsed.jobId, stage.id, user.id); + } + return tx.jobStage.findFirst({ + where: { id: stage.id }, + include: STAGE_DETAIL_INCLUDE, + }); + }); + + revalidatePath("/dashboard"); + return { success: true, data: created }; + } catch (error) { + return handleError(error, "Failed to add stage."); + } +}; + +export const updateJobStage = async ( + values: AddJobStageValues, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + if (!values.id) throw new Error("Stage id is required"); + const parsed = AddJobStageFormSchema.parse(values); + + const existing = await assertStageOwned(values.id, user.id); + const stageType = await resolveStageType(parsed, user.id); + + // Interviewers and prep questions only have a UI on an interview stage, so + // retyping away from one would strand them: invisible, unremovable, and + // still enough to block deleting the question. + if ( + existing.StageType.Status.value === "interview" && + !stageType.isInterview + ) { + const [interviewers, prepQuestions] = await Promise.all([ + prisma.jobStageInterviewer.count({ where: { stageId: values.id } }), + prisma.jobStagePrepQuestion.count({ where: { stageId: values.id } }), + ]); + if (interviewers + prepQuestions > 0) { + const parts = [ + interviewers > 0 && + `${interviewers} interviewer${interviewers === 1 ? "" : "s"}`, + prepQuestions > 0 && + `${prepQuestions} prep question${prepQuestions === 1 ? "" : "s"}`, + ].filter(Boolean); + throw new Error( + `"${existing.StageType.label}" still has ${parts.join(" and ")}. Remove them before changing it to a non-interview stage.`, + ); + } + } + + const updated = await prisma.$transaction(async (tx: any) => { + await tx.jobStage.update({ + where: { id: values.id }, + data: stageDataFrom(parsed, stageType.id, stageType.isInterview), + }); + if (parsed.setAsCurrent) { + await promoteStage(tx, existing.jobId, values.id!, user.id); + } + // A current stage whose type or date changed re-derives the job's + // status, so editing the type of the current stage is a status change. + if (parsed.setAsCurrent || existing.isCurrent) { + await syncJobFromCurrentStage(tx, existing.jobId, values.id!, user.id); + } + return tx.jobStage.findFirst({ + where: { id: values.id }, + include: STAGE_DETAIL_INCLUDE, + }); + }); + + revalidatePath("/dashboard"); + return { success: true, data: updated }; + } catch (error) { + return handleError(error, "Failed to update stage."); + } +}; + +export const deleteJobStage = async ( + stageId: string, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + const stage = await assertStageOwned(stageId, user.id); + + await prisma.$transaction(async (tx: any) => { + await tx.jobStage.delete({ where: { id: stageId } }); + if (!stage.isCurrent) return; + + // Deleting the current stage promotes the furthest-along DATED stage; + // with no stages left the job keeps its status and has no current stage. + // Undated stages sort last within a sortOrder (D2), so simply taking the + // tail would promote a dateless stage over a real one and set the wrong + // status. + const remaining = sortStages<{ + id: string; + occurredAt: Date | null; + createdAt: Date; + StageType: { sortOrder: number }; + }>( + await tx.jobStage.findMany({ + where: { jobId: stage.jobId }, + select: { + id: true, + occurredAt: true, + createdAt: true, + StageType: { select: { sortOrder: true } }, + }, + }), + ); + const dated = remaining.filter((s) => s.occurredAt); + const successor = dated[dated.length - 1] ?? remaining[remaining.length - 1]; + if (!successor) return; + + await promoteStage(tx, stage.jobId, successor.id, user.id); + await syncJobFromCurrentStage(tx, stage.jobId, successor.id, user.id); + }); + + revalidatePath("/dashboard"); + return { success: true }; + } catch (error) { + return handleError(error, "Failed to delete stage."); + } +}; + +export const setCurrentJobStage = async ( + stageId: string, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + const stage = await assertStageOwned(stageId, user.id); + + await prisma.$transaction(async (tx: any) => { + await promoteStage(tx, stage.jobId, stageId, user.id); + await syncJobFromCurrentStage(tx, stage.jobId, stageId, user.id); + }); + + revalidatePath("/dashboard"); + return { success: true }; + } catch (error) { + return handleError(error, "Failed to set the current stage."); + } +}; + +// The detail panel edits notes in place, the way its prep checkboxes already +// save in place (Task 5.3). Its own action rather than updateJobStage: that +// one parses the whole Add Stage form, so a notes-only save would have to +// reconstruct a stage type, a date and a set-as-current flag it is not +// changing — and re-running promoteStage as a side effect of typing a note. +export const setStageNotes = async ( + stageId: string, + notes: string, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + await assertStageOwned(stageId, user.id); + + const res = await prisma.jobStage.updateMany({ + where: { id: stageId, Job: { userId: user.id } }, + data: { notes: notes.trim() || null }, + }); + if (res.count === 0) throw new Error("Stage not found"); + + return { success: true }; + } catch (error) { + return handleError(error, "Failed to save the note."); + } +}; diff --git a/src/actions/jobStage/prep.ts b/src/actions/jobStage/prep.ts new file mode 100644 index 000000000..626bf6ed4 --- /dev/null +++ b/src/actions/jobStage/prep.ts @@ -0,0 +1,99 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "../shared"; +import { assertInterviewStage, assertStageOwned } from "./shared"; + +const PREP_INCLUDE = { + Question: { select: { id: true, question: true, tags: true } }, +}; + +export const addStagePrepQuestions = async ( + stageId: string, + questionIds: string[], +): Promise<any | undefined> => { + try { + const user = await requireUser(); + const ids = [...new Set(questionIds)].filter(Boolean); + if (ids.length === 0) throw new Error("Pick at least one question"); + + const [stage, owned] = await Promise.all([ + assertStageOwned(stageId, user.id), + prisma.question.count({ + where: { id: { in: ids }, createdBy: user.id }, + }), + ]); + // Kind before ownership: the stage is already known to be the caller's, + // and "Applied is not an interview stage" is the message that helps. + assertInterviewStage(stage); + if (owned !== ids.length) throw new Error("Question not found"); + + // SQLite has no skipDuplicates, so the already-linked ids are subtracted + // here rather than left to the unique index. + const linked = await prisma.jobStagePrepQuestion.findMany({ + where: { stageId, questionId: { in: ids } }, + select: { questionId: true }, + }); + const alreadyLinked = new Set(linked.map((row) => row.questionId)); + const fresh = ids.filter((id) => !alreadyLinked.has(id)); + if (fresh.length > 0) { + await prisma.jobStagePrepQuestion.createMany({ + data: fresh.map((questionId) => ({ stageId, questionId, asked: false })), + }); + } + + const data = await prisma.jobStagePrepQuestion.findMany({ + where: { stageId }, + include: PREP_INCLUDE, + orderBy: { createdAt: "asc" }, + }); + return { success: true, data }; + } catch (error) { + return handleError(error, "Failed to add questions to the prep list."); + } +}; + +export const removeStagePrepQuestion = async ( + linkId: string, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + const res = await prisma.jobStagePrepQuestion.deleteMany({ + where: { + id: linkId, + Stage: { Job: { userId: user.id } }, + Question: { createdBy: user.id }, + }, + }); + if (res.count === 0) throw new Error("Prep question not found"); + return { success: true }; + } catch (error) { + return handleError(error, "Failed to remove the prep question."); + } +}; + +export const setPrepQuestionAsked = async ( + linkId: string, + asked: boolean, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + const res = await prisma.jobStagePrepQuestion.updateMany({ + where: { + id: linkId, + Stage: { Job: { userId: user.id } }, + Question: { createdBy: user.id }, + }, + data: { asked, askedAt: asked ? new Date() : null }, + }); + if (res.count === 0) throw new Error("Prep question not found"); + + const data = await prisma.jobStagePrepQuestion.findFirst({ + where: { id: linkId }, + select: { id: true, asked: true, askedAt: true }, + }); + return { success: true, data }; + } catch (error) { + return handleError(error, "Failed to update the prep question."); + } +}; diff --git a/src/actions/jobStage/queries.ts b/src/actions/jobStage/queries.ts new file mode 100644 index 000000000..19e034d8d --- /dev/null +++ b/src/actions/jobStage/queries.ts @@ -0,0 +1,20 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "../shared"; +import { STAGE_DETAIL_INCLUDE, sortStages } from "./shared"; + +export const getJobStages = async ( + jobId: string, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + const stages = await prisma.jobStage.findMany({ + where: { jobId, Job: { userId: user.id } }, + include: STAGE_DETAIL_INCLUDE, + }); + return sortStages(stages); + } catch (error) { + return handleError(error, "Failed to fetch job stages. "); + } +}; diff --git a/src/actions/jobStage/shared.ts b/src/actions/jobStage/shared.ts new file mode 100644 index 000000000..386e97dbb --- /dev/null +++ b/src/actions/jobStage/shared.ts @@ -0,0 +1,168 @@ +import prisma from "@/lib/db"; + +export { sortStages } from "@/lib/jobs/sortStages"; + +// Not a "use server" module: queries.ts, mutations.ts, interviewers.ts and +// prep.ts all use these, and exporting them from any of those would make each +// one a callable server-action endpoint. + +export const STAGE_DETAIL_INCLUDE = { + StageType: { include: { Status: true } }, + interviewers: { + include: { + Contact: { + select: { + id: true, + name: true, + title: true, + email: true, + phone: true, + linkedinUrl: true, + Company: { select: { id: true, label: true } }, + }, + }, + }, + orderBy: { createdAt: "asc" as const }, + }, + prepQuestions: { + include: { + Question: { select: { id: true, question: true, answer: true, tags: true } }, + }, + orderBy: { createdAt: "asc" as const }, + }, +}; + +// The applied/appliedDate rules updateJobStatus has always used, lifted out so +// every stage write path keeps the dashboard and CSV export in agreement. +export function jobFieldsForStage( + statusValue: string, + statusId: string, + occurredAt: Date | null, + existingAppliedDate: Date | null, +): Record<string, unknown> { + const data: Record<string, unknown> = { statusId }; + if (statusValue === "applied" || statusValue === "interview") { + data.applied = true; + } + if (statusValue === "applied" && !existingAppliedDate) { + data.appliedDate = occurredAt ?? new Date(); + } + return data; +} + +export type StageOwnerRow = { + id: string; + jobId: string; + occurredAt: Date | null; + isCurrent: boolean; + StageType: { id: string; label: string; statusId: string; Status: { value: string } }; +}; + +// JobStage has no ownership column, so the chain is the only check there is. +export async function assertStageOwned( + stageId: string, + userId: string, +): Promise<StageOwnerRow> { + const stage = await prisma.jobStage.findFirst({ + where: { id: stageId, Job: { userId } }, + select: { + id: true, + jobId: true, + occurredAt: true, + isCurrent: true, + StageType: { + select: { + id: true, + label: true, + statusId: true, + Status: { select: { value: true } }, + }, + }, + }, + }); + if (!stage) throw new Error("Stage not found"); + return stage; +} + +// The gate is on stage KIND and never on stage timing: preparing for an +// interview a week out is the primary case (spec decision 10). +export function assertInterviewStage(stage: StageOwnerRow): void { + if (stage.StageType.Status.value !== "interview") { + throw new Error( + `"${stage.StageType.label}" is not an interview stage, so it has no interviewers or prep list.`, + ); + } +} + +type StageTx = { + jobStage: { + updateMany: (args: any) => Promise<any>; + findFirst: (args: any) => Promise<any>; + }; + job: { + findFirst: (args: any) => Promise<any>; + update: (args: any) => Promise<any>; + }; +}; + +// Clear the old current row BEFORE setting the new one: the partial unique +// index JobStage_jobId_current_key refuses two live rows for one job. +// +// userId is in every where clause (D8). Callers check ownership first, but a +// helper that finds a row by id alone stays safe only until someone adds a +// caller that forgets, and CLAUDE.md's rule has no "the caller checked" +// exemption. updateMany, not update: a relation filter is not allowed in a +// single-row update's where. +export async function promoteStage( + tx: StageTx, + jobId: string, + stageId: string, + userId: string, +): Promise<void> { + await tx.jobStage.updateMany({ + where: { jobId, isCurrent: true, NOT: { id: stageId }, Job: { userId } }, + data: { isCurrent: false }, + }); + const promoted = await tx.jobStage.updateMany({ + where: { id: stageId, Job: { userId } }, + data: { isCurrent: true }, + }); + if (promoted.count === 0) throw new Error("Stage not found"); +} + +// The derived-status invariant: Job.statusId always equals the current +// stage's type's statusId whenever a current stage exists. +export async function syncJobFromCurrentStage( + tx: StageTx, + jobId: string, + stageId: string, + userId: string, +): Promise<void> { + const stage = await tx.jobStage.findFirst({ + where: { id: stageId, Job: { userId } }, + select: { + occurredAt: true, + StageType: { + select: { statusId: true, Status: { select: { value: true } } }, + }, + }, + }); + if (!stage) throw new Error("Stage not found"); + + const job = await tx.job.findFirst({ + where: { id: jobId, userId }, + select: { appliedDate: true }, + }); + + // Prisma allows a non-unique field beside id in an update's where — + // deleteJobById and updateJob both already do this. + await tx.job.update({ + where: { id: jobId, userId }, + data: jobFieldsForStage( + stage.StageType.Status.value, + stage.StageType.statusId, + stage.occurredAt, + job?.appliedDate ?? null, + ), + }); +} diff --git a/src/actions/jobStageType.actions.ts b/src/actions/jobStageType.actions.ts new file mode 100644 index 000000000..7718be6e1 --- /dev/null +++ b/src/actions/jobStageType.actions.ts @@ -0,0 +1,193 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "./shared"; +import { APP_CONSTANTS } from "@/lib/constants"; +import { resolveJobStageType } from "@/lib/jobs/resolve"; +import { canonicalizeEntityValue } from "@/lib/jobs/canonicalize"; +import { jobFieldsForStage } from "./jobStage/shared"; + +export const getAllJobStageTypes = async (): Promise<any | undefined> => { + try { + const user = await requireUser(); + return await prisma.jobStageType.findMany({ + where: { createdBy: user.id }, + include: { Status: true }, + orderBy: [{ sortOrder: "asc" }, { label: "asc" }], + }); + } catch (error) { + return handleError(error, "Failed to fetch stage types. "); + } +}; + +// Not getReferenceEntityList: that helper counts jobs grouped by an FK on Job, +// and a stage type reaches Job only through JobStage. +export const getJobStageTypeList = async ( + page: number = 1, + limit: number = APP_CONSTANTS.RECORDS_PER_PAGE, + search?: string, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + const whereClause: any = { createdBy: user.id }; + if (search) { + whereClause.label = { contains: search }; + } + + const [data, total] = await Promise.all([ + prisma.jobStageType.findMany({ + where: whereClause, + skip: (page - 1) * limit, + take: limit, + include: { Status: true, _count: { select: { stages: true } } }, + orderBy: [{ sortOrder: "asc" }, { label: "asc" }], + }), + prisma.jobStageType.count({ where: whereClause }), + ]); + + return { data, total }; + } catch (error) { + return handleError(error, "Failed to fetch stage type list. "); + } +}; + +const assertStatusExists = async (statusId: string) => { + const status = await prisma.jobStatus.findUnique({ + where: { id: statusId }, + select: { id: true, label: true, value: true }, + }); + if (!status) throw new Error("Job status not found"); + return status; +}; + +// The types named after a job status are the lookup table the status menus +// resolve through (resolveStageTypeForStatusId keys on the status LABEL), so +// moving one under a different status would make every later status change +// write a stage whose status contradicts Job.statusId. +const isStatusNamedType = async (value: string): Promise<boolean> => { + const statuses = await prisma.jobStatus.findMany({ select: { label: true } }); + return statuses.some((s) => canonicalizeEntityValue(s.label) === value); +}; + +export const createJobStageType = async ( + label: string, + statusId: string, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + await assertStatusExists(statusId); + + // resolveJobStageType returns an existing type by name and ignores the + // requested status, which would report success having done nothing. + const clash = await prisma.jobStageType.findFirst({ + where: { + value: canonicalizeEntityValue(label.trim()), + createdBy: user.id, + NOT: { statusId }, + }, + select: { label: true, Status: { select: { label: true } } }, + }); + if (clash) { + throw new Error( + `"${clash.label}" already exists under the ${clash.Status.label} status.`, + ); + } + + const data = await resolveJobStageType(label, user.id, statusId); + return { success: true, data }; + } catch (error) { + return handleError(error, "Failed to create stage type."); + } +}; + +export const updateJobStageType = async ( + id: string, + label: string, + statusId: string, + sortOrder: number, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + const status = await assertStatusExists(statusId); + + const existing = await prisma.jobStageType.findFirst({ + where: { id, createdBy: user.id }, + select: { value: true, statusId: true }, + }); + if (!existing) throw new Error("Stage type not found"); + + const statusChanged = existing.statusId !== statusId; + if (statusChanged && (await isStatusNamedType(existing.value))) { + throw new Error( + `"${label.trim()}" is the stage the status menus resolve to, so its status cannot be changed.`, + ); + } + + // value is left alone on rename: it is the reverse-lookup key that a + // status resolves through, and rewriting it would orphan the mapping. + await prisma.jobStageType.update({ + where: { id, createdBy: user.id }, + data: { label: label.trim(), statusId, sortOrder }, + }); + + // A job's status is derived from its current stage's type, so retargeting + // a type in use has to re-derive every job now sitting on it. + if (statusChanged) { + const affected = await prisma.job.findMany({ + where: { + userId: user.id, + stages: { some: { stageTypeId: id, isCurrent: true } }, + }, + select: { + id: true, + appliedDate: true, + stages: { + where: { stageTypeId: id, isCurrent: true }, + select: { occurredAt: true }, + }, + }, + }); + for (const job of affected) { + await prisma.job.update({ + where: { id: job.id, userId: user.id }, + data: jobFieldsForStage( + status.value, + statusId, + job.stages[0]?.occurredAt ?? null, + job.appliedDate, + ), + }); + } + } + + return { success: true }; + } catch (error) { + return handleError(error, "Failed to update stage type."); + } +}; + +export const deleteJobStageTypeById = async ( + id: string, +): Promise<any | undefined> => { + try { + const user = await requireUser(); + + // JobStage.stageTypeId is required, so the database already refuses via + // the default Restrict; this turns that into a readable message. + const stages = await prisma.jobStage.count({ + where: { stageTypeId: id, Job: { userId: user.id } }, + }); + if (stages > 0) { + throw new Error( + `Stage type cannot be deleted due to ${stages} job stage${stages === 1 ? "" : "s"} using it! `, + ); + } + + const res = await prisma.jobStageType.delete({ + where: { id, createdBy: user.id }, + }); + return { res, success: true }; + } catch (error) { + return handleError(error, "Failed to delete stage type."); + } +}; diff --git a/src/actions/mock.actions.ts b/src/actions/mock.actions.ts index 03e014d79..62a76fa20 100644 --- a/src/actions/mock.actions.ts +++ b/src/actions/mock.actions.ts @@ -2,6 +2,7 @@ import prisma from "@/lib/db"; import { handleError } from "@/lib/utils"; import { requireUser } from "./shared"; +import { createFirstStage } from "@/lib/jobs/createJobRecord"; import { generateMockActivities } from "@/lib/mock.utils"; import { mockActivityTypes, @@ -573,11 +574,18 @@ export const generateMockJobsAction = async (): Promise<any> => { }; }); - const created = await prisma.job.createMany({ data: jobsData }); + // A loop rather than createMany: createMany returns only a count, so it + // cannot feed createFirstStage the new ids. Dev-only, tens of rows. + let count = 0; + for (const data of jobsData) { + const job = await prisma.job.create({ data }); + await createFirstStage(prisma, job.id, data.statusId, user.id, data.createdAt); + count += 1; + } return { success: true, - message: `Generated ${created.count} mock jobs across the last 30 days.`, + message: `Generated ${count} mock jobs across the last 30 days.`, }; } catch (error) { return handleError(error, "Failed to generate mock jobs"); @@ -611,7 +619,6 @@ export const clearMockJobsAction = async (): Promise<any> => { // Delete related records first (FK constraints) await prisma.note.deleteMany({ where: { jobId: { in: jobIds } } }); - await prisma.interview.deleteMany({ where: { jobId: { in: jobIds } } }); // Disconnect tags (M2M) await Promise.all( diff --git a/src/actions/question.actions.ts b/src/actions/question.actions.ts index 24b5612b0..5d35f515e 100644 --- a/src/actions/question.actions.ts +++ b/src/actions/question.actions.ts @@ -132,6 +132,17 @@ export const deleteQuestion = async ( try { const user = await requireUser(); + // Silently erasing the record that a question was asked in a real + // interview is worse than a delete that fails with a reason. + const prepRows = await prisma.jobStagePrepQuestion.count({ + where: { questionId, Question: { createdBy: user.id } }, + }); + if (prepRows > 0) { + throw new Error( + `Question cannot be deleted: it is on the prep list of ${prepRows} interview stage${prepRows === 1 ? "" : "s"}. Remove it from those stages first.`, + ); + } + await prisma.question.delete({ where: { id: questionId, createdBy: user.id }, }); diff --git a/src/app/api/test/cleanup/route.ts b/src/app/api/test/cleanup/route.ts index 8b72b0cbe..2bb3ee73a 100644 --- a/src/app/api/test/cleanup/route.ts +++ b/src/app/api/test/cleanup/route.ts @@ -33,6 +33,7 @@ export async function POST(req: NextRequest) { automations = [], contacts = [], contactRoles = [], + jobStageTypes = [], }: { jobIds?: string[]; resumes?: string[]; @@ -48,6 +49,7 @@ export async function POST(req: NextRequest) { automations?: string[]; contacts?: string[]; contactRoles?: string[]; + jobStageTypes?: string[]; } = await req.json(); // Delete automations before resumes: Automation.resumeId is a required FK, so @@ -121,6 +123,9 @@ export async function POST(req: NextRequest) { await deleteLibraryByName("location", locations, userId); await deleteLibraryByName("activityType", activityTypes, userId); await deleteLibraryByName("contactRole", contactRoles, userId); + // After the jobs above: a job's stages cascade with it, which is what frees + // a custom stage type. Seeded types are never registered by a test. + await deleteLibraryByName("jobStageType", jobStageTypes, userId); await deleteTagsByName(tags, userId); if (mcpTokens.length > 0) { await prisma.mcpAccessToken.deleteMany({ @@ -156,7 +161,8 @@ type RefModel = | "company" | "location" | "activityType" - | "contactRole"; + | "contactRole" + | "jobStageType"; // Count every place a Library row can still be referenced from, so we never // delete one that another job or resume section still uses. @@ -191,6 +197,9 @@ async function referenceCount( (await prisma.contact.count({ where: { roleId: refId } })) ); } + if (model === "jobStageType") { + return prisma.jobStage.count({ where: { stageTypeId: refId } }); + } return ( (await prisma.job.count({ where: { locationId: refId } })) + (await prisma.workExperience.count({ where: { locationId: refId } })) + diff --git a/src/app/dashboard/myjobs/[id]/page.tsx b/src/app/dashboard/myjobs/[id]/page.tsx index aaa071b73..ba58e0249 100644 --- a/src/app/dashboard/myjobs/[id]/page.tsx +++ b/src/app/dashboard/myjobs/[id]/page.tsx @@ -8,20 +8,30 @@ import { getAllCompanies } from "@/actions/company.actions"; import { getAllJobTitles } from "@/actions/jobtitle.actions"; import { getAllJobLocations } from "@/actions/jobLocation.actions"; import { getAllTags } from "@/actions/tag.actions"; +import { getAllJobStageTypes } from "@/actions/jobStageType.actions"; import { notFound } from "next/navigation"; async function JobDetailsPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const [{ job }, statuses, companies, titles, locations, sources, tags] = - await Promise.all([ - getJobDetails(id), - getStatusList(), - getAllCompanies(), - getAllJobTitles(), - getAllJobLocations(), - getJobSourceList(), - getAllTags(), - ]); + const [ + { job }, + statuses, + companies, + titles, + locations, + sources, + tags, + stageTypes, + ] = await Promise.all([ + getJobDetails(id), + getStatusList(), + getAllCompanies(), + getAllJobTitles(), + getAllJobLocations(), + getJobSourceList(), + getAllTags(), + getAllJobStageTypes(), + ]); // A link to a job that was deleted (or never belonged to this user) would // otherwise crash JobDetails on the first job.* read. @@ -37,6 +47,7 @@ async function JobDetailsPage({ params }: { params: Promise<{ id: string }> }) { locations={locations} sources={sources} tags={tags ?? []} + stageTypes={stageTypes ?? []} /> </div> ); diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index 29ed0d07f..27c4a4350 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -1,6 +1,6 @@ "use client"; -import { addDays, format } from "date-fns"; +import { addDays, addYears, format } from "date-fns"; import { Calendar as CalendarIcon } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -27,10 +27,15 @@ interface DatePickerProps { field: ControllerRenderProps<any, any>; presets: boolean; isEnabled: boolean; + // Swaps the caption for month/year dropdowns, which need a bounded range: + // left to itself the year dropdown stops at the end of the current year. captionLayout?: boolean; // Opt out of the default fixed trigger width when the field sits in a grid fullWidth?: boolean; disableFuture?: boolean; + // Years past today the dropdown reaches. Opt-in: the default cap is the + // current month, so a picker for a past-only field stays that way. + futureYears?: number; } export function DatePicker({ @@ -40,6 +45,7 @@ export function DatePicker({ captionLayout, fullWidth, disableFuture, + futureYears, }: DatePickerProps) { const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false); @@ -84,7 +90,13 @@ export function DatePicker({ mode="single" captionLayout={captionLayout ? "dropdown" : "label"} startMonth={captionLayout ? new Date(1970, 0) : undefined} - endMonth={captionLayout ? new Date() : undefined} + endMonth={ + captionLayout + ? disableFuture || !futureYears + ? new Date() + : addYears(new Date(), futureYears) + : undefined + } selected={field.value} disabled={disableFuture ? { after: new Date() } : undefined} onSelect={(value) => { diff --git a/src/components/DeleteAlertDialog.tsx b/src/components/DeleteAlertDialog.tsx index 08fa437b1..5d67e8b25 100644 --- a/src/components/DeleteAlertDialog.tsx +++ b/src/components/DeleteAlertDialog.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { AlertDialog, AlertDialogAction, @@ -16,7 +17,7 @@ interface DeleteAlertDialogProps { onOpenChange: (open: boolean) => void; onDelete: () => void; alertTitle?: string; - alertDescription?: string; + alertDescription?: ReactNode; deleteAction?: boolean; actionLabel?: string; actionVariant?: "destructive" | "default"; @@ -40,7 +41,10 @@ export function DeleteAlertDialog({ <AlertDialogTitle> {alertTitle ?? `Are you sure you want to delete this ${pageTitle}?`} </AlertDialogTitle> - <AlertDialogDescription>{alertDescription}</AlertDialogDescription> + {/* asChild: a description may carry a list, which cannot nest in a p. */} + <AlertDialogDescription asChild> + <div>{alertDescription}</div> + </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> diff --git a/src/components/TimePicker.tsx b/src/components/TimePicker.tsx index 0ce476113..dd5fa7619 100644 --- a/src/components/TimePicker.tsx +++ b/src/components/TimePicker.tsx @@ -92,9 +92,10 @@ function TimeColumn({ label, options, selected, onSelect }: TimeColumnProps) { interface TimePickerProps { field: ControllerRenderProps<any, any>; + fullWidth?: boolean; } -export function TimePicker({ field }: TimePickerProps) { +export function TimePicker({ field, fullWidth }: TimePickerProps) { const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false); const { hour, minute, meridiem } = parseTime(field.value); @@ -117,7 +118,12 @@ export function TimePicker({ field }: TimePickerProps) { }; return ( - <div className="flex md:w-[240px] lg:w-[280px]"> + <div + className={cn( + "flex", + fullWidth ? "w-full" : "md:w-[240px] lg:w-[280px]", + )} + > <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal> <PopoverTrigger asChild> <FormControl> diff --git a/src/components/admin/AddJobStageType.tsx b/src/components/admin/AddJobStageType.tsx new file mode 100644 index 000000000..31b4a97ff --- /dev/null +++ b/src/components/admin/AddJobStageType.tsx @@ -0,0 +1,168 @@ +"use client"; +import { useTransition, useEffect } from "react"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { Loader } from "lucide-react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../ui/form"; +import { Input } from "../ui/input"; +import SelectFormCtrl from "../Select"; +import { toastActionResult } from "@/lib/toast"; +import { JobStageTypeRef } from "@/models/jobStage.model"; +import { JobStatus } from "@/models/job.model"; +import { + createJobStageType, + updateJobStageType, +} from "@/actions/jobStageType.actions"; + +const AddJobStageTypeFormSchema = z.object({ + label: z + .string({ error: "Stage name is required." }) + .min(1, { message: "Stage name cannot be empty." }) + .max(80, { message: "Stage name must be 80 characters or fewer." }), + statusId: z + .string({ error: "Parent status is required." }) + .min(1, { message: "Pick the status this stage means." }), +}); + +type AddJobStageTypeProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + type: JobStageTypeRef | null; + statuses: JobStatus[]; + reloadTypes: () => void; +}; + +function AddJobStageType({ + open, + onOpenChange, + type, + statuses, + reloadTypes, +}: AddJobStageTypeProps) { + const [isPending, startTransition] = useTransition(); + + const form = useForm<z.infer<typeof AddJobStageTypeFormSchema>>({ + resolver: zodResolver(AddJobStageTypeFormSchema), + defaultValues: { label: "", statusId: "" }, + }); + + const { reset } = form; + + // A custom stage is most often an interview round, so that is the default. + useEffect(() => { + if (!open) return; + reset({ + label: type?.label ?? "", + statusId: + type?.statusId ?? + statuses.find((s) => s.value === "interview")?.id ?? + "", + }); + }, [open, type, statuses, reset]); + + const onSubmit = (values: z.infer<typeof AddJobStageTypeFormSchema>) => { + startTransition(async () => { + const res = type + ? await updateJobStageType( + type.id, + values.label, + values.statusId, + type.sortOrder, + ) + : await createJobStageType(values.label, values.statusId); + toastActionResult(res, { + success: `Stage has been ${type ? "updated" : "added"} successfully.`, + onSuccess: () => { + onOpenChange(false); + reset(); + reloadTypes(); + }, + }); + }); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle> + {type ? "Edit Stage" : "Add New Stage"} + </DialogTitle> + <DialogDescription> + Stages are the steps a job moves through — each one belongs to a + status, which the job takes on when the stage becomes current. + </DialogDescription> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="label" + render={({ field }) => ( + <FormItem> + <FormLabel>Stage Name</FormLabel> + <FormControl> + <Input + placeholder="e.g. Take-home Exercise" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="statusId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>Parent Status</FormLabel> + <SelectFormCtrl + label="status" + options={statuses} + field={field} + /> + <FormMessage /> + </FormItem> + )} + /> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + Cancel + </Button> + <Button type="submit" disabled={isPending}> + Save + {isPending && ( + <Loader className="ml-2 h-4 w-4 shrink-0 spinner" /> + )} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} + +export default AddJobStageType; diff --git a/src/components/admin/AdminTabsContainer.tsx b/src/components/admin/AdminTabsContainer.tsx index 3a7ec3297..f4e15959e 100644 --- a/src/components/admin/AdminTabsContainer.tsx +++ b/src/components/admin/AdminTabsContainer.tsx @@ -5,6 +5,7 @@ import ContactRolesContainer from "@/components/admin/ContactRolesContainer"; import ContactsContainer from "@/components/admin/ContactsContainer"; import JobLocationsContainer from "@/components/admin/JobLocationsContainer"; import JobSourcesContainer from "@/components/admin/JobSourcesContainer"; +import JobStagesContainer from "@/components/admin/JobStagesContainer"; import JobTitlesContainer from "@/components/admin/JobTitlesContainer"; import TagsContainer from "@/components/admin/TagsContainer"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -43,6 +44,7 @@ function AdminTabsContainer() { <TabsTrigger value="activity-types">Activity Types</TabsTrigger> <TabsTrigger value="contacts">Contacts</TabsTrigger> <TabsTrigger value="roles">Roles</TabsTrigger> + <TabsTrigger value="stages">Stages</TabsTrigger> </TabsList> <TabsContent value="companies"> <CompaniesContainer /> @@ -68,6 +70,9 @@ function AdminTabsContainer() { <TabsContent value="roles"> <ContactRolesContainer /> </TabsContent> + <TabsContent value="stages"> + <JobStagesContainer /> + </TabsContent> </Tabs> ); } diff --git a/src/components/admin/JobStagesContainer.tsx b/src/components/admin/JobStagesContainer.tsx new file mode 100644 index 000000000..8da7a8a3a --- /dev/null +++ b/src/components/admin/JobStagesContainer.tsx @@ -0,0 +1,140 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; +import { Card, CardContent, CardTitle } from "../ui/card"; +import { ResponsiveCardHeader } from "../ResponsiveCardHeader"; +import { APP_CONSTANTS } from "@/lib/constants"; +import { JobStageTypeRef } from "@/models/jobStage.model"; +import { JobStatus } from "@/models/job.model"; +import JobStagesTable from "./JobStagesTable"; +import AddJobStageType from "./AddJobStageType"; +import { getJobStageTypeList } from "@/actions/jobStageType.actions"; +import { getStatusList } from "@/actions/job.actions"; +import Loading from "../Loading"; +import { Button } from "../ui/button"; +import { PlusCircle } from "lucide-react"; +import { RecordsCount } from "../RecordsCount"; +import { SearchInput } from "../SearchInput"; + +function JobStagesContainer() { + const [types, setTypes] = useState<JobStageTypeRef[]>([]); + const [totalTypes, setTotalTypes] = useState<number>(0); + const [statuses, setStatuses] = useState<JobStatus[]>([]); + const [page, setPage] = useState<number>(1); + const [loading, setLoading] = useState<boolean>(false); + const [searchTerm, setSearchTerm] = useState<string>(""); + const [dialogOpen, setDialogOpen] = useState(false); + const [editType, setEditType] = useState<JobStageTypeRef | null>(null); + + const loadTypes = useCallback(async (page: number, search?: string) => { + setLoading(true); + const { data, total } = await getJobStageTypeList( + page, + APP_CONSTANTS.RECORDS_PER_PAGE, + search, + ); + if (data) { + setTypes((prev) => (page === 1 ? data : [...prev, ...data])); + setTotalTypes(total); + setPage(page); + } + setLoading(false); + }, []); + + const reloadTypes = useCallback( + async () => loadTypes(1, searchTerm || undefined), + [loadTypes, searchTerm], + ); + + // One effect for mount and debounced search: both reset to page 1 and + // replace the list, so a separate mount effect would double-fetch. + useEffect(() => { + const timer = setTimeout( + () => loadTypes(1, searchTerm || undefined), + searchTerm ? 300 : 0, + ); + return () => clearTimeout(timer); + }, [searchTerm, loadTypes]); + + useEffect(() => { + getStatusList().then((list) => setStatuses(list ?? [])); + }, []); + + const openAdd = () => { + setEditType(null); + setDialogOpen(true); + }; + + const openEdit = (type: JobStageTypeRef) => { + setEditType(type); + setDialogOpen(true); + }; + + return ( + <div className="col-span-3"> + <Card x-chunk="dashboard-06-chunk-0"> + <ResponsiveCardHeader> + <div className="flex items-baseline gap-2"> + <CardTitle>Job Stages</CardTitle> + {!loading && totalTypes > 0 && ( + <RecordsCount + count={types.length} + total={totalTypes} + label="stages" + /> + )} + </div> + <div className="flex flex-wrap items-center justify-end gap-2 sm:ml-auto"> + <SearchInput + value={searchTerm} + onChange={setSearchTerm} + placeholder="Search stages..." + /> + <Button + size="sm" + variant="outline" + className="h-8 gap-1" + onClick={openAdd} + > + <PlusCircle className="h-3.5 w-3.5" /> + <span className="sr-only sm:not-sr-only sm:whitespace-nowrap"> + New Stage + </span> + </Button> + </div> + </ResponsiveCardHeader> + <CardContent> + {loading && <Loading />} + {types.length > 0 && ( + <JobStagesTable + types={types} + reloadTypes={reloadTypes} + onEdit={openEdit} + /> + )} + {types.length < totalTypes && ( + <div className="flex justify-center p-4"> + <Button + size="sm" + variant="outline" + onClick={() => loadTypes(page + 1, searchTerm || undefined)} + disabled={loading} + className="btn btn-primary" + > + {loading ? "Loading..." : "Load More"} + </Button> + </div> + )} + </CardContent> + </Card> + <AddJobStageType + open={dialogOpen} + onOpenChange={setDialogOpen} + type={editType} + statuses={statuses} + reloadTypes={reloadTypes} + /> + </div> + ); +} + +export default JobStagesContainer; diff --git a/src/components/admin/JobStagesTable.tsx b/src/components/admin/JobStagesTable.tsx new file mode 100644 index 000000000..514ccbbb4 --- /dev/null +++ b/src/components/admin/JobStagesTable.tsx @@ -0,0 +1,194 @@ +"use client"; +import { useState } from "react"; +import { Button } from "../ui/button"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { JobStageTypeRef } from "@/models/jobStage.model"; +import { ArrowDown, ArrowUp, MoreVertical, Pencil, Trash } from "lucide-react"; +import { AlertDialog } from "@/models/alertDialog.model"; +import { DeleteAlertDialog } from "../DeleteAlertDialog"; +import { StatusBadge } from "../StatusBadge"; +import { getJobStatusBadgeColor } from "@/lib/badge-colors"; +import { + deleteJobStageTypeById, + updateJobStageType, +} from "@/actions/jobStageType.actions"; +import { toastSuccess, toastError } from "@/lib/toast"; + +type JobStagesTableProps = { + types: JobStageTypeRef[]; + reloadTypes: () => void; + onEdit: (type: JobStageTypeRef) => void; +}; + +function JobStagesTable({ types, reloadTypes, onEdit }: JobStagesTableProps) { + const [alert, setAlert] = useState<AlertDialog>({ + openState: false, + deleteAction: false, + }); + + const onDeleteType = (type: JobStageTypeRef) => { + const stages = type._count?.stages ?? 0; + if (stages > 0) { + setAlert({ + openState: true, + title: "Stage type is in use!", + description: `This stage type is used by ${stages} job stage${ + stages === 1 ? "" : "s" + }. Change or remove those stages before deleting the type.`, + deleteAction: false, + }); + } else { + setAlert({ openState: true, deleteAction: true, itemId: type.id }); + } + }; + + const deleteType = async (typeId: string) => { + if (typeId) { + const { success, message } = await deleteJobStageTypeById(typeId); + if (success) { + toastSuccess("Stage type has been deleted successfully"); + reloadTypes(); + } else { + toastError(message); + } + } + }; + + // Swap sortOrder with the adjacent row rather than renumbering the list: + // a drag-and-drop reorder would need a new dependency. + const move = async (index: number, direction: -1 | 1) => { + const type = types[index]; + const neighbour = types[index + direction]; + if (!neighbour) return; + + const results = await Promise.all([ + updateJobStageType( + type.id, + type.label, + type.statusId, + neighbour.sortOrder, + ), + updateJobStageType( + neighbour.id, + neighbour.label, + neighbour.statusId, + type.sortOrder, + ), + ]); + const failed = results.find((res) => !res?.success); + if (failed) { + toastError(failed.message); + } + reloadTypes(); + }; + + return ( + <> + <Table> + <TableHeader> + <TableRow> + <TableHead>Stage</TableHead> + <TableHead>Parent Status</TableHead> + <TableHead className="hidden sm:table-cell">Order</TableHead> + <TableHead>Stages</TableHead> + <TableHead> + <span className="sr-only">Actions</span> + </TableHead> + </TableRow> + </TableHeader> + <TableBody> + {types.map((type: JobStageTypeRef, index: number) => { + return ( + <TableRow key={type.id}> + <TableCell className="font-medium">{type.label}</TableCell> + <TableCell> + {type.Status && ( + <StatusBadge + label={type.Status.label} + color={getJobStatusBadgeColor(type.Status.value)} + /> + )} + </TableCell> + <TableCell className="font-medium hidden sm:table-cell"> + {type.sortOrder} + </TableCell> + <TableCell className="font-medium"> + {type._count?.stages ?? 0} + </TableCell> + <TableCell> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button aria-haspopup="true" size="icon" variant="ghost"> + <MoreVertical className="h-4 w-4" /> + <span className="sr-only">Toggle menu</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuLabel>Actions</DropdownMenuLabel> + <DropdownMenuItem + className="cursor-pointer" + onClick={() => onEdit(type)} + > + <Pencil className="mr-2 h-4 w-4" /> + Edit + </DropdownMenuItem> + <DropdownMenuItem + className="cursor-pointer" + disabled={index === 0} + onClick={() => move(index, -1)} + > + <ArrowUp className="mr-2 h-4 w-4" /> + Move Up + </DropdownMenuItem> + <DropdownMenuItem + className="cursor-pointer" + disabled={index === types.length - 1} + onClick={() => move(index, 1)} + > + <ArrowDown className="mr-2 h-4 w-4" /> + Move Down + </DropdownMenuItem> + <DropdownMenuItem + className="text-red-600 cursor-pointer" + onClick={() => onDeleteType(type)} + > + <Trash className="mr-2 h-4 w-4" /> + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + <DeleteAlertDialog + pageTitle="stage type" + open={alert.openState} + onOpenChange={() => setAlert({ openState: false, deleteAction: false })} + onDelete={() => deleteType(alert.itemId!)} + alertTitle={alert.title} + alertDescription={alert.description} + deleteAction={alert.deleteAction} + /> + </> + ); +} + +export default JobStagesTable; diff --git a/src/components/myjobs/JobDetails.tsx b/src/components/myjobs/JobDetails.tsx index c839a616a..2ac367348 100644 --- a/src/components/myjobs/JobDetails.tsx +++ b/src/components/myjobs/JobDetails.tsx @@ -40,13 +40,21 @@ import { JobTabEmptyState } from "./job-details/JobTabEmptyState"; import { CoverLetterTab } from "./job-details/CoverLetterTab"; import { useAutoMatch } from "./job-details/useAutoMatch"; import { JobContactsTab } from "./job-details/JobContactsTab"; +import { JobTimelineTab } from "./job-details/timeline/JobTimelineTab"; +import { useJobStages } from "./job-details/timeline/useJobStages"; +import { UpdateStatusMenu } from "./job-details/timeline/UpdateStatusMenu"; +import { AddStageDialog } from "./job-details/timeline/AddStageDialog"; +import { LinkInterviewersDialog } from "./job-details/timeline/LinkInterviewersDialog"; +import { AddPrepQuestionsDialog } from "./job-details/timeline/AddPrepQuestionsDialog"; +import type { JobStage, JobStageTypeRef } from "@/models/jobStage.model"; const JOB_DETAIL_TABS = [ "description", + "timeline", "match", "letter", - "notes", "contacts", + "notes", ] as const; type JobDetailsProps = { @@ -57,6 +65,7 @@ type JobDetailsProps = { locations: JobLocation[]; sources: JobSource[]; tags: Tag[]; + stageTypes: JobStageTypeRef[]; }; function JobDetails({ @@ -67,6 +76,7 @@ function JobDetails({ locations, sources, tags, + stageTypes, }: JobDetailsProps) { const { open: openChat, @@ -77,13 +87,30 @@ function JobDetails({ } = useAgentChat(); const [showClearChatConfirm, setShowClearChatConfirm] = useState(false); const [pendingChatMessage, setPendingChatMessage] = useState(""); - const [currentStatus, setCurrentStatus] = useState(job.Status); const [editJobTarget, setEditJobTarget] = useState<JobResponse | null>( null, ); const [deleteAlertOpen, setDeleteAlertOpen] = useState(false); const [noteOpenTrigger, setNoteOpenTrigger] = useState(0); const [notesCount, setNotesCount] = useState(0); + const { + stages, + currentStage, + selectedStage, + selectedStageId, + selectStage, + reload: reloadStages, + } = useJobStages(job.id, job.stages ?? []); + // Derived, never mirrored: the status is the current stage's parent status, + // and six server-side write paths keep Job.statusId equal to it. State here + // would go stale on every stage add, edit or delete that moves the status. + // job.Status covers the stageless job, which keeps whatever status it had. + const currentStatus = currentStage?.StageType.Status ?? job.Status; + const [addStageTarget, setAddStageTarget] = useState< + { mode: "create" } | { mode: "edit"; stage: JobStage } | null + >(null); + const [linkInterviewersOpen, setLinkInterviewersOpen] = useState(false); + const [prepQuestionsOpen, setPrepQuestionsOpen] = useState(false); const router = useRouter(); const [activeTab, handleTabChange] = useTabQueryParam( JOB_DETAIL_TABS, @@ -157,7 +184,9 @@ function JobDetails({ const onChangeStatus = async (status: JobStatus) => { const { success, message } = await updateJobStatus(job.id, status); if (success) { - setCurrentStatus(status); + // The status change appends a stage server-side, and the badge reads + // that stage, so this is awaited rather than fired off. + await reloadStages(); toastSuccess(`Job has been updated successfully`); } else { toastError(message); @@ -179,8 +208,6 @@ function JobDetails({ <div className="py-6 space-y-6"> <JobDetailsHeader job={job} - jobStatuses={jobStatuses} - currentStatus={currentStatus} coverLetterBlockedReason={coverLetterBlockedReason} chatBusy={chatBusy} onBack={goBack} @@ -189,7 +216,17 @@ function JobDetails({ onEdit={onEditJob} onDelete={() => setDeleteAlertOpen(true)} onAddNote={onAddNote} - onChangeStatus={onChangeStatus} + updateStatusMenu={ + <UpdateStatusMenu + targetStage={selectedStage ?? currentStage} + jobStatuses={jobStatuses} + currentStatusId={currentStatus.id} + onChangeStatus={onChangeStatus} + onAddStage={() => setAddStageTarget({ mode: "create" })} + onLinkInterviewers={() => setLinkInterviewersOpen(true)} + onAddPrepQuestions={() => setPrepQuestionsOpen(true)} + /> + } /> <JobSummaryCard @@ -201,16 +238,16 @@ function JobDetails({ <Tabs value={activeTab} onValueChange={handleTabChange}> <TabsList> <TabsTrigger value="description">Description</TabsTrigger> - <TabsTrigger value="match">AI Match</TabsTrigger> - <TabsTrigger value="letter">Cover Letter</TabsTrigger> - <TabsTrigger value="notes"> - Notes - {notesCount > 0 && ( + <TabsTrigger value="timeline"> + Timeline + {stages.length > 0 && ( <Badge variant="secondary" className="ml-2"> - {notesCount} + {stages.length} </Badge> )} </TabsTrigger> + <TabsTrigger value="match">AI Match</TabsTrigger> + <TabsTrigger value="letter">Cover Letter</TabsTrigger> <TabsTrigger value="contacts"> Contacts {(job.contactLinks?.length ?? 0) > 0 && ( @@ -219,6 +256,14 @@ function JobDetails({ </Badge> )} </TabsTrigger> + <TabsTrigger value="notes"> + Notes + {notesCount > 0 && ( + <Badge variant="secondary" className="ml-2"> + {notesCount} + </Badge> + )} + </TabsTrigger> </TabsList> <TabsContent value="description" className="mt-4"> <Card className="p-6"> @@ -242,6 +287,20 @@ function JobDetails({ )} </Card> </TabsContent> + <TabsContent value="timeline" className="mt-4"> + <JobTimelineTab + stages={stages} + stageTypes={stageTypes} + selectedStageId={selectedStageId} + currentStageId={currentStage?.id ?? null} + onSelect={selectStage} + onAddStage={() => setAddStageTarget({ mode: "create" })} + onEditStage={(stage) => setAddStageTarget({ mode: "edit", stage })} + onLinkInterviewers={() => setLinkInterviewersOpen(true)} + onAddPrepQuestions={() => setPrepQuestionsOpen(true)} + onChanged={reloadStages} + /> + </TabsContent> <TabsContent value="letter" className="mt-4"> <Card className="p-6"> <CoverLetterTab @@ -289,6 +348,36 @@ function JobDetails({ hideTrigger redirectPath={`/dashboard/myjobs/${job.id}?tab=${activeTab}`} /> + <AddStageDialog + open={!!addStageTarget} + jobId={job.id} + jobLabel={`${job.JobTitle?.label ?? ""}${ + job.Company?.label ? ` · ${job.Company.label}` : "" + }`} + stage={addStageTarget?.mode === "edit" ? addStageTarget.stage : null} + stageTypes={stageTypes} + jobStatuses={jobStatuses} + onOpenChange={(open) => !open && setAddStageTarget(null)} + onSaved={() => { + void reloadStages(); + router.refresh(); + }} + /> + <LinkInterviewersDialog + open={linkInterviewersOpen} + stage={selectedStage ?? currentStage} + onOpenChange={setLinkInterviewersOpen} + onLinked={() => { + void reloadStages(); + router.refresh(); + }} + /> + <AddPrepQuestionsDialog + open={prepQuestionsOpen} + stage={selectedStage ?? currentStage} + onOpenChange={setPrepQuestionsOpen} + onAdded={() => void reloadStages()} + /> <DeleteAlertDialog pageTitle="job" open={deleteAlertOpen} diff --git a/src/components/myjobs/job-details/JobDetailsHeader.tsx b/src/components/myjobs/job-details/JobDetailsHeader.tsx index e530e39ec..3357b54c2 100644 --- a/src/components/myjobs/job-details/JobDetailsHeader.tsx +++ b/src/components/myjobs/job-details/JobDetailsHeader.tsx @@ -7,7 +7,6 @@ import { Pencil, Sparkles, StickyNote, - Tags, Trash, Trash2, } from "lucide-react"; @@ -19,17 +18,11 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuPortal, DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { JobStatusMenuItems } from "@/components/myjobs/JobStatusMenuItems"; import { JobResponse, - JobStatus, getJobTypeLabel, getWorkplaceTypeLabel, } from "@/models/job.model"; @@ -37,8 +30,7 @@ import { CompanyLogo } from "../CompanyLogo"; type JobDetailsHeaderProps = { job: JobResponse; - jobStatuses: JobStatus[]; - currentStatus: JobStatus; + updateStatusMenu?: React.ReactNode; coverLetterBlockedReason?: string; chatBusy: boolean; onBack: () => void; @@ -47,13 +39,11 @@ type JobDetailsHeaderProps = { onEdit: () => void; onDelete: () => void; onAddNote: () => void; - onChangeStatus: (status: JobStatus) => void; }; export function JobDetailsHeader({ job, - jobStatuses, - currentStatus, + updateStatusMenu, coverLetterBlockedReason, chatBusy, onBack, @@ -62,7 +52,6 @@ export function JobDetailsHeader({ onEdit, onDelete, onAddNote, - onChangeStatus, }: JobDetailsHeaderProps) { const details = [ job.Location?.label, @@ -99,6 +88,7 @@ export function JobDetailsHeader({ </div> </div> <div className="flex flex-wrap justify-end gap-2"> + {updateStatusMenu} <Button variant="outline" className="cursor-pointer" @@ -108,7 +98,7 @@ export function JobDetailsHeader({ > <Sparkles className="h-4 w-4 sm:mr-2" /> <span className="sr-only sm:not-sr-only sm:whitespace-nowrap"> - Match with AI + AI Match </span> </Button> <Button @@ -125,25 +115,25 @@ export function JobDetailsHeader({ </span> </Button> <Button + title="Edit" variant="outline" + size="icon" onClick={onEdit} data-testid="job-details-edit-btn" > - <Pencil className="h-4 w-4 sm:mr-2" /> - <span className="sr-only sm:not-sr-only sm:whitespace-nowrap"> - Edit - </span> + <Pencil className="h-4 w-4" /> + <span className="sr-only">Edit</span> </Button> <Button + title="Delete" variant="outline" + size="icon" className="text-destructive hover:text-destructive" onClick={onDelete} data-testid="job-details-delete-btn" > - <Trash2 className="h-4 w-4 sm:mr-2" /> - <span className="sr-only sm:not-sr-only sm:whitespace-nowrap"> - Delete - </span> + <Trash2 className="h-4 w-4" /> + <span className="sr-only">Delete</span> </Button> <DropdownMenu> <DropdownMenuTrigger asChild> @@ -169,22 +159,6 @@ export function JobDetailsHeader({ Add a Note </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuSub> - <DropdownMenuSubTrigger> - <Tags className="mr-2 h-4 w-4" /> - Change status - </DropdownMenuSubTrigger> - <DropdownMenuPortal> - <DropdownMenuSubContent className="p-0"> - <JobStatusMenuItems - jobStatuses={jobStatuses} - currentStatusId={currentStatus.id} - onSelectStatus={onChangeStatus} - /> - </DropdownMenuSubContent> - </DropdownMenuPortal> - </DropdownMenuSub> - <DropdownMenuSeparator /> <DropdownMenuItem className="text-red-600 cursor-pointer" onClick={onDelete} diff --git a/src/components/myjobs/job-details/JobTabEmptyState.tsx b/src/components/myjobs/job-details/JobTabEmptyState.tsx index fbc8f446b..a72bca899 100644 --- a/src/components/myjobs/job-details/JobTabEmptyState.tsx +++ b/src/components/myjobs/job-details/JobTabEmptyState.tsx @@ -11,6 +11,7 @@ type JobTabEmptyStateProps = { onAction?: () => void; actionDisabled?: boolean; actionTitle?: string; + actionTestId?: string; }; export function JobTabEmptyState({ @@ -21,6 +22,7 @@ export function JobTabEmptyState({ onAction, actionDisabled, actionTitle, + actionTestId, }: JobTabEmptyStateProps) { return ( <div className="flex flex-col items-center gap-3 py-8 text-center"> @@ -37,6 +39,7 @@ export function JobTabEmptyState({ onClick={onAction} disabled={actionDisabled} title={actionTitle} + data-testid={actionTestId} > <Icon className="h-4 w-4 mr-2" /> {actionLabel} diff --git a/src/components/myjobs/job-details/timeline/AddPrepQuestionsDialog.tsx b/src/components/myjobs/job-details/timeline/AddPrepQuestionsDialog.tsx new file mode 100644 index 000000000..71ae9460a --- /dev/null +++ b/src/components/myjobs/job-details/timeline/AddPrepQuestionsDialog.tsx @@ -0,0 +1,278 @@ +"use client"; +import { useEffect, useId, useRef, useState, useTransition } from "react"; +import { Search, X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { TagInput } from "@/components/myjobs/TagInput"; +import { toastActionResult } from "@/lib/toast"; +import { addStagePrepQuestions } from "@/actions/jobStage.actions"; +import { createQuestion, getQuestionsList } from "@/actions/question.actions"; +import { getAllTags } from "@/actions/tag.actions"; +import type { Tag } from "@/models/job.model"; +import type { JobStage } from "@/models/jobStage.model"; +import { stageHeading } from "./stageDisplay"; + +type BankQuestion = { id: string; question: string; tags: Tag[] }; + +type AddPrepQuestionsDialogProps = { + open: boolean; + stage: JobStage | null; + onOpenChange: (open: boolean) => void; + onAdded: () => void; +}; + +// A cap rather than an unbounded read: the search below filters client-side, +// which is what makes the artboard's instant search possible at all. +const BANK_PAGE_SIZE = 200; + +// The question bank requires an answer; "TBD" is the app's own placeholder +// for one that has not been written yet (QuestionForm). +const UNANSWERED = "TBD"; + +export function AddPrepQuestionsDialog({ + open, + stage, + onOpenChange, + onAdded, +}: AddPrepQuestionsDialogProps) { + const [bank, setBank] = useState<BankQuestion[]>([]); + const [tags, setTags] = useState<Tag[]>([]); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState<string[]>([]); + const [newQuestion, setNewQuestion] = useState(""); + const [newTagIds, setNewTagIds] = useState<string[]>([]); + const [isPending, startTransition] = useTransition(); + const loaded = useRef(false); + const rowId = useId(); + + useEffect(() => { + if (!open || loaded.current) return; + loaded.current = true; + void (async () => { + const [res, allTags] = await Promise.all([ + getQuestionsList(1, BANK_PAGE_SIZE), + getAllTags(), + ]); + if (res?.success && Array.isArray(res.data)) setBank(res.data); + if (Array.isArray(allTags)) setTags(allTags); + })(); + }, [open]); + + useEffect(() => { + if (!open) return; + setSearch(""); + setSelected([]); + setNewQuestion(""); + setNewTagIds([]); + }, [open, stage]); + + if (!stage) return null; + + const alreadyLinked = new Set( + (stage.prepQuestions ?? []).map((row) => row.questionId), + ); + const term = search.trim().toLowerCase(); + const visible = term + ? bank.filter((q) => q.question.toLowerCase().includes(term)) + : bank; + + const toggle = (id: string, checked: boolean) => + setSelected((prev) => + checked ? [...prev, id] : prev.filter((x) => x !== id), + ); + + const addQuestion = () => + startTransition(async () => { + const res = await createQuestion({ + question: newQuestion.trim(), + answer: UNANSWERED, + tagIds: newTagIds, + }); + toastActionResult(res, { + success: "Question has been added to your bank", + onSuccess: () => { + // Banked and selected in one click: the user asked for it here. + setBank((prev) => [res.data, ...prev]); + setSelected((prev) => [...prev, res.data.id]); + setNewQuestion(""); + setNewTagIds([]); + }, + }); + }); + + const submit = () => { + if (selected.length === 0) { + onOpenChange(false); + return; + } + startTransition(async () => { + const res = await addStagePrepQuestions(stage.id, selected); + toastActionResult(res, { + success: "Prep list has been updated successfully", + onSuccess: () => { + onAdded(); + onOpenChange(false); + }, + }); + }); + }; + + const doneLabel = + selected.length === 0 + ? "Done" + : `Done — ${selected.length} question${ + selected.length === 1 ? "" : "s" + } added to prep list`; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[760px] max-h-[800px] overflow-hidden"> + <DialogHeader> + <DialogTitle>Add Questions to Prep List</DialogTitle> + <DialogDescription>{stageHeading(stage)}</DialogDescription> + </DialogHeader> + + <div className="max-h-[60vh] space-y-4 overflow-y-auto p-1"> + <p className="text-[13px] text-muted-foreground"> + Select questions you expect to be asked. After the interview, check + them off as "asked" from the Timeline tab. + </p> + + <div className="relative"> + <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + <Input + className="pl-9" + placeholder="Search question bank…" + value={search} + onChange={(e) => setSearch(e.target.value)} + /> + </div> + + {selected.length > 0 && ( + <div className="flex flex-wrap gap-2"> + {selected.map((id) => { + const q = bank.find((row) => row.id === id); + if (!q) return null; + return ( + <span + key={id} + className="flex max-w-full items-center gap-1.5 rounded-full bg-primary/10 py-1 pl-3 pr-1.5 text-xs" + > + <span className="truncate">{q.question}</span> + <button + type="button" + aria-label={`Remove ${q.question}`} + className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full hover:bg-primary/20" + onClick={() => toggle(id, false)} + > + <X className="h-3 w-3" /> + </button> + </span> + ); + })} + </div> + )} + + <ul className="overflow-hidden rounded-lg border"> + {visible.length === 0 && ( + <li className="px-3.5 py-4 text-[13px] text-muted-foreground"> + No questions match that search. + </li> + )} + {visible.map((q, index) => { + const isLinked = alreadyLinked.has(q.id); + return ( + <li + key={q.id} + className={`flex items-start gap-2.5 px-3.5 py-2.5 ${ + index > 0 ? "border-t" : "" + }`} + > + <input + id={`${rowId}-${q.id}`} + type="checkbox" + className="mt-0.5 h-4 w-4 shrink-0" + checked={isLinked || selected.includes(q.id)} + disabled={isLinked} + onChange={(e) => toggle(q.id, e.target.checked)} + /> + <div className="flex-1"> + <label + htmlFor={`${rowId}-${q.id}`} + className="block cursor-pointer text-[13px]" + > + {q.question} + </label> + {(q.tags?.length ?? 0) > 0 && ( + <div className="mt-1.5 flex flex-wrap gap-1.5"> + {q.tags?.map((tag) => ( + <Badge + key={tag.id} + variant="secondary" + className="text-[11px]" + > + {tag.label} + </Badge> + ))} + </div> + )} + </div> + {isLinked && ( + <span className="shrink-0 text-[11px] text-muted-foreground"> + On prep list + </span> + )} + </li> + ); + })} + </ul> + + <div className="space-y-3 rounded-lg border border-dashed p-3.5"> + <p className="text-[13px] font-semibold">Add a new question</p> + <Input + placeholder="Type a new interview question…" + aria-label="New question" + value={newQuestion} + onChange={(e) => setNewQuestion(e.target.value)} + /> + <div className="space-y-1.5"> + <p className="text-[13px] font-medium">Skill Tags</p> + <TagInput + availableTags={tags} + selectedTagIds={newTagIds} + onChange={setNewTagIds} + onTagCreated={(tag) => setTags((prev) => [...prev, tag])} + /> + </div> + <Button + size="sm" + variant="outline" + disabled={isPending || !newQuestion.trim()} + onClick={addQuestion} + > + Add Question + </Button> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button onClick={submit} disabled={isPending}> + {doneLabel} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/myjobs/job-details/timeline/AddStageDialog.tsx b/src/components/myjobs/job-details/timeline/AddStageDialog.tsx new file mode 100644 index 000000000..05d35cf4f --- /dev/null +++ b/src/components/myjobs/job-details/timeline/AddStageDialog.tsx @@ -0,0 +1,375 @@ +"use client"; +import { useEffect, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { Loader } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Combobox } from "@/components/ComboBox"; +import { DatePicker } from "@/components/DatePicker"; +import { TimePicker } from "@/components/TimePicker"; +import SelectFormCtrl from "@/components/Select"; +import { toastActionResult } from "@/lib/toast"; +import { STAGE_OUTCOMES } from "@/lib/constants"; +import { addJobStage, updateJobStage } from "@/actions/jobStage.actions"; +import { + AddJobStageFormSchema, + type AddJobStageValues, + type JobStage, + type JobStageTypeRef, +} from "@/models/jobStage.model"; +import type { JobStatus } from "@/models/job.model"; + +type AddStageDialogProps = { + open: boolean; + jobId: string; + jobLabel: string; + stage: JobStage | null; + stageTypes: JobStageTypeRef[]; + jobStatuses: JobStatus[]; + onOpenChange: (open: boolean) => void; + onSaved: () => void; +}; + +const OUTCOME_OPTIONS = STAGE_OUTCOMES.map((o) => ({ + id: o.value, + label: o.label, +})); + +// Adding a stage is almost always recording something that just happened, so +// the pickers open on now. Clear the date (click the selected day again) for +// a stage whose date isn't known yet. +const emptyValues = (jobId: string): AddJobStageValues => ({ + jobId, + stageTypeId: "", + customLabel: "", + customStatusId: "", + date: new Date(), + time: format(new Date(), "hh:mm a"), + notes: "", + outcome: null, + durationMins: null, + format: "", + location: "", + setAsCurrent: true, +}); + +const editValues = (jobId: string, stage: JobStage): AddJobStageValues => ({ + ...emptyValues(jobId), + id: stage.id, + stageTypeId: stage.stageTypeId, + date: stage.occurredAt ?? null, + time: stage.occurredAt ? format(stage.occurredAt, "hh:mm a") : "", + notes: stage.notes ?? "", + outcome: stage.outcome ?? null, + durationMins: stage.durationMins ?? null, + format: stage.format ?? "", + location: stage.location ?? "", + setAsCurrent: stage.isCurrent, +}); + +export function AddStageDialog({ + open, + jobId, + jobLabel, + stage, + stageTypes, + jobStatuses, + onOpenChange, + onSaved, +}: AddStageDialogProps) { + const [isPending, startTransition] = useTransition(); + + const form = useForm<AddJobStageValues>({ + resolver: zodResolver(AddJobStageFormSchema) as any, + defaultValues: emptyValues(jobId), + }); + + const { reset } = form; + + // The host renders this dialog permanently and only flips `open`, so it + // cannot call reset() itself — the same reason AddContact resets on open. + useEffect(() => { + if (!open) return; + reset(stage ? editValues(jobId, stage) : emptyValues(jobId), { + keepDefaultValues: true, + }); + }, [open, stage, jobId, reset]); + + const customLabel = form.watch("customLabel"); + const isCustom = !!customLabel?.trim(); + const statusValue = isCustom + ? jobStatuses.find((s) => s.id === form.watch("customStatusId"))?.value + : stageTypes.find((t) => t.id === form.watch("stageTypeId"))?.Status?.value; + const showInterviewFields = statusValue === "interview"; + + // Nothing demotes a stage: stageDataFrom never writes isCurrent, so an + // unticked box here would save without error and change nothing. + const currentLocked = !!stage?.isCurrent; + + const onSubmit = (values: AddJobStageValues) => { + startTransition(async () => { + const res = stage + ? await updateJobStage({ ...values, id: stage.id }) + : await addJobStage(values); + toastActionResult(res, { + success: `Stage has been ${stage ? "updated" : "added"} successfully`, + onSuccess: () => { + onSaved(); + onOpenChange(false); + }, + }); + }); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[480px] max-h-[85vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>{stage ? "Edit Stage" : "Add Stage"}</DialogTitle> + <DialogDescription>{jobLabel}</DialogDescription> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 p-1"> + <FormField + control={form.control} + name="stageTypeId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>STAGE</FormLabel> + <div data-testid="stage-type-select"> + <Combobox + options={stageTypes} + field={field} + fullWidth + label="stage" + /> + </div> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="customLabel" + render={({ field }) => ( + <FormItem> + <FormLabel>OR ENTER A CUSTOM STAGE NAME</FormLabel> + <FormControl> + <Input + placeholder="e.g. Panel Interview, Reference Check…" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {isCustom && ( + <FormField + control={form.control} + name="customStatusId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>PARENT STATUS</FormLabel> + <SelectFormCtrl + label="status" + options={jobStatuses} + field={field} + /> + <FormMessage /> + </FormItem> + )} + /> + )} + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="date" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>DATE</FormLabel> + <DatePicker + field={field} + presets={false} + isEnabled + captionLayout + futureYears={10} + fullWidth + /> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="time" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>TIME</FormLabel> + <TimePicker field={field} fullWidth /> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {showInterviewFields && ( + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="format" + render={({ field }) => ( + <FormItem> + <FormLabel>FORMAT</FormLabel> + <FormControl> + <Input placeholder="e.g. Video, Phone" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="durationMins" + render={({ field }) => ( + <FormItem> + <FormLabel>DURATION (MIN)</FormLabel> + <FormControl> + <Input + type="number" + min={1} + value={field.value ?? ""} + onChange={(e) => + field.onChange( + e.target.value ? Number(e.target.value) : null, + ) + } + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <div className="col-span-2"> + <FormField + control={form.control} + name="location" + render={({ field }) => ( + <FormItem> + <FormLabel>LOCATION</FormLabel> + <FormControl> + <Input + placeholder="e.g. 200 King St W, or a meeting link" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + )} + + {stage && ( + <FormField + control={form.control} + name="outcome" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>OUTCOME</FormLabel> + <SelectFormCtrl + label="outcome" + options={OUTCOME_OPTIONS} + field={{ ...field, value: field.value ?? "" }} + /> + <FormMessage /> + </FormItem> + )} + /> + )} + + <FormField + control={form.control} + name="notes" + render={({ field }) => ( + <FormItem> + <FormLabel>NOTES (OPTIONAL)</FormLabel> + <FormControl> + <Textarea + className="min-h-20" + placeholder="Add any context for this stage…" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="setAsCurrent" + render={({ field }) => ( + <FormItem> + <label className="flex cursor-pointer items-center gap-2.5"> + <input + type="checkbox" + className="h-4 w-4 shrink-0" + checked={!!field.value} + disabled={currentLocked} + onChange={(e) => field.onChange(e.target.checked)} + /> + <span className="text-[13px]">Set as current stage</span> + </label> + {currentLocked && ( + <p className="text-xs text-muted-foreground"> + This is the current stage — tick the box on another stage + to move it. + </p> + )} + </FormItem> + )} + /> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + Cancel + </Button> + <Button type="submit" disabled={isPending}> + {stage ? "Save Changes" : "Add Stage"} + {isPending && <Loader className="h-4 w-4 shrink-0 spinner" />} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/myjobs/job-details/timeline/JobTimelineTab.tsx b/src/components/myjobs/job-details/timeline/JobTimelineTab.tsx new file mode 100644 index 000000000..340327040 --- /dev/null +++ b/src/components/myjobs/job-details/timeline/JobTimelineTab.tsx @@ -0,0 +1,78 @@ +"use client"; +import { CalendarClock } from "lucide-react"; +import type { JobStage, JobStageTypeRef } from "@/models/jobStage.model"; +import { JobTabEmptyState } from "../JobTabEmptyState"; +import { StageHistoryList } from "./StageHistoryList"; +import { StageDetailPanel } from "./StageDetailPanel"; +import { terminalStagesFor } from "./stageDisplay"; + +type JobTimelineTabProps = { + stages: JobStage[]; + stageTypes: JobStageTypeRef[]; + selectedStageId: string | null; + currentStageId: string | null; + onSelect: (stageId: string) => void; + onAddStage: () => void; + onEditStage: (stage: JobStage) => void; + onLinkInterviewers: () => void; + onAddPrepQuestions: () => void; + onChanged: () => void; +}; + +export function JobTimelineTab({ + stages, + stageTypes, + selectedStageId, + currentStageId, + onSelect, + onAddStage, + onEditStage, + onLinkInterviewers, + onAddPrepQuestions, + onChanged, +}: JobTimelineTabProps) { + // A job restored from a pre-feature backup is permanently stageless, so + // this is a supported state, not a transient one. + if (stages.length === 0) { + return ( + <JobTabEmptyState + icon={CalendarClock} + title="No stages recorded yet" + description="Add the first stage to start this job's timeline. Its status stays as it is until a stage sets it." + actionLabel="Add stage" + onAction={onAddStage} + actionTestId="timeline-add-stage-btn" + /> + ); + } + + const selected = stages.find((s) => s.id === selectedStageId) ?? null; + const terminalTypes = terminalStagesFor(stages, stageTypes); + + return ( + <div className="@container/timeline"> + <div className="flex flex-col gap-6 @3xl/timeline:flex-row @3xl/timeline:items-start"> + <div className="w-full shrink-0 @3xl/timeline:w-[340px]"> + <StageHistoryList + stages={stages} + selectedStageId={selectedStageId} + currentStageId={currentStageId} + terminalTypes={terminalTypes} + onSelect={onSelect} + onAddStage={onAddStage} + /> + </div> + {selected && ( + <StageDetailPanel + stage={selected} + isCurrent={selected.id === currentStageId} + onEdit={() => onEditStage(selected)} + onLinkInterviewers={onLinkInterviewers} + onAddPrepQuestions={onAddPrepQuestions} + onChanged={onChanged} + /> + )} + </div> + </div> + ); +} diff --git a/src/components/myjobs/job-details/timeline/LinkInterviewersDialog.tsx b/src/components/myjobs/job-details/timeline/LinkInterviewersDialog.tsx new file mode 100644 index 000000000..35f73bc1a --- /dev/null +++ b/src/components/myjobs/job-details/timeline/LinkInterviewersDialog.tsx @@ -0,0 +1,262 @@ +"use client"; +import { useEffect, useRef, useState, useTransition } from "react"; +import { Check, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { toastActionResult } from "@/lib/toast"; +import { createContact, getAllContacts } from "@/actions/contact.actions"; +import { linkStageInterviewer } from "@/actions/jobStage.actions"; +import type { JobStage } from "@/models/jobStage.model"; +import { stageHeading, stageInitials } from "./stageDisplay"; + +type ContactRow = { + id: string; + label: string; + title: string | null; + company: string | null; + value: string; +}; + +type LinkInterviewersDialogProps = { + open: boolean; + stage: JobStage | null; + onOpenChange: (open: boolean) => void; + onLinked: () => void; +}; + +const EMPTY_NEW = { name: "", title: "", email: "" }; + +// createContact parses the whole contact form; the untouched optional fields +// arrive as "" and toContactData normalises them to null. +const contactPayload = (fields: typeof EMPTY_NEW) => ({ + ...fields, + phone: "", + linkedinUrl: "", + company: "", + location: "", + relationship: "", + workedAtCompany: "", + contactRole: "", + workedFrom: null, + workedTo: null, + notes: "", + lastContactedAt: null, +}); + +export function LinkInterviewersDialog({ + open, + stage, + onOpenChange, + onLinked, +}: LinkInterviewersDialogProps) { + const [contacts, setContacts] = useState<ContactRow[]>([]); + const [search, setSearch] = useState(""); + const [linkedIds, setLinkedIds] = useState<string[]>([]); + const [linkedCount, setLinkedCount] = useState(0); + const [newContact, setNewContact] = useState(EMPTY_NEW); + const [isPending, startTransition] = useTransition(); + const loaded = useRef(false); + + // Loaded on first open only — the job page already fires many queries. + useEffect(() => { + if (!open || loaded.current) return; + loaded.current = true; + void (async () => { + const rows = await getAllContacts(); + if (Array.isArray(rows)) setContacts(rows); + })(); + }, [open]); + + // Transient state only, keyed on stage?.id: a link reloads the stages and + // hands back a new object, which would wipe the search and the draft. + useEffect(() => { + if (!open) return; + setSearch(""); + setLinkedCount(0); + setNewContact(EMPTY_NEW); + }, [open, stage?.id]); + + // Re-seeded on every reload, so a link that failed server-side corrects + // itself rather than reading as linked until the page reloads. + useEffect(() => { + setLinkedIds((stage?.interviewers ?? []).map((iv) => iv.contactId)); + }, [stage]); + + if (!stage) return null; + + const term = search.trim().toLowerCase(); + const visible = term + ? contacts.filter((c) => c.value.includes(term)) + : contacts; + + const link = (contactId: string) => + startTransition(async () => { + const res = await linkStageInterviewer(stage.id, contactId); + toastActionResult(res, { + success: "Interviewer has been linked successfully", + onSuccess: () => { + setLinkedIds((prev) => [...prev, contactId]); + setLinkedCount((n) => n + 1); + onLinked(); + }, + }); + }); + + const addAndLink = () => + startTransition(async () => { + const res = await createContact(contactPayload(newContact)); + toastActionResult(res, { + success: "Contact has been created successfully", + onSuccess: () => { + const row: ContactRow = { + id: res.data.id, + label: newContact.name.trim(), + title: newContact.title.trim() || null, + company: null, + value: `${newContact.name} ${newContact.email}`.toLowerCase(), + }; + setContacts((prev) => [row, ...prev]); + setNewContact(EMPTY_NEW); + link(row.id); + }, + }); + }); + + const doneLabel = + linkedCount === 0 + ? "Done" + : `Done — ${linkedCount} interviewer${linkedCount === 1 ? "" : "s"} linked`; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[520px] max-h-[720px] overflow-hidden"> + <DialogHeader> + <DialogTitle>Add and Link Interviewers</DialogTitle> + <DialogDescription>{stageHeading(stage)}</DialogDescription> + </DialogHeader> + + <div className="max-h-[60vh] space-y-4 overflow-y-auto p-1"> + <div className="relative"> + <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + <Input + className="pl-9" + placeholder="Search contacts by name or company…" + value={search} + onChange={(e) => setSearch(e.target.value)} + /> + </div> + + <ul className="overflow-hidden rounded-lg border"> + {visible.length === 0 && ( + <li className="px-3.5 py-4 text-[13px] text-muted-foreground"> + No contacts match that search. + </li> + )} + {visible.map((contact, index) => { + const isLinked = linkedIds.includes(contact.id); + const sub = [contact.title, contact.company] + .filter(Boolean) + .join(" · "); + return ( + <li + key={contact.id} + className={`flex items-center gap-3 px-3.5 py-2.5 ${ + index > 0 ? "border-t" : "" + }`} + > + <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-[11px] font-bold text-primary"> + {stageInitials(contact.label)} + </span> + <span className="min-w-0 flex-1"> + <span className="block truncate text-[13px] font-semibold"> + {contact.label} + </span> + {sub && ( + <span className="block truncate text-xs text-muted-foreground"> + {sub} + </span> + )} + </span> + {isLinked ? ( + <span + aria-label={`${contact.label} is already linked`} + className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-300" + > + <Check className="h-3.5 w-3.5" /> + </span> + ) : ( + <Button + size="sm" + variant="outline" + disabled={isPending} + onClick={() => link(contact.id)} + > + <span className="sr-only">Add {contact.label}</span> + <span aria-hidden="true">Add</span> + </Button> + )} + </li> + ); + })} + </ul> + + <div className="space-y-3 rounded-lg border border-dashed p-3.5"> + <p className="text-[13px] font-semibold">Add a new contact</p> + <div className="grid grid-cols-2 gap-3"> + <Input + placeholder="Full name" + aria-label="Full name" + value={newContact.name} + onChange={(e) => + setNewContact((v) => ({ ...v, name: e.target.value })) + } + /> + <Input + placeholder="Role / title" + aria-label="Role / title" + value={newContact.title} + onChange={(e) => + setNewContact((v) => ({ ...v, title: e.target.value })) + } + /> + <div className="col-span-2"> + <Input + type="email" + placeholder="Email" + aria-label="Email" + value={newContact.email} + onChange={(e) => + setNewContact((v) => ({ ...v, email: e.target.value })) + } + /> + </div> + </div> + <Button + size="sm" + variant="outline" + disabled={isPending || !newContact.name.trim()} + onClick={addAndLink} + > + Add Contact + </Button> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button onClick={() => onOpenChange(false)}>{doneLabel}</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/myjobs/job-details/timeline/StageDetailPanel.tsx b/src/components/myjobs/job-details/timeline/StageDetailPanel.tsx new file mode 100644 index 000000000..d308e9917 --- /dev/null +++ b/src/components/myjobs/job-details/timeline/StageDetailPanel.tsx @@ -0,0 +1,215 @@ +"use client"; +import { useEffect, useState, useTransition } from "react"; +import { CalendarDays, Pencil, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { DeleteAlertDialog } from "@/components/DeleteAlertDialog"; +import { toastActionResult } from "@/lib/toast"; +import { deleteJobStage } from "@/actions/jobStage.actions"; +import type { JobStage } from "@/models/jobStage.model"; +import { + askedTally, + formatStageDateTime, + isInterviewStage, +} from "./stageDisplay"; +import { StageOverviewTab } from "./StageOverviewTab"; +import { StageInterviewersTab } from "./StageInterviewersTab"; +import { StagePrepList } from "./StagePrepList"; + +// Underline tabs rather than the app's filled pills: this row sits inside the +// job's own pill tab bar, and two nested pill bars read as competing controls. +const TAB_TRIGGER = + "cursor-pointer rounded-none border-b-2 border-transparent px-0 pb-2.5 text-sm font-semibold text-muted-foreground data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:shadow-none"; + +type StageDetailPanelProps = { + stage: JobStage; + isCurrent: boolean; + onEdit: () => void; + onLinkInterviewers: () => void; + onAddPrepQuestions: () => void; + onChanged: () => void; +}; + +export function StageDetailPanel({ + stage, + isCurrent, + onEdit, + onLinkInterviewers, + onAddPrepQuestions, + onChanged, +}: StageDetailPanelProps) { + const [, startTransition] = useTransition(); + const [deleteOpen, setDeleteOpen] = useState(false); + const [tab, setTab] = useState("overview"); + const interview = isInterviewStage(stage); + const { asked, total } = askedTally(stage); + + // Selecting another stage re-renders this component rather than remounting + // it, so the tab has to follow the id or an interview tab survives onto a + // stage that has none. + useEffect(() => { + setTab("overview"); + }, [stage.id]); + + // Both joins cascade with the stage, so the confirmation names what goes. + const interviewerCount = stage.interviewers?.length ?? 0; + const prepCount = stage.prepQuestions?.length ?? 0; + const hasLinks = interviewerCount > 0 || prepCount > 0; + const deleteDescription = + isCurrent || hasLinks ? ( + <> + {isCurrent && ( + <p> + This is the current stage. Deleting it makes the stage before it + current, and the job's status follows that stage. + </p> + )} + {hasLinks && ( + <> + <p className={cn(isCurrent && "mt-2")}>Deleting it also removes:</p> + <ul className="mt-1.5 list-disc space-y-1 pl-5"> + {interviewerCount > 0 && ( + <li> + <span className="font-semibold text-foreground"> + {interviewerCount} linked{" "} + {interviewerCount === 1 ? "interviewer" : "interviewers"} + </span>{" "} + on this stage + </li> + )} + {prepCount > 0 && ( + <li> + <span className="font-semibold text-foreground"> + {prepCount} prep{" "} + {prepCount === 1 ? "question" : "questions"} + </span>{" "} + on this stage, and which of them were asked + </li> + )} + </ul> + <p className="mt-2"> + {interviewerCount > 0 && prepCount > 0 + ? "Nothing is removed from the job's Contacts tab or your question bank." + : interviewerCount > 0 + ? "Nothing is removed from the job's Contacts tab." + : "Nothing is removed from your question bank."} + </p> + </> + )} + </> + ) : undefined; + + const onDelete = () => { + setDeleteOpen(false); + startTransition(async () => { + const res = await deleteJobStage(stage.id); + toastActionResult(res, { + success: "Stage has been deleted", + onSuccess: onChanged, + }); + }); + }; + + return ( + <div className="flex-1 rounded-lg border bg-card p-6"> + <div className="flex items-start justify-between gap-2"> + <div className="flex flex-wrap items-center gap-2.5"> + <h3 className="text-lg font-bold">{stage.StageType.label}</h3> + {isCurrent && ( + <span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[11px] font-bold text-white dark:bg-emerald-400 dark:text-emerald-950"> + CURRENT STAGE + </span> + )} + </div> + <div className="flex shrink-0 items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-8 w-8 cursor-pointer" + aria-label={`Edit ${stage.StageType.label}`} + onClick={onEdit} + > + <Pencil className="h-3.5 w-3.5" /> + </Button> + <Button + variant="outline" + size="icon" + className="h-8 w-8 cursor-pointer text-destructive hover:text-destructive" + aria-label={`Delete ${stage.StageType.label}`} + onClick={() => setDeleteOpen(true)} + > + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </div> + </div> + + <p className="mt-1.5 flex items-center gap-1.5 text-[13px] text-muted-foreground"> + <CalendarDays className="h-3.5 w-3.5" /> + {formatStageDateTime( + stage.occurredAt, + interview ? stage.durationMins : null, + )} + </p> + + {/* A one-tab bar is a control that does nothing, so a non-interview + stage gets the overview on its own. */} + {interview ? ( + <Tabs value={tab} onValueChange={setTab} className="mt-4"> + <TabsList className="h-auto w-full justify-start gap-6 rounded-none border-b bg-transparent p-0"> + <TabsTrigger value="overview" className={TAB_TRIGGER}> + Overview + </TabsTrigger> + <TabsTrigger value="interviewer" className={TAB_TRIGGER}> + Interviewer + {stage.interviewers.length > 0 && ( + <span className="ml-2 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[11px] font-bold text-emerald-700 dark:text-emerald-300"> + {stage.interviewers.length} + </span> + )} + </TabsTrigger> + <TabsTrigger value="prep" className={TAB_TRIGGER}> + Prep List + {total > 0 && ( + <span className="ml-2 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[11px] font-bold text-emerald-700 dark:text-emerald-300"> + {asked} of {total} + </span> + )} + </TabsTrigger> + </TabsList> + <TabsContent value="overview" className="mt-4"> + <StageOverviewTab stage={stage} onChanged={onChanged} /> + </TabsContent> + <TabsContent value="interviewer" className="mt-4"> + <StageInterviewersTab + stage={stage} + onLinkInterviewers={onLinkInterviewers} + onChanged={onChanged} + /> + </TabsContent> + <TabsContent value="prep" className="mt-4"> + <StagePrepList + stage={stage} + onAddPrepQuestions={onAddPrepQuestions} + onChanged={onChanged} + /> + </TabsContent> + </Tabs> + ) : ( + <> + <hr className="my-4" /> + <StageOverviewTab stage={stage} onChanged={onChanged} /> + </> + )} + + <DeleteAlertDialog + pageTitle="stage" + open={deleteOpen} + onOpenChange={setDeleteOpen} + onDelete={onDelete} + alertTitle={`Delete the ${stage.StageType.label} stage?`} + alertDescription={deleteDescription} + /> + </div> + ); +} diff --git a/src/components/myjobs/job-details/timeline/StageHistoryList.tsx b/src/components/myjobs/job-details/timeline/StageHistoryList.tsx new file mode 100644 index 000000000..d6b4b5ff9 --- /dev/null +++ b/src/components/myjobs/job-details/timeline/StageHistoryList.tsx @@ -0,0 +1,149 @@ +"use client"; +import { Check, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import type { JobStage, JobStageTypeRef } from "@/models/jobStage.model"; +import { formatStageDate } from "./stageDisplay"; + +type StageHistoryListProps = { + stages: JobStage[]; + selectedStageId: string | null; + currentStageId: string | null; + terminalTypes: JobStageTypeRef[]; + onSelect: (stageId: string) => void; + onAddStage: () => void; +}; + +// The connector spans the two rows' padding, so it only lines up while the +// rows stay adjacent with no margin between them. +function StageMarker({ + state, + hasNext, + nextReached, +}: { + state: "complete" | "current" | "upcoming"; + hasNext: boolean; + nextReached: boolean; +}) { + return ( + <span aria-hidden className="relative flex h-5 w-5 shrink-0 items-center justify-center"> + <span + className={cn( + "flex h-5 w-5 items-center justify-center rounded-full", + state === "current" + ? "bg-emerald-500 ring-4 ring-emerald-500/20 dark:bg-emerald-400 dark:ring-emerald-400/20" + : state === "complete" + ? "bg-emerald-500 dark:bg-emerald-400" + : "border-2 border-border bg-card", + )} + > + {state === "complete" && ( + <Check className="h-3 w-3 text-background" strokeWidth={3} /> + )} + </span> + {hasNext && ( + <span + className={cn( + "absolute left-1/2 top-full h-5 w-0.5 -translate-x-1/2", + nextReached ? "bg-emerald-500 dark:bg-emerald-400" : "bg-border", + )} + /> + )} + </span> + ); +} + +export function StageHistoryList({ + stages, + selectedStageId, + currentStageId, + terminalTypes, + onSelect, + onAddStage, +}: StageHistoryListProps) { + const currentIndex = stages.findIndex((s) => s.id === currentStageId); + + return ( + <div className="rounded-lg border bg-card p-2.5"> + <h3 className="px-2.5 py-2 text-xs font-bold tracking-wide text-muted-foreground"> + STAGE HISTORY + </h3> + + {/* The complete history lives here, and only here: every stage is + reachable without a sideways scroll. */} + <ul> + {stages.map((stage, index) => { + const isSelected = stage.id === selectedStageId; + const isCurrent = stage.id === currentStageId; + const isComplete = currentIndex >= 0 && index < currentIndex; + return ( + <li key={stage.id}> + <button + type="button" + onClick={() => onSelect(stage.id)} + aria-current={isCurrent ? "step" : undefined} + className={cn( + "flex w-full cursor-pointer items-center gap-2.5 rounded-md p-2.5 text-left", + "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring", + isSelected ? "bg-accent" : "hover:bg-accent/50", + )} + > + <StageMarker + state={isCurrent ? "current" : isComplete ? "complete" : "upcoming"} + hasNext={index < stages.length - 1 || terminalTypes.length > 0} + nextReached={currentIndex >= 0 && index < currentIndex} + /> + <span + className={cn( + "flex-1 truncate text-[13px] font-semibold", + isCurrent && "font-extrabold text-emerald-600 dark:text-emerald-400", + )} + > + {stage.StageType.label} + </span> + <span + className={cn( + "text-xs", + isCurrent + ? "font-bold text-emerald-600 dark:text-emerald-400" + : "text-muted-foreground", + )} + > + {formatStageDate(stage.occurredAt)} + </span> + {isCurrent && <span className="sr-only">Current stage</span>} + </button> + </li> + ); + })} + + {terminalTypes.map((type, index) => ( + <li + key={type.id} + className="flex items-center gap-2.5 p-2.5 text-muted-foreground" + > + <StageMarker + state="upcoming" + hasNext={index < terminalTypes.length - 1} + nextReached={false} + /> + <span className="flex-1 truncate text-[13px] font-semibold"> + {type.label} + </span> + <span className="text-xs">—</span> + </li> + ))} + </ul> + + <Button + variant="outline" + className="mt-1.5 w-full cursor-pointer border-dashed" + onClick={onAddStage} + data-testid="timeline-add-stage-btn" + > + <Plus className="mr-1.5 h-3.5 w-3.5" /> + Add stage + </Button> + </div> + ); +} diff --git a/src/components/myjobs/job-details/timeline/StageInterviewersTab.tsx b/src/components/myjobs/job-details/timeline/StageInterviewersTab.tsx new file mode 100644 index 000000000..8caf641fd --- /dev/null +++ b/src/components/myjobs/job-details/timeline/StageInterviewersTab.tsx @@ -0,0 +1,86 @@ +"use client"; +import { useTransition } from "react"; +import { UserPlus, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { toastActionResult } from "@/lib/toast"; +import { unlinkStageInterviewer } from "@/actions/jobStage.actions"; +import type { JobStage } from "@/models/jobStage.model"; +import { stageInitials } from "./stageDisplay"; + +type StageInterviewersTabProps = { + stage: JobStage; + onLinkInterviewers: () => void; + onChanged: () => void; +}; + +export function StageInterviewersTab({ + stage, + onLinkInterviewers, + onChanged, +}: StageInterviewersTabProps) { + const [, startTransition] = useTransition(); + + const unlink = (linkId: string) => { + startTransition(async () => { + const res = await unlinkStageInterviewer(linkId); + toastActionResult(res, { + success: "Interviewer unlinked from this stage", + onSuccess: onChanged, + }); + }); + }; + + return ( + <div> + <div className="flex items-center justify-between gap-2"> + <span className="text-[11px] font-bold tracking-wide text-muted-foreground"> + INTERVIEWERS + </span> + <Button + variant="outline" + size="sm" + className="h-7 cursor-pointer gap-1" + onClick={onLinkInterviewers} + > + <UserPlus className="h-3 w-3" /> + Add Interviewers + </Button> + </div> + + {stage.interviewers.length === 0 ? ( + <p className="mt-2.5 rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground"> + No interviewers linked yet. + </p> + ) : ( + <ul className="mt-2.5 space-y-2"> + {stage.interviewers.map((link) => ( + <li key={link.id} className="flex items-center gap-2.5"> + <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-[13px] font-bold text-primary-foreground"> + {stageInitials(link.Contact.name)} + </span> + <span className="min-w-0 flex-1"> + <span className="block truncate text-sm font-bold"> + {link.Contact.name} + </span> + {link.Contact.title && ( + <span className="block truncate text-xs text-muted-foreground"> + {link.Contact.title} + </span> + )} + </span> + <Button + variant="ghost" + size="icon" + className="h-7 w-7 cursor-pointer" + aria-label={`Unlink ${link.Contact.name}`} + onClick={() => unlink(link.id)} + > + <X className="h-3.5 w-3.5" /> + </Button> + </li> + ))} + </ul> + )} + </div> + ); +} diff --git a/src/components/myjobs/job-details/timeline/StageNotes.tsx b/src/components/myjobs/job-details/timeline/StageNotes.tsx new file mode 100644 index 000000000..258e9c8de --- /dev/null +++ b/src/components/myjobs/job-details/timeline/StageNotes.tsx @@ -0,0 +1,72 @@ +"use client"; +import { useEffect, useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { toastActionResult } from "@/lib/toast"; +import { setStageNotes } from "@/actions/jobStage.actions"; +import type { JobStage } from "@/models/jobStage.model"; + +type StageNotesProps = { + stage: JobStage; + onChanged: () => void; +}; + +export function StageNotes({ stage, onChanged }: StageNotesProps) { + const [draft, setDraft] = useState(stage.notes ?? ""); + const [saving, startTransition] = useTransition(); + + // Selecting another stage in the history list re-renders this component + // with a different stage, so the draft has to follow the id, not mount. + useEffect(() => { + setDraft(stage.notes ?? ""); + }, [stage.id, stage.notes]); + + const dirty = draft !== (stage.notes ?? ""); + + const save = () => { + startTransition(async () => { + const res = await setStageNotes(stage.id, draft); + toastActionResult(res, { success: "Note saved", onSuccess: onChanged }); + }); + }; + + return ( + <div> + <label + htmlFor={`stage-notes-${stage.id}`} + className="text-[11px] font-bold tracking-wide text-muted-foreground" + > + NOTES + </label> + <Textarea + id={`stage-notes-${stage.id}`} + value={draft} + onChange={(e) => setDraft(e.target.value)} + placeholder="What happened, what to prepare next time…" + className="mt-2 min-h-18 resize-none text-sm" + /> + {/* Only once there is something to save: the artboard shows the edit + state, not the resting state. */} + {dirty && ( + <div className="mt-2 flex justify-end gap-2"> + <Button + variant="outline" + size="sm" + className="cursor-pointer" + onClick={() => setDraft(stage.notes ?? "")} + > + Cancel + </Button> + <Button + size="sm" + className="cursor-pointer" + disabled={saving} + onClick={save} + > + Save Changes + </Button> + </div> + )} + </div> + ); +} diff --git a/src/components/myjobs/job-details/timeline/StageOverviewTab.tsx b/src/components/myjobs/job-details/timeline/StageOverviewTab.tsx new file mode 100644 index 000000000..88ec5b49f --- /dev/null +++ b/src/components/myjobs/job-details/timeline/StageOverviewTab.tsx @@ -0,0 +1,72 @@ +"use client"; +import { Clock, MapPin, Monitor } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { STAGE_OUTCOMES } from "@/lib/constants"; +import type { JobStage } from "@/models/jobStage.model"; +import { isInterviewStage } from "./stageDisplay"; +import { StageNotes } from "./StageNotes"; + +type StageOverviewTabProps = { + stage: JobStage; + onChanged: () => void; +}; + +function StageFact({ + label, + value, + icon: Icon, +}: { + label: string; + value: string | null; + icon: LucideIcon; +}) { + return ( + <div> + <span className="text-[11px] font-bold tracking-wide text-muted-foreground"> + {label} + </span> + <p className="mt-1.5 flex items-center gap-2 text-sm"> + <Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> + {value ?? <span className="text-muted-foreground">Not set</span>} + </p> + </div> + ); +} + +export function StageOverviewTab({ stage, onChanged }: StageOverviewTabProps) { + const outcomeLabel = STAGE_OUTCOMES.find( + (o) => o.value === stage.outcome, + )?.label; + + return ( + <div className="space-y-4"> + <div> + <span className="text-[11px] font-bold tracking-wide text-muted-foreground"> + OUTCOME + </span> + <p className="mt-1.5 text-sm"> + {outcomeLabel ? ( + <Badge variant="secondary">{outcomeLabel}</Badge> + ) : ( + <span className="text-muted-foreground">Not recorded</span> + )} + </p> + </div> + + {isInterviewStage(stage) && ( + <div className="grid gap-4 @lg/timeline:grid-cols-3"> + <StageFact label="LOCATION" value={stage.location} icon={MapPin} /> + <StageFact label="FORMAT" value={stage.format} icon={Monitor} /> + <StageFact + label="DURATION" + value={stage.durationMins ? `${stage.durationMins} min` : null} + icon={Clock} + /> + </div> + )} + + <StageNotes stage={stage} onChanged={onChanged} /> + </div> + ); +} diff --git a/src/components/myjobs/job-details/timeline/StagePrepList.tsx b/src/components/myjobs/job-details/timeline/StagePrepList.tsx new file mode 100644 index 000000000..406dfc27a --- /dev/null +++ b/src/components/myjobs/job-details/timeline/StagePrepList.tsx @@ -0,0 +1,229 @@ +"use client"; +import { useState, useTransition } from "react"; +import { ChevronDown, Plus, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { TipTapContentViewer } from "@/components/TipTapContentViewer"; +import { toastActionResult } from "@/lib/toast"; +import { + removeStagePrepQuestion, + setPrepQuestionAsked, +} from "@/actions/jobStage.actions"; +import type { JobStage } from "@/models/jobStage.model"; +import { askedTally } from "./stageDisplay"; + +type StagePrepListProps = { + stage: JobStage; + onAddPrepQuestions: () => void; + onChanged: () => void; +}; + +// An untouched answer is still QuestionForm's "TBD" default, which the Tiptap +// editor may have wrapped in markup by the time it is saved. +function hasAnswer(answer: string | null): boolean { + const text = (answer ?? "").replace(/<[^>]*>/g, "").trim().toLowerCase(); + return text.length > 0 && text !== "tbd"; +} + +export function StagePrepList({ + stage, + onAddPrepQuestions, + onChanged, +}: StagePrepListProps) { + const [, startTransition] = useTransition(); + // Optimistic so the checkbox never lags the click; the reload below is what + // makes it durable. + const [pending, setPending] = useState<Record<string, boolean>>({}); + const [expanded, setExpanded] = useState<Record<string, boolean>>({}); + const [allExpanded, setAllExpanded] = useState(false); + const rows = stage.prepQuestions ?? []; + const { total } = askedTally(stage); + const answerable = rows.filter((row) => hasAnswer(row.Question.answer)); + + const toggle = (linkId: string, next: boolean) => { + setPending((prev) => ({ ...prev, [linkId]: next })); + startTransition(async () => { + const res = await setPrepQuestionAsked(linkId, next); + toastActionResult(res, { + success: next ? "Marked as asked" : "Marked as not asked", + onSuccess: onChanged, + }); + // Rolled back only on failure: on success the reload lands on the same + // value, so dropping it early would flash the old state. + if (!res?.success) { + setPending(({ [linkId]: _rolledBack, ...rest }) => rest); + } + }); + }; + + const remove = (linkId: string) => { + startTransition(async () => { + const res = await removeStagePrepQuestion(linkId); + toastActionResult(res, { + success: "Question removed from the prep list", + onSuccess: onChanged, + }); + }); + }; + + const setOpen = (linkId: string, next: boolean) => + setExpanded((prev) => ({ ...prev, [linkId]: next })); + + const toggleAll = () => { + const next = !allExpanded; + setAllExpanded(next); + setExpanded( + next ? Object.fromEntries(answerable.map((row) => [row.id, true])) : {}, + ); + }; + + return ( + <div> + <div className="flex flex-wrap items-center justify-between gap-2"> + <p className="text-xs text-muted-foreground"> + Check off a question once your interviewer actually asks it. + </p> + <div className="flex items-center gap-2"> + {answerable.length > 0 && ( + <Button + variant="ghost" + size="sm" + className="h-7 cursor-pointer gap-1 text-xs" + onClick={toggleAll} + > + <ChevronDown + className={cn( + "h-3 w-3 transition-transform", + allExpanded && "rotate-180", + )} + /> + {allExpanded ? "Collapse all" : "Expand all"} + </Button> + )} + <Button + variant="outline" + size="sm" + className="h-7 cursor-pointer gap-1" + onClick={onAddPrepQuestions} + > + <Plus className="h-3 w-3" /> + Add Questions + </Button> + </div> + </div> + + {total === 0 ? ( + <p className="mt-2.5 rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground"> + No questions on this prep list yet. + </p> + ) : ( + <ul className="mt-2.5 overflow-hidden rounded-lg border"> + {rows.map((row, index) => { + const isAsked = pending[row.id] ?? row.asked; + const answered = hasAnswer(row.Question.answer); + const isOpen = answered && (expanded[row.id] ?? false); + const text = ( + <> + <span + className={cn( + "block text-[13px]", + !isAsked && "text-muted-foreground", + )} + > + {row.Question.question} + </span> + <span className="mt-1.5 flex flex-wrap items-center gap-1.5"> + {row.Question.tags?.map((tag) => ( + <Badge + key={tag.id} + variant="secondary" + className="text-[11px]" + > + {tag.label} + </Badge> + ))} + {!answered && ( + <span className="text-[11px] italic text-muted-foreground"> + No answer yet + </span> + )} + </span> + </> + ); + return ( + <li + key={row.id} + className={cn( + "px-3.5 py-2.5", + index > 0 && "border-t", + isAsked && "bg-emerald-500/10", + )} + > + <div className="flex items-start gap-2.5"> + <input + type="checkbox" + className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-emerald-600" + checked={isAsked} + aria-label={`Mark ${row.Question.question} as asked`} + onChange={(e) => toggle(row.id, e.target.checked)} + /> + {/* The text expands the answer; only the checkbox marks asked. */} + {answered ? ( + <button + type="button" + className="flex-1 cursor-pointer text-left" + aria-expanded={isOpen} + onClick={() => setOpen(row.id, !isOpen)} + > + {text} + </button> + ) : ( + <div className="flex-1">{text}</div> + )} + {isAsked && ( + <span className="shrink-0 text-[11px] font-bold text-emerald-700 dark:text-emerald-300"> + Asked + </span> + )} + {answered && ( + <Button + variant="ghost" + size="icon" + className="h-7 w-7 shrink-0 cursor-pointer" + aria-expanded={isOpen} + aria-label={`${isOpen ? "Hide" : "Show"} the answer to ${row.Question.question}`} + onClick={() => setOpen(row.id, !isOpen)} + > + <ChevronDown + className={cn( + "h-3.5 w-3.5 transition-transform", + isOpen && "rotate-180", + )} + /> + </Button> + )} + <Button + variant="ghost" + size="icon" + className="h-7 w-7 shrink-0 cursor-pointer" + aria-label={`Remove ${row.Question.question} from the prep list`} + onClick={() => remove(row.id)} + > + <X className="h-3.5 w-3.5" /> + </Button> + </div> + + {isOpen && ( + <div className="prose prose-sm dark:prose-invert mt-2.5 ml-6.5 max-w-none border-t pt-2.5 text-[13px] text-muted-foreground"> + <TipTapContentViewer content={row.Question.answer!} /> + </div> + )} + </li> + ); + })} + </ul> + )} + </div> + ); +} diff --git a/src/components/myjobs/job-details/timeline/UpdateStatusMenu.tsx b/src/components/myjobs/job-details/timeline/UpdateStatusMenu.tsx new file mode 100644 index 000000000..eabc9d80a --- /dev/null +++ b/src/components/myjobs/job-details/timeline/UpdateStatusMenu.tsx @@ -0,0 +1,119 @@ +"use client"; +import { HelpCircle, Plus, Tags, Users } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { JobStatusMenuItems } from "@/components/myjobs/JobStatusMenuItems"; +import type { JobStatus } from "@/models/job.model"; +import type { JobStage } from "@/models/jobStage.model"; +import { isInterviewStage } from "./stageDisplay"; + +type UpdateStatusMenuProps = { + targetStage: JobStage | null; + jobStatuses: JobStatus[]; + currentStatusId: string; + onChangeStatus: (status: JobStatus) => void; + onAddStage: () => void; + onLinkInterviewers: () => void; + onAddPrepQuestions: () => void; +}; + +export function UpdateStatusMenu({ + targetStage, + jobStatuses, + currentStatusId, + onChangeStatus, + onAddStage, + onLinkInterviewers, + onAddPrepQuestions, +}: UpdateStatusMenuProps) { + // Disabled with a visible reason rather than hidden: an item that comes and + // goes as you change tabs is harder to learn than one that greys out. + const reason = !targetStage + ? "Add a stage first" + : isInterviewStage(targetStage) + ? null + : `${targetStage.StageType.label} is not an interview stage`; + + const items: { + key: string; + label: string; + icon: typeof Plus; + onSelect: () => void; + reason: string | null; + }[] = [ + { key: "add", label: "Add Stage", icon: Plus, onSelect: onAddStage, reason: null }, + { key: "link", label: "Add Interviewers", icon: Users, onSelect: onLinkInterviewers, reason }, + { key: "prep", label: "Add to Prep List", icon: HelpCircle, onSelect: onAddPrepQuestions, reason }, + ]; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + className="cursor-pointer" + data-testid="update-status-menu-btn" + > + <Tags className="h-4 w-4 sm:mr-2" /> + <span className="sr-only sm:not-sr-only sm:whitespace-nowrap"> + Update Status + </span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-[200px]"> + <DropdownMenuLabel>Update Status</DropdownMenuLabel> + <DropdownMenuGroup> + {/* Moved out of the ⋮ menu: one control answers "where is this job + now", instead of two with near-identical names. */} + <DropdownMenuSub> + <DropdownMenuSubTrigger className="cursor-pointer"> + <Tags className="mr-2 h-4 w-4" /> + Change status + </DropdownMenuSubTrigger> + <DropdownMenuPortal> + <DropdownMenuSubContent className="p-0"> + <JobStatusMenuItems + jobStatuses={jobStatuses} + currentStatusId={currentStatusId} + onSelectStatus={onChangeStatus} + /> + </DropdownMenuSubContent> + </DropdownMenuPortal> + </DropdownMenuSub> + <DropdownMenuSeparator /> + + {items.map(({ key, label, icon: Icon, onSelect, reason: itemReason }) => ( + <DropdownMenuItem + key={key} + className="cursor-pointer" + disabled={!!itemReason} + onSelect={onSelect} + > + <Icon className="mr-2 h-4 w-4" /> + <span> + {label} + {itemReason && ( + <span className="block text-xs text-muted-foreground"> + {itemReason} + </span> + )} + </span> + </DropdownMenuItem> + ))} + </DropdownMenuGroup> + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/src/components/myjobs/job-details/timeline/stageDisplay.ts b/src/components/myjobs/job-details/timeline/stageDisplay.ts new file mode 100644 index 000000000..a48453f61 --- /dev/null +++ b/src/components/myjobs/job-details/timeline/stageDisplay.ts @@ -0,0 +1,73 @@ +import { format } from "date-fns"; +import { TERMINAL_STAGE_STATUSES } from "@/lib/constants"; +import type { JobStage, JobStageTypeRef } from "@/models/jobStage.model"; + +// An undated stage renders an em dash, never a fabricated date or a blank. +export function formatStageDate(occurredAt: Date | null): string { + return occurredAt ? format(occurredAt, "MMM d") : "—"; +} + +// "Scheduled for" only while the stage is still ahead (the artboard's wording, +// kept where it is true): a past stage that reads "Scheduled for" a date +// already gone is wrong, and the prefix doubles as the upcoming/done signal. +export function formatStageDateTime( + occurredAt: Date | null, + durationMins: number | null, +): string { + if (!occurredAt) return "No date set"; + const base = format(occurredAt, "MMM d, yyyy · h:mm a"); + const withDuration = durationMins ? `${base} · ${durationMins} min` : base; + return occurredAt.getTime() > Date.now() + ? `Scheduled for ${withDuration}` + : withDuration; +} + +// The subtitle both stage dialogs put under their title. +export function stageHeading(stage: JobStage): string { + const label = stage.StageType?.label ?? "Stage"; + return stage.occurredAt + ? `${label} · ${format(stage.occurredAt, "MMM d, yyyy")}` + : label; +} + +export function stageInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + return parts + .slice(0, 2) + .map((p) => p[0]!.toUpperCase()) + .join(""); +} + +// The gate is stage KIND, never stage timing: an interview a week away is +// still an interview stage. +export function isInterviewStage(stage: JobStage): boolean { + return stage.StageType?.Status?.value === "interview"; +} + +// One greyed step per unreached terminal STATUS, not per type (D6): a user +// whose Library holds a custom "Verbal Offer" under the offer status would +// otherwise get two greyed Offer steps side by side. +export function terminalStagesFor( + stages: JobStage[], + stageTypes: JobStageTypeRef[], +): JobStageTypeRef[] { + const reachedStatuses = new Set( + stages.map((s) => s.StageType?.Status?.value).filter(Boolean), + ); + const terminal = new Set<string>(TERMINAL_STAGE_STATUSES); + + const byStatus = new Map<string, JobStageTypeRef>(); + for (const type of [...stageTypes].sort((a, b) => a.sortOrder - b.sortOrder)) { + const status = type.Status?.value ?? ""; + if (!terminal.has(status)) continue; + if (reachedStatuses.has(status)) continue; + if (!byStatus.has(status)) byStatus.set(status, type); + } + return [...byStatus.values()]; +} + +export function askedTally(stage: JobStage): { asked: number; total: number } { + const rows = stage.prepQuestions ?? []; + return { asked: rows.filter((r) => r.asked).length, total: rows.length }; +} diff --git a/src/components/myjobs/job-details/timeline/useJobStages.ts b/src/components/myjobs/job-details/timeline/useJobStages.ts new file mode 100644 index 000000000..24b61b35c --- /dev/null +++ b/src/components/myjobs/job-details/timeline/useJobStages.ts @@ -0,0 +1,48 @@ +"use client"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getJobStages } from "@/actions/jobStage.actions"; +import { sortStages } from "@/lib/jobs/sortStages"; +import type { JobStage } from "@/models/jobStage.model"; + +// Lives in JobDetails' scope, not the tab's: the header's Update Status menu +// is visible on every tab and targets the same selected stage. +export function useJobStages(jobId: string, initial: JobStage[]) { + // The server prop comes from JOB_DETAILS_INCLUDE, which carries no orderBy + // and could not usefully have one (D2) — without this every job opens with + // a scrambled Stage History list until the first write triggers a refetch. + const [stages, setStages] = useState<JobStage[]>(() => sortStages(initial)); + const [selectedStageId, setSelectedStageId] = useState<string | null>(null); + + const currentStage = useMemo( + () => stages.find((s) => s.isCurrent) ?? null, + [stages], + ); + + // Selection defaults to the current stage, and re-homes when the selected + // stage is deleted rather than leaving the panel pointed at nothing. + useEffect(() => { + if (selectedStageId && stages.some((s) => s.id === selectedStageId)) return; + setSelectedStageId(currentStage?.id ?? stages[0]?.id ?? null); + }, [stages, selectedStageId, currentStage]); + + const selectedStage = useMemo( + () => stages.find((s) => s.id === selectedStageId) ?? null, + [stages, selectedStageId], + ); + + // getJobStages already sorts, but sorting again is free and keeps the one + // rule ("this list is always sorted") true of every path into setStages. + const reload = useCallback(async () => { + const fresh = await getJobStages(jobId); + if (Array.isArray(fresh)) setStages(sortStages(fresh)); + }, [jobId]); + + return { + stages, + currentStage, + selectedStage, + selectedStageId, + selectStage: setSelectedStageId, + reload, + }; +} diff --git a/src/lib/backup/export.ts b/src/lib/backup/export.ts index ebe00fbbf..0db3ea0df 100644 --- a/src/lib/backup/export.ts +++ b/src/lib/backup/export.ts @@ -9,12 +9,13 @@ import { isResumeFilePath } from "@/lib/resumeFiles"; type Row = Record<string, unknown>; -// Job and Question need their tag ids, which are not columns; everything else -// reads as-is. Prisma's implicit m2m tables are not models, so this include is -// the only way the join rows reach the file at all. -const TAG_INCLUDE: Partial<Record<BackupModel, Record<string, unknown>>> = { +// Job and Question need their tag ids and JobStageType needs its status value; +// none of the three is a plain column. Prisma's implicit m2m tables are not +// models, so this include is the only way the join rows reach the file at all. +const ROW_INCLUDE: Partial<Record<BackupModel, Record<string, unknown>>> = { Job: { Status: { select: { value: true } }, tags: { select: { id: true } } }, Question: { tags: { select: { id: true } } }, + JobStageType: { Status: { select: { value: true } } }, }; function stripOwnership(model: BackupModel, row: Row): Row { @@ -32,7 +33,7 @@ export async function collectBackupData(userId: string): Promise<BackupData> { const delegate = (db as unknown as Record<string, { findMany: (a: unknown) => Promise<Row[]> }>)[ spec.delegate ]; - const include = TAG_INCLUDE[model]; + const include = ROW_INCLUDE[model]; return delegate.findMany( include ? { where: spec.scope(userId), include } : { where: spec.scope(userId) }, ); @@ -49,7 +50,12 @@ export async function collectBackupData(userId: string): Promise<BackupData> { select: { defaultResumeId: true }, }), db.jobStatus.findMany({ - where: { jobs: { some: { userId } } }, + // Statuses reached by a job OR by a stage type: a seeded Withdrawn stage + // type long predates any withdrawn job, and its statusValue has to + // resolve on import. + where: { + OR: [{ jobs: { some: { userId } } }, { stageTypes: { some: { createdBy: userId } } }], + }, select: { label: true, value: true }, }), ]; @@ -76,6 +82,13 @@ export async function collectBackupData(userId: string): Promise<BackupData> { return { ...rest, statusValue: Status.value }; } + if (model === "JobStageType") { + const { statusId, Status, ...rest } = clean as Row & { + Status: { value: string }; + }; + return { ...rest, statusValue: Status.value }; + } + if (model === "Question") { const { tags, ...rest } = clean as Row & { tags: { id: string }[] }; for (const tag of tags ?? []) { diff --git a/src/lib/backup/idmap.ts b/src/lib/backup/idmap.ts index f69019e7d..6c6014402 100644 --- a/src/lib/backup/idmap.ts +++ b/src/lib/backup/idmap.ts @@ -16,7 +16,6 @@ const OPTIONAL_FKS = new Set<string>([ "Job.resumeId", "Job.coverLetterId", "Job.automationId", - "Contact.interviewId", "Contact.companyId", "Contact.locationId", "Contact.workedAtCompanyId", diff --git a/src/lib/backup/import.ts b/src/lib/backup/import.ts index 2f53db787..bafcbb95f 100644 --- a/src/lib/backup/import.ts +++ b/src/lib/backup/import.ts @@ -2,7 +2,7 @@ import fs from "fs/promises"; import path from "path"; import type JSZip from "jszip"; import db from "@/lib/db"; -import { APP_CONSTANTS, CONTACT_ROLES } from "@/lib/constants"; +import { APP_CONSTANTS, CONTACT_ROLES, JOB_STAGES } from "@/lib/constants"; import { IdMap, buildCreateData } from "./idmap"; import { BackupError, openBackupZip, readManifest } from "./manifest"; import { @@ -147,12 +147,32 @@ async function insertLookups( data: BackupData, idMap: IdMap, userId: string, + statusByValue: Map<string, string>, ): Promise<void> { for (const model of LOOKUP_MODELS) { const spec = MODEL_SPECS[model]; for (const row of (data as unknown as Record<string, { id: string; value: string }[]>)[model]) { const newId = idMap.mint(row.id); const { id: _old, ...rest } = row; + + // The only lookup model with a foreign key. JobStatus is global and its + // ids differ between installs, so the file carries the value (D3). + if (model === "JobStageType") { + const { statusValue, ...typeRest } = rest as typeof rest & { statusValue: string }; + const statusId = statusByValue.get(statusValue); + // Refusing beats defaulting: a wrong parent status would put the + // derived-status invariant permanently out of step on every job. + if (!statusId) { + throw new BackupError( + `Stage type "${row.value}" names status "${statusValue}", which is not in the backup.`, + ); + } + await tx[spec.delegate].create({ + data: { ...typeRest, id: newId, statusId, createdBy: userId }, + }); + continue; + } + await tx[spec.delegate].create({ data: { ...rest, id: newId, createdBy: userId }, }); @@ -306,7 +326,7 @@ export async function importBackup( const tx = transaction as unknown as Tx; await wipe(tx, userId); - await insertLookups(tx, data, idMap, userId); + await insertLookups(tx, data, idMap, userId, statusByValue); // A backup from before contacts carries no roles, and the wipe above // just deleted the seeded ones — reseed rather than leave none. @@ -320,6 +340,25 @@ export async function importBackup( }); } + // A backup from before stages carries no stage types, and the wipe + // above just deleted the seeded ones — reseed rather than leave none, + // or Add Stage and every status dropdown has nothing to resolve. + if (manifest.counts.JobStageType === undefined && data.JobStageType.length === 0) { + const stageStatuses = (await tx.jobStatus.findMany({ + select: { id: true, value: true }, + })) as unknown as { id: string; value: string }[]; + const stageStatusIds = new Map(stageStatuses.map((s) => [s.value, s.id])); + await tx.jobStageType.createMany({ + data: JOB_STAGES.map((stage) => ({ + label: stage.label, + value: stage.value, + statusId: stageStatusIds.get(stage.status)!, + sortOrder: stage.sortOrder, + createdBy: userId, + })), + }); + } + for (const model of INSERT_ORDER) { if (MODEL_SPECS[model].lookup) continue; const spec = MODEL_SPECS[model]; diff --git a/src/lib/backup/ordering.ts b/src/lib/backup/ordering.ts index c39709ab5..ff1e193e6 100644 --- a/src/lib/backup/ordering.ts +++ b/src/lib/backup/ordering.ts @@ -24,10 +24,13 @@ export type BackupModel = | "Automation" | "Job" | "Note" - | "Interview" | "Contact" | "ContactRole" | "JobContact" + | "JobStageType" + | "JobStage" + | "JobStageInterviewer" + | "JobStagePrepQuestion" | "Task" | "Activity" | "Question" @@ -139,12 +142,10 @@ export const MODEL_SPECS: Record<BackupModel, ModelSpec> = { scope: byUser, }, Note: { delegate: "note", owner: "userId", fks: { jobId: "Job" }, scope: byUser }, - Interview: { delegate: "interview", fks: { jobId: "Job" }, scope: (userId) => ({ job: { userId } }) }, Contact: { delegate: "contact", owner: "createdBy", fks: { - interviewId: "Interview", companyId: "Company", locationId: "Location", workedAtCompanyId: "Company", @@ -166,6 +167,32 @@ export const MODEL_SPECS: Record<BackupModel, ModelSpec> = { fks: { jobId: "Job", contactId: "Contact", roleId: "ContactRole" }, scope: (userId) => ({ Job: { userId } }), }, + // A lookup like ContactRole, so a restore into an account that already holds + // the seeded stage types does not double them. `fks` is empty on purpose: + // JobStatus is global and never a BackupModel, so statusId crosses as a + // natural key (D3), exactly as Job.statusId does. + JobStageType: { + delegate: "jobStageType", + owner: "createdBy", + lookup: true, + fks: {}, + scope: byCreatedBy, + }, + JobStage: { + delegate: "jobStage", + fks: { jobId: "Job", stageTypeId: "JobStageType" }, + scope: (userId) => ({ Job: { userId } }), + }, + JobStageInterviewer: { + delegate: "jobStageInterviewer", + fks: { stageId: "JobStage", contactId: "Contact" }, + scope: (userId) => ({ Stage: { Job: { userId } } }), + }, + JobStagePrepQuestion: { + delegate: "jobStagePrepQuestion", + fks: { stageId: "JobStage", questionId: "Question" }, + scope: (userId) => ({ Stage: { Job: { userId } } }), + }, Task: { delegate: "task", owner: "userId", @@ -210,12 +237,18 @@ export const INSERT_ORDER: BackupModel[] = [ "Automation", "Job", "Note", - "Interview", "Contact", "JobContact", + // Ahead of the stage models: JobStagePrepQuestion.questionId points here, and + // its Restrict also means the reversed DELETE_ORDER has to clear prep rows + // before questions. + "Question", + "JobStageType", + "JobStage", + "JobStageInterviewer", + "JobStagePrepQuestion", "Task", "Activity", - "Question", "AutomationRun", "UserSettings", ]; diff --git a/src/lib/backup/schema.ts b/src/lib/backup/schema.ts index fa6bb143a..9bbb2f17f 100644 --- a/src/lib/backup/schema.ts +++ b/src/lib/backup/schema.ts @@ -20,6 +20,7 @@ const dt = z.coerce.date(); const optDt = z.coerce.date().nullable(); const int = z.number().int(); const optInt = z.number().int().nullable(); +const bool = z.boolean(); // The watchlist columns are optional, not just nullable: a backup taken before // they existed has no such key, and undefined leaves Prisma on the default. @@ -223,9 +224,7 @@ const Note = z.object({ updatedAt: dt, }); -const Interview = z.object({ id, createdAt: dt, jobId: id }); - -// Everything past `interviewId` is `.optional()` as well as nullable: a backup +// Everything past `createdAt` is `.optional()` as well as nullable: a backup // taken before the contacts feature has no such key, and undefined leaves // Prisma on the column default. const Contact = z.object({ @@ -233,7 +232,6 @@ const Contact = z.object({ name: str, email: optStr, createdAt: dt, - interviewId: optId, title: optStr.optional(), phone: optStr.optional(), linkedinUrl: optStr.optional(), @@ -259,6 +257,47 @@ const JobContact = z.object({ createdAt: dt, }); +// statusValue, not statusId: JobStatus is global, never a BackupModel, and its +// ids differ between installs. Mirrors how Job crosses (D3). +const JobStageType = z.object({ + id, + label: str, + value: str, + statusValue: str, + sortOrder: int, +}); + +const JobStage = z.object({ + id, + jobId: id, + stageTypeId: id, + occurredAt: optDt, + isCurrent: bool, + outcome: optStr, + notes: optStr, + durationMins: optInt, + format: optStr, + location: optStr, + createdAt: dt, + updatedAt: dt, +}); + +const JobStageInterviewer = z.object({ + id, + stageId: id, + contactId: id, + createdAt: dt, +}); + +const JobStagePrepQuestion = z.object({ + id, + stageId: id, + questionId: id, + asked: bool, + askedAt: optDt, + createdAt: dt, +}); + const Task = z.object({ id, title: str, @@ -346,9 +385,12 @@ export const BackupDataSchema = z.object({ Automation: group(Automation), Job: group(Job), Note: group(Note), - Interview: group(Interview), Contact: group(Contact), JobContact: group(JobContact), + JobStageType: group(JobStageType), + JobStage: group(JobStage), + JobStageInterviewer: group(JobStageInterviewer), + JobStagePrepQuestion: group(JobStagePrepQuestion), Task: group(Task), Activity: group(Activity), Question: group(Question), diff --git a/src/lib/badge-colors.ts b/src/lib/badge-colors.ts index c6fdd61e4..e3bd8087c 100644 --- a/src/lib/badge-colors.ts +++ b/src/lib/badge-colors.ts @@ -29,6 +29,7 @@ export const JOB_STATUS_BADGE_COLORS: Record<JobStatusValue, BadgeColor> = { rejected: "red", expired: "amber", archived: "slate", + withdrawn: "slate", }; // job.Status.value is a plain string from the DB, not the literal union, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9944fe3a6..408ff8a2f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -301,6 +301,44 @@ export const JOB_STATUSES = [ { label: "Rejected", value: "rejected" }, { label: "Expired", value: "expired" }, { label: "Archived", value: "archived" }, + { label: "Withdrawn", value: "withdrawn" }, +] as const; + +// Seeded per user at signup and backfilled by the stages migration. `value` +// must equal canonicalizeEntityValue(label): the status -> stage-type reverse +// lookup keys on the status LABEL's canonical form, not on status.value, so +// "Offer Accepted" cannot mint a second type beside "offer-accepted". +export const JOB_STAGES = [ + { label: "New", value: "new", status: "new", sortOrder: 0 }, + { label: "Draft", value: "draft", status: "draft", sortOrder: 1 }, + { label: "Applied", value: "applied", status: "applied", sortOrder: 2 }, + { label: "Interview", value: "interview", status: "interview", sortOrder: 3 }, + { label: "1st Screening Interview", value: "1st screening interview", status: "interview", sortOrder: 4 }, + { label: "2nd Technical Interview", value: "2nd technical interview", status: "interview", sortOrder: 5 }, + { label: "Final / Onsite Interview", value: "final / onsite interview", status: "interview", sortOrder: 6 }, + { label: "Offer", value: "offer", status: "offer", sortOrder: 7 }, + { label: "Offer Accepted", value: "offer accepted", status: "offer-accepted", sortOrder: 8 }, + { label: "Offer Declined", value: "offer declined", status: "offer-declined", sortOrder: 9 }, + { label: "Rejected", value: "rejected", status: "rejected", sortOrder: 10 }, + { label: "Expired", value: "expired", status: "expired", sortOrder: 11 }, + { label: "Archived", value: "archived", status: "archived", sortOrder: 12 }, + { label: "Withdrawn", value: "withdrawn", status: "withdrawn", sortOrder: 13 }, +] as const; + +// Statuses whose stage types render greyed at the foot of the Stage History +// list when the job has not reached them (D6). Offer alone: it reads as what a +// job is working toward, and rejected/withdrawn are alternative endings, not +// next steps. Both stay one click away in Add Stage. +export const TERMINAL_STAGE_STATUSES = ["offer"] as const; + +// The fixed outcome set a stage may carry (spec's JobStage.outcome). +export const STAGE_OUTCOMES = [ + { label: "Scheduled", value: "scheduled" }, + { label: "Completed", value: "completed" }, + { label: "Passed", value: "passed" }, + { label: "Failed", value: "failed" }, + { label: "No-show", value: "no-show" }, + { label: "Cancelled", value: "cancelled" }, ] as const; // Zod's z.enum needs a non-empty tuple; JOB_STATUSES is the source of truth. diff --git a/src/lib/data/jobStatusesData.ts b/src/lib/data/jobStatusesData.ts index e8a04d7ea..414c97f39 100644 --- a/src/lib/data/jobStatusesData.ts +++ b/src/lib/data/jobStatusesData.ts @@ -44,4 +44,9 @@ export const JOB_STATUSES = [ label: "Archived", value: "archived", }, + { + id: "3f2b6c41-88d5-4a0e-9d0a-6a1c9f0b7e22", + label: "Withdrawn", + value: "withdrawn", + }, ]; diff --git a/src/lib/jobs/createJobRecord.ts b/src/lib/jobs/createJobRecord.ts index 04283ca14..76f38ddfb 100644 --- a/src/lib/jobs/createJobRecord.ts +++ b/src/lib/jobs/createJobRecord.ts @@ -1,4 +1,36 @@ import prisma from "@/lib/db"; +import { resolveStageTypeForStatusId } from "@/lib/jobs/resolve"; + +type StageClient = { + jobStage: { create: (args: any) => Promise<any> }; +}; + +// The nested payload that writes the job and its first stage as one statement, +// so a job can never commit without its timeline. The stage type is resolved +// BEFORE the create on purpose: a nested create is an implicit transaction, +// and the resolver writes through the base client (D4). +export async function firstStageCreate( + statusId: string, + userId: string, + occurredAt: Date, +) { + const stageTypeId = await resolveStageTypeForStatusId(statusId, userId); + return { create: { stageTypeId, occurredAt, isCurrent: true } }; +} + +// The separate-statement form, still used by the dev-only mock generator. +export async function createFirstStage( + client: StageClient, + jobId: string, + statusId: string, + userId: string, + occurredAt: Date, +): Promise<void> { + const stageTypeId = await resolveStageTypeForStatusId(statusId, userId); + await client.jobStage.create({ + data: { jobId, stageTypeId, occurredAt, isCurrent: true }, + }); +} export async function createJobRecord(fields: { jobTitleId: string; @@ -22,11 +54,14 @@ export async function createJobRecord(fields: { descriptionCompleteness?: string | null; }) { const { tagIds = [], ...rest } = fields; - return prisma.job.create({ + const createdAt = new Date(); + const stages = await firstStageCreate(fields.statusId, fields.userId, createdAt); + return await prisma.job.create({ data: { ...rest, - createdAt: new Date(), + createdAt, ...(tagIds.length > 0 ? { tags: { connect: tagIds.map((id) => ({ id })) } } : {}), + stages, }, }); } diff --git a/src/lib/jobs/resolve.ts b/src/lib/jobs/resolve.ts index ee49334e3..b74ba7f85 100644 --- a/src/lib/jobs/resolve.ts +++ b/src/lib/jobs/resolve.ts @@ -153,6 +153,81 @@ export async function resolveTags( return { resolved, dropped }; } +// Not resolveEntity: that delegate creates exactly { label, value, createdBy } +// and cannot carry statusId and sortOrder, so the pattern is followed rather +// than the function reused (spec decision 3). +export async function resolveJobStageType( + label: string, + userId: string, + statusId: string, +): Promise<ResolvedEntity> { + const trimmed = label.trim(); + const value = canonicalizeEntityValue(trimmed); + if (!value) throw new Error("A non-empty label is required"); + + const existing = await prisma.jobStageType.findUnique({ + where: { value_createdBy: { value, createdBy: userId } }, + }); + if (existing) return { id: existing.id, label: existing.label, created: false }; + + // A custom type slots in after the last type of its own parent status, not + // at the end of the vocabulary: sortStages orders the timeline by sortOrder, + // so an appended type would render after the terminal stages. Never wrap + // this in a transaction — it writes through the base client on purpose. + const siblings = await prisma.jobStageType.aggregate({ + where: { createdBy: userId, statusId }, + _max: { sortOrder: true }, + }); + const all = await prisma.jobStageType.aggregate({ + where: { createdBy: userId }, + _max: { sortOrder: true }, + }); + const sortOrder = + (siblings._max.sortOrder ?? all._max.sortOrder ?? -1) + 1; + + await prisma.jobStageType.updateMany({ + where: { createdBy: userId, sortOrder: { gte: sortOrder } }, + data: { sortOrder: { increment: 1 } }, + }); + + try { + const created = await prisma.jobStageType.create({ + data: { + label: trimmed, + value, + statusId, + sortOrder, + createdBy: userId, + }, + }); + return { id: created.id, label: created.label, created: true }; + } catch (err: any) { + if (err?.code === "P2002") { + const winner = await prisma.jobStageType.findUnique({ + where: { value_createdBy: { value, createdBy: userId } }, + }); + if (winner) return { id: winner.id, label: winner.label, created: false }; + } + throw err; + } +} + +// The status -> stage-type reverse lookup. Keyed on the canonical form of the +// status LABEL, not status.value: canonicalizeEntityValue("Offer Accepted") is +// "offer accepted", so keying on "offer-accepted" would mint a duplicate. +export async function resolveStageTypeForStatusId( + statusId: string, + userId: string, +): Promise<string> { + const status = await prisma.jobStatus.findUnique({ + where: { id: statusId }, + select: { id: true, label: true }, + }); + if (!status) throw new Error("Job status not found"); + const resolved = await resolveJobStageType(status.label, userId, status.id); + return resolved.id; +} + // Bucket B — resolve-ONLY, never create // A caller-correctable bad value, as opposed to a database or programming diff --git a/src/lib/jobs/sortStages.ts b/src/lib/jobs/sortStages.ts new file mode 100644 index 000000000..1c4a64117 --- /dev/null +++ b/src/lib/jobs/sortStages.ts @@ -0,0 +1,30 @@ +// Stage order is the Library -> Stages order (JobStageType.sortOrder), not +// chronology: a job is routinely added days after it was applied to, so the +// New stage's date (when the job entered JobSync) postdates the Applied +// stage's. Dates break ties within one sortOrder. Undated stages sort last +// among their peers — SQLite sorts NULL first on ASC and Prisma's +// nulls:"last" is unsupported there, so none of this can live in the query. +// Both the server actions and the Timeline tab sort through this one +// comparator: the job-details page loads stages with no orderBy at all. +export function sortStages< + T extends { + occurredAt: Date | null; + createdAt: Date; + StageType: { sortOrder: number }; + }, +>(stages: T[]): T[] { + return [...stages].sort((a, b) => { + const byStage = a.StageType.sortOrder - b.StageType.sortOrder; + if (byStage !== 0) return byStage; + + if (a.occurredAt && b.occurredAt) { + const diff = a.occurredAt.getTime() - b.occurredAt.getTime(); + if (diff !== 0) return diff; + } else if (a.occurredAt) { + return -1; + } else if (b.occurredAt) { + return 1; + } + return a.createdAt.getTime() - b.createdAt.getTime(); + }); +} diff --git a/src/lib/jobs/updateJobFromNames.ts b/src/lib/jobs/updateJobFromNames.ts index 7fdb878e0..b9af4bdce 100644 --- a/src/lib/jobs/updateJobFromNames.ts +++ b/src/lib/jobs/updateJobFromNames.ts @@ -13,8 +13,10 @@ import { resolveWorkplaceType, resolveJobStatus, resolveTags, + resolveStageTypeForStatusId, type ResolvedEntity, } from "./resolve"; +import { appendStatusStage } from "@/actions/job/shared"; // Same renderer config as createJobFromNames — html:false escapes raw HTML. const md = new MarkdownIt({ html: false, linkify: false, breaks: true }); @@ -63,7 +65,7 @@ export async function updateJobFromNames( // caller learns "not found" before any entity is created as a side effect. const existing = await prisma.job.findFirst({ where: { id: jobId, userId, createdVia: { not: null } }, - select: { id: true, descriptionCompleteness: true }, + select: { id: true, descriptionCompleteness: true, appliedDate: true }, }); if (!existing) { return { @@ -124,7 +126,13 @@ export async function updateJobFromNames( if (input.jobUrl !== undefined) data.jobUrl = normalizeJobUrl(input.jobUrl); if (input.applied !== undefined) data.applied = input.applied; if (input.appliedDate !== undefined) data.appliedDate = input.appliedDate; - if (input.applied === true && input.appliedDate === undefined) { + // D5: an appliedDate already on the job is never overwritten, the same rule + // updateJobStatus and every stage write path now follow. + if ( + input.applied === true && + input.appliedDate === undefined && + !existing.appliedDate + ) { data.appliedDate = new Date(); } if (resolvedTagsResult) { @@ -151,10 +159,23 @@ export async function updateJobFromNames( }; } + // D4: this resolve can create a row and writes through the base client, so + // it must not run while the transaction below holds SQLite's write lock. + const stageTypeId = statusId + ? await resolveStageTypeForStatusId(statusId, userId) + : null; + try { - await prisma.job.update({ - where: { id: jobId, userId, createdVia: { not: null } }, - data, + await prisma.$transaction(async (tx: any) => { + // D9: the sixth write path to Job.statusId. Without this an agent can + // move a job's status while its timeline stays where it was. + if (statusId && stageTypeId) { + await appendStatusStage(tx, jobId, statusId, stageTypeId, userId); + } + await tx.job.update({ + where: { id: jobId, userId, createdVia: { not: null } }, + data, + }); }); } catch (error: any) { if (error?.code === "P2025") { diff --git a/src/lib/scraper/automation-run/persist.ts b/src/lib/scraper/automation-run/persist.ts index 70a80ee3a..5124e257f 100644 --- a/src/lib/scraper/automation-run/persist.ts +++ b/src/lib/scraper/automation-run/persist.ts @@ -3,6 +3,7 @@ import { APP_CONSTANTS } from "@/lib/constants"; import type { Automation, ScrapedJobData } from "@/models/automation.model"; import type { JobDetails } from "../types"; import { mapScrapedJobToJobRecord } from "../mapper"; +import { firstStageCreate } from "@/lib/jobs/createJobRecord"; import { normalizeJobUrl } from "../utils"; import type { SkillTerm } from "./skillTags"; @@ -51,8 +52,14 @@ export async function persistDiscoveredJob( skillTerms, }); + const stages = await firstStageCreate( + jobRecord.statusId, + automation.userId, + jobRecord.createdAt, + ); + try { - await db.job.create({ data: jobRecord }); + await db.job.create({ data: { ...jobRecord, stages } }); return { saved: true, tagsApplied: jobRecord.tags?.connect.length ?? 0 }; } catch (err: any) { if (err?.code === "P2002") return { saved: false, tagsApplied: 0 }; diff --git a/src/lib/wiki/schema.ts b/src/lib/wiki/schema.ts index 48f79d813..4ca8066c7 100644 --- a/src/lib/wiki/schema.ts +++ b/src/lib/wiki/schema.ts @@ -13,6 +13,7 @@ export const WIKI_TYPES = [ export const WIKI_FEATURES = [ "setup", "jobs", + "timeline", "contacts", "companies", "automations", diff --git a/src/models/job.model.ts b/src/models/job.model.ts index 4f06f28d6..870411226 100644 --- a/src/models/job.model.ts +++ b/src/models/job.model.ts @@ -1,6 +1,9 @@ import type { JobBoard, LeverHost } from "@/models/automation.model"; import { CoverLetter, Resume } from "./profile.model"; import type { JobContactLink } from "./contact.model"; +// Type-only: jobStage.model.ts imports Tag from here and has a runtime export, +// so a value import back would make a real module cycle. +import type { JobStage } from "./jobStage.model"; export interface JobForm { id?: string; @@ -57,6 +60,7 @@ export interface JobResponse { matchData?: string | null; tags?: Tag[]; contactLinks?: JobContactLink[]; + stages?: JobStage[]; createdVia?: string | null; discoveryStatus?: string | null; descriptionCompleteness?: DescriptionCompleteness | null; diff --git a/src/models/jobStage.model.ts b/src/models/jobStage.model.ts new file mode 100644 index 000000000..b9b3b02fc --- /dev/null +++ b/src/models/jobStage.model.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import type { Tag } from "./job.model"; +import type { ContactSummary } from "./contact.model"; +import { STAGE_OUTCOMES } from "@/lib/constants"; + +export interface JobStageTypeRef { + id: string; + label: string; + value: string; + statusId: string; + sortOrder: number; + Status?: { id: string; label: string; value: string }; + _count?: { stages: number }; +} + +export interface JobStageInterviewerLink { + id: string; + stageId: string; + contactId: string; + Contact: ContactSummary; +} + +export interface JobStagePrepQuestionLink { + id: string; + stageId: string; + questionId: string; + asked: boolean; + askedAt: Date | null; + Question: { id: string; question: string; answer: string | null; tags: Tag[] }; +} + +export interface JobStage { + id: string; + jobId: string; + stageTypeId: string; + occurredAt: Date | null; + isCurrent: boolean; + outcome: string | null; + notes: string | null; + durationMins: number | null; + format: string | null; + location: string | null; + createdAt: Date; + updatedAt: Date; + StageType: JobStageTypeRef; + interviewers: JobStageInterviewerLink[]; + prepQuestions: JobStagePrepQuestionLink[]; +} + +const outcomeValues = STAGE_OUTCOMES.map((o) => o.value) as unknown as [ + string, + ...string[], +]; + +// Either an existing type id or a custom name, never neither. A custom name +// also needs its parent status, which the dialog asks for in the same pass. +export const AddJobStageFormSchema = z + .object({ + id: z.string().optional(), + jobId: z.string().min(1), + stageTypeId: z.string().optional(), + customLabel: z.string().max(80).optional(), + customStatusId: z.string().optional(), + date: z.date().nullable().optional(), + time: z.string().optional(), + notes: z.string().max(2000).optional(), + outcome: z.enum(outcomeValues).nullable().optional(), + durationMins: z.number().int().positive().max(1440).nullable().optional(), + format: z.string().max(60).optional(), + location: z.string().max(200).optional(), + setAsCurrent: z.boolean().default(true), + }) + .refine((data) => !!data.stageTypeId || !!data.customLabel?.trim(), { + message: "Pick a stage or enter a custom stage name.", + path: ["stageTypeId"], + }) + .refine( + (data) => !data.customLabel?.trim() || !!data.customStatusId, + { message: "Pick the status this custom stage means.", path: ["customStatusId"] }, + ); + +export type AddJobStageValues = z.infer<typeof AddJobStageFormSchema>; diff --git a/wiki/index.md b/wiki/index.md index 91e12b33d..d8316661a 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -8,6 +8,7 @@ JobSync tracks job applications, resumes and the work you do around them. These - [Getting Started](./getting-started.md) — create an account, connect an AI provider, add your first job, and what each area of the app is for. - [Jobs](./jobs.md) — add jobs manually or by pasting a posting into AI chat, move them through the status workflow, edit, filter, export and delete them. +- [Job Timeline](./timeline.md) — record each stage of a hiring process with its date, link the interviewers, and build a prep list of the questions you expect. - [Contacts](./contacts.md) — track recruiters, interviewers and references, link them to the jobs they touched, and record how you know them. - [Companies](./companies.md) — a company's details page with its jobs and contacts, watching a company, and why a company can't be deleted. - [Automations](./automations.md) — set up a scheduled search over Greenhouse, Lever and Ashby company boards, fill in the filters that make it work, and handle the jobs it discovers. diff --git a/wiki/jobs.md b/wiki/jobs.md index 8c7faa8f0..8b9b2c26a 100644 --- a/wiki/jobs.md +++ b/wiki/jobs.md @@ -27,19 +27,23 @@ This requires an AI provider and model to be set under **Settings → AI Provide ## What do the job statuses mean? -A job carries exactly one status from: **New**, **Draft**, **Applied**, **Interview**, **Offer**, **Offer Accepted**, **Offer Declined**, **Rejected**, **Expired** and **Archived** — *Offer Declined* for an offer you turned down, *Rejected* for one the company turned down. They are labels you move by hand, not a state machine — nothing stops you going from Interview back to Draft, and nothing changes a status on your behalf. +A job carries exactly one status from: **New**, **Draft**, **Applied**, **Interview**, **Offer**, **Offer Accepted**, **Offer Declined**, **Rejected**, **Expired**, **Archived** and **Withdrawn** — *Offer Declined* for an offer you turned down, *Rejected* for one the company turned down, *Withdrawn* for one you pulled out of. They are labels you move by hand, not a state machine — nothing stops you going from Interview back to Draft. + +A job's status now follows its [timeline](./timeline.md): it is whatever the job's current stage means, so changing the status also records a stage, and moving which stage is current also moves the status. ## How do I change a job's status? -There are two ways. In the Jobs list, click a row's status badge and pick the new status from the menu. Or open the job and use the **⋮** menu at the top-right of the job details, choosing **Change status**. Either menu lists every status with the current one greyed out, and the change saves immediately — there is no separate save step. +There are two ways. In the Jobs list, click a row's status badge and pick the new status from the menu. Or open the job and use the **Update Status** button at the top-right of the job details, choosing **Change status**. Either menu lists every status with the current one greyed out, and the change saves immediately — there is no separate save step. The Add Job dialog also has a Status field, so a status can be set when you first create the job or from **Edit Job** later. +All three of those paths also append a stage to the job's [timeline](./timeline.md), so the history stays in step with the label. Picking a status the job already holds adds nothing. + ## What is on a job's details page? -Three parts, top to bottom. A **header row** with the job title, a `Company · Location · Job Type · Workplace` line whose company name opens the company's [details page](./companies.md), and the actions: Match with AI, Cover Letter, Edit, Delete and a **⋮** menu. Below it a **summary card** of eight facts — Status, Job Type, Salary Range, Source, Applied, AI Match, Resume and Added — with the job URL and any skill badges on a divider row underneath. Below that, five **tabs**. +Three parts, top to bottom. A **header row** with the job title, a `Company · Location · Job Type · Workplace` line whose company name opens the company's [details page](./companies.md), and the actions: **Update Status**, AI Match, Cover Letter, Edit, Delete and a **⋮** menu. Below it a **summary card** of eight facts — Status, Job Type, Salary Range, Source, Applied, AI Match, Resume and Added — with the job URL and any skill badges on a divider row underneath. Below that, six **tabs**. -The tabs are **Description**, **AI Match**, **Cover Letter**, **Notes** and **[Contacts](./contacts.md)**, and all five are always there. AI Match and Cover Letter stay in the bar even when the job has neither, showing a short explanation and a button to run it — so the tab set never shifts between jobs. Notes and Contacts each carry a count badge once the job has any. Contacts lists the people linked to this job with the role each played. The tab you are on is kept in the address bar, so a refresh, a bookmark or the browser Back button lands you back on the same one. +The tabs are **Description**, **[Timeline](./timeline.md)**, **AI Match**, **Cover Letter**, **[Contacts](./contacts.md)** and **Notes**, and all six are always there. AI Match and Cover Letter stay in the bar even when the job has neither, showing a short explanation and a button to run it — so the tab set never shifts between jobs. Timeline, Notes and Contacts each carry a count badge once the job has any. Contacts lists the people linked to this job with the role each played. The tab you are on is kept in the address bar, so a refresh, a bookmark or the browser Back button lands you back on the same one. ## How do I edit, annotate or delete a job? @@ -55,9 +59,9 @@ The list also has a view toggle for table or card layout, and a reload button th ## How do I run an AI match from the jobs list? -A job that has no AI match score shows a **Match** button where its score would be — in the Match column in table view, and in the top-right corner of the card in card view. That includes a listing an [automation](./automations.md) saved without AI-scoring it: its relevance percentage stays on the automation page and is never shown here as a match score. Clicking it opens that job's details page on the **AI Match** tab and starts the match straight away, so you do not have to open the job and click again. The match itself runs in the assistant panel exactly as it does from the job's own **Match with AI** button; when it finishes, the analysis is already on the tab in front of you. +A job that has no AI match score shows a **Match** button where its score would be — in the Match column in table view, and in the top-right corner of the card in card view. That includes a listing an [automation](./automations.md) saved without AI-scoring it: its relevance percentage stays on the automation page and is never shown here as a match score. Clicking it opens that job's details page on the **AI Match** tab and starts the match straight away, so you do not have to open the job and click again. The match itself runs in the assistant panel exactly as it does from the job's own **AI Match** button; when it finishes, the analysis is already on the tab in front of you. -Jobs that already have a score show the score instead. To re-run a match on one of those, open the job and use **Match with AI** in its header. +Jobs that already have a score show the score instead. To re-run a match on one of those, open the job and use **AI Match** in its header. ## How do I export my jobs to CSV? diff --git a/wiki/timeline.md b/wiki/timeline.md new file mode 100644 index 000000000..e459e54ee --- /dev/null +++ b/wiki/timeline.md @@ -0,0 +1,106 @@ +--- +type: how-to +title: Job Timeline +description: Recording how a job moved through its hiring process — the stages, when each happened, who interviewed you and which questions they asked. +feature: timeline +tags: [timeline, stages, job stage, interview, interview rounds, screening, onsite, offer, withdrawn, prep list, interview questions, interviewers, update status, current stage, stage history] +aliases: [stages, job stages, interview timeline, hiring process, stage history] +status: stable +stale_after: 2027-09-30 +--- + +# Job Timeline + +## What is the Timeline tab for? + +The **Timeline** tab on a job's details page is that job's record of how the hiring process actually moved — one entry per stage, each with its own date, notes and outcome. A job's [status](./jobs.md) is a single label saying where the job is *now*; the timeline is the history behind that label, and the status is derived from it. + +The tab shows two things. A **Stage History** list down the left walks every stage in the order set by **Library → Stages**, joined top to bottom by a connecting line: a green tick marks each stage already behind you, a filled marker the current one, and a hollow marker the greyed **Offer** step at the end if the job has not reached an offer yet. Clicking a stage opens its **detail panel** on the right. An interview stage splits that panel into three tabs — **Overview**, **Interviewer** and **Prep List** — and opens on Overview; any other stage shows the overview on its own, with no tabs, because the other two have nothing to say about it. + +A count badge on the tab tells you how many stages a job has. Every job created after this feature shipped starts with one. + +## How do I add a stage? + +Use **Add stage** at the bottom of the Stage History list, or the **Update Status** button in the job header and then **Add Stage**. Both open the same dialog. + +Pick a **stage** from the list — *New*, *Draft*, *Applied*, *Interview*, *1st Screening Interview*, *2nd Technical Interview*, *Final / Onsite Interview*, *Offer*, *Offer Accepted*, *Offer Declined*, *Rejected*, *Expired*, *Archived* and *Withdrawn* are set up for you — or type your own name into **Or enter a custom stage name** and choose the **parent status** it means. A custom name is saved to your Library, so the second job that reaches your *Panel Interview* just picks it from the list. + +**Date** and **Time** start on today and the current time, since a stage you add is usually one that just happened; click the selected day again to clear the date if you don't know it yet. **Notes** is free text. If the stage's parent status is *Interview*, three more fields appear: **Format**, **Duration** and **Location** — use Location for a meeting link as happily as for a street address. **Set as current stage** is ticked by default; leave it ticked when you are recording where the job is now, and untick it when you are backfilling something that already happened. + +Editing a stage later opens the same dialog with one extra field, **Outcome**: *Scheduled*, *Completed*, *Passed*, *Failed*, *No-show* or *Cancelled*. Outcome reads back on the stage's **Overview** tab, above the notes box, and location, format and duration join it there on an interview stage. Switching a stage away from *Interview* clears those three, since nothing shows them on a non-interview stage. The switch is refused while interviewers or prep questions remain. + +## How do I delete a stage? + +Select the stage, then use the **bin button** at the top right of its detail panel, beside **Edit**. You are asked to confirm, and the stage goes along with its notes, its interviewer links and its prep list — the contacts and questions themselves stay in your Library. + +Deleting the **current** stage moves the job back to the stage before it, and the job's status follows that stage; the confirmation says so. Deleting the last stage a job has leaves it with no timeline at all, which is a supported state — the job simply keeps the status it had. + +## How does a stage change the job's status? + +The current stage sets the job's status: whatever parent status that stage's type carries becomes the job's status, everywhere the job is shown. Adding a stage with **Set as current stage** ticked moves the status; ticking the box on an older stage moves it back. + +It works the other way round too. Changing the status from **Update Status → Change status**, from a row's status badge in the Jobs list, or from the Status field in the **Edit Job** dialog appends a new stage for that status and makes it current — so a status change never leaves the timeline behind. The appended stage is dated the moment you made the change; open it from the Timeline tab and edit the date if the step actually happened earlier. + +Re-picking a status the job already holds does nothing at all. If the current stage is *2nd Technical Interview* and you pick **Interview** from a dropdown, you already are at Interview, so no second stage is added. + +Marking a job *Applied* or *Interview* this way also sets its **Applied** flag, and an *Applied* stage fills in the applied date if the job has none yet. A date already recorded is never overwritten. + +Stages are laid out in the order **Library → Stages** gives their types, not by date, so a job you added weeks after applying to it still reads New then Applied. Dates decide the order only between stages of the same type — two interview rounds of the same kind, say. + +So a job added long after you applied can show its New stage dated after its Applied stage — the New date records when it entered JobSync. + +## Why does a stage show a dash instead of a date? + +Because that stage has no date, which is allowed. Dates are optional on every stage: a stage appended by a status change never has one, and neither does a stage you added without filling the Date field. + +An undated stage reads `—` in the Stage History list, and **No date set** in its detail panel. Among stages of the same type it sorts to the end rather than to the start. Open the stage and use **Edit** to add the date whenever you learn it. + +## How do I record who is interviewing me? + +Select the interview stage, then use **Add** on its **Interviewer** tab, or **Update Status → Add Interviewers**. The dialog searches your existing [contacts](./contacts.md); the **Add a new contact** box at the bottom takes a name, role and email and creates the person on the spot. + +Linking someone also adds them to the job's **Contacts** tab with the **Interviewer** role, so that tab stays the single roster of everyone involved in the application. The reverse is not true: the **✕** beside an interviewer unlinks them from *that stage only* and leaves the job contact link alone, because the person may hold other roles on the job. + +The same person can be linked to several stages of the same job, and a contact you link here is an ordinary contact afterwards — editable and searchable under **Library → Contacts** like any other. + +## How do I build a prep list for an interview? + +Select the interview stage and use **Add Questions**, either the button on its **Prep List** tab or **Add to Prep List** in the **Update Status** menu. The dialog lists your question bank with an instant search over it; tick as many questions as you like and add them in one go. + +To add a question that is not in the bank yet, type it into the box at the bottom, optionally pick any number of **skill tags**, and add it. It is saved to your question bank as well as to this stage's prep list, so every later job can reach it — the bank is shared, not per-job. A question added this way is banked with the placeholder answer `TBD`; fill in the real answer from the Questions page when you are ready. + +A question whose answer is filled in shows a chevron; click the question or the chevron to read the answer without leaving the tab, or use **Expand all** to open every answered question at once. A question still holding the `TBD` placeholder reads **No answer yet** instead. + +A question can be on the prep lists of several stages at once, and **✕** beside one removes it from this stage's list without touching the bank. + +## What is the difference between a question on a prep list and one marked "asked"? + +A prep-list question is one you *expect*; the checkbox beside it records that it was *actually asked*. Tick it during or after the interview and it saves immediately — there is no separate save step — and the **Prep List** tab carries a running `2 of 5` tally, so you can read the progress without opening the tab. + +The distinction is what makes the list useful afterwards: which of the questions you prepared for came up, and which did not. Marking a question asked never changes the question itself or its answer in your bank. + +One knock-on effect: a question that is on any prep list cannot be deleted from the question bank. The Questions page refuses with a count of the interview stages using it. Remove it from those prep lists first, then delete. + +## Why are Add Interviewers and Add to Prep List greyed out? + +Because the stage they would act on is not an interview stage. Both items are gated on the *kind* of stage, never on its timing — an interview three weeks away is still an interview stage, and you can link its panel and prep for it from the day it is scheduled. + +The menu tells you which stage it is targeting and why: it reads **"Applied is not an interview stage"** when the selected stage is not one, and **"Add a stage first"** when the job has no stages at all. Both items act on the stage you have selected on the Timeline tab, falling back to the current stage, so select the interview stage first. + +A stage counts as an interview stage when its parent status is *Interview*. That is true of the three seeded interview rounds, and of any custom stage type you created under the Interview status. + +## Can I rename or add my own stages? + +Yes — **Library → Stages** holds the full list, and the stage types there are yours. **New Stage** adds one, and the **⋮** menu on a row has **Edit** to rename it or change which status it means, plus **Move Up** and **Move Down** to reorder them — that order drives both the list the Add Stage dialog shows you and the order stages appear in on every job's timeline. A new stage type you type into the Add Stage dialog slots in after the last type of the same parent status, so it lands beside its siblings rather than at the bottom of the list. A **Stages** column tells you how many job stages currently use each type. + +A stage type named after a status can be renamed and reordered but not moved under another status — that name is how a status change finds it. Your own types move freely, jobs following along. + +A stage type that is in use cannot be deleted: the tab refuses and tells you how many job stages are using it. Change or remove those stages first, then delete the type. + +Two near-identical names stay two separate types — *On-site* and *Onsite* are different entries, because matching folds case and accents but not punctuation. If you end up with a duplicate, rename or delete one here. + +## Why does a job have no timeline at all? + +Because it was never given a stage. The most common cause is restoring a backup taken before the timeline existed: those jobs come back with their status intact but with no stages behind it, and that is a supported state rather than an error. + +The Timeline tab shows **No stages recorded yet** with an **Add stage** button, and the job's status stays exactly as it is until a stage sets it. Adding the first stage starts the timeline from there; nothing is invented about the job's past.