Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# JobSync

> **Note:** This is a modified deployment version of the original [JobSync](https://github.com/Gsync/jobsync) project.
<p align="center"><strong>The self-hosted job search assistant with AI-powered resume review, job matching, and automated discovery</strong></p>

<p align="center">
Expand Down
150 changes: 150 additions & 0 deletions __tests__/schedulerRunner.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 25 additions & 4 deletions src/actions/job/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export const getJobsList = async (
titleValue?: string,
locationValue?: string,
sourceValue?: string,
sortKey: string = "date-desc"
): Promise<any | undefined> => {
try {
const user = await requireUser();
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/components/myjobs/JobsContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,15 @@ function JobsContainer({
onChangeViewMode,
totalJobs,
filterKey,
sortKey,
searchTerm,
setSearchTerm,
initialLoading,
loadingMore,
loadJobs,
reloadJobs,
onFilterChange,
onSortChange,
sentinelRef,
} = useJobsList({
companyFilter,
Expand Down Expand Up @@ -148,6 +150,8 @@ function JobsContainer({
onSearchTermChange={setSearchTerm}
filterKey={filterKey}
onFilterChange={onFilterChange}
sortKey={sortKey}
onSortChange={onSortChange}
onDownload={downloadJobsList}
statuses={statuses}
companies={companies}
Expand Down
24 changes: 23 additions & 1 deletion src/components/myjobs/jobs-container/JobsToolbar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -48,6 +48,8 @@ export function JobsToolbar({
onSearchTermChange,
filterKey,
onFilterChange,
sortKey,
onSortChange,
onDownload,
statuses,
companies,
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -175,6 +179,24 @@ export function JobsToolbar({
</SelectGroup>
</SelectContent>
</Select>
<Select value={sortKey} onValueChange={onSortChange}>
<SelectTrigger className="w-[140px] h-8" data-testid="job-sort-select">
<ArrowDownAZ className="h-3.5 w-3.5" />
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Sort by</SelectLabel>
<SelectSeparator />
<SelectItem value="date-desc">Newest First</SelectItem>
<SelectItem value="date-asc">Oldest First</SelectItem>
<SelectItem value="company-asc">Company (A-Z)</SelectItem>
<SelectItem value="company-desc">Company (Z-A)</SelectItem>
<SelectItem value="title-asc">Job Title (A-Z)</SelectItem>
<SelectItem value="title-desc">Job Title (Z-A)</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
Expand Down
25 changes: 18 additions & 7 deletions src/components/myjobs/jobs-container/useJobsList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function useJobsList({
const [page, setPage] = useState(1);
const [totalJobs, setTotalJobs] = useState(0);
const [filterKey, setFilterKey] = useState<string>("none");
const [sortKey, setSortKey] = useState<string>("date-desc");
const [searchTerm, setSearchTerm] = useState("");
const [initialLoading, setInitialLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
Expand All @@ -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(
Expand All @@ -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]));
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 },
Expand All @@ -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 {
Expand All @@ -167,13 +176,15 @@ export function useJobsList({
page,
totalJobs,
filterKey,
sortKey,
searchTerm,
setSearchTerm,
initialLoading,
loadingMore,
loadJobs,
reloadJobs,
onFilterChange,
onSortChange,
sentinelRef,
};
}
2 changes: 1 addition & 1 deletion src/lib/scheduler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`);

Expand Down