Timeline introduced (#38)
* initial implementation * onboarding doesn't pop until invalid values are present * link to job page * proactive inputs working slightly * onboarding gate reinstated * better proactive buttons * fully manual tracking for now. * edit and delete timeline events * status showing correctly * tests update * tests * Update orchestrator/src/server/services/applicationTracking.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/server/services/applicationTracking.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/server/services/applicationTracking.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/client/pages/job/Timeline.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/client/pages/JobPage.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add tests for application tracking routes and remove unused actionId from client API * remove unnecessary await from synchronous transitionStage calls and improve test isolation * relax externalUrl validation to allow non-URL metadata * add toast notifications for data loading and event logging in JobPage * comments * fix: resolve type error in sponsor-matching.test.ts * fix ci * tests fix for github * lint * github comments * build fix * dedupe * format * types fix * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * formatting * title and group id are discrete fields * backfill * hide view button on page * show relevant dropdown options * confetti! * remove redundant * confirm delete is a custom element now * formatting * fix styling --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
ad6a79e74f
commit
6e771ce728
20
orchestrator/package-lock.json
generated
20
orchestrator/package-lock.json
generated
@ -13,6 +13,7 @@
|
|||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@ -24,7 +25,9 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"better-sqlite3": "^11.6.0",
|
"better-sqlite3": "^11.6.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@ -1857,6 +1860,7 @@
|
|||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||||
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/primitive": "1.1.3",
|
"@radix-ui/primitive": "1.1.3",
|
||||||
"@radix-ui/react-compose-refs": "1.1.2",
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
@ -3533,6 +3537,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/canvas-confetti": {
|
||||||
|
"version": "1.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
|
||||||
|
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@ -4326,6 +4336,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/canvas-confetti": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"funding": {
|
||||||
|
"type": "donate",
|
||||||
|
"url": "https://www.paypal.me/kirilvatev"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ccount": {
|
"node_modules/ccount": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@ -42,7 +43,9 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"better-sqlite3": "^11.6.0",
|
"better-sqlite3": "^11.6.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Main App component.
|
* Main App component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ import { CSSTransition, SwitchTransition } from "react-transition-group";
|
|||||||
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { OnboardingGate } from "./components/OnboardingGate";
|
import { OnboardingGate } from "./components/OnboardingGate";
|
||||||
|
import { JobPage } from "./pages/JobPage";
|
||||||
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||||
import { SettingsPage } from "./pages/SettingsPage";
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
|
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
|
||||||
@ -40,6 +41,7 @@ export const App: React.FC = () => {
|
|||||||
<div ref={nodeRef}>
|
<div ref={nodeRef}>
|
||||||
<Routes location={location}>
|
<Routes location={location}>
|
||||||
<Route path="/" element={<Navigate to="/ready" replace />} />
|
<Route path="/" element={<Navigate to="/ready" replace />} />
|
||||||
|
<Route path="/job/:id" element={<JobPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
|
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
|
||||||
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
||||||
|
|||||||
@ -5,9 +5,12 @@
|
|||||||
import { trackEvent } from "@/lib/analytics";
|
import { trackEvent } from "@/lib/analytics";
|
||||||
import type {
|
import type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
ApplicationStage,
|
||||||
|
ApplicationTask,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
CreateJobInput,
|
CreateJobInput,
|
||||||
Job,
|
Job,
|
||||||
|
JobOutcome,
|
||||||
JobSource,
|
JobSource,
|
||||||
JobsListResponse,
|
JobsListResponse,
|
||||||
ManualJobDraft,
|
ManualJobDraft,
|
||||||
@ -18,6 +21,9 @@ import type {
|
|||||||
ResumeProfile,
|
ResumeProfile,
|
||||||
ResumeProjectCatalogItem,
|
ResumeProjectCatalogItem,
|
||||||
ResumeProjectsSettings,
|
ResumeProjectsSettings,
|
||||||
|
StageEvent,
|
||||||
|
StageEventMetadata,
|
||||||
|
StageTransitionTarget,
|
||||||
UkVisaJobsImportResponse,
|
UkVisaJobsImportResponse,
|
||||||
UkVisaJobsSearchResponse,
|
UkVisaJobsSearchResponse,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
@ -67,7 +73,7 @@ export async function getJobs(statuses?: string[]): Promise<JobsListResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getJob(id: string): Promise<Job> {
|
export async function getJob(id: string): Promise<Job> {
|
||||||
return fetchApi<Job>(`/jobs/${id}`);
|
return fetchApi<Job>(`/jobs/${id}?t=${Date.now()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateJob(
|
export async function updateJob(
|
||||||
@ -130,6 +136,71 @@ export async function skipJob(id: string): Promise<Job> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getJobStageEvents(id: string): Promise<StageEvent[]> {
|
||||||
|
return fetchApi<StageEvent[]>(`/jobs/${id}/events?t=${Date.now()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getJobTasks(
|
||||||
|
id: string,
|
||||||
|
options?: { includeCompleted?: boolean },
|
||||||
|
): Promise<ApplicationTask[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.includeCompleted) params.set("includeCompleted", "1");
|
||||||
|
params.set("t", Date.now().toString());
|
||||||
|
const query = params.toString();
|
||||||
|
return fetchApi<ApplicationTask[]>(`/jobs/${id}/tasks?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transitionJobStage(
|
||||||
|
id: string,
|
||||||
|
input: {
|
||||||
|
toStage: StageTransitionTarget;
|
||||||
|
occurredAt?: number | null;
|
||||||
|
metadata?: StageEventMetadata | null;
|
||||||
|
outcome?: JobOutcome | null;
|
||||||
|
},
|
||||||
|
): Promise<StageEvent> {
|
||||||
|
return fetchApi<StageEvent>(`/jobs/${id}/stages`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateJobStageEvent(
|
||||||
|
id: string,
|
||||||
|
eventId: string,
|
||||||
|
input: {
|
||||||
|
toStage?: ApplicationStage;
|
||||||
|
occurredAt?: number | null;
|
||||||
|
metadata?: StageEventMetadata | null;
|
||||||
|
outcome?: JobOutcome | null;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
return fetchApi<void>(`/jobs/${id}/events/${eventId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteJobStageEvent(
|
||||||
|
id: string,
|
||||||
|
eventId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return fetchApi<void>(`/jobs/${id}/events/${eventId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateJobOutcome(
|
||||||
|
id: string,
|
||||||
|
input: { outcome: JobOutcome | null; closedAt?: number | null },
|
||||||
|
): Promise<Job> {
|
||||||
|
return fetchApi<Job>(`/jobs/${id}/outcome`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Pipeline API
|
// Pipeline API
|
||||||
export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
|
export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
|
||||||
return fetchApi<PipelineStatusResponse>("/pipeline/status");
|
return fetchApi<PipelineStatusResponse>("/pipeline/status");
|
||||||
|
|||||||
50
orchestrator/src/client/components/ConfirmDelete.tsx
Normal file
50
orchestrator/src/client/components/ConfirmDelete.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface ConfirmDeleteProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmDelete: React.FC<ConfirmDeleteProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title = "Are you sure?",
|
||||||
|
description = "This action cannot be undone. This will permanently delete this event from the timeline.",
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Job } from "../../shared/types";
|
import type { Job } from "../../shared/types";
|
||||||
import { useSettings } from "../hooks/useSettings";
|
import { useSettings } from "../hooks/useSettings";
|
||||||
@ -37,6 +38,8 @@ const mockJob: Job = {
|
|||||||
salary: "£60,000",
|
salary: "£60,000",
|
||||||
deadline: "2025-12-31",
|
deadline: "2025-12-31",
|
||||||
status: "discovered",
|
status: "discovered",
|
||||||
|
outcome: null,
|
||||||
|
closedAt: null,
|
||||||
source: "linkedin",
|
source: "linkedin",
|
||||||
suitabilityScore: 85,
|
suitabilityScore: 85,
|
||||||
suitabilityReason: "Strong match",
|
suitabilityReason: "Strong match",
|
||||||
@ -46,6 +49,9 @@ const mockJob: Job = {
|
|||||||
} as Job;
|
} as Job;
|
||||||
|
|
||||||
describe("JobHeader", () => {
|
describe("JobHeader", () => {
|
||||||
|
const renderWithRouter = (ui: React.ReactElement) =>
|
||||||
|
render(<MemoryRouter>{ui}</MemoryRouter>);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
(useSettings as any).mockReturnValue({
|
(useSettings as any).mockReturnValue({
|
||||||
@ -54,21 +60,37 @@ describe("JobHeader", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders basic job information", () => {
|
it("renders basic job information", () => {
|
||||||
render(<JobHeader job={mockJob} />);
|
renderWithRouter(<JobHeader job={mockJob} />);
|
||||||
expect(screen.getByText("Software Engineer")).toBeInTheDocument();
|
expect(screen.getByText("Software Engineer")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Tech Corp")).toBeInTheDocument();
|
expect(screen.getByText("Tech Corp")).toBeInTheDocument();
|
||||||
expect(screen.getByText("London")).toBeInTheDocument();
|
expect(screen.getByText("London")).toBeInTheDocument();
|
||||||
expect(screen.getByText("£60,000")).toBeInTheDocument();
|
expect(screen.getByText("£60,000")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links the title and view button to the job page", () => {
|
||||||
|
renderWithRouter(<JobHeader job={mockJob} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Software Engineer" }),
|
||||||
|
).toHaveAttribute("href", "/job/job-1");
|
||||||
|
expect(screen.getByRole("link", { name: /view/i })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/job/job-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
|
it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
|
||||||
const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
|
const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
|
||||||
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />);
|
renderWithRouter(
|
||||||
|
<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />,
|
||||||
|
);
|
||||||
|
|
||||||
const button = screen.getByText("Check Sponsorship Status");
|
const button = screen.getByText("Check Sponsorship Status");
|
||||||
expect(button).toBeInTheDocument();
|
expect(button).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(button);
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
expect(onCheckSponsor).toHaveBeenCalled();
|
expect(onCheckSponsor).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -79,7 +101,7 @@ describe("JobHeader", () => {
|
|||||||
sponsorMatchScore: 98,
|
sponsorMatchScore: 98,
|
||||||
sponsorMatchNames: '["Tech Corp Ltd"]',
|
sponsorMatchNames: '["Tech Corp Ltd"]',
|
||||||
};
|
};
|
||||||
render(<JobHeader job={jobWithSponsor} />);
|
renderWithRouter(<JobHeader job={jobWithSponsor} />);
|
||||||
|
|
||||||
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
|
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -90,7 +112,7 @@ describe("JobHeader", () => {
|
|||||||
sponsorMatchScore: 85,
|
sponsorMatchScore: 85,
|
||||||
sponsorMatchNames: '["Techy Corp"]',
|
sponsorMatchNames: '["Techy Corp"]',
|
||||||
};
|
};
|
||||||
render(<JobHeader job={jobWithPotential} />);
|
renderWithRouter(<JobHeader job={jobWithPotential} />);
|
||||||
|
|
||||||
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
|
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -101,7 +123,7 @@ describe("JobHeader", () => {
|
|||||||
sponsorMatchScore: 40,
|
sponsorMatchScore: 40,
|
||||||
sponsorMatchNames: '["Other Corp"]',
|
sponsorMatchNames: '["Other Corp"]',
|
||||||
};
|
};
|
||||||
render(<JobHeader job={jobNoSponsor} />);
|
renderWithRouter(<JobHeader job={jobNoSponsor} />);
|
||||||
|
|
||||||
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
|
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -112,11 +134,23 @@ describe("JobHeader", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
|
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
|
||||||
render(<JobHeader job={jobWithSponsor} />);
|
renderWithRouter(<JobHeader job={jobWithSponsor} />);
|
||||||
|
|
||||||
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
|
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("Check Sponsorship Status"),
|
screen.queryByText("Check Sponsorship Status"),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides the view button when already on a job page", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/job/job-1"]}>
|
||||||
|
<JobHeader job={mockJob} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", { name: /view/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,14 @@
|
|||||||
import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react";
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
Loader2,
|
||||||
|
MapPin,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -172,6 +180,8 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
|
|||||||
onCheckSponsor,
|
onCheckSponsor,
|
||||||
}) => {
|
}) => {
|
||||||
const { showSponsorInfo } = useSettings();
|
const { showSponsorInfo } = useSettings();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const isJobPage = pathname.startsWith("/job/");
|
||||||
const deadline = formatDate(job.deadline);
|
const deadline = formatDate(job.deadline);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -179,19 +189,37 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
|
|||||||
{/* Detail header: lighter weight than list items */}
|
{/* Detail header: lighter weight than list items */}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-base font-semibold text-foreground/90">
|
<Link
|
||||||
|
to={`/job/${job.id}`}
|
||||||
|
className="flex items-center gap-2 text-base font-semibold underline-offset-2 text-foreground/90 hover:underline"
|
||||||
|
>
|
||||||
{job.title}
|
{job.title}
|
||||||
</div>
|
</Link>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>{job.employer}</span>
|
<span>{job.employer}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<div className="flex items-center gap-2">
|
||||||
variant="outline"
|
<Badge
|
||||||
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
|
variant="outline"
|
||||||
>
|
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
|
||||||
{sourceLabel[job.source]}
|
>
|
||||||
</Badge>
|
{sourceLabel[job.source]}
|
||||||
|
</Badge>
|
||||||
|
{!isJobPage && (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-[10px] uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
<Link to={`/job/${job.id}`}>
|
||||||
|
View
|
||||||
|
<ArrowUpRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tertiary metadata - subdued */}
|
{/* Tertiary metadata - subdued */}
|
||||||
|
|||||||
113
orchestrator/src/client/components/LogEventModal.test.tsx
Normal file
113
orchestrator/src/client/components/LogEventModal.test.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { LogEventModal } from "./LogEventModal";
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/alert-dialog", () => ({
|
||||||
|
AlertDialog: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
AlertDialogFooter: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
AlertDialogCancel: ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<button type="button" {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/select", () => ({
|
||||||
|
Select: ({
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
}) => (
|
||||||
|
<select
|
||||||
|
data-testid="select"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onValueChange?.(event.target.value)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
),
|
||||||
|
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<>{children}</>
|
||||||
|
),
|
||||||
|
SelectItem: ({
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
value: string;
|
||||||
|
}) => <option value={value}>{children}</option>,
|
||||||
|
SelectTrigger: () => null,
|
||||||
|
SelectValue: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("LogEventModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the rejection reason selector and submits the form", async () => {
|
||||||
|
const onLog = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
render(<LogEventModal isOpen onClose={onClose} onLog={onLog} />);
|
||||||
|
|
||||||
|
const stageSelect = screen.getAllByTestId("select")[0];
|
||||||
|
fireEvent.change(stageSelect, { target: { value: "rejected" } });
|
||||||
|
|
||||||
|
expect(screen.getByText("Reason")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const reasonSelect = screen.getAllByTestId("select")[1];
|
||||||
|
fireEvent.change(reasonSelect, { target: { value: "Visa" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /log event/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onLog).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ stage: "rejected", reasonCode: "Visa" }),
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks submit when the title is cleared", async () => {
|
||||||
|
const onLog = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
render(<LogEventModal isOpen onClose={onClose} onLog={onLog} />);
|
||||||
|
|
||||||
|
const titleInput = screen.getByPlaceholderText("e.g. Recruiter Screen");
|
||||||
|
fireEvent.change(titleInput, { target: { value: "" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /log event/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Title is required")).toBeInTheDocument();
|
||||||
|
expect(onLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
241
orchestrator/src/client/components/LogEventModal.tsx
Normal file
241
orchestrator/src/client/components/LogEventModal.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import React from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import type { StageEvent } from "../../shared/types";
|
||||||
|
import { STAGE_LABELS } from "../../shared/types";
|
||||||
|
|
||||||
|
const logEventSchema = z.object({
|
||||||
|
stage: z.string(),
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
date: z.string().min(1, "Date is required"),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
reasonCode: z.string().optional(),
|
||||||
|
salary: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LogEventFormValues = z.infer<typeof logEventSchema>;
|
||||||
|
|
||||||
|
interface LogEventModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onLog: (values: LogEventFormValues, eventId?: string) => Promise<void>;
|
||||||
|
editingEvent?: StageEvent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_OPTIONS = [
|
||||||
|
{ label: "No Stage Change (Keep current status)", value: "no_change" },
|
||||||
|
{ label: STAGE_LABELS.applied, value: "applied" },
|
||||||
|
{ label: STAGE_LABELS.recruiter_screen, value: "recruiter_screen" },
|
||||||
|
{ label: STAGE_LABELS.assessment, value: "assessment" },
|
||||||
|
{ label: STAGE_LABELS.hiring_manager_screen, value: "hiring_manager_screen" },
|
||||||
|
{ label: STAGE_LABELS.technical_interview, value: "technical_interview" },
|
||||||
|
{ label: STAGE_LABELS.onsite, value: "onsite" },
|
||||||
|
{ label: STAGE_LABELS.offer, value: "offer" },
|
||||||
|
{ label: "Rejected", value: "rejected" },
|
||||||
|
{ label: "Withdrawn", value: "withdrawn" },
|
||||||
|
{ label: STAGE_LABELS.closed, value: "closed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REASON_CODES = ["Skills", "Visa", "Timing", "Culture", "Unknown"];
|
||||||
|
|
||||||
|
const toDateTimeLocal = (value: Date) => {
|
||||||
|
const pad = (num: number) => String(num).padStart(2, "0");
|
||||||
|
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(
|
||||||
|
value.getMinutes(),
|
||||||
|
)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LogEventModal: React.FC<LogEventModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onLog,
|
||||||
|
editingEvent,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<LogEventFormValues>({
|
||||||
|
resolver: zodResolver(logEventSchema),
|
||||||
|
defaultValues: {
|
||||||
|
stage: "no_change",
|
||||||
|
title: "Update",
|
||||||
|
date: toDateTimeLocal(new Date()),
|
||||||
|
notes: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedStage = watch("stage");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (editingEvent) {
|
||||||
|
reset({
|
||||||
|
stage: editingEvent.toStage,
|
||||||
|
title: editingEvent.metadata?.eventLabel || "",
|
||||||
|
date: toDateTimeLocal(new Date(editingEvent.occurredAt * 1000)),
|
||||||
|
notes: editingEvent.metadata?.note || "",
|
||||||
|
reasonCode: editingEvent.metadata?.reasonCode || undefined,
|
||||||
|
salary: editingEvent.metadata?.externalUrl?.startsWith("Salary: ")
|
||||||
|
? editingEvent.metadata.externalUrl.replace("Salary: ", "")
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
stage: "no_change",
|
||||||
|
title: "Update",
|
||||||
|
date: toDateTimeLocal(new Date()),
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, reset, editingEvent]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Only auto-update title if we're not editing or if the title is empty/default
|
||||||
|
if (!editingEvent) {
|
||||||
|
if (selectedStage === "no_change") {
|
||||||
|
setValue("title", "Update");
|
||||||
|
} else {
|
||||||
|
const option = STAGE_OPTIONS.find((o) => o.value === selectedStage);
|
||||||
|
if (option) {
|
||||||
|
setValue("title", option.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedStage, setValue, editingEvent]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: LogEventFormValues) => {
|
||||||
|
await onLog(values, editingEvent?.id);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<AlertDialogContent className="max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{editingEvent ? "Edit Event" : "Log Event"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{editingEvent
|
||||||
|
? "Update the details of this event."
|
||||||
|
: "Record a new update or stage change for this application."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>New Stage</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
name="stage"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select stage" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STAGE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.stage]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Event Title</FieldLabel>
|
||||||
|
<Input {...register("title")} placeholder="e.g. Recruiter Screen" />
|
||||||
|
<FieldError errors={[errors.title]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Date</FieldLabel>
|
||||||
|
<Input type="datetime-local" {...register("date")} />
|
||||||
|
<FieldError errors={[errors.date]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Notes (Optional)</FieldLabel>
|
||||||
|
<Textarea {...register("notes")} placeholder="Add details..." />
|
||||||
|
<FieldError errors={[errors.notes]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{selectedStage === "rejected" && (
|
||||||
|
<Field className="animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
<FieldLabel>Reason</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
name="reasonCode"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select reason" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{REASON_CODES.map((code) => (
|
||||||
|
<SelectItem key={code} value={code}>
|
||||||
|
{code}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedStage === "offer" && (
|
||||||
|
<Field className="animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
<FieldLabel>Salary / Details</FieldLabel>
|
||||||
|
<Input {...register("salary")} placeholder="e.g. £50k + bonus" />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialogFooter className="pt-4">
|
||||||
|
<AlertDialogCancel type="button" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? "Saving..."
|
||||||
|
: editingEvent
|
||||||
|
? "Save Changes"
|
||||||
|
: "Log Event"}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</form>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
126
orchestrator/src/client/components/OnboardingGate.test.tsx
Normal file
126
orchestrator/src/client/components/OnboardingGate.test.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import * as api from "@client/api";
|
||||||
|
import { useSettings } from "@client/hooks/useSettings";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { OnboardingGate } from "./OnboardingGate";
|
||||||
|
|
||||||
|
vi.mock("@client/api", () => ({
|
||||||
|
validateOpenrouter: vi.fn(),
|
||||||
|
validateRxresume: vi.fn(),
|
||||||
|
validateResumeConfig: vi.fn(),
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@client/hooks/useSettings", () => ({
|
||||||
|
useSettings: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@client/pages/settings/components/SettingsInput", () => ({
|
||||||
|
SettingsInput: ({ label }: { label: string }) => <div>{label}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@client/pages/settings/components/BaseResumeSelection", () => ({
|
||||||
|
BaseResumeSelection: () => <div>Base resume selection</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/alert-dialog", () => ({
|
||||||
|
AlertDialog: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/tabs", () => ({
|
||||||
|
Tabs: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
TabsContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
TabsList: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
TabsTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<button type="button">{children}</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/progress", () => ({
|
||||||
|
Progress: () => <div>Progress</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const settingsResponse = {
|
||||||
|
settings: {
|
||||||
|
openrouterApiKeyHint: null,
|
||||||
|
rxresumeEmail: "",
|
||||||
|
rxresumePasswordHint: null,
|
||||||
|
rxresumeBaseResumeId: null,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refreshSettings: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("OnboardingGate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(useSettings).mockReturnValue(settingsResponse as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the gate once validations complete and any fail", async () => {
|
||||||
|
vi.mocked(api.validateOpenrouter).mockResolvedValue({
|
||||||
|
valid: false,
|
||||||
|
message: "Invalid",
|
||||||
|
});
|
||||||
|
vi.mocked(api.validateRxresume).mockResolvedValue({
|
||||||
|
valid: true,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
vi.mocked(api.validateResumeConfig).mockResolvedValue({
|
||||||
|
valid: true,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OnboardingGate />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
|
||||||
|
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the gate when all validations succeed", async () => {
|
||||||
|
vi.mocked(api.validateOpenrouter).mockResolvedValue({
|
||||||
|
valid: true,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
vi.mocked(api.validateRxresume).mockResolvedValue({
|
||||||
|
valid: true,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
vi.mocked(api.validateResumeConfig).mockResolvedValue({
|
||||||
|
valid: true,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OnboardingGate />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -126,8 +126,13 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint);
|
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint);
|
||||||
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
|
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
|
||||||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
|
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
|
||||||
|
const hasCheckedValidations =
|
||||||
|
openrouterValidation.checked &&
|
||||||
|
rxresumeValidation.checked &&
|
||||||
|
baseResumeValidation.checked;
|
||||||
const shouldOpen =
|
const shouldOpen =
|
||||||
Boolean(settings && !settingsLoading) &&
|
Boolean(settings && !settingsLoading) &&
|
||||||
|
hasCheckedValidations &&
|
||||||
!(
|
!(
|
||||||
openrouterValidation.valid &&
|
openrouterValidation.valid &&
|
||||||
rxresumeValidation.valid &&
|
rxresumeValidation.valid &&
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Job } from "../../shared/types";
|
import type { Job } from "../../shared/types";
|
||||||
@ -122,6 +123,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
|||||||
appliedAt: null,
|
appliedAt: null,
|
||||||
createdAt: "2025-01-01T00:00:00Z",
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
updatedAt: "2025-01-02T00:00:00Z",
|
updatedAt: "2025-01-02T00:00:00Z",
|
||||||
|
outcome: null,
|
||||||
|
closedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -136,7 +139,13 @@ describe("ReadyPanel", () => {
|
|||||||
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ReadyPanel job={job} onJobUpdated={onJobUpdated} onJobMoved={vi.fn()} />,
|
<MemoryRouter>
|
||||||
|
<ReadyPanel
|
||||||
|
job={job}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
onJobMoved={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Job } from "../../../shared/types";
|
import type { Job } from "../../../shared/types";
|
||||||
@ -115,6 +116,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
|||||||
appliedAt: null,
|
appliedAt: null,
|
||||||
createdAt: "2025-01-01T00:00:00Z",
|
createdAt: "2025-01-01T00:00:00Z",
|
||||||
updatedAt: "2025-01-02T00:00:00Z",
|
updatedAt: "2025-01-02T00:00:00Z",
|
||||||
|
outcome: null,
|
||||||
|
closedAt: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -129,11 +132,13 @@ describe("DiscoveredPanel", () => {
|
|||||||
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<DiscoveredPanel
|
<MemoryRouter>
|
||||||
job={job}
|
<DiscoveredPanel
|
||||||
onJobUpdated={onJobUpdated}
|
job={job}
|
||||||
onJobMoved={vi.fn()}
|
onJobUpdated={onJobUpdated}
|
||||||
/>,
|
onJobMoved={vi.fn()}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
|
|||||||
375
orchestrator/src/client/pages/JobPage.tsx
Normal file
375
orchestrator/src/client/pages/JobPage.tsx
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
import confetti from "canvas-confetti";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CalendarClock,
|
||||||
|
ClipboardList,
|
||||||
|
DollarSign,
|
||||||
|
PlusCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { formatTimestamp } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
type ApplicationStage,
|
||||||
|
type ApplicationTask,
|
||||||
|
type Job,
|
||||||
|
type JobOutcome,
|
||||||
|
STAGE_LABELS,
|
||||||
|
type StageEvent,
|
||||||
|
} from "../../shared/types";
|
||||||
|
import * as api from "../api";
|
||||||
|
import { ConfirmDelete } from "../components/ConfirmDelete";
|
||||||
|
import { JobHeader } from "../components/JobHeader";
|
||||||
|
import {
|
||||||
|
type LogEventFormValues,
|
||||||
|
LogEventModal,
|
||||||
|
} from "../components/LogEventModal";
|
||||||
|
import { JobTimeline } from "./job/Timeline";
|
||||||
|
|
||||||
|
export const JobPage: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [job, setJob] = React.useState<Job | null>(null);
|
||||||
|
const [events, setEvents] = React.useState<StageEvent[]>([]);
|
||||||
|
const [tasks, setTasks] = React.useState<ApplicationTask[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const [isLogModalOpen, setIsLogModalOpen] = React.useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false);
|
||||||
|
const [eventToDelete, setEventToDelete] = React.useState<string | null>(null);
|
||||||
|
const [editingEvent, setEditingEvent] = React.useState<StageEvent | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const pendingEventRef = React.useRef<StageEvent | null>(null);
|
||||||
|
|
||||||
|
const loadData = React.useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const jobData = await api.getJob(id);
|
||||||
|
setJob(jobData);
|
||||||
|
|
||||||
|
api
|
||||||
|
.getJobStageEvents(id)
|
||||||
|
.then((data) => setEvents(mergeEvents(data, pendingEventRef.current)))
|
||||||
|
.catch(() => toast.error("Failed to load stage events"));
|
||||||
|
|
||||||
|
api
|
||||||
|
.getJobTasks(id)
|
||||||
|
.then((data) => setTasks(data))
|
||||||
|
.catch(() => toast.error("Failed to load tasks"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleLogEvent = async (
|
||||||
|
values: LogEventFormValues,
|
||||||
|
eventId?: string,
|
||||||
|
) => {
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
let toStage: ApplicationStage | "no_change" = values.stage as
|
||||||
|
| ApplicationStage
|
||||||
|
| "no_change";
|
||||||
|
let outcome: JobOutcome | null = null;
|
||||||
|
|
||||||
|
if (values.stage === "rejected") {
|
||||||
|
toStage = "closed";
|
||||||
|
outcome = "rejected";
|
||||||
|
} else if (values.stage === "withdrawn") {
|
||||||
|
toStage = "closed";
|
||||||
|
outcome = "withdrawn";
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStage =
|
||||||
|
events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null);
|
||||||
|
const effectiveStage =
|
||||||
|
toStage === "no_change" ? (currentStage ?? "applied") : toStage;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (eventId) {
|
||||||
|
await api.updateJobStageEvent(job.id, eventId, {
|
||||||
|
toStage: toStage === "no_change" ? undefined : toStage,
|
||||||
|
occurredAt: toTimestamp(values.date) ?? undefined,
|
||||||
|
metadata: {
|
||||||
|
note: values.notes?.trim() || undefined,
|
||||||
|
eventLabel: values.title.trim() || undefined,
|
||||||
|
reasonCode: values.reasonCode || undefined,
|
||||||
|
actor: "user",
|
||||||
|
eventType: values.stage === "no_change" ? "note" : "status_update",
|
||||||
|
externalUrl: values.salary ? `Salary: ${values.salary}` : undefined,
|
||||||
|
},
|
||||||
|
outcome,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const newEvent = await api.transitionJobStage(job.id, {
|
||||||
|
toStage: effectiveStage,
|
||||||
|
occurredAt: toTimestamp(values.date),
|
||||||
|
metadata: {
|
||||||
|
note: values.notes?.trim() || undefined,
|
||||||
|
eventLabel: values.title.trim() || undefined,
|
||||||
|
reasonCode: values.reasonCode || undefined,
|
||||||
|
actor: "user",
|
||||||
|
eventType: values.stage === "no_change" ? "note" : "status_update",
|
||||||
|
externalUrl: values.salary ? `Salary: ${values.salary}` : undefined,
|
||||||
|
},
|
||||||
|
outcome,
|
||||||
|
});
|
||||||
|
pendingEventRef.current = newEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [jobData, eventData] = await Promise.all([
|
||||||
|
api.getJob(job.id),
|
||||||
|
api.getJobStageEvents(job.id),
|
||||||
|
]);
|
||||||
|
setJob(jobData);
|
||||||
|
setEvents(eventData);
|
||||||
|
pendingEventRef.current = null;
|
||||||
|
setEditingEvent(null);
|
||||||
|
toast.success(eventId ? "Event updated" : "Event logged");
|
||||||
|
|
||||||
|
if (effectiveStage === "offer") {
|
||||||
|
confetti({
|
||||||
|
particleCount: 150,
|
||||||
|
spread: 70,
|
||||||
|
origin: { y: 0.6 },
|
||||||
|
colors: ["#10b981", "#34d399", "#6ee7b7", "#ffffff"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to log event:", error);
|
||||||
|
toast.error("Failed to log event");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteEvent = (eventId: string) => {
|
||||||
|
setEventToDelete(eventId);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEvent = async () => {
|
||||||
|
if (!job || !eventToDelete) return;
|
||||||
|
try {
|
||||||
|
await api.deleteJobStageEvent(job.id, eventToDelete);
|
||||||
|
const [jobData, eventData] = await Promise.all([
|
||||||
|
api.getJob(job.id),
|
||||||
|
api.getJobStageEvents(job.id),
|
||||||
|
]);
|
||||||
|
setJob(jobData);
|
||||||
|
setEvents(eventData);
|
||||||
|
toast.success("Event deleted");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete event:", error);
|
||||||
|
toast.error("Failed to delete event");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setEventToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditEvent = (event: StageEvent) => {
|
||||||
|
setEditingEvent(event);
|
||||||
|
setIsLogModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentStage = job
|
||||||
|
? (events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto max-w-6xl space-y-6 px-4 py-6 pb-12">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
onClick={() => setIsLogModalOpen(true)}
|
||||||
|
disabled={!job}
|
||||||
|
>
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
Log Event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job ? (
|
||||||
|
<JobHeader
|
||||||
|
job={job}
|
||||||
|
className="rounded-lg border border-border/40 bg-muted/5 p-4"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-border/40 p-6 text-sm text-muted-foreground">
|
||||||
|
{isLoading ? "Loading application..." : "Application not found."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||||
|
<Card className="border-border/50">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ClipboardList className="h-4 w-4" />
|
||||||
|
Stage timeline
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{job?.salary && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
<DollarSign className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{job.salary}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{currentStage && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="px-3 py-1 text-xs font-medium uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{STAGE_LABELS[currentStage as ApplicationStage] ||
|
||||||
|
currentStage}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<JobTimeline
|
||||||
|
events={events}
|
||||||
|
onEdit={handleEditEvent}
|
||||||
|
onDelete={confirmDeleteEvent}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="border-border/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CalendarClock className="h-4 w-4" />
|
||||||
|
Application details
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Current Stage
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm font-medium">
|
||||||
|
{currentStage
|
||||||
|
? STAGE_LABELS[currentStage as ApplicationStage] ||
|
||||||
|
currentStage
|
||||||
|
: job?.status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Outcome
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm font-medium">
|
||||||
|
{job?.outcome ? job.outcome.replace(/_/g, " ") : "Open"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{job?.closedAt && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Closed On
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm font-medium">
|
||||||
|
{formatTimestamp(job.closedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<Card className="border-border/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CalendarClock className="h-4 w-4" />
|
||||||
|
Upcoming tasks
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-start justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-foreground/90">
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
{task.notes && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{task.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{formatTimestamp(task.dueDate)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LogEventModal
|
||||||
|
isOpen={isLogModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsLogModalOpen(false);
|
||||||
|
setEditingEvent(null);
|
||||||
|
}}
|
||||||
|
onLog={handleLogEvent}
|
||||||
|
editingEvent={editingEvent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDelete
|
||||||
|
isOpen={isDeleteModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setEventToDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleDeleteEvent}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toTimestamp = (value: string) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return Math.floor(date.getTime() / 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeEvents = (events: StageEvent[], pending: StageEvent | null) => {
|
||||||
|
if (!pending) return events;
|
||||||
|
if (events.some((event) => event.id === pending.id)) return events;
|
||||||
|
return [...events, pending].sort((a, b) => a.occurredAt - b.occurredAt);
|
||||||
|
};
|
||||||
@ -24,6 +24,8 @@ const jobFixture: Job = {
|
|||||||
starting: null,
|
starting: null,
|
||||||
jobDescription: "Build APIs",
|
jobDescription: "Build APIs",
|
||||||
status: "ready",
|
status: "ready",
|
||||||
|
outcome: null,
|
||||||
|
closedAt: null,
|
||||||
suitabilityScore: 90,
|
suitabilityScore: 90,
|
||||||
suitabilityReason: null,
|
suitabilityReason: null,
|
||||||
tailoredSummary: null,
|
tailoredSummary: null,
|
||||||
|
|||||||
45
orchestrator/src/client/pages/job/Timeline.test.tsx
Normal file
45
orchestrator/src/client/pages/job/Timeline.test.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { StageEvent } from "../../../shared/types";
|
||||||
|
import { JobTimeline } from "./Timeline";
|
||||||
|
|
||||||
|
const baseEvent: StageEvent = {
|
||||||
|
id: "event-1",
|
||||||
|
applicationId: "app-1",
|
||||||
|
fromStage: null,
|
||||||
|
toStage: "applied",
|
||||||
|
title: "Applied",
|
||||||
|
groupId: null,
|
||||||
|
occurredAt: 1735689600,
|
||||||
|
metadata: {
|
||||||
|
eventLabel: "Applied",
|
||||||
|
},
|
||||||
|
outcome: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("JobTimeline", () => {
|
||||||
|
it("renders edit and delete controls when callbacks are provided", () => {
|
||||||
|
const onEdit = vi.fn();
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JobTimeline events={[baseEvent]} onEdit={onEdit} onDelete={onDelete} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const editButton = screen.getByTitle("Edit event");
|
||||||
|
const deleteButton = screen.getByTitle("Delete event");
|
||||||
|
|
||||||
|
fireEvent.click(editButton);
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
expect(onEdit).toHaveBeenCalledWith(baseEvent);
|
||||||
|
expect(onDelete).toHaveBeenCalledWith("event-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits edit and delete controls when callbacks are missing", () => {
|
||||||
|
render(<JobTimeline events={[baseEvent]} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTitle("Edit event")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle("Delete event")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
334
orchestrator/src/client/pages/job/Timeline.tsx
Normal file
334
orchestrator/src/client/pages/job/Timeline.tsx
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardList,
|
||||||
|
Edit2,
|
||||||
|
FileText,
|
||||||
|
MailCheck,
|
||||||
|
PhoneCall,
|
||||||
|
Presentation,
|
||||||
|
Trash2,
|
||||||
|
UserRound,
|
||||||
|
Video,
|
||||||
|
} from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn, formatTimestamp, formatTimestampWithTime } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
type ApplicationStage,
|
||||||
|
STAGE_LABELS,
|
||||||
|
type StageEvent,
|
||||||
|
} from "../../../shared/types";
|
||||||
|
import { CollapsibleSection } from "../../components/discovered-panel/CollapsibleSection";
|
||||||
|
|
||||||
|
const stageIcons: Record<ApplicationStage, React.ReactNode> = {
|
||||||
|
applied: <CheckCircle2 className="h-4 w-4" />,
|
||||||
|
recruiter_screen: <PhoneCall className="h-4 w-4" />,
|
||||||
|
assessment: <FileText className="h-4 w-4" />,
|
||||||
|
hiring_manager_screen: <UserRound className="h-4 w-4" />,
|
||||||
|
technical_interview: <Video className="h-4 w-4" />,
|
||||||
|
onsite: <Presentation className="h-4 w-4" />,
|
||||||
|
offer: <MailCheck className="h-4 w-4" />,
|
||||||
|
closed: <ClipboardList className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRange = (start: number, end: number) => {
|
||||||
|
const startLabel = formatTimestamp(start);
|
||||||
|
const endLabel = formatTimestamp(end);
|
||||||
|
return startLabel === endLabel ? startLabel : `${startLabel} - ${endLabel}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TimelineEntry =
|
||||||
|
| { kind: "event"; event: StageEvent }
|
||||||
|
| {
|
||||||
|
kind: "group";
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
events: StageEvent[];
|
||||||
|
occurredAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface JobTimelineProps {
|
||||||
|
events: StageEvent[];
|
||||||
|
onEdit?: (event: StageEvent) => void;
|
||||||
|
onDelete?: (eventId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JobTimeline: React.FC<JobTimelineProps> = ({
|
||||||
|
events,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const lastEvent = events.at(-1);
|
||||||
|
const currentStage = lastEvent?.toStage ?? null;
|
||||||
|
|
||||||
|
const entries = React.useMemo(() => {
|
||||||
|
const groups = new Map<string, { label: string; events: StageEvent[] }>();
|
||||||
|
const standalone: StageEvent[] = [];
|
||||||
|
|
||||||
|
events.forEach((event) => {
|
||||||
|
const groupId = event.groupId;
|
||||||
|
if (!groupId) {
|
||||||
|
standalone.push(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = event.metadata?.groupLabel || "Grouped events";
|
||||||
|
const group = groups.get(groupId) ?? { label, events: [] };
|
||||||
|
group.events.push(event);
|
||||||
|
groups.set(groupId, group);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped: TimelineEntry[] = standalone.map((event) => ({
|
||||||
|
kind: "event",
|
||||||
|
event,
|
||||||
|
}));
|
||||||
|
|
||||||
|
groups.forEach((value, id) => {
|
||||||
|
const sorted = [...value.events].sort(
|
||||||
|
(a, b) => a.occurredAt - b.occurredAt,
|
||||||
|
);
|
||||||
|
mapped.push({
|
||||||
|
kind: "group",
|
||||||
|
id,
|
||||||
|
label: value.label,
|
||||||
|
events: sorted,
|
||||||
|
occurredAt: sorted[0]?.occurredAt ?? 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapped.sort((a, b) => {
|
||||||
|
const timeA = a.kind === "event" ? a.event.occurredAt : a.occurredAt;
|
||||||
|
const timeB = b.kind === "event" ? b.event.occurredAt : b.occurredAt;
|
||||||
|
return timeA - timeB;
|
||||||
|
});
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed border-border/50 p-6 text-sm text-muted-foreground">
|
||||||
|
No stage events yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{entries.map((entry, entryIndex) => {
|
||||||
|
if (entry.kind === "event") {
|
||||||
|
const title = entry.event.title || STAGE_LABELS[entry.event.toStage];
|
||||||
|
const note = entry.event.metadata?.note;
|
||||||
|
const reason = entry.event.metadata?.reasonCode;
|
||||||
|
const isCurrent =
|
||||||
|
currentStage === entry.event.toStage &&
|
||||||
|
entryIndex === entries.length - 1 &&
|
||||||
|
entry.event.toStage !== "applied";
|
||||||
|
const isOffer = entry.event.toStage === "offer";
|
||||||
|
const salary = entry.event.metadata?.externalUrl?.startsWith(
|
||||||
|
"Salary: ",
|
||||||
|
)
|
||||||
|
? entry.event.metadata.externalUrl.replace("Salary: ", "")
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<TimelineRow
|
||||||
|
key={entry.event.id}
|
||||||
|
date={formatTimestampWithTime(entry.event.occurredAt)}
|
||||||
|
title={title}
|
||||||
|
icon={stageIcons[entry.event.toStage]}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
isOffer={isOffer}
|
||||||
|
isLast={entryIndex === entries.length - 1}
|
||||||
|
onEdit={onEdit ? () => onEdit(entry.event) : undefined}
|
||||||
|
onDelete={onDelete ? () => onDelete(entry.event.id) : undefined}
|
||||||
|
>
|
||||||
|
{note && (
|
||||||
|
<div className="text-sm text-muted-foreground">{note}</div>
|
||||||
|
)}
|
||||||
|
{salary && (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||||||
|
{salary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reason && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="mt-2 text-[10px] uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{reason}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TimelineRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupOpen = Boolean(openGroups[entry.id]);
|
||||||
|
const toggleGroup = () =>
|
||||||
|
setOpenGroups((prev) => ({ ...prev, [entry.id]: !prev[entry.id] }));
|
||||||
|
const groupStart = entry.events[0]?.occurredAt ?? entry.occurredAt;
|
||||||
|
const groupEnd = entry.events.at(-1)?.occurredAt ?? entry.occurredAt;
|
||||||
|
const groupCompleted = entry.events.some((event) =>
|
||||||
|
/submitted|completed|finished/i.test(event.title || ""),
|
||||||
|
);
|
||||||
|
const isCurrentGroup =
|
||||||
|
currentStage === entry.events.at(-1)?.toStage &&
|
||||||
|
entryIndex === entries.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="space-y-2">
|
||||||
|
<TimelineRow
|
||||||
|
date={formatRange(groupStart, groupEnd)}
|
||||||
|
title={entry.label}
|
||||||
|
icon={<ClipboardList className="h-4 w-4" />}
|
||||||
|
isCurrent={isCurrentGroup && !groupCompleted}
|
||||||
|
isCompleted={groupCompleted}
|
||||||
|
isLast={entryIndex === entries.length - 1}
|
||||||
|
>
|
||||||
|
<CollapsibleSection
|
||||||
|
isOpen={groupOpen}
|
||||||
|
label={groupOpen ? "Hide details" : "View details"}
|
||||||
|
onToggle={toggleGroup}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{entry.events.map((event) => (
|
||||||
|
<TimelineRow
|
||||||
|
key={event.id}
|
||||||
|
date={formatTimestampWithTime(event.occurredAt)}
|
||||||
|
title={event.title || STAGE_LABELS[event.toStage]}
|
||||||
|
icon={stageIcons[event.toStage]}
|
||||||
|
isCompact
|
||||||
|
isLast={false}
|
||||||
|
onEdit={onEdit ? () => onEdit(event) : undefined}
|
||||||
|
onDelete={onDelete ? () => onDelete(event.id) : undefined}
|
||||||
|
>
|
||||||
|
{event.metadata?.note && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{event.metadata.note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TimelineRow>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
</TimelineRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TimelineRowProps {
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
isCurrent?: boolean;
|
||||||
|
isOffer?: boolean;
|
||||||
|
isCompleted?: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
|
isCompact?: boolean;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimelineRow: React.FC<TimelineRowProps> = ({
|
||||||
|
date,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
isCurrent,
|
||||||
|
isOffer,
|
||||||
|
isCompleted,
|
||||||
|
isLast,
|
||||||
|
isCompact,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const isHollow = Boolean(isCurrent) && !isCompleted;
|
||||||
|
const isFilled = !isHollow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative",
|
||||||
|
isCompact ? "pl-8" : "",
|
||||||
|
isOffer && "rounded-lg border border-amber-500/20 bg-amber-500/5 p-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isCompact
|
||||||
|
? "grid grid-cols-[80px_20px_1fr] gap-4"
|
||||||
|
: "grid grid-cols-[100px_24px_1fr] gap-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-right text-xs font-medium text-muted-foreground">
|
||||||
|
{date}
|
||||||
|
</div>
|
||||||
|
<div className="relative flex flex-col items-center">
|
||||||
|
<span className="absolute inset-y-0 w-px bg-border" />
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isCompact
|
||||||
|
? "relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500 text-white"
|
||||||
|
: isHollow
|
||||||
|
? "relative z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 border-emerald-500 bg-background text-emerald-600 animate-pulse"
|
||||||
|
: isOffer
|
||||||
|
? "relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 text-white shadow-[0_0_15px_rgba(245,158,11,0.5)]"
|
||||||
|
: "relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-emerald-500 text-white"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isFilled && icon}
|
||||||
|
</div>
|
||||||
|
{isLast && (
|
||||||
|
<span className="absolute bottom-0 h-4 w-px bg-background" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1 min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isCompact ? "text-xs font-semibold" : "text-sm font-semibold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="p-2 cursor-pointer rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Edit event"
|
||||||
|
>
|
||||||
|
<Edit2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
className="p-2 cursor-pointer rounded-md hover:bg-muted text-destructive/70 hover:text-destructive transition-colors"
|
||||||
|
title="Delete event"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -106,6 +106,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
|||||||
starting: null,
|
starting: null,
|
||||||
jobDescription: "Build APIs",
|
jobDescription: "Build APIs",
|
||||||
status: "ready",
|
status: "ready",
|
||||||
|
outcome: null,
|
||||||
|
closedAt: null,
|
||||||
suitabilityScore: 82,
|
suitabilityScore: 82,
|
||||||
suitabilityReason: "Strong fit",
|
suitabilityReason: "Strong fit",
|
||||||
tailoredSummary: null,
|
tailoredSummary: null,
|
||||||
|
|||||||
@ -22,6 +22,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
|||||||
starting: null,
|
starting: null,
|
||||||
jobDescription: "Build APIs",
|
jobDescription: "Build APIs",
|
||||||
status: "ready",
|
status: "ready",
|
||||||
|
outcome: null,
|
||||||
|
closedAt: null,
|
||||||
suitabilityScore: 72,
|
suitabilityScore: 72,
|
||||||
suitabilityReason: null,
|
suitabilityReason: null,
|
||||||
tailoredSummary: null,
|
tailoredSummary: null,
|
||||||
|
|||||||
@ -26,6 +26,26 @@ export const formatDate = (dateStr?: string | null) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatTimestamp = (value?: number | null) => {
|
||||||
|
if (!value) return "No due date";
|
||||||
|
return new Date(value * 1000).toLocaleDateString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTimestampWithTime = (value?: number | null) => {
|
||||||
|
if (!value) return "No date";
|
||||||
|
const date = new Date(value * 1000);
|
||||||
|
const dateLabel = formatTimestamp(value);
|
||||||
|
const timeLabel = date.toLocaleTimeString("en-GB", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
return `${dateLabel} ${timeLabel}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const formatDateTime = (dateStr?: string | null) => {
|
export const formatDateTime = (dateStr?: string | null) => {
|
||||||
if (!dateStr) return null;
|
if (!dateStr) return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -169,4 +169,137 @@ describe.sequential("Jobs API routes", () => {
|
|||||||
expect(body.data.sponsorMatchScore).toBe(100);
|
expect(body.data.sponsorMatchScore).toBe(100);
|
||||||
expect(body.data.sponsorMatchNames).toContain("ACME CORP SPONSOR");
|
expect(body.data.sponsorMatchNames).toContain("ACME CORP SPONSOR");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Application Tracking", () => {
|
||||||
|
let jobId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { createJob } = await import("../../repositories/jobs.js");
|
||||||
|
const job = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Tracking Test",
|
||||||
|
employer: "Test Corp",
|
||||||
|
jobUrl: "https://example.com/tracking",
|
||||||
|
});
|
||||||
|
jobId = job.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions stages and retrieves events", async () => {
|
||||||
|
// 1. Initial transition to applied
|
||||||
|
const trans1 = await fetch(`${baseUrl}/api/jobs/${jobId}/stages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ toStage: "applied" }),
|
||||||
|
});
|
||||||
|
const body1 = await trans1.json();
|
||||||
|
expect(body1.success).toBe(true);
|
||||||
|
expect(body1.data.toStage).toBe("applied");
|
||||||
|
const eventId = body1.data.id;
|
||||||
|
|
||||||
|
// 2. Transition to recruiter_screen with metadata
|
||||||
|
await fetch(`${baseUrl}/api/jobs/${jobId}/stages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
toStage: "recruiter_screen",
|
||||||
|
metadata: { note: "Called by recruiter" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Get events
|
||||||
|
const eventsRes = await fetch(`${baseUrl}/api/jobs/${jobId}/events`);
|
||||||
|
const eventsBody = await eventsRes.json();
|
||||||
|
expect(eventsBody.success).toBe(true);
|
||||||
|
expect(eventsBody.data).toHaveLength(2);
|
||||||
|
expect(eventsBody.data[0].toStage).toBe("applied");
|
||||||
|
expect(eventsBody.data[1].toStage).toBe("recruiter_screen");
|
||||||
|
expect(eventsBody.data[1].metadata.note).toBe("Called by recruiter");
|
||||||
|
|
||||||
|
// 4. Patch an event
|
||||||
|
const patchRes = await fetch(
|
||||||
|
`${baseUrl}/api/jobs/${jobId}/events/${eventId}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ metadata: { note: "Updated note" } }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(patchRes.status).toBe(200);
|
||||||
|
|
||||||
|
const eventsRes2 = await fetch(`${baseUrl}/api/jobs/${jobId}/events`);
|
||||||
|
const eventsBody2 = await eventsRes2.json();
|
||||||
|
expect(eventsBody2.data[0].metadata.note).toBe("Updated note");
|
||||||
|
|
||||||
|
// 5. Delete an event
|
||||||
|
const deleteRes = await fetch(
|
||||||
|
`${baseUrl}/api/jobs/${jobId}/events/${eventId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(deleteRes.status).toBe(200);
|
||||||
|
|
||||||
|
const eventsRes3 = await fetch(`${baseUrl}/api/jobs/${jobId}/events`);
|
||||||
|
const eventsBody3 = await eventsRes3.json();
|
||||||
|
expect(eventsBody3.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manages application tasks", async () => {
|
||||||
|
const { db, schema } = await import("../../db/index.js");
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
const { tasks } = schema;
|
||||||
|
|
||||||
|
// 1. Initial state
|
||||||
|
const res1 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`);
|
||||||
|
const body1 = await res1.json();
|
||||||
|
expect(body1.success).toBe(true);
|
||||||
|
expect(body1.data).toEqual([]);
|
||||||
|
|
||||||
|
// 2. Insert a task
|
||||||
|
await (db as any)
|
||||||
|
.insert(tasks)
|
||||||
|
.values({
|
||||||
|
id: "task-1",
|
||||||
|
applicationId: jobId,
|
||||||
|
type: "todo",
|
||||||
|
title: "Complete test task",
|
||||||
|
isCompleted: false,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const res2 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`);
|
||||||
|
const body2 = await res2.json();
|
||||||
|
expect(body2.data).toHaveLength(1);
|
||||||
|
expect(body2.data[0].title).toBe("Complete test task");
|
||||||
|
|
||||||
|
// 3. Test filtering (completed vs non-completed)
|
||||||
|
await (db as any)
|
||||||
|
.update(tasks)
|
||||||
|
.set({ isCompleted: true })
|
||||||
|
.where(eq(tasks.id, "task-1"))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const res3 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`);
|
||||||
|
const body3 = await res3.json();
|
||||||
|
expect(body3.data).toHaveLength(0); // includeCompleted defaults to false
|
||||||
|
|
||||||
|
const res4 = await fetch(
|
||||||
|
`${baseUrl}/api/jobs/${jobId}/tasks?includeCompleted=true`,
|
||||||
|
);
|
||||||
|
const body4 = await res4.json();
|
||||||
|
expect(body4.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates job outcome", async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/jobs/${jobId}/outcome`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ outcome: "rejected" }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.outcome).toBe("rejected");
|
||||||
|
expect(body.data.closedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { type Request, type Response, Router } from "express";
|
import { type Request, type Response, Router } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type {
|
import {
|
||||||
ApiResponse,
|
APPLICATION_OUTCOMES,
|
||||||
Job,
|
APPLICATION_STAGES,
|
||||||
JobStatus,
|
type ApiResponse,
|
||||||
JobsListResponse,
|
type Job,
|
||||||
|
type JobStatus,
|
||||||
|
type JobsListResponse,
|
||||||
} from "../../../shared/types.js";
|
} from "../../../shared/types.js";
|
||||||
import {
|
import {
|
||||||
generateFinalPdf,
|
generateFinalPdf,
|
||||||
@ -13,6 +15,14 @@ import {
|
|||||||
} from "../../pipeline/index.js";
|
} from "../../pipeline/index.js";
|
||||||
import * as jobsRepo from "../../repositories/jobs.js";
|
import * as jobsRepo from "../../repositories/jobs.js";
|
||||||
import * as settingsRepo from "../../repositories/settings.js";
|
import * as settingsRepo from "../../repositories/settings.js";
|
||||||
|
import {
|
||||||
|
deleteStageEvent,
|
||||||
|
getStageEvents,
|
||||||
|
getTasks,
|
||||||
|
stageEventMetadataSchema,
|
||||||
|
transitionStage,
|
||||||
|
updateStageEvent,
|
||||||
|
} from "../../services/applicationTracking.js";
|
||||||
import { createNotionEntry } from "../../services/notion.js";
|
import { createNotionEntry } from "../../services/notion.js";
|
||||||
import { getProfile } from "../../services/profile.js";
|
import { getProfile } from "../../services/profile.js";
|
||||||
import { scoreJobSuitability } from "../../services/scorer.js";
|
import { scoreJobSuitability } from "../../services/scorer.js";
|
||||||
@ -72,6 +82,8 @@ const updateJobSchema = z.object({
|
|||||||
"expired",
|
"expired",
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
|
outcome: z.enum(APPLICATION_OUTCOMES).nullable().optional(),
|
||||||
|
closedAt: z.number().int().nullable().optional(),
|
||||||
jobDescription: z.string().optional(),
|
jobDescription: z.string().optional(),
|
||||||
suitabilityScore: z.number().min(0).max(100).optional(),
|
suitabilityScore: z.number().min(0).max(100).optional(),
|
||||||
suitabilityReason: z.string().optional(),
|
suitabilityReason: z.string().optional(),
|
||||||
@ -82,6 +94,25 @@ const updateJobSchema = z.object({
|
|||||||
sponsorMatchNames: z.string().optional(),
|
sponsorMatchNames: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const transitionStageSchema = z.object({
|
||||||
|
toStage: z.enum([...APPLICATION_STAGES, "no_change"]),
|
||||||
|
occurredAt: z.number().int().nullable().optional(),
|
||||||
|
metadata: stageEventMetadataSchema.nullable().optional(),
|
||||||
|
outcome: z.enum(APPLICATION_OUTCOMES).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStageEventSchema = z.object({
|
||||||
|
toStage: z.enum(APPLICATION_STAGES).optional(),
|
||||||
|
occurredAt: z.number().int().optional(),
|
||||||
|
metadata: stageEventMetadataSchema.nullable().optional(),
|
||||||
|
outcome: z.enum(APPLICATION_OUTCOMES).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOutcomeSchema = z.object({
|
||||||
|
outcome: z.enum(APPLICATION_OUTCOMES).nullable(),
|
||||||
|
closedAt: z.number().int().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/jobs - List all jobs
|
* GET /api/jobs - List all jobs
|
||||||
* Query params: status (comma-separated list of statuses to filter)
|
* Query params: status (comma-separated list of statuses to filter)
|
||||||
@ -118,6 +149,117 @@ jobsRouter.get("/", async (req: Request, res: Response) => {
|
|||||||
jobsRouter.get("/:id", async (req: Request, res: Response) => {
|
jobsRouter.get("/:id", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const job = await jobsRepo.getJobById(req.params.id);
|
const job = await jobsRepo.getJobById(req.params.id);
|
||||||
|
if (!job) {
|
||||||
|
return res.status(404).json({ success: false, error: "Job not found" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: job });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/jobs/:id/events - Get stage event timeline
|
||||||
|
*/
|
||||||
|
jobsRouter.get("/:id/events", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const events = await getStageEvents(req.params.id);
|
||||||
|
res.json({ success: true, data: events });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/jobs/:id/tasks - Get tasks for an application
|
||||||
|
*/
|
||||||
|
jobsRouter.get("/:id/tasks", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const includeCompleted =
|
||||||
|
req.query.includeCompleted === "1" ||
|
||||||
|
req.query.includeCompleted === "true";
|
||||||
|
const tasks = await getTasks(req.params.id, includeCompleted);
|
||||||
|
res.json({ success: true, data: tasks });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/jobs/:id/stages - Transition stage
|
||||||
|
*/
|
||||||
|
jobsRouter.post("/:id/stages", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const input = transitionStageSchema.parse(req.body);
|
||||||
|
const event = transitionStage(
|
||||||
|
req.params.id,
|
||||||
|
input.toStage,
|
||||||
|
input.occurredAt ?? undefined,
|
||||||
|
input.metadata ?? null,
|
||||||
|
input.outcome ?? null,
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: event });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/jobs/:id/events/:eventId - Update an event
|
||||||
|
*/
|
||||||
|
jobsRouter.patch(
|
||||||
|
"/:id/events/:eventId",
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const input = updateStageEventSchema.parse(req.body);
|
||||||
|
updateStageEvent(req.params.eventId, input);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/jobs/:id/events/:eventId - Delete an event
|
||||||
|
*/
|
||||||
|
jobsRouter.delete(
|
||||||
|
"/:id/events/:eventId",
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
deleteStageEvent(req.params.eventId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/jobs/:id/outcome - Close out application
|
||||||
|
*/
|
||||||
|
jobsRouter.patch("/:id/outcome", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const input = updateOutcomeSchema.parse(req.body);
|
||||||
|
const closedAt = input.outcome
|
||||||
|
? (input.closedAt ?? Math.floor(Date.now() / 1000))
|
||||||
|
: null;
|
||||||
|
const job = await jobsRepo.updateJob(req.params.id, {
|
||||||
|
outcome: input.outcome,
|
||||||
|
closedAt,
|
||||||
|
});
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return res.status(404).json({ success: false, error: "Job not found" });
|
return res.status(404).json({ success: false, error: "Job not found" });
|
||||||
@ -125,6 +267,9 @@ jobsRouter.get("/:id", async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
res.json({ success: true, data: job });
|
res.json({ success: true, data: job });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
res.status(500).json({ success: false, error: message });
|
res.status(500).json({ success: false, error: message });
|
||||||
}
|
}
|
||||||
@ -312,7 +457,8 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
|
|||||||
return res.status(404).json({ success: false, error: "Job not found" });
|
return res.status(404).json({ success: false, error: "Job not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const appliedAt = new Date().toISOString();
|
const appliedAtDate = new Date();
|
||||||
|
const appliedAt = appliedAtDate.toISOString();
|
||||||
|
|
||||||
// Sync to Notion
|
// Sync to Notion
|
||||||
const notionResult = await createNotionEntry({
|
const notionResult = await createNotionEntry({
|
||||||
@ -327,7 +473,18 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
|
|||||||
appliedAt,
|
appliedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update job status
|
transitionStage(
|
||||||
|
job.id,
|
||||||
|
"applied",
|
||||||
|
Math.floor(appliedAtDate.getTime() / 1000),
|
||||||
|
{
|
||||||
|
eventLabel: "Applied",
|
||||||
|
actor: "system",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update job status + Notion metadata
|
||||||
const updatedJob = await jobsRepo.updateJob(job.id, {
|
const updatedJob = await jobsRepo.updateJob(job.id, {
|
||||||
status: "applied",
|
status: "applied",
|
||||||
appliedAt,
|
appliedAt,
|
||||||
@ -338,6 +495,10 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
|
|||||||
notifyJobCompleteWebhook(updatedJob).catch(console.warn);
|
notifyJobCompleteWebhook(updatedJob).catch(console.warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!updatedJob) {
|
||||||
|
return res.status(404).json({ success: false, error: "Job not found" });
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: updatedJob });
|
res.json({ success: true, data: updatedJob });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
|||||||
@ -16,13 +16,15 @@ export function clearDatabase(): { jobsDeleted: number; runsDeleted: number } {
|
|||||||
const sqlite = new Database(DB_PATH);
|
const sqlite = new Database(DB_PATH);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
sqlite.prepare("DELETE FROM stage_events").run();
|
||||||
|
sqlite.prepare("DELETE FROM tasks").run();
|
||||||
|
sqlite.prepare("DELETE FROM interviews").run();
|
||||||
const jobsResult = sqlite.prepare("DELETE FROM jobs").run();
|
const jobsResult = sqlite.prepare("DELETE FROM jobs").run();
|
||||||
const runsResult = sqlite.prepare("DELETE FROM pipeline_runs").run();
|
const runsResult = sqlite.prepare("DELETE FROM pipeline_runs").run();
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`🗑️ Cleared database: ${jobsResult.changes} jobs, ${runsResult.changes} pipeline runs`,
|
`🗑️ Cleared database: ${jobsResult.changes} jobs, ${runsResult.changes} pipeline runs`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobsDeleted: jobsResult.changes,
|
jobsDeleted: jobsResult.changes,
|
||||||
runsDeleted: runsResult.changes,
|
runsDeleted: runsResult.changes,
|
||||||
|
|||||||
@ -62,6 +62,8 @@ const migrations = [
|
|||||||
starting TEXT,
|
starting TEXT,
|
||||||
job_description TEXT,
|
job_description TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'skipped', 'expired')),
|
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'skipped', 'expired')),
|
||||||
|
outcome TEXT,
|
||||||
|
closed_at INTEGER,
|
||||||
suitability_score REAL,
|
suitability_score REAL,
|
||||||
suitability_reason TEXT,
|
suitability_reason TEXT,
|
||||||
tailored_summary TEXT,
|
tailored_summary TEXT,
|
||||||
@ -91,6 +93,40 @@ const migrations = [
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS stage_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
group_id TEXT,
|
||||||
|
from_stage TEXT,
|
||||||
|
to_stage TEXT NOT NULL,
|
||||||
|
occurred_at INTEGER NOT NULL,
|
||||||
|
metadata TEXT,
|
||||||
|
outcome TEXT,
|
||||||
|
FOREIGN KEY (application_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
due_date INTEGER,
|
||||||
|
is_completed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
FOREIGN KEY (application_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS interviews (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
scheduled_at INTEGER NOT NULL,
|
||||||
|
duration_mins INTEGER,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
outcome TEXT,
|
||||||
|
FOREIGN KEY (application_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
|
||||||
// Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run)
|
// Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run)
|
||||||
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
|
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
|
||||||
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
|
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
|
||||||
@ -136,9 +172,35 @@ const migrations = [
|
|||||||
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
|
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
|
||||||
`ALTER TABLE jobs ADD COLUMN sponsor_match_names TEXT`,
|
`ALTER TABLE jobs ADD COLUMN sponsor_match_names TEXT`,
|
||||||
|
|
||||||
|
// Add application tracking columns
|
||||||
|
`ALTER TABLE jobs ADD COLUMN outcome TEXT`,
|
||||||
|
`ALTER TABLE jobs ADD COLUMN closed_at INTEGER`,
|
||||||
|
`ALTER TABLE stage_events ADD COLUMN outcome TEXT`,
|
||||||
|
`ALTER TABLE stage_events ADD COLUMN title TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE stage_events ADD COLUMN group_id TEXT`,
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
|
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started_at ON pipeline_runs(started_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started_at ON pipeline_runs(started_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_stage_events_application_id ON stage_events(application_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_stage_events_occurred_at ON stage_events(occurred_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tasks_application_id ON tasks(application_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_interviews_application_id ON interviews(application_id)`,
|
||||||
|
|
||||||
|
// Backfill: Create "Applied" events for legacy jobs that have applied_at set but no event entry
|
||||||
|
`INSERT INTO stage_events (id, application_id, title, from_stage, to_stage, occurred_at, metadata)
|
||||||
|
SELECT
|
||||||
|
'backfill-applied-' || id,
|
||||||
|
id,
|
||||||
|
'Applied',
|
||||||
|
NULL,
|
||||||
|
'applied',
|
||||||
|
CAST(strftime('%s', applied_at) AS INTEGER),
|
||||||
|
'{"eventLabel":"Applied","actor":"system"}'
|
||||||
|
FROM jobs
|
||||||
|
WHERE applied_at IS NOT NULL
|
||||||
|
AND id NOT IN (SELECT application_id FROM stage_events WHERE to_stage = 'applied')`,
|
||||||
];
|
];
|
||||||
|
|
||||||
console.log("🔧 Running database migrations...");
|
console.log("🔧 Running database migrations...");
|
||||||
@ -150,7 +212,11 @@ for (const migration of migrations) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const isDuplicateColumn =
|
const isDuplicateColumn =
|
||||||
migration.toLowerCase().includes("alter table jobs add column") &&
|
(migration.toLowerCase().includes("alter table jobs add column") ||
|
||||||
|
migration.toLowerCase().includes("alter table tasks add column") ||
|
||||||
|
migration
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("alter table stage_events add column")) &&
|
||||||
message.toLowerCase().includes("duplicate column name");
|
message.toLowerCase().includes("duplicate column name");
|
||||||
|
|
||||||
if (isDuplicateColumn) {
|
if (isDuplicateColumn) {
|
||||||
|
|||||||
@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
import {
|
||||||
|
APPLICATION_OUTCOMES,
|
||||||
|
APPLICATION_STAGES,
|
||||||
|
APPLICATION_TASK_TYPES,
|
||||||
|
INTERVIEW_OUTCOMES,
|
||||||
|
INTERVIEW_TYPES,
|
||||||
|
} from "../../shared/types.js";
|
||||||
|
|
||||||
export const jobs = sqliteTable("jobs", {
|
export const jobs = sqliteTable("jobs", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@ -69,6 +76,8 @@ export const jobs = sqliteTable("jobs", {
|
|||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("discovered"),
|
.default("discovered"),
|
||||||
|
outcome: text("outcome", { enum: APPLICATION_OUTCOMES }),
|
||||||
|
closedAt: integer("closed_at", { mode: "number" }),
|
||||||
suitabilityScore: real("suitability_score"),
|
suitabilityScore: real("suitability_score"),
|
||||||
suitabilityReason: text("suitability_reason"),
|
suitabilityReason: text("suitability_reason"),
|
||||||
tailoredSummary: text("tailored_summary"),
|
tailoredSummary: text("tailored_summary"),
|
||||||
@ -88,6 +97,45 @@ export const jobs = sqliteTable("jobs", {
|
|||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const stageEvents = sqliteTable("stage_events", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
applicationId: text("application_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => jobs.id, { onDelete: "cascade" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
groupId: text("group_id"),
|
||||||
|
fromStage: text("from_stage", { enum: APPLICATION_STAGES }),
|
||||||
|
toStage: text("to_stage", { enum: APPLICATION_STAGES }).notNull(),
|
||||||
|
occurredAt: integer("occurred_at", { mode: "number" }).notNull(),
|
||||||
|
metadata: text("metadata", { mode: "json" }),
|
||||||
|
outcome: text("outcome", { enum: APPLICATION_OUTCOMES }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tasks = sqliteTable("tasks", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
applicationId: text("application_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => jobs.id, { onDelete: "cascade" }),
|
||||||
|
type: text("type", { enum: APPLICATION_TASK_TYPES }).notNull(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
dueDate: integer("due_date", { mode: "number" }),
|
||||||
|
isCompleted: integer("is_completed", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
notes: text("notes"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const interviews = sqliteTable("interviews", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
applicationId: text("application_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => jobs.id, { onDelete: "cascade" }),
|
||||||
|
scheduledAt: integer("scheduled_at", { mode: "number" }).notNull(),
|
||||||
|
durationMins: integer("duration_mins"),
|
||||||
|
type: text("type", { enum: INTERVIEW_TYPES }).notNull(),
|
||||||
|
outcome: text("outcome", { enum: INTERVIEW_OUTCOMES }),
|
||||||
|
});
|
||||||
|
|
||||||
export const pipelineRuns = sqliteTable("pipeline_runs", {
|
export const pipelineRuns = sqliteTable("pipeline_runs", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
|
startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
|
||||||
@ -111,6 +159,12 @@ export const settings = sqliteTable("settings", {
|
|||||||
|
|
||||||
export type JobRow = typeof jobs.$inferSelect;
|
export type JobRow = typeof jobs.$inferSelect;
|
||||||
export type NewJobRow = typeof jobs.$inferInsert;
|
export type NewJobRow = typeof jobs.$inferInsert;
|
||||||
|
export type StageEventRow = typeof stageEvents.$inferSelect;
|
||||||
|
export type NewStageEventRow = typeof stageEvents.$inferInsert;
|
||||||
|
export type TaskRow = typeof tasks.$inferSelect;
|
||||||
|
export type NewTaskRow = typeof tasks.$inferInsert;
|
||||||
|
export type InterviewRow = typeof interviews.$inferSelect;
|
||||||
|
export type NewInterviewRow = typeof interviews.$inferInsert;
|
||||||
export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
|
export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
|
||||||
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
|
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
|
||||||
export type SettingsRow = typeof settings.$inferSelect;
|
export type SettingsRow = typeof settings.$inferSelect;
|
||||||
|
|||||||
@ -73,6 +73,8 @@ const createMockJob = (overrides: Partial<Job> = {}): Job => ({
|
|||||||
starting: null,
|
starting: null,
|
||||||
jobDescription: "Looking for a TypeScript developer.",
|
jobDescription: "Looking for a TypeScript developer.",
|
||||||
status: "discovered",
|
status: "discovered",
|
||||||
|
outcome: null,
|
||||||
|
closedAt: null,
|
||||||
suitabilityScore: null,
|
suitabilityScore: null,
|
||||||
suitabilityReason: null,
|
suitabilityReason: null,
|
||||||
tailoredSummary: null,
|
tailoredSummary: null,
|
||||||
|
|||||||
@ -263,6 +263,8 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
|||||||
starting: row.starting,
|
starting: row.starting,
|
||||||
jobDescription: row.jobDescription,
|
jobDescription: row.jobDescription,
|
||||||
status: row.status as JobStatus,
|
status: row.status as JobStatus,
|
||||||
|
outcome: row.outcome ?? null,
|
||||||
|
closedAt: row.closedAt ?? null,
|
||||||
suitabilityScore: row.suitabilityScore,
|
suitabilityScore: row.suitabilityScore,
|
||||||
suitabilityReason: row.suitabilityReason,
|
suitabilityReason: row.suitabilityReason,
|
||||||
tailoredSummary: row.tailoredSummary,
|
tailoredSummary: row.tailoredSummary,
|
||||||
|
|||||||
@ -34,6 +34,8 @@ const mockJob: Job = {
|
|||||||
starting: null,
|
starting: null,
|
||||||
jobDescription: "Looking for a TypeScript and React expert.",
|
jobDescription: "Looking for a TypeScript and React expert.",
|
||||||
status: "discovered",
|
status: "discovered",
|
||||||
|
outcome: null,
|
||||||
|
closedAt: null,
|
||||||
suitabilityScore: null,
|
suitabilityScore: null,
|
||||||
suitabilityReason: null,
|
suitabilityReason: null,
|
||||||
tailoredSummary: null,
|
tailoredSummary: null,
|
||||||
|
|||||||
268
orchestrator/src/server/services/applicationTracking.test.ts
Normal file
268
orchestrator/src/server/services/applicationTracking.test.ts
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
describe.sequential("Application Tracking Service", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let db: any;
|
||||||
|
let schema: any;
|
||||||
|
let applicationTracking: any;
|
||||||
|
let jobsRepo: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "job-ops-service-test-"));
|
||||||
|
process.env.DATA_DIR = tempDir;
|
||||||
|
process.env.NODE_ENV = "test";
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
await import("../db/migrate.js");
|
||||||
|
|
||||||
|
// Import modules after env is set
|
||||||
|
const dbModule = await import("../db/index.js");
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
|
||||||
|
applicationTracking = await import("./applicationTracking.js");
|
||||||
|
jobsRepo = await import("../repositories/jobs.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const { closeDb } = await import("../db/index.js");
|
||||||
|
closeDb();
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions stage and updates job status", async () => {
|
||||||
|
const job = await jobsRepo.createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Test Developer",
|
||||||
|
employer: "Tech Corp",
|
||||||
|
jobUrl: "https://example.com/job/1",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Initial Transition (Applied)
|
||||||
|
const event1 = applicationTracking.transitionStage(job.id, "applied");
|
||||||
|
|
||||||
|
expect(event1.toStage).toBe("applied");
|
||||||
|
|
||||||
|
// Check Job Status
|
||||||
|
const jobAfter1 = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, job.id))
|
||||||
|
.get();
|
||||||
|
expect(jobAfter1?.status).toBe("applied");
|
||||||
|
expect(jobAfter1?.appliedAt).toBeTruthy();
|
||||||
|
|
||||||
|
// 2. Next Transition (Recruiter Screen)
|
||||||
|
const event2 = applicationTracking.transitionStage(
|
||||||
|
job.id,
|
||||||
|
"recruiter_screen",
|
||||||
|
);
|
||||||
|
expect(event2.fromStage).toBe("applied");
|
||||||
|
expect(event2.toStage).toBe("recruiter_screen");
|
||||||
|
|
||||||
|
// Check Job Status (still applied for recruiter screen)
|
||||||
|
const jobAfter2 = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, job.id))
|
||||||
|
.get();
|
||||||
|
expect(jobAfter2?.status).toBe("applied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates stage event and reflects in job status if latest", async () => {
|
||||||
|
const job = await jobsRepo.createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Frontend Engineer",
|
||||||
|
employer: "Web Co",
|
||||||
|
jobUrl: "https://example.com/job/2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
applicationTracking.transitionStage(job.id, "applied", now - 100);
|
||||||
|
const event2 = applicationTracking.transitionStage(
|
||||||
|
job.id,
|
||||||
|
"recruiter_screen",
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update event2 (latest) to 'offer'
|
||||||
|
applicationTracking.updateStageEvent(event2.id, { toStage: "offer" });
|
||||||
|
|
||||||
|
// Verify Event Updated
|
||||||
|
const events = await applicationTracking.getStageEvents(job.id);
|
||||||
|
const updatedEvent2 = events.find((e: any) => e.id === event2.id);
|
||||||
|
expect(updatedEvent2?.toStage).toBe("offer");
|
||||||
|
|
||||||
|
// Verify Job Status Updated
|
||||||
|
const jobUpdated = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, job.id))
|
||||||
|
.get();
|
||||||
|
expect(jobUpdated?.status).toBe("applied"); // 'offer' maps to 'applied' in status (active)
|
||||||
|
expect(jobUpdated?.outcome).toBe("offer_accepted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes stage event and reverts job status", async () => {
|
||||||
|
const job = await jobsRepo.createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Backend Engineer",
|
||||||
|
employer: "Server Co",
|
||||||
|
jobUrl: "https://example.com/job/3",
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
applicationTracking.transitionStage(job.id, "applied", now - 100); // event1
|
||||||
|
|
||||||
|
// Simulate UI sending outcome for rejection
|
||||||
|
const event2 = applicationTracking.transitionStage(
|
||||||
|
job.id,
|
||||||
|
"closed",
|
||||||
|
now,
|
||||||
|
{ reasonCode: "Skills" },
|
||||||
|
"rejected",
|
||||||
|
); // event2
|
||||||
|
|
||||||
|
// Verify job is closed/rejected
|
||||||
|
let jobCheck = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, job.id))
|
||||||
|
.get();
|
||||||
|
expect(jobCheck?.status).toBe("applied");
|
||||||
|
expect(jobCheck?.outcome).toBe("rejected");
|
||||||
|
|
||||||
|
// Delete event2
|
||||||
|
applicationTracking.deleteStageEvent(event2.id);
|
||||||
|
|
||||||
|
// Verify job status reverted to event1 (applied)
|
||||||
|
jobCheck = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, job.id))
|
||||||
|
.get();
|
||||||
|
expect(jobCheck?.status).toBe("applied");
|
||||||
|
expect(jobCheck?.outcome).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles "no_change" transitions (notes)', async () => {
|
||||||
|
const job = await jobsRepo.createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "DevOps",
|
||||||
|
employer: "Cloud Inc",
|
||||||
|
jobUrl: "https://example.com/job/4",
|
||||||
|
});
|
||||||
|
|
||||||
|
applicationTracking.transitionStage(job.id, "applied");
|
||||||
|
const noteEvent = applicationTracking.transitionStage(
|
||||||
|
job.id,
|
||||||
|
"no_change",
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
note: "Just checking in",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(noteEvent.toStage).toBe("applied");
|
||||||
|
|
||||||
|
const events = await applicationTracking.getStageEvents(job.id);
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(events[1].metadata?.note).toBe("Just checking in");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates closedAt when outcome changes via event update/delete", async () => {
|
||||||
|
const job = await jobsRepo.createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "QA Engineer",
|
||||||
|
employer: "Test Labs",
|
||||||
|
jobUrl: "https://example.com/job/5",
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
applicationTracking.transitionStage(job.id, "applied", now - 100);
|
||||||
|
const event2 = applicationTracking.transitionStage(
|
||||||
|
job.id,
|
||||||
|
"closed",
|
||||||
|
now,
|
||||||
|
{ reasonCode: "Other" },
|
||||||
|
"rejected",
|
||||||
|
);
|
||||||
|
|
||||||
|
let jobCheck = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, job.id))
|
||||||
|
.get();
|
||||||
|
expect(jobCheck?.outcome).toBe("rejected");
|
||||||
|
expect(jobCheck?.closedAt).toBe(now);
|
||||||
|
|
||||||
|
// 1. Update event2 to not be a closure
|
||||||
|
applicationTracking.updateStageEvent(event2.id, {
|
||||||
|
toStage: "technical_interview",
|
||||||
|
});
|
||||||
|
jobCheck = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, job.id))
|
||||||
|
.get();
|
||||||
|
expect(jobCheck?.outcome).toBeNull();
|
||||||
|
expect(jobCheck?.closedAt).toBeNull();
|
||||||
|
|
||||||
|
// 2. Update event2 back to a closure
|
||||||
|
applicationTracking.updateStageEvent(event2.id, { toStage: "offer" });
|
||||||
|
jobCheck = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, job.id))
|
||||||
|
.get();
|
||||||
|
expect(jobCheck?.outcome).toBe("offer_accepted");
|
||||||
|
expect(jobCheck?.closedAt).toBe(now);
|
||||||
|
|
||||||
|
// 3. Delete the closure event
|
||||||
|
applicationTracking.deleteStageEvent(event2.id);
|
||||||
|
jobCheck = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, job.id))
|
||||||
|
.get();
|
||||||
|
expect(jobCheck?.outcome).toBeNull();
|
||||||
|
expect(jobCheck?.closedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicit outcome when updating metadata", async () => {
|
||||||
|
const job = await jobsRepo.createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Support Engineer",
|
||||||
|
employer: "Helpdesk Co",
|
||||||
|
jobUrl: "https://example.com/job/6",
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
applicationTracking.transitionStage(job.id, "applied", now - 100);
|
||||||
|
const closedEvent = applicationTracking.transitionStage(
|
||||||
|
job.id,
|
||||||
|
"closed",
|
||||||
|
now,
|
||||||
|
{ reasonCode: "Other" },
|
||||||
|
"withdrawn",
|
||||||
|
);
|
||||||
|
|
||||||
|
applicationTracking.updateStageEvent(closedEvent.id, {
|
||||||
|
metadata: { note: "Withdrew after offer" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobCheck = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.jobs)
|
||||||
|
.where(eq(schema.jobs.id, job.id))
|
||||||
|
.get();
|
||||||
|
expect(jobCheck?.outcome).toBe("withdrawn");
|
||||||
|
expect(jobCheck?.closedAt).toBe(now);
|
||||||
|
});
|
||||||
|
});
|
||||||
359
orchestrator/src/server/services/applicationTracking.ts
Normal file
359
orchestrator/src/server/services/applicationTracking.ts
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { and, asc, desc, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type {
|
||||||
|
ApplicationStage,
|
||||||
|
ApplicationTask,
|
||||||
|
ApplicationTaskType,
|
||||||
|
JobOutcome,
|
||||||
|
JobStatus,
|
||||||
|
StageEvent,
|
||||||
|
StageEventMetadata,
|
||||||
|
} from "../../shared/types.js";
|
||||||
|
import { db, schema } from "../db/index.js";
|
||||||
|
|
||||||
|
const { jobs, stageEvents, tasks } = schema;
|
||||||
|
|
||||||
|
const STAGE_TO_STATUS: Record<ApplicationStage, JobStatus> = {
|
||||||
|
applied: "applied",
|
||||||
|
recruiter_screen: "applied",
|
||||||
|
assessment: "applied",
|
||||||
|
hiring_manager_screen: "applied",
|
||||||
|
technical_interview: "applied",
|
||||||
|
onsite: "applied",
|
||||||
|
offer: "applied",
|
||||||
|
closed: "applied",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stageEventMetadataSchema = z
|
||||||
|
.object({
|
||||||
|
note: z.string().nullable().optional(),
|
||||||
|
actor: z.enum(["system", "user"]).optional(),
|
||||||
|
groupId: z.string().nullable().optional(),
|
||||||
|
groupLabel: z.string().nullable().optional(),
|
||||||
|
eventLabel: z.string().nullable().optional(),
|
||||||
|
externalUrl: z.string().nullable().optional(),
|
||||||
|
reasonCode: z.string().nullable().optional(),
|
||||||
|
eventType: z
|
||||||
|
.enum(["interview_log", "status_update", "note"])
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export async function getStageEvents(
|
||||||
|
applicationId: string,
|
||||||
|
): Promise<StageEvent[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(stageEvents)
|
||||||
|
.where(eq(stageEvents.applicationId, applicationId))
|
||||||
|
.orderBy(asc(stageEvents.occurredAt));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
applicationId: row.applicationId,
|
||||||
|
title: row.title,
|
||||||
|
groupId: row.groupId ?? null,
|
||||||
|
fromStage: row.fromStage as ApplicationStage | null,
|
||||||
|
toStage: row.toStage as ApplicationStage,
|
||||||
|
occurredAt: row.occurredAt,
|
||||||
|
metadata: parseMetadata(row.metadata),
|
||||||
|
outcome: (row.outcome as JobOutcome | null) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTasks(
|
||||||
|
applicationId: string,
|
||||||
|
includeCompleted = false,
|
||||||
|
): Promise<ApplicationTask[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(
|
||||||
|
includeCompleted
|
||||||
|
? eq(tasks.applicationId, applicationId)
|
||||||
|
: and(
|
||||||
|
eq(tasks.applicationId, applicationId),
|
||||||
|
eq(tasks.isCompleted, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(tasks.dueDate));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
applicationId: row.applicationId,
|
||||||
|
type: row.type as ApplicationTaskType,
|
||||||
|
title: row.title,
|
||||||
|
dueDate: row.dueDate,
|
||||||
|
isCompleted: row.isCompleted ?? false,
|
||||||
|
notes: row.notes ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transitionStage(
|
||||||
|
applicationId: string,
|
||||||
|
toStage: ApplicationStage | "no_change",
|
||||||
|
occurredAt?: number,
|
||||||
|
metadata?: StageEventMetadata | null,
|
||||||
|
outcome?: JobOutcome | null,
|
||||||
|
): StageEvent {
|
||||||
|
const parsedMetadata = metadata
|
||||||
|
? stageEventMetadataSchema.parse(metadata)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const timestamp = occurredAt ?? now;
|
||||||
|
|
||||||
|
return db.transaction((tx) => {
|
||||||
|
const job = tx.select().from(jobs).where(eq(jobs.id, applicationId)).get();
|
||||||
|
if (!job) {
|
||||||
|
throw new Error("Job not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastEvent = tx
|
||||||
|
.select()
|
||||||
|
.from(stageEvents)
|
||||||
|
.where(eq(stageEvents.applicationId, applicationId))
|
||||||
|
.orderBy(desc(stageEvents.occurredAt))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const fromStage =
|
||||||
|
(lastEvent?.toStage as ApplicationStage | undefined) ?? null;
|
||||||
|
const finalToStage =
|
||||||
|
toStage === "no_change" ? (fromStage ?? "applied") : toStage;
|
||||||
|
const eventId = randomUUID();
|
||||||
|
const isNoteEvent = parsedMetadata?.eventType === "note";
|
||||||
|
|
||||||
|
tx.insert(stageEvents)
|
||||||
|
.values({
|
||||||
|
id: eventId,
|
||||||
|
applicationId,
|
||||||
|
title: parsedMetadata?.eventLabel ?? finalToStage,
|
||||||
|
groupId: parsedMetadata?.groupId ?? null,
|
||||||
|
fromStage,
|
||||||
|
toStage: finalToStage,
|
||||||
|
occurredAt: timestamp,
|
||||||
|
metadata: parsedMetadata,
|
||||||
|
outcome,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const updates: Partial<typeof jobs.$inferInsert> = {
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (toStage !== "no_change" && !isNoteEvent) {
|
||||||
|
updates.status = STAGE_TO_STATUS[finalToStage];
|
||||||
|
|
||||||
|
if (finalToStage === "applied" && !job.appliedAt) {
|
||||||
|
updates.appliedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome) {
|
||||||
|
updates.outcome = outcome;
|
||||||
|
updates.closedAt = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.update(jobs).set(updates).where(eq(jobs.id, applicationId)).run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: eventId,
|
||||||
|
applicationId,
|
||||||
|
title: parsedMetadata?.eventLabel ?? finalToStage,
|
||||||
|
groupId: parsedMetadata?.groupId ?? null,
|
||||||
|
fromStage,
|
||||||
|
toStage: finalToStage,
|
||||||
|
occurredAt: timestamp,
|
||||||
|
metadata: parsedMetadata,
|
||||||
|
outcome: outcome ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateStageEvent(
|
||||||
|
eventId: string,
|
||||||
|
payload: {
|
||||||
|
toStage?: ApplicationStage;
|
||||||
|
occurredAt?: number;
|
||||||
|
metadata?: StageEventMetadata | null;
|
||||||
|
outcome?: JobOutcome | null;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const { toStage, occurredAt, metadata, outcome } = payload;
|
||||||
|
const parsedMetadata = metadata
|
||||||
|
? stageEventMetadataSchema.parse(metadata)
|
||||||
|
: undefined;
|
||||||
|
const hasOutcome = Object.hasOwn(payload, "outcome");
|
||||||
|
|
||||||
|
db.transaction((tx) => {
|
||||||
|
const event = tx
|
||||||
|
.select()
|
||||||
|
.from(stageEvents)
|
||||||
|
.where(eq(stageEvents.id, eventId))
|
||||||
|
.get();
|
||||||
|
if (!event) throw new Error("Event not found");
|
||||||
|
|
||||||
|
const updates: Partial<typeof stageEvents.$inferInsert> = {};
|
||||||
|
if (toStage) updates.toStage = toStage;
|
||||||
|
if (occurredAt) updates.occurredAt = occurredAt;
|
||||||
|
if (parsedMetadata !== undefined) {
|
||||||
|
updates.metadata = parsedMetadata;
|
||||||
|
if (parsedMetadata?.eventLabel) updates.title = parsedMetadata.eventLabel;
|
||||||
|
if (parsedMetadata?.groupId !== undefined)
|
||||||
|
updates.groupId = parsedMetadata.groupId;
|
||||||
|
}
|
||||||
|
if (hasOutcome) updates.outcome = outcome ?? null;
|
||||||
|
if (toStage && !hasOutcome && !isClosingStage(toStage)) {
|
||||||
|
updates.outcome = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.update(stageEvents)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(stageEvents.id, eventId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// If this was the latest event, update the job status
|
||||||
|
const lastEvent = tx
|
||||||
|
.select()
|
||||||
|
.from(stageEvents)
|
||||||
|
.where(eq(stageEvents.applicationId, event.applicationId))
|
||||||
|
.orderBy(desc(stageEvents.occurredAt))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (lastEvent && lastEvent.id === eventId) {
|
||||||
|
const job = tx
|
||||||
|
.select()
|
||||||
|
.from(jobs)
|
||||||
|
.where(eq(jobs.id, event.applicationId))
|
||||||
|
.get();
|
||||||
|
if (!job) throw new Error("Job not found");
|
||||||
|
|
||||||
|
const metadata = parseMetadata(lastEvent.metadata);
|
||||||
|
const lastStage = lastEvent.toStage as ApplicationStage;
|
||||||
|
const storedOutcome = (lastEvent.outcome as JobOutcome | null) ?? null;
|
||||||
|
const inferredOutcome = inferOutcome(lastStage, metadata);
|
||||||
|
const closingStage = isClosingStage(lastStage);
|
||||||
|
const outcome =
|
||||||
|
storedOutcome ??
|
||||||
|
inferredOutcome ??
|
||||||
|
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
|
||||||
|
const closedAt = outcome
|
||||||
|
? storedOutcome || inferredOutcome
|
||||||
|
? lastEvent.occurredAt
|
||||||
|
: (job.closedAt ?? null)
|
||||||
|
: closingStage
|
||||||
|
? (job.closedAt ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
tx.update(jobs)
|
||||||
|
.set({
|
||||||
|
status: STAGE_TO_STATUS[lastStage],
|
||||||
|
outcome,
|
||||||
|
closedAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(jobs.id, event.applicationId))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteStageEvent(eventId: string): void {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
const event = tx
|
||||||
|
.select()
|
||||||
|
.from(stageEvents)
|
||||||
|
.where(eq(stageEvents.id, eventId))
|
||||||
|
.get();
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
|
tx.delete(stageEvents).where(eq(stageEvents.id, eventId)).run();
|
||||||
|
|
||||||
|
// Update job status based on the new latest event
|
||||||
|
const lastEvent = tx
|
||||||
|
.select()
|
||||||
|
.from(stageEvents)
|
||||||
|
.where(eq(stageEvents.applicationId, event.applicationId))
|
||||||
|
.orderBy(desc(stageEvents.occurredAt))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (lastEvent) {
|
||||||
|
const job = tx
|
||||||
|
.select()
|
||||||
|
.from(jobs)
|
||||||
|
.where(eq(jobs.id, event.applicationId))
|
||||||
|
.get();
|
||||||
|
if (!job) throw new Error("Job not found");
|
||||||
|
|
||||||
|
const metadata = parseMetadata(lastEvent.metadata);
|
||||||
|
const lastStage = lastEvent.toStage as ApplicationStage;
|
||||||
|
const storedOutcome = (lastEvent.outcome as JobOutcome | null) ?? null;
|
||||||
|
const inferredOutcome = inferOutcome(lastStage, metadata);
|
||||||
|
const closingStage = isClosingStage(lastStage);
|
||||||
|
const outcome =
|
||||||
|
storedOutcome ??
|
||||||
|
inferredOutcome ??
|
||||||
|
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
|
||||||
|
const closedAt = outcome
|
||||||
|
? storedOutcome || inferredOutcome
|
||||||
|
? lastEvent.occurredAt
|
||||||
|
: (job.closedAt ?? null)
|
||||||
|
: closingStage
|
||||||
|
? (job.closedAt ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
tx.update(jobs)
|
||||||
|
.set({
|
||||||
|
status: STAGE_TO_STATUS[lastStage],
|
||||||
|
outcome,
|
||||||
|
closedAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(jobs.id, event.applicationId))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
// If no events left, maybe revert to discovered?
|
||||||
|
// For now just keep it as is or set to discovered if it was applied
|
||||||
|
tx.update(jobs)
|
||||||
|
.set({
|
||||||
|
status: "discovered",
|
||||||
|
appliedAt: null,
|
||||||
|
outcome: null,
|
||||||
|
closedAt: null,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(jobs.id, event.applicationId))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(raw: unknown): StageEventMetadata | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as StageEventMetadata;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw as StageEventMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferOutcome(
|
||||||
|
toStage: ApplicationStage,
|
||||||
|
metadata: StageEventMetadata | null,
|
||||||
|
): JobOutcome | null {
|
||||||
|
if (toStage === "offer") return "offer_accepted";
|
||||||
|
if (toStage === "closed" && metadata?.reasonCode) return "rejected";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClosingStage(toStage: ApplicationStage): boolean {
|
||||||
|
return toStage === "closed" || toStage === "offer";
|
||||||
|
}
|
||||||
@ -10,6 +10,114 @@ export type JobStatus =
|
|||||||
| "skipped" // User skipped this job
|
| "skipped" // User skipped this job
|
||||||
| "expired"; // Deadline passed
|
| "expired"; // Deadline passed
|
||||||
|
|
||||||
|
export const APPLICATION_STAGES = [
|
||||||
|
"applied",
|
||||||
|
"recruiter_screen",
|
||||||
|
"assessment",
|
||||||
|
"hiring_manager_screen",
|
||||||
|
"technical_interview",
|
||||||
|
"onsite",
|
||||||
|
"offer",
|
||||||
|
"closed",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ApplicationStage = (typeof APPLICATION_STAGES)[number];
|
||||||
|
|
||||||
|
export const STAGE_LABELS: Record<ApplicationStage, string> = {
|
||||||
|
applied: "Applied",
|
||||||
|
recruiter_screen: "Recruiter Screen",
|
||||||
|
assessment: "Assessment",
|
||||||
|
hiring_manager_screen: "Hiring Manager Screen",
|
||||||
|
technical_interview: "Technical Interview",
|
||||||
|
onsite: "Final Round",
|
||||||
|
offer: "Offer",
|
||||||
|
closed: "Closed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StageTransitionTarget = ApplicationStage | "no_change";
|
||||||
|
|
||||||
|
export const APPLICATION_OUTCOMES = [
|
||||||
|
"offer_accepted",
|
||||||
|
"offer_declined",
|
||||||
|
"rejected",
|
||||||
|
"withdrawn",
|
||||||
|
"no_response",
|
||||||
|
"ghosted",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type JobOutcome = (typeof APPLICATION_OUTCOMES)[number];
|
||||||
|
|
||||||
|
export const APPLICATION_TASK_TYPES = [
|
||||||
|
"prep",
|
||||||
|
"todo",
|
||||||
|
"follow_up",
|
||||||
|
"check_status",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ApplicationTaskType = (typeof APPLICATION_TASK_TYPES)[number];
|
||||||
|
|
||||||
|
export const INTERVIEW_TYPES = [
|
||||||
|
"recruiter_screen",
|
||||||
|
"technical",
|
||||||
|
"onsite",
|
||||||
|
"panel",
|
||||||
|
"behavioral",
|
||||||
|
"final",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type InterviewType = (typeof INTERVIEW_TYPES)[number];
|
||||||
|
|
||||||
|
export const INTERVIEW_OUTCOMES = [
|
||||||
|
"pass",
|
||||||
|
"fail",
|
||||||
|
"pending",
|
||||||
|
"cancelled",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type InterviewOutcome = (typeof INTERVIEW_OUTCOMES)[number];
|
||||||
|
|
||||||
|
export interface StageEventMetadata {
|
||||||
|
note?: string | null;
|
||||||
|
actor?: "system" | "user";
|
||||||
|
groupId?: string | null;
|
||||||
|
groupLabel?: string | null;
|
||||||
|
eventLabel?: string | null;
|
||||||
|
externalUrl?: string | null;
|
||||||
|
reasonCode?: string | null;
|
||||||
|
eventType?: "interview_log" | "status_update" | "note" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageEvent {
|
||||||
|
id: string;
|
||||||
|
applicationId: string;
|
||||||
|
title: string;
|
||||||
|
groupId: string | null;
|
||||||
|
fromStage: ApplicationStage | null;
|
||||||
|
toStage: ApplicationStage;
|
||||||
|
occurredAt: number;
|
||||||
|
metadata: StageEventMetadata | null;
|
||||||
|
outcome: JobOutcome | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplicationTask {
|
||||||
|
id: string;
|
||||||
|
applicationId: string;
|
||||||
|
type: ApplicationTaskType;
|
||||||
|
title: string;
|
||||||
|
dueDate: number | null;
|
||||||
|
isCompleted: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interview {
|
||||||
|
id: string;
|
||||||
|
applicationId: string;
|
||||||
|
scheduledAt: number;
|
||||||
|
durationMins: number | null;
|
||||||
|
type: InterviewType;
|
||||||
|
outcome: InterviewOutcome | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type JobSource =
|
export type JobSource =
|
||||||
| "gradcracker"
|
| "gradcracker"
|
||||||
| "indeed"
|
| "indeed"
|
||||||
@ -42,6 +150,8 @@ export interface Job {
|
|||||||
|
|
||||||
// Orchestrator enrichments
|
// Orchestrator enrichments
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
|
outcome: JobOutcome | null;
|
||||||
|
closedAt: number | null;
|
||||||
suitabilityScore: number | null; // 0-100 AI-generated score
|
suitabilityScore: number | null; // 0-100 AI-generated score
|
||||||
suitabilityReason: string | null; // AI explanation
|
suitabilityReason: string | null; // AI explanation
|
||||||
tailoredSummary: string | null; // Generated resume summary
|
tailoredSummary: string | null; // Generated resume summary
|
||||||
@ -161,6 +271,8 @@ export interface ManualJobFetchResponse {
|
|||||||
|
|
||||||
export interface UpdateJobInput {
|
export interface UpdateJobInput {
|
||||||
status?: JobStatus;
|
status?: JobStatus;
|
||||||
|
outcome?: JobOutcome | null;
|
||||||
|
closedAt?: number | null;
|
||||||
jobDescription?: string;
|
jobDescription?: string;
|
||||||
suitabilityScore?: number;
|
suitabilityScore?: number;
|
||||||
suitabilityReason?: string;
|
suitabilityReason?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user