mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-05-05 13:53:00 +02:00
Add notification history feature with bell icon and history page
- Add notification_history database table for logging all triggered notifications - Create API endpoints for fetching recent and historical notifications - Add NotificationBell component in navbar with badge showing recent count - Dropdown shows 5 most recent notifications with links to products - Create full NotificationHistory page with filtering by notification type - Log notifications when sent: price drops, target prices, back-in-stock - Track which channels (telegram, discord, pushover, ntfy) received each notification - Update sendNotifications to return which channels succeeded - Bump version to 1.0.3 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
45363e4d97
commit
63fcaebfd8
12 changed files with 1244 additions and 16 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<NotificationHistory> => {
|
||||
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<NotificationHistory[]> => {
|
||||
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<NotificationHistory[]> => {
|
||||
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<number> => {
|
||||
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<number> => {
|
||||
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<number> => {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM notification_history
|
||||
WHERE triggered_at < NOW() - INTERVAL '1 day' * $1`,
|
||||
[days]
|
||||
);
|
||||
return result.rowCount || 0;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
72
backend/src/routes/notifications.ts
Normal file
72
backend/src/routes/notifications.ts
Normal file
|
|
@ -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;
|
||||
|
|
@ -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<void> {
|
||||
const promises: Promise<boolean>[] = [];
|
||||
): Promise<NotificationResult> {
|
||||
const channelPromises: { channel: string; promise: Promise<boolean> }[] = [];
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue