diff --git a/backend/src/index.ts b/backend/src/index.ts index 1a1346e..bed4088 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 $$; `); diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 2ef9505..211b799 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -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; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 7016114..fa32cb3 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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 { diff --git a/backend/src/services/notifications.ts b/backend/src/services/notifications.ts index 0f77db0..73eec73 100644 --- a/backend/src/services/notifications.ts +++ b/backend/src/services/notifications.ts @@ -195,6 +195,52 @@ export async function sendPushoverNotification( } } +export async function sendNtfyNotification( + topic: string, + payload: NotificationPayload +): Promise { + 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 { @@ -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); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e901e97..f098c00 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -157,6 +157,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 const settingsApi = { @@ -172,6 +174,8 @@ export const settingsApi = { pushover_user_key?: string | null; pushover_app_token?: string | null; pushover_enabled?: boolean; + ntfy_topic?: string | null; + ntfy_enabled?: boolean; }) => api.put('/settings/notifications', data), testTelegram: () => @@ -183,6 +187,9 @@ export const settingsApi = { testPushover: () => api.post<{ message: string }>('/settings/notifications/test/pushover'), + testNtfy: () => + api.post<{ message: string }>('/settings/notifications/test/ntfy'), + // AI Settings getAI: () => api.get('/settings/ai'), diff --git a/frontend/src/pages/ProductDetail.tsx b/frontend/src/pages/ProductDetail.tsx index 9c9098c..782066e 100644 --- a/frontend/src/pages/ProductDetail.tsx +++ b/frontend/src/pages/ProductDetail.tsx @@ -527,7 +527,8 @@ export default function ProductDetail() { {notificationSettings && ( ((notificationSettings.telegram_bot_token && notificationSettings.telegram_chat_id) && notificationSettings.telegram_enabled) || (notificationSettings.discord_webhook_url && notificationSettings.discord_enabled) || - ((notificationSettings.pushover_user_key && notificationSettings.pushover_app_token) && notificationSettings.pushover_enabled) + ((notificationSettings.pushover_user_key && notificationSettings.pushover_app_token) && notificationSettings.pushover_enabled) || + (notificationSettings.ntfy_topic && notificationSettings.ntfy_enabled) ) && ( <>