mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-06-08 15:05:16 +02:00
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:
parent
3b7dce8bde
commit
3fa913814d
6 changed files with 208 additions and 5 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NotificationSettings & { message: string }>('/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<AISettings>('/settings/ai'),
|
||||
|
|
|
|||
|
|
@ -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<AISettings | null>(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() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<span className="settings-section-icon">🔔</span>
|
||||
<h2 className="settings-section-title">Pushover Notifications</h2>
|
||||
<span className={`settings-section-status ${notificationSettings?.pushover_configured ? 'configured' : 'not-configured'}`}>
|
||||
{notificationSettings?.pushover_configured ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="settings-section-description">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>User Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pushoverUserKey}
|
||||
onChange={(e) => setPushoverUserKey(e.target.value)}
|
||||
placeholder={notificationSettings?.pushover_configured ? '••••••••••••••••' : 'Enter your user key'}
|
||||
/>
|
||||
<p className="hint">Find your User Key on the Pushover dashboard after logging in</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>Application API Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pushoverAppToken}
|
||||
onChange={(e) => setPushoverAppToken(e.target.value)}
|
||||
placeholder={notificationSettings?.pushover_configured ? '••••••••••••••••' : 'Enter your app token'}
|
||||
/>
|
||||
<p className="hint">Create an application at pushover.net/apps to get an API token</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSavePushover}
|
||||
disabled={isSavingNotifications}
|
||||
>
|
||||
{isSavingNotifications ? 'Saving...' : 'Save Pushover Settings'}
|
||||
</button>
|
||||
{notificationSettings?.pushover_configured && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestPushover}
|
||||
disabled={isTesting === 'pushover'}
|
||||
>
|
||||
{isTesting === 'pushover' ? 'Sending...' : 'Send Test'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue