Nest js 로 저기어때 만들기1

강준호·2024년 2월 22일

NestJs

목록 보기
4/6

요구조건

1. 사용자는 일반 사용자 또는 사업자 사용자로 회원가입 및 로그인 할 수 있다.

1. 일반 사용자 → User
2. 사업자 사용자 → Partner

2. Partner는 복수의 Accommodation을 CRUD 할 수 있다.

3. Accommodation은 복수의 Room을 가진다

4. Room은 복수의 Reservation을 가진다.

1. Room은 하나의 날짜에 대해서는 하나의 Reservation 만을 가진다.
2. Reservation은 연박을 고려하지 않는다.
3. Reservation은 대실 등의 경우를 고려하지 않고 숙박의 경우만을 다룬다.

5. User는 복수의 Reservation을 가질 수 있다.

6. Reservation은 2024년 3월 31일까지만 가능하다.

7. 로그인 하지 않은 사용자도 다음의 조건들을 사용하여 예약 가능한 숙소 목록을 볼 수 있다.

1. 날짜
2. Region
3. AccommodationType

8. Partner는 isReserved 상태의 Reservation은 checkedInAt을 할당할 수 있다.

9. checkedInAt 이 할당 된 Reservation에 대해 User는 Review를 생성할 수 있다.

10. isReserved 상태의 Reservation은 일반 고객과 사업자 고객 양쪽 모두에 의해 취소 될 수 있다.


Modeling

User

  • id : number
  • email : string
  • encryptedPassword : string
  • profile : UserProfile
    • nickname : string
    • phoneNumber : string

Partner

  • id : number
  • email : string
  • encryptedPassword : string
  • name : string
  • phoneNumber : string
  • accommodations : Accommodation[]

Accommodation

  • partner : Partner - 숙소 제공
  • name : string - 숙소명
  • rooms: Room[] - 룸
  • regions : Regions - 숙소가 속하는 지역들
  • address1 : string - 숙소의 주소
  • address2 : string - 숙소의 상세 주소
  • latitude : number - 위도
  • longitude : number - 경도
  • imgUrl : string - 대표 이미지의 주소
  • type : AccomdationType
    • AccommodationType
      • hotel
      • motel
      • resort
      • pension
      • guestHouse
      • poolVilla
      • camping
      • glamping
  • description : string - 한 줄 소개
  • reviews : Review[] - 리뷰들

Room

  • accommodation : Accommodation
  • name : string - 이름
  • originalPrice : number - 정가
  • price : number - 판매가
  • checkInTime : string - 입실 시간
  • checkOutTime : string - 퇴실 시간
  • description : string - 객실 한줄 소개
  • reservations : Reservations[] - 예약들

Region

  • id : number
  • name : string
  • accommodations : Accommodation[]

Reservation

  • id : number
  • date : Date
  • isReserved : boolean - 예약 여부
  • reservedBy : User?
  • room : Room
  • isCheckedIn : boolean - 체크인 여부

Review

  • id : number
  • room : Room
  • user : User
  • createdAt : Date
  • rating : number
  • content: string?

schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id                String        @id
  email             String        @unique
  encryptedPassword String
  profile           UserProfile?
  createdAt         DateTime      @default(now())
  reservations      Reservation[]
  reviews           Review[]
}

model UserProfile {
  userId      String   @id
  user        User     @relation(fields: [userId], references: [id])
  nickname    String
  phoneNumber String?
  updatedAt   DateTime @updatedAt
}

model Partner {
  id                String          @id
  email             String          @unique
  encryptedPassword String
  businessName      String
  phoneNumber       String
  staffName         String
  accommodations    Accommodation[]
  createdAt         DateTime        @default(now())
}

model Accommodation {
  id          Int               @id @default(autoincrement())
  partner     Partner           @relation(fields: [partnerId], references: [id])
  partnerId   String
  regions     Region[]
  type        AccommodationType
  name        String
  description String            @default("")
  rooms       Room[]
  address1    String
  address2    String
  latitude    Float
  longitude   Float
  imgUrl      String            @default("")
  createdAt   DateTime          @default(now())
  updatedAt   DateTime          @updatedAt
}

enum AccommodationType {
  hotel
  motel
  resort
  pension
  guestHouse
  poolVilla
  camping
  glamping
}

model Region {
  id             Int             @id @default(autoincrement())
  name           String          @unique
  label          String
  accommodations Accommodation[]
  createdAt      DateTime        @default(now())
  updatedAt      DateTime        @updatedAt
}

model Room {
  id              Int           @id @default(autoincrement())
  accommodation   Accommodation @relation(fields: [accommodationId], references: [id])
  accommodationId Int
  name            String
  description     String        @default("")
  originalPrice   Int
  price           Int
  checkInTime     String
  checkOutTime    String
  reservations    Reservation[]
  reviews         Review[]
  createdAt       DateTime      @default(now())
  updatedAt       DateTime      @updatedAt
}

model Reservation {
  id           String    @id
  room         Room      @relation(fields: [roomId], references: [id])
  roomId       Int
  date         DateTime
  reservedBy   User?     @relation(fields: [reservedById], references: [id])
  reservedById String?
  reservedAt   DateTime?
  checkedInAt  DateTime?
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt

  @@unique([roomId, date])
}

model Review {
  id        Int      @id @default(autoincrement())
  user      User     @relation(fields: [userId], references: [id])
  userId    String
  room      Room     @relation(fields: [roomId], references: [id])
  roomId    Int
  rating    Int
  content   String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

main.ts

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  await app.listen(5050);
}
bootstrap();
  • Express Request 를 사용할 수 있게 NestExpressApplication 제네릭을 써주자.

prisma

설치

npm install prisma @prisma/client
npx prisma init

