Skip to content

Commit d9374c2

Browse files
clucraftclaude
andcommitted
Add Gotify notification support
- Add gotify_url, gotify_app_token, gotify_enabled columns to users table - Add sendGotifyNotification function with priority levels - Add testGotifyConnection function to verify server connectivity - Add test-gotify endpoint for connection testing before save - Add Gotify section in Settings with: - Server URL input with Test Connection button - App Token input (masked) - Enable/disable toggle - Send Test button (after configured) - Include Gotify in notification dispatching Gotify is a self-hosted push notification server popular in the self-hosted community, complementing the existing ntfy support. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5e850ae commit d9374c2

File tree

6 files changed

+355
-3
lines changed

6 files changed

+355
-3
lines changed

backend/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,15 @@ async function runMigrations() {
119119
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_enabled') THEN
120120
ALTER TABLE users ADD COLUMN ntfy_enabled BOOLEAN DEFAULT true;
121121
END IF;
122+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'gotify_url') THEN
123+
ALTER TABLE users ADD COLUMN gotify_url TEXT;
124+
END IF;
125+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'gotify_app_token') THEN
126+
ALTER TABLE users ADD COLUMN gotify_app_token TEXT;
127+
END IF;
128+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'gotify_enabled') THEN
129+
ALTER TABLE users ADD COLUMN gotify_enabled BOOLEAN DEFAULT true;
130+
END IF;
122131
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ai_verification_enabled') THEN
123132
ALTER TABLE users ADD COLUMN ai_verification_enabled BOOLEAN DEFAULT false;
124133
END IF;

backend/src/models/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export interface NotificationSettings {
3232
pushover_enabled: boolean;
3333
ntfy_topic: string | null;
3434
ntfy_enabled: boolean;
35+
gotify_url: string | null;
36+
gotify_app_token: string | null;
37+
gotify_enabled: boolean;
3538
}
3639

3740
export interface AISettings {
@@ -76,7 +79,8 @@ export const userQueries = {
7679
`SELECT telegram_bot_token, telegram_chat_id, COALESCE(telegram_enabled, true) as telegram_enabled,
7780
discord_webhook_url, COALESCE(discord_enabled, true) as discord_enabled,
7881
pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled,
79-
ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled
82+
ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled,
83+
gotify_url, gotify_app_token, COALESCE(gotify_enabled, true) as gotify_enabled
8084
FROM users WHERE id = $1`,
8185
[id]
8286
);
@@ -131,6 +135,18 @@ export const userQueries = {
131135
fields.push(`ntfy_enabled = $${paramIndex++}`);
132136
values.push(settings.ntfy_enabled);
133137
}
138+
if (settings.gotify_url !== undefined) {
139+
fields.push(`gotify_url = $${paramIndex++}`);
140+
values.push(settings.gotify_url);
141+
}
142+
if (settings.gotify_app_token !== undefined) {
143+
fields.push(`gotify_app_token = $${paramIndex++}`);
144+
values.push(settings.gotify_app_token);
145+
}
146+
if (settings.gotify_enabled !== undefined) {
147+
fields.push(`gotify_enabled = $${paramIndex++}`);
148+
values.push(settings.gotify_enabled);
149+
}
134150

135151
if (fields.length === 0) return null;
136152

@@ -140,7 +156,8 @@ export const userQueries = {
140156
RETURNING telegram_bot_token, telegram_chat_id, COALESCE(telegram_enabled, true) as telegram_enabled,
141157
discord_webhook_url, COALESCE(discord_enabled, true) as discord_enabled,
142158
pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled,
143-
ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled`,
159+
ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled,
160+
gotify_url, gotify_app_token, COALESCE(gotify_enabled, true) as gotify_enabled`,
144161
values
145162
);
146163
return result.rows[0] || null;

backend/src/routes/settings.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ router.get('/notifications', async (req: AuthRequest, res: Response) => {
2929
pushover_enabled: settings.pushover_enabled ?? true,
3030
ntfy_topic: settings.ntfy_topic || null,
3131
ntfy_enabled: settings.ntfy_enabled ?? true,
32+
gotify_url: settings.gotify_url || null,
33+
gotify_app_token: settings.gotify_app_token || null,
34+
gotify_enabled: settings.gotify_enabled ?? true,
3235
});
3336
} catch (error) {
3437
console.error('Error fetching notification settings:', error);
@@ -51,6 +54,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
5154
pushover_enabled,
5255
ntfy_topic,
5356
ntfy_enabled,
57+
gotify_url,
58+
gotify_app_token,
59+
gotify_enabled,
5460
} = req.body;
5561

