import fs from 'node:fs'; import path from 'node:path'; import type { SiprouterStorage } from './storage.ts'; import type { IFaxCompletedEvent, IFaxFailedEvent, IFaxStartedEvent, } from './shared/proxy-events.ts'; export interface IFaxJob { id: string; callId: string; number?: string; providerId?: string; direction: 'outbound' | 'inbound'; status: 'dialing' | 'started' | 'completed' | 'failed'; transport?: 'audio' | 't38'; filePath?: string; objectKey?: string; codec?: string; remoteMedia?: string; success?: boolean; completionCode?: number | null; completionLabel?: string | null; error?: string; stats?: IFaxCompletedEvent['stats']; createdAt: number; updatedAt: number; } export class FaxJobManager { private jobs: IFaxJob[] = []; private readonly log: (msg: string) => void; private readonly storage: SiprouterStorage; constructor(log: (msg: string) => void, storageArg: SiprouterStorage) { this.log = log; this.storage = storageArg; } async init(): Promise { this.jobs = await this.storage.getFaxJobs(); } async noteDialing(callId: string, number: string, providerId: string): Promise { const jobs = this.jobs; const now = Date.now(); const existing = jobs.find((job) => job.callId === callId); if (existing) { existing.number = number; existing.providerId = providerId; existing.updatedAt = now; } else { jobs.unshift({ id: callId, callId, number, providerId, direction: 'outbound', status: 'dialing', createdAt: now, updatedAt: now, }); } await this.writeJobs(); } async noteStarted(event: IFaxStartedEvent): Promise { const now = Date.now(); const job = this.getOrCreateJob(event.call_id, event.direction, now); job.status = 'started'; job.transport = event.transport; job.filePath = event.file_path; await this.ensureOutboundFileObject(job, event.file_path); job.codec = event.codec; job.remoteMedia = event.remote_media; job.updatedAt = now; await this.writeJobs(); } async noteCompleted(event: IFaxCompletedEvent): Promise { const now = Date.now(); const job = this.getOrCreateJob(event.call_id, event.direction, now); job.status = 'completed'; job.transport = event.transport; job.filePath = event.file_path; await this.ensureOutboundFileObject(job, event.file_path); job.codec = event.codec; job.success = event.success; job.completionCode = event.completion_code ?? null; job.completionLabel = event.completion_label ?? null; job.stats = event.stats; job.updatedAt = now; await this.writeJobs(); } async noteFailed(event: IFaxFailedEvent): Promise { const now = Date.now(); const job = this.getOrCreateJob(event.call_id, event.direction, now); job.status = 'failed'; job.transport = event.transport; job.filePath = event.file_path; await this.ensureOutboundFileObject(job, event.file_path); job.error = event.error; job.success = false; job.updatedAt = now; await this.writeJobs(); } getJobs(): IFaxJob[] { return [...this.jobs]; } private getOrCreateJob( callId: string, direction: 'outbound' | 'inbound', now: number, ): IFaxJob { let job = this.jobs.find((entry) => entry.callId === callId); if (!job) { job = { id: callId, callId, direction, status: 'dialing', createdAt: now, updatedAt: now, }; this.jobs.unshift(job); } return job; } private async ensureOutboundFileObject(jobArg: IFaxJob, filePathArg: string | undefined): Promise { if (jobArg.direction !== 'outbound' || jobArg.objectKey || !filePathArg) return; const localPath = path.isAbsolute(filePathArg) ? filePathArg : path.join(process.cwd(), filePathArg); if (!fs.existsSync(localPath)) return; const extension = path.extname(localPath) || '.tif'; jobArg.objectKey = await this.storage.putFileObject(`fax/outbound/${jobArg.callId}${extension}`, localPath); } private async writeJobs(): Promise { await this.storage.writeFaxJobs(this.jobs); this.log(`[fax] persisted ${this.jobs.length} job(s)`); } }