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>
This commit is contained in:
clucraft 2026-01-24 21:52:33 -05:00
parent 5e850aee18
commit d9374c2f57
6 changed files with 355 additions and 3 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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 {

View file

@ -241,6 +241,83 @@ export async function sendNtfyNotification(
}
}
export async function sendGotifyNotification(
serverUrl: string,
appToken: string,
payload: NotificationPayload
): Promise<boolean> {
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<NotificationResult> {
@ -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[] = [];