Files
flyer-crawler.projectium.com/src/services/db/user.db.ts
Torben Sorensen 6ab473f5f0
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 58s
huge linting fixes
2026-01-09 18:50:04 -08:00

751 lines
26 KiB
TypeScript

// src/services/db/user.db.ts
import { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import type { Logger } from 'pino';
import { NotFoundError, handleDbError } from './errors.db';
import {
Profile,
MasterGroceryItem,
ShoppingList,
ActivityLogItem,
UserProfile,
SearchQuery,
User,
} 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
created_at: string;
updated_at: string;
}
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, created_at, updated_at 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) {
handleDbError(
error,
logger,
'Database error in findUserByEmail',
{ email },
{
defaultMessage: 'Failed to retrieve user from database.',
},
);
}
}
/**
* The internal logic for creating a user. This method assumes it is being run
* within a database transaction and operates on a single PoolClient.
*/
private async _createUser(
dbClient: PoolClient,
email: string,
passwordHash: string | null,
profileData: { full_name?: string; avatar_url?: string },
logger: Logger,
): Promise<UserProfile> {
logger.debug(`[DB _createUser] Starting user creation for email: ${email}`);
await dbClient.query("SELECT set_config('my_app.user_metadata', $1, true)", [
JSON.stringify(profileData ?? {}),
]);
logger.debug(`[DB _createUser] Session metadata set for ${email}.`);
const userInsertRes = await dbClient.query<{ user_id: string; email: 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}`);
const profileQuery = `
SELECT u.user_id, u.email, u.created_at as user_created_at, u.updated_at as user_updated_at, 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 dbClient.query(profileQuery, [newUserId]);
const flatProfile = finalProfileRes.rows[0];
if (!flatProfile) {
throw new Error('Failed to create or retrieve user profile after registration.');
}
const fullUserProfile: UserProfile = {
user: {
user_id: flatProfile.user_id,
email: flatProfile.email,
created_at: flatProfile.user_created_at,
updated_at: flatProfile.user_updated_at,
},
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;
}
/**
* 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> {
// This method is now a wrapper that ensures the core logic runs within a transaction.
try {
// If this.db has a 'connect' method, it's a Pool. We must start a transaction.
if ('connect' in this.db) {
return await withTransaction(async (client) => {
return this._createUser(client, email, passwordHash, profileData, logger);
});
} else {
// If this.db is already a PoolClient, we're inside a transaction. Use it directly.
return await this._createUser(
this.db as PoolClient,
email,
passwordHash,
profileData,
logger,
);
}
} catch (error) {
handleDbError(
error,
logger,
'Error during createUser',
{ email },
{
uniqueMessage: 'A user with this email address already exists.',
defaultMessage: '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.created_at as user_created_at, u.updated_at as user_updated_at, 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 & { user_created_at: string; user_updated_at: string }
>(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,
created_at: flatUser.user_created_at,
updated_at: flatUser.user_updated_at,
},
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) {
handleDbError(
error,
logger,
'Database error in findUserWithProfileByEmail',
{ email },
{
defaultMessage: '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> {
try {
const res = await this.db.query<User>(
'SELECT user_id, email, created_at, updated_at 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;
handleDbError(error, logger, 'Database error in findUserById', { userId }, {
defaultMessage: '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 & { password_hash: string | null }> {
try {
const res = await this.db.query<User & { password_hash: string | null }>(
'SELECT user_id, email, password_hash, created_at, updated_at 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;
handleDbError(error, logger, 'Database error in findUserWithPasswordHashById', { userId }, {
defaultMessage: '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,
'created_at', u.created_at,
'updated_at', u.updated_at
) 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;
}
handleDbError(error, logger, 'Database error in findUserProfileById', { userId }, {
defaultMessage: '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;
}
handleDbError(error, logger, 'Database error in updateUserProfile', { userId, profileData }, {
fkMessage: 'The specified address does not exist.',
defaultMessage: '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;
}
handleDbError(error, logger, 'Database error in updateUserPreferences', { userId, preferences }, {
defaultMessage: '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) {
handleDbError(error, logger, 'Database error in updateUserPassword', { userId }, {
defaultMessage: '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 {
const res = await this.db.query('DELETE FROM public.users WHERE user_id = $1', [userId]);
if (res.rowCount === 0) {
throw new NotFoundError(`User with ID ${userId} not found.`);
}
} catch (error) {
if (error instanceof NotFoundError) throw error;
handleDbError(error, logger, 'Database error in deleteUserById', { userId }, {
defaultMessage: '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) {
handleDbError(error, logger, 'Database error in saveRefreshToken', { userId }, {
defaultMessage: '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 | undefined> {
try {
const res = await this.db.query<User>(
'SELECT user_id, email, created_at, updated_at FROM public.users WHERE refresh_token = $1',
[refreshToken],
);
if ((res.rowCount ?? 0) === 0) {
return undefined;
}
return res.rows[0];
} catch (error) {
handleDbError(
error,
logger,
'Database error in findUserByRefreshToken',
{},
{
defaultMessage: 'Failed to find user by refresh token.',
},
);
}
}
/**
* 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) {
// This is a non-critical operation, so we just log the error and continue.
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, client: PoolClient): Promise<void> {
try {
// First, remove any existing tokens for the user to ensure they can only have one active reset request.
await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
// Then, insert the new token.
await client.query(
'INSERT INTO public.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[userId, tokenHash, expiresAt]
);
} catch (error) {
handleDbError(error, logger, 'Database error in createPasswordResetToken', { userId }, {
fkMessage: 'The specified user does not exist.',
uniqueMessage: 'A password reset token with this hash already exists.',
defaultMessage: '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) {
handleDbError(error, logger, 'Database error in getValidResetTokens', {}, {
defaultMessage: '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) {
handleDbError(error, logger, 'Database error in deleteResetToken', { tokenHash }, {
defaultMessage: 'Failed to delete password reset token.',
});
}
}
/**
* 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) {
handleDbError(
error,
logger,
'Database error in deleteExpiredResetTokens',
{},
{
defaultMessage: '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) {
handleDbError(
error,
logger,
'Database error in followUser',
{ followerId, followingId },
{
fkMessage: 'One or both users do not exist.',
checkMessage: 'A user cannot follow themselves.',
defaultMessage: '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) {
handleDbError(
error,
logger,
'Database error in unfollowUser',
{ followerId, followingId },
{
defaultMessage: '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) {
handleDbError(
error,
logger,
'Database error in getUserFeed',
{ userId, limit, offset },
{
defaultMessage: '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' | 'updated_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) {
handleDbError(
error,
logger,
'Database error in logSearchQuery',
{ queryData },
{
fkMessage: 'The specified user does not exist.',
defaultMessage: '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) {
handleDbError(error, logger, 'Database error in exportUserData', { userId }, {
defaultMessage: 'Failed to export user data.',
});
}
}