[USERS-SERVICE] feat: Implement User Profile Service

- Add users.service.ts with getProfile, updateProfile, updateAvatar,
  changePassword, getPublicProfile, and deleteAccount methods
- Add users.types.ts with UserProfile, UpdateProfileInput, PublicProfile interfaces
- Add update-profile.dto.ts with Zod validation schemas
- Add module index.ts for clean exports
- Update users.routes.ts with new endpoints:
  - PUT /api/v1/users/me (update profile)
  - PUT /api/v1/users/me/avatar (update avatar)
  - PUT /api/v1/users/me/password (change password)
  - GET /api/v1/users/:id/public (view public profile)
  - DELETE /api/v1/users/me (soft delete account)
- Update users.controller.ts with service integration and validation

All endpoints require authentication. Password operations use bcrypt.
Account deletion is soft-delete (sets deactivated_at timestamp).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 01:02:55 -06:00
parent 5e03e15916
commit b0a871669f
6 changed files with 829 additions and 115 deletions

View File

@ -3,11 +3,35 @@
* Handles user profile and management endpoints
*/
import { Request, Response, NextFunction } from 'express';
import { Response, NextFunction } from 'express';
import { db } from '../../../shared/database';
import { AuthenticatedRequest } from '../../../core/guards/auth.guard';
import { User, Profile, UserRole, UserStatus } from '../../auth/types/auth.types';
import { logger } from '../../../shared/utils/logger';
import { usersService } from '../services/users.service';
import {
updateProfileSchema,
updateAvatarSchema,
changePasswordSchema,
deleteAccountSchema,
} from '../dto/update-profile.dto';
// Type aliases for query parameters
type QueryParamValue = string | number | boolean | null | undefined | Date | object;
// Interface for user list row
interface UserListRow {
id: string;
email: string;
email_verified: boolean;
role: string;
status: string;
last_login_at: Date | null;
created_at: Date;
first_name: string | null;
last_name: string | null;
display_name: string | null;
avatar_url: string | null;
}
/**
* Get current user profile
@ -29,70 +53,28 @@ export const getCurrentUser = async (
return;
}
const userQuery = `
SELECT
u.id, u.email, u.email_verified, u.phone, u.phone_verified,
u.primary_auth_provider, u.totp_enabled, u.role, u.status,
u.last_login_at, u.created_at, u.updated_at,
p.first_name, p.last_name, p.display_name, p.avatar_url,
p.date_of_birth, p.country_code, p.timezone, p.language,
p.preferred_currency
FROM auth.users u
LEFT JOIN auth.profiles p ON u.id = p.user_id
WHERE u.id = $1
`;
const profile = await usersService.getProfile(userId);
const result = await db.query(userQuery, [userId]);
if (result.rows.length === 0) {
res.json({
success: true,
data: profile,
});
} catch (error) {
logger.error('Error getting current user:', error);
if ((error as Error).message === 'User not found') {
res.status(404).json({
success: false,
error: { message: 'User not found', code: 'NOT_FOUND' },
});
return;
}
const row = result.rows[0];
const user = {
id: row.id,
email: row.email,
emailVerified: row.email_verified,
phone: row.phone,
phoneVerified: row.phone_verified,
primaryAuthProvider: row.primary_auth_provider,
totpEnabled: row.totp_enabled,
role: row.role as UserRole,
status: row.status as UserStatus,
lastLoginAt: row.last_login_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
profile: row.first_name || row.last_name ? {
firstName: row.first_name,
lastName: row.last_name,
displayName: row.display_name,
avatarUrl: row.avatar_url,
dateOfBirth: row.date_of_birth,
countryCode: row.country_code,
timezone: row.timezone || 'UTC',
language: row.language || 'en',
preferredCurrency: row.preferred_currency || 'USD',
} : undefined,
};
res.json({
success: true,
data: user,
});
} catch (error) {
logger.error('Error getting current user:', error);
next(error);
}
};
/**
* Update current user profile
* PATCH /api/v1/users/me
* PUT /api/v1/users/me
*/
export const updateCurrentUser = async (
req: AuthenticatedRequest,
@ -110,67 +92,25 @@ export const updateCurrentUser = async (
return;
}
const {
firstName,
lastName,
displayName,
avatarUrl,
dateOfBirth,
countryCode,
timezone,
language,
preferredCurrency,
} = req.body;
// Validate input
const validation = updateProfileSchema.safeParse(req.body);
if (!validation.success) {
res.status(400).json({
success: false,
error: {
message: 'Validation failed',
code: 'VALIDATION_ERROR',
details: validation.error.errors,
},
});
return;
}
// Update or insert profile
const upsertQuery = `
INSERT INTO auth.profiles (
user_id, first_name, last_name, display_name, avatar_url,
date_of_birth, country_code, timezone, language, preferred_currency,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
ON CONFLICT (user_id) DO UPDATE SET
first_name = COALESCE($2, auth.profiles.first_name),
last_name = COALESCE($3, auth.profiles.last_name),
display_name = COALESCE($4, auth.profiles.display_name),
avatar_url = COALESCE($5, auth.profiles.avatar_url),
date_of_birth = COALESCE($6, auth.profiles.date_of_birth),
country_code = COALESCE($7, auth.profiles.country_code),
timezone = COALESCE($8, auth.profiles.timezone),
language = COALESCE($9, auth.profiles.language),
preferred_currency = COALESCE($10, auth.profiles.preferred_currency),
updated_at = NOW()
RETURNING *
`;
const result = await db.query(upsertQuery, [
userId,
firstName,
lastName,
displayName,
avatarUrl,
dateOfBirth,
countryCode,
timezone,
language,
preferredCurrency,
]);
const profile = result.rows[0];
const profile = await usersService.updateProfile(userId, validation.data);
res.json({
success: true,
data: {
firstName: profile.first_name,
lastName: profile.last_name,
displayName: profile.display_name,
avatarUrl: profile.avatar_url,
dateOfBirth: profile.date_of_birth,
countryCode: profile.country_code,
timezone: profile.timezone,
language: profile.language,
preferredCurrency: profile.preferred_currency,
},
data: profile,
message: 'Profile updated successfully',
});
} catch (error) {
@ -179,6 +119,211 @@ export const updateCurrentUser = async (
}
};
/**
* Update current user avatar
* PUT /api/v1/users/me/avatar
*/
export const updateAvatar = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Not authenticated', code: 'UNAUTHORIZED' },
});
return;
}
// Validate input
const validation = updateAvatarSchema.safeParse(req.body);
if (!validation.success) {
res.status(400).json({
success: false,
error: {
message: 'Validation failed',
code: 'VALIDATION_ERROR',
details: validation.error.errors,
},
});
return;
}
await usersService.updateAvatar(userId, validation.data.avatarUrl);
res.json({
success: true,
message: 'Avatar updated successfully',
});
} catch (error) {
logger.error('Error updating avatar:', error);
next(error);
}
};
/**
* Change current user password
* PUT /api/v1/users/me/password
*/
export const changePassword = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Not authenticated', code: 'UNAUTHORIZED' },
});
return;
}
// Validate input
const validation = changePasswordSchema.safeParse(req.body);
if (!validation.success) {
res.status(400).json({
success: false,
error: {
message: 'Validation failed',
code: 'VALIDATION_ERROR',
details: validation.error.errors,
},
});
return;
}
await usersService.changePassword(
userId,
validation.data.currentPassword,
validation.data.newPassword
);
res.json({
success: true,
message: 'Password changed successfully',
});
} catch (error) {
const errorMessage = (error as Error).message;
logger.error('Error changing password:', error);
if (errorMessage === 'Current password is incorrect') {
res.status(400).json({
success: false,
error: { message: errorMessage, code: 'INVALID_PASSWORD' },
});
return;
}
if (errorMessage === 'Cannot change password for social login accounts') {
res.status(400).json({
success: false,
error: { message: errorMessage, code: 'SOCIAL_ACCOUNT' },
});
return;
}
next(error);
}
};
/**
* Get public profile of another user
* GET /api/v1/users/:id/public
*/
export const getPublicProfile = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const { id } = req.params;
const profile = await usersService.getPublicProfile(id);
res.json({
success: true,
data: profile,
});
} catch (error) {
logger.error('Error getting public profile:', error);
if ((error as Error).message === 'User not found') {
res.status(404).json({
success: false,
error: { message: 'User not found', code: 'NOT_FOUND' },
});
return;
}
next(error);
}
};
/**
* Delete current user account (soft delete)
* DELETE /api/v1/users/me
*/
export const deleteCurrentUser = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({
success: false,
error: { message: 'Not authenticated', code: 'UNAUTHORIZED' },
});
return;
}
// Validate input
const validation = deleteAccountSchema.safeParse(req.body);
if (!validation.success) {
res.status(400).json({
success: false,
error: {
message: 'Validation failed',
code: 'VALIDATION_ERROR',
details: validation.error.errors,
},
});
return;
}
await usersService.deleteAccount(
userId,
validation.data.password,
validation.data.reason
);
res.json({
success: true,
message: 'Account deleted successfully',
});
} catch (error) {
const errorMessage = (error as Error).message;
logger.error('Error deleting account:', error);
if (errorMessage === 'Invalid password') {
res.status(400).json({
success: false,
error: { message: errorMessage, code: 'INVALID_PASSWORD' },
});
return;
}
next(error);
}
};
/**
* Get user by ID (admin only)
* GET /api/v1/users/:id
@ -263,7 +408,7 @@ export const listUsers = async (
const offset = (pageNum - 1) * limitNum;
let whereClause = 'WHERE 1=1';
const params: any[] = [];
const params: QueryParamValue[] = [];
let paramIndex = 1;
if (role) {
@ -311,7 +456,7 @@ export const listUsers = async (
params.push(limitNum, offset);
const dataResult = await db.query(dataQuery, params);
const users = dataResult.rows.map((row: any) => ({
const users = dataResult.rows.map((row: UserListRow) => ({
id: row.id,
email: row.email,
emailVerified: row.email_verified,

View File

@ -0,0 +1,100 @@
// ============================================================================
// Trading Platform - Users DTOs
// Validation schemas for user profile operations
// ============================================================================
import { z } from 'zod';
/**
* DTO for updating user profile
*/
export const updateProfileSchema = z.object({
displayName: z
.string()
.min(2, 'Display name must be at least 2 characters')
.max(200, 'Display name must be at most 200 characters')
.optional(),
firstName: z
.string()
.min(1, 'First name must be at least 1 character')
.max(100, 'First name must be at most 100 characters')
.optional(),
lastName: z
.string()
.min(1, 'Last name must be at least 1 character')
.max(100, 'Last name must be at most 100 characters')
.optional(),
bio: z
.string()
.max(1000, 'Bio must be at most 1000 characters')
.optional(),
phoneNumber: z
.string()
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number format')
.optional()
.nullable(),
countryCode: z
.string()
.length(2, 'Country code must be 2 characters (ISO 3166-1 alpha-2)')
.optional()
.nullable(),
timezone: z
.string()
.max(50, 'Timezone must be at most 50 characters')
.optional(),
language: z
.string()
.max(10, 'Language code must be at most 10 characters')
.optional(),
});
export type UpdateProfileDto = z.infer<typeof updateProfileSchema>;
/**
* DTO for updating avatar
*/
export const updateAvatarSchema = z.object({
avatarUrl: z
.string()
.url('Invalid avatar URL')
.max(2048, 'Avatar URL must be at most 2048 characters'),
});
export type UpdateAvatarDto = z.infer<typeof updateAvatarSchema>;
/**
* DTO for changing password
*/
export const changePasswordSchema = z.object({
currentPassword: z
.string()
.min(1, 'Current password is required'),
newPassword: z
.string()
.min(8, 'Password must be at least 8 characters')
.max(128, 'Password must be at most 128 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(
/[!@#$%^&*(),.?":{}|<>]/,
'Password must contain at least one special character'
),
});
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
/**
* DTO for deleting account
*/
export const deleteAccountSchema = z.object({
password: z
.string()
.min(1, 'Password is required to delete account'),
reason: z
.string()
.max(500, 'Reason must be at most 500 characters')
.optional(),
});
export type DeleteAccountDto = z.infer<typeof deleteAccountSchema>;

View File

@ -0,0 +1,9 @@
// ============================================================================
// Trading Platform - Users Module
// User profile management
// ============================================================================
export { usersRouter } from './users.routes';
export { usersService } from './services/users.service';
export * from './types/users.types';
export * from './dto/update-profile.dto';

View File

@ -0,0 +1,295 @@
// ============================================================================
// Trading Platform - Users Service
// Business logic for user profile management
// ============================================================================
import bcrypt from 'bcryptjs';
import { db } from '../../../shared/database';
import { logger } from '../../../shared/utils/logger';
import type {
UserProfile,
UpdateProfileInput,
PublicProfile,
UserWithProfileRow,
UserRow,
} from '../types/users.types';
class UsersService {
/**
* Get authenticated user's full profile
*/
async getProfile(userId: string): Promise<UserProfile> {
const query = `
SELECT
u.id, u.email, u.phone_number, u.created_at, u.updated_at,
p.first_name, p.last_name, p.display_name, p.avatar_url,
p.bio, p.timezone, p.language, p.country_code
FROM auth.users u
LEFT JOIN auth.user_profiles p ON u.id = p.user_id
WHERE u.id = $1 AND u.deactivated_at IS NULL
`;
const result = await db.query<UserWithProfileRow>(query, [userId]);
if (result.rows.length === 0) {
throw new Error('User not found');
}
const row = result.rows[0];
return {
id: row.id,
email: row.email,
displayName: row.display_name,
firstName: row.first_name,
lastName: row.last_name,
avatarUrl: row.avatar_url,
bio: row.bio,
phoneNumber: row.phone_number,
countryCode: row.country_code,
timezone: row.timezone || 'UTC',
language: row.language || 'en',
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* Update authenticated user's profile
*/
async updateProfile(
userId: string,
data: UpdateProfileInput
): Promise<UserProfile> {
const {
displayName,
firstName,
lastName,
bio,
phoneNumber,
countryCode,
timezone,
language,
} = data;
// Upsert profile
const upsertQuery = `
INSERT INTO auth.user_profiles (
user_id, first_name, last_name, display_name, bio,
country_code, timezone, language, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (user_id) DO UPDATE SET
first_name = COALESCE($2, auth.user_profiles.first_name),
last_name = COALESCE($3, auth.user_profiles.last_name),
display_name = COALESCE($4, auth.user_profiles.display_name),
bio = COALESCE($5, auth.user_profiles.bio),
country_code = COALESCE($6, auth.user_profiles.country_code),
timezone = COALESCE($7, auth.user_profiles.timezone),
language = COALESCE($8, auth.user_profiles.language),
updated_at = NOW()
RETURNING *
`;
await db.query(upsertQuery, [
userId,
firstName,
lastName,
displayName,
bio,
countryCode,
timezone,
language,
]);
// Update phone number in users table if provided
if (phoneNumber !== undefined) {
await db.query(
'UPDATE auth.users SET phone_number = $1, updated_at = NOW() WHERE id = $2',
[phoneNumber, userId]
);
}
logger.info('User profile updated', { userId });
return this.getProfile(userId);
}
/**
* Update user's avatar URL
*/
async updateAvatar(userId: string, avatarUrl: string): Promise<void> {
const query = `
INSERT INTO auth.user_profiles (user_id, avatar_url, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
avatar_url = $2,
updated_at = NOW()
`;
await db.query(query, [userId, avatarUrl]);
logger.info('User avatar updated', { userId });
}
/**
* Change user's password
*/
async changePassword(
userId: string,
currentPassword: string,
newPassword: string
): Promise<void> {
// Get user with password hash
const userQuery = `
SELECT id, password_hash
FROM auth.users
WHERE id = $1 AND deactivated_at IS NULL
`;
const userResult = await db.query<UserRow>(userQuery, [userId]);
if (userResult.rows.length === 0) {
throw new Error('User not found');
}
const user = userResult.rows[0];
if (!user.password_hash) {
throw new Error('Cannot change password for social login accounts');
}
// Verify current password
const isValid = await bcrypt.compare(currentPassword, user.password_hash);
if (!isValid) {
throw new Error('Current password is incorrect');
}
// Hash new password
const newPasswordHash = await bcrypt.hash(newPassword, 12);
// Update password
await db.query(
'UPDATE auth.users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
[newPasswordHash, userId]
);
// Log password change event
await db.query(
`INSERT INTO audit.audit_logs (
entity_type, entity_id, action, user_id, changes
) VALUES ('user', $1, 'password_changed', $1, '{}')`,
[userId]
);
logger.info('User password changed', { userId });
}
/**
* Get public profile of another user
*/
async getPublicProfile(userId: string): Promise<PublicProfile> {
const query = `
SELECT
u.id, u.created_at,
p.display_name, p.avatar_url, p.bio
FROM auth.users u
LEFT JOIN auth.user_profiles p ON u.id = p.user_id
WHERE u.id = $1
AND u.status = 'active'
AND u.deactivated_at IS NULL
`;
const result = await db.query<UserWithProfileRow>(query, [userId]);
if (result.rows.length === 0) {
throw new Error('User not found');
}
const row = result.rows[0];
return {
id: row.id,
displayName: row.display_name,
avatarUrl: row.avatar_url,
bio: row.bio,
memberSince: row.created_at,
};
}
/**
* Delete (soft delete) user account
*/
async deleteAccount(
userId: string,
password: string,
reason?: string
): Promise<void> {
// Get user with password hash
const userQuery = `
SELECT id, password_hash, email
FROM auth.users
WHERE id = $1 AND deactivated_at IS NULL
`;
const userResult = await db.query<UserRow & { email: string }>(
userQuery,
[userId]
);
if (userResult.rows.length === 0) {
throw new Error('User not found');
}
const user = userResult.rows[0];
if (!user.password_hash) {
throw new Error(
'Cannot delete account without password. Please contact support.'
);
}
// Verify password
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
throw new Error('Invalid password');
}
// Soft delete: set deactivated_at timestamp
await db.query(
`UPDATE auth.users SET
deactivated_at = NOW(),
status = 'pending_verification',
updated_at = NOW()
WHERE id = $1`,
[userId]
);
// Log deletion event
await db.query(
`INSERT INTO audit.audit_logs (
entity_type, entity_id, action, user_id, changes
) VALUES ('user', $1, 'account_deleted', $1, $2)`,
[userId, JSON.stringify({ reason: reason || 'User requested deletion' })]
);
logger.info('User account deleted (soft delete)', {
userId,
email: user.email,
reason,
});
}
/**
* Check if user exists and is active
*/
async userExists(userId: string): Promise<boolean> {
const result = await db.query(
`SELECT 1 FROM auth.users
WHERE id = $1 AND deactivated_at IS NULL`,
[userId]
);
return result.rows.length > 0;
}
}
export const usersService = new UsersService();

View File

@ -0,0 +1,138 @@
// ============================================================================
// Trading Platform - Users Types
// Aligned with DDL: auth.users + auth.user_profiles
// ============================================================================
/**
* Full user profile (private - only for authenticated user)
* Combines data from auth.users and auth.user_profiles
*/
export interface UserProfile {
id: string;
email: string;
displayName: string | null;
firstName: string | null;
lastName: string | null;
avatarUrl: string | null;
bio: string | null;
phoneNumber: string | null;
countryCode: string | null;
timezone: string;
language: string;
createdAt: Date;
updatedAt: Date;
}
/**
* Input for updating user profile
*/
export interface UpdateProfileInput {
displayName?: string;
firstName?: string;
lastName?: string;
bio?: string;
phoneNumber?: string | null;
countryCode?: string | null;
timezone?: string;
language?: string;
}
/**
* Public profile (visible to other users)
*/
export interface PublicProfile {
id: string;
displayName: string | null;
avatarUrl: string | null;
bio: string | null;
memberSince: Date;
}
/**
* Database row type for user with profile (snake_case)
*/
export interface UserWithProfileRow {
id: string;
email: string;
email_verified: boolean;
phone_number: string | null;
phone_verified: boolean;
status: string;
role: string;
last_login_at: Date | null;
created_at: Date;
updated_at: Date;
// Profile fields
first_name: string | null;
last_name: string | null;
display_name: string | null;
avatar_url: string | null;
bio: string | null;
timezone: string | null;
language: string | null;
country_code: string | null;
}
/**
* Database row type for user profile (snake_case)
*/
export interface UserProfileRow {
id: string;
user_id: string;
first_name: string | null;
last_name: string | null;
display_name: string | null;
avatar_url: string | null;
bio: string | null;
language: string;
timezone: string;
country_code: string | null;
newsletter_subscribed: boolean;
marketing_emails_enabled: boolean;
notifications_enabled: boolean;
metadata: Record<string, unknown>;
created_at: Date;
updated_at: Date;
}
/**
* Database row for user (snake_case)
*/
export interface UserRow {
id: string;
email: string;
email_verified: boolean;
password_hash: string | null;
phone_number: string | null;
phone_verified: boolean;
status: string;
role: string;
mfa_enabled: boolean;
last_login_at: Date | null;
deactivated_at: Date | null;
created_at: Date;
updated_at: Date;
}
/**
* Change password input
*/
export interface ChangePasswordInput {
currentPassword: string;
newPassword: string;
}
/**
* Delete account input
*/
export interface DeleteAccountInput {
password: string;
reason?: string;
}
/**
* Update avatar input
*/
export interface UpdateAvatarInput {
avatarUrl: string;
}

View File

@ -24,11 +24,38 @@ const authHandler = (fn: Function): RequestHandler => fn as RequestHandler;
router.get('/me', requireAuth, authHandler(usersController.getCurrentUser));
/**
* PATCH /api/v1/users/me
* PUT /api/v1/users/me
* Update current user profile
* Body: { firstName?, lastName?, displayName?, avatarUrl?, timezone?, language?, preferredCurrency? }
* Body: { displayName?, firstName?, lastName?, bio?, phoneNumber?, countryCode?, timezone?, language? }
*/
router.patch('/me', requireAuth, authHandler(usersController.updateCurrentUser));
router.put('/me', requireAuth, authHandler(usersController.updateCurrentUser));
/**
* PUT /api/v1/users/me/avatar
* Update current user avatar
* Body: { avatarUrl: string }
*/
router.put('/me/avatar', requireAuth, authHandler(usersController.updateAvatar));
/**
* PUT /api/v1/users/me/password
* Change current user password
* Body: { currentPassword: string, newPassword: string }
*/
router.put('/me/password', requireAuth, authHandler(usersController.changePassword));
/**
* DELETE /api/v1/users/me
* Delete current user account (soft delete)
* Body: { password: string, reason?: string }
*/
router.delete('/me', requireAuth, authHandler(usersController.deleteCurrentUser));
/**
* GET /api/v1/users/:id/public
* Get public profile of another user
*/
router.get('/:id/public', requireAuth, authHandler(usersController.getPublicProfile));
// ============================================================================
// Admin Routes (Admin only)