diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c27dca..a431fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to PriceGhost will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.3] - 2026-01-23 + +### Added + +- **Notification History** - Complete log of all triggered notifications accessible via bell icon in navbar + - Bell icon with badge showing recent notification count (last 24 hours) + - Dropdown preview showing 5 most recent notifications + - Full history page at `/notifications` with filtering by type + - Tracks price drops, target price alerts, and back-in-stock notifications + - Shows which channels (Telegram, Discord, Pushover, ntfy) received each notification + - Links to product detail pages from notification entries + +--- + ## [1.0.2] - 2026-01-23 ### Fixed @@ -114,6 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | Version | Date | Description | |---------|------|-------------| +| 1.0.3 | 2026-01-23 | Notification history with bell icon dropdown and full history page | | 1.0.2 | 2026-01-23 | Fixed stock status false positives for in-stock items | | 1.0.1 | 2026-01-23 | Bug fixes, JS-rendered price support, pre-order detection | | 1.0.0 | 2026-01-23 | Initial public release | diff --git a/backend/src/index.ts b/backend/src/index.ts index 4c3827a..3fba19d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,7 @@ import priceRoutes from './routes/prices'; import settingsRoutes from './routes/settings'; import profileRoutes from './routes/profile'; import adminRoutes from './routes/admin'; +import notificationRoutes from './routes/notifications'; import { startScheduler } from './services/scheduler'; import pool from './config/database'; @@ -147,6 +148,33 @@ async function runMigrations() { END $$; `); + // Create notification_history table for tracking all triggered notifications + await client.query(` + CREATE TABLE IF NOT EXISTS notification_history ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + product_id INTEGER REFERENCES products(id) ON DELETE CASCADE, + notification_type VARCHAR(50) NOT NULL, + triggered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + old_price DECIMAL(10,2), + new_price DECIMAL(10,2), + currency VARCHAR(10), + price_change_percent DECIMAL(5,2), + target_price DECIMAL(10,2), + old_stock_status VARCHAR(20), + new_stock_status VARCHAR(20), + channels_notified JSONB, + product_name VARCHAR(500), + product_url TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_notification_history_user_date + ON notification_history(user_id, triggered_at DESC); + + CREATE INDEX IF NOT EXISTS idx_notification_history_product + ON notification_history(product_id); + `); + console.log('Database migrations completed'); } catch (error) { console.error('Migration error:', error); @@ -178,6 +206,7 @@ app.use('/api/products', priceRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/profile', profileRoutes); app.use('/api/admin', adminRoutes); +app.use('/api/notifications', notificationRoutes); // Error handling middleware app.use( diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index a67cbfc..414f8d0 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -773,3 +773,127 @@ export const stockStatusHistoryQueries = { }; }, }; + +// Notification History types and queries +export type NotificationType = 'price_drop' | 'price_target' | 'stock_change'; + +export interface NotificationHistory { + id: number; + user_id: number; + product_id: number; + notification_type: NotificationType; + triggered_at: Date; + old_price: number | null; + new_price: number | null; + currency: string | null; + price_change_percent: number | null; + target_price: number | null; + old_stock_status: string | null; + new_stock_status: string | null; + channels_notified: string[]; + product_name: string | null; + product_url: string | null; +} + +export interface CreateNotificationHistory { + user_id: number; + product_id: number; + notification_type: NotificationType; + old_price?: number; + new_price?: number; + currency?: string; + price_change_percent?: number; + target_price?: number; + old_stock_status?: string; + new_stock_status?: string; + channels_notified: string[]; + product_name?: string; + product_url?: string; +} + +export const notificationHistoryQueries = { + // Create a new notification history record + create: async (data: CreateNotificationHistory): Promise => { + const result = await pool.query( + `INSERT INTO notification_history + (user_id, product_id, notification_type, old_price, new_price, currency, + price_change_percent, target_price, old_stock_status, new_stock_status, + channels_notified, product_name, product_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + data.user_id, + data.product_id, + data.notification_type, + data.old_price || null, + data.new_price || null, + data.currency || null, + data.price_change_percent || null, + data.target_price || null, + data.old_stock_status || null, + data.new_stock_status || null, + JSON.stringify(data.channels_notified), + data.product_name || null, + data.product_url || null, + ] + ); + return result.rows[0]; + }, + + // Get notifications for a user with pagination + getByUserId: async ( + userId: number, + limit: number = 50, + offset: number = 0 + ): Promise => { + const result = await pool.query( + `SELECT * FROM notification_history + WHERE user_id = $1 + ORDER BY triggered_at DESC + LIMIT $2 OFFSET $3`, + [userId, limit, offset] + ); + return result.rows; + }, + + // Get recent notifications (for bell dropdown) + getRecent: async (userId: number, limit: number = 10): Promise => { + const result = await pool.query( + `SELECT * FROM notification_history + WHERE user_id = $1 + ORDER BY triggered_at DESC + LIMIT $2`, + [userId, limit] + ); + return result.rows; + }, + + // Count notifications in last 24 hours (for badge) + countRecent: async (userId: number, hours: number = 24): Promise => { + const result = await pool.query( + `SELECT COUNT(*) FROM notification_history + WHERE user_id = $1 AND triggered_at > NOW() - INTERVAL '1 hour' * $2`, + [userId, hours] + ); + return parseInt(result.rows[0].count, 10); + }, + + // Get total count for pagination + getTotalCount: async (userId: number): Promise => { + const result = await pool.query( + `SELECT COUNT(*) FROM notification_history WHERE user_id = $1`, + [userId] + ); + return parseInt(result.rows[0].count, 10); + }, + + // Delete old notifications (for cleanup) + deleteOlderThan: async (days: number): Promise => { + const result = await pool.query( + `DELETE FROM notification_history + WHERE triggered_at < NOW() - INTERVAL '1 day' * $1`, + [days] + ); + return result.rowCount || 0; + }, +}; diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts new file mode 100644 index 0000000..6bbeea8 --- /dev/null +++ b/backend/src/routes/notifications.ts @@ -0,0 +1,72 @@ +import { Router, Response } from 'express'; +import { authMiddleware, AuthRequest } from '../middleware/auth'; +import { notificationHistoryQueries } from '../models'; + +const router = Router(); + +// All routes require authentication +router.use(authMiddleware); + +// Get recent notifications (for bell dropdown) +router.get('/recent', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const limit = Math.min(parseInt(req.query.limit as string) || 10, 20); + + const notifications = await notificationHistoryQueries.getRecent(userId, limit); + const recentCount = await notificationHistoryQueries.countRecent(userId, 24); + + res.json({ + notifications, + recentCount, + }); + } catch (error) { + console.error('Error fetching recent notifications:', error); + res.status(500).json({ error: 'Failed to fetch notifications' }); + } +}); + +// Get notification history with pagination +router.get('/history', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const offset = (page - 1) * limit; + + const [notifications, totalCount] = await Promise.all([ + notificationHistoryQueries.getByUserId(userId, limit, offset), + notificationHistoryQueries.getTotalCount(userId), + ]); + + res.json({ + notifications, + pagination: { + page, + limit, + totalCount, + totalPages: Math.ceil(totalCount / limit), + }, + }); + } catch (error) { + console.error('Error fetching notification history:', error); + res.status(500).json({ error: 'Failed to fetch notification history' }); + } +}); + +// Get count of recent notifications (for badge) +router.get('/count', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const hours = parseInt(req.query.hours as string) || 24; + + const count = await notificationHistoryQueries.countRecent(userId, hours); + + res.json({ count }); + } catch (error) { + console.error('Error counting notifications:', error); + res.status(500).json({ error: 'Failed to count notifications' }); + } +}); + +export default router; diff --git a/backend/src/services/notifications.ts b/backend/src/services/notifications.ts index 73eec73..81c6d46 100644 --- a/backend/src/services/notifications.ts +++ b/backend/src/services/notifications.ts @@ -241,6 +241,11 @@ export async function sendNtfyNotification( } } +export interface NotificationResult { + channelsNotified: string[]; + channelsFailed: string[]; +} + export async function sendNotifications( settings: { telegram_bot_token: string | null; @@ -255,29 +260,51 @@ export async function sendNotifications( ntfy_enabled?: boolean; }, payload: NotificationPayload -): Promise { - const promises: Promise[] = []; +): Promise { + const channelPromises: { channel: string; promise: Promise }[] = []; // Only send if channel is configured AND enabled (default to true if not specified) if (settings.telegram_bot_token && settings.telegram_chat_id && settings.telegram_enabled !== false) { - promises.push( - sendTelegramNotification(settings.telegram_bot_token, settings.telegram_chat_id, payload) - ); + channelPromises.push({ + channel: 'telegram', + promise: sendTelegramNotification(settings.telegram_bot_token, settings.telegram_chat_id, payload), + }); } if (settings.discord_webhook_url && settings.discord_enabled !== false) { - promises.push(sendDiscordNotification(settings.discord_webhook_url, payload)); + channelPromises.push({ + channel: 'discord', + promise: sendDiscordNotification(settings.discord_webhook_url, payload), + }); } if (settings.pushover_user_key && settings.pushover_app_token && settings.pushover_enabled !== false) { - promises.push( - sendPushoverNotification(settings.pushover_user_key, settings.pushover_app_token, payload) - ); + channelPromises.push({ + channel: 'pushover', + promise: sendPushoverNotification(settings.pushover_user_key, settings.pushover_app_token, payload), + }); } if (settings.ntfy_topic && settings.ntfy_enabled !== false) { - promises.push(sendNtfyNotification(settings.ntfy_topic, payload)); + channelPromises.push({ + channel: 'ntfy', + promise: sendNtfyNotification(settings.ntfy_topic, payload), + }); } - await Promise.allSettled(promises); + const results = await Promise.allSettled(channelPromises.map(c => c.promise)); + + const channelsNotified: string[] = []; + const channelsFailed: string[] = []; + + results.forEach((result, index) => { + const channel = channelPromises[index].channel; + if (result.status === 'fulfilled' && result.value === true) { + channelsNotified.push(channel); + } else { + channelsFailed.push(channel); + } + }); + + return { channelsNotified, channelsFailed }; } diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index 86d3029..42adffb 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -1,5 +1,5 @@ import cron from 'node-cron'; -import { productQueries, priceHistoryQueries, userQueries, stockStatusHistoryQueries } from '../models'; +import { productQueries, priceHistoryQueries, userQueries, stockStatusHistoryQueries, notificationHistoryQueries, NotificationType } from '../models'; import { scrapeProduct } from './scraper'; import { sendNotifications, NotificationPayload } from './notifications'; @@ -52,8 +52,24 @@ async function checkPrices(): Promise { newPrice: scrapedData.price?.price, currency: scrapedData.price?.currency || 'USD', }; - await sendNotifications(userSettings, payload); + const result = await sendNotifications(userSettings, payload); console.log(`Back-in-stock notification sent for product ${product.id}`); + + // Log notification to history + if (result.channelsNotified.length > 0) { + await notificationHistoryQueries.create({ + user_id: product.user_id, + product_id: product.id, + notification_type: 'stock_change' as NotificationType, + old_stock_status: product.stock_status, + new_stock_status: scrapedData.stockStatus, + new_price: scrapedData.price?.price, + currency: scrapedData.price?.currency || 'USD', + channels_notified: result.channelsNotified, + product_name: product.name || 'Unknown Product', + product_url: product.url, + }); + } } } catch (notifyError) { console.error(`Failed to send back-in-stock notification for product ${product.id}:`, notifyError); @@ -86,8 +102,25 @@ async function checkPrices(): Promise { currency: scrapedData.price.currency, threshold: product.price_drop_threshold, }; - await sendNotifications(userSettings, payload); + const result = await sendNotifications(userSettings, payload); console.log(`Price drop notification sent for product ${product.id}: ${priceDrop} drop`); + + // Log notification to history + if (result.channelsNotified.length > 0) { + const priceChangePercent = ((oldPrice - newPrice) / oldPrice) * 100; + await notificationHistoryQueries.create({ + user_id: product.user_id, + product_id: product.id, + notification_type: 'price_drop' as NotificationType, + old_price: oldPrice, + new_price: newPrice, + currency: scrapedData.price.currency, + price_change_percent: Math.round(priceChangePercent * 100) / 100, + channels_notified: result.channelsNotified, + product_name: product.name || 'Unknown Product', + product_url: product.url, + }); + } } } catch (notifyError) { console.error(`Failed to send price drop notification for product ${product.id}:`, notifyError); @@ -114,8 +147,24 @@ async function checkPrices(): Promise { currency: scrapedData.price.currency, targetPrice: targetPrice, }; - await sendNotifications(userSettings, payload); + const result = await sendNotifications(userSettings, payload); console.log(`Target price notification sent for product ${product.id}: ${newPrice} <= ${targetPrice}`); + + // Log notification to history + if (result.channelsNotified.length > 0) { + await notificationHistoryQueries.create({ + user_id: product.user_id, + product_id: product.id, + notification_type: 'price_target' as NotificationType, + old_price: oldPrice || undefined, + new_price: newPrice, + currency: scrapedData.price.currency, + target_price: targetPrice, + channels_notified: result.channelsNotified, + product_name: product.name || 'Unknown Product', + product_url: product.url, + }); + } } } catch (notifyError) { console.error(`Failed to send target price notification for product ${product.id}:`, notifyError); diff --git a/frontend/public/version.json b/frontend/public/version.json index 76cf47c..8c7e6af 100644 --- a/frontend/public/version.json +++ b/frontend/public/version.json @@ -1,4 +1,4 @@ { - "version": "1.0.2", + "version": "1.0.3", "releaseDate": "2026-01-23" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 26f5726..f10a367 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import Register from './pages/Register'; import Dashboard from './pages/Dashboard'; import ProductDetail from './pages/ProductDetail'; import Settings from './pages/Settings'; +import NotificationHistory from './pages/NotificationHistory'; function ThemeInitializer({ children }: { children: React.ReactNode }) { useEffect(() => { @@ -111,6 +112,14 @@ function AppRoutes() { } /> + + + + } + /> } /> ); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fcd1711..7731671 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -262,6 +262,59 @@ export const profileApi = { }), }; +// Notification History API +export type NotificationType = 'price_drop' | 'price_target' | 'stock_change'; + +export interface NotificationHistoryEntry { + id: number; + user_id: number; + product_id: number; + notification_type: NotificationType; + triggered_at: string; + old_price: number | null; + new_price: number | null; + currency: string | null; + price_change_percent: number | null; + target_price: number | null; + old_stock_status: string | null; + new_stock_status: string | null; + channels_notified: string[]; + product_name: string | null; + product_url: string | null; +} + +export interface NotificationHistoryResponse { + notifications: NotificationHistoryEntry[]; + pagination: { + page: number; + limit: number; + totalCount: number; + totalPages: number; + }; +} + +export interface RecentNotificationsResponse { + notifications: NotificationHistoryEntry[]; + recentCount: number; +} + +export const notificationsApi = { + getRecent: (limit?: number) => + api.get('/notifications/recent', { + params: limit ? { limit } : undefined, + }), + + getHistory: (page?: number, limit?: number) => + api.get('/notifications/history', { + params: { page: page || 1, limit: limit || 20 }, + }), + + getCount: (hours?: number) => + api.get<{ count: number }>('/notifications/count', { + params: hours ? { hours } : undefined, + }), +}; + // Admin API export interface SystemSettings { registration_enabled: string; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 44ef142..38cab79 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,7 @@ import { ReactNode, useState, useEffect, useRef } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; +import NotificationBell from './NotificationBell'; interface LayoutProps { children: ReactNode; @@ -310,6 +311,7 @@ export default function Layout({ children }: LayoutProps) { > {theme === 'light' ? '🌙' : '☀️'} + {user && } {user && (
+ + {isOpen && ( +
+
+ Notifications + + + + +
+ + {loading ? ( +
Loading...
+ ) : notifications.length === 0 ? ( +
+
{'\u{1F514}'}
+
No notifications yet
+
+ You'll be notified when prices drop +
+
+ ) : ( +
+ {notifications.map((notification) => ( + setIsOpen(false)} + > + + {getNotificationIcon(notification.notification_type)} + +
+
+ {getNotificationTitle(notification)} +
+
+ {notification.product_name || 'Unknown Product'} +
+
+ {notification.new_price && ( + + {formatPrice(notification.new_price, notification.currency)} + + )} + + {formatTimeAgo(notification.triggered_at)} + +
+
+ + ))} +
+ )} + +
+ setIsOpen(false)}> + View All History + +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/NotificationHistory.tsx b/frontend/src/pages/NotificationHistory.tsx new file mode 100644 index 0000000..67381b8 --- /dev/null +++ b/frontend/src/pages/NotificationHistory.tsx @@ -0,0 +1,487 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import Layout from '../components/Layout'; +import { notificationsApi, NotificationHistoryEntry, NotificationType } from '../api/client'; + +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function getNotificationIcon(type: NotificationType): string { + switch (type) { + case 'price_drop': + return '\u{1F4C9}'; + case 'price_target': + return '\u{1F3AF}'; + case 'stock_change': + return '\u{1F4E6}'; + default: + return '\u{1F514}'; + } +} + +function getNotificationTypeLabel(type: NotificationType): string { + switch (type) { + case 'price_drop': + return 'Price Drop'; + case 'price_target': + return 'Target Reached'; + case 'stock_change': + return 'Back in Stock'; + default: + return 'Notification'; + } +} + +function formatPrice(price: number | null, currency: string | null): string { + if (price === null) return '-'; + const symbol = currency === 'EUR' ? '\u20AC' : currency === 'GBP' ? '\u00A3' : currency === 'CHF' ? 'CHF ' : '$'; + return `${symbol}${price.toFixed(2)}`; +} + +function getChannelIcon(channel: string): string { + switch (channel) { + case 'telegram': + return '\u{1F4AC}'; + case 'discord': + return '\u{1F4AC}'; + case 'pushover': + return '\u{1F4F1}'; + case 'ntfy': + return '\u{1F4E2}'; + default: + return '\u{1F4E8}'; + } +} + +export default function NotificationHistory() { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [filter, setFilter] = useState('all'); + + useEffect(() => { + fetchNotifications(); + }, [page]); + + const fetchNotifications = async () => { + setLoading(true); + try { + const response = await notificationsApi.getHistory(page, 20); + setNotifications(response.data.notifications); + setTotalPages(response.data.pagination.totalPages); + } catch (error) { + console.error('Failed to fetch notification history:', error); + } finally { + setLoading(false); + } + }; + + const filteredNotifications = filter === 'all' + ? notifications + : notifications.filter(n => n.notification_type === filter); + + return ( + + + +
+
+

+ {'\u{1F514}'} Notification History +

+ +
+ + + + +
+
+ +
+
+
Product
+
Price
+
Change
+
Channels
+
Date
+
+ + {loading ? ( +
Loading...
+ ) : filteredNotifications.length === 0 ? ( +
+
{'\u{1F514}'}
+
No notifications yet
+

+ Notifications will appear here when price drops, targets are reached, or items come back in stock. +

+
+ ) : ( + filteredNotifications.map((notification) => ( +
+
+ + {getNotificationIcon(notification.notification_type)} + +
+
+ + {notification.product_name || 'Unknown Product'} + +
+ + {getNotificationTypeLabel(notification.notification_type)} + +
+
+ +
+ {notification.old_price && ( +
+ {formatPrice(notification.old_price, notification.currency)} +
+ )} +
+ {formatPrice(notification.new_price, notification.currency)} +
+
+ +
+ {notification.price_change_percent && ( + + -{Math.abs(notification.price_change_percent).toFixed(1)}% + + )} + {notification.target_price && ( + + Target: {formatPrice(notification.target_price, notification.currency)} + + )} + {notification.notification_type === 'stock_change' && ( + + Now available + + )} +
+ +
+ {(notification.channels_notified || []).map((channel) => ( + + {getChannelIcon(channel)} {channel} + + ))} +
+ +
+ {formatDate(notification.triggered_at)} +
+
+ )) + )} +
+ + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+
+ ); +}