mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-05-12 09:12:40 +02:00
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>
298 lines
9.1 KiB
TypeScript
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;
|