PriceGhost/backend/src/routes/products.ts
clucraft 26a802e3d0 Add per-product pause/resume checking feature
Users can now pause and resume price checking for individual products
or in bulk via the Actions menu.

Backend:
- Added checking_paused column to products table
- Scheduler skips products with checking_paused=true
- Added POST /products/bulk/pause endpoint for bulk pause/resume

Frontend:
- Added Pause Checking and Resume Checking to bulk Actions menu
- Added filter dropdown (All/Active/Paused) next to sort controls
- Paused products show greyed out with pause icon and "Paused" label
- Progress bar hidden when paused

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:04:02 -05:00

298 lines
9.1 KiB
TypeScript

import { Router, Response } from 'express';
import { AuthRequest, authMiddleware } from '../middleware/auth';
import { productQueries, priceHistoryQueries, stockStatusHistoryQueries } from '../models';
import { scrapeProduct, scrapeProductWithVoting, ExtractionMethod } from '../services/scraper';
const router = Router();
// All routes require authentication
router.use(authMiddleware);
// Get all products for the authenticated user (with sparkline data)
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const products = await productQueries.findByUserIdWithSparkline(userId);
res.json(products);
} catch (error) {
console.error('Error fetching products:', error);
res.status(500).json({ error: 'Failed to fetch products' });
}
});
// Add a new product to track (with multi-strategy voting)
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const { url, refresh_interval, selectedPrice, selectedMethod } = req.body;
if (!url) {
res.status(400).json({ error: 'URL is required' });
return;
}
// Validate URL
try {
new URL(url);
} catch {
res.status(400).json({ error: 'Invalid URL format' });
return;
}
// If user is confirming a price selection, use the old scraper with their choice
if (selectedPrice !== undefined && selectedMethod) {
// User has selected a price from candidates - use it directly
const scrapedData = await scrapeProduct(url, userId);
// Create product with the user-selected price
const product = await productQueries.create(
userId,
url,
scrapedData.name,
scrapedData.imageUrl,
refresh_interval || 3600,
scrapedData.stockStatus
);
// Store the preferred extraction method and the user-selected price
await productQueries.updateExtractionMethod(product.id, selectedMethod);
// Store the anchor price - used on refresh to select the correct variant
await productQueries.updateAnchorPrice(product.id, selectedPrice);
console.log(`[Products] Saved anchor price ${selectedPrice} for product ${product.id} (method: ${selectedMethod})`);
// Record the user-selected price
await priceHistoryQueries.create(
product.id,
selectedPrice,
'USD', // TODO: Get currency from selection
null
);
// Record initial stock status
if (scrapedData.stockStatus !== 'unknown') {
await stockStatusHistoryQueries.recordChange(product.id, scrapedData.stockStatus);
}
// Update last_checked timestamp
await productQueries.updateLastChecked(product.id, product.refresh_interval);
const productWithPrice = await productQueries.findById(product.id, userId);
res.status(201).json(productWithPrice);
return;
}
// Use multi-strategy voting scraper
const scrapedData = await scrapeProductWithVoting(url, userId);
// Allow adding out-of-stock products, but require a price for in-stock ones
if (!scrapedData.price && scrapedData.stockStatus !== 'out_of_stock') {
res.status(400).json({
error: 'Could not extract price from the provided URL',
});
return;
}
// Always show price selection modal when adding a product so user can verify
// Show if we have at least one candidate with a price
if (scrapedData.priceCandidates.length > 0 || scrapedData.price) {
// Make sure we have at least one candidate to show
const candidates = scrapedData.priceCandidates.length > 0
? scrapedData.priceCandidates
: scrapedData.price
? [{
price: scrapedData.price.price,
currency: scrapedData.price.currency,
method: scrapedData.selectedMethod || 'ai' as const,
context: 'Extracted price',
confidence: 0.8
}]
: [];
if (candidates.length > 0) {
res.status(200).json({
needsReview: true,
name: scrapedData.name,
imageUrl: scrapedData.imageUrl,
stockStatus: scrapedData.stockStatus,
priceCandidates: candidates.map(c => ({
price: c.price,
currency: c.currency,
method: c.method,
context: c.context,
confidence: c.confidence,
})),
suggestedPrice: scrapedData.price,
url,
});
return;
}
}
// Create product with stock status
const product = await productQueries.create(
userId,
url,
scrapedData.name,
scrapedData.imageUrl,
refresh_interval || 3600,
scrapedData.stockStatus
);
// Store the extraction method that worked
if (scrapedData.selectedMethod) {
await productQueries.updateExtractionMethod(product.id, scrapedData.selectedMethod);
}
// Record initial price if available
if (scrapedData.price) {
await priceHistoryQueries.create(
product.id,
scrapedData.price.price,
scrapedData.price.currency,
scrapedData.aiStatus
);
}
// Record initial stock status
if (scrapedData.stockStatus !== 'unknown') {
await stockStatusHistoryQueries.recordChange(product.id, scrapedData.stockStatus);
}
// Update last_checked timestamp and schedule next check
await productQueries.updateLastChecked(product.id, product.refresh_interval);
// Fetch the product with the price
const productWithPrice = await productQueries.findById(product.id, userId);
res.status(201).json(productWithPrice);
} catch (error) {
// Handle unique constraint violation
if (
error instanceof Error &&
error.message.includes('duplicate key value')
) {
res.status(409).json({ error: 'You are already tracking this product' });
return;
}
console.error('Error adding product:', error);
res.status(500).json({ error: 'Failed to add product' });
}
});
// Get a specific product
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const productId = parseInt(req.params.id, 10);
if (isNaN(productId)) {
res.status(400).json({ error: 'Invalid product ID' });
return;
}
const product = await productQueries.findById(productId, userId);
if (!product) {
res.status(404).json({ error: 'Product not found' });
return;
}
// Get price stats
const stats = await priceHistoryQueries.getStats(productId);
res.json({ ...product, stats });
} catch (error) {
console.error('Error fetching product:', error);
res.status(500).json({ error: 'Failed to fetch product' });
}
});
// Update product settings
router.put('/:id', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const productId = parseInt(req.params.id, 10);
if (isNaN(productId)) {
res.status(400).json({ error: 'Invalid product ID' });
return;
}
const { name, refresh_interval, price_drop_threshold, target_price, notify_back_in_stock, ai_verification_disabled, ai_extraction_disabled } = req.body;
const product = await productQueries.update(productId, userId, {
name,
refresh_interval,
price_drop_threshold,
target_price,
notify_back_in_stock,
ai_verification_disabled,
ai_extraction_disabled,
});
if (!product) {
res.status(404).json({ error: 'Product not found' });
return;
}
res.json(product);
} catch (error) {
console.error('Error updating product:', error);
res.status(500).json({ error: 'Failed to update product' });
}
});
// Delete a product
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const productId = parseInt(req.params.id, 10);
if (isNaN(productId)) {
res.status(400).json({ error: 'Invalid product ID' });
return;
}
const deleted = await productQueries.delete(productId, userId);
if (!deleted) {
res.status(404).json({ error: 'Product not found' });
return;
}
res.json({ message: 'Product deleted successfully' });
} catch (error) {
console.error('Error deleting product:', error);
res.status(500).json({ error: 'Failed to delete product' });
}
});
// Bulk pause/resume checking
router.post('/bulk/pause', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId!;
const { ids, paused } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'Product IDs array is required' });
return;
}
if (typeof paused !== 'boolean') {
res.status(400).json({ error: 'Paused status (boolean) is required' });
return;
}
const updated = await productQueries.bulkSetCheckingPaused(ids, userId, paused);
res.json({
message: `${updated} product(s) ${paused ? 'paused' : 'resumed'}`,
updated
});
} catch (error) {
console.error('Error bulk updating pause status:', error);
res.status(500).json({ error: 'Failed to update pause status' });
}
});
export default router;