Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 45 additions & 20 deletions alerts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import { POOLS, LEVERAGE_BRACKETS, POOL_NAMES, fetchReserveRates, computeNetApy, type ReserveRates } from "./stellar.ts";
import { sendVerificationEmail, sendApyAlert } from "./email.ts";
import { notify, sendVerification, type ChannelType } from "./notify.ts";

interface Env {
DB: D1Database;
Expand Down Expand Up @@ -48,6 +48,8 @@ function corsHeaders(env: Env): Record<string, string> {
}

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const WEBHOOK_RE = /^https:\/\/(hooks\.slack\.com|discord\.com\/api\/webhooks)\//;
const VALID_CHANNELS = new Set<ChannelType>(["email", "slack", "discord"]);

/** Known pool IDs for validation. */
const KNOWN_POOL_IDS = new Set(POOLS.flatMap(p => [p.id]));
Expand Down Expand Up @@ -78,12 +80,27 @@ async function handleSubscribe(request: Request, env: Env): Promise<Response> {
return jsonResponse({ ok: false, error: "Invalid JSON" }, 400, env);
}

const { email, pool_id, asset_symbol, leverage_bracket } = body;
const { channel_type = "email", email, webhook_url, pool_id, asset_symbol, leverage_bracket } = body;

// Validate
if (!email || !EMAIL_RE.test(email)) {
return jsonResponse({ ok: false, error: "Invalid email" }, 400, env);
// Validate channel
if (!VALID_CHANNELS.has(channel_type)) {
return jsonResponse({ ok: false, error: "channel_type must be email, slack, or discord" }, 400, env);
}

// Resolve destination
let destination: string;
if (channel_type === "email") {
if (!email || !EMAIL_RE.test(email)) {
return jsonResponse({ ok: false, error: "Invalid email" }, 400, env);
}
destination = email;
} else {
if (!webhook_url || !WEBHOOK_RE.test(webhook_url)) {
return jsonResponse({ ok: false, error: `webhook_url must start with the official ${channel_type} webhook base URL` }, 400, env);
}
destination = webhook_url;
}

if (!KNOWN_POOL_IDS.has(pool_id)) {
return jsonResponse({ ok: false, error: "Unknown pool" }, 400, env);
}
Expand All @@ -100,32 +117,36 @@ async function handleSubscribe(request: Request, env: Env): Promise<Response> {

try {
await env.DB.prepare(`
INSERT INTO subscriptions (email, pool_id, asset_symbol, leverage_bracket, verify_token, unsub_token)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(email, pool_id, asset_symbol, leverage_bracket) DO UPDATE
SET verify_token = ?5, unsub_token = ?6, verified = 0
`).bind(email, pool_id, asset_symbol, lev, verifyToken, unsubToken).run();
INSERT INTO subscriptions (channel_type, destination, pool_id, asset_symbol, leverage_bracket, verify_token, unsub_token)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(destination, pool_id, asset_symbol, leverage_bracket) DO UPDATE
SET channel_type = ?1, verify_token = ?6, unsub_token = ?7, verified = 0
`).bind(channel_type, destination, pool_id, asset_symbol, lev, verifyToken, unsubToken).run();
} catch (e: any) {
console.error("DB insert failed:", e);
return jsonResponse({ ok: false, error: "Database error" }, 500, env);
}

// Send verification email
const base = workerUrl(request);
const verifyUrl = `${base}/verify?token=${verifyToken}`;

const result = await sendVerificationEmail(
const result = await sendVerification(
{ RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM },
email,
channel_type,
destination,
verifyUrl,
);

if (!result.ok) {
console.error("Failed to send verification email:", result.error);
return jsonResponse({ ok: false, error: "Failed to send verification email" }, 500, env);
console.error("Failed to send verification:", result.error);
return jsonResponse({ ok: false, error: "Failed to send verification" }, 500, env);
}

return jsonResponse({ ok: true, message: "Check your email to verify your subscription." }, 200, env);
const channelMsg = channel_type === "email"
? "Check your email to verify your subscription."
: `Check your ${channel_type} channel to verify your subscription.`;

return jsonResponse({ ok: true, message: channelMsg }, 200, env);
}

async function handleVerify(request: Request, env: Env): Promise<Response> {
Expand Down Expand Up @@ -209,7 +230,7 @@ async function handleCron(env: Env): Promise<void> {

// Find verified subscribers who haven't been alerted in the last 24h
const subs = await env.DB.prepare(`
SELECT id, email, unsub_token
SELECT id, channel_type, destination, unsub_token
FROM subscriptions
WHERE pool_id = ?1
AND asset_symbol = ?2
Expand All @@ -224,9 +245,13 @@ async function handleCron(env: Env): Promise<void> {

for (const sub of subs.results) {
const unsubUrl = `https://turbolong-alerts.workers.dev/unsubscribe?token=${sub.unsub_token}`;
const result = await sendApyAlert(
const result = await notify(
{ RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM },
sub.email as string,
{
channel_type: sub.channel_type as ChannelType,
destination: sub.destination as string,
unsub_token: sub.unsub_token as string,
},
{
poolName: pool.name,
assetSymbol: asset.symbol,
Expand All @@ -244,7 +269,7 @@ async function handleCron(env: Env): Promise<void> {
"UPDATE subscriptions SET last_alerted_at = datetime('now') WHERE id = ?1"
).bind(sub.id).run();
} else {
console.error(`[cron] Failed to send alert to ${sub.email}:`, result.error);
console.error(`[cron] Failed to send alert to ${sub.destination}:`, result.error);
}
}
}
Expand Down
122 changes: 122 additions & 0 deletions alerts/src/notify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Unified notification interface.
* Supports email, Slack incoming webhooks, and Discord incoming webhooks.
*/

import { sendVerificationEmail, sendApyAlert } from "./email.ts";

export type ChannelType = "email" | "slack" | "discord";

export interface NotifyEnv {
RESEND_API_KEY: string;
RESEND_FROM: string;
}

export interface SendResult {
ok: boolean;
error?: string;
}

export interface Subscription {
channel_type: ChannelType;
/** email address for email channel; webhook URL for slack/discord */
destination: string;
unsub_token: string;
}

export interface AlertOpts {
poolName: string;
assetSymbol: string;
leverage: number;
netApy: number;
supplyApr: number;
borrowCost: number;
appUrl: string;
unsubscribeUrl: string;
}

// ── Slack ─────────────────────────────────────────────────────────────────────

async function postWebhook(url: string, body: object): Promise<SendResult> {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
return { ok: false, error: `Webhook ${res.status}: ${text}` };
}
return { ok: true };
}

function slackAlertPayload(opts: AlertOpts): object {
return {
text: `⚠️ *Negative APY Alert* — ${opts.assetSymbol} at ${opts.leverage}x on ${opts.poolName}`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `⚠️ *Negative APY Alert*\n${opts.assetSymbol} at ${opts.leverage}x on ${opts.poolName}\n\n• Net supply APR: *${opts.supplyApr.toFixed(2)}%*\n• Net borrow cost: *${opts.borrowCost.toFixed(2)}%*\n• *Net APY: ${opts.netApy.toFixed(2)}%*\n\n<${opts.appUrl}|Open Turbolong> | <${opts.unsubscribeUrl}|Unsubscribe>`,
},
},
],
};
}

function discordAlertPayload(opts: AlertOpts): object {
return {
content: `⚠️ **Negative APY Alert** — ${opts.assetSymbol} at ${opts.leverage}x on ${opts.poolName}`,
embeds: [
{
color: 0xff4d6a,
fields: [
{ name: "Net supply APR", value: `${opts.supplyApr.toFixed(2)}%`, inline: true },
{ name: "Net borrow cost", value: `${opts.borrowCost.toFixed(2)}%`, inline: true },
{ name: `Net APY at ${opts.leverage}x`, value: `**${opts.netApy.toFixed(2)}%**`, inline: false },
],
description: `[Open Turbolong](${opts.appUrl}) | [Unsubscribe](${opts.unsubscribeUrl})`,
},
],
};
}

// ── Verification ──────────────────────────────────────────────────────────────

export async function sendVerification(
env: NotifyEnv,
channel: ChannelType,
destination: string,
verifyUrl: string,
): Promise<SendResult> {
if (channel === "email") {
return sendVerificationEmail(env, destination, verifyUrl);
}
if (channel === "slack") {
return postWebhook(destination, {
text: `👋 Verify your Turbolong alert subscription: ${verifyUrl}`,
});
}
// discord
return postWebhook(destination, {
content: `👋 Verify your Turbolong alert subscription: ${verifyUrl}`,
});
}

// ── Alert ─────────────────────────────────────────────────────────────────────

export async function notify(
env: NotifyEnv,
sub: Subscription,
opts: AlertOpts,
): Promise<SendResult> {
if (sub.channel_type === "email") {
return sendApyAlert(env, sub.destination, opts);
}
const payload =
sub.channel_type === "slack"
? slackAlertPayload(opts)
: discordAlertPayload(opts);
return postWebhook(sub.destination, payload);
}
10 changes: 8 additions & 2 deletions alerts/src/schema.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
channel_type TEXT NOT NULL DEFAULT 'email', -- 'email' | 'slack' | 'discord'
destination TEXT NOT NULL, -- email address or webhook URL
pool_id TEXT NOT NULL,
asset_symbol TEXT NOT NULL,
leverage_bracket REAL NOT NULL,
Expand All @@ -9,8 +10,13 @@ CREATE TABLE IF NOT EXISTS subscriptions (
unsub_token TEXT,
created_at TEXT DEFAULT (datetime('now')),
last_alerted_at TEXT,
UNIQUE(email, pool_id, asset_symbol, leverage_bracket)
UNIQUE(destination, pool_id, asset_symbol, leverage_bracket)
);

CREATE INDEX IF NOT EXISTS idx_subs_pool_asset_lev
ON subscriptions(pool_id, asset_symbol, leverage_bracket);

-- Migration for existing deployments (safe to run multiple times):
-- ALTER TABLE subscriptions ADD COLUMN channel_type TEXT NOT NULL DEFAULT 'email';
-- ALTER TABLE subscriptions ADD COLUMN destination TEXT;
-- UPDATE subscriptions SET destination = email WHERE destination IS NULL;