All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m25s
826 lines
28 KiB
TypeScript
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;
|