Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 58s
751 lines
26 KiB
TypeScript
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.',
|
|
});
|
|
}
|
|
}
|