Files
mortgagefi-helper/utils/scheduler.ts

139 lines
6.1 KiB
TypeScript

import { NotificationSettings } from '@/types/notifications';
export interface ScheduleArgs {
runAtEpoch: number; // seconds
method: 'POST' | 'GET';
url: string;
body?: any;
headers?: Record<string, string>;
}
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<ScheduleResult> {
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<void> {
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<string,string>; payload?: any }
async function schedyList(baseUrl: string, apiKey: string): Promise<SchedyTask[]> {
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<void> {
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<number> {
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<ScheduleResult> {
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<void> {
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<ScheduleResult> {
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 } : {}),
},
});
}