5662
const settings = await userQueries.updateNotificationSettings(userId, {
@@ -64,6 +70,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
6470
pushover_enabled,
6571
ntfy_topic,
6672
ntfy_enabled,
73+
gotify_url,
74+
gotify_app_token,
75+
gotify_enabled,
6776
});
6877

6978
if (!settings) {
@@ -82,6 +91,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
8291
pushover_enabled: settings.pushover_enabled ?? true,
8392
ntfy_topic: settings.ntfy_topic || null,
8493
ntfy_enabled: settings.ntfy_enabled ?? true,
94+
gotify_url: settings.gotify_url || null,
95+
gotify_app_token: settings.gotify_app_token || null,
96+
gotify_enabled: settings.gotify_enabled ?? true,
8597
message: 'Notification settings updated successfully',
8698
});
8799
} catch (error) {
@@ -226,6 +238,66 @@ router.post('/notifications/test/ntfy', async (req: AuthRequest, res: Response)
226238
}
227239
});
228240

241+
// Test Gotify connection (before saving)
242+
router.post('/notifications/test-gotify', async (req: AuthRequest, res: Response) => {
243+
try {
244+
const { url, app_token } = req.body;
245+
246+
if (!url || !app_token) {
247+
res.status(400).json({ error: 'Server URL and app token are required' });
248+
return;
249+
}
250+
251+
const { testGotifyConnection } = await import('../services/notifications');
252+
const result = await testGotifyConnection(url, app_token);
253+
254+
if (result.success) {
255+
res.json({ success: true, message: 'Successfully connected to Gotify server' });
256+
} else {
257+
res.status(400).json({ success: false, error: result.error });
258+
}
259+
} catch (error) {
260+
console.error('Error testing Gotify connection:', error);
261+
res.status(500).json({ error: 'Failed to test Gotify connection' });
262+
}
263+
});
264+
265+
// Test Gotify notification (after saving)
266+
router.post('/notifications/test/gotify', async (req: AuthRequest, res: Response) => {
267+
try {
268+
const userId = req.userId!;
269+
const settings = await userQueries.getNotificationSettings(userId);
270+
271+
if (!settings?.gotify_url || !settings?.gotify_app_token) {
272+
res.status(400).json({ error: 'Gotify not configured' });
273+
return;
274+
}
275+
276+
const { sendGotifyNotification } = await import('../services/notifications');
277+
const success = await sendGotifyNotification(
278+
settings.gotify_url,
279+
settings.gotify_app_token,
280+
{
281+
productName: 'Test Product',
282+
productUrl: 'https://example.com',
283+
type: 'price_drop',
284+
oldPrice: 29.99,
285+
newPrice: 19.99,
286+
currency: 'USD',
287+
}
288+
);
289+
290+
if (success) {
291+
res.json({ message: 'Test notification sent successfully' });
292+
} else {
293+
res.status(500).json({ error: 'Failed to send test notification' });
294+
}
295+
} catch (error) {
296+
console.error('Error sending test Gotify notification:', error);
297+
res.status(500).json({ error: 'Failed to send test notification' });
298+
}
299+
});
300+
229301
// Get AI settings
230302
router.get('/ai', async (req: AuthRequest, res: Response) => {
231303
try {

backend/src/services/notifications.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,83 @@ export async function sendNtfyNotification(
241241
}
242242
}
243243

244+
export async function sendGotifyNotification(
245+
serverUrl: string,
246+
appToken: string,
247+
payload: NotificationPayload
248+
): Promise<boolean> {
249+
try {
250+
const currencySymbol = getCurrencySymbol(payload.currency);
251+
252+
let title: string;
253+
let message: string;
254+
let priority: number;
255+
256+
if (payload.type === 'price_drop') {
257+
const oldPriceStr = payload.oldPrice ? `${currencySymbol}${payload.oldPrice.toFixed(2)}` : 'N/A';
258+
const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A';
259+
title = 'Price Drop Alert!';
260+
message = `${payload.productName}\n\nPrice dropped from ${oldPriceStr} to ${newPriceStr}\n\n${payload.productUrl}`;
261+
priority = 7; // High priority
262+
} else if (payload.type === 'target_price') {
263+
const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A';
264+
const targetPriceStr = payload.targetPrice ? `${currencySymbol}${payload.targetPrice.toFixed(2)}` : 'N/A';
265+
title = 'Target Price Reached!';
266+
message = `${payload.productName}\n\nPrice is now ${newPriceStr} (your target: ${targetPriceStr})\n\n${payload.productUrl}`;
267+
priority = 8; // Higher priority
268+
} else {
269+
const priceStr = payload.newPrice ? ` at ${currencySymbol}${payload.newPrice.toFixed(2)}` : '';
270+
title = 'Back in Stock!';
271+
message = `${payload.productName}\n\nThis item is now available${priceStr}\n\n${payload.productUrl}`;
272+
priority = 8; // Higher priority
273+
}
274+
275+
// Gotify API: POST /message with token as query param or header
276+
const url = `${serverUrl.replace(/\/$/, '')}/message`;
277+
await axios.post(url, {
278+
title,
279+
message,
280+
priority,
281+
}, {
282+
headers: {
283+
'X-Gotify-Key': appToken,
284+
},
285+
});
286+
287+
console.log('Gotify notification sent');
288+
return true;
289+
} catch (error) {
290+
console.error('Failed to send Gotify notification:', error);
291+
return false;
292+
}
293+
}
294+
295+
export async function testGotifyConnection(
296+
serverUrl: string,
297+
appToken: string
298+
): Promise<{ success: boolean; error?: string }> {
299+
try {
300+
// Test by fetching application info
301+
const url = `${serverUrl.replace(/\/$/, '')}/application`;
302+
await axios.get(url, {
303+
headers: {
304+
'X-Gotify-Key': appToken,
305+
},
306+
timeout: 10000,
307+
});
308+
return { success: true };
309+
} catch (error) {
310+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
311+
if (errorMessage.includes('ECONNREFUSED')) {
312+
return { success: false, error: 'Cannot connect to Gotify server. Make sure it is running.' };
313+
}
314+
if (errorMessage.includes('401') || errorMessage.includes('403')) {
315+
return { success: false, error: 'Invalid app token. Check your Gotify application token.' };
316+
}
317+
return { success: false, error: `Connection failed: ${errorMessage}` };
318+
}
319+
}
320+
244321
export interface NotificationResult {
245322
channelsNotified: string[];
246323
channelsFailed: string[];
@@ -258,6 +335,9 @@ export async function sendNotifications(
258335
pushover_enabled?: boolean;
259336
ntfy_topic: string | null;
260337
ntfy_enabled?: boolean;
338+
gotify_url: string | null;
339+
gotify_app_token: string | null;
340+
gotify_enabled?: boolean;
261341
},
262342
payload: NotificationPayload
263343
): Promise<NotificationResult> {
@@ -292,6 +372,13 @@ export async function sendNotifications(
292372
});
293373
}
294374

375+
if (settings.gotify_url && settings.gotify_app_token && settings.gotify_enabled !== false) {
376+
channelPromises.push({
377+
channel: 'gotify',
378+
promise: sendGotifyNotification(settings.gotify_url, settings.gotify_app_token, payload),
379+
});
380+
}
381+
295382
const results = await Promise.allSettled(channelPromises.map(c => c.promise));
296383

297384
const channelsNotified: string[] = [];

frontend/src/api/client.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ export interface NotificationSettings {
191191
pushover_enabled: boolean;
192192
ntfy_topic: string | null;
193193
ntfy_enabled: boolean;
194+
gotify_url: string | null;
195+
gotify_app_token: string | null;
196+
gotify_enabled: boolean;
194197
}
195198

196199
export const settingsApi = {
@@ -208,6 +211,9 @@ export const settingsApi = {
208211
pushover_enabled?: boolean;
209212
ntfy_topic?: string | null;
210213
ntfy_enabled?: boolean;
214+
gotify_url?: string | null;
215+
gotify_app_token?: string | null;
216+
gotify_enabled?: boolean;
211217
}) => api.put<NotificationSettings & { message: string }>('/settings/notifications', data),
212218

213219
testTelegram: () =>
@@ -222,6 +228,15 @@ export const settingsApi = {
222228
testNtfy: () =>
223229
api.post<{ message: string }>('/settings/notifications/test/ntfy'),
224230

231+
testGotifyConnection: (url: string, appToken: string) =>
232+
api.post<{ success: boolean; message?: string; error?: string }>('/settings/notifications/test-gotify', {
233+
url,
234+
app_token: appToken,
235+
}),
236+
237+
testGotify: () =>
238+
api.post<{ message: string }>('/settings/notifications/test/gotify'),
239+
225240
// AI Settings
226241
getAI: () =>
227242
api.get<AISettings>('/settings/ai'),

0 commit comments

Comments
 (0)