import { JOB_STATUSES, type JobPageQuery, type JobStatus } from '@multi-simadmin/contracts'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { JobQueryError, JobQueryService } from '../../application/jobs/job-query-service.js'; import { JobCancelError, JobReconcileService, } from '../../application/jobs/job-reconcile-service.js'; export interface JobRoutesOptions { readonly jobs: JobQueryService; readonly reconcile?: JobReconcileService; } const QUERY_KEYS = new Set([ 'page', 'pageSize', 'sort', 'direction', 'status', 'operationId', 'rootJobId', 'instanceId', ]); const errorResponse = { VALIDATION_FAILED: { status: 400, title: 'Bad Request', detail: 'The jobs query is invalid.', }, NOT_FOUND: { status: 404, title: 'Not Found', detail: 'The requested job does not exist.', }, UNREPRESENTABLE: { status: 503, title: 'Service Unavailable', detail: 'The requested job data is temporarily unavailable.', }, } as const; function invalid(): never { throw new JobQueryError('VALIDATION_FAILED', 'Invalid jobs query'); } function parseQuery(query: unknown): JobPageQuery { if (query === null || typeof query !== 'object' || Array.isArray(query)) invalid(); const input = query as Record; for (const key of Object.keys(input)) { if (!QUERY_KEYS.has(key)) invalid(); if (typeof input[key] !== 'string') invalid(); } const scalar = (key: string): string | undefined => { const value = input[key]; return value === undefined ? undefined : (value as string); }; const integer = (key: 'page' | 'pageSize', fallback: number, maximum: number): number => { const value = scalar(key); if (value === undefined) return fallback; if (!/^[1-9]\d*$/u.test(value)) invalid(); const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed > maximum) invalid(); return parsed; }; const pageSize = integer('pageSize', 25, 100); const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1; const result: { page: number; pageSize: number; sort?: 'createdAt' | 'status' | 'operationId'; direction: 'asc' | 'desc'; status?: JobStatus; operationId?: string; rootJobId?: string; instanceId?: string; } = { page: integer('page', 1, maximumPage), pageSize, direction: 'asc', }; const sort = scalar('sort'); if (sort !== undefined) { if (sort !== 'createdAt' && sort !== 'status' && sort !== 'operationId') invalid(); result.sort = sort; } const direction = scalar('direction'); if (direction !== undefined) { if (direction !== 'asc' && direction !== 'desc') invalid(); result.direction = direction; } const status = scalar('status'); if (status !== undefined) { if (!(JOB_STATUSES as readonly string[]).includes(status)) invalid(); result.status = status as JobStatus; } for (const key of ['operationId', 'rootJobId', 'instanceId'] as const) { const value = scalar(key); if (value !== undefined) { if (value.length === 0) invalid(); result[key] = value; } } return result; } export function registerJobRoutes(app: FastifyInstance, options: JobRoutesOptions): void { const handler = (action: (request: FastifyRequest) => T) => async (request: FastifyRequest, reply: FastifyReply) => { try { return action(request); } catch (error) { if (!(error instanceof JobQueryError)) throw error; const mapped = errorResponse[error.code]; return reply.code(mapped.status).type('application/problem+json').send({ type: 'about:blank', title: mapped.title, status: mapped.status, code: error.code, detail: mapped.detail, requestId: request.id, }); } }; app.get( '/api/v1/jobs', handler((request) => options.jobs.list(parseQuery(request.query))), ); app.get( '/api/v1/jobs/:jobId', handler((request) => options.jobs.get((request.params as { jobId: string }).jobId)), ); const reconcile = options.reconcile; if (reconcile) { app.get('/api/v1/jobs/reconcile', async () => reconcile.summary()); app.post('/api/v1/jobs/reconcile', () => reconcile.reconcile()); app.post('/api/v1/jobs/:jobId/cancel', async (request, reply) => { const jobId = (request.params as { jobId: string }).jobId; try { reconcile.cancel(jobId); } catch (error) { if (!(error instanceof JobCancelError)) throw error; const mapped = error.code === 'NOT_FOUND' ? { status: 404, title: 'Not Found', detail: 'The requested job does not exist.', } : { status: 409, title: 'Conflict', detail: 'The job already reached a terminal state.', }; return reply.code(mapped.status).type('application/problem+json').send({ type: 'about:blank', title: mapped.title, status: mapped.status, code: error.code, detail: mapped.detail, requestId: request.id, }); } return reply.code(202).send(options.jobs.get(jobId)); }); } }