Merge pull request 'feat: undo job actions + AI Engineer search profile' (#4) from feat/undo-and-ai-search-profile into main
This commit is contained in:
commit
6c68155c9d
@ -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`).
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -135,8 +136,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
const handleUndoApplied = useCallback(
|
||||
async (jobId: string) => {
|
||||
try {
|
||||
// Revert to ready status
|
||||
await api.updateJob(jobId, { status: "ready" });
|
||||
await api.restoreJobStatuses([{ jobId, status: "ready" }]);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "move_to_ready",
|
||||
result: "success",
|
||||
@ -199,9 +199,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
description: `${job.title} at ${job.employer}`,
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: () => handleUndoApplied(job.id),
|
||||
onClick: () => void handleUndoApplied(job.id),
|
||||
},
|
||||
duration: 6000,
|
||||
duration: 8000,
|
||||
});
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
@ -254,14 +254,19 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
if (!job) return;
|
||||
|
||||
try {
|
||||
await skipJobMutation.mutateAsync(job.id);
|
||||
const { restores } = await skipJobMutation.mutateAsync(job.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "skip",
|
||||
result: "success",
|
||||
from_status: job.status,
|
||||
to_status: "skipped",
|
||||
});
|
||||
toast.message("Job skipped");
|
||||
toastWithUndo({
|
||||
title: "Job skipped",
|
||||
restores,
|
||||
onRestored: onJobUpdated,
|
||||
successMessage: "Skip undone",
|
||||
});
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
|
||||
@ -6,6 +6,7 @@ import type { Job } from "@shared/types.js";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { toastMoveToReadyUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||
import { trackProductEvent } from "@/lib/analytics";
|
||||
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
|
||||
import { DecideMode } from "./DecideMode";
|
||||
@ -63,14 +64,19 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
if (!job) return;
|
||||
try {
|
||||
setIsSkipping(true);
|
||||
await skipJobMutation.mutateAsync(job.id);
|
||||
const { restores } = await skipJobMutation.mutateAsync(job.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "skip",
|
||||
result: "success",
|
||||
from_status: job.status,
|
||||
to_status: "skipped",
|
||||
});
|
||||
toast.message("Job skipped");
|
||||
toastWithUndo({
|
||||
title: "Job skipped",
|
||||
restores,
|
||||
onRestored: onJobUpdated,
|
||||
successMessage: "Skip undone",
|
||||
});
|
||||
onJobMoved(job.id);
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
@ -100,8 +106,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
to_status: "ready",
|
||||
});
|
||||
|
||||
toast.success("Job moved to Ready", {
|
||||
toastMoveToReadyUndo({
|
||||
jobId: job.id,
|
||||
description: "Your tailored PDF has been generated.",
|
||||
onRestored: onJobUpdated,
|
||||
});
|
||||
|
||||
onJobMoved(job.id);
|
||||
|
||||
@ -46,7 +46,28 @@ export function useMarkAsAppliedMutation() {
|
||||
export function useSkipJobMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.skipJob(id),
|
||||
mutationFn: async (id: string) => {
|
||||
const response = await api.runJobAction({
|
||||
action: "skip",
|
||||
jobIds: [id],
|
||||
});
|
||||
const result = response.results.find((entry) => entry.jobId === id);
|
||||
if (!result?.ok) {
|
||||
throw new Error(
|
||||
result && !result.ok
|
||||
? result.error.message
|
||||
: "Job action did not return a result for the job",
|
||||
);
|
||||
}
|
||||
const restores: Array<{ jobId: string; status: Job["status"] }> = [
|
||||
{
|
||||
jobId: id,
|
||||
status: result.undo?.previousStatus ?? "discovered",
|
||||
},
|
||||
...(result.undo?.alsoSkipped ?? []),
|
||||
];
|
||||
return { job: result.job, restores };
|
||||
},
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) });
|
||||
const previousJob = queryClient.getQueryData<Job>(
|
||||
|
||||
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 { 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",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { toastAppliedUndo, toastWithUndo } from "@/client/lib/jobActionUndo";
|
||||
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
|
||||
import { getRenderableJobDescription } from "@/client/lib/jobDescription";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -261,7 +262,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
from_status: selectedJob.status,
|
||||
to_status: "applied",
|
||||
});
|
||||
toast.success("Marked as applied");
|
||||
toastAppliedUndo({
|
||||
jobId: selectedJob.id,
|
||||
description: `${selectedJob.title} at ${selectedJob.employer}`,
|
||||
onRestored: onJobUpdated,
|
||||
});
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
@ -279,14 +284,19 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
const handleSkip = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
await skipJobMutation.mutateAsync(selectedJob.id);
|
||||
const { restores } = await skipJobMutation.mutateAsync(selectedJob.id);
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "skip",
|
||||
result: "success",
|
||||
from_status: selectedJob.status,
|
||||
to_status: "skipped",
|
||||
});
|
||||
toast.message("Job skipped");
|
||||
toastWithUndo({
|
||||
title: "Job skipped",
|
||||
restores,
|
||||
onRestored: onJobUpdated,
|
||||
successMessage: "Skip undone",
|
||||
});
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
@ -304,14 +314,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
const handleMoveToInProgress = async () => {
|
||||
if (!selectedJob) return;
|
||||
try {
|
||||
const fromStatus = selectedJob.status;
|
||||
await api.updateJob(selectedJob.id, { status: "in_progress" });
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
action: "move_in_progress",
|
||||
result: "success",
|
||||
from_status: selectedJob.status,
|
||||
from_status: fromStatus,
|
||||
to_status: "in_progress",
|
||||
});
|
||||
toast.success("Moved to in progress");
|
||||
toastWithUndo({
|
||||
title: "Moved to in progress",
|
||||
restores: [{ jobId: selectedJob.id, status: fromStatus === "applied" ? "applied" : "applied" }],
|
||||
variant: "success",
|
||||
onRestored: onJobUpdated,
|
||||
successMessage: "Reverted to Applied",
|
||||
});
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
trackProductEvent("jobs_job_action_completed", {
|
||||
|
||||
@ -257,7 +257,7 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
employerIncludeFilter.length > 0 || employerExcludeFilter.length > 0,
|
||||
) +
|
||||
Number(!applySettingsCompanySkipList) +
|
||||
Number(!hidePriorSkips) +
|
||||
Number(hidePriorSkips) +
|
||||
Number(sponsorFilter !== "all") +
|
||||
Number(sponsorshipSignalsFilter.length > 0) +
|
||||
Number(hideSponsorBlockers) +
|
||||
|
||||
@ -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.`,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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...");
|
||||
|
||||
@ -170,13 +170,14 @@ export async function skipOpenJobsWithMatchingDedupKeys(
|
||||
},
|
||||
ownerProfileId: string,
|
||||
excludeJobId: string,
|
||||
): Promise<number> {
|
||||
): Promise<Array<{ id: string; previousStatus: JobStatus }>> {
|
||||
const anchorKeys = collectJobDedupKeys(anchor);
|
||||
if (anchorKeys.length === 0) return 0;
|
||||
if (anchorKeys.length === 0) return [];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: jobs.id,
|
||||
status: jobs.status,
|
||||
employer: jobs.employer,
|
||||
title: jobs.title,
|
||||
jobDescription: jobs.jobDescription,
|
||||
@ -190,7 +191,7 @@ export async function skipOpenJobsWithMatchingDedupKeys(
|
||||
),
|
||||
);
|
||||
|
||||
let skipped = 0;
|
||||
const skipped: Array<{ id: string; previousStatus: JobStatus }> = [];
|
||||
for (const row of rows) {
|
||||
const rowKeys = collectJobDedupKeys({
|
||||
employer: row.employer,
|
||||
@ -198,12 +199,15 @@ export async function skipOpenJobsWithMatchingDedupKeys(
|
||||
jobDescription: row.jobDescription,
|
||||
});
|
||||
if (!rowKeys.some((key) => anchorKeys.includes(key))) continue;
|
||||
const previousStatus = row.status as JobStatus;
|
||||
const updated = await updateJob(
|
||||
row.id,
|
||||
{ status: "skipped" },
|
||||
ownerProfileId,
|
||||
);
|
||||
if (updated) skipped += 1;
|
||||
if (updated) {
|
||||
skipped.push({ id: row.id, previousStatus });
|
||||
}
|
||||
}
|
||||
return skipped;
|
||||
}
|
||||
@ -771,15 +775,26 @@ export async function updateJob(
|
||||
): Promise<Job | null> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Leaving applied / in_progress (e.g. undo mark-as-applied) clears appliedAt
|
||||
// unless the caller sets it explicitly.
|
||||
const leavingAppliedTrack =
|
||||
input.status !== undefined &&
|
||||
input.status !== "applied" &&
|
||||
input.status !== "in_progress" &&
|
||||
input.appliedAt === undefined;
|
||||
|
||||
await db
|
||||
.update(jobs)
|
||||
.set({
|
||||
...input,
|
||||
updatedAt: now,
|
||||
...(input.status === "processing" ? { processedAt: now } : {}),
|
||||
...(input.status === "applied" && !input.appliedAt
|
||||
...(input.status === "applied" && input.appliedAt === undefined
|
||||
? { appliedAt: now }
|
||||
: {}),
|
||||
...(leavingAppliedTrack || input.appliedAt === null
|
||||
? { appliedAt: null }
|
||||
: {}),
|
||||
})
|
||||
.where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId)));
|
||||
|
||||
|
||||
@ -310,11 +310,11 @@ export function deleteStageEvent(eventId: string): void {
|
||||
.where(eq(jobs.id, event.applicationId))
|
||||
.run();
|
||||
} else {
|
||||
// If no events left, maybe revert to discovered?
|
||||
// For now just keep it as is or set to discovered if it was applied
|
||||
// No stage events left — undo apply should land on Ready (PDF may exist),
|
||||
// not Discovered.
|
||||
tx.update(jobs)
|
||||
.set({
|
||||
status: "discovered",
|
||||
status: "ready",
|
||||
appliedAt: null,
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
@ -326,6 +326,29 @@ export function deleteStageEvent(eventId: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo mark-as-applied: clear the Applied stage event(s) and return the job to Ready.
|
||||
* If later stage events exist, keeps them and sets status from the latest remaining event.
|
||||
*/
|
||||
export async function unapplyJob(applicationId: string): Promise<void> {
|
||||
const events = await getStageEvents(applicationId);
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
const appliedOnly = events.every((event) => event.toStage === "applied");
|
||||
if (appliedOnly) {
|
||||
for (const event of events) {
|
||||
deleteStageEvent(event.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mixed timeline: remove only the initial Applied event(s), keep later stages.
|
||||
for (const event of events.filter((e) => e.toStage === "applied")) {
|
||||
deleteStageEvent(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMetadata(raw: unknown): StageEventMetadata | null {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "string") {
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user