mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-06-08 15:05:16 +02:00
Add ntfy.sh notification support
- Add ntfy_topic and ntfy_enabled columns to database - Add sendNtfyNotification function with emoji tags - Add /settings/notifications/test/ntfy endpoint - Add ntfy section in Settings UI with topic input - Show ntfy badge in ProductDetail notification status ntfy.sh is a free, simple notification service - no account needed. Users just pick a topic name and subscribe in the ntfy app. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a4da43c127
commit
3d6af13ac4
7 changed files with 231 additions and 4 deletions
|
|
@ -52,6 +52,12 @@ async function runMigrations() {
|
|||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ollama_model') THEN
|
||||
ALTER TABLE users ADD COLUMN ollama_model TEXT;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_topic') THEN
|
||||
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
|
||||
END IF;
|
||||
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;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export interface NotificationSettings {
|
|||
pushover_user_key: string | null;
|
||||
pushover_app_token: string | null;
|
||||
pushover_enabled: boolean;
|
||||
ntfy_topic: string | null;
|
||||
ntfy_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AISettings {
|
||||
|
|
@ -70,7 +72,8 @@ export const userQueries = {
|
|||
const result = await pool.query(
|
||||
`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
|
||||
pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled,
|
||||
ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled
|
||||
FROM users WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
|
@ -117,6 +120,14 @@ export const userQueries = {
|
|||
fields.push(`pushover_enabled = $${paramIndex++}`);
|
||||
values.push(settings.pushover_enabled);
|
||||
}
|
||||
if (settings.ntfy_topic !== undefined) {
|
||||
fields.push(`ntfy_topic = $${paramIndex++}`);
|
||||
values.push(settings.ntfy_topic);
|
||||
}
|
||||
if (settings.ntfy_enabled !== undefined) {
|
||||
fields.push(`ntfy_enabled = $${paramIndex++}`);
|
||||
values.push(settings.ntfy_enabled);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
|
|
@ -125,7 +136,8 @@ export const userQueries = {
|
|||
`UPDATE users SET ${fields.join(', ')} WHERE id = $${paramIndex}
|
||||
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`,
|
||||
pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled,
|
||||
ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled`,
|
||||
values
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ router.get('/notifications', async (req: AuthRequest, res: Response) => {
|
|||
pushover_user_key: settings.pushover_user_key || null,
|
||||
pushover_app_token: settings.pushover_app_token || null,
|
||||
pushover_enabled: settings.pushover_enabled ?? true,
|
||||
ntfy_topic: settings.ntfy_topic || null,
|
||||
ntfy_enabled: settings.ntfy_enabled ?? true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification settings:', error);
|
||||
|
|
@ -47,6 +49,8 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
|
|||
pushover_user_key,
|
||||
pushover_app_token,
|
||||
pushover_enabled,
|
||||
ntfy_topic,
|
||||
ntfy_enabled,
|
||||
} = req.body;
|
||||
|
||||
const settings = await userQueries.updateNotificationSettings(userId, {
|
||||
|
|
@ -58,6 +62,8 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
|
|||
pushover_user_key,
|
||||
pushover_app_token,
|
||||
pushover_enabled,
|
||||
ntfy_topic,
|
||||
ntfy_enabled,
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
|
|
@ -74,6 +80,8 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
|
|||
pushover_user_key: settings.pushover_user_key || null,
|
||||
pushover_app_token: settings.pushover_app_token || null,
|
||||
pushover_enabled: settings.pushover_enabled ?? true,
|
||||
ntfy_topic: settings.ntfy_topic || null,
|
||||
ntfy_enabled: settings.ntfy_enabled ?? true,
|
||||
message: 'Notification settings updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -186,6 +194,38 @@ router.post('/notifications/test/pushover', async (req: AuthRequest, res: Respon
|
|||
}
|
||||
});
|
||||
|
||||
// Test ntfy notification
|
||||
router.post('/notifications/test/ntfy', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const settings = await userQueries.getNotificationSettings(userId);
|
||||
|
||||
if (!settings?.ntfy_topic) {
|
||||
res.status(400).json({ error: 'ntfy not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { sendNtfyNotification } = await import('../services/notifications');
|
||||
const success = await sendNtfyNotification(settings.ntfy_topic, {
|
||||
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 ntfy notification:', error);
|
||||
res.status(500).json({ error: 'Failed to send test notification' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get AI settings
|
||||
router.get('/ai', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -195,6 +195,52 @@ export async function sendPushoverNotification(
|
|||
}
|
||||
}
|
||||
|
||||
export async function sendNtfyNotification(
|
||||
topic: string,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const currencySymbol = getCurrencySymbol(payload.currency);
|
||||
|
||||
let title: string;
|
||||
let message: string;
|
||||
let tags: 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}`;
|
||||
tags = ['moneybag', 'chart_with_downwards_trend'];
|
||||
} 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})`;
|
||||
tags = ['dart', 'white_check_mark'];
|
||||
} 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}`;
|
||||
tags = ['package', 'tada'];
|
||||
}
|
||||
|
||||
await axios.post(`https://ntfy.sh/${topic}`, message, {
|
||||
headers: {
|
||||
'Title': title,
|
||||
'Tags': tags.join(','),
|
||||
'Click': payload.productUrl,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`ntfy notification sent to topic ${topic}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to send ntfy notification:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNotifications(
|
||||
settings: {
|
||||
telegram_bot_token: string | null;
|
||||
|
|
@ -205,6 +251,8 @@ export async function sendNotifications(
|
|||
pushover_user_key: string | null;
|
||||
pushover_app_token: string | null;
|
||||
pushover_enabled?: boolean;
|
||||
ntfy_topic: string | null;
|
||||
ntfy_enabled?: boolean;
|
||||
},
|
||||
payload: NotificationPayload
|
||||
): Promise<void> {
|
||||
|
|
@ -227,5 +275,9 @@ export async function sendNotifications(
|
|||
);
|
||||
}
|
||||
|
||||
if (settings.ntfy_topic && settings.ntfy_enabled !== false) {
|
||||
promises.push(sendNtfyNotification(settings.ntfy_topic, payload));
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,8 @@ export interface NotificationSettings {
|
|||
pushover_user_key: string | null;
|
||||
pushover_app_token: string | null;
|
||||
pushover_enabled: boolean;
|
||||
ntfy_topic: string | null;
|
||||
ntfy_enabled: boolean;
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
|
|
@ -172,6 +174,8 @@ export const settingsApi = {
|
|||
pushover_user_key?: string | null;
|
||||
pushover_app_token?: string | null;
|
||||
pushover_enabled?: boolean;
|
||||
ntfy_topic?: string | null;
|
||||
ntfy_enabled?: boolean;
|
||||
}) => api.put<NotificationSettings & { message: string }>('/settings/notifications', data),
|
||||
|
||||
testTelegram: () =>
|
||||
|
|
@ -183,6 +187,9 @@ export const settingsApi = {
|
|||
testPushover: () =>
|
||||
api.post<{ message: string }>('/settings/notifications/test/pushover'),
|
||||
|
||||
testNtfy: () =>
|
||||
api.post<{ message: string }>('/settings/notifications/test/ntfy'),
|
||||
|
||||
// AI Settings
|
||||
getAI: () =>
|
||||
api.get<AISettings>('/settings/ai'),
|
||||
|
|
|
|||
|
|
@ -527,7 +527,8 @@ export default function ProductDetail() {
|
|||
{notificationSettings && (
|
||||
((notificationSettings.telegram_bot_token && notificationSettings.telegram_chat_id) && notificationSettings.telegram_enabled) ||
|
||||
(notificationSettings.discord_webhook_url && notificationSettings.discord_enabled) ||
|
||||
((notificationSettings.pushover_user_key && notificationSettings.pushover_app_token) && notificationSettings.pushover_enabled)
|
||||
((notificationSettings.pushover_user_key && notificationSettings.pushover_app_token) && notificationSettings.pushover_enabled) ||
|
||||
(notificationSettings.ntfy_topic && notificationSettings.ntfy_enabled)
|
||||
) && (
|
||||
<>
|
||||
<style>{`
|
||||
|
|
@ -685,6 +686,9 @@ export default function ProductDetail() {
|
|||
{(notificationSettings.pushover_user_key && notificationSettings.pushover_app_token) && notificationSettings.pushover_enabled && (
|
||||
<span className="notification-channel-badge">Pushover</span>
|
||||
)}
|
||||
{notificationSettings.ntfy_topic && notificationSettings.ntfy_enabled && (
|
||||
<span className="notification-channel-badge">ntfy</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="notification-settings-description">
|
||||
|
|
|
|||
|
|
@ -39,8 +39,10 @@ export default function Settings() {
|
|||
const [pushoverUserKey, setPushoverUserKey] = useState('');
|
||||
const [pushoverAppToken, setPushoverAppToken] = useState('');
|
||||
const [pushoverEnabled, setPushoverEnabled] = useState(true);
|
||||
const [ntfyTopic, setNtfyTopic] = useState('');
|
||||
const [ntfyEnabled, setNtfyEnabled] = useState(true);
|
||||
const [isSavingNotifications, setIsSavingNotifications] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState<'telegram' | 'discord' | 'pushover' | null>(null);
|
||||
const [isTesting, setIsTesting] = useState<'telegram' | 'discord' | 'pushover' | 'ntfy' | null>(null);
|
||||
|
||||
// AI state
|
||||
const [aiSettings, setAISettings] = useState<AISettings | null>(null);
|
||||
|
|
@ -90,6 +92,8 @@ export default function Settings() {
|
|||
setPushoverUserKey(notificationsRes.data.pushover_user_key || '');
|
||||
setPushoverAppToken(notificationsRes.data.pushover_app_token || '');
|
||||
setPushoverEnabled(notificationsRes.data.pushover_enabled ?? true);
|
||||
setNtfyTopic(notificationsRes.data.ntfy_topic || '');
|
||||
setNtfyEnabled(notificationsRes.data.ntfy_enabled ?? true);
|
||||
// Populate AI fields with actual values
|
||||
setAISettings(aiRes.data);
|
||||
setAIEnabled(aiRes.data.ai_enabled);
|
||||
|
|
@ -302,6 +306,46 @@ export default function Settings() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSaveNtfy = async () => {
|
||||
clearMessages();
|
||||
setIsSavingNotifications(true);
|
||||
try {
|
||||
const response = await settingsApi.updateNotifications({
|
||||
ntfy_topic: ntfyTopic || null,
|
||||
});
|
||||
setNotificationSettings(response.data);
|
||||
setSuccess('ntfy settings saved successfully');
|
||||
} catch {
|
||||
setError('Failed to save ntfy settings');
|
||||
} finally {
|
||||
setIsSavingNotifications(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNtfy = async () => {
|
||||
clearMessages();
|
||||
setIsTesting('ntfy');
|
||||
try {
|
||||
await settingsApi.testNtfy();
|
||||
setSuccess('Test notification sent to ntfy!');
|
||||
} catch {
|
||||
setError('Failed to send test notification');
|
||||
} finally {
|
||||
setIsTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleNtfy = async (enabled: boolean) => {
|
||||
setNtfyEnabled(enabled);
|
||||
try {
|
||||
const response = await settingsApi.updateNotifications({ ntfy_enabled: enabled });
|
||||
setNotificationSettings(response.data);
|
||||
} catch {
|
||||
setNtfyEnabled(!enabled);
|
||||
setError('Failed to update ntfy status');
|
||||
}
|
||||
};
|
||||
|
||||
// AI handlers
|
||||
const handleSaveAI = async () => {
|
||||
clearMessages();
|
||||
|
|
@ -1150,6 +1194,68 @@ 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">ntfy Notifications</h2>
|
||||
<span className={`settings-section-status ${notificationSettings?.ntfy_topic ? 'configured' : 'not-configured'}`}>
|
||||
{notificationSettings?.ntfy_topic ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="settings-section-description">
|
||||
Receive push notifications via ntfy.sh - a simple, free notification service.
|
||||
No account required! Just pick a topic name and subscribe to it in the ntfy app.
|
||||
</p>
|
||||
|
||||
{notificationSettings?.ntfy_topic && (
|
||||
<div className="settings-toggle">
|
||||
<div className="settings-toggle-label">
|
||||
<span className="settings-toggle-title">Enable ntfy Notifications</span>
|
||||
<span className="settings-toggle-description">
|
||||
Toggle to enable or disable ntfy alerts
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className={`toggle-switch ${ntfyEnabled ? 'active' : ''}`}
|
||||
onClick={() => handleToggleNtfy(!ntfyEnabled)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>Topic Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ntfyTopic}
|
||||
onChange={(e) => setNtfyTopic(e.target.value)}
|
||||
placeholder="my-price-alerts"
|
||||
/>
|
||||
<p className="hint">
|
||||
Pick a unique topic name (e.g., priceghost-myname-123). Then subscribe to it in the{' '}
|
||||
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer">ntfy app</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveNtfy}
|
||||
disabled={isSavingNotifications}
|
||||
>
|
||||
{isSavingNotifications ? 'Saving...' : 'Save ntfy Settings'}
|
||||
</button>
|
||||
{notificationSettings?.ntfy_topic && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestNtfy}
|
||||
disabled={isTesting === 'ntfy'}
|
||||
>
|
||||
{isTesting === 'ntfy' ? 'Sending...' : 'Send Test'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue