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