Files
lcbp3/specs/06-tasks/TASK-BE-013-user-management.md

19 KiB

Task: User Management Module

Status: Not Started Priority: P1 (High - Core User Features) Estimated Effort: 5-7 days Dependencies: TASK-BE-001 (Database), TASK-BE-002 (Auth & RBAC) Owner: Backend Team


📋 Overview

สร้าง User Management Module สำหรับจัดการ Users, User Profiles, Password Management, และ User Preferences


🎯 Objectives

  • User CRUD Operations
  • User Profile Management
  • Password Change & Reset
  • User Preferences (Settings)
  • User Avatar Upload
  • User Search & Filter

📝 Acceptance Criteria

  1. User Management:

    • Create user with default password
    • Update user information
    • Activate/Deactivate users
    • Soft delete users
    • Search users by name/email/username
  2. Profile Management:

    • User can view own profile
    • User can update own profile
    • Upload avatar/profile picture
    • Change display name
  3. Password Management:

    • Change password (authenticated)
    • Reset password (forgot password flow)
    • Password strength validation
    • Password history (prevent reuse)
  4. User Preferences:

    • Email notification settings
    • LINE Notify token
    • Language preference (TH/EN)
    • Timezone settings

🛠️ Implementation Steps

1. User Service

// File: backend/src/modules/user/user.service.ts
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private userRepo: Repository<User>,
    @InjectRepository(UserPreference)
    private preferenceRepo: Repository<UserPreference>,
    private fileStorage: FileStorageService
  ) {}

  async create(dto: CreateUserDto): Promise<User> {
    // Check unique username and email
    const existingUsername = await this.userRepo.findOne({
      where: { username: dto.username },
    });

    if (existingUsername) {
      throw new ConflictException('Username already exists');
    }

    const existingEmail = await this.userRepo.findOne({
      where: { email: dto.email },
    });

    if (existingEmail) {
      throw new ConflictException('Email already exists');
    }

    // Hash default password
    const defaultPassword = dto.password || this.generateRandomPassword();
    const passwordHash = await bcrypt.hash(defaultPassword, 10);

    // Create user
    const user = this.userRepo.create({
      username: dto.username,
      email: dto.email,
      first_name: dto.first_name,
      last_name: dto.last_name,
      organization_id: dto.organization_id,
      password_hash: passwordHash,
      is_active: true,
      must_change_password: true, // Force password change on first login
    });

    await this.userRepo.save(user);

    // Create default preferences
    await this.createDefaultPreferences(user.user_id);

    return user;
  }

  async update(id: number, dto: UpdateUserDto): Promise<User> {
    const user = await this.findOne(id);

    // Check unique email if changed
    if (dto.email && dto.email !== user.email) {
      const existing = await this.userRepo.findOne({
        where: { email: dto.email },
      });

      if (existing) {
        throw new ConflictException('Email already exists');
      }
    }

    Object.assign(user, dto);
    return this.userRepo.save(user);
  }

  async findAll(query: SearchUserDto): Promise<PaginatedResult<User>> {
    const queryBuilder = this.userRepo
      .createQueryBuilder('user')
      .leftJoinAndSelect('user.organization', 'org')
      .where('user.deleted_at IS NULL');

    // Search filters
    if (query.search) {
      queryBuilder.andWhere(
        '(user.username LIKE :search OR user.email LIKE :search OR ' +
          'user.first_name LIKE :search OR user.last_name LIKE :search)',
        { search: `%${query.search}%` }
      );
    }

    if (query.organization_id) {
      queryBuilder.andWhere('user.organization_id = :orgId', {
        orgId: query.organization_id,
      });
    }

    if (query.is_active !== undefined) {
      queryBuilder.andWhere('user.is_active = :isActive', {
        isActive: query.is_active,
      });
    }

    // Pagination
    const page = query.page || 1;
    const limit = query.limit || 20;
    const skip = (page - 1) * limit;

    const [items, total] = await queryBuilder
      .orderBy('user.created_at', 'DESC')
      .skip(skip)
      .take(limit)
      .getManyAndCount();

    // Remove sensitive data
    items.forEach((user) => this.sanitizeUser(user));

    return {
      items,
      total,
      page,
      limit,
      totalPages: Math.ceil(total / limit),
    };
  }

  async findOne(id: number): Promise<User> {
    const user = await this.userRepo.findOne({
      where: { user_id: id, deleted_at: IsNull() },
      relations: ['organization'],
    });

    if (!user) {
      throw new NotFoundException(`User #${id} not found`);
    }

    return this.sanitizeUser(user);
  }

  async toggleActive(id: number): Promise<User> {
    const user = await this.userRepo.findOne({ where: { user_id: id } });

    if (!user) {
      throw new NotFoundException(`User #${id} not found`);
    }

    user.is_active = !user.is_active;
    return this.userRepo.save(user);
  }

  async softDelete(id: number): Promise<void> {
    const user = await this.findOne(id);

    // Prevent deletion of users with active sessions or critical roles
    const hasActiveSessions = await this.hasActiveSessions(id);
    if (hasActiveSessions) {
      throw new BadRequestException('Cannot delete user with active sessions');
    }

    await this.userRepo.softDelete(id);
  }

  private sanitizeUser(user: User): User {
    delete user.password_hash;
    return user;
  }

  private generateRandomPassword(): string {
    return (
      Math.random().toString(36).slice(-8) +
      Math.random().toString(36).slice(-8)
    );
  }

  private async createDefaultPreferences(userId: number): Promise<void> {
    const preferences = this.preferenceRepo.create({
      user_id: userId,
      language: 'th',
      timezone: 'Asia/Bangkok',
      email_notifications_enabled: true,
      line_notify_enabled: false,
    });

    await this.preferenceRepo.save(preferences);
  }

  private async hasActiveSessions(userId: number): Promise<boolean> {
    // Check Redis for active sessions
    // Implementation depends on session management strategy
    return false;
  }
}

2. Profile Service

// File: backend/src/modules/user/profile.service.ts
@Injectable()
export class ProfileService {
  constructor(
    @InjectRepository(User)
    private userRepo: Repository<User>,
    @InjectRepository(UserPreference)
    private preferenceRepo: Repository<UserPreference>,
    private fileStorage: FileStorageService
  ) {}

  async getProfile(userId: number): Promise<UserProfile> {
    const user = await this.userRepo.findOne({
      where: { user_id: userId },
      relations: ['organization', 'preferences'],
    });

    if (!user) {
      throw new NotFoundException('User not found');
    }

    const preferences = await this.preferenceRepo.findOne({
      where: { user_id: userId },
    });

    return {
      user_id: user.user_id,
      username: user.username,
      email: user.email,
      first_name: user.first_name,
      last_name: user.last_name,
      display_name: user.display_name,
      organization: user.organization,
      avatar_url: user.avatar_url,
      preferences,
    };
  }

  async updateProfile(userId: number, dto: UpdateProfileDto): Promise<User> {
    const user = await this.userRepo.findOne({ where: { user_id: userId } });

    if (!user) {
      throw new NotFoundException('User not found');
    }

    // Update allowed fields only
    if (dto.first_name) user.first_name = dto.first_name;
    if (dto.last_name) user.last_name = dto.last_name;
    if (dto.display_name) user.display_name = dto.display_name;
    if (dto.phone) user.phone = dto.phone;

    return this.userRepo.save(user);
  }

  async uploadAvatar(
    userId: number,
    file: Express.Multer.File
  ): Promise<string> {
    // Upload to temp storage
    const uploadResult = await this.fileStorage.uploadToTemp(file, userId);

    // Commit to permanent storage
    const attachments = await this.fileStorage.commitFiles(
      [uploadResult.temp_id],
      userId,
      'user_avatar',
      this.userRepo.manager
    );

    const avatarUrl = `/attachments/${attachments[0].id}`;

    // Update user avatar_url
    await this.userRepo.update(userId, { avatar_url: avatarUrl });

    return avatarUrl;
  }

  async updatePreferences(
    userId: number,
    dto: UpdatePreferencesDto
  ): Promise<UserPreference> {
    let preferences = await this.preferenceRepo.findOne({
      where: { user_id: userId },
    });

    if (!preferences) {
      preferences = this.preferenceRepo.create({ user_id: userId });
    }

    Object.assign(preferences, dto);
    return this.preferenceRepo.save(preferences);
  }
}

3. Password Service

// File: backend/src/modules/user/password.service.ts
@Injectable()
export class PasswordService {
  constructor(
    @InjectRepository(User)
    private userRepo: Repository<User>,
    @InjectRepository(PasswordHistory)
    private passwordHistoryRepo: Repository<PasswordHistory>,
    private redis: Redis,
    private emailQueue: Queue
  ) {}

