// src/routes/user.routes.ts import express, { Request, Response, NextFunction } from 'express'; import passport from './passport.routes'; import multer from 'multer'; // Keep for MulterError type check import fs from 'node:fs/promises'; import * as bcrypt from 'bcrypt'; // This was a duplicate, fixed. import { z } from 'zod'; import { logger } from '../services/logger.server'; import { UserProfile } from '../types'; import { createUploadMiddleware, handleMulterError, } from '../middleware/multer.middleware'; import { userService } from '../services/userService'; import { ForeignKeyConstraintError } from '../services/db/errors.db'; import { validateRequest } from '../middleware/validation.middleware'; import { validatePasswordStrength } from '../utils/authUtils'; import { requiredString, numericIdParam, optionalNumeric, optionalBoolean, } from '../utils/zodUtils'; import * as db from '../services/db/index.db'; /** * Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist. * @param file The multer file object to delete. */ const cleanupUploadedFile = async (file?: Express.Multer.File) => { if (!file) return; try { await fs.unlink(file.path); } catch (err) { logger.warn({ err, filePath: file.path }, 'Failed to clean up uploaded avatar file.'); } }; const router = express.Router(); const updateProfileSchema = z.object({ body: z .object({ full_name: z.string().optional(), avatar_url: z.string().url().optional() }) .refine((data) => Object.keys(data).length > 0, { message: 'At least one field to update must be provided.', }), }); const updatePasswordSchema = z.object({ body: z.object({ newPassword: z .string() .min(8, 'Password must be at least 8 characters long.') .superRefine((password, ctx) => { const strength = validatePasswordStrength(password); if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback }); }), }), }); const deleteAccountSchema = z.object({ body: z.object({ password: requiredString("Field 'password' is required.") }), }); const addWatchedItemSchema = z.object({ body: z.object({ itemName: requiredString("Field 'itemName' is required."), category: requiredString("Field 'category' is required."), }), }); const createShoppingListSchema = z.object({ body: z.object({ name: requiredString("Field 'name' is required.") }), }); // Apply the JWT authentication middleware to all routes in this file. const notificationQuerySchema = z.object({ query: z.object({ limit: optionalNumeric({ default: 20, integer: true, positive: true }), offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }), includeRead: optionalBoolean({ default: false }), }), }); // An empty schema for routes that do not expect any input, to maintain a consistent validation pattern. const emptySchema = z.object({}); // Any request to a /api/users/* endpoint will now require a valid JWT. router.use(passport.authenticate('jwt', { session: false })); const avatarUpload = createUploadMiddleware({ storageType: 'avatar', fileSize: 1 * 1024 * 1024, // 1MB fileFilter: 'image', }); /** * POST /api/users/profile/avatar - Upload a new avatar for the authenticated user. */ router.post( '/profile/avatar', avatarUpload.single('avatar'), async (req: Request, res: Response, next: NextFunction) => { // The try-catch block was already correct here. try { if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' }); const userProfile = req.user as UserProfile; const avatarUrl = `/uploads/avatars/${req.file.filename}`; const updatedProfile = await db.userRepo.updateUserProfile( userProfile.user.user_id, { avatar_url: avatarUrl }, req.log, ); res.json(updatedProfile); } catch (error) { // If an error occurs after the file has been uploaded (e.g., DB error), // we must clean up the orphaned file from the disk. await cleanupUploadedFile(req.file); logger.error({ error }, 'Error uploading avatar'); next(error); } }, ); /** * GET /api/users/notifications - Get notifications for the authenticated user. * Supports pagination with `limit` and `offset` query parameters. */ type GetNotificationsRequest = z.infer; router.get( '/notifications', validateRequest(notificationQuerySchema), async (req: Request, res: Response, next: NextFunction) => { // Cast to UserProfile to access user properties safely. const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety try { const { query } = req as unknown as GetNotificationsRequest; const parsedQuery = notificationQuerySchema.parse({ query: req.query }).query; const notifications = await db.notificationRepo.getNotificationsForUser( userProfile.user.user_id, parsedQuery.limit!, parsedQuery.offset!, parsedQuery.includeRead!, req.log, ); res.json(notifications); } catch (error) { logger.error({ error }, 'Error fetching notifications'); next(error); } }, ); /** * POST /api/users/notifications/mark-all-read - Mark all of the user's notifications as read. */ router.post( '/notifications/mark-all-read', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => { try { const userProfile = req.user as UserProfile; await db.notificationRepo.markAllNotificationsAsRead(userProfile.user.user_id, req.log); res.status(204).send(); // No Content } catch (error) { logger.error({ error }, 'Error marking all notifications as read'); next(error); } }, ); /** * POST /api/users/notifications/:notificationId/mark-read - Mark a single notification as read. */ const notificationIdSchema = numericIdParam('notificationId'); type MarkNotificationReadRequest = z.infer; router.post( '/notifications/:notificationId/mark-read', validateRequest(notificationIdSchema), async (req: Request, res: Response, next: NextFunction) => { try { const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { params } = req as unknown as MarkNotificationReadRequest; await db.notificationRepo.markNotificationAsRead( params.notificationId, userProfile.user.user_id, req.log, ); res.status(204).send(); // Success, no content to return } catch (error) { logger.error({ error }, 'Error marking notification as read'); next(error); } }, ); /** * GET /api/users/profile - Get the full profile for the authenticated user. */ router.get('/profile', validateRequest(emptySchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] GET /api/users/profile - ENTER`); const userProfile = req.user as UserProfile; try { logger.debug( `[ROUTE] Calling db.userRepo.findUserProfileById for user: ${userProfile.user.user_id}`, ); const fullUserProfile = await db.userRepo.findUserProfileById( userProfile.user.user_id, req.log, ); res.json(fullUserProfile); } catch (error) { logger.error({ error }, `[ROUTE] GET /api/users/profile - ERROR`); next(error); } }); /** * PUT /api/users/profile - Update the user's profile information. */ type UpdateProfileRequest = z.infer; router.put( '/profile', validateRequest(updateProfileSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] PUT /api/users/profile - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { body } = req as unknown as UpdateProfileRequest; try { const updatedProfile = await db.userRepo.updateUserProfile( userProfile.user.user_id, body, req.log, ); res.json(updatedProfile); } catch (error) { logger.error({ error }, `[ROUTE] PUT /api/users/profile - ERROR`); next(error); } }, ); /** * PUT /api/users/profile/password - Update the user's password. */ type UpdatePasswordRequest = z.infer; router.put( '/profile/password', validateRequest(updatePasswordSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { body } = req as unknown as UpdatePasswordRequest; try { const saltRounds = 10; const hashedPassword = await bcrypt.hash(body.newPassword, saltRounds); await db.userRepo.updateUserPassword(userProfile.user.user_id, hashedPassword, req.log); res.status(200).json({ message: 'Password updated successfully.' }); } catch (error) { logger.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`); next(error); } }, ); /** * DELETE /api/users/account - Delete the user's own account. */ type DeleteAccountRequest = z.infer; router.delete( '/account', validateRequest(deleteAccountSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { body } = req as unknown as DeleteAccountRequest; try { const userWithHash = await db.userRepo.findUserWithPasswordHashById( userProfile.user.user_id, req.log, ); if (!userWithHash || !userWithHash.password_hash) { return res.status(404).json({ message: 'User not found or password not set.' }); } const isMatch = await bcrypt.compare(body.password, userWithHash.password_hash); if (!isMatch) { return res.status(403).json({ message: 'Incorrect password.' }); } await db.userRepo.deleteUserById(userProfile.user.user_id, req.log); res.status(200).json({ message: 'Account deleted successfully.' }); } catch (error) { logger.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`); next(error); } }, ); /** * GET /api/users/watched-items - Get all watched items for the authenticated user. */ router.get('/watched-items', validateRequest(emptySchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] GET /api/users/watched-items - ENTER`); const userProfile = req.user as UserProfile; try { const items = await db.personalizationRepo.getWatchedItems(userProfile.user.user_id, req.log); res.json(items); } catch (error) { logger.error({ error }, `[ROUTE] GET /api/users/watched-items - ERROR`); next(error); } }); /** * POST /api/users/watched-items - Add a new item to the user's watchlist. */ type AddWatchedItemRequest = z.infer; router.post( '/watched-items', validateRequest(addWatchedItemSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { body } = req as unknown as AddWatchedItemRequest; try { const newItem = await db.personalizationRepo.addWatchedItem( userProfile.user.user_id, body.itemName, body.category, req.log, ); res.status(201).json(newItem); } catch (error) { if (error instanceof ForeignKeyConstraintError) { return res.status(400).json({ message: error.message }); } logger.error({ error, body: req.body }, 'Failed to add watched item'); next(error); } }, ); /** * DELETE /api/users/watched-items/:masterItemId - Remove an item from the watchlist. */ const watchedItemIdSchema = numericIdParam('masterItemId'); type DeleteWatchedItemRequest = z.infer; router.delete( '/watched-items/:masterItemId', validateRequest(watchedItemIdSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { params } = req as unknown as DeleteWatchedItemRequest; try { await db.personalizationRepo.removeWatchedItem( userProfile.user.user_id, params.masterItemId, req.log, ); res.status(204).send(); } catch (error) { logger.error({ error }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`); next(error); } }, ); /** * GET /api/users/shopping-lists - Get all shopping lists for the user. */ router.get( '/shopping-lists', validateRequest(emptySchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] GET /api/users/shopping-lists - ENTER`); const userProfile = req.user as UserProfile; try { const lists = await db.shoppingRepo.getShoppingLists(userProfile.user.user_id, req.log); res.json(lists); } catch (error) { logger.error({ error }, `[ROUTE] GET /api/users/shopping-lists - ERROR`); next(error); } }, ); /** * GET /api/users/shopping-lists/:listId - Get a single shopping list by its ID. */ const shoppingListIdSchema = numericIdParam('listId'); type GetShoppingListRequest = z.infer; router.get( '/shopping-lists/:listId', validateRequest(shoppingListIdSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] GET /api/users/shopping-lists/:listId - ENTER`); const userProfile = req.user as UserProfile; const { params } = req as unknown as GetShoppingListRequest; try { const list = await db.shoppingRepo.getShoppingListById( params.listId, userProfile.user.user_id, req.log, ); res.json(list); } catch (error) { logger.error( { error, listId: params.listId }, `[ROUTE] GET /api/users/shopping-lists/:listId - ERROR`, ); next(error); } }, ); /** * POST /api/users/shopping-lists - Create a new shopping list. */ type CreateShoppingListRequest = z.infer; router.post( '/shopping-lists', validateRequest(createShoppingListSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { body } = req as unknown as CreateShoppingListRequest; try { const newList = await db.shoppingRepo.createShoppingList( userProfile.user.user_id, body.name, req.log, ); res.status(201).json(newList); } catch (error) { if (error instanceof ForeignKeyConstraintError) { return res.status(400).json({ message: error.message }); } logger.error({ error, body: req.body }, 'Failed to create shopping list'); next(error); } }, ); /** * DELETE /api/users/shopping-lists/:listId - Delete a shopping list. */ router.delete( '/shopping-lists/:listId', validateRequest(shoppingListIdSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { params } = req as unknown as GetShoppingListRequest; try { await db.shoppingRepo.deleteShoppingList(params.listId, userProfile.user.user_id, req.log); res.status(204).send(); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; logger.error( { errorMessage, params: req.params }, `[ROUTE] DELETE /api/users/shopping-lists/:listId - ERROR`, ); next(error); } }, ); /** * POST /api/users/shopping-lists/:listId/items - Add an item to a shopping list. */ const addShoppingListItemSchema = shoppingListIdSchema.extend({ body: z .object({ masterItemId: z.number().int().positive().optional(), customItemName: z.string().min(1, 'customItemName cannot be empty if provided').optional(), }) .refine((data) => data.masterItemId || data.customItemName, { message: 'Either masterItemId or customItemName must be provided.', }), }); type AddShoppingListItemRequest = z.infer; router.post( '/shopping-lists/:listId/items', validateRequest(addShoppingListItemSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`); // Apply ADR-003 pattern for type safety const { params, body } = req as unknown as AddShoppingListItemRequest; try { const newItem = await db.shoppingRepo.addShoppingListItem(params.listId, body, req.log); res.status(201).json(newItem); } catch (error) { if (error instanceof ForeignKeyConstraintError) { return res.status(400).json({ message: error.message }); } logger.error({ error, params: req.params, body: req.body }, 'Failed to add shopping list item'); next(error); } }, ); /** * PUT /api/users/shopping-lists/items/:itemId - Update a shopping list item. */ const updateShoppingListItemSchema = numericIdParam('itemId').extend({ body: z .object({ quantity: z.number().int().nonnegative().optional(), is_purchased: z.boolean().optional(), }) .refine((data) => Object.keys(data).length > 0, { message: 'At least one field (quantity, is_purchased) must be provided.', }), }); type UpdateShoppingListItemRequest = z.infer; router.put( '/shopping-lists/items/:itemId', validateRequest(updateShoppingListItemSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`); // Apply ADR-003 pattern for type safety const { params, body } = req as unknown as UpdateShoppingListItemRequest; try { const updatedItem = await db.shoppingRepo.updateShoppingListItem( params.itemId, body, req.log, ); res.json(updatedItem); } catch (error: unknown) { logger.error( { error, params: req.params, body: req.body }, `[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ERROR`, ); next(error); } }, ); /** * DELETE /api/users/shopping-lists/items/:itemId - Remove an item from a shopping list. */ const shoppingListItemIdSchema = numericIdParam('itemId'); type DeleteShoppingListItemRequest = z.infer; router.delete( '/shopping-lists/items/:itemId', validateRequest(shoppingListItemIdSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`); // Apply ADR-003 pattern for type safety const { params } = req as unknown as DeleteShoppingListItemRequest; try { await db.shoppingRepo.removeShoppingListItem(params.itemId, req.log); res.status(204).send(); } catch (error: unknown) { logger.error( { error, params: req.params }, `[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ERROR`, ); next(error); } }, ); /** * PUT /api/users/profile/preferences - Update user preferences. */ const updatePreferencesSchema = z.object({ body: z.object({}).passthrough(), // Ensures body is an object, allows any properties }); type UpdatePreferencesRequest = z.infer; router.put( '/profile/preferences', validateRequest(updatePreferencesSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { body } = req as unknown as UpdatePreferencesRequest; try { const updatedProfile = await db.userRepo.updateUserPreferences( userProfile.user.user_id, body, req.log, ); res.json(updatedProfile); } catch (error) { logger.error({ error }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`); next(error); } }, ); router.get( '/me/dietary-restrictions', validateRequest(emptySchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] GET /api/users/me/dietary-restrictions - ENTER`); const userProfile = req.user as UserProfile; try { const restrictions = await db.personalizationRepo.getUserDietaryRestrictions( userProfile.user.user_id, req.log, ); res.json(restrictions); } catch (error) { logger.error({ error }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`); next(error); } }, ); const setUserRestrictionsSchema = z.object({ body: z.object({ restrictionIds: z.array(z.number().int().positive()) }), }); type SetUserRestrictionsRequest = z.infer; router.put( '/me/dietary-restrictions', validateRequest(setUserRestrictionsSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { body } = req as unknown as SetUserRestrictionsRequest; try { await db.personalizationRepo.setUserDietaryRestrictions( userProfile.user.user_id, body.restrictionIds, req.log, ); res.status(204).send(); } catch (error) { if (error instanceof ForeignKeyConstraintError) { return res.status(400).json({ message: error.message }); } logger.error({ error, body: req.body }, 'Failed to set user dietary restrictions'); next(error); } }, ); router.get('/me/appliances', validateRequest(emptySchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] GET /api/users/me/appliances - ENTER`); const userProfile = req.user as UserProfile; try { const appliances = await db.personalizationRepo.getUserAppliances( userProfile.user.user_id, req.log, ); res.json(appliances); } catch (error) { logger.error({ error }, `[ROUTE] GET /api/users/me/appliances - ERROR`); next(error); } }); const setUserAppliancesSchema = z.object({ body: z.object({ applianceIds: z.array(z.number().int().positive()) }), }); type SetUserAppliancesRequest = z.infer; router.put( '/me/appliances', validateRequest(setUserAppliancesSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { body } = req as unknown as SetUserAppliancesRequest; try { await db.personalizationRepo.setUserAppliances( userProfile.user.user_id, body.applianceIds, req.log, ); res.status(204).send(); } catch (error) { if (error instanceof ForeignKeyConstraintError) { return res.status(400).json({ message: error.message }); } logger.error({ error, body: req.body }, 'Failed to set user appliances'); 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. */ const addressIdSchema = numericIdParam('addressId'); type GetAddressRequest = z.infer; router.get( '/addresses/:addressId', validateRequest(addressIdSchema), async (req, res, next: NextFunction) => { const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { params } = req as unknown as GetAddressRequest; try { const addressId = params.addressId; // Security check: Ensure the requested addressId matches the one on the user's profile. if (userProfile.address_id !== addressId) { return res .status(403) .json({ message: 'Forbidden: You can only access your own address.' }); } const address = await db.addressRepo.getAddressById(addressId, req.log); // This will throw NotFoundError if not found res.json(address); } catch (error) { logger.error({ error }, 'Error fetching user address'); next(error); } }, ); /** * PUT /api/users/profile/address - Create or update the user's primary address. */ const updateUserAddressSchema = z.object({ body: z .object({ address_line_1: z.string().optional(), address_line_2: z.string().optional(), city: z.string().optional(), province_state: z.string().optional(), postal_code: z.string().optional(), country: z.string().optional(), }) .refine((data) => Object.keys(data).length > 0, { message: 'At least one address field must be provided.', }), }); type UpdateUserAddressRequest = z.infer; router.put( '/profile/address', validateRequest(updateUserAddressSchema), async (req, res, next: NextFunction) => { const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { body: addressData } = req as unknown as UpdateUserAddressRequest; try { // Per ADR-002, complex operations involving multiple database writes should be // encapsulated in a single service method that manages the transaction. // This ensures both the address upsert and the user profile update are atomic. const addressId = await userService.upsertUserAddress(userProfile, addressData, req.log); // This was a duplicate, fixed. res.status(200).json({ message: 'Address updated successfully', address_id: addressId }); } catch (error) { logger.error({ error }, 'Error updating user address'); next(error); } }, ); /** * DELETE /api/users/recipes/:recipeId - Delete a recipe created by the user. */ const recipeIdSchema = numericIdParam('recipeId'); type DeleteRecipeRequest = z.infer; router.delete( '/recipes/:recipeId', validateRequest(recipeIdSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { params } = req as unknown as DeleteRecipeRequest; try { await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, false, req.log); res.status(204).send(); } catch (error) { logger.error( { error, params: req.params }, `[ROUTE] DELETE /api/users/recipes/:recipeId - ERROR`, ); next(error); } }, ); /** * PUT /api/users/recipes/:recipeId - Update a recipe created by the user. */ const updateRecipeSchema = recipeIdSchema.extend({ body: z .object({ name: z.string().optional(), description: z.string().optional(), instructions: z.string().optional(), prep_time_minutes: z.number().int().optional(), cook_time_minutes: z.number().int().optional(), servings: z.number().int().optional(), photo_url: z.string().url().optional(), }) .refine((data) => Object.keys(data).length > 0, { message: 'No fields provided to update.' }), }); type UpdateRecipeRequest = z.infer; router.put( '/recipes/:recipeId', validateRequest(updateRecipeSchema), async (req, res, next: NextFunction) => { logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`); const userProfile = req.user as UserProfile; // Apply ADR-003 pattern for type safety const { params, body } = req as unknown as UpdateRecipeRequest; try { const updatedRecipe = await db.recipeRepo.updateRecipe( params.recipeId, userProfile.user.user_id, body, req.log, ); res.json(updatedRecipe); } catch (error) { logger.error( { error, params: req.params, body: req.body }, `[ROUTE] PUT /api/users/recipes/:recipeId - ERROR`, ); next(error); } }, ); /* Catches errors from multer (e.g., file size, file filter) */ router.use(handleMulterError); export default router;