Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 | 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 3x 2x 3x 3x 3x 2x 2x 2x 2x 2x 2x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x 1x 1x 1x 1x 1x 2x 2x 2x 4x 4x 4x 4x 2x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1x 1x 1x 2x 2x 2x 2x 7x 7x 7x 5x 5x 6x 5x 5x 5x 5x 5x 3x 5x 5x 5x 3x 3x 2x 2x 2x 2x 2x 2x 1x 1x 2x 2x 2x 2x 2x 7x 3x 2x 4x 4x 2x 1x 1x 2x 2x 24x | // src/services/userService.ts
import * as db from './db/index.db';
import type { Job } from 'bullmq';
import * as bcrypt from 'bcrypt';
import type { Logger } from 'pino';
import { AddressRepository } from './db/address.db';
import { UserRepository } from './db/user.db';
import type { Address, Profile, UserProfile } from '../types';
import { ValidationError, NotFoundError } from './db/errors.db';
import { DatabaseError } from './processingErrors';
import { logger as globalLogger } from './logger.server';
import type { TokenCleanupJobData } from '../types/job-data';
import { getBaseUrl } from '../utils/serverUtils';
/**
* Encapsulates user-related business logic that may involve multiple repository calls.
*/
class UserService {
/**
* Per ADR-002, this function encapsulates a multi-write operation within a transaction.
* It creates or updates a user's address and links it to their profile atomically.
*
* @param user The user profile object.
* @param addressData The address data to upsert.
* @returns The ID of the upserted address.
*/
async upsertUserAddress(
userprofile: UserProfile,
addressData: Partial<Address>,
logger: Logger,
): Promise<number> {
return db
.withTransaction(async (client) => {
const addressRepo = new AddressRepository(client);
const userRepo = new UserRepository(client);
const addressId = await addressRepo.upsertAddress(
{ ...addressData, address_id: userprofile.address_id ?? undefined },
logger,
);
if (!userprofile.address_id) {
await userRepo.updateUserProfile(userprofile.user.user_id, { address_id: addressId }, logger);
}
return addressId;
})
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
logger.error({ err: error, userId: userprofile.user.user_id }, `Transaction to upsert user address failed: ${errorMessage}`);
// Wrap the original error in a service-level DatabaseError to standardize the error contract,
// as this is an unexpected failure within the transaction boundary.
throw new DatabaseError(errorMessage);
});
}
/**
* Processes a job to clean up expired password reset tokens from the database.
* @param job The BullMQ job object.
* @returns An object containing the count of deleted tokens.
*/
async processTokenCleanupJob(job: Job<TokenCleanupJobData>): Promise<{ deletedCount: number }> {
const logger = globalLogger.child({
jobId: job.id,
jobName: job.name,
});
logger.info('Picked up expired token cleanup job.');
try {
const deletedCount = await db.userRepo.deleteExpiredResetTokens(logger);
logger.info(`Successfully deleted ${deletedCount} expired tokens.`);
return { deletedCount };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
logger.error({ err: error, attemptsMade: job.attemptsMade }, `Expired token cleanup job failed: ${errorMessage}`);
// This is a background job, but wrapping in a standard error type is good practice.
throw new DatabaseError(errorMessage);
}
}
/**
* Updates a user's avatar, creating the URL and updating the profile.
* @param userId The ID of the user to update.
* @param file The uploaded avatar file.
* @param logger The logger instance.
* @returns The updated user profile.
*/
async updateUserAvatar(userId: string, file: Express.Multer.File, logger: Logger): Promise<Profile> {
try {
const baseUrl = getBaseUrl(logger);
const avatarUrl = `${baseUrl}/uploads/avatars/${file.filename}`;
return await db.userRepo.updateUserProfile(userId, { avatar_url: avatarUrl }, logger);
} catch (error) {
// Re-throw known application errors without logging them as system errors.
if (error instanceof NotFoundError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
logger.error({ err: error, userId }, `Failed to update user avatar: ${errorMessage}`);
// Wrap unexpected errors.
throw new DatabaseError(errorMessage);
}
}
/**
* Updates a user's password after hashing it.
* @param userId The ID of the user to update.
* @param newPassword The new plaintext password.
* @param logger The logger instance.
*/
async updateUserPassword(userId: string, newPassword: string, logger: Logger): Promise<void> {
try {
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
await db.userRepo.updateUserPassword(userId, hashedPassword, logger);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
logger.error({ err: error, userId }, `Failed to update user password: ${errorMessage}`);
// Wrap unexpected errors.
throw new DatabaseError(errorMessage);
}
}
/**
* Deletes a user's account after verifying their password.
* @param userId The ID of the user to delete.
* @param password The user's current password for verification.
* @param logger The logger instance.
*/
async deleteUserAccount(userId: string, password: string, logger: Logger): Promise<void> {
try {
const userWithHash = await db.userRepo.findUserWithPasswordHashById(userId, logger);
if (!userWithHash || !userWithHash.password_hash) {
throw new NotFoundError('User not found or password not set.');
}
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
if (!isMatch) {
throw new ValidationError([], 'Incorrect password.');
}
await db.userRepo.deleteUserById(userId, logger);
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
logger.error({ err: error, userId }, `Failed to delete user account: ${errorMessage}`);
// Wrap unexpected errors.
throw new DatabaseError(errorMessage);
}
}
/**
* Fetches a user's address, ensuring the user is authorized to view it.
* @param userProfile The profile of the user making the request.
* @param addressId The ID of the address being requested.
* @param logger The logger instance.
* @returns The address object.
*/
async getUserAddress(userProfile: UserProfile, addressId: number, logger: Logger): Promise<Address> {
if (userProfile.address_id !== addressId) {
throw new ValidationError([], 'Forbidden: You can only access your own address.');
}
try {
return await db.addressRepo.getAddressById(addressId, logger);
} catch (error) {
if (error instanceof NotFoundError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
logger.error({ err: error, userId: userProfile.user.user_id, addressId }, `Failed to get user address: ${errorMessage}`);
// Wrap unexpected errors.
throw new DatabaseError(errorMessage);
}
}
/**
* Encapsulates the business logic for an admin deleting another user's account.
* This includes preventing an admin from deleting their own account.
* @param deleterId The ID of the admin performing the deletion.
* @param userToDeleteId The ID of the user to be deleted.
* @param log The logger instance.
*/
public async deleteUserAsAdmin(deleterId: string, userToDeleteId: string, log: Logger) {
if (deleterId === userToDeleteId) {
throw new ValidationError([], 'Admins cannot delete their own account.');
}
try {
await db.userRepo.deleteUserById(userToDeleteId, log);
} catch (error) {
// Rethrow known errors so they are handled correctly by the API layer (e.g. 404 for NotFound)
if (error instanceof ValidationError || error instanceof NotFoundError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
log.error({ err: error, deleterId, userToDeleteId }, `Admin failed to delete user account: ${errorMessage}`);
// Wrap unexpected errors.
throw new DatabaseError(errorMessage);
}
}
}
export const userService = new UserService();
|