【NestJS】権限付加と見積取得の実装(Guard, QueryBuilder)

#はじめに
リレーション(一対多)の実装に引き続き、権限付加とQueryBuilderの実装を行いました。

https://qiita.com/suzuki0430/items/d15c8642b46b57cdf91b

https://qiita.com/suzuki0430/items/697601370b078cc65fd6

https://qiita.com/suzuki0430/items/ac5ccc587e75a19bb567

https://qiita.com/suzuki0430/items/e4f8cabc61fc1fd41dcc

https://qiita.com/suzuki0430/items/465628e3f47a1d2311d6

実装したコードを確認したい方は以下よりご確認ください。

https://github.com/suzuki0430/car-api-nest


#実装
管理者のみに特定の操作の権限を与える処理とQueryBuilderで見積額を取得する処理を実装しました。

##管理者によるReportの承認
ユーザが作成したReportを管理者(Administrator)が承認する処理を行うルートハンドラー(approveReport)をReportsControllerに追加します。
管理者のみに承認の認可を与えるために、リクエストを行うユーザが管理者であるかどうかを判断するGuardをルートハンドラーの前に置きます。

```ts:reports.controller.ts
@Controller('reports')
export class ReportsController {
  constructor(private reportsService: ReportsService) {}

  ...

  @Patch('/:id')
  @UseGuards(AdminGuard)
  approveReport(@Param('id') id: string, @Body() body: ApproveReportDto) {
    return this.reportsService.changeApproval(id, body.approved);
  }
}
```
Guard(AdminGuard)の中身は以下のようになります。
`currentUser.admin`が`true`のときだけリクエストを通します。

```ts:admin.guard.ts
import { CanActivate, ExecutionContext } from '@nestjs/common';

export class AdminGuard implements CanActivate {
  canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();

    if (!request.currentUser) {
      return false;
    }

    return request.currentUser.admin;
  }
}
```
ただ、AdminGuardを加えただけだとうまく動作しません。
なぜかというと、`currentUser`を返すInterceptorはGuardの後に実行されるためです。
![スクリーンショット 2021-10-21 7.58.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/039b6334-b6e1-a246-63d0-c91285614407.png)

そのため、`currentUser`を返す処理をInterceptorからMiddlewareにうつしてあげる必要があります。

```ts:current-user.middleware.ts
declare global {
  namespace Express {
    interface Request {
      currentUser?: User;
    }
  }
}

@Injectable()
export class CurrentUserMiddleware implements NestMiddleware {
  constructor(private usersService: UsersService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    const { userId } = req.session || {};

    if (userId) {
      const user = await this.usersService.findOne(userId);
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      req.currentUser = user;
    }

    next();
  }
}
```
作成したMiddlewareをUsersModuleに付加します。

```ts:users.module.ts
export class UsersModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(CurrentUserMiddleware).forRoutes('*');
  }
}
```

##QueryBuilderで見積額を取得
入力した中古車情報から見積額`price`を取得する処理を実装します。
`price`を推測するにあたり、以下の条件に当てはまるReportをDBから3件取得します。
そして、3件のレポートの`price`の平均値を見積額として返します。

- make: 同じメーカ
- model: 同じ車種
- lng: +/- 5°
- lat: +/- 5°
- year: 3年以内
- mileage: 近い順

DBから`price`の平均値を取得するにあたって、SQLを構築する必要があります。
そこでTypeORMのQueryBuilderを使用します。

```ts:reports.service.ts
@Injectable()
export class ReportsService {
  constructor(@InjectRepository(Report) private repo: Repository<Report>) {}

  createEstimate({ make, model, lng, lat, year, mileage }: GetEstimateDto) {
    return this.repo
      .createQueryBuilder()
      .select('AVG(price)', 'price')
      .where('make = :make', { make })
      .andWhere('model = :model', { model })
      .andWhere('lng - :lng BETWEEN -5 AND 5', { lng })
      .andWhere('lat - :lat BETWEEN -5 AND 5', { lat })
      .andWhere('year - :year BETWEEN -3 AND 3', { year })
      .andWhere('approved IS TRUE')
      .orderBy('ABS(mileage - :mileage)', 'DESC')
      .setParameters({ mileage })
      .limit(3)
      .getRawOne();
  }
  ...
}
```
クエリに必要なデータについては、クエリパラメータとしてURLに渡すようにします。

```ts:reports.controller.ts
@Controller('reports')
export class ReportsController {
  constructor(private reportsService: ReportsService) {}

  @Get()
  getEstimate(@Query() query: GetEstimateDto) {
    return this.reportsService.createEstimate(query);
  }
  ...
}
```
ただ、クエリパラメータはstring型で入ってくるので、`year`や`mileage`などについては、`GetEstimateDto`内で`@Transform()`デコレータを使用して、型を適切に変換してあげる必要があります。

```ts:get-estimate.dto.ts
export class GetEstimateDto {
  @IsString()
  make: string;

  @IsString()
  model: string;

  @Transform(({ value }) => parseInt(value))
  @IsNumber()
  @Min(1930)
  @Max(2050)
  year: number;

  @Transform(({ value }) => parseFloat(value))
  @IsLongitude()
  lng: number;

  @Transform(({ value }) => parseFloat(value))
  @IsLatitude()
  lat: number;

  @Transform(({ value }) => parseInt(value))
  @IsNumber()
  @Min(0)
  @Max(1000000)
  mileage: number;
}
```

#参考資料

https://www.udemy.com/share/104SA43@5iHng3mXCUwhKvkqKp79zmFdmB999CLF2Bm6VZyebKenD1rjzDnn_4JeokhgumWA/

https://typeorm.io/#/select-query-builder