diff --git a/backend/src/index.ts b/backend/src/index.ts index 892d4e9..ad88225 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -119,6 +119,15 @@ async function runMigrations() { 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; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'gotify_url') THEN + ALTER TABLE users ADD COLUMN gotify_url TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'gotify_app_token') THEN + ALTER TABLE users ADD COLUMN gotify_app_token TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'gotify_enabled') THEN + ALTER TABLE users ADD COLUMN gotify_enabled BOOLEAN DEFAULT true; + END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ai_verification_enabled') THEN ALTER TABLE users ADD COLUMN ai_verification_enabled BOOLEAN DEFAULT false; END IF; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index c612438..96b4185 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -32,6 +32,9 @@ export interface NotificationSettings { pushover_enabled: boolean; ntfy_topic: string | null; ntfy_enabled: boolean; + gotify_url: string | null; + gotify_app_token: string | null; + gotify_enabled: boolean; } export interface AISettings { @@ -76,7 +79,8 @@ export const userQueries = { `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, - ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled + ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled, + gotify_url, gotify_app_token, COALESCE(gotify_enabled, true) as gotify_enabled FROM users WHERE id = $1`, [id] ); @@ -131,6 +135,18 @@ export const userQueries = { fields.push(`ntfy_enabled = $${paramIndex++}`); values.push(settings.ntfy_enabled); } + if (settings.gotify_url !== undefined) { + fields.push(`gotify_url = $${paramIndex++}`); + values.push(settings.gotify_url); + } + if (settings.gotify_app_token !== undefined) { + fields.push(`gotify_app_token = $${paramIndex++}`); + values.push(settings.gotify_app_token); + } + if (settings.gotify_enabled !== undefined) { + fields.push(`gotify_enabled = $${paramIndex++}`); + values.push(settings.gotify_enabled); + } if (fields.length === 0) return null; @@ -140,7 +156,8 @@ export const userQueries = { 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, - ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled`, + ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled, + gotify_url, gotify_app_token, COALESCE(gotify_enabled, true) as gotify_enabled`, values ); return result.rows[0] || null; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 6e809aa..5a1ba3e 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -29,6 +29,9 @@ router.get('/notifications', async (req: AuthRequest, res: Response) => { pushover_enabled: settings.pushover_enabled ?? true, ntfy_topic: settings.ntfy_topic || null, ntfy_enabled: settings.ntfy_enabled ?? true, + gotify_url: settings.gotify_url || null, + gotify_app_token: settings.gotify_app_token || null, + gotify_enabled: settings.gotify_enabled ?? true, }); } catch (error) { console.error('Error fetching notification settings:', error); @@ -51,6 +54,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => { pushover_enabled, ntfy_topic, ntfy_enabled, + gotify_url, + gotify_app_token, + gotify_enabled, } = req.body; const settings = await userQueries.updateNotificationSettings(userId, { @@ -64,6 +70,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => { pushover_enabled, ntfy_topic, ntfy_enabled, + gotify_url, + gotify_app_token, + gotify_enabled, }); if (!settings) { @@ -82,6 +91,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => { pushover_enabled: settings.pushover_enabled ?? true, ntfy_topic: settings.ntfy_topic || null, ntfy_enabled: settings.ntfy_enabled ?? true, + gotify_url: settings.gotify_url || null, + gotify_app_token: settings.gotify_app_token || null, + gotify_enabled: settings.gotify_enabled ?? true, message: 'Notification settings updated successfully', }); } catch (error) { @@ -226,6 +238,66 @@ router.post('/notifications/test/ntfy', async (req: AuthRequest, res: Response) } }); +// Test Gotify connection (before saving) +router.post('/notifications/test-gotify', async (req: AuthRequest, res: Response) => { + try { + const { url, app_token } = req.body; + + if (!url || !app_token) { + res.status(400).json({ error: 'Server URL and app token are required' }); + return; + } + + const { testGotifyConnection } = await import('../services/notifications'); + const result = await testGotifyConnection(url, app_token); + + if (result.success) { + res.json({ success: true, message: 'Successfully connected to Gotify server' }); + } else { + res.status(400).json({ success: false, error: result.error }); + } + } catch (error) { + console.error('Error testing Gotify connection:', error); + res.status(500).json({ error: 'Failed to test Gotify connection' }); + } +}); + +// Test Gotify notification (after saving) +router.post('/notifications/test/gotify', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const settings = await userQueries.getNotificationSettings(userId); + + if (!settings?.gotify_url || !settings?.gotify_app_token) { + res.status(400).json({ error: 'Gotify not configured' }); + return; + } + + const { sendGotifyNotification } = await import('../services/notifications'); + const success = await sendGotifyNotification( + settings.gotify_url, + settings.gotify_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 Gotify 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 81c6d46..1deaf04 100644 --- a/backend/src/services/notifications.ts +++ b/backend/src/services/notifications.ts @@ -241,6 +241,83 @@ export async function sendNtfyNotification( } } +export async function sendGotifyNotification( + serverUrl: string, + appToken: string, + payload: NotificationPayload +): Promise { + try { + const currencySymbol = getCurrencySymbol(payload.currency); + + let title: string; + let message: string; + let priority: number; + + 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}\n\n${payload.productUrl}`; + priority = 7; // High priority + } 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})\n\n${payload.productUrl}`; + priority = 8; // Higher priority + } 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}\n\n${payload.productUrl}`; + priority = 8; // Higher priority + } + + // Gotify API: POST /message with token as query param or header + const url = `${serverUrl.replace(/\/$/, '')}/message`; + await axios.post(url, { + title, + message, + priority, + }, { + headers: { + 'X-Gotify-Key': appToken, + }, + }); + + console.log('Gotify notification sent'); + return true; + } catch (error) { + console.error('Failed to send Gotify notification:', error); + return false; + } +} + +export async function testGotifyConnection( + serverUrl: string, + appToken: string +): Promise<{ success: boolean; error?: string }> { + try { + // Test by fetching application info + const url = `${serverUrl.replace(/\/$/, '')}/application`; + await axios.get(url, { + headers: { + 'X-Gotify-Key': appToken, + }, + timeout: 10000, + }); + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (errorMessage.includes('ECONNREFUSED')) { + return { success: false, error: 'Cannot connect to Gotify server. Make sure it is running.' }; + } + if (errorMessage.includes('401') || errorMessage.includes('403')) { + return { success: false, error: 'Invalid app token. Check your Gotify application token.' }; + } + return { success: false, error: `Connection failed: ${errorMessage}` }; + } +} + export interface NotificationResult { channelsNotified: string[]; channelsFailed: string[]; @@ -258,6 +335,9 @@ export async function sendNotifications( pushover_enabled?: boolean; ntfy_topic: string | null; ntfy_enabled?: boolean; + gotify_url: string | null; + gotify_app_token: string | null; + gotify_enabled?: boolean; }, payload: NotificationPayload ): Promise { @@ -292,6 +372,13 @@ export async function sendNotifications( }); } + if (settings.gotify_url && settings.gotify_app_token && settings.gotify_enabled !== false) { + channelPromises.push({ + channel: 'gotify', + promise: sendGotifyNotification(settings.gotify_url, settings.gotify_app_token, payload), + }); + } + const results = await Promise.allSettled(channelPromises.map(c => c.promise)); const channelsNotified: string[] = []; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4d7c3ae..3278e1d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -191,6 +191,9 @@ export interface NotificationSettings { pushover_enabled: boolean; ntfy_topic: string | null; ntfy_enabled: boolean; + gotify_url: string | null; + gotify_app_token: string | null; + gotify_enabled: boolean; } export const settingsApi = { @@ -208,6 +211,9 @@ export const settingsApi = { pushover_enabled?: boolean; ntfy_topic?: string | null; ntfy_enabled?: boolean; + gotify_url?: string | null; + gotify_app_token?: string | null; + gotify_enabled?: boolean; }) => api.put('/settings/notifications', data), testTelegram: () => @@ -222,6 +228,15 @@ export const settingsApi = { testNtfy: () => api.post<{ message: string }>('/settings/notifications/test/ntfy'), + testGotifyConnection: (url: string, appToken: string) => + api.post<{ success: boolean; message?: string; error?: string }>('/settings/notifications/test-gotify', { + url, + app_token: appToken, + }), + + testGotify: () => + api.post<{ message: string }>('/settings/notifications/test/gotify'), + // AI Settings getAI: () => api.get('/settings/ai'), diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index dc8cfe8..2c2fefb 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -47,8 +47,12 @@ export default function Settings() { const [pushoverEnabled, setPushoverEnabled] = useState(true); const [ntfyTopic, setNtfyTopic] = useState(''); const [ntfyEnabled, setNtfyEnabled] = useState(true); + const [gotifyUrl, setGotifyUrl] = useState(''); + const [gotifyAppToken, setGotifyAppToken] = useState(''); + const [gotifyEnabled, setGotifyEnabled] = useState(true); + const [isTestingGotify, setIsTestingGotify] = useState(false); const [isSavingNotifications, setIsSavingNotifications] = useState(false); - const [isTesting, setIsTesting] = useState<'telegram' | 'discord' | 'pushover' | 'ntfy' | null>(null); + const [isTesting, setIsTesting] = useState<'telegram' | 'discord' | 'pushover' | 'ntfy' | 'gotify' | null>(null); // AI state const [aiSettings, setAISettings] = useState(null); @@ -108,6 +112,9 @@ export default function Settings() { setPushoverEnabled(notificationsRes.data.pushover_enabled ?? true); setNtfyTopic(notificationsRes.data.ntfy_topic || ''); setNtfyEnabled(notificationsRes.data.ntfy_enabled ?? true); + setGotifyUrl(notificationsRes.data.gotify_url || ''); + setGotifyAppToken(notificationsRes.data.gotify_app_token || ''); + setGotifyEnabled(notificationsRes.data.gotify_enabled ?? true); // Populate AI fields with actual values setAISettings(aiRes.data); setAIEnabled(aiRes.data.ai_enabled); @@ -363,6 +370,69 @@ export default function Settings() { } }; + const handleTestGotifyConnection = async () => { + clearMessages(); + if (!gotifyUrl || !gotifyAppToken) { + setError('Please enter both the Gotify server URL and app token'); + return; + } + setIsTestingGotify(true); + try { + const response = await settingsApi.testGotifyConnection(gotifyUrl, gotifyAppToken); + if (response.data.success) { + setSuccess('Successfully connected to Gotify server!'); + } else { + setError(response.data.error || 'Failed to connect to Gotify'); + } + } catch { + setError('Failed to connect to Gotify. Make sure the server is running.'); + } finally { + setIsTestingGotify(false); + } + }; + + const handleSaveGotify = async () => { + clearMessages(); + setIsSavingNotifications(true); + try { + const response = await settingsApi.updateNotifications({ + gotify_url: gotifyUrl || null, + gotify_app_token: gotifyAppToken || null, + }); + setNotificationSettings(response.data); + setGotifyAppToken(''); + setSuccess('Gotify settings saved successfully'); + } catch { + setError('Failed to save Gotify settings'); + } finally { + setIsSavingNotifications(false); + } + }; + + const handleTestGotify = async () => { + clearMessages(); + setIsTesting('gotify'); + try { + await settingsApi.testGotify(); + setSuccess('Test notification sent to Gotify!'); + } catch { + setError('Failed to send test notification'); + } finally { + setIsTesting(null); + } + }; + + const handleToggleGotify = async (enabled: boolean) => { + setGotifyEnabled(enabled); + try { + const response = await settingsApi.updateNotifications({ gotify_enabled: enabled }); + setNotificationSettings(response.data); + } catch { + setGotifyEnabled(!enabled); + setError('Failed to update Gotify status'); + } + }; + // AI handlers const handleSaveAI = async () => { clearMessages(); @@ -1318,6 +1388,88 @@ export default function Settings() { )} + +
+
+ 🔔 +

Gotify Notifications

+ + {notificationSettings?.gotify_url && notificationSettings?.gotify_app_token ? 'Configured' : 'Not configured'} + +
+

+ Receive notifications via your self-hosted Gotify server. You'll need to create an application + in Gotify to get an app token. +

+ + {notificationSettings?.gotify_url && notificationSettings?.gotify_app_token && ( +
+
+ Enable Gotify Notifications + + Toggle to enable or disable Gotify alerts + +
+
+ )} + +
+ +
+ setGotifyUrl(e.target.value)} + placeholder="https://gotify.example.com" + style={{ flex: 1 }} + /> + +
+

The URL of your self-hosted Gotify server

+
+ +
+ + setGotifyAppToken(e.target.value)} + placeholder="Enter your app token" + /> +

+ Create an application in Gotify (Apps → Create Application) to get a token +

+
+ +
+ + {notificationSettings?.gotify_url && notificationSettings?.gotify_app_token && ( + + )} +
+
)}