created a new branch with alert functionality and added the compose files etc ..

This commit is contained in:
Siavash Sameni
2025-08-26 16:15:20 +04:00
parent 0d06090865
commit 6c4a8dfe83
13 changed files with 980 additions and 58 deletions

38
utils/cronhost.ts Normal file
View File

@@ -0,0 +1,38 @@
// NOTE: Real cronhost API integration pending official REST docs.
// These stubs simulate scheduling/canceling and should be replaced with actual fetch calls.
export interface ScheduleParams {
apiKey: string;
runAt: number; // epoch seconds
method: 'POST' | 'GET';
url: string;
body?: any;
headers?: Record<string, string>;
}
export interface ScheduleResult {
jobId: string;
scheduledRunAtUtc: number;
}
export async function scheduleOneTimeJob(params: ScheduleParams): Promise<ScheduleResult> {
const { apiKey, runAt, method, url } = params;
if (!apiKey) throw new Error('Missing cronhost API key');
if (!url) throw new Error('Missing target URL');
if (!runAt || runAt < Math.floor(Date.now() / 1000)) throw new Error('runAt must be in the future');
console.warn('[cronhost] scheduleOneTimeJob is a stub. Replace with real API call.');
// Example real call outline:
// await fetch('https://api.cronho.st/schedules', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({...}) })
return {
jobId: `stub_${Math.random().toString(36).slice(2)}`,
scheduledRunAtUtc: runAt,
};
}
export async function cancelJob(apiKey: string, jobId: string): Promise<void> {
if (!apiKey || !jobId) return;
console.warn('[cronhost] cancelJob is a stub. Replace with real API call.', { jobId });
// Example: await fetch(`https://api.cronho.st/jobs/${jobId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` } })
}

138
utils/scheduler.ts Normal file
View File

@@ -0,0 +1,138 @@
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 } : {}),
},
});
}

42
utils/useLocalStorage.ts Normal file
View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
export function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
try {
if (typeof window !== 'undefined') {
const raw = localStorage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw);
console.log('[useLocalStorage] Init from storage', { key, value: parsed });
return parsed as T;
}
}
} catch {}
return initial;
});
useEffect(() => {
try {
const raw = typeof window !== 'undefined' ? localStorage.getItem(key) : null;
if (raw) {
const parsed = JSON.parse(raw);
console.log('[useLocalStorage] Loaded', { key, value: parsed });
setValue(parsed);
} else {
console.log('[useLocalStorage] No existing value, using initial', { key, initial });
}
} catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(() => {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(key, JSON.stringify(value));
console.log('[useLocalStorage] Saved', { key, value });
}
} catch {}
}, [key, value]);
return [value, setValue] as const;
}