Files
flyer-crawler.projectium.com/src/services/db/user.db.ts

678 lines
25 KiB
TypeScript

// 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<DbUser | undefined> {
logger.debug({ email }, `[DB findUserByEmail] Searching for user.`);
try {
const res = await this.db.query<DbUser>(
'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<UserProfile> {
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<DbUser, 'user_id' | 'email'>) | 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<DbUser & Profile>(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<DbUser, 'user_id' | 'email'> = {
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<UserProfile> {
try {
const res = await this.db.query<UserProfile>(
`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<Pick<Profile, 'full_name' | 'avatar_url' | 'address_id'>>, logger: Logger): Promise<Profile> {
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<Profile>;
}
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<Profile>(
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<Profile> {
try {
const res = await this.db.query<Profile>(
`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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<number> {
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<void> {
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<void> {
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<ActivityLogItem[]> {
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<ActivityLogItem>(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<SearchQuery, 'search_query_id' | 'created_at'>,
logger: Logger,
): Promise<SearchQuery> {
const { user_id, query_text, result_count, was_successful } = queryData;
try {
const res = await this.db.query<SearchQuery>(
'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.');
}
}