// src/services/db/user.db.ts import { Pool, PoolClient } from 'pg'; import { getPool } from './connection.db'; import type { Logger } from 'pino'; import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db'; import { Profile, MasterGroceryItem, ShoppingList, ActivityLogItem, UserProfile, SearchQuery, } from '../../types'; import { ShoppingRepository } from './shopping.db'; import { PersonalizationRepository } from './personalization.db'; import { withTransaction } from './connection.db'; /** * Defines the structure of a user object as returned from the database. */ interface DbUser { user_id: string; email: string; // The password_hash can be null for users who signed up via OAuth. password_hash: string | null; refresh_token?: string | null; failed_login_attempts: number; last_failed_login: string | null; // This will be a date string from the DB } export class UserRepository { private db: Pool | PoolClient; constructor(db: Pool | PoolClient = getPool()) { this.db = db; } /** * Finds a user by their email in the public.users table. * @param email The email of the user to find. * @returns A promise that resolves to the user object or undefined if not found. */ async findUserByEmail(email: string, logger: Logger): Promise { logger.debug({ email }, `[DB findUserByEmail] Searching for user.`); try { const res = await this.db.query( 'SELECT user_id, email, password_hash, refresh_token, failed_login_attempts, last_failed_login FROM public.users WHERE email = $1', [email], ); const userFound = res.rows[0]; logger.debug( `[DB findUserByEmail] Query for ${email} result: ${userFound ? `FOUND user ID ${userFound.user_id}` : 'NOT FOUND'}`, ); return res.rows[0]; } catch (error) { logger.error({ err: error, email }, 'Database error in findUserByEmail'); throw new Error('Failed to retrieve user from database.'); } } /** * Creates a new user in the public.users table. * This method expects to be run within a transaction, so it requires a PoolClient. * @param email The user's email. * @param passwordHash The bcrypt hashed password. * @param profileData An object containing optional full_name and avatar_url for the profile. * @returns A promise that resolves to the newly created user object (id, email). */ async createUser( email: string, passwordHash: string | null, profileData: { full_name?: string; avatar_url?: string }, logger: Logger, ): Promise { return withTransaction(async (client: PoolClient) => { logger.debug(`[DB createUser] Starting transaction for email: ${email}`); // Use 'set_config' to safely pass parameters to a configuration variable. await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [ JSON.stringify(profileData), ]); logger.debug(`[DB createUser] Session metadata set for ${email}.`); // Insert the new user into the 'users' table. This will fire the trigger. const userInsertRes = await client.query<{ user_id: string }>( 'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email', [email, passwordHash], ); const newUserId = userInsertRes.rows[0].user_id; logger.debug(`[DB createUser] Inserted into users table. New user ID: ${newUserId}`); // After the trigger has run, fetch the complete profile data. const profileQuery = ` SELECT u.user_id, u.email, p.full_name, p.avatar_url, p.role, p.points, p.preferences, p.created_at, p.updated_at FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id WHERE u.user_id = $1; `; const finalProfileRes = await client.query(profileQuery, [newUserId]); const flatProfile = finalProfileRes.rows[0]; if (!flatProfile) { throw new Error('Failed to create or retrieve user profile after registration.'); } // Construct the nested UserProfile object to match the type definition. const fullUserProfile: UserProfile = { // user_id is now correctly part of the nested user object, not at the top level. user: { user_id: flatProfile.user_id, email: flatProfile.email, }, full_name: flatProfile.full_name, avatar_url: flatProfile.avatar_url, role: flatProfile.role, points: flatProfile.points, preferences: flatProfile.preferences, created_at: flatProfile.created_at, updated_at: flatProfile.updated_at, }; logger.debug({ user: fullUserProfile }, `[DB createUser] Fetched full profile for new user:`); return fullUserProfile; }).catch((error) => { // Check for specific PostgreSQL error codes if (error instanceof Error && 'code' in error && error.code === '23505') { logger.warn(`Attempted to create a user with an existing email: ${email}`); throw new UniqueConstraintError('A user with this email address already exists.'); } // The withTransaction helper logs the rollback, so we just log the context here. logger.error({ err: error, email }, 'Error during createUser transaction'); throw new Error('Failed to create user in database.'); }); } /** * Finds a user by their email and joins their profile data. * This is used by the LocalStrategy to get all necessary data for authentication and session creation in one query. * @param email The email of the user to find. * @returns A promise that resolves to the combined user and profile object or undefined if not found. */ async findUserWithProfileByEmail( email: string, logger: Logger, ): Promise<(UserProfile & Omit) | undefined> { logger.debug({ email }, `[DB findUserWithProfileByEmail] Searching for user.`); try { const query = ` SELECT u.user_id, u.email, u.password_hash, u.refresh_token, u.failed_login_attempts, u.last_failed_login, p.full_name, p.avatar_url, p.role, p.points, p.preferences, p.address_id, p.created_at, p.updated_at FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id WHERE u.email = $1; `; const res = await this.db.query(query, [email]); const flatUser = res.rows[0]; if (!flatUser) { return undefined; } // Manually construct the nested UserProfile object and add auth fields const authableProfile: UserProfile & Omit = { full_name: flatUser.full_name, avatar_url: flatUser.avatar_url, role: flatUser.role, points: flatUser.points, preferences: flatUser.preferences, address_id: flatUser.address_id, created_at: flatUser.created_at, updated_at: flatUser.updated_at, user: { user_id: flatUser.user_id, email: flatUser.email, }, password_hash: flatUser.password_hash, failed_login_attempts: flatUser.failed_login_attempts, last_failed_login: flatUser.last_failed_login, refresh_token: flatUser.refresh_token, }; return authableProfile; } catch (error) { logger.error({ err: error, email }, 'Database error in findUserWithProfileByEmail'); throw new Error('Failed to retrieve user with profile from database.'); } } /** * Finds a user by their ID. Used by the JWT strategy to validate tokens. * @param id The UUID of the user to find. * @returns A promise that resolves to the user object (id, email) or undefined if not found. */ // prettier-ignore async findUserById(userId: string, logger: Logger): Promise<{ user_id: string; email: string; }> { try { const res = await this.db.query<{ user_id: string; email: string }>( 'SELECT user_id, email FROM public.users WHERE user_id = $1', [userId] ); if (res.rowCount === 0) { throw new NotFoundError(`User with ID ${userId} not found.`); } return res.rows[0]; } catch (error) { if (error instanceof NotFoundError) throw error; logger.error( { err: error, userId }, 'Database error in findUserById', ); throw new Error('Failed to retrieve user by ID from database.'); } } /** * Finds a user by their ID, including their password hash. * This should only be used in contexts where password verification is required, like account deletion. * @param id The UUID of the user to find. * @returns A promise that resolves to the user object (id, email, password_hash) or undefined if not found. */ // prettier-ignore async findUserWithPasswordHashById(userId: string, logger: Logger): Promise<{ user_id: string; email: string; password_hash: string | null }> { try { const res = await this.db.query<{ user_id: string; email: string; password_hash: string | null }>( 'SELECT user_id, email, password_hash FROM public.users WHERE user_id = $1', [userId] ); if ((res.rowCount ?? 0) === 0) { throw new NotFoundError(`User with ID ${userId} not found.`); } return res.rows[0]; } catch (error) { if (error instanceof NotFoundError) throw error; logger.error( { err: error, userId }, 'Database error in findUserWithPasswordHashById', ); throw new Error('Failed to retrieve user with sensitive data by ID from database.'); } } /** * Finds a user's profile by their user ID. * @param id The UUID of the user. * @returns A promise that resolves to the user's profile object or undefined if not found. */ // prettier-ignore async findUserProfileById(userId: string, logger: Logger): Promise { try { const res = await this.db.query( `SELECT p.full_name, p.avatar_url, p.address_id, p.preferences, p.role, p.points, p.created_at, p.updated_at, json_build_object( 'user_id', u.user_id, 'email', u.email ) as user, CASE WHEN a.address_id IS NOT NULL THEN json_build_object( 'address_id', a.address_id, 'address_line_1', a.address_line_1, 'address_line_2', a.address_line_2, 'city', a.city, 'province_state', a.province_state, 'postal_code', a.postal_code, 'country', a.country ) ELSE NULL END as address FROM public.profiles p JOIN public.users u ON p.user_id = u.user_id LEFT JOIN public.addresses a ON p.address_id = a.address_id WHERE p.user_id = $1`, [userId] ); if (res.rowCount === 0) { throw new NotFoundError('Profile not found for this user.'); } return res.rows[0]; } catch (error) { if (error instanceof NotFoundError) { throw error; } logger.error( { err: error, userId }, 'Database error in findUserProfileById', ); throw new Error('Failed to retrieve user profile from database.'); } } /** * Updates the profile for a given user. * @param id The UUID of the user. * @param profileData The profile data to update (e.g., full_name, avatar_url). * @returns A promise that resolves to the updated profile object. */ // prettier-ignore async updateUserProfile(userId: string, profileData: Partial>, logger: Logger): Promise { try { const { full_name, avatar_url, address_id } = profileData; const fieldsToUpdate = []; const values = []; let paramIndex = 1; if (full_name !== undefined) { fieldsToUpdate.push(`full_name = $${paramIndex++}`); values.push(full_name); } if (avatar_url !== undefined) { fieldsToUpdate.push(`avatar_url = $${paramIndex++}`); values.push(avatar_url); } if (address_id !== undefined) { fieldsToUpdate.push(`address_id = $${paramIndex++}`); values.push(address_id); } if (fieldsToUpdate.length === 0) { return this.findUserProfileById(userId, logger) as Promise; } values.push(userId); const query = ` UPDATE public.profiles SET ${fieldsToUpdate.join(', ')}, updated_at = now() WHERE user_id = $${paramIndex} RETURNING *; `; const res = await this.db.query( query, values ); if (res.rowCount === 0) { throw new NotFoundError('User not found or user does not have permission to update.'); } return res.rows[0]; } catch (error) { if (error instanceof NotFoundError) { throw error; } logger.error( { err: error, userId, profileData }, 'Database error in updateUserProfile', ); throw new Error('Failed to update user profile in database.'); } } /** * Updates the preferences for a given user. * @param id The UUID of the user. * @param preferences The preferences object to save. * @returns A promise that resolves to the updated profile object. */ // prettier-ignore async updateUserPreferences(userId: string, preferences: Profile['preferences'], logger: Logger): Promise { try { const res = await this.db.query( `UPDATE public.profiles SET preferences = COALESCE(preferences, '{}'::jsonb) || $1, updated_at = now() WHERE user_id = $2 RETURNING user_id, full_name, avatar_url, preferences, role`, [preferences, userId] ); if (res.rowCount === 0) { throw new NotFoundError('User not found or user does not have permission to update.'); } return res.rows[0]; } catch (error) { if (error instanceof NotFoundError) { throw error; } logger.error( { err: error, userId, preferences }, 'Database error in updateUserPreferences', ); throw new Error('Failed to update user preferences in database.'); } } /** * Updates the password hash for a given user. * @param id The UUID of the user. * @param passwordHash The new bcrypt hashed password. */ // prettier-ignore async updateUserPassword(userId: string, passwordHash: string, logger: Logger): Promise { try { await this.db.query( 'UPDATE public.users SET password_hash = $1 WHERE user_id = $2', [passwordHash, userId] ); } catch (error) { logger.error( { err: error, userId }, 'Database error in updateUserPassword', ); throw new Error('Failed to update user password in database.'); } } /** * Deletes a user from the database by their ID. * @param id The UUID of the user to delete. */ // prettier-ignore async deleteUserById(userId: string, logger: Logger): Promise { try { await this.db.query('DELETE FROM public.users WHERE user_id = $1', [userId]); } catch (error) { // This was a duplicate, fixed. logger.error( { err: error, userId }, 'Database error in deleteUserById', ); throw new Error('Failed to delete user from database.'); } } /** * Saves or updates a refresh token for a user. * @param userId The UUID of the user. * @param refreshToken The new refresh token to save. */ // prettier-ignore async saveRefreshToken(userId: string, refreshToken: string, logger: Logger): Promise { try { await this.db.query( 'UPDATE public.users SET refresh_token = $1 WHERE user_id = $2', [refreshToken, userId] ); } catch (error) { logger.error( { err: error, userId }, 'Database error in saveRefreshToken', ); throw new Error('Failed to save refresh token.'); } } /** * Finds a user by their refresh token. * @param refreshToken The refresh token to look up. * @returns A promise that resolves to the user object (id, email) or undefined if not found. */ async findUserByRefreshToken( refreshToken: string, logger: Logger, ): Promise<{ user_id: string; email: string } | undefined> { try { const res = await this.db.query<{ user_id: string; email: string }>( 'SELECT user_id, email FROM public.users WHERE refresh_token = $1', [refreshToken], ); if ((res.rowCount ?? 0) === 0) { return undefined; } return res.rows[0]; } catch (error) { logger.error({ err: error }, 'Database error in findUserByRefreshToken'); throw new Error('Failed to find user by refresh token.'); // Generic error for other failures } } /** * Deletes a refresh token from the database by setting it to NULL. * @param refreshToken The refresh token to delete. */ async deleteRefreshToken(refreshToken: string, logger: Logger): Promise { try { await this.db.query('UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', [ refreshToken, ]); } catch (error) { logger.error({ err: error }, 'Database error in deleteRefreshToken'); } } /** * Creates a password reset token for a user. * @param userId The UUID of the user. * @param tokenHash The hashed version of the reset token. * @param expiresAt The timestamp when the token expires. */ // prettier-ignore async createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date, logger: Logger): Promise { const client = this.db as PoolClient; try { await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]); await client.query( 'INSERT INTO public.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', [userId, tokenHash, expiresAt] ); } catch (error) { if (error instanceof Error && 'code' in error && error.code === '23503') { throw new ForeignKeyConstraintError('The specified user does not exist.'); } logger.error( { err: error, userId }, 'Database error in createPasswordResetToken', ); throw new Error('Failed to create password reset token.'); } } /** * Finds a user and token details by the token hash. * @returns A promise that resolves to an array of valid token records. */ // prettier-ignore async getValidResetTokens(logger: Logger): Promise<{ user_id: string; token_hash: string; expires_at: Date }[]> { try { const res = await this.db.query<{ user_id: string; token_hash: string; expires_at: Date }>( 'SELECT user_id, token_hash, expires_at FROM public.password_reset_tokens WHERE expires_at > NOW()' ); return res.rows; } catch (error) { logger.error( { err: error }, 'Database error in getValidResetTokens', ); throw new Error('Failed to retrieve valid reset tokens.'); } } /** * Deletes a password reset token by its hash. * @param tokenHash The hashed token to delete. */ // prettier-ignore async deleteResetToken(tokenHash: string, logger: Logger): Promise { try { await this.db.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]); } catch (error) { logger.error( { err: error, tokenHash }, 'Database error in deleteResetToken', ); } } /** * Deletes all expired password reset tokens from the database. * This is intended for a periodic cleanup job. * @returns A promise that resolves to the number of deleted tokens. */ async deleteExpiredResetTokens(logger: Logger): Promise { try { const res = await this.db.query( 'DELETE FROM public.password_reset_tokens WHERE expires_at < NOW()', ); logger.info( `[DB deleteExpiredResetTokens] Deleted ${res.rowCount ?? 0} expired password reset tokens.`, ); return res.rowCount ?? 0; } catch (error) { logger.error({ err: error }, 'Database error in deleteExpiredResetTokens'); throw new Error('Failed to delete expired password reset tokens.'); } } /** * Creates a following relationship between two users. * @param followerId The ID of the user who is following. * @param followingId The ID of the user being followed. */ async followUser(followerId: string, followingId: string, logger: Logger): Promise { try { await this.db.query( 'INSERT INTO public.user_follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT (follower_id, following_id) DO NOTHING', [followerId, followingId], ); } catch (error) { if (error instanceof Error && 'code' in error && error.code === '23503') { throw new ForeignKeyConstraintError('One or both users do not exist.'); } logger.error({ err: error, followerId, followingId }, 'Database error in followUser'); throw new Error('Failed to follow user.'); } } /** * Removes a following relationship between two users. * @param followerId The ID of the user who is unfollowing. * @param followingId The ID of the user being unfollowed. */ async unfollowUser(followerId: string, followingId: string, logger: Logger): Promise { try { await this.db.query( 'DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2', [followerId, followingId], ); } catch (error) { logger.error({ err: error, followerId, followingId }, 'Database error in unfollowUser'); throw new Error('Failed to unfollow user.'); } } /** * Retrieves a personalized activity feed for a user, based on the actions of users they follow. * @param userId The ID of the user for whom to generate the feed. * @param limit The number of feed items to retrieve. * @param offset The number of feed items to skip for pagination. * @returns A promise that resolves to an array of ActivityLogItem objects. */ async getUserFeed( userId: string, limit: number, offset: number, logger: Logger, ): Promise { try { const query = ` SELECT al.*, p.full_name as user_full_name, p.avatar_url as user_avatar_url FROM public.activity_log al JOIN public.user_follows uf ON al.user_id = uf.following_id JOIN public.profiles p ON al.user_id = p.user_id WHERE uf.follower_id = $1 ORDER BY al.created_at DESC LIMIT $2 OFFSET $3; `; const res = await this.db.query(query, [userId, limit, offset]); return res.rows; } catch (error) { logger.error({ err: error, userId, limit, offset }, 'Database error in getUserFeed'); throw new Error('Failed to retrieve user feed.'); } } /** * Logs a search query made by a user. * @param queryData The search query data to log. * @returns A promise that resolves to the created SearchQuery object. */ async logSearchQuery( queryData: Omit, logger: Logger, ): Promise { const { user_id, query_text, result_count, was_successful } = queryData; try { const res = await this.db.query( 'INSERT INTO public.search_queries (user_id, query_text, result_count, was_successful) VALUES ($1, $2, $3, $4) RETURNING *', [user_id, query_text, result_count, was_successful], ); return res.rows[0]; } catch (error) { logger.error({ err: error, queryData }, 'Database error in logSearchQuery'); throw new Error('Failed to log search query.'); } } } /** * Gathers all data associated with a specific user for export. * @param userId The UUID of the user. * @returns A promise that resolves to an object containing all user data. */ // prettier-ignore export async function exportUserData(userId: string, logger: Logger): Promise<{ profile: Profile; watchedItems: MasterGroceryItem[]; shoppingLists: ShoppingList[] }> { try { return await withTransaction(async (client) => { const userRepo = new UserRepository(client); const shoppingRepo = new ShoppingRepository(client); const personalizationRepo = new PersonalizationRepository(client); // Run queries in parallel for efficiency within the transaction const profileQuery = userRepo.findUserProfileById(userId, logger); const watchedItemsQuery = personalizationRepo.getWatchedItems(userId, logger); const shoppingListsQuery = shoppingRepo.getShoppingLists(userId, logger); const [profile, watchedItems, shoppingLists] = await Promise.all([profileQuery, watchedItemsQuery, shoppingListsQuery]); if (!profile) { // This will be caught by withTransaction and rolled back (though no writes were made) throw new NotFoundError('User profile not found for data export.'); } return { profile, watchedItems, shoppingLists }; }); } catch (error) { logger.error( { err: error, userId }, 'Database error in exportUserData', ); throw new Error('Failed to export user data.'); } }