import { NotificationSettings } from '@/types/notifications'; export interface ScheduleArgs { runAtEpoch: number; // seconds method: 'POST' | 'GET'; url: string; body?: any; headers?: Record; } // Schedule a backup-email specific test (uses backupEmail target) export async function scheduleTestBackupNotification(settings: NotificationSettings): Promise { const scheduler = settings.scheduler || 'schedy'; if (scheduler !== 'schedy') throw new Error('Only Schedy is supported for tests'); const backup = (settings.backupEmail || '').trim(); if (!backup) throw new Error('Please set a backup email'); if (settings.provider !== 'ntfy') { throw new Error('Backup test currently supports ntfy provider only'); } const topic = (settings.ntfyTopic || '').trim(); const base = (settings.ntfyServer || process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, ''); if (!base || !topic) throw new Error('ntfy server or topic missing'); const relUrl = `${base}/${encodeURIComponent(topic)}`; const now = Math.floor(Date.now() / 1000); const runAtEpoch = now + 120; // 2 minutes const primary = (settings.email || '').trim(); const prefix = `You are the backup contact for this position. Please contact the primary contact${primary ? ' ' + primary : ''} immediately. If they do not respond, please pay down the debt to avoid liquidation in a timely manner.`; const body = `${prefix}\n\n(MortgageFi backup email test at ${new Date().toISOString()})`; return schedySchedule((settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''), settings.schedyApiKey || '', { runAtEpoch, method: 'POST', url: relUrl, body, headers: { 'Content-Type': 'text/plain', 'X-Email': backup, }, }); } export interface ScheduleResult { jobId: string; scheduledRunAtUtc: number } // Resolve an absolute callback URL for schedulers that execute webhooks server-side function toAbsoluteUrl(url: string): string { // Already absolute if (/^https?:\/\//i.test(url)) return url; // If relative, prefer an explicit callback origin if provided const explicit = (process.env.NEXT_PUBLIC_CALLBACK_ORIGIN || '').replace(/\/$/, ''); if (explicit) return `${explicit}${url.startsWith('/') ? '' : '/'}${url}`; // Fallback to browser origin (client-side only). If unavailable, return as-is. try { if (typeof window !== 'undefined' && window.location?.origin) { return `${window.location.origin}${url.startsWith('/') ? '' : '/'}${url}`; } } catch {} return url; } // Schedy implementation async function schedySchedule(baseUrl: string, apiKey: string, args: ScheduleArgs): Promise { const executeAt = new Date(args.runAtEpoch * 1000).toISOString(); // RFC3339 UTC const targetUrl = toAbsoluteUrl(args.url); const res = await fetch(`${baseUrl.replace(/\/$/, '')}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, body: JSON.stringify({ execute_at: executeAt, url: targetUrl, headers: args.headers || {}, payload: args.body ?? undefined, }), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Schedy schedule failed: ${res.status} ${text}`); } const json = await res.json().catch(() => ({} as any)); const id = json?.id || json?.task_id || `schedy_${Math.random().toString(36).slice(2)}`; return { jobId: String(id), scheduledRunAtUtc: args.runAtEpoch }; } async function schedyCancel(baseUrl: string, apiKey: string, jobId: string): Promise { await fetch(`${baseUrl.replace(/\/$/, '')}/tasks/${encodeURIComponent(jobId)}`, { method: 'DELETE', headers: { 'X-API-Key': apiKey } }); } // --- Schedy management helpers --- interface SchedyTask { id: string; url: string; execute_at?: string; headers?: Record; payload?: any } async function schedyList(baseUrl: string, apiKey: string): Promise { const res = await fetch(`${baseUrl.replace(/\/$/, '')}/tasks`, { headers: { 'X-API-Key': apiKey } }); if (!res.ok) throw new Error(`Schedy list failed: ${res.status}`); const json = await res.json().catch(() => []); return Array.isArray(json) ? json as SchedyTask[] : []; } async function schedyDelete(baseUrl: string, apiKey: string, id: string): Promise { await fetch(`${baseUrl.replace(/\/$/, '')}/tasks/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'X-API-Key': apiKey } }); } // Compute absolute ntfy topic URL for comparison (matches what schedule uses) function absoluteNtfyUrl(settings: NotificationSettings): string | null { const topic = (settings.ntfyTopic || '').trim(); if (!topic) return null; const base = (settings.ntfyServer || process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, ''); const rel = `${base}/${encodeURIComponent(topic)}`; return toAbsoluteUrl(rel); } export async function purgeNtfyTopicSchedules(settings: NotificationSettings): Promise { const scheduler = settings.scheduler || 'cronhost'; if (scheduler !== 'schedy') return 0; // only implemented for schedy const base = (settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''); const key = settings.schedyApiKey || ''; if (!base || !key) throw new Error('Schedy base URL or API key missing'); const targetUrl = absoluteNtfyUrl(settings); if (!targetUrl) return 0; const tasks = await schedyList(base, key); const victims = tasks.filter(t => t.url === targetUrl); await Promise.allSettled(victims.map(v => schedyDelete(base, key, v.id))); return victims.length; } const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, ''); export async function scheduleJob(settings: NotificationSettings, args: ScheduleArgs): Promise { const base = (settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''); const key = settings.schedyApiKey || ''; if (!base || !key) throw new Error('Schedy base URL or API key missing'); return schedySchedule(base, key, args); } export async function cancelScheduledJob(settings: NotificationSettings, jobId: string): Promise { const base = (settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''); const key = settings.schedyApiKey || ''; if (!base || !key || !jobId) return; return schedyCancel(base, key, jobId); } // Schedule a quick test notification to validate configuration end-to-end. export async function scheduleTestNotification(settings: NotificationSettings): Promise { const scheduler = settings.scheduler || 'schedy'; if (scheduler !== 'schedy') throw new Error('Only Schedy is supported for tests'); if (!settings.email) throw new Error('Please set an email (used to validate requirements)'); // Currently support ntfy provider for E2E test if (settings.provider !== 'ntfy') { throw new Error('Test alert currently supports ntfy provider only'); } const topic = (settings.ntfyTopic || '').trim(); const base = (settings.ntfyServer || process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, ''); if (!base || !topic) throw new Error('ntfy server or topic missing'); const relUrl = `${base}/${encodeURIComponent(topic)}`; // can be relative; toAbsoluteUrl will fix const now = Math.floor(Date.now() / 1000); const runAtEpoch = now + 120; // 2 minutes from now const body = `MortgageFi test alert at ${new Date().toISOString()}`; return schedySchedule((settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''), settings.schedyApiKey || '', { runAtEpoch, method: 'POST', url: relUrl, body, headers: { 'Content-Type': 'text/plain', ...(settings.email ? { 'X-Email': settings.email } : {}), }, }); }