more ts and break apart big ass files
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Has been cancelled

This commit is contained in:
2025-11-21 00:30:47 -08:00
parent 69be398cd9
commit 4f87a3ee84
20 changed files with 3770 additions and 1735 deletions

View File

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

1668
server.ts

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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;

View File

@@ -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
View 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.');
}
}

View 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
View 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
View 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';

View 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
View 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
View 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
View 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.
}
}

View File

@@ -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.');
}
};

View File

@@ -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;
}