feat: undo job actions + AI Engineer search profile #4
@ -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.
|
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 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`).
|
- Same title after normalization (including simple plural variants such as `Engineer` vs `Engineers`).
|
||||||
|
|||||||
@ -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?: {
|
export async function getTracerAnalytics(options?: {
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
from?: number;
|
from?: number;
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import {
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -135,8 +136,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
const handleUndoApplied = useCallback(
|
const handleUndoApplied = useCallback(
|
||||||
async (jobId: string) => {
|
async (jobId: string) => {
|
||||||
try {
|
try {
|
||||||
// Revert to ready status
|
await api.restoreJobStatuses([{ jobId, status: "ready" }]);
|
||||||
await api.updateJob(jobId, { status: "ready" });
|
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
action: "move_to_ready",
|
action: "move_to_ready",
|
||||||
result: "success",
|
result: "success",
|
||||||
@ -199,9 +199,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
description: `${job.title} at ${job.employer}`,
|
description: `${job.title} at ${job.employer}`,
|
||||||
action: {
|
action: {
|
||||||
label: "Undo",
|
label: "Undo",
|
||||||
onClick: () => handleUndoApplied(job.id),
|
onClick: () => void handleUndoApplied(job.id),
|
||||||
},
|
},
|
||||||
duration: 6000,
|
duration: 8000,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
@ -254,14 +254,19 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
if (!job) return;
|
if (!job) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await skipJobMutation.mutateAsync(job.id);
|
const { restores } = await skipJobMutation.mutateAsync(job.id);
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
action: "skip",
|
action: "skip",
|
||||||
result: "success",
|
result: "success",
|
||||||
from_status: job.status,
|
from_status: job.status,
|
||||||
to_status: "skipped",
|
to_status: "skipped",
|
||||||
});
|
});
|
||||||
toast.message("Job skipped");
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores,
|
||||||
|
onRestored: onJobUpdated,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
onJobMoved(job.id);
|
onJobMoved(job.id);
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { Job } from "@shared/types.js";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { toastMoveToReadyUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { trackProductEvent } from "@/lib/analytics";
|
import { trackProductEvent } from "@/lib/analytics";
|
||||||
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
|
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
|
||||||
import { DecideMode } from "./DecideMode";
|
import { DecideMode } from "./DecideMode";
|
||||||
@ -63,14 +64,19 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
if (!job) return;
|
if (!job) return;
|
||||||
try {
|
try {
|
||||||
setIsSkipping(true);
|
setIsSkipping(true);
|
||||||
await skipJobMutation.mutateAsync(job.id);
|
const { restores } = await skipJobMutation.mutateAsync(job.id);
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
action: "skip",
|
action: "skip",
|
||||||
result: "success",
|
result: "success",
|
||||||
from_status: job.status,
|
from_status: job.status,
|
||||||
to_status: "skipped",
|
to_status: "skipped",
|
||||||
});
|
});
|
||||||
toast.message("Job skipped");
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores,
|
||||||
|
onRestored: onJobUpdated,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
onJobMoved(job.id);
|
onJobMoved(job.id);
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -100,8 +106,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
to_status: "ready",
|
to_status: "ready",
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Job moved to Ready", {
|
toastMoveToReadyUndo({
|
||||||
|
jobId: job.id,
|
||||||
description: "Your tailored PDF has been generated.",
|
description: "Your tailored PDF has been generated.",
|
||||||
|
onRestored: onJobUpdated,
|
||||||
});
|
});
|
||||||
|
|
||||||
onJobMoved(job.id);
|
onJobMoved(job.id);
|
||||||
|
|||||||
@ -46,7 +46,28 @@ export function useMarkAsAppliedMutation() {
|
|||||||
export function useSkipJobMutation() {
|
export function useSkipJobMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
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) => {
|
onMutate: async (id) => {
|
||||||
await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) });
|
await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) });
|
||||||
const previousJob = queryClient.getQueryData<Job>(
|
const previousJob = queryClient.getQueryData<Job>(
|
||||||
|
|||||||
62
orchestrator/src/client/lib/jobActionUndo.test.ts
Normal file
62
orchestrator/src/client/lib/jobActionUndo.test.ts
Normal 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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
146
orchestrator/src/client/lib/jobActionUndo.ts
Normal file
146
orchestrator/src/client/lib/jobActionUndo.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ import {
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { toastAppliedUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { invalidateJobData } from "@/client/hooks/queries/invalidate";
|
import { invalidateJobData } from "@/client/hooks/queries/invalidate";
|
||||||
import {
|
import {
|
||||||
useCheckSponsorMutation,
|
useCheckSponsorMutation,
|
||||||
@ -257,7 +258,11 @@ export const JobPage: React.FC = () => {
|
|||||||
await runAction("mark-applied", async () => {
|
await runAction("mark-applied", async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
await markAsAppliedMutation.mutateAsync(job.id);
|
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,
|
id: job.id,
|
||||||
update: { status: "in_progress" },
|
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 () => {
|
const handleSkip = async () => {
|
||||||
await runAction("skip", async () => {
|
await runAction("skip", async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
await skipJobMutation.mutateAsync(job.id);
|
const { restores } = await skipJobMutation.mutateAsync(job.id);
|
||||||
toast.message("Job skipped");
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores,
|
||||||
|
onRestored: loadData,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,24 @@ vi.mock("../api", () => ({
|
|||||||
}),
|
}),
|
||||||
getProfile: vi.fn().mockResolvedValue({ personName: "Test User" }),
|
getProfile: vi.fn().mockResolvedValue({ personName: "Test User" }),
|
||||||
skipJob: vi.fn().mockResolvedValue({}),
|
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({}),
|
markAsApplied: vi.fn().mockResolvedValue({}),
|
||||||
processJob: vi.fn().mockResolvedValue({}),
|
processJob: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
@ -1096,8 +1114,16 @@ describe("OrchestratorPage", () => {
|
|||||||
|
|
||||||
pressKey("s");
|
pressKey("s");
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(api.skipJob).toHaveBeenCalledWith("job-1");
|
expect(api.runJobAction).toHaveBeenCalledWith({
|
||||||
expect(toast.message).toHaveBeenCalledWith("Job skipped");
|
action: "skip",
|
||||||
|
jobIds: ["job-1"],
|
||||||
|
});
|
||||||
|
expect(toast.message).toHaveBeenCalledWith(
|
||||||
|
"Job skipped",
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ label: "Undo" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
pressKey("a");
|
pressKey("a");
|
||||||
@ -1105,7 +1131,9 @@ describe("OrchestratorPage", () => {
|
|||||||
expect(api.markAsApplied).toHaveBeenCalledWith("job-1");
|
expect(api.markAsApplied).toHaveBeenCalledWith("job-1");
|
||||||
expect(toast.success).toHaveBeenCalledWith(
|
expect(toast.success).toHaveBeenCalledWith(
|
||||||
"Marked as applied",
|
"Marked as applied",
|
||||||
expect.anything(),
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ label: "Undo" }),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1127,6 +1155,12 @@ describe("OrchestratorPage", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(toast.message).toHaveBeenCalledWith("Moving job to Ready...");
|
expect(toast.message).toHaveBeenCalledWith("Moving job to Ready...");
|
||||||
expect(api.processJob).toHaveBeenCalledWith("job-2");
|
expect(api.processJob).toHaveBeenCalledWith("job-2");
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(
|
||||||
|
"Job moved to Ready",
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ label: "Undo" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useSettings } from "@client/hooks/useSettings";
|
import { useSettings } from "@client/hooks/useSettings";
|
||||||
import { buildDuplicateDismissHints } from "@client/lib/job-dedup";
|
import { buildDuplicateDismissHints } from "@client/lib/job-dedup";
|
||||||
import { inferCountryKeyFromSearchGeography } from "@shared/search-cities";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
@ -168,12 +167,6 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
[settings],
|
[settings],
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchGeographyCountryKey = useMemo(
|
|
||||||
() =>
|
|
||||||
inferCountryKeyFromSearchGeography(settings?.searchCities?.value ?? null),
|
|
||||||
[settings?.searchCities?.value],
|
|
||||||
);
|
|
||||||
|
|
||||||
const duplicateDismissHints = useMemo(
|
const duplicateDismissHints = useMemo(
|
||||||
() => (hidePriorSkips ? buildDuplicateDismissHints(jobs) : undefined),
|
() => (hidePriorSkips ? buildDuplicateDismissHints(jobs) : undefined),
|
||||||
[hidePriorSkips, jobs],
|
[hidePriorSkips, jobs],
|
||||||
@ -188,7 +181,7 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
settingsBlockedEmployerKeywords: applySettingsCompanySkipList
|
settingsBlockedEmployerKeywords: applySettingsCompanySkipList
|
||||||
? settingsSkipEmployerKeywords
|
? settingsSkipEmployerKeywords
|
||||||
: [],
|
: [],
|
||||||
searchGeographyCountryKey,
|
searchGeographyCountryKey: null,
|
||||||
duplicateDismissHints,
|
duplicateDismissHints,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@ -198,7 +191,6 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
employerExcludeFilter,
|
employerExcludeFilter,
|
||||||
applySettingsCompanySkipList,
|
applySettingsCompanySkipList,
|
||||||
settingsSkipEmployerKeywords,
|
settingsSkipEmployerKeywords,
|
||||||
searchGeographyCountryKey,
|
|
||||||
duplicateDismissHints,
|
duplicateDismissHints,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import {
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { toastAppliedUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
|
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
|
||||||
import { getRenderableJobDescription } from "@/client/lib/jobDescription";
|
import { getRenderableJobDescription } from "@/client/lib/jobDescription";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -261,7 +262,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
from_status: selectedJob.status,
|
from_status: selectedJob.status,
|
||||||
to_status: "applied",
|
to_status: "applied",
|
||||||
});
|
});
|
||||||
toast.success("Marked as applied");
|
toastAppliedUndo({
|
||||||
|
jobId: selectedJob.id,
|
||||||
|
description: `${selectedJob.title} at ${selectedJob.employer}`,
|
||||||
|
onRestored: onJobUpdated,
|
||||||
|
});
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
@ -279,14 +284,19 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
const handleSkip = async () => {
|
const handleSkip = async () => {
|
||||||
if (!selectedJob) return;
|
if (!selectedJob) return;
|
||||||
try {
|
try {
|
||||||
await skipJobMutation.mutateAsync(selectedJob.id);
|
const { restores } = await skipJobMutation.mutateAsync(selectedJob.id);
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
action: "skip",
|
action: "skip",
|
||||||
result: "success",
|
result: "success",
|
||||||
from_status: selectedJob.status,
|
from_status: selectedJob.status,
|
||||||
to_status: "skipped",
|
to_status: "skipped",
|
||||||
});
|
});
|
||||||
toast.message("Job skipped");
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores,
|
||||||
|
onRestored: onJobUpdated,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
@ -304,14 +314,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
const handleMoveToInProgress = async () => {
|
const handleMoveToInProgress = async () => {
|
||||||
if (!selectedJob) return;
|
if (!selectedJob) return;
|
||||||
try {
|
try {
|
||||||
|
const fromStatus = selectedJob.status;
|
||||||
await api.updateJob(selectedJob.id, { status: "in_progress" });
|
await api.updateJob(selectedJob.id, { status: "in_progress" });
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
action: "move_in_progress",
|
action: "move_in_progress",
|
||||||
result: "success",
|
result: "success",
|
||||||
from_status: selectedJob.status,
|
from_status: fromStatus,
|
||||||
to_status: "in_progress",
|
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();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackProductEvent("jobs_job_action_completed", {
|
trackProductEvent("jobs_job_action_completed", {
|
||||||
|
|||||||
@ -257,7 +257,7 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
|||||||
employerIncludeFilter.length > 0 || employerExcludeFilter.length > 0,
|
employerIncludeFilter.length > 0 || employerExcludeFilter.length > 0,
|
||||||
) +
|
) +
|
||||||
Number(!applySettingsCompanySkipList) +
|
Number(!applySettingsCompanySkipList) +
|
||||||
Number(!hidePriorSkips) +
|
Number(hidePriorSkips) +
|
||||||
Number(sponsorFilter !== "all") +
|
Number(sponsorFilter !== "all") +
|
||||||
Number(sponsorshipSignalsFilter.length > 0) +
|
Number(sponsorshipSignalsFilter.length > 0) +
|
||||||
Number(hideSponsorBlockers) +
|
Number(hideSponsorBlockers) +
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
} from "@shared/types.js";
|
} from "@shared/types.js";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { restoresFromSkipResults, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { trackProductEvent } from "@/lib/analytics";
|
import { trackProductEvent } from "@/lib/analytics";
|
||||||
import type { FilterTab } from "./constants";
|
import type { FilterTab } from "./constants";
|
||||||
import { JobActionProgressToast } from "./JobActionProgressToast";
|
import { JobActionProgressToast } from "./JobActionProgressToast";
|
||||||
@ -250,7 +251,30 @@ export function useJobSelectionActions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.failed === 0) {
|
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 {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
`${result.succeeded} succeeded, ${result.failed} failed.`,
|
`${result.succeeded} succeeded, ${result.failed} failed.`,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { SHORTCUTS } from "@client/lib/shortcut-map";
|
|||||||
import type { JobAction, JobListItem } from "@shared/types.js";
|
import type { JobAction, JobListItem } from "@shared/types.js";
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { toastAppliedUndo, toastMoveToReadyUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||||
import { safeFilenamePart } from "@/lib/utils";
|
import { safeFilenamePart } from "@/lib/utils";
|
||||||
import type { FilterTab } from "./constants";
|
import type { FilterTab } from "./constants";
|
||||||
import { tabs } from "./constants";
|
import { tabs } from "./constants";
|
||||||
@ -145,10 +146,18 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
|
|||||||
if (!selectedJob) return;
|
if (!selectedJob) return;
|
||||||
shortcutActionInFlight.current = true;
|
shortcutActionInFlight.current = true;
|
||||||
const jobId = selectedJob.id;
|
const jobId = selectedJob.id;
|
||||||
|
const previousStatus = selectedJob.status;
|
||||||
skipJobMutation
|
skipJobMutation
|
||||||
.mutateAsync(jobId)
|
.mutateAsync(jobId)
|
||||||
.then(async () => {
|
.then(async ({ restores }) => {
|
||||||
toast.message("Job skipped");
|
toastWithUndo({
|
||||||
|
title: "Job skipped",
|
||||||
|
restores: restores.length
|
||||||
|
? restores
|
||||||
|
: [{ jobId, status: previousStatus }],
|
||||||
|
onRestored: loadJobs,
|
||||||
|
successMessage: "Skip undone",
|
||||||
|
});
|
||||||
selectNextAfterAction(jobId);
|
selectNextAfterAction(jobId);
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
})
|
})
|
||||||
@ -171,8 +180,10 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
|
|||||||
markAsAppliedMutation
|
markAsAppliedMutation
|
||||||
.mutateAsync(jobId)
|
.mutateAsync(jobId)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Marked as applied", {
|
toastAppliedUndo({
|
||||||
|
jobId,
|
||||||
description: `${selectedJob.title} at ${selectedJob.employer}`,
|
description: `${selectedJob.title} at ${selectedJob.employer}`,
|
||||||
|
onRestored: loadJobs,
|
||||||
});
|
});
|
||||||
selectNextAfterAction(jobId);
|
selectNextAfterAction(jobId);
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
@ -205,8 +216,10 @@ export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
|
|||||||
api
|
api
|
||||||
.processJob(jobId)
|
.processJob(jobId)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Job moved to Ready", {
|
toastMoveToReadyUndo({
|
||||||
|
jobId,
|
||||||
description: "Your tailored PDF has been generated.",
|
description: "Your tailored PDF has been generated.",
|
||||||
|
onRestored: loadJobs,
|
||||||
});
|
});
|
||||||
selectNextAfterAction(jobId);
|
selectNextAfterAction(jobId);
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
|
|||||||
@ -436,7 +436,7 @@ export const useOrchestratorFilters = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hidePriorSkips = useMemo(
|
const hidePriorSkips = useMemo(
|
||||||
() => searchParams.get("hidePriorSkips") !== "0",
|
() => searchParams.get("hidePriorSkips") === "1",
|
||||||
[searchParams],
|
[searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -458,8 +458,8 @@ export const useOrchestratorFilters = () => {
|
|||||||
(value: boolean) => {
|
(value: boolean) => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prev) => {
|
(prev) => {
|
||||||
if (value) prev.delete("hidePriorSkips");
|
if (value) prev.set("hidePriorSkips", "1");
|
||||||
else prev.set("hidePriorSkips", "0");
|
else prev.delete("hidePriorSkips");
|
||||||
return prev;
|
return prev;
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
getTasks,
|
getTasks,
|
||||||
stageEventMetadataSchema,
|
stageEventMetadataSchema,
|
||||||
transitionStage,
|
transitionStage,
|
||||||
|
unapplyJob,
|
||||||
updateStageEvent,
|
updateStageEvent,
|
||||||
} from "@server/services/applicationTracking";
|
} from "@server/services/applicationTracking";
|
||||||
import {
|
import {
|
||||||
@ -178,6 +179,7 @@ const updateJobSchema = z.object({
|
|||||||
selectedProjectIds: z.string().optional(),
|
selectedProjectIds: z.string().optional(),
|
||||||
pdfPath: z.string().optional(),
|
pdfPath: z.string().optional(),
|
||||||
tracerLinksEnabled: z.boolean().optional(),
|
tracerLinksEnabled: z.boolean().optional(),
|
||||||
|
appliedAt: z.string().nullable().optional(),
|
||||||
sponsorMatchScore: z.number().min(0).max(100).optional(),
|
sponsorMatchScore: z.number().min(0).max(100).optional(),
|
||||||
sponsorMatchNames: z.string().optional(),
|
sponsorMatchNames: z.string().optional(),
|
||||||
notes: z.string().max(10000).nullable().optional(),
|
notes: z.string().max(10000).nullable().optional(),
|
||||||
@ -380,6 +382,7 @@ async function executeJobActionForJob(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousStatus = job.status;
|
||||||
const updated = await jobsRepo.updateJob(
|
const updated = await jobsRepo.updateJob(
|
||||||
jobId,
|
jobId,
|
||||||
{ status: "skipped" },
|
{ status: "skipped" },
|
||||||
@ -402,14 +405,25 @@ async function executeJobActionForJob(
|
|||||||
updated.ownerProfileId,
|
updated.ownerProfileId,
|
||||||
updated.id,
|
updated.id,
|
||||||
);
|
);
|
||||||
if (alsoSkipped > 0) {
|
if (alsoSkipped.length > 0) {
|
||||||
logger.info("Auto-skipped duplicate open jobs", {
|
logger.info("Auto-skipped duplicate open jobs", {
|
||||||
jobId: updated.id,
|
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") {
|
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) => {
|
jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
|
||||||
const forceRaw = req.query.force as string | undefined;
|
const forceRaw = req.query.force as string | undefined;
|
||||||
const force = forceRaw === "1" || forceRaw === "true";
|
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
|
* 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.ownerProfileId,
|
||||||
updatedJob.id,
|
updatedJob.id,
|
||||||
);
|
);
|
||||||
if (alsoSkipped > 0) {
|
if (alsoSkipped.length > 0) {
|
||||||
logger.info("Auto-skipped duplicate open jobs after apply", {
|
logger.info("Auto-skipped duplicate open jobs after apply", {
|
||||||
jobId: updatedJob.id,
|
jobId: updatedJob.id,
|
||||||
alsoSkipped,
|
alsoSkipped: alsoSkipped.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -98,6 +98,44 @@ const ILIA_DOBKIN_US_KEYWORD_TERMS = [
|
|||||||
"TN SDET",
|
"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 = [
|
const migrations = [
|
||||||
`CREATE TABLE IF NOT EXISTS jobs (
|
`CREATE TABLE IF NOT EXISTS jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@ -1142,6 +1180,105 @@ const migrations = [
|
|||||||
'685b0000-0000-4000-8000-000000009901',
|
'685b0000-0000-4000-8000-000000009901',
|
||||||
'685b0000-0000-4000-8000-000000009902'
|
'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...");
|
console.log("🔧 Running database migrations...");
|
||||||
|
|||||||
@ -170,13 +170,14 @@ export async function skipOpenJobsWithMatchingDedupKeys(
|
|||||||
},
|
},
|
||||||
ownerProfileId: string,
|
ownerProfileId: string,
|
||||||
excludeJobId: string,
|
excludeJobId: string,
|
||||||
): Promise<number> {
|
): Promise<Array<{ id: string; previousStatus: JobStatus }>> {
|
||||||
const anchorKeys = collectJobDedupKeys(anchor);
|
const anchorKeys = collectJobDedupKeys(anchor);
|
||||||
if (anchorKeys.length === 0) return 0;
|
if (anchorKeys.length === 0) return [];
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: jobs.id,
|
id: jobs.id,
|
||||||
|
status: jobs.status,
|
||||||
employer: jobs.employer,
|
employer: jobs.employer,
|
||||||
title: jobs.title,
|
title: jobs.title,
|
||||||
jobDescription: jobs.jobDescription,
|
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) {
|
for (const row of rows) {
|
||||||
const rowKeys = collectJobDedupKeys({
|
const rowKeys = collectJobDedupKeys({
|
||||||
employer: row.employer,
|
employer: row.employer,
|
||||||
@ -198,12 +199,15 @@ export async function skipOpenJobsWithMatchingDedupKeys(
|
|||||||
jobDescription: row.jobDescription,
|
jobDescription: row.jobDescription,
|
||||||
});
|
});
|
||||||
if (!rowKeys.some((key) => anchorKeys.includes(key))) continue;
|
if (!rowKeys.some((key) => anchorKeys.includes(key))) continue;
|
||||||
|
const previousStatus = row.status as JobStatus;
|
||||||
const updated = await updateJob(
|
const updated = await updateJob(
|
||||||
row.id,
|
row.id,
|
||||||
{ status: "skipped" },
|
{ status: "skipped" },
|
||||||
ownerProfileId,
|
ownerProfileId,
|
||||||
);
|
);
|
||||||
if (updated) skipped += 1;
|
if (updated) {
|
||||||
|
skipped.push({ id: row.id, previousStatus });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return skipped;
|
return skipped;
|
||||||
}
|
}
|
||||||
@ -771,15 +775,26 @@ export async function updateJob(
|
|||||||
): Promise<Job | null> {
|
): Promise<Job | null> {
|
||||||
const now = new Date().toISOString();
|
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
|
await db
|
||||||
.update(jobs)
|
.update(jobs)
|
||||||
.set({
|
.set({
|
||||||
...input,
|
...input,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
...(input.status === "processing" ? { processedAt: now } : {}),
|
...(input.status === "processing" ? { processedAt: now } : {}),
|
||||||
...(input.status === "applied" && !input.appliedAt
|
...(input.status === "applied" && input.appliedAt === undefined
|
||||||
? { appliedAt: now }
|
? { appliedAt: now }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(leavingAppliedTrack || input.appliedAt === null
|
||||||
|
? { appliedAt: null }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
.where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId)));
|
.where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId)));
|
||||||
|
|
||||||
|
|||||||
@ -310,11 +310,11 @@ export function deleteStageEvent(eventId: string): void {
|
|||||||
.where(eq(jobs.id, event.applicationId))
|
.where(eq(jobs.id, event.applicationId))
|
||||||
.run();
|
.run();
|
||||||
} else {
|
} else {
|
||||||
// If no events left, maybe revert to discovered?
|
// No stage events left — undo apply should land on Ready (PDF may exist),
|
||||||
// For now just keep it as is or set to discovered if it was applied
|
// not Discovered.
|
||||||
tx.update(jobs)
|
tx.update(jobs)
|
||||||
.set({
|
.set({
|
||||||
status: "discovered",
|
status: "ready",
|
||||||
appliedAt: null,
|
appliedAt: null,
|
||||||
outcome: null,
|
outcome: null,
|
||||||
closedAt: 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 {
|
function parseMetadata(raw: unknown): StageEventMetadata | null {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
if (typeof raw === "string") {
|
if (typeof raw === "string") {
|
||||||
|
|||||||
25
scripts/jobber-cron-dobkin-ai.env.example
Normal file
25
scripts/jobber-cron-dobkin-ai.env.example
Normal 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
|
||||||
@ -322,7 +322,8 @@ export interface UpdateJobInput {
|
|||||||
selectedProjectIds?: string;
|
selectedProjectIds?: string;
|
||||||
pdfPath?: string;
|
pdfPath?: string;
|
||||||
tracerLinksEnabled?: boolean;
|
tracerLinksEnabled?: boolean;
|
||||||
appliedAt?: string;
|
/** Pass `null` to clear (e.g. undo mark-as-applied). */
|
||||||
|
appliedAt?: string | null;
|
||||||
coverLetter?: string | null;
|
coverLetter?: string | null;
|
||||||
sponsorMatchScore?: number;
|
sponsorMatchScore?: number;
|
||||||
sponsorMatchNames?: string;
|
sponsorMatchNames?: string;
|
||||||
|
|||||||
@ -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 =
|
export type JobActionResult =
|
||||||
| {
|
| {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
ok: true;
|
ok: true;
|
||||||
job: Job;
|
job: Job;
|
||||||
|
/** Present when the action can be reversed (e.g. skip). */
|
||||||
|
undo?: {
|
||||||
|
previousStatus: Job["status"];
|
||||||
|
alsoSkipped?: JobStatusRestore[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user