[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:
parent
5e03e15916
commit
b0a871669f
@ -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,
|
||||
|
||||
100
src/modules/users/dto/update-profile.dto.ts
Normal file
100
src/modules/users/dto/update-profile.dto.ts
Normal 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>;
|
||||
9
src/modules/users/index.ts
Normal file
9
src/modules/users/index.ts
Normal 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';
|
||||
295
src/modules/users/services/users.service.ts
Normal file
295
src/modules/users/services/users.service.ts
Normal 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();
|
||||
138
src/modules/users/types/users.types.ts
Normal file
138
src/modules/users/types/users.types.ts
Normal 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;
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user