Alerts: backup-contact phrasing; correct backup countdown; include per-position pay link; deep-link route rendering in-place; single-position view via deep-link; ARB alias; backup test message alignment
This commit is contained in:
@@ -55,6 +55,7 @@ export default function DappPage() {
|
||||
const [presetKey, setPresetKey] = useState<string>('cbBTC-USDC');
|
||||
const [manualWallet, setManualWallet] = useState<string>('');
|
||||
const [manualTokenId, setManualTokenId] = useState('');
|
||||
const [deeplinkTokenId, setDeeplinkTokenId] = useState('');
|
||||
const [detectedTokenIds, setDetectedTokenIds] = useState<bigint[]>([]);
|
||||
const [scanBusy, setScanBusy] = useState(false);
|
||||
const [scanComplete, setScanComplete] = useState(false);
|
||||
@@ -86,6 +87,8 @@ export default function DappPage() {
|
||||
schedyBaseUrl: ENV_SCHEDY,
|
||||
schedyApiKey: ENV_SCHEDY_API_KEY,
|
||||
email: '',
|
||||
backupEmail: '',
|
||||
backupDelayDays: 1,
|
||||
daysBefore: 10,
|
||||
});
|
||||
useEffect(() => {
|
||||
@@ -143,6 +146,32 @@ export default function DappPage() {
|
||||
complete?: boolean; // when true, stop further ownerOf scans (gap limit reached)
|
||||
};
|
||||
|
||||
// Consume deep-link payload from /dapp/position/[network]/[preset]/[tokenId]
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
const raw = localStorage.getItem('dapp:deeplink:v1');
|
||||
if (!raw) return;
|
||||
localStorage.removeItem('dapp:deeplink:v1');
|
||||
const parsed = JSON.parse(raw || '{}') as { network?: string; preset?: number; tokenId?: string; ts?: number };
|
||||
const age = Math.floor(Date.now() / 1000) - (parsed.ts || 0);
|
||||
if (age > 600) return; // ignore stale links >10min
|
||||
const net = String(parsed.network || '').toUpperCase();
|
||||
const presetIdx = Math.max(1, Number(parsed.preset || 1));
|
||||
const nextChainId = net === 'BASE' ? base.id : ((net === 'ARBITRUM' || net === 'ARB') ? arbitrum.id : selectedChainId);
|
||||
if (nextChainId !== selectedChainId) {
|
||||
setSelectedChainId(nextChainId);
|
||||
loadChainDefaults(nextChainId);
|
||||
}
|
||||
const list = PRESETS[nextChainId] || [];
|
||||
const item = list[presetIdx - 1];
|
||||
if (item?.key) setPresetKey(item.key);
|
||||
// Force single-position view for deep link
|
||||
if (parsed.tokenId) setDeeplinkTokenId(String(parsed.tokenId));
|
||||
} catch {}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
// Utility: sleep for backoff
|
||||
@@ -397,9 +426,12 @@ export default function DappPage() {
|
||||
|
||||
// Resolve tokenIds: either detected or manual entry
|
||||
const tokenIds = useMemo(() => {
|
||||
if (deeplinkTokenId.trim()) {
|
||||
try { return [BigInt(deeplinkTokenId.trim())]; } catch { return []; }
|
||||
}
|
||||
const manual = manualTokenId.trim() ? [BigInt(manualTokenId.trim())] : [];
|
||||
return [...detectedTokenIds, ...manual];
|
||||
}, [detectedTokenIds, manualTokenId]);
|
||||
}, [detectedTokenIds, manualTokenId, deeplinkTokenId]);
|
||||
|
||||
// Build reads for debt contract
|
||||
const debtReads = useMemo(() => {
|
||||
@@ -622,14 +654,15 @@ export default function DappPage() {
|
||||
};
|
||||
|
||||
// Helpers to construct provider webhook URL/body for cronhost
|
||||
const buildNotificationRequest = (row: { tokenId: bigint; secondsTillLiq?: bigint }, message: string) => {
|
||||
const buildNotificationRequest = (row: { tokenId: bigint; secondsTillLiq?: bigint }, message: string, opts?: { email?: 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);
|
||||
const targetEmail = (opts?.email || notifSettings.email || '').trim();
|
||||
if (targetEmail) headers['X-Email'] = String(targetEmail);
|
||||
return {
|
||||
url: `${baseUrl}/${encodeURIComponent(topic)}`,
|
||||
method: 'POST' as const,
|
||||
@@ -685,7 +718,11 @@ export default function DappPage() {
|
||||
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 origin = (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : 'https://markets.mortgagefi.app';
|
||||
const dappUrl = `${origin}/dapp`;
|
||||
const networkSlug = selectedChainId === base.id ? 'BASE' : (selectedChainId === arbitrum.id ? 'ARBITRUM' : 'UNKNOWN');
|
||||
const presetIdx = Math.max(0, (PRESETS[selectedChainId]?.findIndex(p => p.key === presetKey) ?? 0)) + 1; // 1-based
|
||||
const positionUrl = `${origin}/dapp/position/${networkSlug}/${presetIdx}/${row.tokenId.toString()}`;
|
||||
const humanLeft = human(offset);
|
||||
|
||||
const mul15 = (v: bigint) => (v * BigInt(3)) / BigInt(2);
|
||||
@@ -705,13 +742,38 @@ export default function DappPage() {
|
||||
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 msg = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeft}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Pay link: ${positionUrl}. 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 liqAt = nowSec + Math.max(0, seconds);
|
||||
const runAt1 = liqAt - offset;
|
||||
if (runAt1 <= nowSec) throw new Error('Computed run time is in the past');
|
||||
// Primary job (to main email)
|
||||
const req1 = buildNotificationRequest(row, msg);
|
||||
const res1 = await scheduleJob(notifSettings, { runAtEpoch: runAt1, method: req1.method, url: req1.url, body: req1.body, headers: req1.headers });
|
||||
|
||||
// Optional backup job
|
||||
const jobs: Array<{ id: string; at: number; label: 'lead' | 'half' | 'last' }> = [
|
||||
{ id: res1.jobId, at: runAt1, label: 'lead' },
|
||||
];
|
||||
const bEmail = (notifSettings.backupEmail || '').trim();
|
||||
const bDelayDays = Math.max(0, Number(notifSettings.backupDelayDays ?? 0));
|
||||
if (bEmail && bDelayDays >= 1) {
|
||||
const requested = runAt1 + Math.floor(bDelayDays * 86400);
|
||||
const min1dBeforeLiq = liqAt - 86400; // must be >= 1 day before liq
|
||||
const runAt2 = Math.min(requested, min1dBeforeLiq);
|
||||
if (runAt2 > nowSec && runAt2 > runAt1) {
|
||||
const primaryEmail = (notifSettings.email || '').trim();
|
||||
const prefix = `You are the backup contact for this position. Please contact the primary contact${primaryEmail ? ' ' + primaryEmail : ''} immediately. If they do not respond, please pay down the debt to avoid liquidation in a timely manner.`;
|
||||
const humanLeftBackup = human(Math.max(0, liqAt - runAt2));
|
||||
const msgBackupCore = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeftBackup}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Pay link: ${positionUrl}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`;
|
||||
const backupMsg = `${prefix}\n\n${msgBackupCore}`;
|
||||
const req2 = buildNotificationRequest(row, backupMsg, { email: bEmail });
|
||||
const res2 = await scheduleJob(notifSettings, { runAtEpoch: runAt2, method: req2.method, url: req2.url, body: req2.body, headers: req2.headers });
|
||||
jobs.push({ id: res2.jobId, at: runAt2, label: 'last' });
|
||||
}
|
||||
}
|
||||
|
||||
return { jobs };
|
||||
};
|
||||
|
||||
const cancelNotification = async (p: PositionNotification) => {
|
||||
@@ -800,13 +862,15 @@ export default function DappPage() {
|
||||
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
|
||||
const scheduledAtLead = (p.jobs?.find(j => j.label === 'lead')?.at) || (p.scheduledAt || 0);
|
||||
const drift = Math.abs(scheduledAtLead - targetRunAt);
|
||||
const hasAnyJob = (p.jobs && p.jobs.length > 0) || Boolean(p.jobId);
|
||||
if (!hasAnyJob || drift > 60) {
|
||||
// reschedule
|
||||
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 } }));
|
||||
const { jobs } = await scheduleNotification(row);
|
||||
setPositionsNotif((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobs } }));
|
||||
} catch (e) {
|
||||
console.warn('[notif] reschedule failed', e);
|
||||
}
|
||||
@@ -983,7 +1047,7 @@ export default function DappPage() {
|
||||
<div className="rounded border p-4 bg-gray-800 border-gray-700">
|
||||
<div className="font-medium text-gray-100">About</div>
|
||||
<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>
|
||||
<p className="text-xs text-gray-400 mt-2">{selectedChainId === base.id ? 'Base' : selectedChainId === arbitrum.id ? 'Arbitrum' : 'Selected'} network is required. The app will attempt to detect your token IDs via on-chain queries when supported.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4 bg-gray-800 border-gray-700 space-y-3">
|
||||
@@ -1168,10 +1232,10 @@ export default function DappPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { jobId, runAt } = await scheduleNotification(row);
|
||||
const { jobs } = await scheduleNotification(row);
|
||||
setPositionsNotif((prev) => ({
|
||||
...prev,
|
||||
[key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt },
|
||||
[key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobs },
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('[notif] schedule failed', e);
|
||||
@@ -1243,9 +1307,7 @@ export default function DappPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{/* Footer note removed per requirements */}
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
|
||||
26
app/dapp/position/[network]/[preset]/[tokenId]/page.tsx
Normal file
26
app/dapp/position/[network]/[preset]/[tokenId]/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import DappPage from "../../../../page";
|
||||
|
||||
export default function PositionDeepLinkPage() {
|
||||
const params = useParams<{ network: string; preset: string; tokenId: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const network = String(params?.network || '').toUpperCase();
|
||||
const preset = Number(params?.preset || '1');
|
||||
const tokenId = String(params?.tokenId || '');
|
||||
if (!network || !preset || !tokenId) return;
|
||||
// Persist for Dapp page to consume on mount; keep URL unchanged
|
||||
const payload = { network, preset, tokenId, ts: Math.floor(Date.now() / 1000) };
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('dapp:deeplink:v1', JSON.stringify(payload));
|
||||
}
|
||||
} catch {}
|
||||
}, [params]);
|
||||
|
||||
// Render the main Dapp UI without redirect; it will consume the deep-link
|
||||
return <DappPage />;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { NotificationSettings, NotificationProvider, SchedulerProvider } from "@/types/notifications";
|
||||
import { scheduleTestNotification } from "@/utils/scheduler";
|
||||
import { scheduleTestNotification, scheduleTestBackupNotification } from "@/utils/scheduler";
|
||||
|
||||
export interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -28,6 +28,8 @@ const defaultSettings: NotificationSettings = {
|
||||
schedyBaseUrl: ENV_SCHEDY,
|
||||
schedyApiKey: ENV_SCHEDY_API_KEY,
|
||||
email: "",
|
||||
backupEmail: "",
|
||||
backupDelayDays: 1,
|
||||
daysBefore: 10,
|
||||
};
|
||||
|
||||
@@ -37,13 +39,12 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const base: any = initial || defaultSettings;
|
||||
const next: any = { ...base };
|
||||
if (next.scheduler !== 'schedy') next.scheduler = 'schedy';
|
||||
for (const k of ['cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId']) {
|
||||
if (k in next) delete next[k];
|
||||
}
|
||||
setForm(next);
|
||||
// Merge defaults to ensure newly added fields (e.g., backupDelayDays) are populated
|
||||
const merged = { ...defaultSettings, ...(initial || {}) } as NotificationSettings;
|
||||
// Re-apply env defaults if user hasn't set values
|
||||
if (!merged.ntfyServer) merged.ntfyServer = ENV_NTFY;
|
||||
if (!merged.schedyBaseUrl) merged.schedyBaseUrl = ENV_SCHEDY;
|
||||
setForm(merged);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initial]);
|
||||
@@ -55,6 +56,12 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
|
||||
if (!form.email) return true;
|
||||
const db = form.daysBefore ?? 10;
|
||||
if (Number(db) < 0) return true;
|
||||
// if backupEmail provided, enforce min backupDelayDays >= 1 (treat empty/NaN as invalid)
|
||||
const hasBackup = Boolean((form.backupEmail || '').trim());
|
||||
if (hasBackup) {
|
||||
const bdd = Number(form.backupDelayDays);
|
||||
if (!Number.isFinite(bdd) || bdd < 1) return true;
|
||||
}
|
||||
if (!provider) return true;
|
||||
if (!scheduler) return true;
|
||||
if (provider === 'ntfy') return !(form.ntfyServer && form.ntfyTopic);
|
||||
@@ -80,6 +87,16 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
|
||||
}
|
||||
};
|
||||
|
||||
const onTestBackup = async () => {
|
||||
setTestStatus('Scheduling backup test…');
|
||||
try {
|
||||
const { jobId } = await scheduleTestBackupNotification(form);
|
||||
setTestStatus(`Backup test scheduled (job ${jobId}). You should receive a backup email shortly.`);
|
||||
} catch (e: any) {
|
||||
setTestStatus(`Backup test failed: ${e?.message || String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
@@ -146,6 +163,16 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="text-gray-300">Backup Email (optional)</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
placeholder="backup@example.com"
|
||||
value={form.backupEmail || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, backupEmail: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="text-gray-300">Days before liquidation</span>
|
||||
<input
|
||||
@@ -156,7 +183,16 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
|
||||
onChange={(e) => setForm((f) => ({ ...f, daysBefore: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
<div />
|
||||
<label>
|
||||
<span className="text-gray-300">Backup delay (days after first email)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
value={form.backupDelayDays ?? 1}
|
||||
onChange={(e) => setForm((f) => ({ ...f, backupDelayDays: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{provider === 'ntfy' && (
|
||||
<>
|
||||
@@ -260,6 +296,14 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
|
||||
>
|
||||
Send test alert
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-teal-600 px-3 py-1.5 disabled:opacity-50"
|
||||
disabled={!canTest || !(form.backupEmail || '').trim()}
|
||||
onClick={onTestBackup}
|
||||
title={!((form.backupEmail || '').trim()) ? 'Set a backup email first' : ''}
|
||||
>
|
||||
Send backup test
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-indigo-600 px-3 py-1.5 disabled:opacity-50"
|
||||
disabled={saveDisabled}
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface NotificationSettings {
|
||||
schedyApiKey?: string;
|
||||
// Contact details
|
||||
email?: string;
|
||||
// Backup contact email and delay after first email
|
||||
backupEmail?: string;
|
||||
// Days after the first email to send a backup notification
|
||||
backupDelayDays?: number;
|
||||
// Lead time before liquidation in days
|
||||
daysBefore?: number; // default 0
|
||||
}
|
||||
|
||||
@@ -8,6 +8,39 @@ export interface ScheduleArgs {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Schedule a backup-email specific test (uses backupEmail target)
|
||||
export async function scheduleTestBackupNotification(settings: NotificationSettings): Promise<ScheduleResult> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user