  async changePassword(userId: number, dto: ChangePasswordDto): Promise<void> {
    const user = await this.userRepo.findOne({ where: { user_id: userId } });

    if (!user) {
      throw new NotFoundException('User not found');
    }

    // Verify current password
    const isValid = await bcrypt.compare(
      dto.current_password,
      user.password_hash
    );

    if (!isValid) {
      throw new BadRequestException('Current password is incorrect');
    }

    // Validate new password strength
    this.validatePasswordStrength(dto.new_password);

    // Check password history (prevent reuse of last 5 passwords)
    await this.checkPasswordHistory(userId, dto.new_password);

    // Hash new password
    const newPasswordHash = await bcrypt.hash(dto.new_password, 10);

    // Update password
    user.password_hash = newPasswordHash;
    user.must_change_password = false;
    user.password_changed_at = new Date();
    await this.userRepo.save(user);

    // Save to password history
    await this.passwordHistoryRepo.save({
      user_id: userId,
      password_hash: newPasswordHash,
    });

    // Invalidate all existing sessions
    await this.invalidateUserSessions(userId);
  }

  async requestPasswordReset(email: string): Promise<void> {
    const user = await this.userRepo.findOne({ where: { email } });

    if (!user) {
      // Don't reveal if email exists
      return;
    }

    // Generate reset token
    const resetToken = this.generateResetToken();
    const resetTokenHash = await bcrypt.hash(resetToken, 10);

    // Store token in Redis (expires in 1 hour)
    await this.redis.set(
      `password_reset:${user.user_id}`,
      resetTokenHash,
      'EX',
      3600
    );

    // Send reset email
    await this.emailQueue.add('send-password-reset', {
      to: user.email,
      resetToken,
      username: user.username,
    });
  }

  async resetPassword(dto: ResetPasswordDto): Promise<void> {
    const user = await this.userRepo.findOne({
      where: { username: dto.username },
    });

    if (!user) {
      throw new BadRequestException('Invalid reset token');
    }

    // Verify reset token
    const storedTokenHash = await this.redis.get(
      `password_reset:${user.user_id}`
    );

    if (!storedTokenHash) {
      throw new BadRequestException('Reset token expired');
    }

    const isValid = await bcrypt.compare(dto.reset_token, storedTokenHash);

    if (!isValid) {
      throw new BadRequestException('Invalid reset token');
    }

    // Validate new password
    this.validatePasswordStrength(dto.new_password);

    // Hash and update password
    const newPasswordHash = await bcrypt.hash(dto.new_password, 10);
    user.password_hash = newPasswordHash;
    user.password_changed_at = new Date();
    await this.userRepo.save(user);

    // Delete reset token
    await this.redis.del(`password_reset:${user.user_id}`);

    // Invalidate sessions
    await this.invalidateUserSessions(user.user_id);
  }

  private validatePasswordStrength(password: string): void {
    if (password.length < 8) {
      throw new BadRequestException('Password must be at least 8 characters');
    }

    // Check for at least one uppercase, one lowercase, one number
    const hasUpperCase = /[A-Z]/.test(password);
    const hasLowerCase = /[a-z]/.test(password);
    const hasNumber = /[0-9]/.test(password);

    if (!hasUpperCase || !hasLowerCase || !hasNumber) {
      throw new BadRequestException(
        'Password must contain uppercase, lowercase, and numbers'
      );
    }
  }

  private async checkPasswordHistory(
    userId: number,
    newPassword: string
  ): Promise<void> {
    const history = await this.passwordHistoryRepo.find({
      where: { user_id: userId },
      order: { changed_at: 'DESC' },
      take: 5,
    });

    for (const record of history) {
      const isSame = await bcrypt.compare(newPassword, record.password_hash);
      if (isSame) {
        throw new BadRequestException('Cannot reuse recently used passwords');
      }
    }
  }

  private generateResetToken(): string {
    return (
      Math.random().toString(36).substring(2, 15) +
      Math.random().toString(36).substring(2, 15)
    );
  }

  private async invalidateUserSessions(userId: number): Promise<void> {
    await this.redis.del(`user:${userId}:permissions`);
    await this.redis.del(`refresh_token:${userId}`);
  }
}

4. User Controller

// File: backend/src/modules/user/user.controller.ts
@Controller('users')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('Users')
export class UserController {
  constructor(
    private userService: UserService,
    private profileService: ProfileService,
    private passwordService: PasswordService
  ) {}

  // User Management (Admin)
  @Get()
  @RequirePermission('user.view')
  async findAll(@Query() query: SearchUserDto) {
    return this.userService.findAll(query);
  }

