Vizzuality/landgriffon

View on GitHub
api/src/modules/users/users.service.ts

Summary

Maintainability
A
1 hr
Test Coverage
A
96%
import {
  ForbiddenException,
  forwardRef,
  Inject,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { User, userResource } from 'modules/users/user.entity';

import { omit } from 'lodash';
import { CreateUserDTO } from 'modules/users/dto/create.user.dto';
import { UpdateUserDTO } from 'modules/users/dto/update.user.dto';
import { AppInfoDTO } from 'dto/info.dto';

import {
  AppBaseService,
  JSONAPISerializerConfig,
} from 'utils/app-base.service';
import { UpdateUserPasswordDTO } from 'modules/users/dto/update.user-password';
import { compare, hash } from 'bcrypt';
import { AuthenticationService } from 'modules/authentication/authentication.service';
import { v4 } from 'uuid';
import { UserRepository } from 'modules/users/user.repository';
import { DeleteResult } from 'typeorm';
import { AuthorizationService } from 'modules/authorization/authorization.service';

@Injectable()
export class UsersService extends AppBaseService<
  User,
  CreateUserDTO,
  UpdateUserDTO,
  AppInfoDTO
> {
  constructor(
    protected readonly repository: UserRepository,
    @Inject(forwardRef(() => AuthenticationService))
    private readonly authenticationService: AuthenticationService,
    private readonly authorizationService: AuthorizationService,
  ) {
    super(repository, userResource.name.singular, userResource.name.plural);
  }

  get serializerConfig(): JSONAPISerializerConfig<User> {
    return {
      attributes: [
        'fname',
        'lname',
        'email',
        'title',
        'avatarDataUrl',
        'isActive',
        'isDeleted',
        'roles',
      ],
      keyForAttribute: 'camelCase',
      projects: {
        ref: 'id',
        attributes: [
          'name',
          'description',
          'countryId',
          'adminAreaLevel1Id',
          'adminAreaLevel2Id',
          'planningUnitGridShape',
          'planningUnitAreakm2',
          'createdAt',
          'lastModifiedAt',
        ],
      },
      scenarios: {
        ref: 'id',
        attributes: [
          'name',
          'description',
          'type',
          'wdpaFilter',
          'wdpaThreshold',
          'adminRegionId',
          'numberOfRuns',
          'boundaryLengthModifier',
          'status',
          'createdAt',
          'lastModifiedAt',
        ],
      },
    };
  }

  /**
   * Assemble a sanitized user object from whitelisted properties of the User
   * entity.
   *
   * @debt Should be extended to include roles and permissions.
   */
  static getSanitizedUserMetadata(
    user: User,
  ): Omit<User, 'password' | 'salt' | 'isActive' | 'isDeleted'> {
    return omit(user, ['password', 'salt', 'isActive', 'isDeleted']);
  }

  /**
   * Mark user as deleted (and inactive).
   *
   * We don't currently delete users physically from the system when an account
   * deletion is requested, as this would mean needing to remove them from all
   * the objects (scenarios, etc) to which they are linked, which may not be the
   * desired default behaviour.
   *
   * When we soft-delete a user, we also set their account's email address to
   * a random one `@example.com`, so that a new account can be created later
   * on with the same email address.
   *
   * @debt We will need to implement hard-deletion later on, so that instance
   * administrators can enforce compliance with relevant data protection
   * regulations.
   */
  async markAsDeleted(userId: string): Promise<void> {
    await this.repository.update(
      { id: userId },
      {
        isDeleted: true,
        isActive: false,
        email: `deleted-account.${v4()}@example.com`,
      },
    );
  }

  /**
   * Update a user's own password.
   *
   * We require a guard here - the user should be able to prove they know their
   * current password. If they are not able to do so, they should go the 'reset
   * password' route (@debt, this will be implemented later).
   */
  async updateOwnPassword(
    userId: string,
    currentAndNewPasswords: UpdateUserPasswordDTO,
  ): Promise<void> {
    const user: User = await this.getById(userId);
    if (
      user &&
      (await compare(
        await hash(currentAndNewPasswords.currentPassword, user.salt),
        await hash(user.password, user.salt),
      ))
    ) {
      user.password = await hash(currentAndNewPasswords.newPassword, user.salt);
      await this.repository.save(user);
      return;
    }
    throw new ForbiddenException(
      'Updating the password is not allowed: the password provided for validation as current one does not match the actual current password. If you have forgotten your password, try resetting it instead.',
    );
  }

  /**
   * Validate that an update request can be fulfilled.
   *
   * - we enforce updating passwords via a separate route (`PATCH
   *   /api/v1/users/me/password`)
   * - @debt also we don't allow updating the user's email address at this stage
   *   (pending implementation of email verification)
   */
  async validateBeforeUpdate(
    id: string,
    updateModel: UpdateUserDTO,
  ): Promise<void> {
    if (updateModel.password) {
      throw new ForbiddenException(
        "The user's password cannot be updated alongside other user data: please use the API endpoint for password updates.",
      );
    }
  }

  async createUser(createUserDTO: CreateUserDTO): Promise<Partial<User>> {
    return this.authenticationService.createUser(createUserDTO);
  }

  async deleteUser(userId: string): Promise<DeleteResult> {
    return this.repository.delete(userId);
  }

  async updateUser(userId: string, updateUser: any): Promise<User> {
    if ('roles' in updateUser && updateUser.roles) {
      updateUser.roles = this.authorizationService.assignRoles(
        updateUser.roles,
      );
    }
    // UpdateResult does not seems to load data back from the DB but return the dto has consumed, so
    // we call to the repo again to return the user with loaded permissions as well
    return this.update(userId, updateUser as UpdateUserDTO).then(() =>
      this.repository.findOneOrFail({ where: { id: userId } }),
    );
  }

  async recoverPassword(email: string): Promise<void> {
    const user: User | null = await this.repository.findByEmail(email);
    if (!user || !user.isActive) {
      throw new NotFoundException(`No user found with email address ${email}`);
    }
    return this.authenticationService.sendPasswordRecoverEmail(user.email);
  }

  async resetPassword(user: User, newPassword: string): Promise<User> {
    const salt: string = await this.authorizationService.generateSalt();
    const hashedNewPassword: string =
      await this.authorizationService.generatePassword(salt, newPassword);
    user.salt = salt;
    user.password = hashedNewPassword;

    return this.repository.save(user);
  }
}