diff --git a/docs-site/docs/features/duplicate-jobs.md b/docs-site/docs/features/duplicate-jobs.md index 13463d31..a90eb498 100644 --- a/docs-site/docs/features/duplicate-jobs.md +++ b/docs-site/docs/features/duplicate-jobs.md @@ -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`). diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index fb1c54f3..2ea88b6f 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -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; diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index c032c19b..f5b7bcf5 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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) { diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx index 1dfae7a2..8b89b7c8 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx @@ -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 = ({ 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 = ({ 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); diff --git a/orchestrator/src/client/hooks/queries/useJobMutations.ts b/orchestrator/src/client/hooks/queries/useJobMutations.ts index b8fa400f..0104e98a 100644 --- a/orchestrator/src/client/hooks/queries/useJobMutations.ts +++ b/orchestrator/src/client/hooks/queries/useJobMutations.ts @@ -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( diff --git a/orchestrator/src/client/lib/jobActionUndo.test.ts b/orchestrator/src/client/lib/jobActionUndo.test.ts new file mode 100644 index 00000000..0020e291 --- /dev/null +++ b/orchestrator/src/client/lib/jobActionUndo.test.ts @@ -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" }, + ]); + }); + }); +}); diff --git a/orchestrator/src/client/lib/jobActionUndo.ts b/orchestrator/src/client/lib/jobActionUndo.ts new file mode 100644 index 00000000..6c4d367e --- /dev/null +++ b/orchestrator/src/client/lib/jobActionUndo.ts @@ -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(); + 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 { + 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; + 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 { + 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 { + 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 { + 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", + }); +} diff --git a/orchestrator/src/client/pages/JobPage.tsx b/orchestrator/src/client/pages/JobPage.tsx index ec6a2a4c..57e0a2b6 100644 --- a/orchestrator/src/client/pages/JobPage.tsx +++ b/orchestrator/src/client/pages/JobPage.tsx @@ -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", + }); }); }; diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index 29aba61c..b8c01fe4 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -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" }), + }), + ); }); }); diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 11169a2a..5f6f5b4e 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -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, ], ); diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index 4fc41965..a9c6dbe3 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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", { diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx index 8796023f..fe46c883 100644 --- a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx +++ b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx @@ -257,7 +257,7 @@ export const OrchestratorFilters: React.FC = ({ employerIncludeFilter.length > 0 || employerExcludeFilter.length > 0, ) + Number(!applySettingsCompanySkipList) + - Number(!hidePriorSkips) + + Number(hidePriorSkips) + Number(sponsorFilter !== "all") + Number(sponsorshipSignalsFilter.length > 0) + Number(hideSponsorBlockers) + diff --git a/orchestrator/src/client/pages/orchestrator/useJobSelectionActions.tsx b/orchestrator/src/client/pages/orchestrator/useJobSelectionActions.tsx index 7bac1d9b..be3e1b74 100644 --- a/orchestrator/src/client/pages/orchestrator/useJobSelectionActions.tsx +++ b/orchestrator/src/client/pages/orchestrator/useJobSelectionActions.tsx @@ -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.`, diff --git a/orchestrator/src/client/pages/orchestrator/useKeyboardShortcuts.ts b/orchestrator/src/client/pages/orchestrator/useKeyboardShortcuts.ts index f450f407..69e6dcae 100644 --- a/orchestrator/src/client/pages/orchestrator/useKeyboardShortcuts.ts +++ b/orchestrator/src/client/pages/orchestrator/useKeyboardShortcuts.ts @@ -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(); diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts index 55c2c4d6..902dc6fa 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts @@ -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 }, diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts index ff1b4685..cd742545 100644 --- a/orchestrator/src/server/api/routes/jobs.ts +++ b/orchestrator/src/server/api/routes/jobs.ts @@ -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, }); } diff --git a/orchestrator/src/server/db/migrate.ts b/orchestrator/src/server/db/migrate.ts index e4ea0dfd..e4e750dc 100644 --- a/orchestrator/src/server/db/migrate.ts +++ b/orchestrator/src/server/db/migrate.ts @@ -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..."); diff --git a/orchestrator/src/server/repositories/jobs.ts b/orchestrator/src/server/repositories/jobs.ts index 8990db3a..f32b33f4 100644 --- a/orchestrator/src/server/repositories/jobs.ts +++ b/orchestrator/src/server/repositories/jobs.ts @@ -170,13 +170,14 @@ export async function skipOpenJobsWithMatchingDedupKeys( }, ownerProfileId: string, excludeJobId: string, -): Promise { +): Promise> { 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 { 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))); diff --git a/orchestrator/src/server/services/applicationTracking.ts b/orchestrator/src/server/services/applicationTracking.ts index 8e3dad94..a2362fdc 100644 --- a/orchestrator/src/server/services/applicationTracking.ts +++ b/orchestrator/src/server/services/applicationTracking.ts @@ -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 { + 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") { diff --git a/scripts/jobber-cron-dobkin-ai.env.example b/scripts/jobber-cron-dobkin-ai.env.example new file mode 100644 index 00000000..cd9ce525 --- /dev/null +++ b/scripts/jobber-cron-dobkin-ai.env.example @@ -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 diff --git a/shared/src/types/jobs.ts b/shared/src/types/jobs.ts index 64c39846..de18aa54 100644 --- a/shared/src/types/jobs.ts +++ b/shared/src/types/jobs.ts @@ -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; diff --git a/shared/src/types/pipeline.ts b/shared/src/types/pipeline.ts index 490647e4..9545a4d6 100644 --- a/shared/src/types/pipeline.ts +++ b/shared/src/types/pipeline.ts @@ -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;