// src/routes/user.ts import express, { Request, Response } from 'express'; import passport from './passport'; import multer from 'multer'; import path from 'path'; import fs from 'fs/promises'; import * as bcrypt from 'bcrypt'; import zxcvbn from 'zxcvbn'; import * as db from '../services/db/index.db'; import { logger } from '../services/logger.server'; import { User, UserProfile, Address } from '../types'; const router = express.Router(); // Apply the JWT authentication middleware to all routes in this file. // Any request to a /api/users/* endpoint will now require a valid JWT. router.use(passport.authenticate('jwt', { session: false })); // Ensure the directory for avatar uploads exists. const avatarUploadDir = path.join(process.cwd(), 'public', 'uploads', 'avatars'); fs.mkdir(avatarUploadDir, { recursive: true }).catch(err => { logger.error('Failed to create avatar upload directory:', err); }); /** * POST /api/users/profile/avatar - Upload a new avatar for the authenticated user. */ router.post( '/profile/avatar', async (req, res, next) => { // Initialize multer inside the route handler where req.user is available. // This prevents the "Cannot read properties of undefined" error during test setup. const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, avatarUploadDir), filename: (req, file, cb) => { const user = req.user as User; // This code now runs safely because passport has already populated req.user. const uniqueSuffix = `${user.user_id}-${Date.now()}${path.extname(file.originalname)}`; cb(null, uniqueSuffix); }, }); const upload = multer({ storage: storage, limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only image files are allowed!')); } }, }).single('avatar'); // Manually invoke the multer middleware. upload(req, res, async (err) => { if (err) return next(err); if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' }); const user = req.user as User; // Construct the public URL for the uploaded file const avatarUrl = `/uploads/avatars/${req.file.filename}`; // Update the user's profile in the database with the new URL const updatedProfile = await db.updateUserProfile(user.user_id, { avatar_url: avatarUrl }); res.json(updatedProfile); }); } ); /** * GET /api/users/notifications - Get notifications for the authenticated user. * Supports pagination with `limit` and `offset` query parameters. */ router.get( '/notifications', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { const user = req.user as User; const limit = parseInt(req.query.limit as string, 10) || 20; const offset = parseInt(req.query.offset as string, 10) || 0; const notifications = await db.getNotificationsForUser(user.user_id, limit, offset); res.json(notifications); } ); /** * POST /api/users/notifications/mark-all-read - Mark all of the user's notifications as read. */ router.post( '/notifications/mark-all-read', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { const user = req.user as User; await db.markAllNotificationsAsRead(user.user_id); res.status(204).send(); // No Content } ); /** * POST /api/users/notifications/:notificationId/mark-read - Mark a single notification as read. */ router.post( '/notifications/:notificationId/mark-read', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { const user = req.user as User; const notificationId = parseInt(req.params.notificationId, 10); if (isNaN(notificationId)) { return res.status(400).json({ message: 'Invalid notification ID.' }); } await db.markNotificationAsRead(notificationId, user.user_id); res.status(204).send(); // Success, no content to return } ); /** * GET /api/users/profile - Get the full profile for the authenticated user. */ router.get('/profile', async (req, res, next) => { logger.debug(`[ROUTE] GET /api/users/profile - ENTER`); const user = req.user as UserProfile; try { logger.debug(`[ROUTE] Calling db.findUserProfileById for user: ${user.user_id}`); const userProfile = await db.findUserProfileById(user.user_id); if (!userProfile) { logger.warn(`[ROUTE] GET /api/users/profile - Profile not found in DB for user ID: ${user.user_id}`); return res.status(404).json({ message: 'Profile not found for this user.' }); } res.json(userProfile); } catch (error) { logger.error(`[ROUTE] GET /api/users/profile - ERROR`, { error }); next(error); } }); /** * PUT /api/users/profile - Update the user's profile information. */ router.put('/profile', async (req, res, next) => { logger.debug(`[ROUTE] PUT /api/users/profile - ENTER`); const user = req.user as UserProfile; const { full_name, avatar_url } = req.body; if (!full_name && !avatar_url) { return res.status(400).json({ message: 'At least one field to update must be provided.' }); } try { const updatedProfile = await db.updateUserProfile(user.user_id, { full_name, avatar_url }); res.json(updatedProfile); } catch (error) { logger.error(`[ROUTE] PUT /api/users/profile - ERROR`, { error }); next(error); } }); /** * PUT /api/users/profile/password - Update the user's password. */ router.put('/profile/password', async (req, res, next) => { logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`); const user = req.user as UserProfile; const { newPassword } = req.body; if (!newPassword) { return res.status(400).json({ message: 'New password is required.' }); } const MIN_PASSWORD_SCORE = 3; const strength = zxcvbn(newPassword); if (strength.score < MIN_PASSWORD_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 || ''}`.trim() }); } try { const saltRounds = 10; const hashedPassword = await bcrypt.hash(newPassword, saltRounds); await db.updateUserPassword(user.user_id, hashedPassword); res.status(200).json({ message: 'Password updated successfully.' }); } catch (error) { logger.error(`[ROUTE] PUT /api/users/profile/password - ERROR`, { error }); next(error); } }); /** * DELETE /api/users/account - Delete the user's own account. */ router.delete('/account', async (req, res, next) => { logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`); const user = req.user as UserProfile; const { password } = req.body; if (!password) { return res.status(400).json({ message: 'Password is required to confirm account deletion.' }); } try { const userWithHash = await db.findUserWithPasswordHashById(user.user_id); if (!userWithHash || !userWithHash.password_hash) { return res.status(404).json({ message: 'User not found or password not set.' }); } const isMatch = await bcrypt.compare(password, userWithHash.password_hash); if (!isMatch) { return res.status(403).json({ message: 'Incorrect password.' }); } await db.deleteUserById(user.user_id); res.status(200).json({ message: 'Account deleted successfully.' }); } catch (error) { logger.error(`[ROUTE] DELETE /api/users/account - ERROR`, { error }); next(error); } }); /** * GET /api/users/watched-items - Get all watched items for the authenticated user. */ router.get('/watched-items', async (req, res, next) => { logger.debug(`[ROUTE] GET /api/users/watched-items - ENTER`); const user = req.user as UserProfile; try { const items = await db.getWatchedItems(user.user_id); res.json(items); } catch (error) { logger.error(`[ROUTE] GET /api/users/watched-items - ERROR`, { error }); next(error); } }); /** * POST /api/users/watched-items - Add a new item to the user's watchlist. */ router.post('/watched-items', async (req, res, next) => { logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`); const user = req.user as UserProfile; const { itemName, category } = req.body; try { const newItem = await db.addWatchedItem(user.user_id, itemName, category); res.status(201).json(newItem); } catch (error) { logger.error(`[ROUTE] POST /api/users/watched-items - ERROR`, { error }); next(error); } }); /** * DELETE /api/users/watched-items/:masterItemId - Remove an item from the watchlist. */ router.delete('/watched-items/:masterItemId', async (req, res, next) => { logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`); const user = req.user as UserProfile; const masterItemId = parseInt(req.params.masterItemId, 10); try { await db.removeWatchedItem(user.user_id, masterItemId); res.status(204).send(); } catch (error) { logger.error(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`, { error }); next(error); } }); /** * GET /api/users/shopping-lists - Get all shopping lists for the user. */ router.get('/shopping-lists', async (req, res, next) => { logger.debug(`[ROUTE] GET /api/users/shopping-lists - ENTER`); const user = req.user as UserProfile; try { const lists = await db.getShoppingLists(user.user_id); res.json(lists); } catch (error) { logger.error(`[ROUTE] GET /api/users/shopping-lists - ERROR`, { error }); next(error); } }); /** * POST /api/users/shopping-lists - Create a new shopping list. */ router.post('/shopping-lists', async (req, res, next) => { logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`); const user = req.user as UserProfile; const { name } = req.body; try { const newList = await db.createShoppingList(user.user_id, name); res.status(201).json(newList); } catch (error) { logger.error(`[ROUTE] POST /api/users/shopping-lists - ERROR`, { error }); next(error); } }); /** * DELETE /api/users/shopping-lists/:listId - Delete a shopping list. */ router.delete('/shopping-lists/:listId', async (req, res, next) => { logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`); const user = req.user as UserProfile; const listId = parseInt(req.params.listId, 10); try { await db.deleteShoppingList(listId, user.user_id); res.status(204).send(); } catch (error) { logger.error(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ERROR`, { error }); next(error); } }); /** * POST /api/users/shopping-lists/:listId/items - Add an item to a shopping list. */ router.post('/shopping-lists/:listId/items', async (req, res, next) => { logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`); const listId = parseInt(req.params.listId, 10); try { const newItem = await db.addShoppingListItem(listId, req.body); res.status(201).json(newItem); } catch (error) { logger.error(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ERROR`, { error }); next(error); } }); /** * PUT /api/users/shopping-lists/items/:itemId - Update a shopping list item. */ router.put('/shopping-lists/items/:itemId', async (req, res, next) => { logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`); const itemId = parseInt(req.params.itemId, 10); try { const updatedItem = await db.updateShoppingListItem(itemId, req.body); res.json(updatedItem); } catch (error) { logger.error(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ERROR`, { error }); next(error); } }); /** * DELETE /api/users/shopping-lists/items/:itemId - Remove an item from a shopping list. */ router.delete('/shopping-lists/items/:itemId', async (req, res, next) => { logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`); const itemId = parseInt(req.params.itemId, 10); try { await db.removeShoppingListItem(itemId); res.status(204).send(); } catch (error) { logger.error(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ERROR`, { error }); next(error); } }); /** * PUT /api/users/profile/preferences - Update user preferences. */ router.put('/profile/preferences', async (req, res, next) => { logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`); const user = req.user as UserProfile; if (typeof req.body !== 'object' || req.body === null || Array.isArray(req.body)) { return res.status(400).json({ message: 'Invalid preferences format. Body must be a JSON object.' }); } try { const updatedProfile = await db.updateUserPreferences(user.user_id, req.body); res.json(updatedProfile); } catch (error) { logger.error(`[ROUTE] PUT /api/users/profile/preferences - ERROR`, { error }); next(error); } }); router.get('/me/dietary-restrictions', async (req, res, next) => { logger.debug(`[ROUTE] GET /api/users/me/dietary-restrictions - ENTER`); const user = req.user as UserProfile; try { const restrictions = await db.getUserDietaryRestrictions(user.user_id); res.json(restrictions); } catch (error) { logger.error(`[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`, { error }); next(error); } }); router.put('/me/dietary-restrictions', async (req, res, next) => { logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`); const user = req.user as UserProfile; const { restrictionIds } = req.body; try { await db.setUserDietaryRestrictions(user.user_id, restrictionIds); res.status(204).send(); } catch (error) { logger.error(`[ROUTE] PUT /api/users/me/dietary-restrictions - ERROR`, { error }); next(error); } }); router.get('/me/appliances', async (req, res, next) => { logger.debug(`[ROUTE] GET /api/users/me/appliances - ENTER`); const user = req.user as UserProfile; try { const appliances = await db.getUserAppliances(user.user_id); res.json(appliances); } catch (error) { logger.error(`[ROUTE] GET /api/users/me/appliances - ERROR`, { error }); next(error); } }); router.put('/me/appliances', async (req, res, next) => { logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`); const user = req.user as UserProfile; const { applianceIds } = req.body; try { await db.setUserAppliances(user.user_id, applianceIds); res.status(204).send(); } catch (error) { logger.error(`[ROUTE] PUT /api/users/me/appliances - ERROR`, { error }); next(error); } }); /** * GET /api/users/addresses/:addressId - Get a specific address by its ID. * This is protected to ensure a user can only fetch their own address details. */ router.get('/addresses/:addressId', async (req, res, next) => { const user = req.user as UserProfile; const addressId = parseInt(req.params.addressId, 10); // Security check: Ensure the requested addressId matches the one on the user's profile. if (user.address_id !== addressId) { return res.status(403).json({ message: 'Forbidden: You can only access your own address.' }); } try { const address = await db.getAddressById(addressId); if (!address) { return res.status(404).json({ message: 'Address not found.' }); } res.json(address); } catch (error) { next(error); } }); /** * PUT /api/users/profile/address - Create or update the user's primary address. */ router.put('/profile/address', async (req, res, next) => { const user = req.user as UserProfile; const addressData = req.body as Partial
; try { const addressId = await db.upsertAddress({ ...addressData, address_id: user.address_id ?? undefined }); // If the user didn't have an address_id before, update their profile to link it. if (!user.address_id) { await db.updateUserProfile(user.user_id, { address_id: addressId }); } res.status(200).json({ message: 'Address updated successfully', address_id: addressId }); } catch (error) { next(error); } }); export default router;