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