diff --git a/backend/src/index.ts b/backend/src/index.ts index 14e70f6..d482bfb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -31,6 +31,12 @@ async function runMigrations() { IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'openai_api_key') THEN ALTER TABLE users ADD COLUMN openai_api_key TEXT; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'pushover_user_key') THEN + ALTER TABLE users ADD COLUMN pushover_user_key TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'pushover_app_token') THEN + ALTER TABLE users ADD COLUMN pushover_app_token TEXT; + END IF; END $$; `); console.log('Database migrations completed'); diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 9763030..8f5fe86 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -25,6 +25,8 @@ export interface NotificationSettings { telegram_bot_token: string | null; telegram_chat_id: string | null; discord_webhook_url: string | null; + pushover_user_key: string | null; + pushover_app_token: string | null; } export interface AISettings { @@ -61,7 +63,7 @@ export const userQueries = { getNotificationSettings: async (id: number): Promise => { const result = await pool.query( - 'SELECT telegram_bot_token, telegram_chat_id, discord_webhook_url FROM users WHERE id = $1', + 'SELECT telegram_bot_token, telegram_chat_id, discord_webhook_url, pushover_user_key, pushover_app_token FROM users WHERE id = $1', [id] ); return result.rows[0] || null; @@ -87,13 +89,21 @@ export const userQueries = { fields.push(`discord_webhook_url = $${paramIndex++}`); values.push(settings.discord_webhook_url); } + if (settings.pushover_user_key !== undefined) { + fields.push(`pushover_user_key = $${paramIndex++}`); + values.push(settings.pushover_user_key); + } + if (settings.pushover_app_token !== undefined) { + fields.push(`pushover_app_token = $${paramIndex++}`); + values.push(settings.pushover_app_token); + } if (fields.length === 0) return null; values.push(id.toString()); const result = await pool.query( `UPDATE users SET ${fields.join(', ')} WHERE id = $${paramIndex} - RETURNING telegram_bot_token, telegram_chat_id, discord_webhook_url`, + RETURNING telegram_bot_token, telegram_chat_id, discord_webhook_url, pushover_user_key, pushover_app_token`, values ); return result.rows[0] || null; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index b61c17a..318e59c 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -18,11 +18,12 @@ router.get('/notifications', async (req: AuthRequest, res: Response) => { return; } - // Don't expose full bot token, just indicate if it's set + // Don't expose full tokens, just indicate if they're set res.json({ telegram_configured: !!(settings.telegram_bot_token && settings.telegram_chat_id), telegram_chat_id: settings.telegram_chat_id, discord_configured: !!settings.discord_webhook_url, + pushover_configured: !!(settings.pushover_user_key && settings.pushover_app_token), }); } catch (error) { console.error('Error fetching notification settings:', error); @@ -34,12 +35,14 @@ router.get('/notifications', async (req: AuthRequest, res: Response) => { router.put('/notifications', async (req: AuthRequest, res: Response) => { try { const userId = req.userId!; - const { telegram_bot_token, telegram_chat_id, discord_webhook_url } = req.body; + const { telegram_bot_token, telegram_chat_id, discord_webhook_url, pushover_user_key, pushover_app_token } = req.body; const settings = await userQueries.updateNotificationSettings(userId, { telegram_bot_token, telegram_chat_id, discord_webhook_url, + pushover_user_key, + pushover_app_token, }); if (!settings) { @@ -51,6 +54,7 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => { telegram_configured: !!(settings.telegram_bot_token && settings.telegram_chat_id), telegram_chat_id: settings.telegram_chat_id, discord_configured: !!settings.discord_webhook_url, + pushover_configured: !!(settings.pushover_user_key && settings.pushover_app_token), message: 'Notification settings updated successfully', }); } catch (error) { @@ -127,6 +131,42 @@ router.post('/notifications/test/discord', async (req: AuthRequest, res: Respons } }); +// Test Pushover notification +router.post('/notifications/test/pushover', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const settings = await userQueries.getNotificationSettings(userId); + + if (!settings?.pushover_user_key || !settings?.pushover_app_token) { + res.status(400).json({ error: 'Pushover not configured' }); + return; + } + + const { sendPushoverNotification } = await import('../services/notifications'); + const success = await sendPushoverNotification( + settings.pushover_user_key, + settings.pushover_app_token, + { + 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 Pushover 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 99015c3..5e2ba9f 100644 --- a/backend/src/services/notifications.ts +++ b/backend/src/services/notifications.ts @@ -139,11 +139,57 @@ export async function sendDiscordNotification( } } +export async function sendPushoverNotification( + userKey: string, + appToken: string, + payload: NotificationPayload +): Promise { + try { + const currencySymbol = payload.currency === 'EUR' ? '€' : payload.currency === 'GBP' ? '£' : '$'; + + let title: string; + let message: 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}`; + } 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})`; + } 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}`; + } + + await axios.post('https://api.pushover.net/1/messages.json', { + token: appToken, + user: userKey, + title, + message, + url: payload.productUrl, + url_title: 'View Product', + }); + + console.log('Pushover notification sent'); + return true; + } catch (error) { + console.error('Failed to send Pushover notification:', error); + return false; + } +} + export async function sendNotifications( settings: { telegram_bot_token: string | null; telegram_chat_id: string | null; discord_webhook_url: string | null; + pushover_user_key: string | null; + pushover_app_token: string | null; }, payload: NotificationPayload ): Promise { @@ -159,5 +205,11 @@ export async function sendNotifications( promises.push(sendDiscordNotification(settings.discord_webhook_url, payload)); } + if (settings.pushover_user_key && settings.pushover_app_token) { + promises.push( + sendPushoverNotification(settings.pushover_user_key, settings.pushover_app_token, payload) + ); + } + await Promise.allSettled(promises); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 34a1764..259e610 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -127,6 +127,7 @@ export interface NotificationSettings { telegram_configured: boolean; telegram_chat_id: string | null; discord_configured: boolean; + pushover_configured: boolean; } export const settingsApi = { @@ -137,6 +138,8 @@ export const settingsApi = { telegram_bot_token?: string | null; telegram_chat_id?: string | null; discord_webhook_url?: string | null; + pushover_user_key?: string | null; + pushover_app_token?: string | null; }) => api.put('/settings/notifications', data), testTelegram: () => @@ -145,6 +148,9 @@ export const settingsApi = { testDiscord: () => api.post<{ message: string }>('/settings/notifications/test/discord'), + testPushover: () => + api.post<{ message: string }>('/settings/notifications/test/pushover'), + // AI Settings getAI: () => api.get('/settings/ai'), diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index f58b07f..8b2e66f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -33,8 +33,10 @@ export default function Settings() { const [telegramBotToken, setTelegramBotToken] = useState(''); const [telegramChatId, setTelegramChatId] = useState(''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(''); + const [pushoverUserKey, setPushoverUserKey] = useState(''); + const [pushoverAppToken, setPushoverAppToken] = useState(''); const [isSavingNotifications, setIsSavingNotifications] = useState(false); - const [isTesting, setIsTesting] = useState<'telegram' | 'discord' | null>(null); + const [isTesting, setIsTesting] = useState<'telegram' | 'discord' | 'pushover' | null>(null); // AI state const [aiSettings, setAISettings] = useState(null); @@ -216,6 +218,38 @@ export default function Settings() { } }; + const handleSavePushover = async () => { + clearMessages(); + setIsSavingNotifications(true); + try { + const response = await settingsApi.updateNotifications({ + pushover_user_key: pushoverUserKey || null, + pushover_app_token: pushoverAppToken || null, + }); + setNotificationSettings(response.data); + setPushoverUserKey(''); + setPushoverAppToken(''); + setSuccess('Pushover settings saved successfully'); + } catch { + setError('Failed to save Pushover settings'); + } finally { + setIsSavingNotifications(false); + } + }; + + const handleTestPushover = async () => { + clearMessages(); + setIsTesting('pushover'); + try { + await settingsApi.testPushover(); + setSuccess('Test notification sent to Pushover!'); + } catch { + setError('Failed to send test notification'); + } finally { + setIsTesting(null); + } + }; + // AI handlers const handleSaveAI = async () => { clearMessages(); @@ -944,6 +978,61 @@ export default function Settings() { )} + +
+
+ 🔔 +

Pushover Notifications

+ + {notificationSettings?.pushover_configured ? 'Configured' : 'Not configured'} + +
+

+ Receive price drop and back-in-stock alerts via Pushover. You'll need to create a Pushover account + and an application to get your keys. +

+ +
+ + setPushoverUserKey(e.target.value)} + placeholder={notificationSettings?.pushover_configured ? '••••••••••••••••' : 'Enter your user key'} + /> +

Find your User Key on the Pushover dashboard after logging in

+
+ +
+ + setPushoverAppToken(e.target.value)} + placeholder={notificationSettings?.pushover_configured ? '••••••••••••••••' : 'Enter your app token'} + /> +

Create an application at pushover.net/apps to get an API token

+
+ +
+ + {notificationSettings?.pushover_configured && ( + + )} +
+
)}