"rejected" isn't a thing. it's "skipped"
This commit is contained in:
parent
b5eebfcf49
commit
717987c5cc
@ -89,7 +89,7 @@ job-ops/
|
||||
|
||||
- `jobs`
|
||||
- from crawl: `title`, `employer`, `jobUrl`, `applicationLink`, `deadline`, `salary`, `location`, `jobDescription`, `source` (gradcracker/indeed/linkedin/ukvisajobs), etc.
|
||||
- enrichments: `status` (`discovered` -> `processing` -> `ready` -> `applied`/`rejected`), `suitabilityScore`, `suitabilityReason`, `tailoredSummary`, `pdfPath`, `notionPageId`
|
||||
- enrichments: `status` (`discovered` -> `processing` -> `ready` -> `applied`/`skipped`), `suitabilityScore`, `suitabilityReason`, `tailoredSummary`, `pdfPath`, `notionPageId`
|
||||
- `pipeline_runs`: audit log of runs (`running`/`completed`/`failed`, counts, error)
|
||||
|
||||
## Running (Docker)
|
||||
@ -148,7 +148,7 @@ Dev URLs:
|
||||
|
||||
## Key endpoints
|
||||
|
||||
- Jobs: `GET /api/jobs`, `POST /api/jobs/:id/process`, `POST /api/jobs/:id/apply`, `POST /api/jobs/:id/reject`, `POST /api/jobs/process-discovered`
|
||||
- Jobs: `GET /api/jobs`, `POST /api/jobs/:id/process`, `POST /api/jobs/:id/apply`, `POST /api/jobs/:id/skip`, `POST /api/jobs/process-discovered`
|
||||
- Pipeline: `POST /api/pipeline/run`, `GET /api/pipeline/status`, `GET /api/pipeline/progress` (SSE)
|
||||
- Webhook: `POST /api/webhook/trigger` (optional auth via `WEBHOOK_SECRET`)
|
||||
- Ops: `DELETE /api/database` (wipes DB)
|
||||
|
||||
@ -61,7 +61,7 @@ orchestrator/
|
||||
| PATCH | `/api/jobs/:id` | Update job |
|
||||
| POST | `/api/jobs/:id/process` | Generate resume for job |
|
||||
| POST | `/api/jobs/:id/apply` | Mark as applied + sync to Notion |
|
||||
| POST | `/api/jobs/:id/reject` | Mark as rejected |
|
||||
| POST | `/api/jobs/:id/skip` | Mark as skipped |
|
||||
|
||||
### Pipeline
|
||||
|
||||
|
||||
@ -89,8 +89,8 @@ export async function markAsApplied(id: string): Promise<Job> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectJob(id: string): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}/reject`, {
|
||||
export async function skipJob(id: string): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}/skip`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ import { StatusBadge } from "./StatusBadge";
|
||||
interface JobCardProps {
|
||||
job: Job;
|
||||
onApply: (id: string) => void | Promise<void>;
|
||||
onReject: (id: string) => void | Promise<void>;
|
||||
onSkip: (id: string) => void | Promise<void>;
|
||||
onProcess: (id: string) => void | Promise<void>;
|
||||
onEditDescription?: (id: string) => void;
|
||||
isProcessing: boolean;
|
||||
@ -78,7 +78,7 @@ const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_");
|
||||
export const JobCard: React.FC<JobCardProps> = ({
|
||||
job,
|
||||
onApply,
|
||||
onReject,
|
||||
onSkip,
|
||||
onProcess,
|
||||
onEditDescription,
|
||||
isProcessing,
|
||||
@ -95,7 +95,7 @@ export const JobCard: React.FC<JobCardProps> = ({
|
||||
const hasPdf = !!job.pdfPath;
|
||||
const canApply = job.status === "ready";
|
||||
const canProcess = ["discovered", "ready"].includes(job.status);
|
||||
const canReject = ["discovered", "ready"].includes(job.status);
|
||||
const canSkip = ["discovered", "ready"].includes(job.status);
|
||||
|
||||
const jobLink = job.applicationLink || job.jobUrl;
|
||||
const pdfHref = `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}`;
|
||||
@ -164,7 +164,7 @@ export const JobCard: React.FC<JobCardProps> = ({
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{(job.suitabilityReason || canApply || canReject || canProcess || hasPdf) && (
|
||||
{(job.suitabilityReason || canApply || canSkip || canProcess || hasPdf) && (
|
||||
<CardContent className="space-y-3">
|
||||
{job.suitabilityReason && (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
@ -246,8 +246,8 @@ export const JobCard: React.FC<JobCardProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canReject && (
|
||||
<Button variant="destructive" size="sm" onClick={() => onReject(job.id)}>
|
||||
{canSkip && (
|
||||
<Button variant="destructive" size="sm" onClick={() => onSkip(job.id)}>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Skip
|
||||
</Button>
|
||||
|
||||
@ -39,7 +39,7 @@ import { TailoringEditor } from "./TailoringEditor";
|
||||
interface JobListProps {
|
||||
jobs: Job[];
|
||||
onApply: (id: string) => void | Promise<void>;
|
||||
onReject: (id: string) => void | Promise<void>;
|
||||
onSkip: (id: string) => void | Promise<void>;
|
||||
onProcess: (id: string) => void | Promise<void>;
|
||||
onUpdate: () => void | Promise<void>;
|
||||
processingJobId: string | null;
|
||||
@ -87,7 +87,7 @@ const statusRank: Record<JobStatus, number> = {
|
||||
processing: 1,
|
||||
ready: 2,
|
||||
applied: 3,
|
||||
rejected: 4,
|
||||
skipped: 4,
|
||||
expired: 5,
|
||||
};
|
||||
|
||||
@ -194,7 +194,7 @@ const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+
|
||||
export const JobList: React.FC<JobListProps> = ({
|
||||
jobs,
|
||||
onApply,
|
||||
onReject,
|
||||
onSkip,
|
||||
onProcess,
|
||||
onUpdate,
|
||||
processingJobId,
|
||||
@ -204,7 +204,7 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
const [sourceFilter, setSourceFilter] = useState<JobSource | "all">("all");
|
||||
const [sort, setSort] = useState<JobSort>(DEFAULT_SORT);
|
||||
const [selectedJobIds, setSelectedJobIds] = useState<Set<string>>(() => new Set());
|
||||
const [batchAction, setBatchAction] = useState<null | "process" | "reject" | "apply">(null);
|
||||
const [batchAction, setBatchAction] = useState<null | "process" | "skip" | "apply">(null);
|
||||
const [highlightedJobId, setHighlightedJobId] = useState<string | null>(null);
|
||||
const [isHighlightVisible, setIsHighlightVisible] = useState(false);
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||
@ -369,7 +369,7 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
|
||||
const selectedCount = selectedJobIds.size;
|
||||
|
||||
const runBatch = async (action: "process" | "reject" | "apply") => {
|
||||
const runBatch = async (action: "process" | "skip" | "apply") => {
|
||||
if (selectedJobs.length === 0) return;
|
||||
|
||||
const eligible = selectedJobs.filter((job) => {
|
||||
@ -389,7 +389,7 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
for (const job of eligible) {
|
||||
if (action === "process") await Promise.resolve(onProcess(job.id));
|
||||
if (action === "apply") await Promise.resolve(onApply(job.id));
|
||||
if (action === "reject") await Promise.resolve(onReject(job.id));
|
||||
if (action === "skip") await Promise.resolve(onSkip(job.id));
|
||||
}
|
||||
|
||||
setSelectedJobIds(new Set());
|
||||
@ -440,7 +440,7 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
<JobCard
|
||||
job={highlightedJob}
|
||||
onApply={onApply}
|
||||
onReject={onReject}
|
||||
onSkip={onSkip}
|
||||
onProcess={onProcess}
|
||||
isProcessing={processingJobId === highlightedJob.id}
|
||||
highlightedJobId={highlightedJobId}
|
||||
@ -713,7 +713,7 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => runBatch("reject")}
|
||||
onClick={() => runBatch("skip")}
|
||||
disabled={batchAction !== null}
|
||||
>
|
||||
Skip
|
||||
@ -746,7 +746,7 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
selectedJobIds={selectedJobIds}
|
||||
onSelectedJobIdsChange={setSelectedJobIds}
|
||||
onApply={onApply}
|
||||
onReject={onReject}
|
||||
onSkip={onSkip}
|
||||
onProcess={onProcess}
|
||||
processingJobId={processingJobId}
|
||||
highlightedJobId={highlightedJobId}
|
||||
@ -762,7 +762,7 @@ export const JobList: React.FC<JobListProps> = ({
|
||||
key={job.id}
|
||||
job={job}
|
||||
onApply={onApply}
|
||||
onReject={onReject}
|
||||
onSkip={onSkip}
|
||||
onProcess={onProcess}
|
||||
isProcessing={processingJobId === job.id}
|
||||
highlightedJobId={highlightedJobId}
|
||||
|
||||
@ -57,7 +57,7 @@ export interface JobTableProps {
|
||||
selectedJobIds: Set<string>;
|
||||
onSelectedJobIdsChange: (ids: Set<string>) => void;
|
||||
onApply: (id: string) => void | Promise<void>;
|
||||
onReject: (id: string) => void | Promise<void>;
|
||||
onSkip: (id: string) => void | Promise<void>;
|
||||
onProcess: (id: string) => void | Promise<void>;
|
||||
onEditDescription?: (id: string) => void;
|
||||
processingJobId: string | null;
|
||||
@ -145,7 +145,7 @@ export const JobTable: React.FC<JobTableProps> = ({
|
||||
selectedJobIds,
|
||||
onSelectedJobIdsChange,
|
||||
onApply,
|
||||
onReject,
|
||||
onSkip,
|
||||
onProcess,
|
||||
onEditDescription,
|
||||
processingJobId,
|
||||
@ -228,7 +228,7 @@ export const JobTable: React.FC<JobTableProps> = ({
|
||||
|
||||
const canApply = job.status === "ready";
|
||||
const canProcess = ["discovered", "ready"].includes(job.status);
|
||||
const canReject = ["discovered", "ready"].includes(job.status);
|
||||
const canSkip = ["discovered", "ready"].includes(job.status);
|
||||
const isProcessing = processingJobId === job.id;
|
||||
const isSelected = selectedJobIds.has(job.id);
|
||||
const isHighlighted = highlightedJobId === job.id;
|
||||
@ -342,7 +342,7 @@ export const JobTable: React.FC<JobTableProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canProcess || canReject || canApply) && <DropdownMenuSeparator />}
|
||||
{(canProcess || canSkip || canApply) && <DropdownMenuSeparator />}
|
||||
|
||||
{canProcess && (
|
||||
<DropdownMenuItem
|
||||
@ -358,9 +358,9 @@ export const JobTable: React.FC<JobTableProps> = ({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{canReject && (
|
||||
{canSkip && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onReject(job.id)}
|
||||
onSelect={() => onSkip(job.id)}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Skip
|
||||
|
||||
@ -28,7 +28,7 @@ const statConfig: Array<{
|
||||
{ key: "processing", label: "Processing", Icon: Loader2 },
|
||||
{ key: "ready", label: "Ready", Icon: Sparkles },
|
||||
{ key: "applied", label: "Applied", Icon: CheckCircle2 },
|
||||
{ key: "rejected", label: "Rejected", Icon: XCircle },
|
||||
{ key: "skipped", label: "Skipped", Icon: XCircle },
|
||||
{ key: "expired", label: "Expired", Icon: Clock },
|
||||
];
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ const statusLabels: Record<JobStatus, string> = {
|
||||
processing: "Processing",
|
||||
ready: "Ready",
|
||||
applied: "Applied",
|
||||
rejected: "Rejected",
|
||||
skipped: "Skipped",
|
||||
expired: "Expired",
|
||||
};
|
||||
|
||||
@ -30,7 +30,7 @@ const statusStyles: Record<
|
||||
processing: { variant: "secondary" },
|
||||
ready: { variant: "default" },
|
||||
applied: { variant: "outline", className: "text-emerald-400 border-emerald-500/30" },
|
||||
rejected: { variant: "destructive" },
|
||||
skipped: { variant: "destructive" },
|
||||
expired: { variant: "outline", className: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
|
||||
@ -93,8 +93,8 @@ const statusTokens: Record<
|
||||
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
rejected: {
|
||||
label: "Rejected",
|
||||
skipped: {
|
||||
label: "Skipped",
|
||||
badge: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||
dot: "bg-rose-400",
|
||||
},
|
||||
@ -292,7 +292,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
processing: 0,
|
||||
ready: 0,
|
||||
applied: 0,
|
||||
rejected: 0,
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@ -430,13 +430,13 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (jobId: string) => {
|
||||
const handleSkip = async (jobId: string) => {
|
||||
try {
|
||||
await api.rejectJob(jobId);
|
||||
await api.skipJob(jobId);
|
||||
toast.message("Job skipped");
|
||||
await loadJobs();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to reject job";
|
||||
const message = error instanceof Error ? error.message : "Failed to skip job";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
@ -586,7 +586,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const selectedDiscoveredAt = selectedJob ? formatDateTime(selectedJob.discoveredAt) : null;
|
||||
const canApply = selectedJob?.status === "ready";
|
||||
const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
||||
const canReject = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
||||
const canSkip = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
||||
const showReadyPdf = activeTab === "ready";
|
||||
const showGeneratePdf = activeTab === "discovered";
|
||||
const isProcessingSelected =
|
||||
@ -716,7 +716,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
{ label: "Processing", value: stats.processing },
|
||||
{ label: "Ready", value: stats.ready },
|
||||
{ label: "Applied", value: stats.applied },
|
||||
{ label: "Rejected", value: stats.rejected },
|
||||
{ label: "Skipped", value: stats.skipped },
|
||||
{ label: "Expired", value: stats.expired },
|
||||
].map((item, index) => (
|
||||
<div
|
||||
@ -1060,11 +1060,11 @@ export const OrchestratorPage: React.FC = () => {
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canReject && (
|
||||
{canSkip && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleReject(selectedJob.id)}
|
||||
onSelect={() => handleSkip(selectedJob.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
|
||||
@ -906,7 +906,7 @@ export const SettingsPage: React.FC = () => {
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">Clear Discovered Jobs</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Delete all jobs with the status "discovered". Ready, applied, and rejected jobs are kept.
|
||||
Delete all jobs with the status "discovered". Ready, applied, and skipped jobs are kept.
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
|
||||
@ -105,7 +105,7 @@ apiRouter.get('/jobs/:id', async (req: Request, res: Response) => {
|
||||
* PATCH /api/jobs/:id - Update a job
|
||||
*/
|
||||
const updateJobSchema = z.object({
|
||||
status: z.enum(['discovered', 'processing', 'ready', 'applied', 'rejected', 'expired']).optional(),
|
||||
status: z.enum(['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']).optional(),
|
||||
jobDescription: z.string().optional(),
|
||||
suitabilityScore: z.number().min(0).max(100).optional(),
|
||||
suitabilityReason: z.string().optional(),
|
||||
@ -241,11 +241,11 @@ apiRouter.post('/jobs/:id/apply', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/reject - Mark a job as rejected
|
||||
* POST /api/jobs/:id/skip - Mark a job as skipped
|
||||
*/
|
||||
apiRouter.post('/jobs/:id/reject', async (req: Request, res: Response) => {
|
||||
apiRouter.post('/jobs/:id/skip', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const job = await jobsRepo.updateJob(req.params.id, { status: 'rejected' });
|
||||
const job = await jobsRepo.updateJob(req.params.id, { status: 'skipped' });
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||
|
||||
@ -65,7 +65,7 @@ const migrations = [
|
||||
degree_required TEXT,
|
||||
starting TEXT,
|
||||
job_description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'rejected', 'expired')),
|
||||
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'skipped', 'expired')),
|
||||
suitability_score REAL,
|
||||
suitability_reason TEXT,
|
||||
tailored_summary TEXT,
|
||||
|
||||
@ -54,7 +54,7 @@ export const jobs = sqliteTable('jobs', {
|
||||
|
||||
// Orchestrator enrichments
|
||||
status: text('status', {
|
||||
enum: ['discovered', 'processing', 'ready', 'applied', 'rejected', 'expired']
|
||||
enum: ['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']
|
||||
}).notNull().default('discovered'),
|
||||
suitabilityScore: real('suitability_score'),
|
||||
suitabilityReason: text('suitability_reason'),
|
||||
|
||||
@ -165,7 +165,7 @@ export async function getJobStats(): Promise<Record<JobStatus, number>> {
|
||||
processing: 0,
|
||||
ready: 0,
|
||||
applied: 0,
|
||||
rejected: 0,
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
};
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ export type JobStatus =
|
||||
| 'processing' // Currently generating resume
|
||||
| 'ready' // PDF generated, waiting for user to apply
|
||||
| 'applied' // User marked as applied (added to Notion)
|
||||
| 'rejected' // User rejected this job
|
||||
| 'skipped' // User skipped this job
|
||||
| 'expired'; // Deadline passed
|
||||
|
||||
export type JobSource =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user