mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-05-15 10:52:36 +02:00
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:
parent
8c5d20707d
commit
a6928a0c17
13 changed files with 1373 additions and 21 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
130
backend/src/routes/settings.ts
Normal file
130
backend/src/routes/settings.ts
Normal 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;
|
||||
137
backend/src/services/notifications.ts
Normal file
137
backend/src/services/notifications.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue