All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m34s
1779 lines
55 KiB
TypeScript
1779 lines
55 KiB
TypeScript
// src/routes/user.routes.ts
|
|
import express, { Request, Response, NextFunction } from 'express';
|
|
import passport from '../config/passport';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import { z } from 'zod';
|
|
// Removed: import { logger } from '../services/logger.server';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import { UserProfile } from '../types';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import { userService } from '../services/userService';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import { validateRequest } from '../middleware/validation.middleware';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import { validatePasswordStrength } from '../utils/authUtils';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import {
|
|
requiredString,
|
|
numericIdParam,
|
|
optionalNumeric,
|
|
optionalBoolean,
|
|
} from '../utils/zodUtils';
|
|
import * as db from '../services/db/index.db';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import { cleanupUploadedFile } from '../utils/fileUtils';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import {
|
|
userUpdateLimiter,
|
|
userSensitiveUpdateLimiter,
|
|
userUploadLimiter,
|
|
} from '../config/rateLimiters';
|
|
import { sendSuccess, sendNoContent, sendError, ErrorCode } from '../utils/apiResponse';
|
|
|
|
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_id: z.number().int().positive("Field 'category_id' must be a positive integer."),
|
|
}),
|
|
});
|
|
|
|
const createShoppingListSchema = z.object({
|
|
body: z.object({ name: requiredString("Field 'name' is required.") }),
|
|
});
|
|
|
|
const createRecipeSchema = z.object({
|
|
body: z.object({
|
|
name: requiredString("Field 'name' is required."),
|
|
instructions: requiredString("Field 'instructions' is required."),
|
|
description: z.string().trim().optional(),
|
|
prep_time_minutes: z.number().int().nonnegative().optional(),
|
|
cook_time_minutes: z.number().int().nonnegative().optional(),
|
|
servings: z.number().int().positive().optional(),
|
|
photo_url: z.string().trim().url().optional(),
|
|
}),
|
|
});
|
|
|
|
// 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',
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/profile/avatar:
|
|
* post:
|
|
* tags: [Users]
|
|
* summary: Upload user avatar
|
|
* description: Upload a new avatar image for the authenticated user.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* multipart/form-data:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - avatar
|
|
* properties:
|
|
* avatar:
|
|
* type: string
|
|
* format: binary
|
|
* description: Avatar image file (max 1MB)
|
|
* responses:
|
|
* 200:
|
|
* description: Avatar updated successfully
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 400:
|
|
* description: No avatar file uploaded
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
router.post(
|
|
'/profile/avatar',
|
|
userUploadLimiter,
|
|
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 sendError(res, ErrorCode.BAD_REQUEST, 'No avatar file uploaded.', 400);
|
|
const userProfile = req.user as UserProfile;
|
|
const updatedProfile = await userService.updateUserAvatar(
|
|
userProfile.user.user_id,
|
|
req.file,
|
|
req.log,
|
|
);
|
|
sendSuccess(res, 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);
|
|
req.log.error({ error }, 'Error uploading avatar');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/notifications:
|
|
* get:
|
|
* tags: [Users]
|
|
* summary: Get user notifications
|
|
* description: Retrieve notifications for the authenticated user with pagination.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: query
|
|
* name: limit
|
|
* schema:
|
|
* type: integer
|
|
* default: 20
|
|
* description: Maximum number of notifications to return
|
|
* - in: query
|
|
* name: offset
|
|
* schema:
|
|
* type: integer
|
|
* default: 0
|
|
* description: Number of notifications to skip
|
|
* - in: query
|
|
* name: includeRead
|
|
* schema:
|
|
* type: boolean
|
|
* default: false
|
|
* description: Include read notifications in results
|
|
* responses:
|
|
* 200:
|
|
* description: List of notifications
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
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;
|
|
try {
|
|
const parsedQuery = notificationQuerySchema.shape.query.parse(req.query);
|
|
const notifications = await db.notificationRepo.getNotificationsForUser(
|
|
userProfile.user.user_id,
|
|
parsedQuery.limit!,
|
|
parsedQuery.offset!,
|
|
parsedQuery.includeRead!,
|
|
req.log,
|
|
);
|
|
sendSuccess(res, notifications);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching notifications');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/notifications/unread-count:
|
|
* get:
|
|
* tags: [Users]
|
|
* summary: Get unread notification count
|
|
* description: Get the count of unread notifications for the authenticated user. Optimized for navbar badge UI.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: Unread notification count
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* success:
|
|
* type: boolean
|
|
* example: true
|
|
* data:
|
|
* type: object
|
|
* properties:
|
|
* count:
|
|
* type: integer
|
|
* example: 5
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
router.get(
|
|
'/notifications/unread-count',
|
|
validateRequest(emptySchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const userProfile = req.user as UserProfile;
|
|
const count = await db.notificationRepo.getUnreadCount(userProfile.user.user_id, req.log);
|
|
sendSuccess(res, { count });
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching unread notification count');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/notifications/mark-all-read:
|
|
* post:
|
|
* tags: [Users]
|
|
* summary: Mark all notifications as read
|
|
* description: Mark all of the authenticated user's notifications as read.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 204:
|
|
* description: All notifications marked as read
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
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);
|
|
sendNoContent(res);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error marking all notifications as read');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/notifications/{notificationId}/mark-read:
|
|
* post:
|
|
* tags: [Users]
|
|
* summary: Mark single notification as read
|
|
* description: Mark a specific notification as read by its ID.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: notificationId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: ID of the notification to mark as read
|
|
* responses:
|
|
* 204:
|
|
* description: Notification marked as read
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Notification not found
|
|
*/
|
|
const notificationIdSchema = numericIdParam('notificationId');
|
|
type NotificationIdRequest = 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 NotificationIdRequest;
|
|
await db.notificationRepo.markNotificationAsRead(
|
|
params.notificationId,
|
|
userProfile.user.user_id,
|
|
req.log,
|
|
);
|
|
sendNoContent(res);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error marking notification as read');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/notifications/{notificationId}:
|
|
* delete:
|
|
* tags: [Users]
|
|
* summary: Delete a notification
|
|
* description: Delete a specific notification by its ID. Users can only delete their own notifications.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: notificationId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: ID of the notification to delete
|
|
* responses:
|
|
* 204:
|
|
* description: Notification deleted successfully
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Notification not found or user does not have permission
|
|
*/
|
|
router.delete(
|
|
'/notifications/:notificationId',
|
|
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 NotificationIdRequest;
|
|
await db.notificationRepo.deleteNotification(
|
|
params.notificationId,
|
|
userProfile.user.user_id,
|
|
req.log,
|
|
);
|
|
sendNoContent(res);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error deleting notification');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/profile:
|
|
* get:
|
|
* tags: [Users]
|
|
* summary: Get user profile
|
|
* description: Retrieve the full profile for the authenticated user.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: User profile data
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
router.get('/profile', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
|
req.log.debug(`[ROUTE] GET /api/users/profile - ENTER`);
|
|
const userProfile = req.user as UserProfile;
|
|
try {
|
|
req.log.debug(
|
|
`[ROUTE] Calling db.userRepo.findUserProfileById for user: ${userProfile.user.user_id}`,
|
|
);
|
|
const fullUserProfile = await db.userRepo.findUserProfileById(
|
|
userProfile.user.user_id,
|
|
req.log,
|
|
);
|
|
sendSuccess(res, fullUserProfile);
|
|
} catch (error) {
|
|
req.log.error({ error }, `[ROUTE] GET /api/users/profile - ERROR`);
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/profile:
|
|
* put:
|
|
* tags: [Users]
|
|
* summary: Update user profile
|
|
* description: Update the authenticated user's profile information.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* full_name:
|
|
* type: string
|
|
* description: User's full name
|
|
* avatar_url:
|
|
* type: string
|
|
* format: uri
|
|
* description: URL to avatar image
|
|
* responses:
|
|
* 200:
|
|
* description: Profile updated successfully
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 400:
|
|
* description: Validation error - at least one field required
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
type UpdateProfileRequest = z.infer<typeof updateProfileSchema>;
|
|
router.put(
|
|
'/profile',
|
|
userUpdateLimiter,
|
|
validateRequest(updateProfileSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendSuccess(res, updatedProfile);
|
|
} catch (error) {
|
|
req.log.error({ error }, `[ROUTE] PUT /api/users/profile - ERROR`);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/profile/password:
|
|
* put:
|
|
* tags: [Users]
|
|
* summary: Update password
|
|
* description: Update the authenticated user's password.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - newPassword
|
|
* properties:
|
|
* newPassword:
|
|
* type: string
|
|
* minLength: 8
|
|
* description: New password (must meet strength requirements)
|
|
* responses:
|
|
* 200:
|
|
* description: Password updated successfully
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 400:
|
|
* description: Password validation failed
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
type UpdatePasswordRequest = z.infer<typeof updatePasswordSchema>;
|
|
router.put(
|
|
'/profile/password',
|
|
userSensitiveUpdateLimiter,
|
|
validateRequest(updatePasswordSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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);
|
|
sendSuccess(res, { message: 'Password updated successfully.' });
|
|
} catch (error) {
|
|
req.log.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/account:
|
|
* delete:
|
|
* tags: [Users]
|
|
* summary: Delete user account
|
|
* description: Permanently delete the authenticated user's account. Requires password confirmation.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - password
|
|
* properties:
|
|
* password:
|
|
* type: string
|
|
* description: Current password for confirmation
|
|
* responses:
|
|
* 200:
|
|
* description: Account deleted successfully
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized or incorrect password
|
|
*/
|
|
type DeleteAccountRequest = z.infer<typeof deleteAccountSchema>;
|
|
router.delete(
|
|
'/account',
|
|
userSensitiveUpdateLimiter,
|
|
validateRequest(deleteAccountSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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);
|
|
sendSuccess(res, { message: 'Account deleted successfully.' });
|
|
} catch (error) {
|
|
req.log.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/watched-items:
|
|
* get:
|
|
* tags: [Users]
|
|
* summary: Get watched items
|
|
* description: Retrieve all items the authenticated user is watching for price changes.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of watched items
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
router.get('/watched-items', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
|
req.log.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);
|
|
sendSuccess(res, items);
|
|
} catch (error) {
|
|
req.log.error({ error }, `[ROUTE] GET /api/users/watched-items - ERROR`);
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/watched-items:
|
|
* post:
|
|
* tags: [Users]
|
|
* summary: Add watched item
|
|
* description: Add a new item to the user's watchlist for price tracking.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - itemName
|
|
* - category
|
|
* properties:
|
|
* itemName:
|
|
* type: string
|
|
* description: Name of the item to watch
|
|
* category:
|
|
* type: string
|
|
* description: Category of the item
|
|
* responses:
|
|
* 201:
|
|
* description: Item added to watchlist
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 400:
|
|
* description: Validation error
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
type AddWatchedItemRequest = z.infer<typeof addWatchedItemSchema>;
|
|
router.post(
|
|
'/watched-items',
|
|
userUpdateLimiter,
|
|
validateRequest(addWatchedItemSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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_id,
|
|
req.log,
|
|
);
|
|
sendSuccess(res, newItem, 201);
|
|
} catch (error) {
|
|
if (error instanceof ForeignKeyConstraintError) {
|
|
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
|
|
}
|
|
req.log.error({ error, body: req.body }, 'Failed to add watched item');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/watched-items/{masterItemId}:
|
|
* delete:
|
|
* tags: [Users]
|
|
* summary: Remove watched item
|
|
* description: Remove an item from the user's watchlist.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: masterItemId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: ID of the master item to stop watching
|
|
* responses:
|
|
* 204:
|
|
* description: Item removed from watchlist
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Item not found in watchlist
|
|
*/
|
|
const watchedItemIdSchema = numericIdParam('masterItemId');
|
|
type DeleteWatchedItemRequest = z.infer<typeof watchedItemIdSchema>;
|
|
router.delete(
|
|
'/watched-items/:masterItemId',
|
|
userUpdateLimiter,
|
|
validateRequest(watchedItemIdSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendNoContent(res);
|
|
} catch (error) {
|
|
req.log.error({ error }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/shopping-lists:
|
|
* get:
|
|
* tags: [Users]
|
|
* summary: Get shopping lists
|
|
* description: Retrieve all shopping lists for the authenticated user.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of shopping lists
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
router.get(
|
|
'/shopping-lists',
|
|
validateRequest(emptySchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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);
|
|
sendSuccess(res, lists);
|
|
} catch (error) {
|
|
req.log.error({ error }, `[ROUTE] GET /api/users/shopping-lists - ERROR`);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/shopping-lists/{listId}:
|
|
* get:
|
|
* tags: [Users]
|
|
* summary: Get shopping list by ID
|
|
* description: Retrieve a specific shopping list with all its items.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: listId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Shopping list ID
|
|
* responses:
|
|
* 200:
|
|
* description: Shopping list with items
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Shopping list not found
|
|
*/
|
|
const shoppingListIdSchema = numericIdParam('listId');
|
|
type GetShoppingListRequest = z.infer<typeof shoppingListIdSchema>;
|
|
router.get(
|
|
'/shopping-lists/:listId',
|
|
validateRequest(shoppingListIdSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendSuccess(res, list);
|
|
} catch (error) {
|
|
req.log.error(
|
|
{ error, listId: params.listId },
|
|
`[ROUTE] GET /api/users/shopping-lists/:listId - ERROR`,
|
|
);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/shopping-lists:
|
|
* post:
|
|
* tags: [Users]
|
|
* summary: Create shopping list
|
|
* description: Create a new shopping list for the authenticated user.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - name
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* description: Name of the shopping list
|
|
* responses:
|
|
* 201:
|
|
* description: Shopping list created
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 400:
|
|
* description: Validation error
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
type CreateShoppingListRequest = z.infer<typeof createShoppingListSchema>;
|
|
router.post(
|
|
'/shopping-lists',
|
|
userUpdateLimiter,
|
|
validateRequest(createShoppingListSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendSuccess(res, newList, 201);
|
|
} catch (error) {
|
|
if (error instanceof ForeignKeyConstraintError) {
|
|
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
|
|
}
|
|
req.log.error({ error, body: req.body }, 'Failed to create shopping list');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/shopping-lists/{listId}:
|
|
* delete:
|
|
* tags: [Users]
|
|
* summary: Delete shopping list
|
|
* description: Delete a shopping list and all its items.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: listId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Shopping list ID
|
|
* responses:
|
|
* 204:
|
|
* description: Shopping list deleted
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Shopping list not found
|
|
*/
|
|
router.delete(
|
|
'/shopping-lists/:listId',
|
|
userUpdateLimiter,
|
|
validateRequest(shoppingListIdSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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);
|
|
sendNoContent(res);
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
|
|
req.log.error(
|
|
{ errorMessage, params: req.params },
|
|
`[ROUTE] DELETE /api/users/shopping-lists/:listId - ERROR`,
|
|
);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/shopping-lists/{listId}/items:
|
|
* post:
|
|
* tags: [Users]
|
|
* summary: Add item to shopping list
|
|
* description: Add an item to a shopping list by master item ID or custom name.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: listId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Shopping list ID
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* masterItemId:
|
|
* type: integer
|
|
* description: ID of master item to add
|
|
* customItemName:
|
|
* type: string
|
|
* description: Custom item name (use if masterItemId not provided)
|
|
* responses:
|
|
* 201:
|
|
* description: Item added to shopping list
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 400:
|
|
* description: Validation error - must provide masterItemId or customItemName
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Shopping list not found
|
|
*/
|
|
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',
|
|
userUpdateLimiter,
|
|
validateRequest(addShoppingListItemSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendSuccess(res, newItem, 201);
|
|
} catch (error) {
|
|
if (error instanceof ForeignKeyConstraintError) {
|
|
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
|
|
}
|
|
req.log.error(
|
|
{ error, params: req.params, body: req.body },
|
|
'Failed to add shopping list item',
|
|
);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/shopping-lists/items/{itemId}:
|
|
* put:
|
|
* tags: [Users]
|
|
* summary: Update shopping list item
|
|
* description: Update quantity or purchased status of a shopping list item.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: itemId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Shopping list item ID
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* quantity:
|
|
* type: integer
|
|
* minimum: 0
|
|
* description: Item quantity
|
|
* is_purchased:
|
|
* type: boolean
|
|
* description: Whether item has been purchased
|
|
* responses:
|
|
* 200:
|
|
* description: Item updated
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 400:
|
|
* description: Validation error - at least one field required
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Item not found
|
|
*/
|
|
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',
|
|
userUpdateLimiter,
|
|
validateRequest(updateShoppingListItemSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendSuccess(res, updatedItem);
|
|
} catch (error: unknown) {
|
|
req.log.error(
|
|
{ error, params: req.params, body: req.body },
|
|
`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ERROR`,
|
|
);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/shopping-lists/items/{itemId}:
|
|
* delete:
|
|
* tags: [Users]
|
|
* summary: Remove shopping list item
|
|
* description: Remove an item from a shopping list.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: itemId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Shopping list item ID
|
|
* responses:
|
|
* 204:
|
|
* description: Item removed from shopping list
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Item not found
|
|
*/
|
|
const shoppingListItemIdSchema = numericIdParam('itemId');
|
|
type DeleteShoppingListItemRequest = z.infer<typeof shoppingListItemIdSchema>;
|
|
router.delete(
|
|
'/shopping-lists/items/:itemId',
|
|
userUpdateLimiter,
|
|
validateRequest(shoppingListItemIdSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendNoContent(res);
|
|
} catch (error: unknown) {
|
|
req.log.error(
|
|
{ error, params: req.params },
|
|
`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ERROR`,
|
|
);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/profile/preferences:
|
|
* put:
|
|
* tags: [Users]
|
|
* summary: Update user preferences
|
|
* description: Update the authenticated user's application preferences.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* additionalProperties: true
|
|
* description: User preference key-value pairs
|
|
* responses:
|
|
* 200:
|
|
* description: Preferences updated
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
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',
|
|
userUpdateLimiter,
|
|
validateRequest(updatePreferencesSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendSuccess(res, updatedProfile);
|
|
} catch (error) {
|
|
req.log.error({ error }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/me/dietary-restrictions:
|
|
* get:
|
|
* tags: [Users]
|
|
* summary: Get dietary restrictions
|
|
* description: Retrieve the authenticated user's dietary restrictions.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of dietary restrictions
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
router.get(
|
|
'/me/dietary-restrictions',
|
|
validateRequest(emptySchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendSuccess(res, restrictions);
|
|
} catch (error) {
|
|
req.log.error({ error }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/me/dietary-restrictions:
|
|
* put:
|
|
* tags: [Users]
|
|
* summary: Set dietary restrictions
|
|
* description: Replace the authenticated user's dietary restrictions.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - restrictionIds
|
|
* properties:
|
|
* restrictionIds:
|
|
* type: array
|
|
* items:
|
|
* type: integer
|
|
* description: Array of dietary restriction IDs
|
|
* responses:
|
|
* 204:
|
|
* description: Dietary restrictions updated
|
|
* 400:
|
|
* description: Invalid restriction IDs
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
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',
|
|
userUpdateLimiter,
|
|
validateRequest(setUserRestrictionsSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendNoContent(res);
|
|
} catch (error) {
|
|
if (error instanceof ForeignKeyConstraintError) {
|
|
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
|
|
}
|
|
req.log.error({ error, body: req.body }, 'Failed to set user dietary restrictions');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/me/appliances:
|
|
* get:
|
|
* tags: [Users]
|
|
* summary: Get user appliances
|
|
* description: Retrieve the authenticated user's kitchen appliances.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of user's appliances
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
router.get('/me/appliances', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendSuccess(res, appliances);
|
|
} catch (error) {
|
|
req.log.error({ error }, `[ROUTE] GET /api/users/me/appliances - ERROR`);
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/me/appliances:
|
|
* put:
|
|
* tags: [Users]
|
|
* summary: Set user appliances
|
|
* description: Replace the authenticated user's kitchen appliances.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - applianceIds
|
|
* properties:
|
|
* applianceIds:
|
|
* type: array
|
|
* items:
|
|
* type: integer
|
|
* description: Array of appliance IDs
|
|
* responses:
|
|
* 204:
|
|
* description: Appliances updated
|
|
* 400:
|
|
* description: Invalid appliance IDs
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
const setUserAppliancesSchema = z.object({
|
|
body: z.object({ applianceIds: z.array(z.number().int().positive()) }),
|
|
});
|
|
type SetUserAppliancesRequest = z.infer<typeof setUserAppliancesSchema>;
|
|
router.put(
|
|
'/me/appliances',
|
|
userUpdateLimiter,
|
|
validateRequest(setUserAppliancesSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendNoContent(res);
|
|
} catch (error) {
|
|
if (error instanceof ForeignKeyConstraintError) {
|
|
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
|
|
}
|
|
req.log.error({ error, body: req.body }, 'Failed to set user appliances');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/addresses/{addressId}:
|
|
* get:
|
|
* tags: [Users]
|
|
* summary: Get address by ID
|
|
* description: Retrieve a specific address by its ID. Users can only access their own addresses.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: addressId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Address ID
|
|
* responses:
|
|
* 200:
|
|
* description: Address details
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Address not found
|
|
*/
|
|
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);
|
|
sendSuccess(res, address);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching user address');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/profile/address:
|
|
* put:
|
|
* tags: [Users]
|
|
* summary: Create or update address
|
|
* description: Create or update the authenticated user's primary address.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* address_line_1:
|
|
* type: string
|
|
* description: Street address line 1
|
|
* address_line_2:
|
|
* type: string
|
|
* description: Street address line 2 (apt, suite, etc.)
|
|
* city:
|
|
* type: string
|
|
* description: City name
|
|
* province_state:
|
|
* type: string
|
|
* description: Province or state
|
|
* postal_code:
|
|
* type: string
|
|
* description: Postal or ZIP code
|
|
* country:
|
|
* type: string
|
|
* description: Country name
|
|
* responses:
|
|
* 200:
|
|
* description: Address updated successfully
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 400:
|
|
* description: Validation error - at least one field required
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
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',
|
|
userUpdateLimiter,
|
|
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.
|
|
sendSuccess(res, { message: 'Address updated successfully', address_id: addressId });
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error updating user address');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/recipes:
|
|
* post:
|
|
* tags: [Users]
|
|
* summary: Create recipe
|
|
* description: Create a new recipe for the authenticated user.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - name
|
|
* - instructions
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* description: Recipe name
|
|
* instructions:
|
|
* type: string
|
|
* description: Cooking instructions
|
|
* description:
|
|
* type: string
|
|
* description: Recipe description
|
|
* prep_time_minutes:
|
|
* type: integer
|
|
* minimum: 0
|
|
* description: Preparation time in minutes
|
|
* cook_time_minutes:
|
|
* type: integer
|
|
* minimum: 0
|
|
* description: Cooking time in minutes
|
|
* servings:
|
|
* type: integer
|
|
* minimum: 1
|
|
* description: Number of servings
|
|
* photo_url:
|
|
* type: string
|
|
* format: uri
|
|
* description: URL to recipe photo
|
|
* responses:
|
|
* 201:
|
|
* description: Recipe created
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 400:
|
|
* description: Validation error
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
*/
|
|
router.post(
|
|
'/recipes',
|
|
userUpdateLimiter,
|
|
validateRequest(createRecipeSchema),
|
|
async (req, res, next) => {
|
|
const userProfile = req.user as UserProfile;
|
|
const { body } = req as unknown as z.infer<typeof createRecipeSchema>;
|
|
try {
|
|
const recipe = await db.recipeRepo.createRecipe(userProfile.user.user_id, body, req.log);
|
|
sendSuccess(res, recipe, 201);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error creating recipe');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/recipes/{recipeId}:
|
|
* delete:
|
|
* tags: [Users]
|
|
* summary: Delete recipe
|
|
* description: Delete a recipe created by the authenticated user.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: recipeId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Recipe ID
|
|
* responses:
|
|
* 204:
|
|
* description: Recipe deleted
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Recipe not found or not owned by user
|
|
*/
|
|
const recipeIdSchema = numericIdParam('recipeId');
|
|
type DeleteRecipeRequest = z.infer<typeof recipeIdSchema>;
|
|
router.delete(
|
|
'/recipes/:recipeId',
|
|
userUpdateLimiter,
|
|
validateRequest(recipeIdSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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);
|
|
sendNoContent(res);
|
|
} catch (error) {
|
|
req.log.error(
|
|
{ error, params: req.params },
|
|
`[ROUTE] DELETE /api/users/recipes/:recipeId - ERROR`,
|
|
);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /users/recipes/{recipeId}:
|
|
* put:
|
|
* tags: [Users]
|
|
* summary: Update recipe
|
|
* description: Update a recipe created by the authenticated user.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: recipeId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Recipe ID
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* description: Recipe name
|
|
* description:
|
|
* type: string
|
|
* description: Recipe description
|
|
* instructions:
|
|
* type: string
|
|
* description: Cooking instructions
|
|
* prep_time_minutes:
|
|
* type: integer
|
|
* description: Preparation time in minutes
|
|
* cook_time_minutes:
|
|
* type: integer
|
|
* description: Cooking time in minutes
|
|
* servings:
|
|
* type: integer
|
|
* description: Number of servings
|
|
* photo_url:
|
|
* type: string
|
|
* format: uri
|
|
* description: URL to recipe photo
|
|
* responses:
|
|
* 200:
|
|
* description: Recipe updated
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 400:
|
|
* description: Validation error - at least one field required
|
|
* 401:
|
|
* description: Unauthorized - invalid or missing token
|
|
* 404:
|
|
* description: Recipe not found or not owned by 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',
|
|
userUpdateLimiter,
|
|
validateRequest(updateRecipeSchema),
|
|
async (req, res, next: NextFunction) => {
|
|
req.log.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,
|
|
);
|
|
sendSuccess(res, updatedRecipe);
|
|
} catch (error) {
|
|
req.log.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;
|