break up server routes into separate files
This commit is contained in:
parent
84987f5921
commit
58fdf6781f
File diff suppressed because it is too large
Load Diff
25
orchestrator/src/server/api/routes/database.ts
Normal file
25
orchestrator/src/server/api/routes/database.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { clearDatabase } from '../../db/clear.js';
|
||||||
|
|
||||||
|
export const databaseRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/database - Clear all data from the database
|
||||||
|
*/
|
||||||
|
databaseRouter.delete('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = clearDatabase();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: 'Database cleared',
|
||||||
|
jobsDeleted: result.jobsDeleted,
|
||||||
|
runsDeleted: result.runsDeleted,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
261
orchestrator/src/server/api/routes/jobs.ts
Normal file
261
orchestrator/src/server/api/routes/jobs.ts
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as jobsRepo from '../../repositories/jobs.js';
|
||||||
|
import * as settingsRepo from '../../repositories/settings.js';
|
||||||
|
import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js';
|
||||||
|
import { createNotionEntry } from '../../services/notion.js';
|
||||||
|
import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.js';
|
||||||
|
|
||||||
|
export const jobsRouter = Router();
|
||||||
|
|
||||||
|
async function notifyJobCompleteWebhook(job: Job) {
|
||||||
|
const overrideWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl')
|
||||||
|
const webhookUrl = (overrideWebhookUrl || process.env.JOB_COMPLETE_WEBHOOK_URL || '').trim()
|
||||||
|
if (!webhookUrl) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
const secret = process.env.WEBHOOK_SECRET
|
||||||
|
if (secret) headers.Authorization = `Bearer ${secret}`
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
event: 'job.completed',
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
job,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`ƒsÿ‹,? Job complete webhook POST failed (${response.status}): ${await response.text()}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('ƒsÿ‹,? Job complete webhook POST failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/jobs/:id - Update a job
|
||||||
|
*/
|
||||||
|
const updateJobSchema = z.object({
|
||||||
|
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(),
|
||||||
|
tailoredSummary: z.string().optional(),
|
||||||
|
selectedProjectIds: z.string().optional(),
|
||||||
|
pdfPath: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/jobs - List all jobs
|
||||||
|
* Query params: status (comma-separated list of statuses to filter)
|
||||||
|
*/
|
||||||
|
jobsRouter.get('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const statusFilter = req.query.status as string | undefined;
|
||||||
|
const statuses = statusFilter?.split(',').filter(Boolean) as JobStatus[] | undefined;
|
||||||
|
|
||||||
|
const jobs = await jobsRepo.getAllJobs(statuses);
|
||||||
|
const stats = await jobsRepo.getJobStats();
|
||||||
|
|
||||||
|
const response: ApiResponse<JobsListResponse> = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
jobs,
|
||||||
|
total: jobs.length,
|
||||||
|
byStatus: stats,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/jobs/:id - Get a single job
|
||||||
|
*/
|
||||||
|
jobsRouter.get('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const job = await jobsRepo.getJobById(req.params.id);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: job });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
jobsRouter.patch('/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const input = updateJobSchema.parse(req.body);
|
||||||
|
const job = await jobsRepo.updateJob(req.params.id, input);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: job });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/jobs/:id/summarize - Generate AI summary and suggest projects
|
||||||
|
*/
|
||||||
|
jobsRouter.post('/:id/summarize', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const forceRaw = req.query.force as string | undefined;
|
||||||
|
const force = forceRaw === '1' || forceRaw === 'true';
|
||||||
|
|
||||||
|
const result = await summarizeJob(req.params.id, { force });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await jobsRepo.getJobById(req.params.id);
|
||||||
|
res.json({ success: true, data: job });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/jobs/:id/generate-pdf - Generate PDF using current manual overrides
|
||||||
|
*/
|
||||||
|
jobsRouter.post('/:id/generate-pdf', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await generateFinalPdf(req.params.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await jobsRepo.getJobById(req.params.id);
|
||||||
|
res.json({ success: true, data: job });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/jobs/:id/process - Process a single job (generate summary + PDF)
|
||||||
|
*/
|
||||||
|
jobsRouter.post('/:id/process', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const forceRaw = req.query.force as string | undefined;
|
||||||
|
const force = forceRaw === '1' || forceRaw === 'true';
|
||||||
|
|
||||||
|
const result = await processJob(req.params.id, { force });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await jobsRepo.getJobById(req.params.id);
|
||||||
|
res.json({ success: true, data: job });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/jobs/:id/apply - Mark a job as applied and sync to Notion
|
||||||
|
*/
|
||||||
|
jobsRouter.post('/:id/apply', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const job = await jobsRepo.getJobById(req.params.id);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Sync to Notion
|
||||||
|
const notionResult = await createNotionEntry({
|
||||||
|
id: job.id,
|
||||||
|
title: job.title,
|
||||||
|
employer: job.employer,
|
||||||
|
applicationLink: job.applicationLink,
|
||||||
|
deadline: job.deadline,
|
||||||
|
salary: job.salary,
|
||||||
|
location: job.location,
|
||||||
|
pdfPath: job.pdfPath,
|
||||||
|
appliedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update job status
|
||||||
|
const updatedJob = await jobsRepo.updateJob(job.id, {
|
||||||
|
status: 'applied',
|
||||||
|
appliedAt,
|
||||||
|
notionPageId: notionResult.pageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedJob) {
|
||||||
|
notifyJobCompleteWebhook(updatedJob).catch(console.warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: updatedJob });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/jobs/:id/skip - Mark a job as skipped
|
||||||
|
*/
|
||||||
|
jobsRouter.post('/:id/skip', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const job = await jobsRepo.updateJob(req.params.id, { status: 'skipped' });
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Job not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: job });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/jobs/status/:status - Clear jobs with a specific status
|
||||||
|
*/
|
||||||
|
jobsRouter.delete('/status/:status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const status = req.params.status as JobStatus;
|
||||||
|
const count = await jobsRepo.deleteJobsByStatus(status);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: `Cleared ${count} ${status} jobs`,
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
126
orchestrator/src/server/api/routes/manual-jobs.ts
Normal file
126
orchestrator/src/server/api/routes/manual-jobs.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as jobsRepo from '../../repositories/jobs.js';
|
||||||
|
import { inferManualJobDetails } from '../../services/manualJob.js';
|
||||||
|
import { scoreJobSuitability } from '../../services/scorer.js';
|
||||||
|
import { loadResumeProfile } from '../../services/resumeProjects.js';
|
||||||
|
import type { ApiResponse, ManualJobInferenceResponse } from '../../../shared/types.js';
|
||||||
|
|
||||||
|
export const manualJobsRouter = Router();
|
||||||
|
|
||||||
|
const manualJobInferenceSchema = z.object({
|
||||||
|
jobDescription: z.string().trim().min(1).max(40000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const manualJobImportSchema = z.object({
|
||||||
|
job: z.object({
|
||||||
|
title: z.string().trim().min(1).max(500),
|
||||||
|
employer: z.string().trim().min(1).max(500),
|
||||||
|
jobUrl: z.string().trim().url().max(2000).optional(),
|
||||||
|
applicationLink: z.string().trim().url().max(2000).optional(),
|
||||||
|
location: z.string().trim().max(200).optional(),
|
||||||
|
salary: z.string().trim().max(200).optional(),
|
||||||
|
deadline: z.string().trim().max(100).optional(),
|
||||||
|
jobDescription: z.string().trim().min(1).max(40000),
|
||||||
|
jobType: z.string().trim().max(200).optional(),
|
||||||
|
jobLevel: z.string().trim().max(200).optional(),
|
||||||
|
jobFunction: z.string().trim().max(200).optional(),
|
||||||
|
disciplines: z.string().trim().max(200).optional(),
|
||||||
|
degreeRequired: z.string().trim().max(200).optional(),
|
||||||
|
starting: z.string().trim().max(200).optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanOptional = (value?: string | null) => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/manual-jobs/infer - Infer job details from a pasted description
|
||||||
|
*/
|
||||||
|
manualJobsRouter.post('/infer', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const input = manualJobInferenceSchema.parse(req.body ?? {});
|
||||||
|
const result = await inferManualJobDetails(input.jobDescription);
|
||||||
|
|
||||||
|
const response: ApiResponse<ManualJobInferenceResponse> = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
job: result.job,
|
||||||
|
warning: result.warning ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/manual-jobs/import - Import a manually curated job into the DB
|
||||||
|
*/
|
||||||
|
manualJobsRouter.post('/import', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const input = manualJobImportSchema.parse(req.body ?? {});
|
||||||
|
const job = input.job;
|
||||||
|
|
||||||
|
const jobUrl =
|
||||||
|
cleanOptional(job.jobUrl) ||
|
||||||
|
cleanOptional(job.applicationLink) ||
|
||||||
|
`manual://${randomUUID()}`;
|
||||||
|
|
||||||
|
const createdJob = await jobsRepo.createJob({
|
||||||
|
source: 'manual',
|
||||||
|
title: job.title.trim(),
|
||||||
|
employer: job.employer.trim(),
|
||||||
|
jobUrl,
|
||||||
|
applicationLink: cleanOptional(job.applicationLink) ?? undefined,
|
||||||
|
location: cleanOptional(job.location) ?? undefined,
|
||||||
|
salary: cleanOptional(job.salary) ?? undefined,
|
||||||
|
deadline: cleanOptional(job.deadline) ?? undefined,
|
||||||
|
jobDescription: job.jobDescription.trim(),
|
||||||
|
jobType: cleanOptional(job.jobType) ?? undefined,
|
||||||
|
jobLevel: cleanOptional(job.jobLevel) ?? undefined,
|
||||||
|
jobFunction: cleanOptional(job.jobFunction) ?? undefined,
|
||||||
|
disciplines: cleanOptional(job.disciplines) ?? undefined,
|
||||||
|
degreeRequired: cleanOptional(job.degreeRequired) ?? undefined,
|
||||||
|
starting: cleanOptional(job.starting) ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Score asynchronously so the import returns immediately.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rawProfile = await loadResumeProfile();
|
||||||
|
if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||||
|
throw new Error('Invalid resume profile format');
|
||||||
|
}
|
||||||
|
const profile = rawProfile as Record<string, unknown>;
|
||||||
|
const { score, reason } = await scoreJobSuitability(createdJob, profile);
|
||||||
|
await jobsRepo.updateJob(createdJob.id, {
|
||||||
|
suitabilityScore: score,
|
||||||
|
suitabilityReason: reason,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Manual job scoring failed:', error);
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
console.warn('Manual job scoring task failed to start:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: createdJob });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
103
orchestrator/src/server/api/routes/pipeline.ts
Normal file
103
orchestrator/src/server/api/routes/pipeline.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as pipelineRepo from '../../repositories/pipeline.js';
|
||||||
|
import { runPipeline, getPipelineStatus, subscribeToProgress } from '../../pipeline/index.js';
|
||||||
|
import type { ApiResponse, PipelineStatusResponse } from '../../../shared/types.js';
|
||||||
|
|
||||||
|
export const pipelineRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/pipeline/status - Get pipeline status
|
||||||
|
*/
|
||||||
|
pipelineRouter.get('/status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { isRunning } = getPipelineStatus();
|
||||||
|
const lastRun = await pipelineRepo.getLatestPipelineRun();
|
||||||
|
|
||||||
|
const response: ApiResponse<PipelineStatusResponse> = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isRunning,
|
||||||
|
lastRun,
|
||||||
|
nextScheduledRun: null, // Would come from n8n
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/pipeline/progress - Server-Sent Events endpoint for live progress
|
||||||
|
*/
|
||||||
|
pipelineRouter.get('/progress', (req: Request, res: Response) => {
|
||||||
|
// Set headers for SSE
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no'); // Disable Nginx buffering
|
||||||
|
|
||||||
|
// Send initial progress
|
||||||
|
const sendProgress = (data: unknown) => {
|
||||||
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscribe to progress updates
|
||||||
|
const unsubscribe = subscribeToProgress(sendProgress);
|
||||||
|
|
||||||
|
// Send heartbeat every 30 seconds to keep connection alive
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
res.write(': heartbeat\n\n');
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Cleanup on close
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/pipeline/runs - Get recent pipeline runs
|
||||||
|
*/
|
||||||
|
pipelineRouter.get('/runs', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const runs = await pipelineRepo.getRecentPipelineRuns(20);
|
||||||
|
res.json({ success: true, data: runs });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/pipeline/run - Trigger the pipeline manually
|
||||||
|
*/
|
||||||
|
const runPipelineSchema = z.object({
|
||||||
|
topN: z.number().min(1).max(50).optional(),
|
||||||
|
minSuitabilityScore: z.number().min(0).max(100).optional(),
|
||||||
|
sources: z.array(z.enum(['gradcracker', 'indeed', 'linkedin', 'ukvisajobs'])).min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
pipelineRouter.post('/run', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const config = runPipelineSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Start pipeline in background
|
||||||
|
runPipeline(config).catch(console.error);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { message: 'Pipeline started' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
18
orchestrator/src/server/api/routes/profile.ts
Normal file
18
orchestrator/src/server/api/routes/profile.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { extractProjectsFromProfile, loadResumeProfile } from '../../services/resumeProjects.js';
|
||||||
|
|
||||||
|
export const profileRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/profile/projects - Get all projects available in the base resume
|
||||||
|
*/
|
||||||
|
profileRouter.get('/projects', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const profile = await loadResumeProfile();
|
||||||
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
|
res.json({ success: true, data: catalog });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
394
orchestrator/src/server/api/routes/settings.ts
Normal file
394
orchestrator/src/server/api/routes/settings.ts
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as settingsRepo from '../../repositories/settings.js';
|
||||||
|
import {
|
||||||
|
extractProjectsFromProfile,
|
||||||
|
loadResumeProfile,
|
||||||
|
normalizeResumeProjectsSettings,
|
||||||
|
resolveResumeProjectsSettings,
|
||||||
|
} from '../../services/resumeProjects.js';
|
||||||
|
|
||||||
|
export const settingsRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/settings - Get app settings (effective + defaults)
|
||||||
|
*/
|
||||||
|
settingsRouter.get('/', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const overrideModel = await settingsRepo.getSetting('model');
|
||||||
|
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||||
|
const model = overrideModel || defaultModel;
|
||||||
|
|
||||||
|
// Specific AI models
|
||||||
|
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
|
||||||
|
const modelScorer = overrideModelScorer || model;
|
||||||
|
|
||||||
|
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
|
||||||
|
const modelTailoring = overrideModelTailoring || model;
|
||||||
|
|
||||||
|
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
|
||||||
|
const modelProjectSelection = overrideModelProjectSelection || model;
|
||||||
|
|
||||||
|
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl');
|
||||||
|
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
|
||||||
|
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
|
||||||
|
|
||||||
|
const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl');
|
||||||
|
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||||
|
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||||
|
|
||||||
|
const profile = await loadResumeProfile();
|
||||||
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
|
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||||
|
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||||
|
|
||||||
|
const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs');
|
||||||
|
const defaultUkvisajobsMaxJobs = 50;
|
||||||
|
const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null;
|
||||||
|
const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs;
|
||||||
|
|
||||||
|
const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm');
|
||||||
|
const defaultGradcrackerMaxJobsPerTerm = 50;
|
||||||
|
const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null;
|
||||||
|
const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm;
|
||||||
|
|
||||||
|
const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms');
|
||||||
|
const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer';
|
||||||
|
const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean);
|
||||||
|
const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null;
|
||||||
|
const searchTerms = overrideSearchTerms ?? defaultSearchTerms;
|
||||||
|
|
||||||
|
// JobSpy settings (GET)
|
||||||
|
const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation');
|
||||||
|
const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK';
|
||||||
|
const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation;
|
||||||
|
|
||||||
|
const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted');
|
||||||
|
const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10);
|
||||||
|
const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null;
|
||||||
|
const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted;
|
||||||
|
|
||||||
|
const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld');
|
||||||
|
const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10);
|
||||||
|
const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null;
|
||||||
|
const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld;
|
||||||
|
|
||||||
|
const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed');
|
||||||
|
const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK';
|
||||||
|
const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed;
|
||||||
|
|
||||||
|
const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites');
|
||||||
|
const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null;
|
||||||
|
const jobspySites = overrideJobspySites ?? defaultJobspySites;
|
||||||
|
|
||||||
|
const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription');
|
||||||
|
const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1';
|
||||||
|
const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw
|
||||||
|
? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1'
|
||||||
|
: null;
|
||||||
|
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
model,
|
||||||
|
defaultModel,
|
||||||
|
overrideModel,
|
||||||
|
modelScorer,
|
||||||
|
overrideModelScorer,
|
||||||
|
modelTailoring,
|
||||||
|
overrideModelTailoring,
|
||||||
|
modelProjectSelection,
|
||||||
|
overrideModelProjectSelection,
|
||||||
|
pipelineWebhookUrl,
|
||||||
|
defaultPipelineWebhookUrl,
|
||||||
|
overridePipelineWebhookUrl,
|
||||||
|
jobCompleteWebhookUrl,
|
||||||
|
defaultJobCompleteWebhookUrl,
|
||||||
|
overrideJobCompleteWebhookUrl,
|
||||||
|
...resumeProjectsData,
|
||||||
|
ukvisajobsMaxJobs,
|
||||||
|
defaultUkvisajobsMaxJobs,
|
||||||
|
overrideUkvisajobsMaxJobs,
|
||||||
|
gradcrackerMaxJobsPerTerm,
|
||||||
|
defaultGradcrackerMaxJobsPerTerm,
|
||||||
|
overrideGradcrackerMaxJobsPerTerm,
|
||||||
|
searchTerms,
|
||||||
|
defaultSearchTerms,
|
||||||
|
overrideSearchTerms,
|
||||||
|
jobspyLocation,
|
||||||
|
defaultJobspyLocation,
|
||||||
|
overrideJobspyLocation,
|
||||||
|
jobspyResultsWanted,
|
||||||
|
defaultJobspyResultsWanted,
|
||||||
|
overrideJobspyResultsWanted,
|
||||||
|
jobspyHoursOld,
|
||||||
|
defaultJobspyHoursOld,
|
||||||
|
overrideJobspyHoursOld,
|
||||||
|
jobspyCountryIndeed,
|
||||||
|
defaultJobspyCountryIndeed,
|
||||||
|
overrideJobspyCountryIndeed,
|
||||||
|
jobspySites,
|
||||||
|
defaultJobspySites,
|
||||||
|
overrideJobspySites,
|
||||||
|
jobspyLinkedinFetchDescription,
|
||||||
|
defaultJobspyLinkedinFetchDescription,
|
||||||
|
overrideJobspyLinkedinFetchDescription,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSettingsSchema = z.object({
|
||||||
|
model: z.string().trim().min(1).max(200).nullable().optional(),
|
||||||
|
modelScorer: z.string().trim().min(1).max(200).nullable().optional(),
|
||||||
|
modelTailoring: z.string().trim().min(1).max(200).nullable().optional(),
|
||||||
|
modelProjectSelection: z.string().trim().min(1).max(200).nullable().optional(),
|
||||||
|
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
||||||
|
jobCompleteWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
||||||
|
resumeProjects: z.object({
|
||||||
|
maxProjects: z.number().int().min(0).max(50),
|
||||||
|
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||||
|
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||||
|
}).nullable().optional(),
|
||||||
|
ukvisajobsMaxJobs: z.number().int().min(1).max(200).nullable().optional(),
|
||||||
|
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(200).nullable().optional(),
|
||||||
|
searchTerms: z.array(z.string().trim().min(1).max(200)).max(50).nullable().optional(),
|
||||||
|
jobspyLocation: z.string().trim().min(1).max(100).nullable().optional(),
|
||||||
|
jobspyResultsWanted: z.number().int().min(1).max(500).nullable().optional(),
|
||||||
|
jobspyHoursOld: z.number().int().min(1).max(168).nullable().optional(),
|
||||||
|
jobspyCountryIndeed: z.string().trim().min(1).max(100).nullable().optional(),
|
||||||
|
jobspySites: z.array(z.string().trim().min(1).max(50)).max(10).nullable().optional(),
|
||||||
|
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/settings - Update settings overrides
|
||||||
|
*/
|
||||||
|
settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const input = updateSettingsSchema.parse(req.body);
|
||||||
|
|
||||||
|
if ('model' in input) {
|
||||||
|
const model = input.model ?? null;
|
||||||
|
await settingsRepo.setSetting('model', model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('modelScorer' in input) {
|
||||||
|
await settingsRepo.setSetting('modelScorer', input.modelScorer ?? null);
|
||||||
|
}
|
||||||
|
if ('modelTailoring' in input) {
|
||||||
|
await settingsRepo.setSetting('modelTailoring', input.modelTailoring ?? null);
|
||||||
|
}
|
||||||
|
if ('modelProjectSelection' in input) {
|
||||||
|
await settingsRepo.setSetting('modelProjectSelection', input.modelProjectSelection ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('pipelineWebhookUrl' in input) {
|
||||||
|
const pipelineWebhookUrl = input.pipelineWebhookUrl ?? null;
|
||||||
|
await settingsRepo.setSetting('pipelineWebhookUrl', pipelineWebhookUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('jobCompleteWebhookUrl' in input) {
|
||||||
|
const webhookUrl = input.jobCompleteWebhookUrl ?? null;
|
||||||
|
await settingsRepo.setSetting('jobCompleteWebhookUrl', webhookUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('resumeProjects' in input) {
|
||||||
|
const resumeProjects = input.resumeProjects ?? null;
|
||||||
|
|
||||||
|
if (resumeProjects === null) {
|
||||||
|
await settingsRepo.setSetting('resumeProjects', null);
|
||||||
|
} else {
|
||||||
|
const rawProfile = await loadResumeProfile();
|
||||||
|
|
||||||
|
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||||
|
throw new Error('Invalid resume profile format: expected a non-null object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = rawProfile as Record<string, unknown>;
|
||||||
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
|
const allowed = new Set(catalog.map((p) => p.id));
|
||||||
|
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||||
|
await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('ukvisajobsMaxJobs' in input) {
|
||||||
|
const ukvisajobsMaxJobs = input.ukvisajobsMaxJobs ?? null;
|
||||||
|
await settingsRepo.setSetting('ukvisajobsMaxJobs', ukvisajobsMaxJobs !== null ? String(ukvisajobsMaxJobs) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('gradcrackerMaxJobsPerTerm' in input) {
|
||||||
|
const gradcrackerMaxJobsPerTerm = input.gradcrackerMaxJobsPerTerm ?? null;
|
||||||
|
await settingsRepo.setSetting('gradcrackerMaxJobsPerTerm', gradcrackerMaxJobsPerTerm !== null ? String(gradcrackerMaxJobsPerTerm) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('searchTerms' in input) {
|
||||||
|
const searchTerms = input.searchTerms ?? null;
|
||||||
|
await settingsRepo.setSetting('searchTerms', searchTerms !== null ? JSON.stringify(searchTerms) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('jobspyLocation' in input) {
|
||||||
|
const value = input.jobspyLocation ?? null;
|
||||||
|
await settingsRepo.setSetting('jobspyLocation', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('jobspyResultsWanted' in input) {
|
||||||
|
const value = input.jobspyResultsWanted ?? null;
|
||||||
|
await settingsRepo.setSetting('jobspyResultsWanted', value !== null ? String(value) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('jobspyHoursOld' in input) {
|
||||||
|
const value = input.jobspyHoursOld ?? null;
|
||||||
|
await settingsRepo.setSetting('jobspyHoursOld', value !== null ? String(value) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('jobspyCountryIndeed' in input) {
|
||||||
|
const value = input.jobspyCountryIndeed ?? null;
|
||||||
|
await settingsRepo.setSetting('jobspyCountryIndeed', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('jobspySites' in input) {
|
||||||
|
const value = input.jobspySites ?? null;
|
||||||
|
await settingsRepo.setSetting('jobspySites', value !== null ? JSON.stringify(value) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('jobspyLinkedinFetchDescription' in input) {
|
||||||
|
const value = input.jobspyLinkedinFetchDescription ?? null;
|
||||||
|
await settingsRepo.setSetting('jobspyLinkedinFetchDescription', value !== null ? (value ? '1' : '0') : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideModel = await settingsRepo.getSetting('model');
|
||||||
|
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||||
|
const model = overrideModel || defaultModel;
|
||||||
|
|
||||||
|
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
|
||||||
|
const modelScorer = overrideModelScorer || model;
|
||||||
|
|
||||||
|
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
|
||||||
|
const modelTailoring = overrideModelTailoring || model;
|
||||||
|
|
||||||
|
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
|
||||||
|
const modelProjectSelection = overrideModelProjectSelection || model;
|
||||||
|
|
||||||
|
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl');
|
||||||
|
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
|
||||||
|
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
|
||||||
|
|
||||||
|
const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl');
|
||||||
|
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||||
|
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||||
|
|
||||||
|
const profile = await loadResumeProfile();
|
||||||
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
|
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
||||||
|
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
||||||
|
|
||||||
|
const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs');
|
||||||
|
const defaultUkvisajobsMaxJobs = 50;
|
||||||
|
const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null;
|
||||||
|
const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs;
|
||||||
|
|
||||||
|
const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm');
|
||||||
|
const defaultGradcrackerMaxJobsPerTerm = 50;
|
||||||
|
const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null;
|
||||||
|
const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm;
|
||||||
|
|
||||||
|
// Search terms - stored as JSON array, default from env var (pipe-separated)
|
||||||
|
const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms');
|
||||||
|
const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer';
|
||||||
|
const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean);
|
||||||
|
const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null;
|
||||||
|
const searchTerms = overrideSearchTerms ?? defaultSearchTerms;
|
||||||
|
|
||||||
|
// JobSpy settings (re-fetch to update response)
|
||||||
|
const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation');
|
||||||
|
const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK';
|
||||||
|
const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation;
|
||||||
|
|
||||||
|
const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted');
|
||||||
|
const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10);
|
||||||
|
const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null;
|
||||||
|
const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted;
|
||||||
|
|
||||||
|
const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld');
|
||||||
|
const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10);
|
||||||
|
const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null;
|
||||||
|
const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld;
|
||||||
|
|
||||||
|
const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed');
|
||||||
|
const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK';
|
||||||
|
const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed;
|
||||||
|
|
||||||
|
const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites');
|
||||||
|
const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null;
|
||||||
|
const jobspySites = overrideJobspySites ?? defaultJobspySites;
|
||||||
|
|
||||||
|
const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription');
|
||||||
|
const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1';
|
||||||
|
const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw
|
||||||
|
? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1'
|
||||||
|
: null;
|
||||||
|
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
model,
|
||||||
|
defaultModel,
|
||||||
|
overrideModel,
|
||||||
|
modelScorer,
|
||||||
|
overrideModelScorer,
|
||||||
|
modelTailoring,
|
||||||
|
overrideModelTailoring,
|
||||||
|
modelProjectSelection,
|
||||||
|
overrideModelProjectSelection,
|
||||||
|
pipelineWebhookUrl,
|
||||||
|
defaultPipelineWebhookUrl,
|
||||||
|
overridePipelineWebhookUrl,
|
||||||
|
jobCompleteWebhookUrl,
|
||||||
|
defaultJobCompleteWebhookUrl,
|
||||||
|
overrideJobCompleteWebhookUrl,
|
||||||
|
...resumeProjectsData,
|
||||||
|
ukvisajobsMaxJobs,
|
||||||
|
defaultUkvisajobsMaxJobs,
|
||||||
|
overrideUkvisajobsMaxJobs,
|
||||||
|
gradcrackerMaxJobsPerTerm,
|
||||||
|
defaultGradcrackerMaxJobsPerTerm,
|
||||||
|
overrideGradcrackerMaxJobsPerTerm,
|
||||||
|
searchTerms,
|
||||||
|
defaultSearchTerms,
|
||||||
|
overrideSearchTerms,
|
||||||
|
jobspyLocation,
|
||||||
|
defaultJobspyLocation,
|
||||||
|
overrideJobspyLocation,
|
||||||
|
jobspyResultsWanted,
|
||||||
|
defaultJobspyResultsWanted,
|
||||||
|
overrideJobspyResultsWanted,
|
||||||
|
jobspyHoursOld,
|
||||||
|
defaultJobspyHoursOld,
|
||||||
|
overrideJobspyHoursOld,
|
||||||
|
jobspyCountryIndeed,
|
||||||
|
defaultJobspyCountryIndeed,
|
||||||
|
overrideJobspyCountryIndeed,
|
||||||
|
jobspySites,
|
||||||
|
defaultJobspySites,
|
||||||
|
overrideJobspySites,
|
||||||
|
jobspyLinkedinFetchDescription,
|
||||||
|
defaultJobspyLinkedinFetchDescription,
|
||||||
|
overrideJobspyLinkedinFetchDescription,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
// PATCH usually returns 500 for unknown, but let's stick to what was there (400?)
|
||||||
|
// Wait, the file said 400? Let's verify line 608.
|
||||||
|
res.status(400).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
128
orchestrator/src/server/api/routes/ukvisajobs.ts
Normal file
128
orchestrator/src/server/api/routes/ukvisajobs.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as jobsRepo from '../../repositories/jobs.js';
|
||||||
|
import { fetchUkVisaJobsPage } from '../../services/ukvisajobs.js';
|
||||||
|
import { getPipelineStatus } from '../../pipeline/index.js';
|
||||||
|
import type { ApiResponse, UkVisaJobsSearchResponse, UkVisaJobsImportResponse } from '../../../shared/types.js';
|
||||||
|
|
||||||
|
export const ukVisaJobsRouter = Router();
|
||||||
|
let isUkVisaJobsSearchRunning = false;
|
||||||
|
|
||||||
|
const ukVisaJobsSearchSchema = z.object({
|
||||||
|
query: z.string().trim().min(1).max(200).optional(),
|
||||||
|
searchTerm: z.string().trim().min(1).max(200).optional(),
|
||||||
|
searchTerms: z.array(z.string().trim().min(1).max(200)).max(20).optional(),
|
||||||
|
page: z.number().int().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ukvisajobs/search - Run a UKVisaJobs search without importing into the DB
|
||||||
|
*/
|
||||||
|
ukVisaJobsRouter.post('/search', async (req: Request, res: Response) => {
|
||||||
|
let lockAcquired = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = ukVisaJobsSearchSchema.parse(req.body ?? {});
|
||||||
|
|
||||||
|
if (isUkVisaJobsSearchRunning) {
|
||||||
|
return res.status(409).json({ success: false, error: 'UK Visa Jobs search is already running' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isRunning } = getPipelineStatus();
|
||||||
|
if (isRunning) {
|
||||||
|
return res.status(409).json({ success: false, error: 'Pipeline is running. Stop it before running UK Visa Jobs search.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
isUkVisaJobsSearchRunning = true;
|
||||||
|
lockAcquired = true;
|
||||||
|
|
||||||
|
const rawTerms = input.searchTerms ?? [];
|
||||||
|
if (rawTerms.length > 1) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Pagination supports a single search term.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = input.searchTerm ?? input.query ?? rawTerms[0];
|
||||||
|
const page = input.page ?? 1;
|
||||||
|
|
||||||
|
const result = await fetchUkVisaJobsPage({
|
||||||
|
searchKeyword: searchTerm,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(result.totalJobs / result.pageSize));
|
||||||
|
|
||||||
|
const response: ApiResponse<UkVisaJobsSearchResponse> = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
jobs: result.jobs,
|
||||||
|
totalJobs: result.totalJobs,
|
||||||
|
page: result.page,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
} finally {
|
||||||
|
if (lockAcquired) {
|
||||||
|
isUkVisaJobsSearchRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ukVisaJobsImportSchema = z.object({
|
||||||
|
jobs: z.array(z.object({
|
||||||
|
title: z.string().trim().min(1).max(500),
|
||||||
|
employer: z.string().trim().min(1).max(500),
|
||||||
|
jobUrl: z.string().trim().min(1).max(2000),
|
||||||
|
sourceJobId: z.string().trim().min(1).max(200).optional(),
|
||||||
|
employerUrl: z.string().trim().min(1).max(2000).optional(),
|
||||||
|
applicationLink: z.string().trim().min(1).max(2000).optional(),
|
||||||
|
location: z.string().trim().max(200).optional(),
|
||||||
|
deadline: z.string().trim().max(100).optional(),
|
||||||
|
salary: z.string().trim().max(200).optional(),
|
||||||
|
jobDescription: z.string().trim().max(20000).optional(),
|
||||||
|
datePosted: z.string().trim().max(100).optional(),
|
||||||
|
degreeRequired: z.string().trim().max(200).optional(),
|
||||||
|
jobType: z.string().trim().max(200).optional(),
|
||||||
|
jobLevel: z.string().trim().max(200).optional(),
|
||||||
|
})).min(1).max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ukvisajobs/import - Import selected UKVisaJobs results into the DB
|
||||||
|
*/
|
||||||
|
ukVisaJobsRouter.post('/import', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const input = ukVisaJobsImportSchema.parse(req.body ?? {});
|
||||||
|
|
||||||
|
const jobs = input.jobs.map((job) => ({
|
||||||
|
...job,
|
||||||
|
source: 'ukvisajobs' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await jobsRepo.bulkCreateJobs(jobs);
|
||||||
|
|
||||||
|
const response: ApiResponse<UkVisaJobsImportResponse> = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
created: result.created,
|
||||||
|
skipped: result.skipped,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
109
orchestrator/src/server/api/routes/visa-sponsors.ts
Normal file
109
orchestrator/src/server/api/routes/visa-sponsors.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as visaSponsors from '../../services/visa-sponsors/index.js';
|
||||||
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
VisaSponsorSearchResponse,
|
||||||
|
VisaSponsorStatusResponse,
|
||||||
|
} from '../../../shared/types.js';
|
||||||
|
|
||||||
|
export const visaSponsorsRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/visa-sponsors/status - Get status of the visa sponsor service
|
||||||
|
*/
|
||||||
|
visaSponsorsRouter.get('/status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const status = visaSponsors.getStatus();
|
||||||
|
const response: ApiResponse<VisaSponsorStatusResponse> = {
|
||||||
|
success: true,
|
||||||
|
data: status,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/visa-sponsors/search - Search for visa sponsors
|
||||||
|
*/
|
||||||
|
const visaSponsorSearchSchema = z.object({
|
||||||
|
query: z.string().min(1),
|
||||||
|
limit: z.number().int().min(1).max(200).optional(),
|
||||||
|
minScore: z.number().int().min(0).max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
visaSponsorsRouter.post('/search', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const input = visaSponsorSearchSchema.parse(req.body);
|
||||||
|
|
||||||
|
const results = visaSponsors.searchSponsors(input.query, {
|
||||||
|
limit: input.limit,
|
||||||
|
minScore: input.minScore,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse<VisaSponsorSearchResponse> = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
results,
|
||||||
|
query: input.query,
|
||||||
|
total: results.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/visa-sponsors/organization/:name - Get all entries for an organization
|
||||||
|
*/
|
||||||
|
visaSponsorsRouter.get('/organization/:name', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const name = decodeURIComponent(req.params.name);
|
||||||
|
const entries = visaSponsors.getOrganizationDetails(name);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Organization not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: entries,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/visa-sponsors/update - Trigger a manual update of the visa sponsor list
|
||||||
|
*/
|
||||||
|
visaSponsorsRouter.post('/update', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await visaSponsors.downloadLatestCsv();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({ success: false, error: result.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: result.message,
|
||||||
|
status: visaSponsors.getStatus(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
33
orchestrator/src/server/api/routes/webhook.ts
Normal file
33
orchestrator/src/server/api/routes/webhook.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { runPipeline } from '../../pipeline/index.js';
|
||||||
|
|
||||||
|
export const webhookRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/webhook/trigger - Webhook endpoint for n8n to trigger the pipeline
|
||||||
|
*/
|
||||||
|
webhookRouter.post('/trigger', async (req: Request, res: Response) => {
|
||||||
|
// Optional: Add authentication check
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const expectedToken = process.env.WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
if (expectedToken && authHeader !== `Bearer ${expectedToken}`) {
|
||||||
|
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start pipeline in background
|
||||||
|
runPipeline().catch(console.error);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: 'Pipeline triggered',
|
||||||
|
triggeredAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user