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:
clucraft 2026-01-23 20:32:24 -05:00
parent 45363e4d97
commit 63fcaebfd8
12 changed files with 1244 additions and 16 deletions

View file

@ -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(

View file

@ -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;
},
};

View 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;

View file

@ -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 };
}

View file

@ -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);