678 lines
25 KiB
TypeScript
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.');
|
|
}
|
|
}
|