  @Post()
  @RequirePermission('user.create')
  async create(@Body() dto: CreateUserDto) {
    return this.userService.create(dto);
  }

  @Put(':id')
  @RequirePermission('user.update')
  async update(
    @Param('id', ParseIntPipe) id: number,
    @Body() dto: UpdateUserDto
  ) {
    return this.userService.update(id, dto);
  }

  @Post(':id/toggle-active')
  @RequirePermission('user.update')
  async toggleActive(@Param('id', ParseIntPipe) id: number) {
    return this.userService.toggleActive(id);
  }

  @Delete(':id')
  @RequirePermission('user.delete')
  @HttpCode(204)
  async delete(@Param('id', ParseIntPipe) id: number) {
    return this.userService.softDelete(id);
  }

  // Profile Management (Self)
  @Get('me/profile')
  async getMyProfile(@CurrentUser() user: User) {
    return this.profileService.getProfile(user.user_id);
  }

  @Put('me/profile')
  async updateMyProfile(
    @CurrentUser() user: User,
    @Body() dto: UpdateProfileDto
  ) {
    return this.profileService.updateProfile(user.user_id, dto);
  }

  @Post('me/avatar')
  @UseInterceptors(FileInterceptor('avatar'))
  async uploadAvatar(
    @CurrentUser() user: User,
    @UploadedFile() file: Express.Multer.File
  ) {
    return this.profileService.uploadAvatar(user.user_id, file);
  }

  @Put('me/preferences')
  async updatePreferences(
    @CurrentUser() user: User,
    @Body() dto: UpdatePreferencesDto
  ) {
    return this.profileService.updatePreferences(user.user_id, dto);
  }

  // Password Management
  @Post('me/change-password')
  async changePassword(
    @CurrentUser() user: User,
    @Body() dto: ChangePasswordDto
  ) {
    return this.passwordService.changePassword(user.user_id, dto);
  }

  @Post('request-password-reset')
  @Public() // No auth required
  async requestPasswordReset(@Body() dto: RequestPasswordResetDto) {
    return this.passwordService.requestPasswordReset(dto.email);
  }

  @Post('reset-password')
  @Public() // No auth required
  async resetPassword(@Body() dto: ResetPasswordDto) {
    return this.passwordService.resetPassword(dto);
  }
}

Testing & Verification

1. Unit Tests

describe('UserService', () => {
  it('should create user with hashed password', async () => {
    const dto = {
      username: 'testuser',
      email: 'test@example.com',
      first_name: 'Test',
      last_name: 'User',
      organization_id: 1,
    };

    const result = await service.create(dto);

    expect(result.username).toBe('testuser');
    expect(result.password_hash).toBeUndefined(); // Sanitized
    expect(result.must_change_password).toBe(true);
  });

  it('should prevent duplicate username', async () => {
    await expect(
      service.create({ username: 'admin', email: 'new@example.com' })
    ).rejects.toThrow(ConflictException);
  });
});

describe('PasswordService', () => {
  it('should change password successfully', async () => {
    await service.changePassword(1, {
      current_password: 'oldPassword123',
      new_password: 'NewPassword123',
    });

    // Verify password updated
  });

  it('should prevent password reuse', async () => {
    await expect(
      service.changePassword(1, {
        current_password: 'current',
        new_password: 'previouslyUsed',
      })
    ).rejects.toThrow('Cannot reuse recently used passwords');
  });

  it('should validate password strength', async () => {
    await expect(
      service.changePassword(1, {
        current_password: 'current',
        new_password: 'weak',
      })
    ).rejects.toThrow('Password must be at least 8 characters');
  });
});


📦 Deliverables

  • UserService (CRUD)
  • ProfileService (Profile & Avatar)
  • PasswordService (Change & Reset)
  • UserController
  • DTOs (Create, Update, Profile, Password)
  • Password History tracking
  • Unit Tests (85% coverage)
  • Integration Tests
  • API Documentation

🚨 Risks & Mitigation

Risk Impact Mitigation
Password reset abuse High Rate limiting, token expiration
Session hijacking Critical Session invalidation on password change
Weak passwords High Password strength validation
Email not delivered Medium Logging + retry mechanism

📌 Notes

  • Default password generated on user creation
  • Force password change on first login
  • Password history prevents reuse (last 5 passwords)
  • Reset token expires in 1 hour
  • All sessions invalidated on password change
  • Avatar uploaded via two-phase storage
  • User preferences stored separately
  • Soft delete for users
  • Admin permission required for user CRUD
  • Users can manage own profile without admin permission