Add Pushover notification support

- Add pushover_user_key and pushover_app_token columns to users table
- Add sendPushoverNotification function to notifications service
- Add Pushover test endpoint
- Add Pushover settings UI in Settings page
- User provides both User Key and App Token (self-hosted friendly)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-22 13:48:31 -05:00
parent 3b7dce8bde
commit 3fa913814d
6 changed files with 208 additions and 5 deletions

View file

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

View file

@ -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<NotificationSettings | null> => {
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;

View file

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

View file

@ -139,11 +139,57 @@ export async function sendDiscordNotification(
}
}
export async function sendPushoverNotification(
userKey: string,
appToken: string,
payload: NotificationPayload
): Promise<boolean> {
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<void> {
@ -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);
}