Merge pull request 'feat: undo job actions + AI Engineer search profile' (#4) from feat/undo-and-ai-search-profile into main
All checks were successful
CI / skip-ci-check (push) Successful in 29s
CI / secret-scan (push) Successful in 14s
CI / docker-ci (push) Successful in 17s

This commit is contained in:
ilia 2026-07-19 09:21:32 -05:00
commit 6c68155c9d
22 changed files with 710 additions and 55 deletions

View File

@ -45,7 +45,7 @@ During a pipeline run, if a new posting matches an existing row by URL, source i
Open jobs that match a prior skip or apply are **hidden** from Discovered, Ready, and All tabs so the queue stays fresh. Skipped and applied rows themselves remain visible in their statuses.
Use **Filters → Employer keywords → Hide roles you already skipped** to toggle this behavior (on by default). When enabled, JobOps compares **company + job title** only:
Use **Filters → Employer keywords → Hide roles you already skipped** to toggle this behavior (off by default). When enabled, JobOps compares **company + job title** only:
- Same company after normalization (legal suffixes stripped; short names like `CGI` match longer forms like `CGI IT UK Limited`).
- Same title after normalization (including simple plural variants such as `Engineer` vs `Engineers`).

View File

@ -645,6 +645,15 @@ export async function updateJob(
});
}
export async function restoreJobStatuses(
restores: Array<{ jobId: string; status: Job["status"] }>,
): Promise<{ restored: number; jobs: Job[] }> {
return fetchApi<{ restored: number; jobs: Job[] }>("/jobs/restore-status", {
method: "POST",
body: JSON.stringify({ restores }),
});
}
export async function getTracerAnalytics(options?: {
jobId?: string;
from?: number;

View File

@ -25,6 +25,7 @@ import {
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { toastWithUndo } from "@/client/lib/jobActionUndo";
import { Button, buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
@ -135,8 +136,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
const handleUndoApplied = useCallback(
async (jobId: string) => {
try {
// Revert to ready status
await api.updateJob(jobId, { status: "ready" });
await api.restoreJobStatuses([{ jobId, status: "ready" }]);
trackProductEvent("jobs_job_action_completed", {
action: "move_to_ready",
result: "success",
@ -199,9 +199,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
description: `${job.title} at ${job.employer}`,
action: {
label: "Undo",
onClick: () => handleUndoApplied(job.id),
onClick: () => void handleUndoApplied(job.id),
},
duration: 6000,
duration: 8000,
});
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
@ -254,14 +254,19 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
if (!job) return;
try {
await skipJobMutation.mutateAsync(job.id);
const { restores } = await skipJobMutation.mutateAsync(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "success",
from_status: job.status,
to_status: "skipped",
});
toast.message("Job skipped");
toastWithUndo({
title: "Job skipped",
restores,
onRestored: onJobUpdated,
successMessage: "Skip undone",
});
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {

View File

@ -6,6 +6,7 @@ import type { Job } from "@shared/types.js";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { toastMoveToReadyUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
import { trackProductEvent } from "@/lib/analytics";
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
import { DecideMode } from "./DecideMode";
@ -63,14 +64,19 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
if (!job) return;
try {
setIsSkipping(true);
await skipJobMutation.mutateAsync(job.id);
const { restores } = await skipJobMutation.mutateAsync(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "success",
from_status: job.status,
to_status: "skipped",
});
toast.message("Job skipped");
toastWithUndo({
title: "Job skipped",
restores,
onRestored: onJobUpdated,
successMessage: "Skip undone",
});
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {
@ -100,8 +106,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
to_status: "ready",
});
toast.success("Job moved to Ready", {
toastMoveToReadyUndo({
jobId: job.id,
description: "Your tailored PDF has been generated.",
onRestored: onJobUpdated,
});
onJobMoved(job.id);

View File

@ -46,7 +46,28 @@ export function useMarkAsAppliedMutation() {
export function useSkipJobMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.skipJob(id),
mutationFn: async (id: string) => {
const response = await api.runJobAction({
action: "skip",
jobIds: [id],
});
const result = response.results.find((entry) => entry.jobId === id);
if (!result?.ok) {
throw new Error(
result && !result.ok
? result.error.message
: "Job action did not return a result for the job",
);
}
const restores: Array<{ jobId: string; status: Job["status"] }> = [
{
jobId: id,
status: result.undo?.previousStatus ?? "discovered",
},
...(result.undo?.alsoSkipped ?? []),
];
return { job: result.job, restores };
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) });
const previousJob = queryClient.getQueryData<Job>(

View File

@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
message: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@client/api", () => ({
restoreJobStatuses: vi.fn(async () => ({ restored: 1, jobs: [] })),
}));
import * as api from "@client/api";
import { toast } from "sonner";
import {
restoresFromSkipResults,
toastWithUndo,
} from "./jobActionUndo";
describe("restoresFromSkipResults", () => {
it("includes primary and also-skipped restores", () => {
const restores = restoresFromSkipResults([
{
jobId: "a",
ok: true,
job: { id: "a" } as never,
undo: {
previousStatus: "ready",
alsoSkipped: [{ jobId: "b", status: "discovered" }],
},
},
{ jobId: "c", ok: false, error: { code: "X", message: "nope" } },
]);
expect(restores).toEqual([
{ jobId: "a", status: "ready" },
{ jobId: "b", status: "discovered" },
]);
});
});
describe("toastWithUndo", () => {
it("wires Undo action to restoreJobStatuses", async () => {
toastWithUndo({
title: "Job skipped",
restores: [{ jobId: "j1", status: "discovered" }],
});
expect(toast.message).toHaveBeenCalled();
const call = vi.mocked(toast.message).mock.calls[0];
const opts = call?.[1] as {
action?: { onClick: () => void };
};
expect(opts?.action?.label ?? opts?.action).toBeTruthy();
opts?.action?.onClick();
await vi.waitFor(() => {
expect(api.restoreJobStatuses).toHaveBeenCalledWith([
{ jobId: "j1", status: "discovered" },
]);
});
});
});

View File

@ -0,0 +1,146 @@
import * as api from "@client/api";
import type { JobActionResult, JobStatus, JobStatusRestore } from "@shared/types";
import { toast } from "sonner";
const DEFAULT_UNDO_MS = 8_000;
export type StatusRestore = JobStatusRestore;
/** Collect restore snapshots from skip action results (includes auto-skipped dupes). */
export function restoresFromSkipResults(
results: JobActionResult[],
): StatusRestore[] {
const byId = new Map<string, StatusRestore>();
for (const result of results) {
if (!result.ok || !result.undo) continue;
byId.set(result.jobId, {
jobId: result.jobId,
status: result.undo.previousStatus,
});
for (const also of result.undo.alsoSkipped ?? []) {
if (!byId.has(also.jobId)) {
byId.set(also.jobId, also);
}
}
}
return Array.from(byId.values());
}
export async function restoreJobStatuses(
restores: StatusRestore[],
): Promise<void> {
if (restores.length === 0) return;
await api.restoreJobStatuses(restores);
}
/**
* Show a success/message toast with an Undo action that restores prior statuses.
*/
export function toastWithUndo(input: {
title: string;
description?: string;
restores: StatusRestore[];
variant?: "success" | "message";
durationMs?: number;
onRestored?: () => void | Promise<void>;
successMessage?: string;
}): void {
const {
title,
description,
restores,
variant = "message",
durationMs = DEFAULT_UNDO_MS,
onRestored,
successMessage = restores.length === 1
? "Undone"
: `Restored ${restores.length} jobs`,
} = input;
if (restores.length === 0) {
if (variant === "success") {
toast.success(title, { description, duration: durationMs });
} else {
toast.message(title, { description, duration: durationMs });
}
return;
}
const action = {
label: "Undo",
onClick: () => {
void (async () => {
try {
await restoreJobStatuses(restores);
toast.success(successMessage);
await onRestored?.();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to undo";
toast.error(message);
}
})();
},
};
if (variant === "success") {
toast.success(title, { description, action, duration: durationMs });
} else {
toast.message(title, { description, action, duration: durationMs });
}
}
/** Convenience for a single skip with known previous status (when action result undo is unavailable). */
export function toastSkipUndo(input: {
jobId: string;
previousStatus: JobStatus;
title?: string;
description?: string;
alsoSkipped?: StatusRestore[];
onRestored?: () => void | Promise<void>;
}): void {
const restores: StatusRestore[] = [
{ jobId: input.jobId, status: input.previousStatus },
...(input.alsoSkipped ?? []),
];
toastWithUndo({
title: input.title ?? "Job skipped",
description: input.description,
restores,
variant: "message",
onRestored: input.onRestored,
successMessage: "Skip undone",
});
}
export function toastAppliedUndo(input: {
jobId: string;
title?: string;
description?: string;
onRestored?: () => void | Promise<void>;
}): void {
toastWithUndo({
title: input.title ?? "Marked as applied",
description: input.description,
restores: [{ jobId: input.jobId, status: "ready" }],
variant: "success",
onRestored: input.onRestored,
successMessage: "Reverted to Ready",
});
}
export function toastMoveToReadyUndo(input: {
jobId: string;
title?: string;
description?: string;
onRestored?: () => void | Promise<void>;
}): void {
toastWithUndo({
title: input.title ?? "Job moved to Ready",
description: input.description,
restores: [{ jobId: input.jobId, status: "discovered" }],
variant: "success",
onRestored: input.onRestored,
successMessage: "Moved back to Discovered",
});
}

View File

@ -27,6 +27,7 @@ import {
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { toastAppliedUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
import { invalidateJobData } from "@/client/hooks/queries/invalidate";
import {
useCheckSponsorMutation,
@ -257,7 +258,11 @@ export const JobPage: React.FC = () => {
await runAction("mark-applied", async () => {
if (!job) return;
await markAsAppliedMutation.mutateAsync(job.id);
toast.success("Marked as applied");
toastAppliedUndo({
jobId: job.id,
description: `${job.title} at ${job.employer}`,
onRestored: loadData,
});
});
};
@ -268,15 +273,26 @@ export const JobPage: React.FC = () => {
id: job.id,
update: { status: "in_progress" },
});
toast.success("Moved to in progress");
toastWithUndo({
title: "Moved to in progress",
restores: [{ jobId: job.id, status: "applied" }],
variant: "success",
onRestored: loadData,
successMessage: "Reverted to Applied",
});
});
};
const handleSkip = async () => {
await runAction("skip", async () => {
if (!job) return;
await skipJobMutation.mutateAsync(job.id);
toast.message("Job skipped");
const { restores } = await skipJobMutation.mutateAsync(job.id);
toastWithUndo({
title: "Job skipped",
restores,
onRestored: loadData,
successMessage: "Skip undone",
});
});
};

View File

@ -34,6 +34,24 @@ vi.mock("../api", () => ({
}),
getProfile: vi.fn().mockResolvedValue({ personName: "Test User" }),
skipJob: vi.fn().mockResolvedValue({}),
runJobAction: vi.fn().mockImplementation(async (input: { action: string; jobIds: string[] }) => {
const jobId = input.jobIds[0] ?? "job-1";
return {
action: input.action,
requested: 1,
succeeded: 1,
failed: 0,
results: [
{
jobId,
ok: true,
job: { id: jobId, status: "skipped" },
undo: { previousStatus: "ready", alsoSkipped: [] },
},
],
};
}),
restoreJobStatuses: vi.fn().mockResolvedValue({ restored: 1, jobs: [] }),
markAsApplied: vi.fn().mockResolvedValue({}),
processJob: vi.fn().mockResolvedValue({}),
}));
@ -1096,8 +1114,16 @@ describe("OrchestratorPage", () => {
pressKey("s");
await waitFor(() => {
expect(api.skipJob).toHaveBeenCalledWith("job-1");
expect(toast.message).toHaveBeenCalledWith("Job skipped");
expect(api.runJobAction).toHaveBeenCalledWith({
action: "skip",
jobIds: ["job-1"],
});
expect(toast.message).toHaveBeenCalledWith(
"Job skipped",
expect.objectContaining({
action: expect.objectContaining({ label: "Undo" }),
}),
);
});
pressKey("a");
@ -1105,7 +1131,9 @@ describe("OrchestratorPage", () => {
expect(api.markAsApplied).toHaveBeenCalledWith("job-1");
expect(toast.success).toHaveBeenCalledWith(
"Marked as applied",
expect.anything(),
expect.objectContaining({
action: expect.objectContaining({ label: "Undo" }),
}),
);
});
@ -1127,6 +1155,12 @@ describe("OrchestratorPage", () => {
await waitFor(() => {
expect(toast.message).toHaveBeenCalledWith("Moving job to Ready...");
expect(api.processJob).toHaveBeenCalledWith("job-2");
expect(toast.success).toHaveBeenCalledWith(
"Job moved to Ready",
expect.objectContaining({
action: expect.objectContaining({ label: "Undo" }),
}),
);
});
});

View File

@ -1,6 +1,5 @@
import { useSettings } from "@client/hooks/useSettings";
import { buildDuplicateDismissHints } from "@client/lib/job-dedup";
import { inferCountryKeyFromSearchGeography } from "@shared/search-cities";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
@ -168,12 +167,6 @@ export const OrchestratorPage: React.FC = () => {
[settings],
);
const searchGeographyCountryKey = useMemo(
() =>
inferCountryKeyFromSearchGeography(settings?.searchCities?.value ?? null),
[settings?.searchCities?.value],
);
const duplicateDismissHints = useMemo(
() => (hidePriorSkips ? buildDuplicateDismissHints(jobs) : undefined),
[hidePriorSkips, jobs],
@ -188,7 +181,7 @@ export const OrchestratorPage: React.FC = () => {
settingsBlockedEmployerKeywords: applySettingsCompanySkipList
? settingsSkipEmployerKeywords
: [],
searchGeographyCountryKey,
searchGeographyCountryKey: null,
duplicateDismissHints,
}),
[
@ -198,7 +191,6 @@ export const OrchestratorPage: React.FC = () => {
employerExcludeFilter,
applySettingsCompanySkipList,
settingsSkipEmployerKeywords,
searchGeographyCountryKey,
duplicateDismissHints,
],
);

View File

@ -33,6 +33,7 @@ import {
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { toastAppliedUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
import { getRenderableJobDescription } from "@/client/lib/jobDescription";
import { Button } from "@/components/ui/button";
@ -261,7 +262,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
from_status: selectedJob.status,
to_status: "applied",
});
toast.success("Marked as applied");
toastAppliedUndo({
jobId: selectedJob.id,
description: `${selectedJob.title} at ${selectedJob.employer}`,
onRestored: onJobUpdated,
});
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
@ -279,14 +284,19 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const handleSkip = async () => {
if (!selectedJob) return;
try {
await skipJobMutation.mutateAsync(selectedJob.id);
const { restores } = await skipJobMutation.mutateAsync(selectedJob.id);
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "success",
from_status: selectedJob.status,
to_status: "skipped",
});
toast.message("Job skipped");
toastWithUndo({
title: "Job skipped",
restores,
onRestored: onJobUpdated,
successMessage: "Skip undone",
});
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
@ -304,14 +314,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const handleMoveToInProgress = async () => {
if (!selectedJob) return;
try {
const fromStatus = selectedJob.status;
await api.updateJob(selectedJob.id, { status: "in_progress" });
trackProductEvent("jobs_job_action_completed", {
action: "move_in_progress",
result: "success",
from_status: selectedJob.status,
from_status: fromStatus,
to_status: "in_progress",
});
toast.success("Moved to in progress");
toastWithUndo({
title: "Moved to in progress",
restores: [{ jobId: selectedJob.id, status: fromStatus === "applied" ? "applied" : "applied" }],
variant: "success",
onRestored: onJobUpdated,
successMessage: "Reverted to Applied",
});
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {

View File

@ -257,7 +257,7 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
employerIncludeFilter.length > 0 || employerExcludeFilter.length > 0,
) +
Number(!applySettingsCompanySkipList) +
Number(!hidePriorSkips) +
Number(hidePriorSkips) +
Number(sponsorFilter !== "all") +
Number(sponsorshipSignalsFilter.length > 0) +
Number(hideSponsorBlockers) +

View File

@ -7,6 +7,7 @@ import {
} from "@shared/types.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { restoresFromSkipResults, toastWithUndo } from "@/client/lib/jobActionUndo";
import { trackProductEvent } from "@/lib/analytics";
import type { FilterTab } from "./constants";
import { JobActionProgressToast } from "./JobActionProgressToast";
@ -250,7 +251,30 @@ export function useJobSelectionActions({
});
if (result.failed === 0) {
toast.success(`${result.succeeded} ${successLabel}`);
if (action === "skip") {
const restores = restoresFromSkipResults(result.results);
toastWithUndo({
title: `${result.succeeded} ${successLabel}`,
restores,
variant: "success",
onRestored: loadJobs,
successMessage: "Skip undone",
});
} else if (action === "move_to_ready") {
const restores = selectedAtStart.map((jobId) => ({
jobId,
status: "discovered" as const,
}));
toastWithUndo({
title: `${result.succeeded} ${successLabel}`,
restores,
variant: "success",
onRestored: loadJobs,
successMessage: "Moved back to Discovered",
});
} else {
toast.success(`${result.succeeded} ${successLabel}`);
}
} else {
toast.error(
`${result.succeeded} succeeded, ${result.failed} failed.`,

View File

@ -9,6 +9,7 @@ import { SHORTCUTS } from "@client/lib/shortcut-map";
import type { JobAction, JobListItem } from "@shared/types.js";
import { useCallback, useRef } from "react";
import { toast } from "sonner";
import { toastAppliedUndo, toastMoveToReadyUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
import { safeFilenamePart } from "@/lib/utils";
import type { FilterTab } from "./constants";
import { tabs } from "./constants";
@ -145,10 +146,18 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
if (!selectedJob) return;
shortcutActionInFlight.current = true;
const jobId = selectedJob.id;
const previousStatus = selectedJob.status;
skipJobMutation
.mutateAsync(jobId)
.then(async () => {
toast.message("Job skipped");
.then(async ({ restores }) => {
toastWithUndo({
title: "Job skipped",
restores: restores.length
? restores
: [{ jobId, status: previousStatus }],
onRestored: loadJobs,
successMessage: "Skip undone",
});
selectNextAfterAction(jobId);
await loadJobs();
})
@ -171,8 +180,10 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
markAsAppliedMutation
.mutateAsync(jobId)
.then(async () => {
toast.success("Marked as applied", {
toastAppliedUndo({
jobId,
description: `${selectedJob.title} at ${selectedJob.employer}`,
onRestored: loadJobs,
});
selectNextAfterAction(jobId);
await loadJobs();
@ -205,8 +216,10 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
api
.processJob(jobId)
.then(async () => {
toast.success("Job moved to Ready", {
toastMoveToReadyUndo({
jobId,
description: "Your tailored PDF has been generated.",
onRestored: loadJobs,
});
selectNextAfterAction(jobId);
await loadJobs();

View File

@ -436,7 +436,7 @@ export const useOrchestratorFilters = () => {
);
const hidePriorSkips = useMemo(
() => searchParams.get("hidePriorSkips") !== "0",
() => searchParams.get("hidePriorSkips") === "1",
[searchParams],
);
@ -458,8 +458,8 @@ export const useOrchestratorFilters = () => {
(value: boolean) => {
setSearchParams(
(prev) => {
if (value) prev.delete("hidePriorSkips");
else prev.set("hidePriorSkips", "0");
if (value) prev.set("hidePriorSkips", "1");
else prev.delete("hidePriorSkips");
return prev;
},
{ replace: true },

View File

@ -23,6 +23,7 @@ import {
getTasks,
stageEventMetadataSchema,
transitionStage,
unapplyJob,
updateStageEvent,
} from "@server/services/applicationTracking";
import {
@ -178,6 +179,7 @@ const updateJobSchema = z.object({
selectedProjectIds: z.string().optional(),
pdfPath: z.string().optional(),
tracerLinksEnabled: z.boolean().optional(),
appliedAt: z.string().nullable().optional(),
sponsorMatchScore: z.number().min(0).max(100).optional(),
sponsorMatchNames: z.string().optional(),
notes: z.string().max(10000).nullable().optional(),
@ -380,6 +382,7 @@ async function executeJobActionForJob(
});
}
const previousStatus = job.status;
const updated = await jobsRepo.updateJob(
jobId,
{ status: "skipped" },
@ -402,14 +405,25 @@ async function executeJobActionForJob(
updated.ownerProfileId,
updated.id,
);
if (alsoSkipped > 0) {
if (alsoSkipped.length > 0) {
logger.info("Auto-skipped duplicate open jobs", {
jobId: updated.id,
alsoSkipped,
alsoSkipped: alsoSkipped.length,
});
}
return { jobId, ok: true, job: updated };
return {
jobId,
ok: true,
job: updated,
undo: {
previousStatus,
alsoSkipped: alsoSkipped.map((entry) => ({
jobId: entry.id,
status: entry.previousStatus,
})),
},
};
}
if (action === "move_to_ready") {
@ -964,6 +978,87 @@ jobsRouter.post("/actions/stream", async (req: Request, res: Response) => {
}
});
const restoreStatusSchema = z.object({
restores: z
.array(
z.object({
jobId: z.string().min(1),
status: z.enum([
"discovered",
"processing",
"ready",
"applied",
"in_progress",
"skipped",
]),
}),
)
.min(1)
.max(2500),
});
/**
* POST /api/jobs/restore-status - Undo skip / apply / move by restoring prior statuses.
*/
jobsRouter.post("/restore-status", async (req: Request, res: Response) => {
try {
const parsed = restoreStatusSchema.safeParse(req.body);
if (!parsed.success) {
return fail(res, badRequest("Invalid restore payload", {
issues: parsed.error.issues,
}));
}
const restored: Job[] = [];
for (const entry of parsed.data.restores) {
const job = await jobsRepo.getJobById(entry.jobId);
if (!job) {
return fail(res, notFound(`Job not found: ${entry.jobId}`));
}
const from = job.status;
const to = entry.status;
const allowed =
(from === "skipped" && (to === "discovered" || to === "ready")) ||
(from === "applied" && to === "ready") ||
(from === "ready" && to === "discovered") ||
(from === "in_progress" && to === "applied") ||
(from === "processing" && to === "discovered") ||
from === to;
if (!allowed) {
return fail(
res,
badRequest(
`Cannot restore job ${entry.jobId} from "${from}" to "${to}"`,
{ jobId: entry.jobId, from, to },
),
);
}
if (from === "applied" && to === "ready") {
await unapplyJob(job.id);
}
const updated = await jobsRepo.updateJob(
job.id,
{
status: to,
...(to === "ready" || to === "discovered" ? { appliedAt: null } : {}),
},
job.ownerProfileId,
);
if (updated) restored.push(updated);
}
return ok(res, { restored: restored.length, jobs: restored });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
const forceRaw = req.query.force as string | undefined;
const force = forceRaw === "1" || forceRaw === "true";
@ -1375,6 +1470,7 @@ jobsRouter.post("/:id/generate-pdf", async (req: Request, res: Response) => {
}
});
/**
* POST /api/jobs/:id/apply - Mark a job as applied
*/
@ -1433,10 +1529,10 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
updatedJob.ownerProfileId,
updatedJob.id,
);
if (alsoSkipped > 0) {
if (alsoSkipped.length > 0) {
logger.info("Auto-skipped duplicate open jobs after apply", {
jobId: updatedJob.id,
alsoSkipped,
alsoSkipped: alsoSkipped.length,
});
}

View File

@ -98,6 +98,44 @@ const ILIA_DOBKIN_US_KEYWORD_TERMS = [
"TN SDET",
];
/** AI engineering / AI enablement — agents, MCP, RAG, evals, local LLM serving. */
const ILIA_DOBKIN_AI_TARGET_ROLES = [
"AI Engineer",
"Senior AI Engineer",
"Staff AI Engineer",
"AI Automation Engineer",
"Applied AI Engineer",
"LLM Engineer",
"Generative AI Engineer",
"GenAI Engineer",
"AI Platform Engineer",
"AI Solutions Engineer",
"AI Enablement Lead",
"AI Enablement Engineer",
"AI Agent Engineer",
"ML Engineer",
"Machine Learning Engineer",
"Prompt Engineer",
"RAG Engineer",
];
const ILIA_DOBKIN_AI_KEYWORD_TERMS = [
...ILIA_DOBKIN_AI_TARGET_ROLES.map((role) => role.toLowerCase()),
"LLM agents",
"MCP engineer",
"LangGraph",
"RAG engineer",
"prompt engineering",
"Ollama",
"AI enablement",
"AI automation remote",
"contract AI engineer",
"GenAI remote",
"LLM evals",
"promptfoo",
"multi-agent",
];
const migrations = [
`CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
@ -1142,6 +1180,105 @@ const migrations = [
'685b0000-0000-4000-8000-000000009901',
'685b0000-0000-4000-8000-000000009902'
)`,
// AI engineering profile — agents, MCP, RAG, evals (paired with same login as Canada/US).
sqlInsertSearchProfileSeed({
id: "685b0000-0000-4000-8000-000000000004",
name: "Ilia Dobkin (AI)",
profile: {
targetRoles: ILIA_DOBKIN_AI_TARGET_ROLES,
experienceLevel: "Senior",
mustHaveSkills: [
"Python",
"LLM",
"RAG",
"MCP",
"Agents",
"FastAPI",
"Prompt engineering",
],
niceToHaveSkills: [
"LangGraph",
"Ollama",
"promptfoo",
"TypeScript",
"Playwright",
"Ansible",
"Docker",
],
dealBreakers: [],
preferredWorkArrangement: ["remote", "hybrid"],
preferredLocations: [
"Toronto",
"Ontario",
"GTA",
"Greater Toronto Area",
"Canada",
"Remote Canada",
"Remote",
],
minimumSalary: "",
industriesToTarget: [
"Software",
"AI / ML",
"Enterprise SaaS",
"FinTech",
"Developer tools",
],
industriesToAvoid: [],
aboutMe:
"Ilia Dobkin (AI) — AI Automation Engineer / LLM agents, MCP, RAG & evals; AI enablement lead experience. Switch to this profile for AI-role discovery (same login as Canada/US). Resume: ilia-dobkin-ai.json.",
basicAuthUser: "ilia,dobkin",
resumeLocalPath: "../data/resumes/ilia-dobkin-ai.json",
},
}),
`INSERT OR IGNORE INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
VALUES (
'ilia-dobkin-ai-keyword-set',
'685b0000-0000-4000-8000-000000000004',
'AI Engineer',
'${sqlJsonLiteral(ILIA_DOBKIN_AI_KEYWORD_TERMS)}',
1,
datetime('now'),
datetime('now')
)`,
`UPDATE search_profiles
SET data = json_set(
json_set(
json_set(data, '$.basicAuthUser', 'ilia,dobkin'),
'$.resumeLocalPath',
'../data/resumes/ilia-dobkin-ai.json'
),
'$.aboutMe',
'Ilia Dobkin (AI) — AI Automation Engineer / LLM agents, MCP, RAG & evals; AI enablement lead experience. Switch to this profile for AI-role discovery (same login as Canada/US). Resume: ilia-dobkin-ai.json.'
),
updated_at = datetime('now')
WHERE id = '685b0000-0000-4000-8000-000000000004'`,
`UPDATE search_profiles
SET data = json_set(data, '$.targetRoles', '${sqlJsonLiteral(ILIA_DOBKIN_AI_TARGET_ROLES)}'),
updated_at = datetime('now')
WHERE id = '685b0000-0000-4000-8000-000000000004'`,
`UPDATE keyword_sets
SET terms = '${sqlJsonLiteral(ILIA_DOBKIN_AI_KEYWORD_TERMS)}',
name = 'AI Engineer',
is_active = 1,
updated_at = datetime('now')
WHERE id = 'ilia-dobkin-ai-keyword-set'`,
// Also add AI Engineer as an inactive keyword set on the Canada SDET profile
// (same pattern as Caseware / Lead) so it can be flipped without switching profiles.
`INSERT OR IGNORE INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
VALUES (
'ilia-dobkin-ai',
'685b0000-0000-4000-8000-000000000001',
'AI Engineer',
'${sqlJsonLiteral(ILIA_DOBKIN_AI_KEYWORD_TERMS)}',
0,
datetime('now'),
datetime('now')
)`,
`UPDATE keyword_sets
SET terms = '${sqlJsonLiteral(ILIA_DOBKIN_AI_KEYWORD_TERMS)}',
updated_at = datetime('now')
WHERE id = 'ilia-dobkin-ai'`,
];
console.log("🔧 Running database migrations...");

View File

@ -170,13 +170,14 @@ export async function skipOpenJobsWithMatchingDedupKeys(
},
ownerProfileId: string,
excludeJobId: string,
): Promise<number> {
): Promise<Array<{ id: string; previousStatus: JobStatus }>> {
const anchorKeys = collectJobDedupKeys(anchor);
if (anchorKeys.length === 0) return 0;
if (anchorKeys.length === 0) return [];
const rows = await db
.select({
id: jobs.id,
status: jobs.status,
employer: jobs.employer,
title: jobs.title,
jobDescription: jobs.jobDescription,
@ -190,7 +191,7 @@ export async function skipOpenJobsWithMatchingDedupKeys(
),
);
let skipped = 0;
const skipped: Array<{ id: string; previousStatus: JobStatus }> = [];
for (const row of rows) {
const rowKeys = collectJobDedupKeys({
employer: row.employer,
@ -198,12 +199,15 @@ export async function skipOpenJobsWithMatchingDedupKeys(
jobDescription: row.jobDescription,
});
if (!rowKeys.some((key) => anchorKeys.includes(key))) continue;
const previousStatus = row.status as JobStatus;
const updated = await updateJob(
row.id,
{ status: "skipped" },
ownerProfileId,
);
if (updated) skipped += 1;
if (updated) {
skipped.push({ id: row.id, previousStatus });
}
}
return skipped;
}
@ -771,15 +775,26 @@ export async function updateJob(
): Promise<Job | null> {
const now = new Date().toISOString();
// Leaving applied / in_progress (e.g. undo mark-as-applied) clears appliedAt
// unless the caller sets it explicitly.
const leavingAppliedTrack =
input.status !== undefined &&
input.status !== "applied" &&
input.status !== "in_progress" &&
input.appliedAt === undefined;
await db
.update(jobs)
.set({
...input,
updatedAt: now,
...(input.status === "processing" ? { processedAt: now } : {}),
...(input.status === "applied" && !input.appliedAt
...(input.status === "applied" && input.appliedAt === undefined
? { appliedAt: now }
: {}),
...(leavingAppliedTrack || input.appliedAt === null
? { appliedAt: null }
: {}),
})
.where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId)));

View File

@ -310,11 +310,11 @@ export function deleteStageEvent(eventId: string): void {
.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
// No stage events left — undo apply should land on Ready (PDF may exist),
// not Discovered.
tx.update(jobs)
.set({
status: "discovered",
status: "ready",
appliedAt: null,
outcome: null,
closedAt: null,
@ -326,6 +326,29 @@ export function deleteStageEvent(eventId: string): void {
});
}
/**
* Undo mark-as-applied: clear the Applied stage event(s) and return the job to Ready.
* If later stage events exist, keeps them and sets status from the latest remaining event.
*/
export async function unapplyJob(applicationId: string): Promise<void> {
const events = await getStageEvents(applicationId);
if (events.length === 0) {
return;
}
const appliedOnly = events.every((event) => event.toStage === "applied");
if (appliedOnly) {
for (const event of events) {
deleteStageEvent(event.id);
}
return;
}
// Mixed timeline: remove only the initial Applied event(s), keep later stages.
for (const event of events.filter((e) => e.toStage === "applied")) {
deleteStageEvent(event.id);
}
}
function parseMetadata(raw: unknown): StageEventMetadata | null {
if (!raw) return null;
if (typeof raw === "string") {

View File

@ -0,0 +1,25 @@
#
# Jobber cron env — dobkin (AI Engineer)
# Copy to /root/.jobber-cron-dobkin-ai.env (chmod 600)
#
# Used by: scripts/jobber-pipeline-telegram.sh
# Activate profile "Ilia Dobkin (AI)" in the Jobs UI (or via API) before cron,
# or pass the owner profile in your pipeline wrapper if supported.
#
# JobOps base URL (where the app is reachable from the cron host)
JOBOPS_URL=http://127.0.0.1:3005
# Optional: limit number of jobs linked in Telegram message
JOB_TELEGRAM_MAX_JOBS=25
# Optional: comma-separated sources to run (leave empty to use server defaults)
# JOBBER_PIPELINE_SOURCES=adzuna,gradcracker,ukvisajobs
# App-level Basic Auth (enables per-user separation when set on the server)
BASIC_AUTH_USER=dobkin
BASIC_AUTH_PASSWORD=CHANGEME
# Telegram bot + chat destination
TELEGRAM_BOT_TOKEN=CHANGEME
TELEGRAM_CHAT_ID=CHANGEME

View File

@ -322,7 +322,8 @@ export interface UpdateJobInput {
selectedProjectIds?: string;
pdfPath?: string;
tracerLinksEnabled?: boolean;
appliedAt?: string;
/** Pass `null` to clear (e.g. undo mark-as-applied). */
appliedAt?: string | null;
coverLetter?: string | null;
sponsorMatchScore?: number;
sponsorMatchNames?: string;

View File

@ -66,11 +66,22 @@ export type JobActionRequest =
};
};
/** Snapshot used by client Undo toasts after skip / apply / move. */
export type JobStatusRestore = {
jobId: string;
status: Job["status"];
};
export type JobActionResult =
| {
jobId: string;
ok: true;
job: Job;
/** Present when the action can be reversed (e.g. skip). */
undo?: {
previousStatus: Job["status"];
alsoSkipped?: JobStatusRestore[];
};
}
| {
jobId: string;