Skip to content

Commit a6928a0

Browse files
clucraftclaude
andcommitted
Add refresh controls and notification support
- Add refresh button to product list items with spinning animation - Add editable refresh interval dropdown on product detail page - Add user profile dropdown with settings link in navbar - Create Settings page for Telegram and Discord configuration - Add per-product notification options (price drop threshold, back in stock) - Integrate notifications into scheduler for automatic alerts - Add notification service supporting Telegram Bot API and Discord webhooks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8c5d207 commit a6928a0

File tree

13 files changed

+1373
-21
lines changed

13 files changed

+1373
-21
lines changed

backend/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import dotenv from 'dotenv';
55
import authRoutes from './routes/auth';
66
import productRoutes from './routes/products';
77
import priceRoutes from './routes/prices';
8+
import settingsRoutes from './routes/settings';
89
import { startScheduler } from './services/scheduler';
910

1011
// Load environment variables
@@ -26,6 +27,7 @@ app.get('/health', (_, res) => {
2627
app.use('/api/auth', authRoutes);
2728
app.use('/api/products', productRoutes);
2829
app.use('/api/products', priceRoutes);
30+
app.use('/api/settings', settingsRoutes);
2931

3032
// Error handling middleware
3133
app.use(

backend/src/models/index.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@ export interface User {
55
id: number;
66
email: string;
77
password_hash: string;
8+
telegram_bot_token: string | null;
9+
telegram_chat_id: string | null;
10+
discord_webhook_url: string | null;
811
created_at: Date;
912
}
1013

14+
export interface NotificationSettings {
15+
telegram_bot_token: string | null;
16+
telegram_chat_id: string | null;
17+
discord_webhook_url: string | null;
18+
}
19+
1120
export const userQueries = {
1221
findByEmail: async (email: string): Promise<User | null> => {
1322
const result = await pool.query(
@@ -32,6 +41,46 @@ export const userQueries = {
3241
);
3342
return result.rows[0];
3443
},
44+
45+
getNotificationSettings: async (id: number): Promise<NotificationSettings | null> => {
46+
const result = await pool.query(
47+
'SELECT telegram_bot_token, telegram_chat_id, discord_webhook_url FROM users WHERE id = $1',
48+
[id]
49+
);
50+
return result.rows[0] || null;
51+
},
52+
53+
updateNotificationSettings: async (
54+
id: number,
55+
settings: Partial<NotificationSettings>
56+
): Promise<NotificationSettings | null> => {
57+
const fields: string[] = [];
58+
const values: (string | null)[] = [];
59+
let paramIndex = 1;
60+
61+
if (settings.telegram_bot_token !== undefined) {
62+
fields.push(`telegram_bot_token = $${paramIndex++}`);
63+
values.push(settings.telegram_bot_token);
64+
}
65+
if (settings.telegram_chat_id !== undefined) {
66+
fields.push(`telegram_chat_id = $${paramIndex++}`);
67+
values.push(settings.telegram_chat_id);
68+
}
69+
if (settings.discord_webhook_url !== undefined) {
70+
fields.push(`discord_webhook_url = $${paramIndex++}`);
71+
values.push(settings.discord_webhook_url);
72+
}
73+
74+
if (fields.length === 0) return null;
75+
76+
values.push(id.toString());
77+
const result = await pool.query(
78+
`UPDATE users SET ${fields.join(', ')} WHERE id = $${paramIndex}
79+
RETURNING telegram_bot_token, telegram_chat_id, discord_webhook_url`,
80+
values
81+
);
82+
return result.rows[0] || null;
83+
},
3584
};
3685

3786
// Product types and queries
@@ -46,6 +95,8 @@ export interface Product {
4695
refresh_interval: number;
4796
last_checked: Date | null;
4897
stock_status: StockStatus;
98+
price_drop_threshold: number | null;
99+
notify_back_in_stock: boolean;
49100
created_at: Date;
50101
}
51102

@@ -177,10 +228,15 @@ export const productQueries = {
177228
update: async (
178229
id: number,
179230
userId: number,
180-
updates: { name?: string; refresh_interval?: number }
231+
updates: {
232+
name?: string;
233+
refresh_interval?: number;
234+
price_drop_threshold?: number | null;
235+
notify_back_in_stock?: boolean;
236+
}
181237
): Promise<Product | null> => {
182238
const fields: string[] = [];
183-
const values: (string | number)[] = [];
239+
const values: (string | number | boolean | null)[] = [];
184240
let paramIndex = 1;
185241

186242
if (updates.name !== undefined) {
@@ -191,6 +247,14 @@ export const productQueries = {
191247
fields.push(`refresh_interval = $${paramIndex++}`);
192248
values.push(updates.refresh_interval);
193249
}
250+
if (updates.price_drop_threshold !== undefined) {
251+
fields.push(`price_drop_threshold = $${paramIndex++}`);
252+
values.push(updates.price_drop_threshold);
253+
}
254+
if (updates.notify_back_in_stock !== undefined) {
255+
fields.push(`notify_back_in_stock = $${paramIndex++}`);
256+
values.push(updates.notify_back_in_stock);
257+
}
194258

195259
if (fields.length === 0) return null;
196260

backend/src/routes/settings.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Router, Response } from 'express';
2+
import { AuthRequest, authMiddleware } from '../middleware/auth';
3+
import { userQueries } from '../models';
4+
5+
const router = Router();
6+
7+
// All routes require authentication
8+
router.use(authMiddleware);
9+
10+
// Get notification settings
11+
router.get('/notifications', async (req: AuthRequest, res: Response) => {
12+
try {
13+
const userId = req.userId!;
14+
const settings = await userQueries.getNotificationSettings(userId);
15+
16+
if (!settings) {
17+
res.status(404).json({ error: 'User not found' });
18+
return;
19+
}
20+
21+
// Don't expose full bot token, just indicate if it's set
22+
res.json({
23+
telegram_configured: !!(settings.telegram_bot_token && settings.telegram_chat_id),
24+
telegram_chat_id: settings.telegram_chat_id,
25+
discord_configured: !!settings.discord_webhook_url,
26+
});
27+
} catch (error) {
28+
console.error('Error fetching notification settings:', error);
29+
res.status(500).json({ error: 'Failed to fetch notification settings' });
30+
}
31+
});
32+
33+
// Update notification settings
34+
router.put('/notifications', async (req: AuthRequest, res: Response) => {
35+
try {
36+
const userId = req.userId!;
37+
const { telegram_bot_token, telegram_chat_id, discord_webhook_url } = req.body;
38+
39+
const settings = await userQueries.updateNotificationSettings(userId, {
40+
telegram_bot_token,
41+
telegram_chat_id,
42+
discord_webhook_url,
43+
});
44+
45+
if (!settings) {
46+
res.status(400).json({ error: 'No settings to update' });
47+
return;
48+
}
49+
50+
res.json({
51+
telegram_configured: !!(settings.telegram_bot_token && settings.telegram_chat_id),
52+
telegram_chat_id: settings.telegram_chat_id,
53+
discord_configured: !!settings.discord_webhook_url,
54+
message: 'Notification settings updated successfully',
55+
});
56+
} catch (error) {
57+
console.error('Error updating notification settings:', error);
58+
res.status(500).json({ error: 'Failed to update notification settings' });
59+
}
60+
});
61+
62+
// Test Telegram notification
63+
router.post('/notifications/test/telegram', async (req: AuthRequest, res: Response) => {
64+
try {
65+
const userId = req.userId!;
66+
const settings = await userQueries.getNotificationSettings(userId);
67+
68+
if (!settings?.telegram_bot_token || !settings?.telegram_chat_id) {
69+
res.status(400).json({ error: 'Telegram not configured' });
70+
return;
71+
}
72+
73+
const { sendTelegramNotification } = await import('../services/notifications');
74+
const success = await sendTelegramNotification(
75+
settings.telegram_bot_token,
76+
settings.telegram_chat_id,
77+
{
78+
productName: 'Test Product',
79+
productUrl: 'https://example.com',
80+
type: 'price_drop',
81+
oldPrice: 29.99,
82+
newPrice: 19.99,
83+
currency: 'USD',
84+
}
85+
);
86+
87+
if (success) {
88+
res.json({ message: 'Test notification sent successfully' });
89+
} else {
90+
res.status(500).json({ error: 'Failed to send test notification' });
91+
}
92+
} catch (error) {
93+
console.error('Error sending test Telegram notification:', error);
94+
res.status(500).json({ error: 'Failed to send test notification' });
95+
}
96+
});
97+
98+
// Test Discord notification
99+
router.post('/notifications/test/discord', async (req: AuthRequest, res: Response) => {
100+
try {
101+
const userId = req.userId!;
102+
const settings = await userQueries.getNotificationSettings(userId);
103+
104+
if (!settings?.discord_webhook_url) {
105+
res.status(400).json({ error: 'Discord not configured' });
106+
return;
107+
}
108+
109+
const { sendDiscordNotification } = await import('../services/notifications');
110+
const success = await sendDiscordNotification(settings.discord_webhook_url, {
111+
productName: 'Test Product',
112+
productUrl: 'https://example.com',
113+
type: 'price_drop',
114+
oldPrice: 29.99,
115+
newPrice: 19.99,
116+
currency: 'USD',
117+
});
118+
119+
if (success) {
120+
res.json({ message: 'Test notification sent successfully' });
121+
} else {
122+
res.status(500).json({ error: 'Failed to send test notification' });
123+
}
124+
} catch (error) {
125+
console.error('Error sending test Discord notification:', error);
126+
res.status(500).json({ error: 'Failed to send test notification' });
127+
}
128+
});
129+
130+
export default router;
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import axios from 'axios';
2+
3+
export interface NotificationPayload {
4+
productName: string;
5+
productUrl: string;
6+
type: 'price_drop' | 'back_in_stock';
7+
oldPrice?: number;
8+
newPrice?: number;
9+
currency?: string;
10+
threshold?: number;
11+
}
12+
13+
function formatMessage(payload: NotificationPayload): string {
14+
const currencySymbol = payload.currency === 'EUR' ? '€' : payload.currency === 'GBP' ? '£' : '$';
15+
16+
if (payload.type === 'price_drop') {
17+
const oldPriceStr = payload.oldPrice ? `${currencySymbol}${payload.oldPrice.toFixed(2)}` : 'N/A';
18+
const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A';
19+
const dropAmount = payload.oldPrice && payload.newPrice
20+
? `${currencySymbol}${(payload.oldPrice - payload.newPrice).toFixed(2)}`
21+
: '';
22+
23+
return `🔔 Price Drop Alert!\n\n` +
24+
`📦 ${payload.productName}\n\n` +
25+
`💰 Price dropped from ${oldPriceStr} to ${newPriceStr}` +
26+
(dropAmount ? ` (-${dropAmount})` : '') + `\n\n` +
27+
`🔗 ${payload.productUrl}`;
28+
}
29+
30+
if (payload.type === 'back_in_stock') {
31+
const priceStr = payload.newPrice ? ` at ${currencySymbol}${payload.newPrice.toFixed(2)}` : '';
32+
return `🎉 Back in Stock!\n\n` +
33+
`📦 ${payload.productName}\n\n` +
34+
`✅ This item is now available${priceStr}\n\n` +
35+
`🔗 ${payload.productUrl}`;
36+
}
37+
38+
return '';
39+
}
40+
41+
export async function sendTelegramNotification(
42+
botToken: string,
43+
chatId: string,
44+
payload: NotificationPayload
45+
): Promise<boolean> {
46+
try {
47+
const message = formatMessage(payload);
48+
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
49+
50+
await axios.post(url, {
51+
chat_id: chatId,
52+
text: message,
53+
parse_mode: 'HTML',
54+
disable_web_page_preview: false,
55+
});
56+
57+
console.log(`Telegram notification sent to chat ${chatId}`);
58+
return true;
59+
} catch (error) {
60+
console.error('Failed to send Telegram notification:', error);
61+
return false;
62+
}
63+
}
64+
65+
export async function sendDiscordNotification(
66+
webhookUrl: string,
67+
payload: NotificationPayload
68+
): Promise<boolean> {
69+
try {
70+
const currencySymbol = payload.currency === 'EUR' ? '€' : payload.currency === 'GBP' ? '£' : '$';
71+
72+
let embed;
73+
if (payload.type === 'price_drop') {
74+
const oldPriceStr = payload.oldPrice ? `${currencySymbol}${payload.oldPrice.toFixed(2)}` : 'N/A';
75+
const newPriceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'N/A';
76+
77+
embed = {
78+
title: '🔔 Price Drop Alert!',
79+
description: payload.productName,
80+
color: 0x10b981, // Green
81+
fields: [
82+
{ name: 'Old Price', value: oldPriceStr, inline: true },
83+
{ name: 'New Price', value: newPriceStr, inline: true },
84+
],
85+
url: payload.productUrl,
86+
timestamp: new Date().toISOString(),
87+
};
88+
} else {
89+
const priceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'Check link';
90+
91+
embed = {
92+
title: '🎉 Back in Stock!',
93+
description: payload.productName,
94+
color: 0x6366f1, // Indigo
95+
fields: [
96+
{ name: 'Price', value: priceStr, inline: true },
97+
{ name: 'Status', value: '✅ Available', inline: true },
98+
],
99+
url: payload.productUrl,
100+
timestamp: new Date().toISOString(),
101+
};
102+
}
103+
104+
await axios.post(webhookUrl, {
105+
embeds: [embed],
106+
});
107+
108+
console.log('Discord notification sent');
109+
return true;
110+
} catch (error) {
111+
console.error('Failed to send Discord notification:', error);
112+
return false;
113+
}
114+
}
115+
116+
export async function sendNotifications(
117+
settings: {
118+
telegram_bot_token: string | null;
119+
telegram_chat_id: string | null;
120+
discord_webhook_url: string | null;
121+
},
122+
payload: NotificationPayload
123+
): Promise<void> {
124+
const promises: Promise<boolean>[] = [];
125+
126+
if (settings.telegram_bot_token && settings.telegram_chat_id) {
127+
promises.push(
128+
sendTelegramNotification(settings.telegram_bot_token, settings.telegram_chat_id, payload)
129+
);
130+
}
131+
132+
if (settings.discord_webhook_url) {
133+
promises.push(sendDiscordNotification(settings.discord_webhook_url, payload));
134+
}
135+
136+
await Promise.allSettled(promises);
137+
}

0 commit comments

Comments
 (0)