Files
flyer-crawler.projectium.com/src/routes/user.ts
Torben Sorensen 80d2b1ffe6
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m28s
Add user database service and unit tests
- 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.
2025-12-04 15:30:27 -08:00

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;