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>
This commit is contained in:
clucraft 2026-01-20 21:15:04 -05:00
parent 8c5d20707d
commit a6928a0c17
13 changed files with 1373 additions and 21 deletions

View file

@ -5,6 +5,7 @@ import dotenv from 'dotenv';
import authRoutes from './routes/auth';
import productRoutes from './routes/products';
import priceRoutes from './routes/prices';
import settingsRoutes from './routes/settings';
import { startScheduler } from './services/scheduler';
// Load environment variables
@ -26,6 +27,7 @@ app.get('/health', (_, res) => {
app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);
app.use('/api/products', priceRoutes);
app.use('/api/settings', settingsRoutes);
// Error handling middleware
app.use(

View file

@ -5,9 +5,18 @@ export interface User {
id: number;
email: string;
password_hash: string;
telegram_bot_token: string | null;
telegram_chat_id: string | null;
discord_webhook_url: string | null;
created_at: Date;
}
export interface NotificationSettings {
telegram_bot_token: string | null;
telegram_chat_id: string | null;
discord_webhook_url: string | null;
}
export const userQueries = {
findByEmail: async (email: string): Promise<User | null> => {
const result = await pool.query(
@ -32,6 +41,46 @@ export const userQueries = {
);
return result.rows[0];
},
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',
[id]
);
return result.rows[0] || null;
},
updateNotificationSettings: async (
id: number,
settings: Partial<NotificationSettings>
): Promise<NotificationSettings | null> => {
const fields: string[] = [];
const values: (string | null)[] = [];
let paramIndex = 1;
if (settings.telegram_bot_token !== undefined) {
fields.push(`telegram_bot_token = $${paramIndex++}`);
values.push(settings.telegram_bot_token);
}
if (settings.telegram_chat_id !== undefined) {
fields.push(`telegram_chat_id = $${paramIndex++}`);
values.push(settings.telegram_chat_id);
}
if (settings.discord_webhook_url !== undefined) {
fields.push(`discord_webhook_url = $${paramIndex++}`);
values.push(settings.discord_webhook_url);
}
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`,
values
);
return result.rows[0] || null;
},
};
// Product types and queries
@ -46,6 +95,8 @@ export interface Product {
refresh_interval: number;
last_checked: Date | null;
stock_status: StockStatus;
price_drop_threshold: number | null;
notify_back_in_stock: boolean;
created_at: Date;
}
@ -177,10 +228,15 @@ export const productQueries = {
update: async (
id: number,
userId: number,
updates: { name?: string; refresh_interval?: number }
updates: {
name?: string;
refresh_interval?: number;
price_drop_threshold?: number | null;
notify_back_in_stock?: boolean;
}
): Promise<Product | null> => {
const fields: string[] = [];
const values: (string | number)[] = [];
const values: (string | number | boolean | null)[] = [];
let paramIndex = 1;
if (updates.name !== undefined) {
@ -191,6 +247,14 @@ export const productQueries = {
fields.push(`refresh_interval = $${paramIndex++}`);
values.push(updates.refresh_interval);
}
if (updates.price_drop_threshold !== undefined) {
fields.push(`price_drop_threshold = $${paramIndex++}`);
values.push(updates.price_drop_threshold);
}
if (updates.notify_back_in_stock !== undefined) {
fields.push(`notify_back_in_stock = $${paramIndex++}`);
values.push(updates.notify_back_in_stock);
}
if (fields.length === 0) return null;

View file

@ -0,0 +1,130 @@
import { Router, Response } from 'express';
import { AuthRequest, authMiddleware } from '../middleware/auth';
import { userQueries } from '../models';
const router = Router();
// All routes require authentication
router.use(authMiddleware);
// Get notification settings
router.get('/notifications', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const settings = await userQueries.getNotificationSettings(userId);
if (!settings) {
res.status(404).json({ error: 'User not found' });
return;
}
// Don't expose full bot token, just indicate if it's 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,
});
} catch (error) {
console.error('Error fetching notification settings:', error);
res.status(500).json({ error: 'Failed to fetch notification settings' });
}
});
// Update notification settings
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 settings = await userQueries.updateNotificationSettings(userId, {
telegram_bot_token,
telegram_chat_id,
discord_webhook_url,
});
if (!settings) {
res.status(400).json({ error: 'No settings to update' });
return;
}
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,
message: 'Notification settings updated successfully',
});
} catch (error) {
console.error('Error updating notification settings:', error);
res.status(500).json({ error: 'Failed to update notification settings' });
}
});
// Test Telegram notification
router.post('/notifications/test/telegram', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const settings = await userQueries.getNotificationSettings(userId);
if (!settings?.telegram_bot_token || !settings?.telegram_chat_id) {
res.status(400).json({ error: 'Telegram not configured' });
return;
}
const { sendTelegramNotification } = await import('../services/notifications');
const success = await sendTelegramNotification(
settings.telegram_bot_token,
settings.telegram_chat_id,
{
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 Telegram notification:', error);
res.status(500).json({ error: 'Failed to send test notification' });
}
});
// Test Discord notification
router.post('/notifications/test/discord', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const settings = await userQueries.getNotificationSettings(userId);
if (!settings?.discord_webhook_url) {
res.status(400).json({ error: 'Discord not configured' });
return;
}
const { sendDiscordNotification } = await import('../services/notifications');
const success = await sendDiscordNotification(settings.discord_webhook_url, {
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 Discord notification:', error);
res.status(500).json({ error: 'Failed to send test notification' });
}
});
export default router;

View file

@ -0,0 +1,137 @@
import axios from 'axios';
export interface NotificationPayload {
productName: string;
productUrl: string;
type: 'price_drop' | 'back_in_stock';
oldPrice?: number;
newPrice?: number;
currency?: string;
threshold?: number;
}
function formatMessage(payload: NotificationPayload): string {
const currencySymbol = payload.currency === 'EUR' ? '€' : payload.currency === 'GBP' ? '£' : '$';
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';
const dropAmount = payload.oldPrice && payload.newPrice
? `${currencySymbol}${(payload.oldPrice - payload.newPrice).toFixed(2)}`
: '';
return `🔔 Price Drop Alert!\n\n` +
`📦 ${payload.productName}\n\n` +
`💰 Price dropped from ${oldPriceStr} to ${newPriceStr}` +
(dropAmount ? ` (-${dropAmount})` : '') + `\n\n` +
`🔗 ${payload.productUrl}`;
}
if (payload.type === 'back_in_stock') {
const priceStr = payload.newPrice ? ` at ${currencySymbol}${payload.newPrice.toFixed(2)}` : '';
return `🎉 Back in Stock!\n\n` +
`📦 ${payload.productName}\n\n` +
`✅ This item is now available${priceStr}\n\n` +
`🔗 ${payload.productUrl}`;
}
return '';
}
export async function sendTelegramNotification(
botToken: string,
chatId: string,
payload: NotificationPayload
): Promise<boolean> {
try {
const message = formatMessage(payload);
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
await axios.post(url, {
chat_id: chatId,
text: message,
parse_mode: 'HTML',
disable_web_page_preview: false,
});
console.log(`Telegram notification sent to chat ${chatId}`);
return true;
} catch (error) {
console.error('Failed to send Telegram notification:', error);
return false;
}
}
export async function sendDiscordNotification(
webhookUrl: string,
payload: NotificationPayload
): Promise<boolean> {
try {
const currencySymbol = payload.currency === 'EUR' ? '€' : payload.currency === 'GBP' ? '£' : '$';
let embed;
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';
embed = {
title: '🔔 Price Drop Alert!',
description: payload.productName,
color: 0x10b981, // Green
fields: [
{ name: 'Old Price', value: oldPriceStr, inline: true },
{ name: 'New Price', value: newPriceStr, inline: true },
],
url: payload.productUrl,
timestamp: new Date().toISOString(),
};
} else {
const priceStr = payload.newPrice ? `${currencySymbol}${payload.newPrice.toFixed(2)}` : 'Check link';
embed = {
title: '🎉 Back in Stock!',
description: payload.productName,
color: 0x6366f1, // Indigo
fields: [
{ name: 'Price', value: priceStr, inline: true },
{ name: 'Status', value: '✅ Available', inline: true },
],
url: payload.productUrl,
timestamp: new Date().toISOString(),
};
}
await axios.post(webhookUrl, {
embeds: [embed],
});
console.log('Discord notification sent');
return true;
} catch (error) {
console.error('Failed to send Discord notification:', error);
return false;
}
}
export async function sendNotifications(
settings: {
telegram_bot_token: string | null;
telegram_chat_id: string | null;
discord_webhook_url: string | null;
},
payload: NotificationPayload
): Promise<void> {
const promises: Promise<boolean>[] = [];
if (settings.telegram_bot_token && settings.telegram_chat_id) {
promises.push(
sendTelegramNotification(settings.telegram_bot_token, settings.telegram_chat_id, payload)
);
}
if (settings.discord_webhook_url) {
promises.push(sendDiscordNotification(settings.discord_webhook_url, payload));
}
await Promise.allSettled(promises);
}

View file

@ -1,6 +1,7 @@
import cron from 'node-cron';
import { productQueries, priceHistoryQueries } from '../models';
import { productQueries, priceHistoryQueries, userQueries } from '../models';
import { scrapeProduct } from './scraper';
import { sendNotifications, NotificationPayload } from './notifications';
let isRunning = false;
@ -24,12 +25,36 @@ async function checkPrices(): Promise<void> {
const scrapedData = await scrapeProduct(product.url);
// Check for back-in-stock notification
const wasOutOfStock = product.stock_status === 'out_of_stock';
const nowInStock = scrapedData.stockStatus === 'in_stock';
// Update stock status
if (scrapedData.stockStatus !== product.stock_status) {
await productQueries.updateStockStatus(product.id, scrapedData.stockStatus);
console.log(
`Stock status changed for product ${product.id}: ${product.stock_status} -> ${scrapedData.stockStatus}`
);
// Send back-in-stock notification
if (wasOutOfStock && nowInStock && product.notify_back_in_stock) {
try {
const userSettings = await userQueries.getNotificationSettings(product.user_id);
if (userSettings) {
const payload: NotificationPayload = {
productName: product.name || 'Unknown Product',
productUrl: product.url,
type: 'back_in_stock',
newPrice: scrapedData.price?.price,
currency: scrapedData.price?.currency || 'USD',
};
await sendNotifications(userSettings, payload);
console.log(`Back-in-stock notification sent for product ${product.id}`);
}
} catch (notifyError) {
console.error(`Failed to send back-in-stock notification for product ${product.id}:`, notifyError);
}
}
}
if (scrapedData.price) {
@ -38,6 +63,34 @@ async function checkPrices(): Promise<void> {
// Only record if price has changed or it's the first entry
if (!latestPrice || latestPrice.price !== scrapedData.price.price) {
// Check for price drop notification before recording
if (latestPrice && product.price_drop_threshold) {
const oldPrice = parseFloat(String(latestPrice.price));
const newPrice = scrapedData.price.price;
const priceDrop = oldPrice - newPrice;
if (priceDrop >= product.price_drop_threshold) {
try {
const userSettings = await userQueries.getNotificationSettings(product.user_id);
if (userSettings) {
const payload: NotificationPayload = {
productName: product.name || 'Unknown Product',
productUrl: product.url,
type: 'price_drop',
oldPrice: oldPrice,
newPrice: newPrice,
currency: scrapedData.price.currency,
threshold: product.price_drop_threshold,
};
await sendNotifications(userSettings, payload);
console.log(`Price drop notification sent for product ${product.id}: ${priceDrop} drop`);
}
} catch (notifyError) {
console.error(`Failed to send price drop notification for product ${product.id}:`, notifyError);
}
}
}
await priceHistoryQueries.create(
product.id,
scrapedData.price.price,