Add ntfy.sh notification support

- Add ntfy_topic and ntfy_enabled columns to database
- Add sendNtfyNotification function with emoji tags
- Add /settings/notifications/test/ntfy endpoint
- Add ntfy section in Settings UI with topic input
- Show ntfy badge in ProductDetail notification status

ntfy.sh is a free, simple notification service - no account needed.
Users just pick a topic name and subscribe in the ntfy app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-22 21:02:49 -05:00
parent a4da43c127
commit 3d6af13ac4
7 changed files with 231 additions and 4 deletions

View file

@ -52,6 +52,12 @@ async function runMigrations() {
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ollama_model') THEN
ALTER TABLE users ADD COLUMN ollama_model TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_topic') THEN
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_enabled') THEN
ALTER TABLE users ADD COLUMN ntfy_enabled BOOLEAN DEFAULT true;
END IF;
END $$;
`);

View file

@ -30,6 +30,8 @@ export interface NotificationSettings {
pushover_user_key: string | null;
pushover_app_token: string | null;
pushover_enabled: boolean;
ntfy_topic: string | null;
ntfy_enabled: boolean;
}
export interface AISettings {
@ -70,7 +72,8 @@ export const userQueries = {
const result = await pool.query(
`SELECT telegram_bot_token, telegram_chat_id, COALESCE(telegram_enabled, true) as telegram_enabled,
discord_webhook_url, COALESCE(discord_enabled, true) as discord_enabled,
pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled
pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled,
ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled
FROM users WHERE id = $1`,
[id]
);
@ -117,6 +120,14 @@ export const userQueries = {
fields.push(`pushover_enabled = $${paramIndex++}`);
values.push(settings.pushover_enabled);
}
if (settings.ntfy_topic !== undefined) {
fields.push(`ntfy_topic = $${paramIndex++}`);
values.push(settings.ntfy_topic);
}
if (settings.ntfy_enabled !== undefined) {
fields.push(`ntfy_enabled = $${paramIndex++}`);
values.push(settings.ntfy_enabled);
}
if (fields.length === 0) return null;
@ -125,7 +136,8 @@ export const userQueries = {
`UPDATE users SET ${fields.join(', ')} WHERE id = $${paramIndex}
RETURNING telegram_bot_token, telegram_chat_id, COALESCE(telegram_enabled, true) as telegram_enabled,
discord_webhook_url, COALESCE(discord_enabled, true) as discord_enabled,
pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled`,
pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled,
ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled`,
values
);
return result.rows[0] || null;

View file

@ -27,6 +27,8 @@ router.get('/notifications', async (req: AuthRequest, res: Response) => {
pushover_user_key: settings.pushover_user_key || null,
pushover_app_token: settings.pushover_app_token || null,
pushover_enabled: settings.pushover_enabled ?? true,
ntfy_topic: settings.ntfy_topic || null,
ntfy_enabled: settings.ntfy_enabled ?? true,
});
} catch (error) {
console.error('Error fetching notification settings:', error);
@ -47,6 +49,8 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
pushover_user_key,
pushover_app_token,
pushover_enabled,
ntfy_topic,
ntfy_enabled,
} = req.body;
const settings = await userQueries.updateNotificationSettings(userId, {
@ -58,6 +62,8 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
pushover_user_key,
pushover_app_token,
pushover_enabled,
ntfy_topic,
ntfy_enabled,
});
if (!settings) {
@ -74,6 +80,8 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
pushover_user_key: settings.pushover_user_key || null,
pushover_app_token: settings.pushover_app_token || null,
pushover_enabled: settings.pushover_enabled ?? true,
ntfy_topic: settings.ntfy_topic || null,
ntfy_enabled: settings.ntfy_enabled ?? true,
message: 'Notification settings updated successfully',
});
} catch (error) {
@ -186,6 +194,38 @@ router.post('/notifications/test/pushover', async (req: AuthRequest, res: Respon
}
});
// Test ntfy notification
router.post('/notifications/test/ntfy', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const settings = await userQueries.getNotificationSettings(userId);
if (!settings?.ntfy_topic) {
res.status(400).json({ error: 'ntfy not configured' });
return;
}
const { sendNtfyNotification } = await import('../services/notifications');
const success = await sendNtfyNotification(settings.ntfy_topic, {
productName: 'Test Product',
productUrl: 'https://example.com',
type: 'price_drop',
oldPrice: 29.99,
newPrice: 19.99,
currency: 'USD',
});
if (success) {
res.json({ message: 'Test notification sent successfully' });
} else {
res.status(500).json({ error: 'Failed to send test notification' });
}
} catch (error) {
console.error('Error sending test ntfy notification:', error);
res.status(500).json({ error: 'Failed to send test notification' });
}
});
// Get AI settings
router.get('/ai', async (req: AuthRequest, res: Response) => {
try {

View file

@ -195,6 +195,52 @@ export async function sendPushoverNotification(
}
}
export async function sendNtfyNotification(
topic: string,
payload: NotificationPayload
): Promise<boolean> {
try {
const currencySymbol = getCurrencySymbol(payload.currency);
let title: string;
let message: string;
let tags: string[];
if (payload.type === 'price_drop') {
const oldPriceStr = payload.oldPrice ? `${currencySymbol}${payload.oldPrice.toFixed(2)}` : 'N/A';
const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A';
title = 'Price Drop Alert!';
message = `${payload.productName}\n\nPrice dropped from ${oldPriceStr} to ${newPriceStr}`;
tags = ['moneybag', 'chart_with_downwards_trend'];
} else if (payload.type === 'target_price') {
const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A';
const targetPriceStr = payload.targetPrice ? `${currencySymbol}${payload.targetPrice.toFixed(2)}` : 'N/A';
title = 'Target Price Reached!';
message = `${payload.productName}\n\nPrice is now ${newPriceStr} (your target: ${targetPriceStr})`;
tags = ['dart', 'white_check_mark'];
} else {
const priceStr = payload.newPrice ? ` at ${currencySymbol}${payload.newPrice.toFixed(2)}` : '';
title = 'Back in Stock!';
message = `${payload.productName}\n\nThis item is now available${priceStr}`;
tags = ['package', 'tada'];
}
await axios.post(`https://ntfy.sh/${topic}`, message, {
headers: {
'Title': title,
'Tags': tags.join(','),
'Click': payload.productUrl,
},
});
console.log(`ntfy notification sent to topic ${topic}`);
return true;
} catch (error) {
console.error('Failed to send ntfy notification:', error);
return false;
}
}
export async function sendNotifications(
settings: {
telegram_bot_token: string | null;
@ -205,6 +251,8 @@ export async function sendNotifications(
pushover_user_key: string | null;
pushover_app_token: string | null;
pushover_enabled?: boolean;
ntfy_topic: string | null;
ntfy_enabled?: boolean;
},
payload: NotificationPayload
): Promise<void> {
@ -227,5 +275,9 @@ export async function sendNotifications(
);
}
if (settings.ntfy_topic && settings.ntfy_enabled !== false) {
promises.push(sendNtfyNotification(settings.ntfy_topic, payload));
}
await Promise.allSettled(promises);
}