스키마 작성 후에는 꼭 package.json 에 스키마 경로를 표시해주자.

  "prisma": {
    "schema": "./src/db/prisma/schema.prisma",
    "seed": "ts-node ./src/db/prisma/prisma.seed.ts"
  }

prismaService

  • 싱글톤으로 prismaService 를 한번만 인스턴스화하고 사용하기 위해 하나 만들어두자.
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  public client: PrismaClient;

  async onModuleInit() {
    await this.$connect(); //this 자체가 prismaClient 가 됐음.
  }
  async onModuleDestroy() {
    await this.$disconnect();
  }
}
  • OnModuleInit 을 사용해서 모듈이 불러와질때 connect 된다.

account/accommodation CRUD

accommodation.controller.ts

import {
  Body,
  Controller,
  Get,
  Param,
  ParseIntPipe,
  Patch,
  Post,
} from '@nestjs/common';
import { Partner } from '@prisma/client';
import { DPartner } from 'src/decorators/partner.decorator';
import { Private } from 'src/decorators/private.decorator';
import { AccommodationsCreateDto as AccommodationsRegisterDto } from './accommodations.dto';
import { AccommodationsService } from './accommodations.service';

@Controller('accommodations')
export class AccommodationsController {
  constructor(private readonly accommodationsService: AccommodationsService) {}

  @Post()
  @Private('partner')
  registerAccommodation(
    @DPartner() partner: Partner,
    @Body() dto: AccommodationsRegisterDto,
  ) {
    return this.accommodationsService.createAccommodation({
      ...dto,
      partnerId: partner.id,
    });
  }

  @Get()
  getAccommodations() {
    return this.accommodationsService.getAccommodations();
  }

  @Get(':accommodationId')
  getAccommodation(
    @Param('accommodationId', ParseIntPipe) accommodationId: number,
  ) {
    return this.accommodationsService.getAccommodation(+accommodationId);
  }

  @Patch(':accommodationId')
  @Private('partner')
  updateAccommodation() {}
}

커스텀 데코레이터로 플래그를 지정하고 전역 가드를 설치하는 방식

https://docs.nestjs.com/security/authentication

  • 인증 가드를 글로벌 가드 @UseGuards() 로 등록하고 각 컨트롤러 위에 데코레이터를 사용하는 대신 공개해야 하는 경로에 플래그를 지정하면 된다.

=> 커스텀 데코레이터 @private으로 partner 만 숙소 등록이 가능하게 한다.

//private.decorator.ts

import { SetMetadata } from '@nestjs/common';
import { AccountType } from 'src/domains/accounts/accounts.type';

export const Private = (accountType: AccountType) =>
  SetMetadata('accountType', accountType);
  • SetMetadata데코레이터 팩토리 기능을 사용하여 사용자 정의 데코레이터를 만든다.

전역 가드

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { JwtPayload, verify } from 'jsonwebtoken';
import { AccountType } from 'src/domains/accounts/accounts.type';
import { PrismaService } from './../db/prisma/prisma.service';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private readonly configService: ConfigService,
    private readonly prismaService: PrismaService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const accountTypeInDecorator = this.reflector.getAllAndOverride<
      'user' | 'partner'
    >('accountType', [context.getHandler(), context.getClass()]);

    if (!accountTypeInDecorator) {
      return true;
    }

    const request = context.switchToHttp().getRequest<Request>();
    const accessToken = this.extractTokenFromHeader(request);
    if (!accessToken) throw new UnauthorizedException();

    try {
      const secret = this.configService.getOrThrow<string>('JWT_SECRET_KEY');
      const { sub: id, accountType: accountTypeInAccessToken } = verify(
        accessToken,
        secret,
      ) as JwtPayload & { accountType: AccountType }; // 제네릭이 없어서 이렇게 타입 지정

      //accessToken 이 유효하다

      if (accountTypeInDecorator !== accountTypeInAccessToken)
        throw new Error(); // 파트너만 접근할 수 있는 라우트인데 유저 토큰이 들어왔을때

      if (accountTypeInDecorator === 'user') {
        const user = await this.prismaService.user.findUniqueOrThrow({
          where: { id },
        });
        request.user = user;
      } else {
        const partner = await this.prismaService.partner.findUniqueOrThrow({
          where: { id },
        });
        request.partner = partner;
      }
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? []; // 만약에 null 이면 빈 배열
    return type === 'Bearer' ? token : undefined;
  }
}
  • 'accountType' 데코레이터의 값 확인한다.

  • request 의 헤더에서 토큰을 읽고, 이를 verify 로 검증한다.

  • 토큰의 accountType 과 데코레이터에서 가져온 accountType 이 일치하는지 검사한다.

JWT 를 범용적으로 사용하기 위한 configService

//설치

npm i @nestjs/config

후에 app.module 에 전역으로 설정

//app.module.ts

@Module({
  imports: [
    DomainsModule,
    PrismaModule,
    ConfigModule.forRoot({
      isGlobal: true,
    }),
  ],
  • ConfigService는 환경 변수(ex. JWT 시크릿 키)에 대한 액세스를 추상화하는 데 사용되어 애플리케이션 구성을 관리하는 안전하고 중앙화된 방법을 제공한다!

데이터 전달을 위한 데코레이터 @DPartner

https://docs.nestjs.com/custom-decorators

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';

export const DPartner = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest<Request>();

    return request.partner;
  },
);

@DPartne() 는 어떻게 partner 정보를 가지고 있을까?🤔

  • Gaurd 의 request.partner = Partner; 로 partner 정보를 얻게되고,

  • @DUser에서 가드에 의해 첨부된 partner 객체를 추출.


=> 2편에서 이어서 ...

0개의 댓글