Files
flyer-crawler.projectium.com/src/routes/user.routes.ts
Torben Sorensen 0ec4cd68d2
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m25s
integration test fixes
2026-01-01 11:35:23 -08:00

826 lines
28 KiB
TypeScript

// 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 { 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';
import { cleanupUploadedFile } from '../utils/fileUtils';
const router = express.Router();
const updateProfileSchema = z.object({
body: z
.object({
full_name: z.string().optional(),
avatar_url: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().trim().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()
.trim() // Trim whitespace from password input.
.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 });
}),
}),
});
// The `requiredString` utility (modified in `zodUtils.ts`) now handles trimming,
// so no changes are needed here, but we are confirming that password trimming
// is now implicitly handled for this schema.
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 {
// The `requireFileUpload` middleware is not used here, so we must check for `req.file`.
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
const userProfile = req.user as UserProfile;
const updatedProfile = await userService.updateUserAvatar(userProfile.user.user_id, req.file, 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<typeof notificationQuerySchema>;
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<typeof notificationIdSchema>;
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<typeof updateProfileSchema>;
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<typeof updatePasswordSchema>;
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 {
await userService.updateUserPassword(userProfile.user.user_id, body.newPassword, 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<typeof deleteAccountSchema>;
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 {
await userService.deleteUserAccount(userProfile.user.user_id, body.password, 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<typeof addWatchedItemSchema>;
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<typeof watchedItemIdSchema>;
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<typeof shoppingListIdSchema>;
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<typeof createShoppingListSchema>;
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()
.trim()
.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<typeof addShoppingListItemSchema>;
router.post(
'/shopping-lists/:listId/items',
validateRequest(addShoppingListItemSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
const userProfile = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as AddShoppingListItemRequest;
try {
const newItem = await db.shoppingRepo.addShoppingListItem(
params.listId,
userProfile.user.user_id,
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<typeof updateShoppingListItemSchema>;
router.put(
'/shopping-lists/items/:itemId',
validateRequest(updateShoppingListItemSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
const userProfile = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as UpdateShoppingListItemRequest;
try {
const updatedItem = await db.shoppingRepo.updateShoppingListItem(
params.itemId,
userProfile.user.user_id,
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<typeof shoppingListItemIdSchema>;
router.delete(
'/shopping-lists/items/:itemId',
validateRequest(shoppingListItemIdSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
const userProfile = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as DeleteShoppingListItemRequest;
try {
await db.shoppingRepo.removeShoppingListItem(params.itemId, userProfile.user.user_id, 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<typeof updatePreferencesSchema>;
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<typeof setUserRestrictionsSchema>;
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<typeof setUserAppliancesSchema>;
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<typeof addressIdSchema>;
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;
const address = await userService.getUserAddress(userProfile, addressId, req.log);
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().trim().optional(),
address_line_2: z.string().trim().optional(),
city: z.string().trim().optional(),
province_state: z.string().trim().optional(),
postal_code: z.string().trim().optional(),
country: z.string().trim().optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one address field must be provided.',
}),
});
type UpdateUserAddressRequest = z.infer<typeof updateUserAddressSchema>;
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<typeof recipeIdSchema>;
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().trim().optional(),
description: z.string().trim().optional(),
instructions: z.string().trim().optional(),
prep_time_minutes: z.number().int().optional(),
cook_time_minutes: z.number().int().optional(),
servings: z.number().int().optional(),
photo_url: z.string().trim().url().optional(),
})
.refine((data) => Object.keys(data).length > 0, { message: 'No fields provided to update.' }),
});
type UpdateRecipeRequest = z.infer<typeof updateRecipeSchema>;
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;