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({
+
+
+
+
+
+
+
+ Sort by
+
+ Newest First
+ Oldest First
+ Company (A-Z)
+ Company (Z-A)
+ Job Title (A-Z)
+ Job Title (Z-A)
+
+
+
("none");
+ const [sortKey, setSortKey] = useState("date-desc");
const [searchTerm, setSearchTerm] = useState("");
const [initialLoading, setInitialLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
@@ -55,7 +56,7 @@ export function useJobsList({
const jobsPerPage = APP_CONSTANTS.RECORDS_PER_PAGE;
const loadJobs = useCallback(
- async (page: number, filter?: string, search?: string) => {
+ async (page: number, filter?: string, search?: string, sort?: string) => {
if (page === 1) setInitialLoading(true);
else setLoadingMore(true);
const { success, data, total, message } = await getJobsList(
@@ -68,6 +69,7 @@ export function useJobsList({
titleFilter || undefined,
locationFilter || undefined,
sourceFilter || undefined,
+ sort || "date-desc"
);
if (success && data) {
setJobs((prev) => (page === 1 ? data : [...prev, ...data]));
@@ -90,14 +92,15 @@ export function useJobsList({
);
const reloadJobs = useCallback(async () => {
- await loadJobs(1, undefined, searchTerm || undefined);
+ await loadJobs(1, undefined, searchTerm || undefined, sortKey);
if (filterKey !== "none") {
setFilterKey("none");
}
- }, [loadJobs, filterKey, searchTerm]);
+ }, [loadJobs, filterKey, searchTerm, sortKey]);
useEffect(() => {
- (async () => await loadJobs(1))();
+ (async () => await loadJobs(1, filterKey, searchTerm || undefined, sortKey))();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadJobs]);
// The agent saves the job server-side, so only this counter tells us a row
@@ -117,7 +120,7 @@ export function useJobsList({
if (searchTerm === "" && !hasSearched.current) return;
const timer = setTimeout(() => {
- loadJobs(1, filterKey, searchTerm || undefined);
+ loadJobs(1, filterKey, searchTerm || undefined, sortKey);
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -136,7 +139,7 @@ export function useJobsList({
!loadingMore &&
jobs.length < totalJobs
) {
- loadJobs(page + 1, filterKey, searchTerm || undefined);
+ loadJobs(page + 1, filterKey, searchTerm || undefined, sortKey);
}
},
{ threshold: APP_CONSTANTS.INTERSECTION_OBSERVER_THRESHOLD },
@@ -153,11 +156,17 @@ export function useJobsList({
initialLoading,
loadingMore,
loadJobs,
+ sortKey,
]);
const onFilterChange = (filterBy: string) => {
setFilterKey(filterBy);
- loadJobs(1, filterBy, searchTerm || undefined);
+ loadJobs(1, filterBy, searchTerm || undefined, sortKey);
+ };
+
+ const onSortChange = (sortBy: string) => {
+ setSortKey(sortBy);
+ loadJobs(1, filterKey, searchTerm || undefined, sortBy);
};
return {
@@ -167,6 +176,7 @@ export function useJobsList({
page,
totalJobs,
filterKey,
+ sortKey,
searchTerm,
setSearchTerm,
initialLoading,
@@ -174,6 +184,7 @@ export function useJobsList({
loadJobs,
reloadJobs,
onFilterChange,
+ onSortChange,
sentinelRef,
};
}
diff --git a/src/lib/scheduler/index.ts b/src/lib/scheduler/index.ts
index 5edc5fcd9..e6f6bc558 100644
--- a/src/lib/scheduler/index.ts
+++ b/src/lib/scheduler/index.ts
@@ -6,7 +6,7 @@ import type { JobBoard } from "@/models/automation.model";
let scheduledTask: ScheduledTask | null = null;
-async function runDueAutomations() {
+export async function runDueAutomations() {
const now = new Date();
console.log(`[Scheduler] Checking for due automations at ${now.toISOString()}`);