mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-05-12 09:12:40 +02:00
Initial commit: PriceGhost price tracking application
Full-stack application for tracking product prices: - Backend: Node.js + Express + TypeScript - Frontend: React + Vite + TypeScript - Database: PostgreSQL - Price scraping with Cheerio - JWT authentication - Background price checking with node-cron - Price history charts with Recharts - Docker support with docker-compose Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
10660e5626
44 changed files with 3662 additions and 0 deletions
93
backend/src/routes/auth.ts
Normal file
93
backend/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { userQueries } from '../models';
|
||||
import { generateToken } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Register new user
|
||||
router.post('/register', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'Email and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
res.status(400).json({ error: 'Invalid email format' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUser = await userQueries.findByEmail(email);
|
||||
if (existingUser) {
|
||||
res.status(409).json({ error: 'Email already registered' });
|
||||
return;
|
||||
}
|
||||
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
const user = await userQueries.create(email, passwordHash);
|
||||
const token = generateToken(user.id);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'User registered successfully',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'Email and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userQueries.findByEmail(email);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Invalid email or password' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
res.status(401).json({ error: 'Invalid email or password' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = generateToken(user.id);
|
||||
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
93
backend/src/routes/prices.ts
Normal file
93
backend/src/routes/prices.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { AuthRequest, authMiddleware } from '../middleware/auth';
|
||||
import { productQueries, priceHistoryQueries } from '../models';
|
||||
import { scrapePrice } from '../services/scraper';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Get price history for a product
|
||||
router.get('/:productId/prices', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const productId = parseInt(req.params.productId, 10);
|
||||
|
||||
if (isNaN(productId)) {
|
||||
res.status(400).json({ error: 'Invalid product ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify product belongs to user
|
||||
const product = await productQueries.findById(productId, userId);
|
||||
if (!product) {
|
||||
res.status(404).json({ error: 'Product not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get optional days filter from query
|
||||
const days = req.query.days ? parseInt(req.query.days as string, 10) : undefined;
|
||||
|
||||
const priceHistory = await priceHistoryQueries.findByProductId(
|
||||
productId,
|
||||
days
|
||||
);
|
||||
|
||||
res.json({
|
||||
product,
|
||||
prices: priceHistory,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching price history:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch price history' });
|
||||
}
|
||||
});
|
||||
|
||||
// Force immediate price refresh
|
||||
router.post('/:productId/refresh', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const productId = parseInt(req.params.productId, 10);
|
||||
|
||||
if (isNaN(productId)) {
|
||||
res.status(400).json({ error: 'Invalid product ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify product belongs to user
|
||||
const product = await productQueries.findById(productId, userId);
|
||||
if (!product) {
|
||||
res.status(404).json({ error: 'Product not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Scrape new price
|
||||
const priceData = await scrapePrice(product.url);
|
||||
|
||||
if (!priceData) {
|
||||
res.status(400).json({ error: 'Could not extract price from URL' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Record new price
|
||||
const newPrice = await priceHistoryQueries.create(
|
||||
productId,
|
||||
priceData.price,
|
||||
priceData.currency
|
||||
);
|
||||
|
||||
// Update last_checked timestamp
|
||||
await productQueries.updateLastChecked(productId);
|
||||
|
||||
res.json({
|
||||
message: 'Price refreshed successfully',
|
||||
price: newPrice,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error refreshing price:', error);
|
||||
res.status(500).json({ error: 'Failed to refresh price' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
172
backend/src/routes/products.ts
Normal file
172
backend/src/routes/products.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { AuthRequest, authMiddleware } from '../middleware/auth';
|
||||
import { productQueries, priceHistoryQueries } from '../models';
|
||||
import { scrapeProduct } from '../services/scraper';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Get all products for the authenticated user
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const products = await productQueries.findByUserId(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
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const { url, refresh_interval } = 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;
|
||||
}
|
||||
|
||||
// Scrape product info
|
||||
const scrapedData = await scrapeProduct(url);
|
||||
|
||||
if (!scrapedData.price) {
|
||||
res.status(400).json({
|
||||
error: 'Could not extract price from the provided URL',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create product
|
||||
const product = await productQueries.create(
|
||||
userId,
|
||||
url,
|
||||
scrapedData.name,
|
||||
scrapedData.imageUrl,
|
||||
refresh_interval || 3600
|
||||
);
|
||||
|
||||
// Record initial price
|
||||
await priceHistoryQueries.create(
|
||||
product.id,
|
||||
scrapedData.price.price,
|
||||
scrapedData.price.currency
|
||||
);
|
||||
|
||||
// Update last_checked timestamp
|
||||
await productQueries.updateLastChecked(product.id);
|
||||
|
||||
// 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 } = req.body;
|
||||
|
||||
const product = await productQueries.update(productId, userId, {
|
||||
name,
|
||||
refresh_interval,
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Loading…
Add table
Add a link
Reference in a new issue