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

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