diff --git a/README.md b/README.md index 600889941..78b86e911 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # JobSync +> **Note:** This is a modified deployment version of the original [JobSync](https://github.com/Gsync/jobsync) project.

The self-hosted job search assistant with AI-powered resume review, job matching, and automated discovery

diff --git a/__tests__/schedulerRunner.spec.ts b/__tests__/schedulerRunner.spec.ts new file mode 100644 index 000000000..431a85a75 --- /dev/null +++ b/__tests__/schedulerRunner.spec.ts @@ -0,0 +1,150 @@ +import { PrismaClient } from "@prisma/client"; +import { runDueAutomations } from "@/lib/scheduler"; +import { runAutomation, AutomationAlreadyRunningError } from "@/lib/scraper"; + +const prisma = new PrismaClient(); + +vi.mock("node-cron", () => { + return { + default: { schedule: vi.fn(), validate: vi.fn() }, + }; +}); + +vi.mock("@prisma/client", () => { + const m = { + automation: { findMany: vi.fn() }, + automationRun: { create: vi.fn(), findFirst: vi.fn() }, + }; + return { + PrismaClient: vi.fn(function () { + return m; + }), + }; +}); + +vi.mock("@/lib/scraper", () => { + return { + runAutomation: vi.fn(), + AutomationAlreadyRunningError: class extends Error { + constructor(id: string) { + super(id); + this.name = "AutomationAlreadyRunningError"; + } + }, + }; +}); + +describe("runDueAutomations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns early if no automations are due", async () => { + (prisma.automation.findMany as any).mockResolvedValue([]); + await runDueAutomations(); + + expect(prisma.automation.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: "active", + nextRunAt: expect.objectContaining({ + lte: expect.any(Date) + }) + }) + }) + ); + expect(runAutomation).not.toHaveBeenCalled(); + expect(prisma.automationRun.create).not.toHaveBeenCalled(); + }); + + it("skips automation if it has no resume", async () => { + (prisma.automation.findMany as any).mockResolvedValue([{ id: "auto-1", resume: null }]); + (prisma.automationRun.findFirst as any).mockResolvedValue(null); + + await runDueAutomations(); + + expect(prisma.automationRun.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + automationId: "auto-1", + status: "failed", + errorMessage: "resume_missing", + }), + }) + ); + expect(runAutomation).not.toHaveBeenCalled(); + }); + + it("skips automation if a run is already in progress (optimistic check)", async () => { + (prisma.automation.findMany as any).mockResolvedValue([{ id: "auto-2", resume: { id: "res-1" } }]); + (prisma.automationRun.findFirst as any).mockResolvedValue({ id: "run-1" }); + + await runDueAutomations(); + + expect(runAutomation).not.toHaveBeenCalled(); + }); + + it("handles concurrent automation by catching AutomationAlreadyRunningError", async () => { + (prisma.automation.findMany as any).mockResolvedValue([ + { id: "auto-3", name: "Concurrent", resume: { id: "res-1" } }, + { id: "auto-4", name: "Next", resume: { id: "res-1" } } + ]); + (prisma.automationRun.findFirst as any).mockResolvedValue(null); + + (runAutomation as any) + .mockRejectedValueOnce(new AutomationAlreadyRunningError("auto-3")) + .mockResolvedValueOnce({ status: "success", jobsSaved: 0 }); + + await runDueAutomations(); + expect(runAutomation).toHaveBeenCalledTimes(2); + expect(prisma.automationRun.create).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + automationId: "auto-3", + status: "failed", + }), + }) + ); + }); + + it("processes successful run", async () => { + (prisma.automation.findMany as any).mockResolvedValue([ + { + id: "auto-4", + userId: "user-1", + jobBoard: "greenhouse", + matchThreshold: 80, + resumeId: "res-1", + resume: { id: "res-1" } + }, + ]); + (prisma.automationRun.findFirst as any).mockResolvedValue(null); + (runAutomation as any).mockResolvedValue({ status: "success", jobsSaved: 2 }); + + await runDueAutomations(); + + expect(runAutomation).toHaveBeenCalledWith( + expect.objectContaining({ + id: "auto-4", + userId: "user-1", + resumeId: "res-1", + jobBoard: "greenhouse", + matchThreshold: 80, + }) + ); + }); + + it("handles failed run by catching generic error and continuing", async () => { + (prisma.automation.findMany as any).mockResolvedValue([ + { id: "auto-5", name: "Failed", resume: { id: "res-1" } }, + { id: "auto-6", name: "Continuing", resume: { id: "res-1" } }, + ]); + (prisma.automationRun.findFirst as any).mockResolvedValue(null); + (runAutomation as any) + .mockRejectedValueOnce(new Error("Network Error")) + .mockResolvedValueOnce({ status: "success", jobsSaved: 0 }); + + await runDueAutomations(); + expect(runAutomation).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/actions/job/queries.ts b/src/actions/job/queries.ts index 8a5080175..bbf169a01 100644 --- a/src/actions/job/queries.ts +++ b/src/actions/job/queries.ts @@ -161,6 +161,7 @@ export const getJobsList = async ( titleValue?: string, locationValue?: string, sourceValue?: string, + sortKey: string = "date-desc" ): Promise => { try { const user = await requireUser(); @@ -176,16 +177,36 @@ export const getJobsList = async ( sourceValue, }); + let orderBy: any = { createdAt: "desc" }; + switch (sortKey) { + case "date-asc": + orderBy = { createdAt: "asc" }; + break; + case "company-asc": + orderBy = { Company: { label: "asc" } }; + break; + case "company-desc": + orderBy = { Company: { label: "desc" } }; + break; + case "title-asc": + orderBy = { JobTitle: { label: "asc" } }; + break; + case "title-desc": + orderBy = { JobTitle: { label: "desc" } }; + break; + case "date-desc": + default: + orderBy = { createdAt: "desc" }; + break; + } + const [data, total] = await Promise.all([ prisma.job.findMany({ where: whereClause, skip, take: limit, select: JOB_LIST_SELECT, - orderBy: { - createdAt: "desc", - // appliedDate: "desc", - }, + orderBy, }), prisma.job.count({ where: whereClause, diff --git a/src/components/myjobs/JobsContainer.tsx b/src/components/myjobs/JobsContainer.tsx index 856e02c15..6f0b5bb0e 100644 --- a/src/components/myjobs/JobsContainer.tsx +++ b/src/components/myjobs/JobsContainer.tsx @@ -71,6 +71,7 @@ function JobsContainer({ onChangeViewMode, totalJobs, filterKey, + sortKey, searchTerm, setSearchTerm, initialLoading, @@ -78,6 +79,7 @@ function JobsContainer({ loadJobs, reloadJobs, onFilterChange, + onSortChange, sentinelRef, } = useJobsList({ companyFilter, @@ -148,6 +150,8 @@ function JobsContainer({ onSearchTermChange={setSearchTerm} filterKey={filterKey} onFilterChange={onFilterChange} + sortKey={sortKey} + onSortChange={onSortChange} onDownload={downloadJobsList} statuses={statuses} companies={companies} diff --git a/src/components/myjobs/jobs-container/JobsToolbar.tsx b/src/components/myjobs/jobs-container/JobsToolbar.tsx index cab79b7fb..b93f7af38 100644 --- a/src/components/myjobs/jobs-container/JobsToolbar.tsx +++ b/src/components/myjobs/jobs-container/JobsToolbar.tsx @@ -1,5 +1,5 @@ "use client"; -import { File, ListFilter, RefreshCw, X } from "lucide-react"; +import { File, ListFilter, RefreshCw, X, ArrowDownAZ } from "lucide-react"; import { CardHeader, CardTitle } from "../../ui/card"; import { Button } from "../../ui/button"; import { SearchInput } from "../../SearchInput"; @@ -48,6 +48,8 @@ export function JobsToolbar({ onSearchTermChange, filterKey, onFilterChange, + sortKey, + onSortChange, onDownload, statuses, companies, @@ -77,6 +79,8 @@ export function JobsToolbar({ onSearchTermChange: (value: string) => void; filterKey: string; onFilterChange: (filterBy: string) => void; + sortKey: string; + onSortChange: (sortBy: string) => void; onDownload: () => void; statuses: JobStatus[]; companies: Company[]; @@ -175,6 +179,24 @@ export function JobsToolbar({ +