import { SendJobStatus, prisma } from '../lib/prisma'; import { logger } from '../lib/logger'; export async function logJobRun( jobName: string, success: boolean, message?: string, ): Promise { await prisma.jobRunLog.create({ data: { jobName, success, message }, }); } export async function getJobStatuses(): Promise< Array<{ jobName: string; lastRunAt: string | null; success: boolean | null; message: string | null }> > { const jobNames = [ 'campaign-dispatcher', 'rescuer', 'campaign-rollover', 'stats-rollup', ]; const results = []; for (const jobName of jobNames) { const last = await prisma.jobRunLog.findFirst({ where: { jobName }, orderBy: { ranAt: 'desc' }, }); results.push({ jobName, lastRunAt: last?.ranAt.toISOString() ?? null, success: last?.success ?? null, message: last?.message ?? null, }); } const pendingSends = await prisma.sendJob.count({ where: { status: SendJobStatus.pending }, }); return results.map((row) => row.jobName === 'campaign-dispatcher' ? { ...row, message: row.message ?? `${pendingSends} queued sends` } : row, ); } export async function enqueueSendJob( campaignId: string, scheduledAt: Date = new Date(), ): Promise { const dayKey = scheduledAt.toISOString().slice(0, 10); const idempotencyKey = `${campaignId}:${dayKey}:${scheduledAt.getTime()}`; const existing = await prisma.sendJob.findUnique({ where: { idempotencyKey }, }); if (existing) { return existing.id; } const job = await prisma.sendJob.create({ data: { campaignId, scheduledAt, idempotencyKey, status: SendJobStatus.pending, }, }); logger.debug({ campaignId, jobId: job.id }, 'Send job enqueued'); return job.id; } const STALE_SEND_JOB_MS = 60 * 60 * 1000; export async function reclaimStaleSendJobs(staleMs = STALE_SEND_JOB_MS): Promise { const cutoff = new Date(Date.now() - staleMs); const result = await prisma.sendJob.updateMany({ where: { status: SendJobStatus.processing, updatedAt: { lt: cutoff }, }, data: { status: SendJobStatus.failed, errorMessage: 'Stale processing job reclaimed', processedAt: new Date(), }, }); if (result.count > 0) { logger.warn({ count: result.count }, 'Reclaimed stale send jobs stuck in processing'); } return result.count; } export async function claimDueSendJob(campaignId: string) { const now = new Date(); const job = await prisma.sendJob.findFirst({ where: { campaignId, status: SendJobStatus.pending, scheduledAt: { lte: now }, }, orderBy: { scheduledAt: 'asc' }, }); if (!job) return null; const updated = await prisma.sendJob.updateMany({ where: { id: job.id, status: SendJobStatus.pending }, data: { status: SendJobStatus.processing }, }); if (updated.count === 0) return null; return job; } export async function completeSendJob( jobId: string, success: boolean, errorMessage?: string, ): Promise { await prisma.sendJob.update({ where: { id: jobId }, data: { status: success ? SendJobStatus.completed : SendJobStatus.failed, processedAt: new Date(), errorMessage, }, }); }