more ts and break apart big ass files
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Has been cancelled
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Has been cancelled
This commit is contained in:
@@ -125,7 +125,7 @@ Fill in the required fields:
|
||||
|
||||
Application name: A descriptive name for your app (e.g., "Flyer Crawler").
|
||||
Homepage URL: The base URL of your application (e.g., http://localhost:5173 for local development).
|
||||
Authorization callback URL: This is where GitHub will redirect users after they authorize your app. For local development, this will be: http://localhost:3001/api/auth/github/callback.
|
||||
Authorization callback URL: This is where GitHub will redirect users after they authorize your app. For local development, this will be: <http://localhost:3001/api/auth/github/callback>.
|
||||
Click Register application.
|
||||
|
||||
You will be given a Client ID and a Client Secret.
|
||||
@@ -134,4 +134,4 @@ Add these credentials to your .env file at the project root:
|
||||
|
||||
plaintext
|
||||
GITHUB_CLIENT_ID="your-github-client-id"
|
||||
GITHUB_CLIENT_SECRET="your-github-client-secret"
|
||||
GITHUB_CLIENT_SECRET="your-github-client-secret"
|
||||
|
||||
@@ -829,7 +829,7 @@ function App() {
|
||||
<main className="max-w-screen-2xl mx-auto py-4 px-2.5 sm:py-6 lg:py-8">
|
||||
{/* This banner will only appear for users who have interacted with the app but are not logged in. */}
|
||||
{authStatus === 'ANONYMOUS' && (
|
||||
<div className="max-w-screen-lg mx-auto mb-6 px-4 lg:px-0">
|
||||
<div className="max-w-5xl mx-auto mb-6 px-4 lg:px-0">
|
||||
<AnonymousUserBanner onOpenProfile={() => setIsProfileManagerOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
162
src/routes/admin.ts
Normal file
162
src/routes/admin.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import passport from './passport';
|
||||
import { isAdmin } from './passport';
|
||||
import multer from 'multer';
|
||||
|
||||
import * as db from '../services/db';
|
||||
import { logger } from '../services/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Multer Configuration for File Uploads ---
|
||||
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, storagePath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
// --- Middleware for all admin routes ---
|
||||
router.use(passport.authenticate('jwt', { session: false }), isAdmin);
|
||||
|
||||
// --- Admin Routes ---
|
||||
|
||||
router.get('/corrections', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const corrections = await db.getSuggestedCorrections();
|
||||
res.json(corrections);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching corrections in /api/admin/corrections:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/brands', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const brands = await db.getAllBrands();
|
||||
res.json(brands);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching brands in /api/admin/brands:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const stats = await db.getApplicationStats();
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching application stats in /api/admin/stats:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats/daily', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const dailyStats = await db.getDailyStatsForLast30Days();
|
||||
res.json(dailyStats);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching daily stats in /api/admin/stats/daily:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/corrections/:id/approve', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
await db.approveCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction approved successfully.' });
|
||||
} catch (error) {
|
||||
logger.error(`Error approving correction ${correctionId}:`, { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/corrections/:id/reject', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
await db.rejectCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction rejected successfully.' });
|
||||
} catch (error) {
|
||||
logger.error(`Error rejecting correction ${correctionId}:`, { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/corrections/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
const { suggested_value } = req.body;
|
||||
if (!suggested_value) {
|
||||
return res.status(400).json({ message: 'A new suggested_value is required.' });
|
||||
}
|
||||
try {
|
||||
const updatedCorrection = await db.updateSuggestedCorrection(correctionId, suggested_value);
|
||||
res.status(200).json(updatedCorrection);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/recipes/:id/status', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const recipeId = parseInt(req.params.id, 10);
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !['private', 'pending_review', 'public', 'rejected'].includes(status)) {
|
||||
return res.status(400).json({ message: 'A valid status (private, pending_review, public, rejected) is required.' });
|
||||
}
|
||||
try {
|
||||
const updatedRecipe = await db.updateRecipeStatus(recipeId, status);
|
||||
res.status(200).json(updatedRecipe);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/brands/:id/logo', upload.single('logoImage'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const brandId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Logo image file is required.' });
|
||||
}
|
||||
|
||||
const logoUrl = `/assets/${req.file.filename}`;
|
||||
await db.updateBrandLogo(brandId, logoUrl);
|
||||
|
||||
logger.info(`Brand logo updated for brand ID: ${brandId}`, { brandId, logoUrl });
|
||||
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/unmatched-items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const items = await db.getUnmatchedFlyerItems();
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching unmatched items in /api/admin/unmatched-items:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/comments/:id/status', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const commentId = parseInt(req.params.id, 10);
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !['visible', 'hidden', 'reported'].includes(status)) {
|
||||
return res.status(400).json({ message: 'A valid status (visible, hidden, reported) is required.' });
|
||||
}
|
||||
try {
|
||||
const updatedComment = await db.updateRecipeCommentStatus(commentId, status);
|
||||
res.status(200).json(updatedComment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
131
src/routes/ai.ts
Normal file
131
src/routes/ai.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import multer from 'multer';
|
||||
import passport from './passport';
|
||||
import { optionalAuth } from './passport';
|
||||
import * as aiService from '../services/aiService.server';
|
||||
import { logger } from '../services/logger';
|
||||
import { UserProfile } from '../types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Multer Configuration for File Uploads ---
|
||||
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, storagePath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
/**
|
||||
* This endpoint processes a flyer using AI. It uses `optionalAuth` middleware to allow
|
||||
* both authenticated and anonymous users to upload flyers.
|
||||
*/
|
||||
router.post('/process-flyer', optionalAuth, upload.array('flyerImages'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
|
||||
return res.status(400).json({ message: 'Flyer image files are required.' });
|
||||
}
|
||||
|
||||
const masterItems = JSON.parse(req.body.masterItems);
|
||||
const imagePaths = (req.files as Express.Multer.File[]).map(file => ({
|
||||
path: file.path,
|
||||
mimetype: file.mimetype
|
||||
}));
|
||||
|
||||
const user = req.user as UserProfile | undefined;
|
||||
const logIdentifier = user ? `user ID: ${user.id}` : 'anonymous user';
|
||||
|
||||
logger.info(`Starting AI flyer data extraction for ${imagePaths.length} image(s) for ${logIdentifier}.`);
|
||||
|
||||
const extractedData = await aiService.extractCoreDataFromFlyerImage(imagePaths, masterItems);
|
||||
logger.info(`Completed AI flyer data extraction. Found ${extractedData.items.length} items.`);
|
||||
|
||||
res.status(200).json({ data: extractedData });
|
||||
} catch (error) {
|
||||
logger.error('Error in /api/ai/process-flyer endpoint:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This endpoint checks if an image is a flyer. It uses `optionalAuth` to allow
|
||||
* both authenticated and anonymous users to perform this check.
|
||||
*/
|
||||
router.post('/check-flyer', optionalAuth, upload.single('image'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Image file is required.' });
|
||||
}
|
||||
logger.info(`Server-side flyer check for file: ${req.file.originalname}`);
|
||||
res.status(200).json({ is_flyer: true }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/extract-address', optionalAuth, upload.single('image'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Image file is required.' });
|
||||
}
|
||||
logger.info(`Server-side address extraction for file: ${req.file.originalname}`);
|
||||
res.status(200).json({ address: "123 AI Street, Server City" }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/extract-logo', optionalAuth, upload.array('images'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
|
||||
return res.status(400).json({ message: 'Image files are required.' });
|
||||
}
|
||||
logger.info(`Server-side logo extraction for ${req.files.length} image(s).`);
|
||||
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/quick-insights', passport.authenticate('jwt', { session: false }), async (req, res, next) => {
|
||||
try {
|
||||
logger.info(`Server-side quick insights requested.`);
|
||||
res.status(200).json({ text: "This is a server-generated quick insight: buy the cheap stuff!" }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/deep-dive', passport.authenticate('jwt', { session: false }), async (req, res, next) => {
|
||||
try {
|
||||
logger.info(`Server-side deep dive requested.`);
|
||||
res.status(200).json({ text: "This is a server-generated deep dive analysis. It is very detailed." }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/search-web', passport.authenticate('jwt', { session: false }), async (req, res, next) => {
|
||||
try {
|
||||
logger.info(`Server-side web search requested.`);
|
||||
res.status(200).json({ text: "The web says this is good.", sources: [] }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/plan-trip', passport.authenticate('jwt', { session: false }), async (req, res, next) => {
|
||||
try {
|
||||
logger.info(`Server-side trip planning requested.`);
|
||||
res.status(200).json({ text: "Here is your trip plan.", sources: [] }); // Stubbed response
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
242
src/routes/auth.ts
Normal file
242
src/routes/auth.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import zxcvbn from 'zxcvbn';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
import passport from './passport';
|
||||
import * as db from '../services/db';
|
||||
import { logger } from '../services/logger';
|
||||
import { sendPasswordResetEmail } from '../services/emailService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_jwt_key_change_this';
|
||||
|
||||
// --- Rate Limiting Configuration ---
|
||||
const forgotPasswordLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5,
|
||||
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
const resetPasswordLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10,
|
||||
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// --- Authentication Routes ---
|
||||
|
||||
// Registration Route
|
||||
router.post('/register', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { email, password, full_name, avatar_url } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ message: 'Email and password are required.' });
|
||||
}
|
||||
|
||||
// --- Password Strength Check ---
|
||||
const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4)
|
||||
const strength = zxcvbn(password);
|
||||
if (strength.score < MIN_PASSWORD_SCORE) {
|
||||
logger.warn(`Weak password rejected during registration for email: ${email}. Score: ${strength.score}`);
|
||||
const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
|
||||
return res.status(400).json({ message: `Password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() });
|
||||
}
|
||||
|
||||
try {
|
||||
const existingUser = await db.findUserByEmail(email);
|
||||
if (existingUser) {
|
||||
logger.warn(`Registration attempt for existing email: ${email}`);
|
||||
return res.status(409).json({ message: 'User with that email already exists.' });
|
||||
}
|
||||
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
logger.info(`Hashing password for new user: ${email}`);
|
||||
|
||||
const newUser = await db.createUser(email, hashedPassword, { full_name, avatar_url });
|
||||
logger.info(`Successfully created new user in DB: ${newUser.email} (ID: ${newUser.id})`);
|
||||
|
||||
const payload = { id: newUser.id, email: newUser.email };
|
||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
|
||||
|
||||
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||
await db.saveRefreshToken(newUser.id, refreshToken);
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
return res.status(201).json({ message: 'User registered successfully!', user: payload, token });
|
||||
} catch (err) {
|
||||
logger.error('Error during /register route handling:', { error: err });
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Login Route
|
||||
router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
||||
passport.authenticate('local', { session: false }, (err: Error, user: Express.User | false, info: { message: string }) => {
|
||||
const { rememberMe } = req.body;
|
||||
if (err) {
|
||||
logger.error('Login authentication error in /login route:', { error: err });
|
||||
return next(err);
|
||||
}
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: info.message || 'Login failed' });
|
||||
}
|
||||
|
||||
const typedUser = user as { id: string; email: string };
|
||||
const payload = { id: typedUser.id, email: typedUser.email };
|
||||
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||
|
||||
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||
db.saveRefreshToken(typedUser.id, refreshToken).then(() => {
|
||||
logger.info(`JWT and refresh token issued for user: ${typedUser.email}`);
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined
|
||||
};
|
||||
|
||||
res.cookie('refreshToken', refreshToken, cookieOptions);
|
||||
return res.json({ user: payload, token: accessToken });
|
||||
}).catch(tokenErr => {
|
||||
logger.error('Failed to save refresh token during login:', { error: tokenErr });
|
||||
return next(tokenErr);
|
||||
});
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
// Route to request a password reset
|
||||
router.post('/forgot-password', forgotPasswordLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { email } = req.body;
|
||||
if (!email) {
|
||||
return res.status(400).json({ message: 'Email is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db.findUserByEmail(email);
|
||||
if (user) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const saltRounds = 10;
|
||||
const tokenHash = await bcrypt.hash(token, saltRounds);
|
||||
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
|
||||
|
||||
await db.createPasswordResetToken(user.id, tokenHash, expiresAt);
|
||||
|
||||
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/reset-password/${token}`;
|
||||
|
||||
try {
|
||||
await sendPasswordResetEmail(email, resetLink);
|
||||
} catch (emailError) {
|
||||
logger.error(`Email send failure during password reset for user: ${emailError}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Password reset requested for non-existent email: ${email}`);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'If an account with that email exists, a password reset link has been sent.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Route to reset the password using a token
|
||||
router.post('/reset-password', resetPasswordLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { token, newPassword } = req.body;
|
||||
if (!token || !newPassword) {
|
||||
return res.status(400).json({ message: 'Token and new password are required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const validTokens = await db.getValidResetTokens();
|
||||
let tokenRecord;
|
||||
for (const record of validTokens) {
|
||||
const isMatch = await bcrypt.compare(token, record.token_hash);
|
||||
if (isMatch) {
|
||||
tokenRecord = record;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tokenRecord) {
|
||||
return res.status(400).json({ message: 'Invalid or expired password reset token.' });
|
||||
}
|
||||
|
||||
const MIN_PASSWORD_SCORE = 3;
|
||||
const strength = zxcvbn(newPassword);
|
||||
if (strength.score < MIN_PASSWORD_SCORE) {
|
||||
logger.warn(`Weak password rejected during password reset for user ID: ${tokenRecord.user_id}. Score: ${strength.score}`);
|
||||
const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
|
||||
return res.status(400).json({ message: `New password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() });
|
||||
}
|
||||
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
await db.updateUserPassword(tokenRecord.user_id, hashedPassword);
|
||||
await db.deleteResetToken(tokenRecord.token_hash);
|
||||
|
||||
res.status(200).json({ message: 'Password has been reset successfully.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// New Route to refresh the access token
|
||||
router.post('/refresh-token', async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.cookies;
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({ message: 'Refresh token not found.' });
|
||||
}
|
||||
|
||||
const user = await db.findUserByRefreshToken(refreshToken);
|
||||
if (!user) {
|
||||
return res.status(403).json({ message: 'Invalid refresh token.' });
|
||||
}
|
||||
|
||||
const payload = { id: user.id, email: user.email };
|
||||
const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||
|
||||
res.json({ token: newAccessToken });
|
||||
});
|
||||
|
||||
// --- OAuth Routes ---
|
||||
|
||||
const handleOAuthCallback = (req: Request, res: Response) => {
|
||||
const user = req.user as { id: string; email: string };
|
||||
const payload = { id: user.id, email: user.email };
|
||||
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||
|
||||
db.saveRefreshToken(user.id, refreshToken).then(() => {
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
});
|
||||
// Redirect to a frontend page that can handle the token
|
||||
res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${accessToken}`);
|
||||
}).catch(err => {
|
||||
logger.error('Failed to save refresh token during OAuth callback:', { error: err });
|
||||
res.redirect(`${process.env.FRONTEND_URL}/login?error=auth_failed`);
|
||||
});
|
||||
};
|
||||
|
||||
router.get('/google', passport.authenticate('google', { session: false }));
|
||||
router.get('/google/callback', passport.authenticate('google', { session: false, failureRedirect: '/login' }), handleOAuthCallback);
|
||||
|
||||
router.get('/github', passport.authenticate('github', { session: false }));
|
||||
router.get('/github/callback', passport.authenticate('github', { session: false, failureRedirect: '/login' }), handleOAuthCallback);
|
||||
|
||||
export default router;
|
||||
208
src/routes/passport.ts
Normal file
208
src/routes/passport.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import passport from 'passport';
|
||||
import { Strategy as LocalStrategy } from 'passport-local';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import { Strategy as GitHubStrategy } from 'passport-github2';
|
||||
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
import * as db from '../services/db';
|
||||
import { logger } from '../services/logger';
|
||||
import { sendWelcomeEmail } from '../services/emailService';
|
||||
import { UserProfile } from '../types';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_jwt_key_change_this';
|
||||
|
||||
// --- Passport Local Strategy (for email/password login) ---
|
||||
passport.use(new LocalStrategy(
|
||||
{ usernameField: 'email' }, // Tell Passport to expect 'email' instead of 'username'
|
||||
async (email, password, done) => {
|
||||
try {
|
||||
// 1. Find the user in your PostgreSQL database by email.
|
||||
const user = await db.findUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
// User not found
|
||||
logger.warn(`Login attempt failed for non-existent user: ${email}`);
|
||||
return done(null, false, { message: 'Incorrect email or password.' });
|
||||
}
|
||||
|
||||
if (!user.password_hash) {
|
||||
// User exists but signed up via OAuth, so they don't have a password.
|
||||
logger.warn(`Password login attempt for OAuth user: ${email}`);
|
||||
return done(null, false, { message: 'This account was created using a social login. Please use Google or GitHub to sign in.' });
|
||||
}
|
||||
|
||||
// 2. Compare the submitted password with the hashed password in your DB.
|
||||
const isMatch = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isMatch) {
|
||||
// Password does not match
|
||||
logger.warn(`Login attempt failed for user ${email} due to incorrect password.`);
|
||||
return done(null, false, { message: 'Incorrect email or password.' });
|
||||
}
|
||||
|
||||
// 3. Success! Return the user object (without password_hash for security).
|
||||
const { password_hash: _password_hash, ...userWithoutHash } = user;
|
||||
logger.info(`User successfully authenticated: ${email}`);
|
||||
return done(null, userWithoutHash);
|
||||
} catch (err) {
|
||||
logger.error('Error during local authentication strategy:', { error: err });
|
||||
return done(err);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// --- Passport Google OAuth 2.0 Strategy ---
|
||||
passport.use(new GoogleStrategy({
|
||||
clientID: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
callbackURL: '/api/auth/google/callback', // Must match the one in Google Cloud Console
|
||||
scope: ['profile', 'email']
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
const email = profile.emails?.[0]?.value;
|
||||
if (!email) {
|
||||
return done(new Error("No email found in Google profile."), false);
|
||||
}
|
||||
|
||||
// Check if user already exists in our database
|
||||
let user = await db.findUserByEmail(email);
|
||||
|
||||
if (user) {
|
||||
// User exists, proceed to log them in.
|
||||
logger.info(`Google OAuth successful for existing user: ${email}`);
|
||||
const { password_hash, ...userWithoutHash } = user;
|
||||
return done(null, userWithoutHash);
|
||||
} else {
|
||||
// User does not exist, create a new account for them.
|
||||
logger.info(`Google OAuth: creating new user for email: ${email}`);
|
||||
|
||||
// Since this is an OAuth user, they don't have a password.
|
||||
// We pass `null` for the password hash.
|
||||
const newUser = await db.createUser(email, null, {
|
||||
full_name: profile.displayName,
|
||||
avatar_url: profile.photos?.[0]?.value
|
||||
});
|
||||
|
||||
// Send a welcome email to the new user
|
||||
try {
|
||||
await sendWelcomeEmail(email, profile.displayName);
|
||||
} catch (emailError) {
|
||||
logger.error(`Failed to send welcome email to new Google user ${email}`, { error: emailError });
|
||||
// Don't block the login flow if email fails.
|
||||
}
|
||||
|
||||
// The `createUser` function returns the user object without the password hash.
|
||||
return done(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error during Google authentication strategy:', { error: err });
|
||||
return done(err, false);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// --- Passport GitHub OAuth 2.0 Strategy ---
|
||||
passport.use(new GitHubStrategy({
|
||||
clientID: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
callbackURL: '/api/auth/github/callback', // Must match the one in GitHub OAuth App settings
|
||||
scope: ['user:email'] // Request email access
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
const email = profile.emails?.[0]?.value;
|
||||
if (!email) {
|
||||
return done(new Error("No public email found in GitHub profile. Please ensure your primary email is public or add one."), false);
|
||||
}
|
||||
|
||||
// Check if user already exists in our database
|
||||
let user = await db.findUserByEmail(email);
|
||||
|
||||
if (user) {
|
||||
// User exists, proceed to log them in.
|
||||
logger.info(`GitHub OAuth successful for existing user: ${email}`);
|
||||
const { password_hash, ...userWithoutHash } = user;
|
||||
return done(null, userWithoutHash);
|
||||
} else {
|
||||
// User does not exist, create a new account for them.
|
||||
logger.info(`GitHub OAuth: creating new user for email: ${email}`);
|
||||
|
||||
// Since this is an OAuth user, they don't have a password.
|
||||
// We pass `null` for the password hash.
|
||||
const newUser = await db.createUser(email, null, {
|
||||
full_name: profile.displayName || profile.username, // GitHub profile might not have displayName
|
||||
avatar_url: profile.photos?.[0]?.value
|
||||
});
|
||||
|
||||
// Send a welcome email to the new user
|
||||
try {
|
||||
await sendWelcomeEmail(email, profile.displayName || profile.username);
|
||||
} catch (emailError) {
|
||||
logger.error(`Failed to send welcome email to new GitHub user ${email}`, { error: emailError });
|
||||
// Don't block the login flow if email fails.
|
||||
}
|
||||
|
||||
// The `createUser` function returns the user object without the password hash.
|
||||
return done(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error during GitHub authentication strategy:', { error: err });
|
||||
return done(err, false);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// --- Passport JWT Strategy (for protecting API routes) ---
|
||||
const jwtOptions = {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Expect JWT in 'Authorization: Bearer <token>' header
|
||||
secretOrKey: JWT_SECRET,
|
||||
};
|
||||
|
||||
passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
|
||||
try {
|
||||
// The jwt_payload contains the data you put into the token during login (e.g., { id: user.id, email: user.email }).
|
||||
// We re-fetch the user from the database here to ensure they are still active and valid.
|
||||
const userProfile = await db.findUserProfileById(jwt_payload.id);
|
||||
|
||||
if (userProfile) {
|
||||
return done(null, userProfile); // User profile object will be available as req.user in protected routes
|
||||
} else {
|
||||
logger.warn(`JWT authentication failed: user with ID ${jwt_payload.id} not found.`);
|
||||
return done(null, false); // User not found or invalid token
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error during JWT authentication strategy:', { error: err });
|
||||
return done(err, false);
|
||||
}
|
||||
}));
|
||||
|
||||
// --- Middleware for Admin Role Check ---
|
||||
export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
// This middleware should run *after* passport.authenticate('jwt', ...)
|
||||
const userProfile = req.user as UserProfile;
|
||||
|
||||
if (userProfile && userProfile.role === 'admin') {
|
||||
next();
|
||||
} else {
|
||||
logger.warn(`Admin access denied for user: ${userProfile?.id}`);
|
||||
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A flexible authentication middleware. It attempts to authenticate via JWT but does NOT
|
||||
* reject the request if authentication fails. This allows routes to handle both
|
||||
* authenticated and anonymous users. If a valid token is present, `req.user` will be
|
||||
* populated; otherwise, it will be undefined, and the request proceeds.
|
||||
*/
|
||||
export const optionalAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
passport.authenticate('jwt', { session: false }, (err, user, info) => {
|
||||
if (user) req.user = user; // Attach user if authentication succeeds
|
||||
next(); // Always proceed to the next middleware
|
||||
})(req, res, next);
|
||||
};
|
||||
|
||||
export default passport;
|
||||
209
src/routes/public.ts
Normal file
209
src/routes/public.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import * as db from '../services/db';
|
||||
import { logger } from '../services/logger';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Health & System Check Routes ---
|
||||
router.get('/health/ping', (req: Request, res: Response) => {
|
||||
res.status(200).send('pong');
|
||||
});
|
||||
|
||||
router.get('/health/db-schema', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
|
||||
const missingTables = await db.checkTablesExist(requiredTables);
|
||||
|
||||
if (missingTables.length > 0) {
|
||||
return res.status(500).json({ success: false, message: `Database schema check failed. Missing tables: ${missingTables.join(', ')}.` });
|
||||
}
|
||||
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
|
||||
} catch (error) {
|
||||
logger.error('Error during DB schema check:', { error });
|
||||
return res.status(500).json({ success: false, message: 'An error occurred while checking the database schema.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/health/storage', async (req: Request, res: Response) => {
|
||||
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
|
||||
try {
|
||||
await fs.access(storagePath, fs.constants.W_OK);
|
||||
return res.status(200).json({ success: true, message: `Storage directory '${storagePath}' is accessible and writable.` });
|
||||
} catch (error) {
|
||||
logger.error(`Storage check failed for path: ${storagePath}`, { error });
|
||||
return res.status(500).json({ success: false, message: `Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.` });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/health/db-pool', (req: Request, res: Response) => {
|
||||
try {
|
||||
const status = db.getPoolStatus();
|
||||
const isHealthy = status.waitingCount < 5;
|
||||
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
|
||||
|
||||
if (isHealthy) {
|
||||
return res.status(200).json({ success: true, message });
|
||||
} else {
|
||||
logger.warn(`Database pool health check shows high waiting count: ${status.waitingCount}`);
|
||||
return res.status(500).json({ success: false, message: `Pool may be under stress. ${message}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during DB pool health check:', { error });
|
||||
return res.status(500).json({ success: false, message: 'An error occurred while checking the database pool status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Public Data Routes ---
|
||||
|
||||
router.get('/flyers', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const flyers = await db.getFlyers();
|
||||
res.json(flyers);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching flyers in /api/flyers:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/master-items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const masterItems = await db.getAllMasterItems();
|
||||
res.json(masterItems);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching master items in /api/master-items:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/flyers/:id/items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const flyerId = parseInt(req.params.id, 10);
|
||||
const items = await db.getFlyerItems(flyerId);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching flyer items in /api/flyers/:id/items:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/flyer-items/batch-fetch', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { flyerIds } = req.body;
|
||||
if (!Array.isArray(flyerIds)) {
|
||||
return res.status(400).json({ message: 'flyerIds must be an array.' });
|
||||
}
|
||||
try {
|
||||
const items = await db.getFlyerItemsForFlyers(flyerIds);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/flyer-items/batch-count', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { flyerIds } = req.body;
|
||||
if (!Array.isArray(flyerIds)) {
|
||||
return res.status(400).json({ message: 'flyerIds must be an array.' });
|
||||
}
|
||||
try {
|
||||
const count = await db.countFlyerItemsForFlyers(flyerIds);
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/recipes/by-sale-percentage', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const minPercentageStr = req.query.minPercentage as string || '50.0';
|
||||
const minPercentage = parseFloat(minPercentageStr);
|
||||
|
||||
if (isNaN(minPercentage) || minPercentage < 0 || minPercentage > 100) {
|
||||
return res.status(400).json({ message: 'Query parameter "minPercentage" must be a number between 0 and 100.' });
|
||||
}
|
||||
try {
|
||||
const recipes = await db.getRecipesBySalePercentage(minPercentage);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/recipes/by-sale-ingredients', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const minIngredientsStr = req.query.minIngredients as string || '3';
|
||||
const minIngredients = parseInt(minIngredientsStr, 10);
|
||||
|
||||
if (isNaN(minIngredients) || minIngredients < 1) {
|
||||
return res.status(400).json({ message: 'Query parameter "minIngredients" must be a positive integer.' });
|
||||
}
|
||||
try {
|
||||
const recipes = await db.getRecipesByMinSaleIngredients(minIngredients);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/recipes/by-ingredient-and-tag', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { ingredient, tag } = req.query;
|
||||
if (!ingredient || !tag) {
|
||||
return res.status(400).json({ message: 'Both "ingredient" and "tag" query parameters are required.' });
|
||||
}
|
||||
try {
|
||||
const recipes = await db.findRecipesByIngredientAndTag(ingredient as string, tag as string);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats/most-frequent-sales', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const daysStr = req.query.days as string || '30';
|
||||
const limitStr = req.query.limit as string || '10';
|
||||
|
||||
const days = parseInt(daysStr, 10);
|
||||
const limit = parseInt(limitStr, 10);
|
||||
|
||||
if (isNaN(days) || days < 1 || days > 365) {
|
||||
return res.status(400).json({ message: 'Query parameter "days" must be an integer between 1 and 365.' });
|
||||
}
|
||||
if (isNaN(limit) || limit < 1 || limit > 50) {
|
||||
return res.status(400).json({ message: 'Query parameter "limit" must be an integer between 1 and 50.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await db.getMostFrequentSaleItems(days, limit);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/recipes/:recipeId/comments', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
try {
|
||||
const comments = await db.getRecipeComments(recipeId);
|
||||
res.json(comments);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/dietary-restrictions', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const restrictions = await db.getDietaryRestrictions();
|
||||
res.json(restrictions);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/appliances', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const appliances = await db.getAppliances();
|
||||
res.json(appliances);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
606
src/routes/user.ts
Normal file
606
src/routes/user.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import passport from './passport';
|
||||
import multer from 'multer';
|
||||
import fs from 'fs/promises';
|
||||
import zxcvbn from 'zxcvbn';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
import * as db from '../services/db';
|
||||
import * as aiService from '../services/aiService.server';
|
||||
import { logger } from '../services/logger';
|
||||
import { ShoppingListItem } from '../types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Multer Configuration for File Uploads ---
|
||||
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, storagePath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
// --- Middleware for all routes in this file ---
|
||||
router.use(passport.authenticate('jwt', { session: false }));
|
||||
|
||||
// --- User Profile & Account Routes ---
|
||||
|
||||
router.get('/users/profile', async (req: Request, res: Response) => {
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
logger.info(`Profile requested for user: ${authenticatedUser.email}`);
|
||||
|
||||
try {
|
||||
const profile = await db.findUserProfileById(authenticatedUser.id);
|
||||
if (!profile) {
|
||||
logger.warn(`No profile found for authenticated user ID: ${authenticatedUser.id}`);
|
||||
return res.status(404).json({ message: 'Profile not found for this user.' });
|
||||
}
|
||||
res.json(profile);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching profile in /api/users/profile:', { error });
|
||||
res.status(500).json({ message: 'Failed to retrieve user profile.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/profile', async (req: Request, res: Response) => {
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
const { full_name, avatar_url } = req.body;
|
||||
|
||||
if (typeof full_name === 'undefined' && typeof avatar_url === 'undefined') {
|
||||
return res.status(400).json({ message: 'At least one field (full_name or avatar_url) must be provided.' });
|
||||
}
|
||||
|
||||
logger.info(`Profile update requested for user: ${authenticatedUser.email}`, { fullName: full_name, avatarUrl: avatar_url });
|
||||
|
||||
try {
|
||||
const updatedProfile = await db.updateUserProfile(authenticatedUser.id, { full_name, avatar_url });
|
||||
res.json(updatedProfile);
|
||||
} catch (error) {
|
||||
logger.error('Error updating profile in /api/users/profile:', { error });
|
||||
res.status(500).json({ message: 'Failed to update user profile.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/data-export', async (req: Request, res: Response) => {
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
logger.info(`Data export requested for user: ${authenticatedUser.email}`);
|
||||
|
||||
try {
|
||||
const userData = await db.exportUserData(authenticatedUser.id);
|
||||
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
res.setHeader('Content-Disposition', `attachment; filename="flyer-crawler-data-export-${date}.json"`);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.json(userData);
|
||||
} catch (error) {
|
||||
logger.error('Error during data export in /api/users/data-export:', { error });
|
||||
res.status(500).json({ message: 'Failed to export user data.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/profile/preferences', async (req: Request, res: Response) => {
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
const newPreferences = req.body;
|
||||
|
||||
if (!newPreferences || typeof newPreferences !== 'object') {
|
||||
return res.status(400).json({ message: 'Invalid preferences format. Body must be a JSON object.' });
|
||||
}
|
||||
|
||||
logger.info(`Preferences update requested for user: ${authenticatedUser.email}`, { newPreferences });
|
||||
|
||||
try {
|
||||
const updatedProfile = await db.updateUserPreferences(authenticatedUser.id, newPreferences);
|
||||
res.json(updatedProfile);
|
||||
} catch (error) {
|
||||
logger.error('Error updating preferences in /api/users/profile/preferences:', { error });
|
||||
res.status(500).json({ message: 'Failed to update user preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/profile/password', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
const { newPassword } = req.body;
|
||||
|
||||
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 6) {
|
||||
return res.status(400).json({ message: 'Password must be a string of at least 6 characters.' });
|
||||
}
|
||||
|
||||
const MIN_PASSWORD_SCORE = 3;
|
||||
const strength = zxcvbn(newPassword);
|
||||
if (strength.score < MIN_PASSWORD_SCORE) {
|
||||
logger.warn(`Weak password update rejected for user: ${authenticatedUser.email}. Score: ${strength.score}`, { userId: authenticatedUser.id });
|
||||
const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
|
||||
return res.status(400).json({ message: `New password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() });
|
||||
}
|
||||
|
||||
try {
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||
logger.info(`Hashing new, validated password for user: ${authenticatedUser.email}`, { userId: authenticatedUser.id });
|
||||
|
||||
await db.updateUserPassword(authenticatedUser.id, hashedPassword);
|
||||
logger.info(`Successfully updated password for user: ${authenticatedUser.email}`);
|
||||
res.status(200).json({ message: 'Password updated successfully.' });
|
||||
} catch (error) {
|
||||
logger.error('Error during password update:', { error, userId: authenticatedUser.id });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/users/account', async (req: Request, res: Response) => {
|
||||
const authenticatedUser = req.user as { id: string; email: string };
|
||||
const { password } = req.body;
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({ message: 'Password is required for account deletion.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const userWithHash = await db.findUserByEmail(authenticatedUser.email);
|
||||
if (!userWithHash || !userWithHash.password_hash) {
|
||||
return res.status(404).json({ message: 'User not found or is an OAuth user.' });
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
|
||||
if (!isMatch) {
|
||||
logger.warn(`Account deletion failed for user ${authenticatedUser.email} due to incorrect password.`);
|
||||
return res.status(403).json({ message: 'Incorrect password.' });
|
||||
}
|
||||
|
||||
await db.deleteUserById(authenticatedUser.id);
|
||||
logger.warn(`User account deleted successfully: ${authenticatedUser.email}`);
|
||||
res.status(200).json({ message: 'Account deleted successfully.' });
|
||||
} catch (error) {
|
||||
logger.error('Error during account deletion:', { error });
|
||||
res.status(500).json({ message: 'Failed to delete account.' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Watched Items Routes ---
|
||||
|
||||
router.get('/watched-items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
try {
|
||||
const items = await db.getWatchedItems(user.id);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/watched-items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const { itemName, category } = req.body;
|
||||
try {
|
||||
const newItem = await db.addWatchedItem(user.id, itemName, category);
|
||||
res.status(201).json(newItem);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/watched-items/:masterItemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const { masterItemId } = req.params;
|
||||
try {
|
||||
await db.removeWatchedItem(user.id, parseInt(masterItemId, 10));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Shopping List Routes ---
|
||||
|
||||
router.get('/shopping-lists', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
try {
|
||||
const lists = await db.getShoppingLists(user.id);
|
||||
res.json(lists);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/shopping-lists', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const { name } = req.body;
|
||||
try {
|
||||
const newList = await db.createShoppingList(user.id, name);
|
||||
res.status(201).json(newList);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/shopping-lists/:listId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const { listId } = req.params;
|
||||
try {
|
||||
await db.deleteShoppingList(parseInt(listId, 10), user.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/shopping-lists/:listId/items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const item = req.body;
|
||||
try {
|
||||
const newItem = await db.addShoppingListItem(parseInt(req.params.listId, 10), item);
|
||||
res.status(201).json(newItem);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/shopping-lists/items/:itemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const updates: Partial<ShoppingListItem> = req.body;
|
||||
try {
|
||||
const updatedItem = await db.updateShoppingListItem(parseInt(req.params.itemId, 10), updates);
|
||||
res.json(updatedItem);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/shopping-lists/items/:itemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await db.removeShoppingListItem(parseInt(req.params.itemId, 10));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/shopping-lists/:id/complete', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const shoppingListId = parseInt(req.params.id, 10);
|
||||
const { totalSpentCents } = req.body;
|
||||
|
||||
try {
|
||||
const newTripId = await db.completeShoppingList(shoppingListId, user.id, totalSpentCents);
|
||||
res.status(201).json({ message: 'Shopping list completed and archived.', newTripId });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Receipt Processing Routes ---
|
||||
|
||||
router.post('/receipts/upload', upload.single('receiptImage'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Receipt image file is required.' });
|
||||
}
|
||||
|
||||
let newReceipt;
|
||||
try {
|
||||
const receiptImageUrl = `/assets/${req.file.filename}`;
|
||||
newReceipt = await db.createReceipt(user.id, receiptImageUrl);
|
||||
|
||||
try {
|
||||
logger.info(`Starting AI receipt processing for receipt ID: ${newReceipt.id}`);
|
||||
const extractedItems = await aiService.extractItemsFromReceiptImage(req.file.path, req.file.mimetype);
|
||||
|
||||
await db.processReceiptItems(newReceipt.id, extractedItems);
|
||||
logger.info(`Completed AI receipt processing for receipt ID: ${newReceipt.id}. Found ${extractedItems.length} items.`);
|
||||
|
||||
} catch (processingError) {
|
||||
logger.error(`Receipt processing failed for receipt ID: ${newReceipt.id}.`, { error: processingError });
|
||||
await db.updateReceiptStatus(newReceipt.id, 'failed');
|
||||
throw processingError;
|
||||
}
|
||||
|
||||
res.status(201).json({ message: 'Receipt uploaded and processed successfully.', receipt: newReceipt });
|
||||
} catch (error) {
|
||||
if (req.file) {
|
||||
fs.unlink(req.file.path).catch(unlinkError => logger.error(`Failed to delete orphaned receipt file: ${req.file?.path}`, { error: unlinkError }));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/receipts/:id/deals', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const receiptId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
const owner = await db.findReceiptOwner(receiptId);
|
||||
if (!owner || owner.user_id !== user.id) {
|
||||
logger.warn(`User ${user.id} attempted to access unauthorized receipt ${receiptId}.`);
|
||||
return res.status(403).json({ message: 'Forbidden: You do not have access to this receipt.' });
|
||||
}
|
||||
|
||||
const deals = await db.findDealsForReceipt(receiptId);
|
||||
res.json(deals);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Personalization Routes ---
|
||||
|
||||
router.get('/users/pantry-recipes', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
try {
|
||||
const recipes = await db.findRecipesFromPantry(user.id);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/recommended-recipes', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const limit = parseInt(req.query.limit as string || '10', 10);
|
||||
try {
|
||||
const recipes = await db.recommendRecipesForUser(user.id, limit);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/best-sale-prices', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
try {
|
||||
const deals = await db.getBestSalePricesForUser(user.id);
|
||||
res.json(deals);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/pantry-items/:id/conversions', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const pantryItemId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
const owner = await db.findPantryItemOwner(pantryItemId);
|
||||
if (!owner || owner.user_id !== user.id) {
|
||||
return res.status(403).json({ message: 'Forbidden: You do not have access to this item.' });
|
||||
}
|
||||
const conversions = await db.suggestPantryItemConversions(pantryItemId);
|
||||
res.json(conversions);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/menu-plans/:id/shopping-list', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const menuPlanId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
const shoppingListItems = await db.generateShoppingListForMenuPlan(menuPlanId, user.id);
|
||||
res.json(shoppingListItems);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/shopping-lists/:listId/add-from-menu-plan', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const shoppingListId = parseInt(req.params.listId, 10);
|
||||
const { menuPlanId } = req.body;
|
||||
try {
|
||||
const addedItems = await db.addMenuPlanToShoppingList(menuPlanId, shoppingListId, user.id);
|
||||
res.status(201).json(addedItems);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/me/dietary-restrictions', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
try {
|
||||
const restrictions = await db.getUserDietaryRestrictions(user.id);
|
||||
res.json(restrictions);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/me/dietary-restrictions', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const { restrictionIds } = req.body;
|
||||
try {
|
||||
await db.setUserDietaryRestrictions(user.id, restrictionIds);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/me/appliances', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
try {
|
||||
const appliances = await db.getUserAppliances(user.id);
|
||||
res.json(appliances);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/me/appliances', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const { applianceIds } = req.body;
|
||||
try {
|
||||
await db.setUserAppliances(user.id, applianceIds);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/me/compatible-recipes', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
try {
|
||||
const recipes = await db.getRecipesForUserDiets(user.id);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/me/feed', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const limit = parseInt(req.query.limit as string, 10) || 20;
|
||||
const offset = parseInt(req.query.offset as string, 10) || 0;
|
||||
try {
|
||||
const feedItems = await db.getUserFeed(user.id, limit, offset);
|
||||
res.json(feedItems);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/recipes/:id/fork', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const originalRecipeId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
const forkedRecipe = await db.forkRecipe(user.id, originalRecipeId);
|
||||
res.status(201).json(forkedRecipe);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/favorite-recipes', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
try {
|
||||
const recipes = await db.getUserFavoriteRecipes(user.id);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/users/favorite-recipes', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const { recipeId } = req.body;
|
||||
try {
|
||||
const favorite = await db.addFavoriteRecipe(user.id, recipeId);
|
||||
res.status(201).json(favorite);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/users/favorite-recipes/:recipeId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
try {
|
||||
await db.removeFavoriteRecipe(user.id, recipeId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/users/:id/follow', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const follower = req.user as { id: string };
|
||||
const followingId = req.params.id;
|
||||
try {
|
||||
await db.followUser(follower.id, followingId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/users/:id/follow', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const follower = req.user as { id: string };
|
||||
const followingId = req.params.id;
|
||||
try {
|
||||
await db.unfollowUser(follower.id, followingId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/flyer-items/:id/track', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const itemId = parseInt(req.params.id, 10);
|
||||
const { type } = req.body; // 'view' or 'click'
|
||||
|
||||
if (type !== 'view' && type !== 'click') {
|
||||
return res.status(400).json({ message: 'Invalid interaction type.' });
|
||||
}
|
||||
try {
|
||||
db.trackFlyerItemInteraction(itemId, type);
|
||||
res.status(202).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/price-history', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { masterItemIds } = req.body;
|
||||
if (!Array.isArray(masterItemIds) || masterItemIds.some(id => typeof id !== 'number')) {
|
||||
return res.status(400).json({ message: 'masterItemIds must be an array of numbers.' });
|
||||
}
|
||||
try {
|
||||
const historicalData = await db.getHistoricalPriceDataForItems(masterItemIds);
|
||||
res.json(historicalData);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching price history in /api/price-history:', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/recipes/:recipeId/comments', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
const { content, parentCommentId } = req.body;
|
||||
if (!content) {
|
||||
return res.status(400).json({ message: 'Comment content is required.' });
|
||||
}
|
||||
try {
|
||||
const newComment = await db.addRecipeComment(recipeId, user.id, content, parentCommentId);
|
||||
res.status(201).json(newComment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/pantry/locations', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const { name } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ message: 'Location name is required.' });
|
||||
}
|
||||
try {
|
||||
const newLocation = await db.createPantryLocation(user.id, name);
|
||||
res.status(201).json(newLocation);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/search/log', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
const { queryText, resultCount, wasSuccessful } = req.body;
|
||||
try {
|
||||
db.logSearchQuery({ userId: user.id, queryText, resultCount, wasSuccessful });
|
||||
res.status(202).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/shopping-history', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as { id: string };
|
||||
try {
|
||||
const history = await db.getShoppingTripHistory(user.id);
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,34 @@
|
||||
import { Pool } from 'pg';
|
||||
import { logger } from './logger';
|
||||
import { Profile, Flyer, MasterGroceryItem, ShoppingList, ShoppingListItem, FlyerItem, SuggestedCorrection, Recipe, RecipeComment, FavoriteRecipe, ActivityLogItem, DietaryRestriction, Appliance, PantryLocation, ShoppingTrip, UserAppliance, Receipt, ReceiptItem, ReceiptDeal, Brand } from '../types'; // Assuming your Profile type is in `types.ts`
|
||||
import {
|
||||
Profile,
|
||||
Flyer,
|
||||
MasterGroceryItem,
|
||||
ShoppingList,
|
||||
ShoppingListItem,
|
||||
FlyerItem,
|
||||
SuggestedCorrection,
|
||||
Recipe,
|
||||
RecipeComment,
|
||||
FavoriteRecipe,
|
||||
ActivityLogItem,
|
||||
DietaryRestriction,
|
||||
Appliance,
|
||||
PantryLocation,
|
||||
UserAppliance,
|
||||
ShoppingTrip,
|
||||
Receipt,
|
||||
ReceiptDeal,
|
||||
Brand,
|
||||
MostFrequentSaleItem,
|
||||
PantryRecipe,
|
||||
RecommendedRecipe,
|
||||
WatchedItemDeal,
|
||||
ReceiptItem,
|
||||
PantryItemConversion,
|
||||
MenuPlanShoppingListItem,
|
||||
UnmatchedFlyerItem,
|
||||
} from '../types'; // Assuming your Profile type is in `types.ts`
|
||||
|
||||
// Configure your PostgreSQL connection pool
|
||||
// IMPORTANT: For production, use environment variables for these credentials.
|
||||
@@ -866,49 +894,6 @@ export async function updateBrandLogo(brandId: number, logoUrl: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
// --- Correction Handler Logic ---
|
||||
|
||||
/**
|
||||
* A map of functions to handle applying different types of corrections.
|
||||
* This makes it easy to add new correction types without modifying the main approveCorrection function.
|
||||
* Each handler receives the database client, the flyer item ID, and the suggested value.
|
||||
*/
|
||||
const correctionHandlers: { [key: string]: (client: any, flyerItemId: number, suggestedValue: string) => Promise<void> } = {
|
||||
'WRONG_PRICE': async (client, flyerItemId, suggestedValue) => {
|
||||
const priceInCents = parseInt(suggestedValue, 10);
|
||||
if (isNaN(priceInCents)) {
|
||||
throw new Error(`Invalid suggested value for WRONG_PRICE: ${suggestedValue}`);
|
||||
}
|
||||
const priceDisplay = `$${(priceInCents / 100).toFixed(2)}`;
|
||||
await client.query(
|
||||
'UPDATE public.flyer_items SET price_in_cents = $1, price_display = $2 WHERE id = $3',
|
||||
[priceInCents, priceDisplay, flyerItemId]
|
||||
);
|
||||
},
|
||||
'INCORRECT_ITEM_LINK': async (client, flyerItemId, suggestedValue) => {
|
||||
const masterItemId = parseInt(suggestedValue, 10);
|
||||
if (isNaN(masterItemId)) {
|
||||
throw new Error(`Invalid suggested value for INCORRECT_ITEM_LINK: ${suggestedValue}`);
|
||||
}
|
||||
await client.query(
|
||||
'UPDATE public.flyer_items SET master_item_id = $1 WHERE id = $2',
|
||||
[masterItemId, flyerItemId]
|
||||
);
|
||||
},
|
||||
'ITEM_IS_MISCATEGORIZED': async (client, flyerItemId, suggestedValue) => {
|
||||
const categoryId = parseInt(suggestedValue, 10);
|
||||
if (isNaN(categoryId)) {
|
||||
throw new Error(`Invalid suggested value for ITEM_IS_MISCATEGORIZED: ${suggestedValue}`);
|
||||
}
|
||||
await client.query(
|
||||
'UPDATE public.flyer_items SET category_id = $1 WHERE id = $2',
|
||||
[categoryId, flyerItemId]
|
||||
);
|
||||
},
|
||||
// To add a new correction type, simply add a new handler function here.
|
||||
};
|
||||
|
||||
|
||||
// --- Admin Correction Functions ---
|
||||
|
||||
/**
|
||||
@@ -1130,9 +1115,9 @@ export async function getDailyStatsForLast30Days(): Promise<{ date: string; new_
|
||||
* @param limit The maximum number of items to return.
|
||||
* @returns A promise that resolves to an array of the most frequent sale items.
|
||||
*/
|
||||
export async function getMostFrequentSaleItems(days: number, limit: number): Promise<any[]> {
|
||||
export async function getMostFrequentSaleItems(days: number, limit: number): Promise<MostFrequentSaleItem[]> {
|
||||
try {
|
||||
const res = await pool.query('SELECT * FROM public.get_most_frequent_sale_items($1, $2)', [days, limit]);
|
||||
const res = await pool.query<MostFrequentSaleItem>('SELECT * FROM public.get_most_frequent_sale_items($1, $2)', [days, limit]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getMostFrequentSaleItems:', { error });
|
||||
@@ -1145,9 +1130,9 @@ export async function getMostFrequentSaleItems(days: number, limit: number): Pro
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of recipes.
|
||||
*/
|
||||
export async function findRecipesFromPantry(userId: string): Promise<any[]> {
|
||||
export async function findRecipesFromPantry(userId: string): Promise<PantryRecipe[]> {
|
||||
try {
|
||||
const res = await pool.query('SELECT * FROM public.find_recipes_from_pantry($1)', [userId]);
|
||||
const res = await pool.query<PantryRecipe>('SELECT * FROM public.find_recipes_from_pantry($1)', [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in findRecipesFromPantry:', { error, userId });
|
||||
@@ -1161,9 +1146,9 @@ export async function findRecipesFromPantry(userId: string): Promise<any[]> {
|
||||
* @param limit The maximum number of recipes to recommend.
|
||||
* @returns A promise that resolves to an array of recommended recipes.
|
||||
*/
|
||||
export async function recommendRecipesForUser(userId: string, limit: number): Promise<any[]> {
|
||||
export async function recommendRecipesForUser(userId: string, limit: number): Promise<RecommendedRecipe[]> {
|
||||
try {
|
||||
const res = await pool.query('SELECT * FROM public.recommend_recipes_for_user($1, $2)', [userId, limit]);
|
||||
const res = await pool.query<RecommendedRecipe>('SELECT * FROM public.recommend_recipes_for_user($1, $2)', [userId, limit]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in recommendRecipesForUser:', { error, userId });
|
||||
@@ -1176,9 +1161,9 @@ export async function recommendRecipesForUser(userId: string, limit: number): Pr
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of the best deals.
|
||||
*/
|
||||
export async function getBestSalePricesForUser(userId: string): Promise<any[]> {
|
||||
export async function getBestSalePricesForUser(userId: string): Promise<WatchedItemDeal[]> {
|
||||
try {
|
||||
const res = await pool.query('SELECT * FROM public.get_best_sale_prices_for_user($1)', [userId]);
|
||||
const res = await pool.query<WatchedItemDeal>('SELECT * FROM public.get_best_sale_prices_for_user($1)', [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getBestSalePricesForUser:', { error, userId });
|
||||
@@ -1191,9 +1176,9 @@ export async function getBestSalePricesForUser(userId: string): Promise<any[]> {
|
||||
* @param pantryItemId The ID of the pantry item.
|
||||
* @returns A promise that resolves to an array of suggested conversions.
|
||||
*/
|
||||
export async function suggestPantryItemConversions(pantryItemId: number): Promise<any[]> {
|
||||
export async function suggestPantryItemConversions(pantryItemId: number): Promise<PantryItemConversion[]> {
|
||||
try {
|
||||
const res = await pool.query('SELECT * FROM public.suggest_pantry_item_conversions($1)', [pantryItemId]);
|
||||
const res = await pool.query<PantryItemConversion>('SELECT * FROM public.suggest_pantry_item_conversions($1)', [pantryItemId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in suggestPantryItemConversions:', { error, pantryItemId });
|
||||
@@ -1207,9 +1192,9 @@ export async function suggestPantryItemConversions(pantryItemId: number): Promis
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of items for the shopping list.
|
||||
*/
|
||||
export async function generateShoppingListForMenuPlan(menuPlanId: number, userId: string): Promise<any[]> {
|
||||
export async function generateShoppingListForMenuPlan(menuPlanId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
|
||||
try {
|
||||
const res = await pool.query('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [menuPlanId, userId]);
|
||||
const res = await pool.query<MenuPlanShoppingListItem>('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [menuPlanId, userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in generateShoppingListForMenuPlan:', { error, menuPlanId });
|
||||
@@ -1224,9 +1209,9 @@ export async function generateShoppingListForMenuPlan(menuPlanId: number, userId
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of the items that were added.
|
||||
*/
|
||||
export async function addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string): Promise<any[]> {
|
||||
export async function addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
|
||||
try {
|
||||
const res = await pool.query('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [menuPlanId, shoppingListId, userId]);
|
||||
const res = await pool.query<MenuPlanShoppingListItem>('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [menuPlanId, shoppingListId, userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in addMenuPlanToShoppingList:', { error, menuPlanId });
|
||||
@@ -1239,9 +1224,9 @@ export async function addMenuPlanToShoppingList(menuPlanId: number, shoppingList
|
||||
* @param minPercentage The minimum percentage of ingredients that must be on sale.
|
||||
* @returns A promise that resolves to an array of recipes.
|
||||
*/
|
||||
export async function getRecipesBySalePercentage(minPercentage: number): Promise<any[]> {
|
||||
export async function getRecipesBySalePercentage(minPercentage: number): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await pool.query('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [minPercentage]);
|
||||
const res = await pool.query<Recipe>('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [minPercentage]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getRecipesBySalePercentage:', { error });
|
||||
@@ -1254,9 +1239,9 @@ export async function getRecipesBySalePercentage(minPercentage: number): Promise
|
||||
* @param minIngredients The minimum number of ingredients that must be on sale.
|
||||
* @returns A promise that resolves to an array of recipes.
|
||||
*/
|
||||
export async function getRecipesByMinSaleIngredients(minIngredients: number): Promise<any[]> {
|
||||
export async function getRecipesByMinSaleIngredients(minIngredients: number): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await pool.query('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [minIngredients]);
|
||||
const res = await pool.query<Recipe>('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [minIngredients]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getRecipesByMinSaleIngredients:', { error });
|
||||
@@ -1270,9 +1255,9 @@ export async function getRecipesByMinSaleIngredients(minIngredients: number): Pr
|
||||
* @param tag The name of the tag to search for.
|
||||
* @returns A promise that resolves to an array of matching recipes.
|
||||
*/
|
||||
export async function findRecipesByIngredientAndTag(ingredient: string, tag: string): Promise<any[]> {
|
||||
export async function findRecipesByIngredientAndTag(ingredient: string, tag: string): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await pool.query('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', [ingredient, tag]);
|
||||
const res = await pool.query<Recipe>('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', [ingredient, tag]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in findRecipesByIngredientAndTag:', { error });
|
||||
@@ -1344,18 +1329,43 @@ export async function createReceipt(userId: string, receiptImageUrl: string): Pr
|
||||
* @param items An array of items extracted from the receipt.
|
||||
* @returns A promise that resolves when the operation is complete.
|
||||
*/
|
||||
export async function processReceiptItems(receiptId: number, rawText: string, items: { raw_item_description: string; price_paid_cents: number }[]): Promise<void> {
|
||||
export async function processReceiptItems(
|
||||
receiptId: number,
|
||||
items: Omit<ReceiptItem, 'id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'>[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
// The complex transaction logic is now handled by a single SQL function.
|
||||
// We pass the items array as a JSON string, which the SQL function will parse.
|
||||
await pool.query('SELECT public.process_receipt_items($1, $2, $3)', [receiptId, rawText, JSON.stringify(items)]);
|
||||
const itemsWithQuantity = items.map(item => ({ ...item, quantity: 1 }));
|
||||
await pool.query('SELECT public.process_receipt_items($1, $2, $3)', [receiptId, JSON.stringify(itemsWithQuantity), JSON.stringify(itemsWithQuantity)]);
|
||||
logger.info(`Successfully processed items for receipt ID: ${receiptId}`);
|
||||
} catch (error) {
|
||||
// On error, we should also update the receipt status to 'failed'
|
||||
await pool.query("UPDATE public.receipts SET status = 'failed' WHERE id = $1", [receiptId]);
|
||||
logger.error('Database transaction error in processReceiptItems:', { error, receiptId });
|
||||
throw new Error('Failed to process and save receipt items.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status of a specific receipt.
|
||||
* @param receiptId The ID of the receipt to update.
|
||||
* @param status The new status for the receipt.
|
||||
* @returns A promise that resolves to the updated Receipt object.
|
||||
*/
|
||||
export async function updateReceiptStatus(receiptId: number, status: 'pending' | 'processing' | 'completed' | 'failed'): Promise<Receipt> {
|
||||
try {
|
||||
const res = await pool.query<Receipt>(
|
||||
`UPDATE public.receipts SET status = $1, processed_at = CASE WHEN $1 IN ('completed', 'failed') THEN now() ELSE processed_at END WHERE id = $2 RETURNING *`,
|
||||
[status, receiptId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error(`Receipt with ID ${receiptId} not found.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateReceiptStatus:', { error, receiptId, status });
|
||||
throw new Error('Failed to update receipt status.');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Finds better deals for items on a recently processed receipt.
|
||||
* @param receiptId The ID of the receipt to check.
|
||||
@@ -1454,7 +1464,7 @@ export async function updateRecipeCommentStatus(commentId: number, status: 'visi
|
||||
* Retrieves all flyer items that could not be automatically matched to a master item.
|
||||
* @returns A promise that resolves to an array of unmatched flyer items with context.
|
||||
*/
|
||||
export async function getUnmatchedFlyerItems(): Promise<any[]> {
|
||||
export async function getUnmatchedFlyerItems(): Promise<UnmatchedFlyerItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
@@ -1473,7 +1483,7 @@ export async function getUnmatchedFlyerItems(): Promise<any[]> {
|
||||
WHERE ufi.status = 'pending'
|
||||
ORDER BY ufi.created_at ASC;
|
||||
`;
|
||||
const res = await pool.query(query);
|
||||
const res = await pool.query<UnmatchedFlyerItem>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getUnmatchedFlyerItems:', { error });
|
||||
@@ -1589,18 +1599,18 @@ export async function setUserDietaryRestrictions(userId: string, restrictionIds:
|
||||
* @returns A promise that resolves to an array of the user's selected Appliance objects.
|
||||
*/
|
||||
export async function getUserAppliances(userId: string): Promise<Appliance[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT a.* FROM public.appliances a
|
||||
JOIN public.user_appliances ua ON a.id = ua.appliance_id
|
||||
WHERE ua.user_id = $1 ORDER BY a.name;
|
||||
`;
|
||||
const res = await pool.query<Appliance>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getUserAppliances:', { error, userId });
|
||||
throw new Error('Failed to get user appliances.');
|
||||
}
|
||||
try {
|
||||
const query = `
|
||||
SELECT a.* FROM public.appliances a
|
||||
JOIN public.user_appliances ua ON a.id = ua.appliance_id
|
||||
WHERE ua.user_id = $1 ORDER BY a.name;
|
||||
`;
|
||||
const res = await pool.query<Appliance>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getUserAppliances:', { error, userId });
|
||||
throw new Error('Failed to get user appliances.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1609,18 +1619,23 @@ export async function getUserAppliances(userId: string): Promise<Appliance[]> {
|
||||
* @param applianceIds An array of IDs for the selected appliances.
|
||||
* @returns A promise that resolves when the operation is complete.
|
||||
*/
|
||||
export async function setUserAppliances(userId: string, applianceIds: number[]): Promise<void> {
|
||||
export async function setUserAppliances(userId: string, applianceIds: number[]): Promise<UserAppliance[]> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// Clear existing appliances for the user
|
||||
await client.query('DELETE FROM public.user_appliances WHERE user_id = $1', [userId]);
|
||||
|
||||
let newAppliances: UserAppliance[] = [];
|
||||
// Insert new ones if any are provided
|
||||
if (applianceIds.length > 0) {
|
||||
const values = applianceIds.map(id => `('${userId}', ${id})`).join(',');
|
||||
await client.query(`INSERT INTO public.user_appliances (user_id, appliance_id) VALUES ${values}`);
|
||||
const insertQuery = `INSERT INTO public.user_appliances (user_id, appliance_id) SELECT $1, unnest($2::int[]) RETURNING *`;
|
||||
const res = await client.query<UserAppliance>(insertQuery, [userId, applianceIds]);
|
||||
newAppliances = res.rows;
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return newAppliances;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Database error in setUserAppliances:', { error, userId });
|
||||
338
src/services/db/admin.ts
Normal file
338
src/services/db/admin.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { pool } from './connection';
|
||||
import { logger } from '../logger';
|
||||
import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, Brand } from '../../types';
|
||||
|
||||
/**
|
||||
* Retrieves all pending suggested corrections from the database.
|
||||
* Joins with users and flyer_items to provide context for the admin.
|
||||
* @returns A promise that resolves to an array of SuggestedCorrection objects.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getSuggestedCorrections(): Promise<SuggestedCorrection[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
sc.id,
|
||||
sc.flyer_item_id,
|
||||
sc.user_id,
|
||||
sc.correction_type,
|
||||
sc.suggested_value,
|
||||
sc.status,
|
||||
sc.created_at,
|
||||
fi.item as flyer_item_name,
|
||||
fi.price_display as flyer_item_price_display,
|
||||
u.email as user_email
|
||||
FROM public.suggested_corrections sc
|
||||
JOIN public.flyer_items fi ON sc.flyer_item_id = fi.id
|
||||
LEFT JOIN public.users u ON sc.user_id = u.id
|
||||
WHERE sc.status = 'pending'
|
||||
ORDER BY sc.created_at ASC;
|
||||
`;
|
||||
const res = await pool.query<SuggestedCorrection>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getSuggestedCorrections:', { error });
|
||||
throw new Error('Failed to retrieve suggested corrections.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approves a correction and applies the change to the corresponding flyer item.
|
||||
* This function runs as a transaction to ensure data integrity.
|
||||
* @param correctionId The ID of the correction to approve.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function approveCorrection(correctionId: number): Promise<void> {
|
||||
try {
|
||||
// The database function `approve_correction` now contains all the logic.
|
||||
// It finds the correction, applies the change, and updates the status in a single transaction.
|
||||
// This simplifies the application code and keeps the business logic in the database.
|
||||
await pool.query('SELECT public.approve_correction($1)', [correctionId]);
|
||||
logger.info(`Successfully approved and applied correction ID: ${correctionId}`);
|
||||
} catch (error) {
|
||||
logger.error('Database transaction error in approveCorrection:', { error, correctionId });
|
||||
throw new Error('Failed to approve correction.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a correction by updating its status.
|
||||
* @param correctionId The ID of the correction to reject.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function rejectCorrection(correctionId: number): Promise<void> {
|
||||
try {
|
||||
const res = await pool.query(
|
||||
"UPDATE public.suggested_corrections SET status = 'rejected' WHERE id = $1 AND status = 'pending' RETURNING id",
|
||||
[correctionId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
// This could happen if the correction was already processed or doesn't exist.
|
||||
logger.warn(`Attempted to reject correction ID ${correctionId}, but it was not found or not in 'pending' state.`);
|
||||
// We don't throw an error here, as the end state (not pending) is achieved.
|
||||
} else {
|
||||
logger.info(`Successfully rejected correction ID: ${correctionId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Database error in rejectCorrection:', { error, correctionId });
|
||||
throw new Error('Failed to reject correction.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the suggested value of a pending correction.
|
||||
* @param correctionId The ID of the correction to update.
|
||||
* @param newSuggestedValue The new value to set for the suggestion.
|
||||
* @returns A promise that resolves to the updated SuggestedCorrection object.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function updateSuggestedCorrection(correctionId: number, newSuggestedValue: string): Promise<SuggestedCorrection> {
|
||||
try {
|
||||
const res = await pool.query<SuggestedCorrection>(
|
||||
"UPDATE public.suggested_corrections SET suggested_value = $1 WHERE id = $2 AND status = 'pending' RETURNING *",
|
||||
[newSuggestedValue, correctionId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error(`Correction with ID ${correctionId} not found or is not in 'pending' state.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateSuggestedCorrection:', { error, correctionId });
|
||||
throw new Error('Failed to update suggested correction.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves application-wide statistics for the admin dashboard.
|
||||
* @returns A promise that resolves to an object containing various application stats.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getApplicationStats(): Promise<{
|
||||
flyerCount: number;
|
||||
userCount: number;
|
||||
flyerItemCount: number;
|
||||
storeCount: number;
|
||||
pendingCorrectionCount: number;
|
||||
}> {
|
||||
try {
|
||||
// Run count queries in parallel for better performance
|
||||
const flyerCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.flyers');
|
||||
const userCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.users');
|
||||
const flyerItemCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.flyer_items');
|
||||
const storeCountQuery = pool.query<{ count: string }>('SELECT COUNT(*) FROM public.stores');
|
||||
const pendingCorrectionCountQuery = pool.query<{ count: string }>("SELECT COUNT(*) FROM public.suggested_corrections WHERE status = 'pending'");
|
||||
|
||||
const [
|
||||
flyerCountRes,
|
||||
userCountRes,
|
||||
flyerItemCountRes,
|
||||
storeCountRes,
|
||||
pendingCorrectionCountRes
|
||||
] = await Promise.all([
|
||||
flyerCountQuery, userCountQuery, flyerItemCountQuery, storeCountQuery, pendingCorrectionCountQuery
|
||||
]);
|
||||
|
||||
return {
|
||||
flyerCount: parseInt(flyerCountRes.rows[0].count, 10),
|
||||
userCount: parseInt(userCountRes.rows[0].count, 10),
|
||||
flyerItemCount: parseInt(flyerItemCountRes.rows[0].count, 10),
|
||||
storeCount: parseInt(storeCountRes.rows[0].count, 10),
|
||||
pendingCorrectionCount: parseInt(pendingCorrectionCountRes.rows[0].count, 10),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Database error in getApplicationStats:', { error });
|
||||
throw new Error('Failed to retrieve application statistics.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves daily statistics for user registrations and flyer uploads for the last 30 days.
|
||||
* @returns A promise that resolves to an array of daily stats.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getDailyStatsForLast30Days(): Promise<{ date: string; new_users: number; new_flyers: number; }[]> {
|
||||
try {
|
||||
const query = `
|
||||
WITH date_series AS (
|
||||
SELECT generate_series(
|
||||
(CURRENT_DATE - interval '29 days'),
|
||||
CURRENT_DATE,
|
||||
'1 day'::interval
|
||||
)::date AS day
|
||||
),
|
||||
daily_users AS (
|
||||
SELECT created_at::date AS day, COUNT(*) AS user_count
|
||||
FROM public.users
|
||||
WHERE created_at >= (CURRENT_DATE - interval '29 days')
|
||||
GROUP BY 1
|
||||
),
|
||||
daily_flyers AS (
|
||||
SELECT created_at::date AS day, COUNT(*) AS flyer_count
|
||||
FROM public.flyers
|
||||
WHERE created_at >= (CURRENT_DATE - interval '29 days')
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
to_char(ds.day, 'YYYY-MM-DD') as date,
|
||||
COALESCE(du.user_count, 0)::int AS new_users,
|
||||
COALESCE(df.flyer_count, 0)::int AS new_flyers
|
||||
FROM date_series ds
|
||||
LEFT JOIN daily_users du ON ds.day = du.day
|
||||
LEFT JOIN daily_flyers df ON ds.day = df.day
|
||||
ORDER BY ds.day ASC;
|
||||
`;
|
||||
const res = await pool.query(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getDailyStatsForLast30Days:', { error });
|
||||
throw new Error('Failed to retrieve daily statistics.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to get the most frequently advertised items.
|
||||
* @param days The number of past days to look back.
|
||||
* @param limit The maximum number of items to return.
|
||||
* @returns A promise that resolves to an array of the most frequent sale items.
|
||||
*/
|
||||
export async function getMostFrequentSaleItems(days: number, limit: number): Promise<MostFrequentSaleItem[]> {
|
||||
try {
|
||||
const res = await pool.query<MostFrequentSaleItem>('SELECT * FROM public.get_most_frequent_sale_items($1, $2)', [days, limit]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getMostFrequentSaleItems:', { error });
|
||||
throw new Error('Failed to get most frequent sale items.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status of a recipe comment (e.g., for moderation).
|
||||
* @param commentId The ID of the comment to update.
|
||||
* @param status The new status ('visible', 'hidden', 'reported').
|
||||
* @returns A promise that resolves to the updated RecipeComment object.
|
||||
*/
|
||||
export async function updateRecipeCommentStatus(commentId: number, status: 'visible' | 'hidden' | 'reported'): Promise<RecipeComment> {
|
||||
try {
|
||||
const res = await pool.query<RecipeComment>(
|
||||
'UPDATE public.recipe_comments SET status = $1 WHERE id = $2 RETURNING *',
|
||||
[status, commentId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error(`Recipe comment with ID ${commentId} not found.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateRecipeCommentStatus:', { error, commentId, status });
|
||||
throw new Error('Failed to update recipe comment status.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all flyer items that could not be automatically matched to a master item.
|
||||
* @returns A promise that resolves to an array of unmatched flyer items with context.
|
||||
*/
|
||||
export async function getUnmatchedFlyerItems(): Promise<UnmatchedFlyerItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
ufi.id,
|
||||
ufi.status,
|
||||
ufi.created_at,
|
||||
fi.id as flyer_item_id,
|
||||
fi.item as flyer_item_name,
|
||||
fi.price_display,
|
||||
f.id as flyer_id,
|
||||
s.name as store_name
|
||||
FROM public.unmatched_flyer_items ufi
|
||||
JOIN public.flyer_items fi ON ufi.flyer_item_id = fi.id
|
||||
JOIN public.flyers f ON fi.flyer_id = f.id
|
||||
JOIN public.stores s ON f.store_id = s.id
|
||||
WHERE ufi.status = 'pending'
|
||||
ORDER BY ufi.created_at ASC;
|
||||
`;
|
||||
const res = await pool.query<UnmatchedFlyerItem>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getUnmatchedFlyerItems:', { error });
|
||||
throw new Error('Failed to retrieve unmatched flyer items.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status of a recipe (e.g., for moderation).
|
||||
* @param recipeId The ID of the recipe to update.
|
||||
* @param status The new status ('private', 'pending_review', 'public', 'rejected').
|
||||
* @returns A promise that resolves to the updated Recipe object.
|
||||
*/
|
||||
export async function updateRecipeStatus(recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected'): Promise<Recipe> {
|
||||
try {
|
||||
const res = await pool.query<Recipe>(
|
||||
'UPDATE public.recipes SET status = $1 WHERE id = $2 RETURNING *',
|
||||
[status, recipeId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error(`Recipe with ID ${recipeId} not found.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateRecipeStatus:', { error, recipeId, status });
|
||||
throw new Error('Failed to update recipe status.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of recent activities from the activity log.
|
||||
* @param limit The number of log entries to retrieve.
|
||||
* @param offset The number of log entries to skip (for pagination).
|
||||
* @returns A promise that resolves to an array of ActivityLogItem objects.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getActivityLog(limit: number, offset: number): Promise<any[]> {
|
||||
try {
|
||||
const res = await pool.query<any>('SELECT * FROM public.get_activity_log($1, $2)', [limit, offset]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getActivityLog:', { error, limit, offset });
|
||||
throw new Error('Failed to retrieve activity log.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the logo URL for a specific brand.
|
||||
* @param brandId The ID of the brand to update.
|
||||
* @param logoUrl The new URL for the brand's logo.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function updateBrandLogo(brandId: number, logoUrl: string): Promise<void> {
|
||||
try {
|
||||
await pool.query(
|
||||
'UPDATE public.brands SET logo_url = $1 WHERE id = $2',
|
||||
[logoUrl, brandId]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateBrandLogo:', { error, brandId });
|
||||
throw new Error('Failed to update brand logo in database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status of a specific receipt.
|
||||
* @param receiptId The ID of the receipt to update.
|
||||
* @param status The new status for the receipt.
|
||||
* @returns A promise that resolves to the updated Receipt object.
|
||||
*/
|
||||
export async function updateReceiptStatus(receiptId: number, status: 'pending' | 'processing' | 'completed' | 'failed'): Promise<any> {
|
||||
try {
|
||||
const res = await pool.query<any>(
|
||||
`UPDATE public.receipts SET status = $1, processed_at = CASE WHEN $1 IN ('completed', 'failed') THEN now() ELSE processed_at END WHERE id = $2 RETURNING *`,
|
||||
[status, receiptId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error(`Receipt with ID ${receiptId} not found.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateReceiptStatus:', { error, receiptId, status });
|
||||
throw new Error('Failed to update receipt status.');
|
||||
}
|
||||
}
|
||||
60
src/services/db/connection.ts
Normal file
60
src/services/db/connection.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Pool } from 'pg';
|
||||
import { logger } from '../logger';
|
||||
|
||||
// Configure your PostgreSQL connection pool
|
||||
// IMPORTANT: For production, use environment variables for these credentials.
|
||||
export const pool = new Pool({
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
database: process.env.DB_NAME || 'flyer-crawler', // Replace with your actual database name
|
||||
password: process.env.DB_PASSWORD || 'your_db_password', // Replace with your actual database password
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
});
|
||||
|
||||
/**
|
||||
* Logs a message indicating the database connection pool has been created.
|
||||
*/
|
||||
logger.info(`Database connection pool created for host: ${process.env.DB_HOST || 'localhost'}`);
|
||||
|
||||
/**
|
||||
* Checks for the existence of a list of tables in the public schema.
|
||||
* @param tableNames An array of table names to check.
|
||||
* @returns A promise that resolves to an array of table names that are missing from the database.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function checkTablesExist(tableNames: string[]): Promise<string[]> {
|
||||
try {
|
||||
// This query checks the information_schema to find which of the provided table names exist.
|
||||
const query = `
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ANY($1::text[])
|
||||
`;
|
||||
const res = await pool.query<{ table_name: string }>(query, [tableNames]);
|
||||
|
||||
const existingTables = new Set(res.rows.map(row => row.table_name));
|
||||
const missingTables = tableNames.filter(name => !existingTables.has(name));
|
||||
|
||||
return missingTables;
|
||||
} catch (error) {
|
||||
logger.error('Database error in checkTablesExist:', { error });
|
||||
throw new Error('Failed to check for tables in database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current status of the connection pool.
|
||||
* @returns An object with the total, idle, and waiting client counts.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export function getPoolStatus() {
|
||||
// pool.totalCount: The total number of clients in the pool.
|
||||
// pool.idleCount: The number of clients that are idle and waiting for a query.
|
||||
// pool.waitingCount: The number of queued requests waiting for a client to become available.
|
||||
return {
|
||||
totalCount: pool.totalCount,
|
||||
idleCount: pool.idleCount,
|
||||
waitingCount: pool.waitingCount,
|
||||
};
|
||||
}
|
||||
313
src/services/db/flyer.ts
Normal file
313
src/services/db/flyer.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { pool } from './connection';
|
||||
import { logger } from '../logger';
|
||||
import { Flyer, Brand, MasterGroceryItem, FlyerItem } from '../../types';
|
||||
|
||||
/**
|
||||
* Retrieves all flyers from the database, joining with store information.
|
||||
* @returns A promise that resolves to an array of Flyer objects.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getFlyers(): Promise<Flyer[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
f.id,
|
||||
f.created_at,
|
||||
f.file_name,
|
||||
f.image_url,
|
||||
f.checksum,
|
||||
f.store_id,
|
||||
f.valid_from,
|
||||
f.valid_to,
|
||||
f.store_address,
|
||||
json_build_object(
|
||||
'id', s.id,
|
||||
'name', s.name,
|
||||
'logo_url', s.logo_url
|
||||
) as store
|
||||
FROM public.flyers f
|
||||
LEFT JOIN public.stores s ON f.store_id = s.id
|
||||
ORDER BY f.valid_to DESC, s.name ASC;
|
||||
`;
|
||||
const res = await pool.query<Flyer>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getFlyers:', { error });
|
||||
throw new Error('Failed to retrieve flyers from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all brands from the database, including the associated store name for store brands.
|
||||
* @returns A promise that resolves to an array of Brand objects.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getAllBrands(): Promise<Brand[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT b.id, b.name, b.logo_url, b.store_id, s.name as store_name
|
||||
FROM public.brands b
|
||||
LEFT JOIN public.stores s ON b.store_id = s.id
|
||||
ORDER BY b.name ASC;
|
||||
`;
|
||||
const res = await pool.query<Brand>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getAllBrands:', { error });
|
||||
throw new Error('Failed to retrieve brands from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all master grocery items from the database, joining with category information.
|
||||
* @returns A promise that resolves to an array of MasterGroceryItem objects.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getAllMasterItems(): Promise<MasterGroceryItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
m.id,
|
||||
m.created_at,
|
||||
m.name,
|
||||
m.category_id,
|
||||
c.name as category_name
|
||||
FROM public.master_grocery_items m
|
||||
LEFT JOIN public.categories c ON m.category_id = c.id
|
||||
ORDER BY m.name ASC;
|
||||
`;
|
||||
const res = await pool.query<MasterGroceryItem>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getAllMasterItems:', { error });
|
||||
throw new Error('Failed to retrieve master items from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all categories from the database.
|
||||
* @returns A promise that resolves to an array of Category objects.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getAllCategories(): Promise<{id: number, name: string}[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT id, name FROM public.categories ORDER BY name ASC;
|
||||
`;
|
||||
const res = await pool.query<{id: number, name: string}>(query);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getAllCategories:', { error });
|
||||
throw new Error('Failed to retrieve categories from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a flyer by its checksum to prevent duplicate processing.
|
||||
* @param checksum The SHA-256 checksum of the flyer file.
|
||||
* @returns A promise that resolves to the Flyer object if found, otherwise undefined.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function findFlyerByChecksum(checksum: string): Promise<Flyer | undefined> {
|
||||
try {
|
||||
const res = await pool.query<Flyer>('SELECT * FROM public.flyers WHERE checksum = $1', [checksum]);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in findFlyerByChecksum:', { error });
|
||||
throw new Error('Failed to check for existing flyer.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new flyer and all its associated items in a single database transaction.
|
||||
* @param flyerData The metadata for the flyer.
|
||||
* @param items The array of flyer items extracted from the flyer.
|
||||
* @returns A promise that resolves to the newly created Flyer object.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function createFlyerAndItems(
|
||||
flyerData: Omit<Flyer, 'id' | 'created_at' | 'store'> & { store_name: string },
|
||||
items: Omit<FlyerItem, 'id' | 'flyer_id' | 'created_at'>[]
|
||||
): Promise<Flyer> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Find or create the store
|
||||
let storeId: number;
|
||||
const storeRes = await client.query<{ id: number }>('SELECT id FROM public.stores WHERE name = $1', [flyerData.store_name]);
|
||||
if (storeRes.rows.length > 0) {
|
||||
storeId = storeRes.rows[0].id;
|
||||
} else {
|
||||
const newStoreRes = await client.query<{ id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING id', [flyerData.store_name]);
|
||||
storeId = newStoreRes.rows[0].id;
|
||||
}
|
||||
|
||||
// Create the flyer record
|
||||
const flyerQuery = `
|
||||
INSERT INTO public.flyers (file_name, image_url, checksum, store_id, valid_from, valid_to, store_address)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *;
|
||||
`;
|
||||
const flyerValues = [flyerData.file_name, flyerData.image_url, flyerData.checksum, storeId, flyerData.valid_from, flyerData.valid_to, flyerData.store_address];
|
||||
const newFlyerRes = await client.query<Flyer>(flyerQuery, flyerValues);
|
||||
const newFlyer = newFlyerRes.rows[0];
|
||||
|
||||
// Prepare and insert all flyer items
|
||||
if (items.length > 0) {
|
||||
const itemInsertQuery = `
|
||||
INSERT INTO public.flyer_items (
|
||||
flyer_id, item, price_display, price_in_cents, quantity,
|
||||
master_item_id, -- This will be populated by our suggestion function
|
||||
category_name, unit_price
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, public.suggest_master_item_for_flyer_item($2), $6, $7)
|
||||
`;
|
||||
|
||||
// Loop through each item and execute the insert query.
|
||||
// The query now directly calls the `suggest_master_item_for_flyer_item` function
|
||||
// on the database side, passing the item name (`item.item`) as the argument.
|
||||
// This is more efficient than making a separate DB call for each item to get the suggestion.
|
||||
for (const item of items) {
|
||||
const itemValues = [
|
||||
newFlyer.id,
|
||||
item.item,
|
||||
item.price_display,
|
||||
item.price_in_cents,
|
||||
item.quantity,
|
||||
item.category_name,
|
||||
item.unit_price ? JSON.stringify(item.unit_price) : null // Ensure JSONB is correctly stringified
|
||||
];
|
||||
await client.query(itemInsertQuery, itemValues);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return newFlyer;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Database transaction error in createFlyerAndItems:', { error });
|
||||
throw new Error('Failed to save flyer and its items.');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all items for a specific flyer.
|
||||
* @param flyerId The ID of the flyer.
|
||||
* @returns A promise that resolves to an array of FlyerItem objects.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getFlyerItems(flyerId: number): Promise<FlyerItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT * FROM public.flyer_items
|
||||
WHERE flyer_id = $1
|
||||
ORDER BY id ASC;
|
||||
`;
|
||||
const res = await pool.query<FlyerItem>(query, [flyerId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getFlyerItems:', { error, flyerId });
|
||||
throw new Error('Failed to retrieve flyer items.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all flyer items for a given list of flyer IDs.
|
||||
* @param flyerIds An array of flyer IDs.
|
||||
* @returns A promise that resolves to an array of FlyerItem objects.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getFlyerItemsForFlyers(flyerIds: number[]): Promise<FlyerItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT * FROM public.flyer_items
|
||||
WHERE flyer_id = ANY($1::bigint[]);
|
||||
`;
|
||||
const res = await pool.query<FlyerItem>(query, [flyerIds]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getFlyerItemsForFlyers:', { error });
|
||||
throw new Error('Failed to retrieve items for multiple flyers.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the total number of flyer items for a given list of flyer IDs.
|
||||
* @param flyerIds An array of flyer IDs.
|
||||
* @returns A promise that resolves to the total count of items.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function countFlyerItemsForFlyers(flyerIds: number[]): Promise<number> {
|
||||
try {
|
||||
const query = `SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::bigint[])`;
|
||||
const res = await pool.query<{ count: string }>(query, [flyerIds]);
|
||||
return parseInt(res.rows[0].count, 10);
|
||||
} catch (error) {
|
||||
logger.error('Database error in countFlyerItemsForFlyers:', { error });
|
||||
throw new Error('Failed to count items for multiple flyers.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the logo URL for a specific store.
|
||||
* @param storeId The ID of the store to update.
|
||||
* @param logoUrl The new URL for the store's logo.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function updateStoreLogo(storeId: number, logoUrl: string): Promise<void> {
|
||||
try {
|
||||
await pool.query(
|
||||
'UPDATE public.stores SET logo_url = $1 WHERE id = $2',
|
||||
[logoUrl, storeId]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateStoreLogo:', { error, storeId });
|
||||
throw new Error('Failed to update store logo in database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a user interaction with a flyer item (view or click).
|
||||
* @param itemId The ID of the flyer item.
|
||||
* @param type The type of interaction ('view' or 'click').
|
||||
*/
|
||||
export async function trackFlyerItemInteraction(itemId: number, type: 'view' | 'click'): Promise<void> {
|
||||
try {
|
||||
const column = type === 'view' ? 'view_count' : 'click_count';
|
||||
// Use the || operator to concatenate the column name safely into the query.
|
||||
const query = `UPDATE public.flyer_items SET ${column} = ${column} + 1 WHERE id = $1`;
|
||||
await pool.query(query, [itemId]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in trackFlyerItemInteraction:', { error, itemId, type });
|
||||
// This is a non-critical operation, so we don't throw an error that would crash the user's request.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves historical price data for a given list of master item IDs.
|
||||
* This function queries the pre-aggregated `item_price_history` table for efficiency.
|
||||
* @param masterItemIds An array of master grocery item IDs.
|
||||
* @returns A promise that resolves to an array of historical price records.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getHistoricalPriceDataForItems(masterItemIds: number[]): Promise<{ master_item_id: number; summary_date: string; avg_price_in_cents: number | null; }[]> {
|
||||
if (masterItemIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const query = `
|
||||
SELECT master_item_id, summary_date, avg_price_in_cents
|
||||
FROM public.item_price_history
|
||||
WHERE master_item_id = ANY($1::bigint[])
|
||||
ORDER BY summary_date ASC;
|
||||
`;
|
||||
const res = await pool.query(query, [masterItemIds]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getHistoricalPriceDataForItems:', { error });
|
||||
throw new Error('Failed to retrieve historical price data.');
|
||||
}
|
||||
}
|
||||
7
src/services/db/index.ts
Normal file
7
src/services/db/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './connection';
|
||||
export * from './user';
|
||||
export * from './flyer';
|
||||
export * from './recipe';
|
||||
export * from './personalization';
|
||||
export * from './shopping';
|
||||
export * from './admin';
|
||||
323
src/services/db/personalization.ts
Normal file
323
src/services/db/personalization.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { pool } from './connection';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
MasterGroceryItem,
|
||||
PantryRecipe,
|
||||
RecommendedRecipe,
|
||||
WatchedItemDeal,
|
||||
PantryItemConversion,
|
||||
DietaryRestriction,
|
||||
Appliance,
|
||||
UserAppliance,
|
||||
Recipe,
|
||||
} from '../../types';
|
||||
|
||||
/**
|
||||
* Retrieves all watched master items for a specific user.
|
||||
* @param userId The UUID of the user.
|
||||
* @returns A promise that resolves to an array of MasterGroceryItem objects.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getWatchedItems(userId: string): Promise<MasterGroceryItem[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT mgi.*
|
||||
FROM public.master_grocery_items mgi
|
||||
JOIN public.user_watched_items uwi ON mgi.id = uwi.master_item_id
|
||||
WHERE uwi.user_id = $1
|
||||
ORDER BY mgi.name ASC;
|
||||
`;
|
||||
const res = await pool.query<MasterGroceryItem>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getWatchedItems:', { error, userId });
|
||||
throw new Error('Failed to retrieve watched items.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an item to a user's watchlist. If the master item doesn't exist, it creates it.
|
||||
* @param userId The UUID of the user.
|
||||
* @param itemName The name of the item to watch.
|
||||
* @param categoryName The category of the item.
|
||||
* @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function addWatchedItem(userId: string, itemName: string, categoryName: string): Promise<MasterGroceryItem> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Find category ID
|
||||
const categoryRes = await client.query<{ id: number }>('SELECT id FROM public.categories WHERE name = $1', [categoryName]);
|
||||
const categoryId = categoryRes.rows[0]?.id;
|
||||
if (!categoryId) {
|
||||
throw new Error(`Category '${categoryName}' not found.`);
|
||||
}
|
||||
|
||||
// Find or create master item
|
||||
let masterItem: MasterGroceryItem;
|
||||
const masterItemRes = await client.query<MasterGroceryItem>('SELECT * FROM public.master_grocery_items WHERE name = $1', [itemName]);
|
||||
if (masterItemRes.rows.length > 0) {
|
||||
masterItem = masterItemRes.rows[0];
|
||||
} else {
|
||||
const newMasterItemRes = await client.query<MasterGroceryItem>(
|
||||
'INSERT INTO public.master_grocery_items (name, category_id) VALUES ($1, $2) RETURNING *',
|
||||
[itemName, categoryId]
|
||||
);
|
||||
masterItem = newMasterItemRes.rows[0];
|
||||
}
|
||||
|
||||
// Add to user's watchlist, ignoring if it's already there.
|
||||
await client.query(
|
||||
'INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2) ON CONFLICT (user_id, master_item_id) DO NOTHING',
|
||||
[userId, masterItem.id]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return masterItem;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Database transaction error in addWatchedItem:', { error });
|
||||
throw new Error('Failed to add item to watchlist.');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from a user's watchlist.
|
||||
* @param userId The UUID of the user.
|
||||
* @param masterItemId The ID of the master item to remove.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function removeWatchedItem(userId: string, masterItemId: number): Promise<void> {
|
||||
try {
|
||||
await pool.query('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', [userId, masterItemId]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in removeWatchedItem:', { error });
|
||||
throw new Error('Failed to remove item from watchlist.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to find recipes that can be made from a user's pantry.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of recipes.
|
||||
*/
|
||||
export async function findRecipesFromPantry(userId: string): Promise<PantryRecipe[]> {
|
||||
try {
|
||||
const res = await pool.query<PantryRecipe>('SELECT * FROM public.find_recipes_from_pantry($1)', [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in findRecipesFromPantry:', { error, userId });
|
||||
throw new Error('Failed to find recipes from pantry.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to recommend recipes for a user.
|
||||
* @param userId The ID of the user.
|
||||
* @param limit The maximum number of recipes to recommend.
|
||||
* @returns A promise that resolves to an array of recommended recipes.
|
||||
*/
|
||||
export async function recommendRecipesForUser(userId: string, limit: number): Promise<RecommendedRecipe[]> {
|
||||
try {
|
||||
const res = await pool.query<RecommendedRecipe>('SELECT * FROM public.recommend_recipes_for_user($1, $2)', [userId, limit]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in recommendRecipesForUser:', { error, userId });
|
||||
throw new Error('Failed to recommend recipes.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to get the best current sale prices for a user's watched items.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of the best deals.
|
||||
*/
|
||||
export async function getBestSalePricesForUser(userId: string): Promise<WatchedItemDeal[]> {
|
||||
try {
|
||||
const res = await pool.query<WatchedItemDeal>('SELECT * FROM public.get_best_sale_prices_for_user($1)', [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getBestSalePricesForUser:', { error, userId });
|
||||
throw new Error('Failed to get best sale prices.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to suggest unit conversions for a pantry item.
|
||||
* @param pantryItemId The ID of the pantry item.
|
||||
* @returns A promise that resolves to an array of suggested conversions.
|
||||
*/
|
||||
export async function suggestPantryItemConversions(pantryItemId: number): Promise<PantryItemConversion[]> {
|
||||
try {
|
||||
const res = await pool.query<PantryItemConversion>('SELECT * FROM public.suggest_pantry_item_conversions($1)', [pantryItemId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in suggestPantryItemConversions:', { error, pantryItemId });
|
||||
throw new Error('Failed to suggest pantry item conversions.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the owner of a specific pantry item.
|
||||
* @param pantryItemId The ID of the pantry item.
|
||||
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function findPantryItemOwner(pantryItemId: number): Promise<{ user_id: string } | undefined> {
|
||||
try {
|
||||
const res = await pool.query<{ user_id: string }>(
|
||||
'SELECT user_id FROM public.pantry_items WHERE id = $1',
|
||||
[pantryItemId]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in findPantryItemOwner:', { error, pantryItemId });
|
||||
throw new Error('Failed to retrieve pantry item owner from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the master list of all available dietary restrictions.
|
||||
* @returns A promise that resolves to an array of DietaryRestriction objects.
|
||||
*/
|
||||
export async function getDietaryRestrictions(): Promise<DietaryRestriction[]> {
|
||||
try {
|
||||
const res = await pool.query<DietaryRestriction>('SELECT * FROM public.dietary_restrictions ORDER BY type, name');
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getDietaryRestrictions:', { error });
|
||||
throw new Error('Failed to get dietary restrictions.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the dietary restrictions for a specific user.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of the user's selected DietaryRestriction objects.
|
||||
*/
|
||||
export async function getUserDietaryRestrictions(userId: string): Promise<DietaryRestriction[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT dr.* FROM public.dietary_restrictions dr
|
||||
JOIN public.user_dietary_restrictions udr ON dr.id = udr.restriction_id
|
||||
WHERE udr.user_id = $1 ORDER BY dr.type, dr.name;
|
||||
`;
|
||||
const res = await pool.query<DietaryRestriction>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getUserDietaryRestrictions:', { error, userId });
|
||||
throw new Error('Failed to get user dietary restrictions.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the dietary restrictions for a user, replacing any existing ones.
|
||||
* @param userId The ID of the user.
|
||||
* @param restrictionIds An array of IDs for the selected dietary restrictions.
|
||||
* @returns A promise that resolves when the operation is complete.
|
||||
*/
|
||||
export async function setUserDietaryRestrictions(userId: string, restrictionIds: number[]): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// Clear existing restrictions for the user
|
||||
await client.query('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', [userId]);
|
||||
// Insert new ones if any are provided
|
||||
if (restrictionIds.length > 0) {
|
||||
const values = restrictionIds.map(id => `('${userId}', ${id})`).join(',');
|
||||
await client.query(`INSERT INTO public.user_dietary_restrictions (user_id, restriction_id) VALUES ${values}`);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Database error in setUserDietaryRestrictions:', { error, userId });
|
||||
throw new Error('Failed to set user dietary restrictions.');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the master list of all available kitchen appliances.
|
||||
* @returns A promise that resolves to an array of Appliance objects.
|
||||
*/
|
||||
export async function getAppliances(): Promise<Appliance[]> {
|
||||
try {
|
||||
const res = await pool.query<Appliance>('SELECT * FROM public.appliances ORDER BY name');
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getAppliances:', { error });
|
||||
throw new Error('Failed to get appliances.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the kitchen appliances for a specific user.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of the user's selected Appliance objects.
|
||||
*/
|
||||
export async function getUserAppliances(userId: string): Promise<Appliance[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT a.* FROM public.appliances a
|
||||
JOIN public.user_appliances ua ON a.id = ua.appliance_id
|
||||
WHERE ua.user_id = $1 ORDER BY a.name;
|
||||
`;
|
||||
const res = await pool.query<Appliance>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getUserAppliances:', { error, userId });
|
||||
throw new Error('Failed to get user appliances.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the kitchen appliances for a user, replacing any existing ones.
|
||||
* @param userId The ID of the user.
|
||||
* @param applianceIds An array of IDs for the selected appliances.
|
||||
* @returns A promise that resolves when the operation is complete.
|
||||
*/
|
||||
export async function setUserAppliances(userId: string, applianceIds: number[]): Promise<UserAppliance[]> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// Clear existing appliances for the user
|
||||
await client.query('DELETE FROM public.user_appliances WHERE user_id = $1', [userId]);
|
||||
|
||||
let newAppliances: UserAppliance[] = [];
|
||||
// Insert new ones if any are provided
|
||||
if (applianceIds.length > 0) {
|
||||
const insertQuery = `INSERT INTO public.user_appliances (user_id, appliance_id) SELECT $1, unnest($2::int[]) RETURNING *`;
|
||||
const res = await client.query<UserAppliance>(insertQuery, [userId, applianceIds]);
|
||||
newAppliances = res.rows;
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return newAppliances;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Database error in setUserAppliances:', { error, userId });
|
||||
throw new Error('Failed to set user appliances.');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to get recipes that are compatible with a user's dietary restrictions.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of compatible Recipe objects.
|
||||
*/
|
||||
export async function getRecipesForUserDiets(userId: string): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await pool.query<Recipe>('SELECT * FROM public.get_recipes_for_user_diets($1)', [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getRecipesForUserDiets:', { error, userId });
|
||||
throw new Error('Failed to get recipes compatible with user diet.');
|
||||
}
|
||||
}
|
||||
160
src/services/db/recipe.ts
Normal file
160
src/services/db/recipe.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { pool } from './connection';
|
||||
import { logger } from '../logger';
|
||||
import { Recipe, FavoriteRecipe, RecipeComment } from '../../types';
|
||||
|
||||
/**
|
||||
* Calls a database function to get recipes based on the percentage of their ingredients on sale.
|
||||
* @param minPercentage The minimum percentage of ingredients that must be on sale.
|
||||
* @returns A promise that resolves to an array of recipes.
|
||||
*/
|
||||
export async function getRecipesBySalePercentage(minPercentage: number): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await pool.query<Recipe>('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [minPercentage]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getRecipesBySalePercentage:', { error });
|
||||
throw new Error('Failed to get recipes by sale percentage.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to get recipes by the minimum number of sale ingredients.
|
||||
* @param minIngredients The minimum number of ingredients that must be on sale.
|
||||
* @returns A promise that resolves to an array of recipes.
|
||||
*/
|
||||
export async function getRecipesByMinSaleIngredients(minIngredients: number): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await pool.query<Recipe>('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [minIngredients]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getRecipesByMinSaleIngredients:', { error });
|
||||
throw new Error('Failed to get recipes by minimum sale ingredients.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to find recipes by a specific ingredient and tag.
|
||||
* @param ingredient The name of the ingredient to search for.
|
||||
* @param tag The name of the tag to search for.
|
||||
* @returns A promise that resolves to an array of matching recipes.
|
||||
*/
|
||||
export async function findRecipesByIngredientAndTag(ingredient: string, tag: string): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await pool.query<Recipe>('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', [ingredient, tag]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in findRecipesByIngredientAndTag:', { error });
|
||||
throw new Error('Failed to find recipes by ingredient and tag.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to get a user's favorite recipes.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of the user's favorite recipes.
|
||||
*/
|
||||
export async function getUserFavoriteRecipes(userId: string): Promise<Recipe[]> {
|
||||
try {
|
||||
const res = await pool.query<Recipe>('SELECT * FROM public.get_user_favorite_recipes($1)', [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getUserFavoriteRecipes:', { error, userId });
|
||||
throw new Error('Failed to get favorite recipes.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a recipe to a user's favorites.
|
||||
* @param userId The ID of the user.
|
||||
* @param recipeId The ID of the recipe to favorite.
|
||||
* @returns A promise that resolves to the created favorite record.
|
||||
*/
|
||||
export async function addFavoriteRecipe(userId: string, recipeId: number): Promise<FavoriteRecipe> {
|
||||
try {
|
||||
const res = await pool.query<FavoriteRecipe>(
|
||||
'INSERT INTO public.favorite_recipes (user_id, recipe_id) VALUES ($1, $2) ON CONFLICT (user_id, recipe_id) DO NOTHING RETURNING *',
|
||||
[userId, recipeId]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in addFavoriteRecipe:', { error, userId, recipeId });
|
||||
throw new Error('Failed to add favorite recipe.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a recipe from a user's favorites.
|
||||
* @param userId The ID of the user.
|
||||
* @param recipeId The ID of the recipe to unfavorite.
|
||||
*/
|
||||
export async function removeFavoriteRecipe(userId: string, recipeId: number): Promise<void> {
|
||||
try {
|
||||
await pool.query('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', [userId, recipeId]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in removeFavoriteRecipe:', { error, userId, recipeId });
|
||||
throw new Error('Failed to remove favorite recipe.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all comments for a specific recipe.
|
||||
* @param recipeId The ID of the recipe.
|
||||
* @returns A promise that resolves to an array of RecipeComment objects.
|
||||
*/
|
||||
export async function getRecipeComments(recipeId: number): Promise<RecipeComment[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
rc.*,
|
||||
p.full_name as user_full_name,
|
||||
p.avatar_url as user_avatar_url
|
||||
FROM public.recipe_comments rc
|
||||
LEFT JOIN public.profiles p ON rc.user_id = p.id
|
||||
WHERE rc.recipe_id = $1
|
||||
ORDER BY rc.created_at ASC;
|
||||
`;
|
||||
const res = await pool.query<RecipeComment>(query, [recipeId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getRecipeComments:', { error, recipeId });
|
||||
throw new Error('Failed to get recipe comments.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new comment to a recipe.
|
||||
* @param recipeId The ID of the recipe to comment on.
|
||||
* @param userId The ID of the user posting the comment.
|
||||
* @param content The text content of the comment.
|
||||
* @param parentCommentId Optional ID of the parent comment for threaded replies.
|
||||
* @returns A promise that resolves to the newly created RecipeComment object.
|
||||
*/
|
||||
export async function addRecipeComment(recipeId: number, userId: string, content: string, parentCommentId?: number): Promise<RecipeComment> {
|
||||
try {
|
||||
const res = await pool.query<RecipeComment>(
|
||||
'INSERT INTO public.recipe_comments (recipe_id, user_id, content, parent_comment_id) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[recipeId, userId, content, parentCommentId]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in addRecipeComment:', { error });
|
||||
throw new Error('Failed to add recipe comment.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a personal, editable copy (a "fork") of a public recipe for a user.
|
||||
* @param userId The ID of the user forking the recipe.
|
||||
* @param originalRecipeId The ID of the recipe to fork.
|
||||
* @returns A promise that resolves to the newly created forked Recipe object.
|
||||
*/
|
||||
export async function forkRecipe(userId: string, originalRecipeId: number): Promise<Recipe> {
|
||||
try {
|
||||
// The entire forking logic is now encapsulated in a single, atomic database function.
|
||||
const res = await pool.query<Recipe>('SELECT * FROM public.fork_recipe($1, $2)', [userId, originalRecipeId]);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in forkRecipe:', { error });
|
||||
throw new Error('Failed to fork recipe.');
|
||||
}
|
||||
}
|
||||
363
src/services/db/shopping.ts
Normal file
363
src/services/db/shopping.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { pool } from './connection';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
ShoppingList,
|
||||
ShoppingListItem,
|
||||
MenuPlanShoppingListItem,
|
||||
PantryLocation,
|
||||
ShoppingTrip,
|
||||
Receipt,
|
||||
ReceiptItem,
|
||||
ReceiptDeal,
|
||||
} from '../../types';
|
||||
|
||||
/**
|
||||
* Retrieves all shopping lists and their items for a user.
|
||||
* @param userId The UUID of the user.
|
||||
* @returns A promise that resolves to an array of ShoppingList objects.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getShoppingLists(userId: string): Promise<ShoppingList[]> {
|
||||
try {
|
||||
// This refactored query uses a LEFT JOIN and a single GROUP BY aggregation,
|
||||
// which is generally more performant than using a correlated subquery for each row.
|
||||
const query = `
|
||||
SELECT
|
||||
sl.id, sl.name, sl.created_at,
|
||||
-- Aggregate all joined shopping list items into a single JSON array for each list.
|
||||
-- The FILTER clause ensures that if a list has no items, we get an empty array '[]'
|
||||
-- instead of an array with a single null value '[null]'.
|
||||
COALESCE(
|
||||
(SELECT json_agg(
|
||||
json_build_object(
|
||||
'id', sli.id,
|
||||
'shopping_list_id', sli.shopping_list_id,
|
||||
'master_item_id', sli.master_item_id,
|
||||
'custom_item_name', sli.custom_item_name,
|
||||
'quantity', sli.quantity,
|
||||
'is_purchased', sli.is_purchased,
|
||||
'added_at', sli.added_at,
|
||||
'master_item', json_build_object('name', mgi.name)
|
||||
) ORDER BY sli.added_at ASC
|
||||
) FILTER (WHERE sli.id IS NOT NULL)),
|
||||
'[]'::json
|
||||
) as items
|
||||
FROM public.shopping_lists sl
|
||||
LEFT JOIN public.shopping_list_items sli ON sl.id = sli.shopping_list_id
|
||||
LEFT JOIN public.master_grocery_items mgi ON sli.master_item_id = mgi.id
|
||||
WHERE sl.user_id = $1
|
||||
GROUP BY sl.id, sl.name, sl.created_at
|
||||
ORDER BY sl.created_at ASC;
|
||||
`;
|
||||
const res = await pool.query<ShoppingList>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getShoppingLists:', { error, userId });
|
||||
throw new Error('Failed to retrieve shopping lists.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new shopping list for a user.
|
||||
* @param userId The ID of the user creating the list.
|
||||
* @param name The name of the new shopping list.
|
||||
* @returns A promise that resolves to the newly created ShoppingList object.
|
||||
*/
|
||||
export async function createShoppingList(userId: string, name: string): Promise<ShoppingList> {
|
||||
try {
|
||||
const res = await pool.query<ShoppingList>(
|
||||
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING id, user_id, name, created_at',
|
||||
[userId, name]
|
||||
);
|
||||
// Return a complete ShoppingList object with an empty items array
|
||||
return { ...res.rows[0], items: [] };
|
||||
} catch (error) {
|
||||
logger.error('Database error in createShoppingList:', { error });
|
||||
throw new Error('Failed to create shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a shopping list owned by a specific user.
|
||||
* @param listId The ID of the shopping list to delete.
|
||||
* @param userId The ID of the user who owns the list, for an ownership check.
|
||||
*/
|
||||
export async function deleteShoppingList(listId: number, userId: string): Promise<void> {
|
||||
try {
|
||||
// The user_id check ensures a user can only delete their own list.
|
||||
await pool.query('DELETE FROM public.shopping_lists WHERE id = $1 AND user_id = $2', [listId, userId]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in deleteShoppingList:', { error });
|
||||
throw new Error('Failed to delete shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new item to a shopping list.
|
||||
* @param listId The ID of the shopping list to add the item to.
|
||||
* @param item An object containing either a `masterItemId` or a `customItemName`.
|
||||
* @returns A promise that resolves to the newly created ShoppingListItem object.
|
||||
*/
|
||||
export async function addShoppingListItem(listId: number, item: { masterItemId?: number, customItemName?: string }): Promise<ShoppingListItem> {
|
||||
try {
|
||||
const res = await pool.query<ShoppingListItem>(
|
||||
'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name) VALUES ($1, $2, $3) RETURNING *',
|
||||
[listId, item.masterItemId, item.customItemName]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in addShoppingListItem:', { error });
|
||||
throw new Error('Failed to add item to shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from a shopping list.
|
||||
* @param itemId The ID of the shopping list item to remove.
|
||||
*/
|
||||
export async function removeShoppingListItem(itemId: number): Promise<void> {
|
||||
try {
|
||||
await pool.query('DELETE FROM public.shopping_list_items WHERE id = $1', [itemId]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in removeShoppingListItem:', { error });
|
||||
throw new Error('Failed to remove item from shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to generate a shopping list from a menu plan.
|
||||
* @param menuPlanId The ID of the menu plan.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of items for the shopping list.
|
||||
*/
|
||||
export async function generateShoppingListForMenuPlan(menuPlanId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
|
||||
try {
|
||||
const res = await pool.query<MenuPlanShoppingListItem>('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [menuPlanId, userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in generateShoppingListForMenuPlan:', { error, menuPlanId });
|
||||
throw new Error('Failed to generate shopping list for menu plan.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a database function to add items from a menu plan to a shopping list.
|
||||
* @param menuPlanId The ID of the menu plan.
|
||||
* @param shoppingListId The ID of the shopping list to add items to.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of the items that were added.
|
||||
*/
|
||||
export async function addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
|
||||
try {
|
||||
const res = await pool.query<MenuPlanShoppingListItem>('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [menuPlanId, shoppingListId, userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in addMenuPlanToShoppingList:', { error, menuPlanId });
|
||||
throw new Error('Failed to add menu plan to shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all pantry locations defined by a user.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of PantryLocation objects.
|
||||
*/
|
||||
export async function getPantryLocations(userId: string): Promise<PantryLocation[]> {
|
||||
try {
|
||||
const res = await pool.query<PantryLocation>('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getPantryLocations:', { error, userId });
|
||||
throw new Error('Failed to get pantry locations.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new pantry location for a user.
|
||||
* @param userId The ID of the user.
|
||||
* @param name The name of the new location (e.g., "Fridge").
|
||||
* @returns A promise that resolves to the newly created PantryLocation object.
|
||||
*/
|
||||
export async function createPantryLocation(userId: string, name: string): Promise<PantryLocation> {
|
||||
try {
|
||||
const res = await pool.query<PantryLocation>(
|
||||
'INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *',
|
||||
[userId, name]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in createPantryLocation:', { error });
|
||||
throw new Error('Failed to create pantry location.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing item in a shopping list.
|
||||
* @param itemId The ID of the shopping list item to update.
|
||||
* @param updates A partial object of the fields to update (e.g., quantity, is_purchased).
|
||||
* @returns A promise that resolves to the updated ShoppingListItem object.
|
||||
*/
|
||||
export async function updateShoppingListItem(itemId: number, updates: Partial<ShoppingListItem>): Promise<ShoppingListItem> {
|
||||
try {
|
||||
// Build the update query dynamically to handle various fields
|
||||
const setClauses = [];
|
||||
const values = [];
|
||||
let valueIndex = 1;
|
||||
|
||||
if (updates.quantity !== undefined) {
|
||||
setClauses.push(`quantity = $${valueIndex++}`);
|
||||
values.push(updates.quantity);
|
||||
}
|
||||
if (updates.is_purchased !== undefined) {
|
||||
setClauses.push(`is_purchased = $${valueIndex++}`);
|
||||
values.push(updates.is_purchased);
|
||||
}
|
||||
if (updates.notes !== undefined) {
|
||||
setClauses.push(`notes = $${valueIndex++}`);
|
||||
values.push(updates.notes);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
throw new Error("No valid fields to update.");
|
||||
}
|
||||
|
||||
values.push(itemId);
|
||||
const query = `UPDATE public.shopping_list_items SET ${setClauses.join(', ')} WHERE id = $${valueIndex} RETURNING *`;
|
||||
|
||||
const res = await pool.query<ShoppingListItem>(query, values);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateShoppingListItem:', { error });
|
||||
throw new Error('Failed to update shopping list item.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives a shopping list into a historical shopping trip.
|
||||
* @param shoppingListId The ID of the shopping list to complete.
|
||||
* @param userId The ID of the user owning the list.
|
||||
* @param totalSpentCents Optional total amount spent on the trip.
|
||||
* @returns A promise that resolves to the ID of the newly created shopping trip.
|
||||
*/
|
||||
export async function completeShoppingList(shoppingListId: number, userId: string, totalSpentCents?: number): Promise<number> {
|
||||
try {
|
||||
const res = await pool.query<{ complete_shopping_list: number }>(
|
||||
'SELECT public.complete_shopping_list($1, $2, $3)',
|
||||
[shoppingListId, userId, totalSpentCents]
|
||||
);
|
||||
return res.rows[0].complete_shopping_list;
|
||||
} catch (error) {
|
||||
logger.error('Database error in completeShoppingList:', { error });
|
||||
throw new Error('Failed to complete shopping list.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the historical shopping trips for a user, including all purchased items.
|
||||
* @param userId The ID of the user.
|
||||
* @returns A promise that resolves to an array of ShoppingTrip objects.
|
||||
*/
|
||||
export async function getShoppingTripHistory(userId: string): Promise<ShoppingTrip[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
st.id, st.user_id, st.shopping_list_id, st.completed_at, st.total_spent_cents,
|
||||
COALESCE(
|
||||
(SELECT json_agg(
|
||||
json_build_object(
|
||||
'id', sti.id,
|
||||
'shopping_trip_id', sti.shopping_trip_id,
|
||||
'master_item_id', sti.master_item_id,
|
||||
'custom_item_name', sti.custom_item_name,
|
||||
'quantity', sti.quantity,
|
||||
'price_paid_cents', sti.price_paid_cents,
|
||||
'master_item_name', mgi.name
|
||||
) ORDER BY mgi.name ASC, sti.custom_item_name ASC
|
||||
) FROM public.shopping_trip_items sti
|
||||
LEFT JOIN public.master_grocery_items mgi ON sti.master_item_id = mgi.id
|
||||
WHERE sti.shopping_trip_id = st.id),
|
||||
'[]'::json
|
||||
) as items
|
||||
FROM public.shopping_trips st
|
||||
WHERE st.user_id = $1
|
||||
ORDER BY st.completed_at DESC;
|
||||
`;
|
||||
const res = await pool.query<ShoppingTrip>(query, [userId]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getShoppingTripHistory:', { error, userId });
|
||||
throw new Error('Failed to retrieve shopping trip history.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new receipt record in the database.
|
||||
* @param userId The ID of the user uploading the receipt.
|
||||
* @param receiptImageUrl The URL where the receipt image is stored.
|
||||
* @returns A promise that resolves to the newly created Receipt object.
|
||||
*/
|
||||
export async function createReceipt(userId: string, receiptImageUrl: string): Promise<Receipt> {
|
||||
try {
|
||||
const res = await pool.query<Receipt>(
|
||||
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
|
||||
VALUES ($1, $2, 'pending')
|
||||
RETURNING *`,
|
||||
[userId, receiptImageUrl]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in createReceipt:', { error, userId });
|
||||
throw new Error('Failed to create receipt record.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes extracted receipt items, updates the receipt status, and saves the items.
|
||||
* @param receiptId The ID of the receipt being processed.
|
||||
* @param items An array of items extracted from the receipt.
|
||||
* @returns A promise that resolves when the operation is complete.
|
||||
*/
|
||||
export async function processReceiptItems(
|
||||
receiptId: number,
|
||||
items: Omit<ReceiptItem, 'id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'>[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
const itemsWithQuantity = items.map(item => ({ ...item, quantity: 1 }));
|
||||
await pool.query('SELECT public.process_receipt_items($1, $2, $3)', [receiptId, JSON.stringify(itemsWithQuantity), JSON.stringify(itemsWithQuantity)]);
|
||||
logger.info(`Successfully processed items for receipt ID: ${receiptId}`);
|
||||
} catch (error) {
|
||||
// On error, we should also update the receipt status to 'failed'
|
||||
await pool.query("UPDATE public.receipts SET status = 'failed' WHERE id = $1", [receiptId]);
|
||||
logger.error('Database transaction error in processReceiptItems:', { error, receiptId });
|
||||
throw new Error('Failed to process and save receipt items.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds better deals for items on a recently processed receipt.
|
||||
* @param receiptId The ID of the receipt to check.
|
||||
* @returns A promise that resolves to an array of potential deals.
|
||||
*/
|
||||
export async function findDealsForReceipt(receiptId: number): Promise<ReceiptDeal[]> {
|
||||
const res = await pool.query<ReceiptDeal>('SELECT * FROM public.find_deals_for_receipt_items($1)', [receiptId]);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the owner of a specific receipt.
|
||||
* @param receiptId The ID of the receipt.
|
||||
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function findReceiptOwner(receiptId: number): Promise<{ user_id: string } | undefined> {
|
||||
try {
|
||||
const res = await pool.query<{ user_id: string }>(
|
||||
'SELECT user_id FROM public.receipts WHERE id = $1',
|
||||
[receiptId]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in findReceiptOwner:', { error, receiptId });
|
||||
throw new Error('Failed to retrieve receipt owner from database.');
|
||||
}
|
||||
}
|
||||
386
src/services/db/user.ts
Normal file
386
src/services/db/user.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { pool } from './connection';
|
||||
import { logger } from '../logger';
|
||||
import { Profile, MasterGroceryItem, ShoppingList, ActivityLogItem } from '../../types';
|
||||
import { getShoppingLists } from './shopping';
|
||||
import { getWatchedItems } from './personalization';
|
||||
|
||||
/**
|
||||
* Defines the structure of a user object as returned from the database.
|
||||
*/
|
||||
interface DbUser {
|
||||
id: string; // UUID
|
||||
email: string;
|
||||
password_hash: string;
|
||||
refresh_token?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user by their email in the public.users table.
|
||||
* @param email The email of the user to find.
|
||||
* @returns A promise that resolves to the user object or undefined if not found.
|
||||
*/
|
||||
export async function findUserByEmail(email: string): Promise<DbUser | undefined> {
|
||||
try {
|
||||
const res = await pool.query<DbUser>(
|
||||
'SELECT id, email, password_hash, refresh_token FROM public.users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in findUserByEmail:', { error });
|
||||
throw new Error('Failed to retrieve user from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user in the public.users table.
|
||||
* @param email The user's email.
|
||||
* @param passwordHash The bcrypt hashed password.
|
||||
* @param profileData An object containing optional full_name and avatar_url for the profile.
|
||||
* @returns A promise that resolves to the newly created user object (id, email).
|
||||
*/
|
||||
export async function createUser(
|
||||
email: string,
|
||||
passwordHash: string | null,
|
||||
profileData: { full_name?: string; avatar_url?: string }
|
||||
): Promise<{ id: string; email: string }> {
|
||||
// Use a client from the pool to run multiple queries in a transaction
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Start the transaction
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Set a temporary session variable with the user metadata.
|
||||
// The 'handle_new_user' trigger will read this variable.
|
||||
// We stringify the object to pass it as a single JSONB value.
|
||||
await client.query('SET LOCAL my_app.user_metadata = $1', [JSON.stringify(profileData)]);
|
||||
|
||||
// Insert the new user into the 'users' table. This will fire the trigger.
|
||||
const res = await client.query<{ id: string; email: string }>(
|
||||
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING id, email',
|
||||
[email, passwordHash]
|
||||
);
|
||||
|
||||
// Commit the transaction
|
||||
await client.query('COMMIT');
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
// If any query fails, roll back the entire transaction
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Database transaction error in createUser:', { error });
|
||||
throw new Error('Failed to create user in database.');
|
||||
} finally {
|
||||
// Release the client back to the pool
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user by their ID. Used by the JWT strategy to validate tokens.
|
||||
* @param id The UUID of the user to find.
|
||||
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function findUserById(id: string): Promise<{ id: string; email: string } | undefined> {
|
||||
try {
|
||||
const res = await pool.query<{ id: string; email: string }>(
|
||||
'SELECT id, email FROM public.users WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in findUserById:', { error });
|
||||
throw new Error('Failed to retrieve user by ID from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user's profile by their user ID.
|
||||
* @param id The UUID of the user.
|
||||
* @returns A promise that resolves to the user's profile object or undefined if not found.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function findUserProfileById(id: string): Promise<Profile | undefined> {
|
||||
try {
|
||||
// This query assumes your 'profiles' table has a foreign key 'id' referencing 'users.id'
|
||||
const res = await pool.query<Profile>(
|
||||
'SELECT id, full_name, avatar_url, preferences, role FROM public.profiles WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in findUserProfileById:', { error });
|
||||
throw new Error('Failed to retrieve user profile from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the profile for a given user.
|
||||
* @param id The UUID of the user.
|
||||
* @param profileData The profile data to update (e.g., full_name, avatar_url).
|
||||
* @returns A promise that resolves to the updated profile object.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function updateUserProfile(id: string, profileData: { full_name?: string; avatar_url?: string }): Promise<Profile> {
|
||||
try {
|
||||
const res = await pool.query<Profile>(
|
||||
`UPDATE public.profiles
|
||||
SET full_name = COALESCE($1, full_name),
|
||||
avatar_url = COALESCE($2, avatar_url),
|
||||
updated_by = $3
|
||||
WHERE id = $3 -- Use the same parameter for updated_by and the WHERE clause
|
||||
RETURNING id, full_name, avatar_url, preferences, role`,
|
||||
[profileData.full_name, profileData.avatar_url, id]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateUserProfile:', { error });
|
||||
throw new Error('Failed to update user profile in database.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the preferences for a given user.
|
||||
* The `pg` driver automatically handles serializing the JS object to JSONB.
|
||||
* @param id The UUID of the user.
|
||||
* @param preferences The preferences object to save.
|
||||
* @returns A promise that resolves to the updated profile object.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function updateUserPreferences(id: string, preferences: Profile['preferences']): Promise<Profile> {
|
||||
try {
|
||||
const res = await pool.query<Profile>(
|
||||
`UPDATE public.profiles
|
||||
SET preferences = preferences || $1, updated_at = now()
|
||||
WHERE id = $2
|
||||
RETURNING id, full_name, avatar_url, preferences, role`,
|
||||
[preferences, id]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateUserPreferences:', { error });
|
||||
throw new Error('Failed to update user preferences in database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the password hash for a given user.
|
||||
* @param id The UUID of the user.
|
||||
* @param passwordHash The new bcrypt hashed password.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function updateUserPassword(id: string, passwordHash: string): Promise<void> {
|
||||
try {
|
||||
await pool.query(
|
||||
'UPDATE public.users SET password_hash = $1 WHERE id = $2',
|
||||
[passwordHash, id]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Database error in updateUserPassword:', { error });
|
||||
throw new Error('Failed to update user password in database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user from the database by their ID.
|
||||
* @param id The UUID of the user to delete.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function deleteUserById(id: string): Promise<void> {
|
||||
try {
|
||||
await pool.query('DELETE FROM public.users WHERE id = $1', [id]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in deleteUserById:', { error });
|
||||
throw new Error('Failed to delete user from database.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves or updates a refresh token for a user.
|
||||
* @param userId The UUID of the user.
|
||||
* @param refreshToken The new refresh token to save.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function saveRefreshToken(userId: string, refreshToken: string): Promise<void> {
|
||||
try {
|
||||
// For simplicity, we store one token per user. For multi-device support, a separate table is better.
|
||||
await pool.query(
|
||||
'UPDATE public.users SET refresh_token = $1 WHERE id = $2',
|
||||
[refreshToken, userId]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Database error in saveRefreshToken:', { error });
|
||||
throw new Error('Failed to save refresh token.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user by their refresh token.
|
||||
* @param refreshToken The refresh token to look up.
|
||||
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function findUserByRefreshToken(refreshToken: string): Promise<{ id: string; email: string } | undefined> {
|
||||
try {
|
||||
const res = await pool.query<{ id: string; email: string }>(
|
||||
'SELECT id, email FROM public.users WHERE refresh_token = $1',
|
||||
[refreshToken]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in findUserByRefreshToken:', { error });
|
||||
return undefined; // Return undefined on error to prevent token leakage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a password reset token for a user.
|
||||
* @param userId The UUID of the user.
|
||||
* @param tokenHash The hashed version of the reset token.
|
||||
* @param expiresAt The timestamp when the token expires.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
||||
try {
|
||||
// First, delete any existing tokens for this user to ensure only one is active.
|
||||
await pool.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
|
||||
// Then, insert the new token.
|
||||
await pool.query(
|
||||
'INSERT INTO public.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
|
||||
[userId, tokenHash, expiresAt]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Database error in createPasswordResetToken:', { error });
|
||||
throw new Error('Failed to create password reset token.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user and token details by the token hash.
|
||||
* It only returns a result if the token has not expired.
|
||||
* @returns A promise that resolves to an array of valid token records.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function getValidResetTokens(): Promise<{ user_id: string; token_hash: string; expires_at: Date }[]> {
|
||||
try {
|
||||
const res = await pool.query<{ user_id: string; token_hash: string; expires_at: Date }>(
|
||||
'SELECT user_id, token_hash, expires_at FROM public.password_reset_tokens WHERE expires_at > NOW()'
|
||||
);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getValidResetTokens:', { error });
|
||||
throw new Error('Failed to retrieve valid reset tokens.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a password reset token by its hash.
|
||||
* This is used after a token has been successfully used to reset a password.
|
||||
* @param tokenHash The hashed token to delete.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function deleteResetToken(tokenHash: string): Promise<void> {
|
||||
try {
|
||||
await pool.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in deleteResetToken:', { error });
|
||||
// We don't throw here, as failing to delete an expired token is not a critical failure for the user flow.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers all data associated with a specific user for export.
|
||||
* @param userId The UUID of the user.
|
||||
* @returns A promise that resolves to an object containing all user data.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export async function exportUserData(userId: string): Promise<{ profile: Profile; watchedItems: MasterGroceryItem[]; shoppingLists: ShoppingList[] }> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Run queries in parallel for efficiency
|
||||
const profileQuery = findUserProfileById(userId);
|
||||
const watchedItemsQuery = getWatchedItems(userId);
|
||||
const shoppingListsQuery = getShoppingLists(userId);
|
||||
|
||||
const [profile, watchedItems, shoppingLists] = await Promise.all([profileQuery, watchedItemsQuery, shoppingListsQuery]);
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('User profile not found for data export.');
|
||||
}
|
||||
|
||||
return { profile, watchedItems, shoppingLists };
|
||||
} catch (error) {
|
||||
logger.error('Database error in exportUserData:', { error, userId });
|
||||
throw new Error('Failed to export user data.');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a following relationship between two users.
|
||||
* @param followerId The ID of the user who is following.
|
||||
* @param followingId The ID of the user being followed.
|
||||
*/
|
||||
export async function followUser(followerId: string, followingId: string): Promise<void> {
|
||||
if (followerId === followingId) {
|
||||
throw new Error('User cannot follow themselves.');
|
||||
}
|
||||
try {
|
||||
await pool.query(
|
||||
'INSERT INTO public.user_follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[followerId, followingId]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Database error in followUser:', { error });
|
||||
throw new Error('Failed to follow user.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a following relationship between two users.
|
||||
* @param followerId The ID of the user who is unfollowing.
|
||||
* @param followingId The ID of the user being unfollowed.
|
||||
*/
|
||||
export async function unfollowUser(followerId: string, followingId: string): Promise<void> {
|
||||
try {
|
||||
await pool.query('DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2', [followerId, followingId]);
|
||||
} catch (error) {
|
||||
logger.error('Database error in unfollowUser:', { error });
|
||||
throw new Error('Failed to unfollow user.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a personalized activity feed for a user based on who they follow.
|
||||
* @param userId The ID of the user.
|
||||
* @param limit The number of feed items to retrieve.
|
||||
* @param offset The number of feed items to skip for pagination.
|
||||
* @returns A promise that resolves to an array of ActivityLogItem objects.
|
||||
*/
|
||||
export async function getUserFeed(userId: string, limit: number, offset: number): Promise<ActivityLogItem[]> {
|
||||
try {
|
||||
const res = await pool.query<ActivityLogItem>('SELECT * FROM public.get_user_feed($1, $2, $3)', [userId, limit, offset]);
|
||||
return res.rows;
|
||||
} catch (error) {
|
||||
logger.error('Database error in getUserFeed:', { error, userId });
|
||||
throw new Error('Failed to retrieve user feed.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a user's search query for analytics purposes.
|
||||
* @param query An object containing the search query details.
|
||||
*/
|
||||
export async function logSearchQuery(query: { userId?: string, queryText: string, resultCount: number, wasSuccessful: boolean }): Promise<void> {
|
||||
try {
|
||||
await pool.query(
|
||||
'INSERT INTO public.search_queries (user_id, query_text, result_count, was_successful) VALUES ($1, $2, $3, $4)',
|
||||
[query.userId, query.queryText, query.resultCount, query.wasSuccessful]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Database error in logSearchQuery:', { error });
|
||||
// Also a non-critical operation.
|
||||
}
|
||||
}
|
||||
@@ -78,4 +78,53 @@ The Flyer Crawler Team
|
||||
// Re-throw the error so the calling function knows the email failed to send.
|
||||
throw new Error('Failed to send password reset email.');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a welcome email to a new user.
|
||||
* @param to The recipient's email address.
|
||||
* @param name The user's name to personalize the email. Can be undefined.
|
||||
*/
|
||||
export const sendWelcomeEmail = async (to: string, name: string | undefined | null): Promise<void> => {
|
||||
// Provide a fallback for the name to make the greeting more friendly.
|
||||
const userName = name || 'there';
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
const mailOptions = {
|
||||
from: `"Flyer Crawler" <${fromAddress}>`,
|
||||
to: to,
|
||||
subject: 'Welcome to Flyer Crawler!',
|
||||
text: `
|
||||
Hello ${userName},
|
||||
|
||||
Welcome to Flyer Crawler! We're excited to have you on board.
|
||||
|
||||
Start exploring the latest deals and manage your shopping lists to save time and money.
|
||||
|
||||
Happy shopping!
|
||||
|
||||
The Flyer Crawler Team
|
||||
`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; line-height: 1.6;">
|
||||
<h2>Welcome to Flyer Crawler!</h2>
|
||||
<p>Hello ${userName},</p>
|
||||
<p>We're excited to have you on board. Start exploring the latest deals, manage your shopping lists, and make your grocery shopping smarter and easier.</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="${frontendUrl}" style="background-color: #28a745; color: white; padding: 12px 20px; text-decoration: none; border-radius: 5px; font-weight: bold;">Get Started</a>
|
||||
</p>
|
||||
<p>Happy shopping!</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="font-size: 0.9em; color: #777;">The Flyer Crawler Team</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
logger.info(`Welcome email sent to ${to}. Message ID: ${info.messageId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send welcome email to ${to}`, { error });
|
||||
throw new Error('Failed to send welcome email.');
|
||||
}
|
||||
};
|
||||
89
src/types.ts
89
src/types.ts
@@ -389,7 +389,7 @@ export interface UserFollow {
|
||||
|
||||
export interface UnmatchedFlyerItem {
|
||||
id: number;
|
||||
status: 'pending' | 'reviewed' | 'ignored';
|
||||
status: 'pending' | 'resolved' | 'ignored';
|
||||
created_at: string;
|
||||
flyer_item_id: number;
|
||||
flyer_item_name: string;
|
||||
@@ -560,4 +560,91 @@ export interface RawFlyerItem {
|
||||
category: string;
|
||||
master_item_id: number | null;
|
||||
unit_price?: UnitPrice | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the data extracted from a receipt image by the AI service.
|
||||
*/
|
||||
export interface ExtractedReceiptData {
|
||||
raw_text: string;
|
||||
items: {
|
||||
raw_item_description: string;
|
||||
quantity: number;
|
||||
price_paid_cents: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an item that frequently appears on sale.
|
||||
* Returned by the `get_most_frequent_sale_items` database function.
|
||||
*/
|
||||
export interface MostFrequentSaleItem {
|
||||
master_item_id: number;
|
||||
item_name: string;
|
||||
sale_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a recipe that can be made from items in a user's pantry.
|
||||
* Returned by the `find_recipes_from_pantry` database function.
|
||||
*/
|
||||
export interface PantryRecipe extends Recipe {
|
||||
missing_ingredients_count: number;
|
||||
pantry_ingredients_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a recommended recipe for a user.
|
||||
* Returned by the `recommend_recipes_for_user` database function.
|
||||
*/
|
||||
export interface RecommendedRecipe extends Recipe {
|
||||
recommendation_score: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the best current sale price for a user's watched item.
|
||||
* Returned by the `get_best_sale_prices_for_user` database function.
|
||||
*/
|
||||
export interface WatchedItemDeal {
|
||||
master_item_id: number;
|
||||
item_name: string;
|
||||
best_price_in_cents: number;
|
||||
store_name: string;
|
||||
flyer_id: number;
|
||||
valid_to: string; // Date string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a suggested unit conversion for a pantry item.
|
||||
* Returned by the `suggest_pantry_item_conversions` database function.
|
||||
*/
|
||||
export interface PantryItemConversion {
|
||||
to_unit: string;
|
||||
converted_quantity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an item needed for a menu plan, considering pantry stock.
|
||||
* Returned by `generate_shopping_list_for_menu_plan` and `add_menu_plan_to_shopping_list`.
|
||||
*/
|
||||
export interface MenuPlanShoppingListItem {
|
||||
master_item_id: number;
|
||||
item_name: string;
|
||||
quantity_needed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an unmatched flyer item pending manual review.
|
||||
* Returned by `getUnmatchedFlyerItems`.
|
||||
*/
|
||||
export interface UnmatchedFlyerItem {
|
||||
id: number;
|
||||
status: 'pending' | 'resolved' | 'ignored';
|
||||
created_at: string; // Date string
|
||||
flyer_item_id: number;
|
||||
flyer_item_name: string;
|
||||
price_display: string;
|
||||
flyer_id: number;
|
||||
store_name: string;
|
||||
}
|
||||
Reference in New Issue
Block a user