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

View File

@@ -6,6 +6,11 @@ import { base, arbitrum } from 'wagmi/chains';
import { Abi, parseUnits } from 'viem';
import debtAbi from '@/ABIs/mortgagefiusdccbbtcupgraded.json';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import SettingsModal from '@/components/SettingsModal';
import { useLocalStorage } from '@/utils/useLocalStorage';
import type { NotificationSettings, PositionNotification } from '@/types/notifications';
import { scheduleJob, cancelScheduledJob, purgeNtfyTopicSchedules } from '@/utils/scheduler';
// Minimal ERC-721 ABI for balance/owner/enumeration
const erc721Abi = [
@@ -42,6 +47,7 @@ export default function DappPage() {
const { address, isConnected } = useAccount();
const chainId = useChainId();
const { switchChain } = useSwitchChain();
const searchParams = useSearchParams();
const [selectedChainId, setSelectedChainId] = useState<number>(base.id);
const [nftAddress, setNftAddress] = useState<string>(DEFAULTS[base.id].nft);
@@ -57,6 +63,77 @@ export default function DappPage() {
const { writeContractAsync, isPending: writePending } = useWriteContract();
const { sendTransactionAsync, isPending: txPending } = useSendTransaction();
// Notification settings (global) and per-position preferences
const [settingsOpen, setSettingsOpen] = useState(false);
useEffect(() => {
if (searchParams?.get('settings') === '1') setSettingsOpen(true);
}, [searchParams]);
const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, '');
const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, '');
const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim();
const [notifSettings, setNotifSettings] = useLocalStorage<NotificationSettings>('notif:settings', {
provider: '',
scheduler: 'schedy',
ntfyServer: ENV_NTFY,
ntfyTopic: '',
gotifyServer: '',
gotifyToken: '',
snsRegion: '',
snsTopicArn: '',
snsAccessKeyId: '',
snsSecretAccessKey: '',
schedyBaseUrl: ENV_SCHEDY,
schedyApiKey: ENV_SCHEDY_API_KEY,
email: '',
daysBefore: 10,
});
useEffect(() => {
console.log('[Settings] Current', notifSettings);
}, [notifSettings]);
// One-time migration: minutesBefore -> daysBefore
useEffect(() => {
const anySettings: any = notifSettings as any;
if (anySettings && anySettings.minutesBefore !== undefined && anySettings.daysBefore === undefined) {
const mins = Number(anySettings.minutesBefore) || 0;
const days = Math.max(0, Math.round(mins / 1440));
const next = { ...notifSettings, daysBefore: days } as NotificationSettings;
delete (next as any).minutesBefore;
console.log('[Settings] Migrating minutesBefore -> daysBefore', { mins, days });
setNotifSettings(next);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// One-time migration: legacy scheduler providers -> schedy; remove legacy fields
useEffect(() => {
const s: any = notifSettings as any;
if (!s) return;
let changed = false;
const next: any = { ...notifSettings };
// Normalize scheduler
if (!s.scheduler || s.scheduler !== 'schedy') {
next.scheduler = 'schedy';
changed = true;
}
// Remove legacy keys if present
const legacyKeys = ['cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId'];
for (const k of legacyKeys) {
if (k in next) {
delete next[k];
changed = true;
}
}
if (changed) {
console.log('[Settings] Migrating legacy scheduler -> schedy and pruning legacy fields');
setNotifSettings(next as NotificationSettings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const positionsStoreKey = useMemo(() => `notif:positions:v1:${selectedChainId}:${debtAddress?.toLowerCase()}`, [selectedChainId, debtAddress]);
const [positionsNotif, setPositionsNotif] = useLocalStorage<Record<string, PositionNotification>>(positionsStoreKey, {});
// Cache helpers
type WalletCache = {
lastScannedIndex: number;
@@ -544,6 +621,135 @@ export default function DappPage() {
}
};
// Helpers to construct provider webhook URL/body for cronhost
const buildNotificationRequest = (row: { tokenId: bigint; secondsTillLiq?: bigint }, message: string) => {
const provider = notifSettings.provider;
if (!provider) throw new Error('Notification provider not configured');
if (provider === 'ntfy') {
const baseUrl = (notifSettings.ntfyServer || 'https://ntfy.sh').replace(/\/$/, '');
const topic = notifSettings.ntfyTopic || '';
const headers: Record<string, string> = { 'Content-Type': 'text/plain' };
if ((notifSettings.email || '').trim()) headers['X-Email'] = String(notifSettings.email);
return {
url: `${baseUrl}/${encodeURIComponent(topic)}`,
method: 'POST' as const,
body: message,
headers,
};
}
if (provider === 'gotify') {
const baseUrl = (notifSettings.gotifyServer || '').replace(/\/$/, '');
const token = notifSettings.gotifyToken || '';
return {
url: `${baseUrl}/message?token=${encodeURIComponent(token)}`,
method: 'POST' as const,
body: { title: 'MortgageFi Alert', message, priority: 5 },
headers: { 'Content-Type': 'application/json' },
};
}
if (provider === 'sns') {
// Note: Direct SNS publish requires AWS SigV4; recommend server-side relay. Here we simply target a placeholder you control.
// User should set up a relay endpoint that publishes to SNS.
const relay = '/api/sns-relay';
return {
url: relay,
method: 'POST' as const,
body: { topicArn: notifSettings.snsTopicArn, region: notifSettings.snsRegion, message, email: notifSettings.email },
headers: { 'Content-Type': 'application/json' },
};
}
throw new Error('Unsupported provider');
};
// Single schedule at configured lead time (or 1 day by default)
const scheduleNotification = async (row: { tokenId: bigint; secondsTillLiq?: bigint }) => {
const seconds = Number(row.secondsTillLiq ?? 0);
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('Invalid liquidation timer');
const nowSec = Math.floor(Date.now() / 1000);
const leadSecsCfg = Math.max(0, Math.floor(Number(notifSettings.daysBefore ?? 0) * 86400));
const offset = leadSecsCfg > 0 ? leadSecsCfg : 86400; // default to 1 day
// Helper: humanize a duration in seconds
const human = (rem: number) => {
if (rem <= 0) return '0m';
const days = Math.floor(rem / 86400);
const hours = Math.floor((rem % 86400) / 3600);
const mins = Math.floor((rem % 3600) / 60);
if (days >= 2) return `${days}d ${hours}h`;
if (days === 1) return `1d ${hours}h`;
if (hours >= 1) return `${hours}h ${mins}m`;
return `${mins}m`;
};
// Amount suggestion: prefer total debt remaining; fallback to 1.5x monthly payment; else current payment pending
const monthly = (row as any)?.monthlyPaymentSize as bigint | undefined;
const paymentPending = (row as any)?.currentPaymentPending as bigint | undefined;
const debtRemaining = (row as any)?.debtAtThisSize as bigint | undefined;
const dappUrl = (typeof window !== 'undefined' && window.location?.origin) ? `${window.location.origin}/dapp` : 'https://markets.mortgagefi.app/dashboard';
const humanLeft = human(offset);
const mul15 = (v: bigint) => (v * BigInt(3)) / BigInt(2);
const formatToken = (v: bigint | undefined) => {
if (v === undefined) return null;
const dec = Number(stableDecimals ?? 6);
const factor = BigInt(10) ** BigInt(dec);
const int = v / factor;
const frac = (v % factor).toString().padStart(dec, '0').slice(0, 6);
return `${int.toString()}.${frac}`;
};
const payAmt15 = monthly !== undefined ? mul15(monthly) : undefined;
const payAmtStr = formatToken(debtRemaining ?? payAmt15 ?? paymentPending ?? BigInt(0));
const payClause = payAmtStr ? `Pay at least ${payAmtStr} USDC to avoid liquidation.` : `Make a payment to avoid liquidation.`;
// Collateral at risk: use same formatting as UI Loan Collateral
const collateralStr = fmt((row as any)?.coinSize as bigint | undefined, Number(coinDecimals ?? 8), 8);
const collateralSym = String(coinSymbol ?? '');
const msg = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeft}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`;
const runAt = nowSec + Math.max(0, seconds - offset);
if (runAt <= nowSec) throw new Error('Computed run time is in the past');
const req = buildNotificationRequest(row, msg);
const res = await scheduleJob(notifSettings, { runAtEpoch: runAt, method: req.method, url: req.url, body: req.body, headers: req.headers });
return { jobId: res.jobId, runAt };
};
const cancelNotification = async (p: PositionNotification) => {
// Cancel new multi-jobs first
if (p.jobs && p.jobs.length) {
for (const j of p.jobs) {
try { await cancelScheduledJob(notifSettings, j.id); } catch (e) { console.warn('[scheduler] cancel failed', e); }
}
}
// Back-compat: single jobId
if (p.jobId) {
try { await cancelScheduledJob(notifSettings, p.jobId); } catch (e) { console.warn('[scheduler] cancel failed', e); }
}
};
// Scheduled notifications helpers
const scheduledList = useMemo(() => {
const entries = Object.entries(positionsNotif || {});
return entries
.filter(([, v]) => v && v.enabled && ((v.jobs && v.jobs.length) || v.jobId || v.scheduledAt))
.map(([k, v]) => {
const earliest = v.jobs && v.jobs.length ? Math.min(...v.jobs.map(j => j.at)) : (v.scheduledAt || 0);
return { key: k, ...v, scheduledAt: earliest };
})
.sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0));
}, [positionsNotif]);
const deleteSchedule = async (key: string) => {
const p = positionsNotif[key];
if (!p) return;
try { await cancelNotification(p); } catch {}
setPositionsNotif((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
};
const parsed = useMemo(() => {
if (!debtResults || !tokenIds.length) return [] as Array<{
tokenId: bigint;
@@ -583,6 +789,33 @@ export default function DappPage() {
return out;
}, [debtResults, tokenIds]);
// Auto-reschedule when secondsTillLiq changes for enabled positions
useEffect(() => {
(async () => {
for (const row of parsed) {
const key = row.tokenId.toString();
const p = positionsNotif[key];
if (!p || !p.enabled) continue;
const seconds = Number(row.secondsTillLiq ?? 0);
if (!Number.isFinite(seconds) || seconds <= 0) continue;
const lead = Math.max(0, Number(notifSettings.daysBefore ?? 0) * 86400);
const targetRunAt = Math.floor(Date.now() / 1000) + Math.max(0, seconds - (lead > 0 ? lead : 86400));
const drift = Math.abs((p.scheduledAt || 0) - targetRunAt);
if (!p.jobId || drift > 60) {
// reschedule single
if (p) try { await cancelNotification(p); } catch {}
try {
const { jobId, runAt } = await scheduleNotification(row);
setPositionsNotif((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt } }));
} catch (e) {
console.warn('[notif] reschedule failed', e);
}
}
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parsed.map(r => String(r.tokenId)+':'+String(r.secondsTillLiq)).join(','), notifSettings.daysBefore, positionsStoreKey]);
// Map tokenId -> max payable (Amount to reset timer)
const payMaxByTokenId = useMemo(() => {
const m: Record<string, bigint> = {};
@@ -752,6 +985,50 @@ export default function DappPage() {
<p className="text-sm text-gray-300 mt-1">Enter your NFT contract and token ID(s), then fetch debt details from the mortgage contract.</p>
<p className="text-xs text-gray-400 mt-2">Base network is required. The app will attempt to detect your token IDs via on-chain queries.</p>
</div>
<div className="rounded border p-4 bg-gray-800 border-gray-700 space-y-3">
<div className="flex items-center justify-between">
<div className="font-medium text-gray-100">Scheduled notifications</div>
<button
className="text-xs px-2 py-1 border border-red-700 text-red-300 hover:bg-red-900/40 rounded"
onClick={async () => {
// Purge remotely from Schedy by ntfy topic URL
try { await purgeNtfyTopicSchedules(notifSettings); } catch (e) { console.warn('[schedy] purge failed', e); }
// Also clear any local state we track
if (scheduledList.length) {
for (const it of scheduledList) {
try { await deleteSchedule(it.key); } catch {}
}
}
}}
>
Delete all
</button>
</div>
{scheduledList.length === 0 ? (
<div className="text-sm text-gray-400">No schedules yet.</div>
) : (
<ul className="divide-y divide-gray-700">
{scheduledList.map((it) => (
<li key={it.key} className="py-2 flex items-center justify-between text-sm">
<div className="space-y-0.5">
<div className="text-gray-200">Token #{it.tokenId}</div>
<div className="text-gray-400">
{it.scheduledAt ? `Runs at ${new Date(it.scheduledAt * 1000).toLocaleString()}` : 'Scheduled'}
{it.jobId ? ` · Job ${it.jobId}` : ''}
</div>
</div>
<button
className="px-2 py-1 border border-gray-600 hover:bg-gray-700 rounded text-gray-200"
onClick={() => deleteSchedule(it.key)}
>
Delete
</button>
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-gray-100">
@@ -860,7 +1137,55 @@ export default function DappPage() {
<div className="text-gray-900">{row.startDate ? new Date(Number(row.startDate) * 1000).toLocaleDateString() : '-'}</div>
<div className="text-gray-800">Seconds Till Liquidation</div>
<div className="text-gray-900">{fmtDuration(row.secondsTillLiq)}</div>
<div className="text-gray-900 flex items-center gap-3">
<span>{fmtDuration(row.secondsTillLiq)}</span>
{!isClosed && (
(() => {
const key = row.tokenId.toString();
const p = positionsNotif[key];
const checked = Boolean(p?.enabled);
const disabled = row.secondsTillLiq === undefined || Number(row.secondsTillLiq) <= 0;
const onToggle = async (next: boolean) => {
// Ensure settings
const haveProvider = (() => {
if (!notifSettings.provider) return false;
if (notifSettings.provider === 'ntfy') return Boolean(notifSettings.ntfyServer && notifSettings.ntfyTopic);
if (notifSettings.provider === 'gotify') return Boolean(notifSettings.gotifyServer && notifSettings.gotifyToken);
if (notifSettings.provider === 'sns') return Boolean(notifSettings.snsRegion && notifSettings.snsTopicArn && notifSettings.snsAccessKeyId && notifSettings.snsSecretAccessKey);
return false;
})();
const haveScheduler = (() => {
const s = notifSettings.scheduler || 'schedy';
if (s !== 'schedy') return false;
return Boolean(notifSettings.schedyBaseUrl && notifSettings.schedyApiKey);
})();
const haveSettings = Boolean(haveProvider && haveScheduler && notifSettings.email);
if (next && !haveSettings) { setSettingsOpen(true); return; }
if (!next) {
// disable and cancel
if (p) await cancelNotification(p);
setPositionsNotif((prev) => ({ ...prev, [key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: false } }));
return;
}
try {
const { jobId, runAt } = await scheduleNotification(row);
setPositionsNotif((prev) => ({
...prev,
[key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt },
}));
} catch (e) {
console.warn('[notif] schedule failed', e);
}
};
return (
<label className="inline-flex items-center gap-1 text-xs text-gray-700">
<input type="checkbox" className="h-4 w-4" disabled={disabled} checked={checked} onChange={(e) => onToggle(e.target.checked)} />
Enable alert
</label>
);
})()
)}
</div>
</div>
{/* Pay controls (hidden for Closed/Defaulted loans) */}
@@ -921,6 +1246,18 @@ export default function DappPage() {
<div className="text-xs text-gray-500">
If there's a specific "start date" function in the ABI, let me know its exact name so I can add it to the reads.
</div>
{/* Settings Modal */}
<SettingsModal
open={settingsOpen}
initial={notifSettings as NotificationSettings}
onClose={() => setSettingsOpen(false)}
onSave={(s) => {
console.log('[Settings] Save requested', s);
setNotifSettings(s);
setSettingsOpen(false);
}}
/>
</div>
);
}