diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 1cc70ac..69c4e4b 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -39,6 +39,20 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + - name: Create .env + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_SSH_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + command_timeout: 30m + script: | + set -e + cat << 'EOF' > ~/capstone-backend/.env + ${{ secrets.ENV_FILE }} + EOF + cd ~/capstone-backend + - name: Deploy to server uses: appleboy/ssh-action@v1 with: diff --git a/Dockerfile.dev b/Dockerfile.dev index 747ae5a..0e619a0 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -21,4 +21,4 @@ RUN yarn prisma generate EXPOSE 3000 -CMD ["npx", "nest", "start", "--watch"] +CMD ["sh", "-c", "yarn prisma generate --schema prisma/schema && npx nest start --watch"] diff --git a/docs/external-api-contracts.md b/docs/external-api-contracts.md new file mode 100644 index 0000000..cdab791 --- /dev/null +++ b/docs/external-api-contracts.md @@ -0,0 +1,186 @@ +# 외부 API 호출 및 응답 계약 + +이 문서는 현재 `ExternalApiService`가 호출하는 외부 API를 코드 기준으로 정리한다. +실제 운영 URL, 자격 증명, 쿠키 값은 기록하지 않는다. + +## 읽는 법 + +- **근거**는 응답 형태를 확인한 위치다. `코드 소비`는 실제 HTTP 응답 캡처가 아니라, 현재 서버가 응답을 읽는 방식을 뜻한다. +- **미확인**은 코드에 구체적인 타입 또는 예시가 없어 실제 외부 API 응답으로 검증해야 하는 부분이다. +- 환경변수 기반 API의 URL은 해당 환경변수에 설정한다. 설정 키는 [config.module.ts](../src/app/modules/config.module.ts)에서 필수값으로 검증된다. + +## 공통 전송 방식 + +- 대부분의 요청은 `POST`, `Content-Type: application/json`이며 본문은 JSON 문자열이다. +- `fetchGodokCalendar`만 `GET`이다. +- `fetchGodokBooks`는 `multipart/form-data`로 전송한다. +- HTTP 응답 본문은 원래 문자열로 받아 왔다. 스터디룸 예약 목록·생성·취소는 현재 외부 API 서비스에서 DTO로 변환하며, 나머지는 각 소비 서비스가 `JSON.parse`한다. + +> 스터디룸 Lambda 요청은 `validateStatus: () => true`를 사용하므로 4xx/5xx도 DTO 변환과 상태 코드 분기에 도달한다. 다른 외부 API 요청은 Axios 기본 동작을 따른다. + +## 포털 및 eCampus + +### 포털 인증 + +| 항목 | 내용 | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 메서드 | `authenticatePortal` | +| URL 설정 키 | `PORTAL_AUTH_URL` | +| 요청 | `{ id: string, password: string }` | +| 성공 응답 | `{ name: string, department: string, grade: number, studentId: string, year: number }` | +| 오류 처리 | HTTP 상태가 200이 아니면 인증 실패로 처리 | +| 근거 | [external-api.service.ts](../src/common/services/external-api.service.ts), [portal-user.type.ts](../src/auth/types/portal-user.type.ts), [auth.service.ts](../src/auth/auth.service.ts) | + +### eCampus 출석 조회 + +| 항목 | 내용 | +| --------------- | ---------------------------------------------------------------------------------------------------------------------- | +| 메서드 | `fetchCourseAttendance` | +| URL 설정 키 | `GET_COURSE_ATTENDANCE_URL` | +| 요청 | `{ studentId: string, name: string, password: string }` | +| 성공 응답 | `RawCourse[]` | +| `RawCourse` | `{ id: string, name: string, ecampusId: number, lectures: RawLecture[], assignments: RawAssignment[] }` | +| `RawLecture` | `{ id: number, name: string, week: number, startsAt: Date, endsAt: Date, isDone: boolean }` | +| `RawAssignment` | `{ id: number, name: string, week: number, endsAt?: Date, isDone: boolean }` | +| 오류 처리 | 500: 출석 Lambda 오류, 401: 로그인 실패, 400: 강의 정보 조회 실패 | +| 근거 | [ecampus.service.ts](../src/attendance/ecampus.service.ts), [raw-course.d.ts](../src/attendance/types/raw-course.d.ts) | + +## 스터디룸 + +### 스터디룸 슬롯 크롤링 + +| 항목 | 내용 | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| 메서드 | `fetchStudyroom` | +| URL 설정 키 | `CRAWLER_API_ROOT` | +| 요청 | `{ room_name: string }` | +| 성공 응답 | `{ room_name: string, slots: RawStudyroomSlot[] }` | +| `RawStudyroomSlot` | `{ date: string, time: string, is_reserved: boolean, is_closed: boolean }` | +| 오류 처리 | `slots`가 배열이 아니면 경고를 남기고 처리 중단 | +| 근거 | [studyroom.service.ts](../src/studyroom/studyroom.service.ts), [rawStudyroom.d.ts](../src/studyroom/types/rawStudyroom.d.ts) | + +### 사용자 예약 목록 + +| 항목 | 내용 | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 메서드 | `fetchStudyroomReservations` | +| URL 설정 키 | `GET_USER_RESERVATIONS_URL` | +| 요청 | `{ student_id: string, password: string }` | +| 성공 응답 원본 | `{ result: ReservationResponse[] }` | +| 원본 예약 항목 | `{ booking_id: string \| null, ipid: string \| null, room_name: string, duration: string \| null, date: string, starts_at: string \| null }` | +| 서비스 반환 DTO | `{ status: number, reservations: StudyroomReservationDto[], errorMessage?: string }` | +| DTO 예약 항목 | `{ bookingId: string \| null, ipid: string \| null, roomName: string, duration: string \| null, date: string, startsAt: string \| null }` | +| 동기화 정책 | `duration` 또는 `starts_at`이 `null`인 항목은 취소된 예약으로 간주해 DB에서 제거하고 응답에서 제외한다. | +| 오류 처리 | 404: 빈 예약 목록으로 동기화, 401: `result` 문자열을 인증 오류 메시지로 처리 | +| 근거 | [fetch-studyroom-reservations-response.dto.ts](../src/common/dto/fetch-studyroom-reservations-response.dto.ts), [reservation.service.ts](../src/studyroom/reservation.service.ts) | + +### 사용자 예약 가능 여부 (삭제됨) + +| 항목 | 내용 | +| ------------ | ------------------------------------------------------------------------------------- | +| 현재 상태 | Lambda와 백엔드에서 삭제됨 | +| 과거 메서드 | `fetchStudyroomAvailability` | +| 과거 설정 키 | `GET_USER_AVAILABILITY_URL` | +| 대체 계약 | 예약 생성 API가 잘못된 이용자를 HTTP 422으로 반환 | +| 근거 | Lambda 변경 정보 및 [reservation.service.ts](../src/studyroom/reservation.service.ts) | + +### 예약 생성 + +| 항목 | 내용 | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 메서드 | `createStudyroomReservation` | +| URL 설정 키 | `CREATE_RESERVATION_URL` | +| 요청 | `{ id, password, room_id, users, year, month, day, start_time, hours }` | +| `users` | `Array<{ student_id: string, name: string }>` | +| 성공 응답 원본 | `{ result: string }` | +| 서비스 반환 DTO | `{ status: number, result: string, error?: string, content?: string }` | +| 오류 처리 | 400/422는 `{ message: error, content }`의 HTTP 400, 401은 `error` 문자열의 HTTP 401 | +| 근거 | [create-studyroom-reservation-response.dto.ts](../src/common/dto/create-studyroom-reservation-response.dto.ts), [reservation.service.ts](../src/studyroom/reservation.service.ts) | + +### 예약 취소 + +| 항목 | 내용 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| 메서드 | `cancelStudyroomReservation` | +| URL 설정 키 | `CANCEL_RESERVATION_URL` | +| 요청 | `{ id: string, password: string, reserve_no: string, cancel_msg?: string }` | +| 성공 응답 | `{ result: { ok: true, resultCode: string, resultMsg: string, reserveNo: string } }`를 서버가 HTTP 201 빈 본문으로 처리 | +| 업무 실패 | HTTP 200의 `result.ok: false`는 HTTP 404로 변환하고 DB 예약을 유지 | +| 오류 처리 | 401은 외부 오류 문자열을 HTTP 401로 변환하고, 그 외 4xx/5xx는 내부 오류 | +| 근거 | [reservation.service.ts](../src/studyroom/reservation.service.ts), [resultResponse.type.ts](../src/studyroom/types/resultResponse.type.ts) | + +## 고전독서 + +### 캘린더 조회 + +| 항목 | 내용 | +| ----------- | ------------------------------------------------------------------------------------------------------------------ | +| 메서드 | `fetchGodokCalendar` | +| URL 설정 키 | `GET_GODOK_CALENDAR_URL` | +| 요청 | 본문 없음 (`GET`) | +| 성공 응답 | `Array<{ data_id: string, date_time: string, available_seats: number, total_seats: number }>` | +| 근거 | [rawGodokSlot.type.ts](../src/godok/types/rawGodokSlot.type.ts), [godok.service.ts](../src/godok/godok.service.ts) | + +### 예약 생성 + +| 항목 | 내용 | +| ----------- | --------------------------------------------------------------- | +| 메서드 | `createGodokReservation` | +| URL 설정 키 | `CREATE_GODOK_RESERVATION_URL` | +| 요청 | `{ student_id, password, shInfoId, bkCode, bkAreaCode }` | +| 성공 응답 | `ResultResponse`로 반환하는 것으로 사용됨: `{ result: string }` | +| 오류 응답 | 400/401에서 `{ message: string }`을 소비 | +| 근거 | [godok.service.ts](../src/godok/godok.service.ts) | + +### 사용자 예약 목록 + +| 항목 | 내용 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| 메서드 | `fetchGodokReservations` | +| URL 설정 키 | `GET_USER_GODOK_RESERVATIONS_URL` | +| 요청 | `{ student_id: string, password: string }` | +| 성공 응답 | `{ reservations: Array<{ date_time: string, book_name: string, reserve_id: string }> }` | +| 오류 응답 | 400/401에서 `{ error: string }`을 소비 | +| 근거 | [godokReservationResponse.type.ts](../src/godok/types/godokReservationResponse.type.ts), [godok.service.ts](../src/godok/godok.service.ts) | + +### 도서 목록 + +| 항목 | 내용 | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| 메서드 | `fetchGodokBooks` | +| URL | `http://classic.sejong.ac.kr/seletTermBookList.json` | +| 요청 | `multipart/form-data`: `opTermId=TERM-00571`, `bkAreaCode: number` | +| 성공 응답 | `{ results: Array<{ bkAreaCode: number, bkAreaName: string, bkCode: number, bkName: string, appCount: number, bkStatus: string }> }` | +| 근거 | [external-api.service.ts](../src/common/services/external-api.service.ts), [godok.service.ts](../src/godok/godok.service.ts) | + +### 예약 취소 + +| 항목 | 내용 | +| ----------- | --------------------------------------------------------------- | +| 메서드 | `cancelGodokReservation` | +| URL 설정 키 | `CANCEL_GODOK_RESERVATION_URL` | +| 요청 | `{ student_id: string, password: string, opAppInfoId: string }` | +| 성공 응답 | 본문 형식은 현재 사용하지 않음 | +| 오류 응답 | 400/401에서 `{ error: string }`을 소비 | +| 근거 | [godok.service.ts](../src/godok/godok.service.ts) | + +### 사용자 상태 + +| 항목 | 내용 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------ | +| 메서드 | `fetchGodokStatus` | +| URL 설정 키 | `GET_USER_GODOK_STATUS_URL` | +| 요청 | `{ student_id: string, password: string }` | +| 성공 응답 | `{ reservations: { status: boolean, values: Record } }` | +| 오류 응답 | 400/401에서 `{ error: string }`을 소비 | +| 근거 | [godokStatusInfo.type.ts](../src/godok/types/godokStatusInfo.type.ts), [godok.service.ts](../src/godok/godok.service.ts) | + +## 응답 계약 검증 상태 + +현재 저장소에는 외부 API의 실응답 fixture나 OpenAPI 명세가 없다. 따라서 이 문서의 형식은 다음 근거에서만 작성됐다. + +1. `ExternalApiService`의 요청 조립 방식 +2. 응답을 파싱해 사용하는 서비스 코드 +3. 해당 서비스가 전달하는 기존 TypeScript 타입 + +새 DTO를 추가하거나 기존 응답을 변경하기 전에, 운영 비밀값을 제거한 실응답 샘플 또는 외부 API의 소스/명세로 각 계약을 확인해야 한다. diff --git a/docs/segam-aws-lambda-v2-backend-changes.md b/docs/segam-aws-lambda-v2-backend-changes.md new file mode 100644 index 0000000..e0549b8 --- /dev/null +++ b/docs/segam-aws-lambda-v2-backend-changes.md @@ -0,0 +1,136 @@ +# segam AWS Lambda v2 기준 백엔드 수정사항 + +`segamAWSlambda` Postman 컬렉션의 저장된 요청·응답을 현재 스터디룸 연동 코드와 비교한 결과다. +컬렉션에 포함된 실제 URL, 학번, 비밀번호, 예약 번호는 이 문서에 기록하지 않는다. + +## 범위와 근거 + +컬렉션에는 다음 Lambda v2 API 네 개가 있다. 고전독서·포털·eCampus API에는 변경사항이 없으므로 이번 수정 범위에서 제외한다. + +| Lambda v2 API | 현재 백엔드 메서드 | 환경변수 | +| ---------------------- | ---------------------------- | --------------------------- | +| `getMyReservations_v2` | `fetchStudyroomReservations` | `GET_USER_RESERVATIONS_URL` | +| `createReservation_v2` | `createStudyroomReservation` | `CREATE_RESERVATION_URL` | +| `studyroomCrawler_v2` | `fetchStudyroom` | `CRAWLER_API_ROOT` | +| `cancelReservation_v2` | `cancelStudyroomReservation` | `CANCEL_RESERVATION_URL` | + +예약 가능 여부 API(`GET_USER_AVAILABILITY_URL`)는 Lambda에서 삭제됐다. 사용자 확인 실패는 `createReservation_v2`가 422 응답으로 반환한다. + +## 우선순위별 수정사항 + +### P0: HTTP 오류 응답을 서비스가 일관되게 수신하게 한다 + +Postman 컬렉션은 편의상 GET으로 작성됐지만, Lambda는 현재 백엔드의 `POST` JSON 요청도 허용한다. 따라서 HTTP 메서드 변경은 필요 없다. + +다만 Axios 기본 동작은 4xx/5xx를 reject한다. 현재 예약 서비스는 `response.status === 401` 같은 분기를 사용하므로, 다음 둘 중 하나가 필요하다. + +1. 해당 외부 요청에 `validateStatus: () => true`를 설정해 모든 HTTP 상태를 응답으로 받는다. +2. `AxiosError`를 한 곳에서 변환해 응답 본문과 상태를 보존한다. + +이 처리가 없으면 컬렉션에 있는 401·422 응답은 서비스의 상태 코드 분기로 전달되지 않는다. + +### P0: 예약 취소 계약을 교체한다 + +컬렉션의 `cancelReservation_v2` 계약은 현재 코드와 다르다. + +| 항목 | Lambda v2 | 현재 백엔드 | +| -------------- | --------------------------------------------------------------------------- | -------------------------------------------- | +| 요청 예약 번호 | `reserve_no` | `booking_id` | +| 성공 `result` | `{ ok: boolean, resultCode: string, resultMsg: string, reserveNo: string }` | `ResultResponse`의 `result: string`으로 취급 | +| 업무 실패 | HTTP 200 + `result.ok: false` | HTTP 상태만 검사한 뒤 DB 예약을 삭제 | +| 인증 실패 | HTTP 401 + `{ result: string }` | `result` 문자열을 소비 | + +필수 변경: + +1. [external-api.type.ts](../src/common/types/external-api.type.ts)의 취소 요청 필드를 `reserveNo`로 바꾸고 Lambda 요청에서는 `reserve_no`로 직렬화한다. +2. 취소 응답 DTO를 만들고 `result.ok`, `resultCode`, `resultMsg`, `reserveNo`를 타입으로 표현한다. +3. [reservation.service.ts](../src/studyroom/reservation.service.ts)에서 `result.ok === false`이면 DB를 삭제하지 않고 적절한 예외를 반환한다. +4. 예약 목록의 `booking_id`를 DB `bookingId`에 저장하고, 취소 요청에서 `reserve_no`로 직렬화한다. + +`reserve_no`는 `getMyReservations_v2`의 `booking_id`를 사용한다. 따라서 현재 DB의 `bookingId` 필드는 유지할 수 있다. 단, `booking_id`가 `null`인 항목은 취소할 수 없으므로 현재의 예약 취소 전 검증을 유지해야 한다. + +### P0: 예약 목록 DTO와 동기화 로직이 nullable 응답을 처리하게 한다 + +`getMyReservations_v2`의 성공 샘플에는 다음과 같은 항목이 실제로 있다. + +```json +{ + "booking_id": null, + "ipid": null, + "room_name": "…", + "duration": null, + "date": "YYYY.MM.DD", + "starts_at": null +} +``` + +현재 [fetch-studyroom-reservations-response.dto.ts](../src/common/dto/fetch-studyroom-reservations-response.dto.ts)는 `duration`과 `startsAt`을 `string`으로 요구한다. 이 응답은 DTO 변환에서 실패한다. 이전 raw 타입도 같은 필드를 `string`으로 선언해 실제 계약과 다르다. + +필수 변경: + +1. 외부 예약 항목의 `duration`, `starts_at`을 `string | null`로 모델링한다. +2. [studyroom.repository.ts](../src/studyroom/studyroom.repository.ts)의 동기화 전에 `duration` 또는 `starts_at`이 없는 항목의 처리 정책을 정한다. 현 저장 로직은 시간·기간이 있는 예약만 저장할 수 있다. +3. 정책이 “불완전 항목 무시”라면, DTO 또는 서비스 경계에서 이를 명시적으로 필터링한다. 정책이 “보관”이라면 Prisma 스키마와 DB 필드도 nullable로 바꿔야 한다. + +현재 코드처럼 nullable 값을 문자열로 가정하면 DTO 예외, 잘못된 키 생성, 또는 잘못된 기간 저장이 발생할 수 있다. + +### P1: 예약 생성 오류 계약을 DTO와 HTTP 예외에 반영한다 + +`createReservation_v2`의 컬렉션 응답은 다음을 포함한다. + +| 상황 | HTTP | 응답 | +| ----------------------- | ---- | ------------------------------------ | +| 성공 | 200 | `{ result: string }` | +| 이용자 학번·이름 불일치 | 422 | `{ error: string, content: string }` | +| 인증 실패 | 401 | `{ error: string }` | + +현재 [create-studyroom-reservation-response.dto.ts](../src/common/dto/create-studyroom-reservation-response.dto.ts)는 `result`와 `error`만 보관한다. `content`를 보존하지 않고, [reservation.service.ts](../src/studyroom/reservation.service.ts)는 422를 내부 오류로 처리한다. + +필수 변경: + +1. 생성 응답 DTO에 `content?: string`을 추가한다. +2. 422를 `BadRequestException`으로 변환하고 `error`와 필요하면 `content`를 클라이언트에 전달한다. +3. 401에서는 DTO 전체가 아니라 `response.error`를 예외 메시지로 사용한다. + +추가 확인 사항: 컬렉션의 생성 요청은 `year`를 문자열로 보낸다. 현재 백엔드는 숫자로 전송한다. Lambda 구현이 숫자도 허용하는지 확인하고, 문자열만 허용한다면 `toDateParts()`의 `year`를 문자열로 맞춘다. + +### P1: 삭제된 예약 가능 여부 API 의존성을 제거한다 + +`GET_USER_AVAILABILITY_URL`과 해당 Lambda는 더 이상 존재하지 않는다. 잘못된 스터디룸 이용자는 예약 생성에서 422로 검증한다. + +현재 백엔드에서 정리할 대상: + +| 계층 | 제거 또는 변경 대상 | +| ------------------------ | ------------------------------------------------------------------------------------------------------------ | +| 설정 | `GET_USER_AVAILABILITY_URL` 환경변수 검증 | +| 외부 API | `ExternalApiService.fetchStudyroomAvailability()`와 endpoint 상수 | +| 테스트 | 외부 API 스터디룸 테스트의 예약 가능 여부 사례 | +| 예약 서비스 | `ReservationService.checkUserAvailablity()` | +| 사용자 서비스 | `UserService.getUserPid()` 및 친구 추가 시의 외부 검증 경로 | +| 스터디룸 서비스·컨트롤러 | `StudyroomService.checkUserAvailablity()`와 `POST /studyroom/user` API, `StudyroomUserPayload`, `UserPidDto` | + +친구 추가는 현재 존재하지 않는 사용자를 이 API로 검증한 뒤 PID를 저장한다. 삭제 후에는 “친구 추가 시 검증하지 않고 관계만 저장”할지, “이미 등록된 사용자만 친구로 허용”할지 제품 정책을 정해야 한다. 예약 시에는 Lambda의 422 응답을 사용자에게 반환해 잘못된 동반인을 알려준다. + +### P1: 크롤러 응답의 날짜 타입을 문자열로 바로잡는다 + +`studyroomCrawler_v2`는 슬롯의 `date`를 `"YYYY-MM-DD"` 문자열로 반환한다. 현재 [rawStudyroom.d.ts](../src/studyroom/types/rawStudyroom.d.ts)는 이를 `Date`로 선언한다. + +수정 대상: + +- `RawStudyroomSlot.date: string` +- [studyroom.service.ts](../src/studyroom/studyroom.service.ts)에서 DB 저장 시 `new Date(slot.date)`로 변환하는 현재 로직은 유지 가능 + +잘못된 스터디룸 이름은 HTTP 200과 빈 `slots: []`를 반환한다. 현재 로직도 빈 배열을 안전하게 처리하므로 추가 변경은 필요 없다. + +## 권장 작업 순서 + +1. 공통 HTTP 응답 수신 정책(`validateStatus` 또는 오류 변환)을 정한다. +2. 예약 목록 nullable 필드 처리 정책을 확정하고 DTO·저장소를 수정한다. +3. 취소 요청·응답 DTO와 DB 삭제 조건을 수정한다. +4. 예약 생성 422 DTO·예외 처리를 수정한다. +5. 크롤러 날짜 타입을 수정한다. +6. Postman 컬렉션의 성공·401·422·`ok: false` 응답을 fixture로 옮겨 회귀 테스트를 추가한다. + +## 수정 범위 밖 항목 + +- 고전독서, 포털, eCampus API는 변경사항 없음 diff --git a/docs/studyroom-client-response-changes.md b/docs/studyroom-client-response-changes.md new file mode 100644 index 0000000..18612a6 --- /dev/null +++ b/docs/studyroom-client-response-changes.md @@ -0,0 +1,56 @@ +# 스터디룸 API 클라이언트 응답 변경사항 + +Lambda v2 연동 변경 후, 클라이언트가 받는 백엔드 HTTP API의 차이만 정리한다. +고전독서·포털·eCampus API는 이 변경 범위에 포함되지 않는다. + +## 변경 요약 + +| API 또는 상황 | 이전 동작 | 현재 동작 | 클라이언트 대응 | +| --------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------- | +| `POST /studyroom/user` | 스터디룸 인원 추가 가능 여부를 확인하고 `UserPidDto`를 반환 | **삭제됨** | 이 API 호출을 제거한다. 동반인 유효성은 예약 생성에서 확인한다. | +| `POST /studyroom/reservation` 성공 | 201, 빈 본문 | 동일 | 변경 없음 | +| 예약 생성: 잘못된 동반인 | 외부 오류가 일관되게 전달되지 않음 | HTTP 400으로 검증 오류 반환 | `message`와 `content`를 표시한다. | +| 예약 생성: 포털 인증 실패 | 응답 메시지가 DTO 전체가 될 수 있음 | HTTP 401, 외부 API의 오류 문자열을 메시지로 반환 | 기존 401 처리 유지 | +| `POST /studyroom/reservation/cancel/:id` 성공 | 201, 빈 본문 | 동일 | 변경 없음 | +| 예약 취소: 외부 API가 `200 + result.ok: false` 반환 | 성공으로 간주되어 DB 예약이 삭제될 수 있음 | HTTP 404, DB 예약을 유지 | 404를 “외부 예약이 존재하지 않음”으로 안내한다. | +| `POST /studyroom/reservation/me` | 시간·기간이 없는 외부 예약도 목록에 남을 수 있음 | `duration` 또는 `starts_at`이 `null`인 항목은 삭제·제외 | 취소된 예약은 목록에서 제거된다. | +| `POST /studyroom/reservation/me` 401 | 외부 응답을 직접 파싱 | 외부 `result` 문자열을 401 메시지로 반환 | 기존 401 처리에서 메시지 문자열을 표시한다. | +| `POST /user/friend`로 미등록 사용자 추가 | 외부 예약 가능 여부 API로 학번·이름을 사전 검증 | 로컬 친구 관계를 먼저 만들고 예약 생성에서 검증 | 친구 추가 성공이 예약 가능을 보장하지 않는다. | + +## 예약 생성 오류 응답 + +Lambda가 동반인 학번·이름 불일치를 반환하면, 백엔드는 HTTP 400을 반환한다. + +```json +{ + "message": "이름과 학번이 일치하지 않는 이용자가 있습니다.", + "content": "학번-이름" +} +``` + +`content`는 문제가 된 동반인을 식별하는 Lambda 제공 값이다. 실제 값에는 개인 정보가 포함될 수 있으므로, 로그에 기록할 때는 마스킹한다. + +## 삭제된 사전 검증 흐름 + +기존 흐름은 다음 API를 사용했다. + +```text +POST /studyroom/user → 외부 예약 가능 여부 API → PID 반환 +``` + +새 흐름에서는 해당 API가 없다. + +```text +POST /user/friend → 로컬 친구 관계 생성 +POST /studyroom/reservation → Lambda가 동반인 검증 → 성공 또는 400 +``` + +따라서 예약 UI는 친구 선택 이후가 아니라 예약 제출 이후에도 동반인 검증 오류를 표시할 수 있어야 한다. + +## 변경되지 않은 성공 응답 + +- `POST /studyroom/reservation`: HTTP 201, 빈 본문 +- `POST /studyroom/reservation/cancel/:id`: HTTP 201, 빈 본문 +- `POST /studyroom/reservation/me`: `{ reservations: Array<{ id, name, date, startsAt: string | null, duration: number | null }> }` + +예약 목록의 공개 DTO 필드명은 변경하지 않았다. 외부 Lambda의 `booking_id`와 `ipid`는 취소를 위해 서버 DB에만 저장하며, 클라이언트 응답에는 포함하지 않는다. diff --git a/package.json b/package.json index 26fcd62..3d36ab8 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/prisma/migrations/20260828043000_make_studyroom_reservation_schedule_nullable/migration.sql b/prisma/migrations/20260828043000_make_studyroom_reservation_schedule_nullable/migration.sql new file mode 100644 index 0000000..875d365 --- /dev/null +++ b/prisma/migrations/20260828043000_make_studyroom_reservation_schedule_nullable/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "studyroom_reservation" + ALTER COLUMN "starts_at" DROP NOT NULL, + ALTER COLUMN "duration" DROP NOT NULL; diff --git a/prisma/migrations/20260828045000_remove_cancelled_studyroom_reservations/migration.sql b/prisma/migrations/20260828045000_remove_cancelled_studyroom_reservations/migration.sql new file mode 100644 index 0000000..541d4bc --- /dev/null +++ b/prisma/migrations/20260828045000_remove_cancelled_studyroom_reservations/migration.sql @@ -0,0 +1,6 @@ +DELETE FROM "studyroom_reservation" +WHERE "starts_at" IS NULL OR "duration" IS NULL; + +ALTER TABLE "studyroom_reservation" + ALTER COLUMN "starts_at" SET NOT NULL, + ALTER COLUMN "duration" SET NOT NULL; diff --git a/src/app/modules/config.module.ts b/src/app/modules/config.module.ts index 2f2c1e1..d1f529d 100644 --- a/src/app/modules/config.module.ts +++ b/src/app/modules/config.module.ts @@ -20,7 +20,6 @@ export const configModule = ConfigModule.forRoot({ GET_USER_RESERVATIONS_URL: Joi.string().required(), CREATE_RESERVATION_URL: Joi.string().required(), CANCEL_RESERVATION_URL: Joi.string().required(), - GET_USER_AVAILABILITY_URL: Joi.string().required(), DISCORD_NEW_USER_WEBHOOK_URL: Joi.string().required(), DISCORD_INTERNAL_ERROR_WEBHOOK_URL: Joi.string().required(), DISCORD_ERROR_WEBHOOK_URL: Joi.string().required(), diff --git a/src/auth/pipes/signup-validation.pipe.ts b/src/auth/pipes/signup-validation.pipe.ts index 742f5d0..8e98b4d 100644 --- a/src/auth/pipes/signup-validation.pipe.ts +++ b/src/auth/pipes/signup-validation.pipe.ts @@ -18,9 +18,10 @@ export class PasswordValidationPipe implements PipeTransform { value: PasswordPayload, metadata: ArgumentMetadata, ): PasswordPayload { - // if (this.configService.get('NODE_ENV') === 'local') { - // return value; - // } + console.log(this.configService.get('NODE_ENV')); + if (this.configService.get('NODE_ENV') === 'local') { + return value; + } const pattern = /^[0-9a-fA-F]{32}:(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{64}|[0-9a-fA-F]{96})$/; diff --git a/src/common/dto/cancel-studyroom-reservation-response.dto.ts b/src/common/dto/cancel-studyroom-reservation-response.dto.ts new file mode 100644 index 0000000..e84e52e --- /dev/null +++ b/src/common/dto/cancel-studyroom-reservation-response.dto.ts @@ -0,0 +1,101 @@ +import type { AxiosResponse } from 'axios'; + +export class CancelStudyroomReservationResultDto { + ok!: boolean; + resultCode!: string; + resultMessage!: string; + reserveNo!: string; + + static from(value: unknown): CancelStudyroomReservationResultDto { + if (!this.isRecord(value)) { + throw new TypeError('스터디룸 예약 취소 결과가 객체 형식이 아닙니다.'); + } + + const dto = new CancelStudyroomReservationResultDto(); + dto.ok = this.getBoolean(value, 'ok'); + dto.resultCode = this.getString(value, 'resultCode'); + dto.resultMessage = this.getString(value, 'resultMsg'); + dto.reserveNo = this.getString(value, 'reserveNo'); + return dto; + } + + private static getBoolean( + value: Record, + key: string, + ): boolean { + const field = value[key]; + + if (typeof field === 'boolean') { + return field; + } + + throw new TypeError( + `스터디룸 예약 취소 결과의 ${key} 필드가 불리언이 아닙니다.`, + ); + } + + private static getString( + value: Record, + key: string, + ): string { + const field = value[key]; + + if (typeof field === 'string') { + return field; + } + + throw new TypeError( + `스터디룸 예약 취소 결과의 ${key} 필드가 문자열이 아닙니다.`, + ); + } + + private static isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; + } +} + +export class CancelStudyroomReservationResponseDto { + status!: number; + result?: CancelStudyroomReservationResultDto; + errorMessage?: string; + + static from( + response: Pick, 'data' | 'status'>, + ): CancelStudyroomReservationResponseDto { + const dto = new CancelStudyroomReservationResponseDto(); + dto.status = response.status; + + const body = this.parseBody(response.data); + dto.result = body.result; + dto.errorMessage = body.errorMessage; + return dto; + } + + private static parseBody(data: string): { + readonly result?: CancelStudyroomReservationResultDto; + readonly errorMessage?: string; + } { + let parsed: unknown; + + try { + parsed = JSON.parse(data); + } catch { + throw new TypeError('스터디룸 예약 취소 응답이 JSON 형식이 아닙니다.'); + } + + if (!this.isRecord(parsed)) { + throw new TypeError('스터디룸 예약 취소 응답이 객체 형식이 아닙니다.'); + } + + const result = parsed['result']; + if (typeof result === 'string') { + return { errorMessage: result }; + } + + return { result: CancelStudyroomReservationResultDto.from(result) }; + } + + private static isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; + } +} diff --git a/src/common/dto/create-studyroom-reservation-response.dto.ts b/src/common/dto/create-studyroom-reservation-response.dto.ts new file mode 100644 index 0000000..5bb231b --- /dev/null +++ b/src/common/dto/create-studyroom-reservation-response.dto.ts @@ -0,0 +1,58 @@ +import type { AxiosResponse } from 'axios'; + +export class CreateStudyroomReservationResponseDto { + status!: number; + result!: string; + error?: string; + content?: string; + + static from( + response: Pick, 'data' | 'status'>, + ): CreateStudyroomReservationResponseDto { + const dto = new CreateStudyroomReservationResponseDto(); + dto.status = response.status; + const body = this.parseBody(response.data); + dto.result = body.result ?? ''; + dto.error = body.error; + dto.content = body.content; + return dto; + } + + private static parseBody(data: string): { + readonly result?: string; + readonly error?: string; + readonly content?: string; + } { + let parsed: unknown; + + try { + parsed = JSON.parse(data); + } catch { + throw new TypeError('스터디룸 예약 생성 응답이 JSON 형식이 아닙니다.'); + } + + if (!this.isResponseBody(parsed)) { + throw new TypeError( + '스터디룸 예약 생성 응답에 result 또는 error 문자열이 없습니다.', + ); + } + + return { + result: + typeof parsed['result'] === 'string' ? parsed['result'] : undefined, + error: typeof parsed['error'] === 'string' ? parsed['error'] : undefined, + content: + typeof parsed['content'] === 'string' ? parsed['content'] : undefined, + }; + } + + private static isResponseBody( + value: unknown, + ): value is Record { + return ( + typeof value === 'object' && + value !== null && + ('result' in value || 'error' in value) + ); + } +} diff --git a/src/common/dto/fetch-studyroom-reservations-response.dto.ts b/src/common/dto/fetch-studyroom-reservations-response.dto.ts new file mode 100644 index 0000000..01f431f --- /dev/null +++ b/src/common/dto/fetch-studyroom-reservations-response.dto.ts @@ -0,0 +1,116 @@ +import type { AxiosResponse } from 'axios'; + +export class StudyroomReservationDto { + bookingId!: string | null; + ipid!: string | null; + roomName!: string; + duration!: string | null; + date!: string; + startsAt!: string | null; + + static from(value: unknown): StudyroomReservationDto { + if (!this.isRecord(value)) { + throw new TypeError('스터디룸 예약 정보가 객체 형식이 아닙니다.'); + } + + const dto = new StudyroomReservationDto(); + dto.bookingId = this.getNullableString(value, 'booking_id'); + dto.ipid = this.getNullableString(value, 'ipid'); + dto.roomName = this.getString(value, 'room_name'); + dto.duration = this.getNullableString(value, 'duration'); + dto.date = this.getString(value, 'date'); + dto.startsAt = this.getNullableString(value, 'starts_at'); + return dto; + } + + private static getNullableString( + value: Record, + key: string, + ): string | null { + const field = value[key]; + + if (typeof field === 'string') { + return field; + } + + if (field === null) { + return null; + } + + throw new TypeError( + `스터디룸 예약 정보의 ${key} 필드가 올바르지 않습니다.`, + ); + } + + private static getString( + value: Record, + key: string, + ): string { + const field = value[key]; + + if (typeof field === 'string') { + return field; + } + + throw new TypeError( + `스터디룸 예약 정보의 ${key} 필드가 문자열이 아닙니다.`, + ); + } + + private static isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; + } +} + +export class FetchStudyroomReservationsResponseDto { + status!: number; + reservations!: StudyroomReservationDto[]; + errorMessage?: string; + + static from( + response: Pick, 'data' | 'status'>, + ): FetchStudyroomReservationsResponseDto { + const dto = new FetchStudyroomReservationsResponseDto(); + dto.status = response.status; + const body = this.parseBody(response.data); + dto.reservations = body.reservations; + dto.errorMessage = body.errorMessage; + return dto; + } + + private static parseBody(data: string): { + readonly reservations: StudyroomReservationDto[]; + readonly errorMessage?: string; + } { + let parsed: unknown; + + try { + parsed = JSON.parse(data); + } catch { + throw new TypeError('스터디룸 예약 목록 응답이 JSON 형식이 아닙니다.'); + } + + if (!this.isRecord(parsed)) { + throw new TypeError('스터디룸 예약 목록 응답이 객체 형식이 아닙니다.'); + } + + const result = parsed['result']; + if (Array.isArray(result)) { + return { + reservations: result.map((reservation) => + StudyroomReservationDto.from(reservation), + ), + }; + } + + if (typeof result === 'string') { + return { reservations: [], errorMessage: result }; + } + + throw new TypeError('스터디룸 예약 목록 응답에 예약 배열이 없습니다.'); + } + + private static isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; + } +} diff --git a/src/common/services/external-api-studyroom.service.spec.ts b/src/common/services/external-api-studyroom.service.spec.ts index e0b11b2..b84085b 100644 --- a/src/common/services/external-api-studyroom.service.spec.ts +++ b/src/common/services/external-api-studyroom.service.spec.ts @@ -6,8 +6,8 @@ import { ExternalApiService } from './external-api.service'; describe('ExternalApiService studyroom API', () => { const response = { data: '{}', status: 200 }; const endpointByKey: Record = { + CRAWLER_API_ROOT: 'https://external.test/studyroom/crawler', GET_USER_RESERVATIONS_URL: 'https://external.test/studyroom/reservations', - GET_USER_AVAILABILITY_URL: 'https://external.test/studyroom/availability', CREATE_RESERVATION_URL: 'https://external.test/studyroom/create', CANCEL_RESERVATION_URL: 'https://external.test/studyroom/cancel', }; @@ -35,42 +35,95 @@ describe('ExternalApiService studyroom API', () => { service = moduleRef.get(ExternalApiService); }); - it.each([ - { - name: '예약 목록', - endpoint: endpointByKey.GET_USER_RESERVATIONS_URL, - body: { student_id: '20240001', password: 'secret' }, - invoke: () => - service.fetchStudyroomReservations({ - userId: '20240001', - password: 'secret', - }), - }, - { - name: '사용자 예약 가능 여부', - endpoint: endpointByKey.GET_USER_AVAILABILITY_URL, - body: { - id: '23011583', - password: 'secret', - user_name: '김시윤', - student_id: '23011583', - year: 2026, - month: '07', - day: '27', - }, - invoke: () => - service.fetchStudyroomAvailability({ - userId: '20240001', - password: 'secret', - friendName: '김빵빵', - friendId: '23011583', - date, - }), - }, - { - name: '예약 생성', - endpoint: endpointByKey.CREATE_RESERVATION_URL, - body: { + it('스터디룸 크롤링 요청을 외부 규격으로 전송한다', async () => { + // Given + // When + const result = await service.fetchStudyroom({ + roomName: '스터디룸 02', + }); + + // Then + expect(post).toHaveBeenCalledWith( + endpointByKey.CRAWLER_API_ROOT, + JSON.stringify({ room_name: '스터디룸 02' }), + expect.objectContaining({ + headers: { 'Content-Type': 'application/json' }, + validateStatus: expect.any(Function), + }), + ); + expect(result).toBe(response); + }); + + it('예약 목록 응답을 타입이 지정된 예약 DTO 배열로 직렬화한다', async () => { + // Given + post.mockResolvedValueOnce({ + data: JSON.stringify({ + result: [ + { + booking_id: 'booking-1', + ipid: 'ipid-1', + room_name: '대양AI센터 B205', + duration: null, + date: '2026.07.27', + starts_at: null, + }, + ], + }), + status: 200, + }); + + // When + const result = await service.fetchStudyroomReservations({ + userId: '20240001', + password: 'secret', + }); + + // Then + expect(post).toHaveBeenCalledWith( + endpointByKey.GET_USER_RESERVATIONS_URL, + JSON.stringify({ student_id: '20240001', password: 'secret' }), + expect.objectContaining({ + headers: { 'Content-Type': 'application/json' }, + validateStatus: expect.any(Function), + }), + ); + expect(result).toEqual({ + status: 200, + reservations: [ + { + bookingId: 'booking-1', + ipid: 'ipid-1', + roomName: '대양AI센터 B205', + duration: null, + date: '2026.07.27', + startsAt: null, + }, + ], + }); + }); + + it('예약 생성 응답을 DTO로 직렬화한다', async () => { + // Given + post.mockResolvedValueOnce({ + data: '{"result":"예약되었습니다"}', + status: 200, + }); + + // When + const result = await service.createStudyroomReservation({ + userId: '20240001', + password: 'secret', + studyroomId: 1, + users: [{ student_id: '23011583', name: '김빵빵' }], + date, + startsAt: 10, + duration: 2, + }); + + // Then + expect(post).toHaveBeenCalledWith( + endpointByKey.CREATE_RESERVATION_URL, + JSON.stringify({ id: '20240001', password: 'secret', room_id: 1, @@ -80,49 +133,79 @@ describe('ExternalApiService studyroom API', () => { day: '27', start_time: 10, hours: 2, - }, - invoke: () => - service.createStudyroomReservation({ - userId: '20240001', - password: 'secret', - studyroomId: 1, - users: [{ student_id: '23011583', name: '김빵빵' }], - date, - startsAt: 10, - duration: 2, - }), - }, - { - name: '예약 취소', - endpoint: endpointByKey.CANCEL_RESERVATION_URL, - body: { + }), + expect.objectContaining({ + headers: { 'Content-Type': 'application/json' }, + validateStatus: expect.any(Function), + }), + ); + expect(result).toEqual({ status: 200, result: '예약되었습니다' }); + }); + + it('예약 생성의 이용자 검증 오류를 DTO로 직렬화한다', async () => { + // Given + post.mockResolvedValueOnce({ + data: '{"error":"이름과 학번이 일치하지 않는 이용자가 있습니다.","content":"23011078-남정우"}', + status: 422, + }); + + // When + const result = await service.createStudyroomReservation({ + userId: '20240001', + password: 'secret', + studyroomId: 1, + users: [{ student_id: '23011078', name: '남정우' }], + date, + startsAt: 10, + duration: 2, + }); + + // Then + expect(result).toEqual({ + status: 422, + result: '', + error: '이름과 학번이 일치하지 않는 이용자가 있습니다.', + content: '23011078-남정우', + }); + }); + + it('예약 취소 응답을 DTO로 직렬화하고 reserve_no를 전송한다', async () => { + // Given + post.mockResolvedValueOnce({ + data: '{"result":{"ok":true,"resultCode":"0","resultMsg":"예약이 취소되었습니다.","reserveNo":"2026081908103849"}}', + status: 200, + }); + + // When + const result = await service.cancelStudyroomReservation({ + userId: '20240001', + password: 'secret', + reserveNo: '2026081908103849', + cancelReason: '일정 변경', + }); + + // Then + expect(post).toHaveBeenCalledWith( + endpointByKey.CANCEL_RESERVATION_URL, + JSON.stringify({ id: '20240001', password: 'secret', - booking_id: 'booking-1', + reserve_no: '2026081908103849', cancel_msg: '일정 변경', - }, - invoke: () => - service.cancelStudyroomReservation({ - userId: '20240001', - password: 'secret', - bookingId: 'booking-1', - cancelReason: '일정 변경', - }), - }, - ])( - '$name 요청을 외부 규격으로 전송한다', - async ({ endpoint, body, invoke }) => { - // Given - post.mockClear(); - - // When - const result = await invoke(); - - // Then - expect(post).toHaveBeenCalledWith(endpoint, JSON.stringify(body), { + }), + expect.objectContaining({ headers: { 'Content-Type': 'application/json' }, - }); - expect(result).toBe(response); - }, - ); + validateStatus: expect.any(Function), + }), + ); + expect(result).toEqual({ + status: 200, + result: { + ok: true, + resultCode: '0', + resultMessage: '예약이 취소되었습니다.', + reserveNo: '2026081908103849', + }, + }); + }); }); diff --git a/src/common/services/external-api.service.spec.ts b/src/common/services/external-api.service.spec.ts index 0a66237..05c04b4 100644 --- a/src/common/services/external-api.service.spec.ts +++ b/src/common/services/external-api.service.spec.ts @@ -38,6 +38,7 @@ describe('ExternalApiService', () => { name: '포털 인증', endpoint: endpointByKey.PORTAL_AUTH_URL, body: { id: '20240001', password: 'secret' }, + requestConfig: { headers: { 'Content-Type': 'application/json' } }, invoke: () => service.authenticatePortal({ studentId: '20240001', @@ -48,6 +49,7 @@ describe('ExternalApiService', () => { name: '출석 조회', endpoint: endpointByKey.GET_COURSE_ATTENDANCE_URL, body: { studentId: '20240001', name: '홍길동', password: 'secret' }, + requestConfig: { headers: { 'Content-Type': 'application/json' } }, invoke: () => service.fetchCourseAttendance({ studentId: '20240001', @@ -59,11 +61,15 @@ describe('ExternalApiService', () => { name: '스터디룸 크롤링', endpoint: endpointByKey.CRAWLER_API_ROOT, body: { room_name: '대양AI센터 B205' }, + requestConfig: expect.objectContaining({ + headers: { 'Content-Type': 'application/json' }, + validateStatus: expect.any(Function), + }), invoke: () => service.fetchStudyroom({ roomName: '대양AI센터 B205' }), }, ])( '$name 요청을 외부 규격으로 전송한다', - async ({ endpoint, body, invoke }) => { + async ({ endpoint, body, requestConfig, invoke }) => { // Given post.mockClear(); @@ -71,9 +77,11 @@ describe('ExternalApiService', () => { const result = await invoke(); // Then - expect(post).toHaveBeenCalledWith(endpoint, JSON.stringify(body), { - headers: { 'Content-Type': 'application/json' }, - }); + expect(post).toHaveBeenCalledWith( + endpoint, + JSON.stringify(body), + requestConfig, + ); expect(result).toBe(response); }, ); diff --git a/src/common/services/external-api.service.ts b/src/common/services/external-api.service.ts index ed4c9c3..4250b80 100644 --- a/src/common/services/external-api.service.ts +++ b/src/common/services/external-api.service.ts @@ -9,9 +9,11 @@ import type { CreateStudyroomReservationRequest, PortalAuthenticationRequest, StudentCredentials, - StudyroomAvailabilityRequest, StudyroomCrawlerRequest, } from '../types/external-api.type'; +import { CancelStudyroomReservationResponseDto } from '../dto/cancel-studyroom-reservation-response.dto'; +import { CreateStudyroomReservationResponseDto } from '../dto/create-studyroom-reservation-response.dto'; +import { FetchStudyroomReservationsResponseDto } from '../dto/fetch-studyroom-reservations-response.dto'; import { AxiosService } from './axios.service'; const EXTERNAL_API_ENDPOINTS = { @@ -19,7 +21,6 @@ const EXTERNAL_API_ENDPOINTS = { courseAttendance: 'GET_COURSE_ATTENDANCE_URL', studyroomCrawler: 'CRAWLER_API_ROOT', studyroomReservations: 'GET_USER_RESERVATIONS_URL', - studyroomAvailability: 'GET_USER_AVAILABILITY_URL', createStudyroomReservation: 'CREATE_RESERVATION_URL', cancelStudyroomReservation: 'CANCEL_RESERVATION_URL', godokCalendar: 'GET_GODOK_CALENDAR_URL', @@ -38,13 +39,17 @@ const GODOK_TERM_ID = 'TERM-00571'; const JSON_REQUEST_CONFIG = { headers: { 'Content-Type': 'application/json' }, } as const; +const STUDYROOM_JSON_REQUEST_CONFIG = { + ...JSON_REQUEST_CONFIG, + validateStatus: () => true, +} as const; @Injectable() export class ExternalApiService { constructor( private readonly axiosService: AxiosService, private readonly configService: ConfigService, - ) {} + ) { } authenticatePortal( request: PortalAuthenticationRequest, @@ -68,36 +73,32 @@ export class ExternalApiService { fetchStudyroom( request: StudyroomCrawlerRequest, ): Promise> { - return this.postJson(EXTERNAL_API_ENDPOINTS.studyroomCrawler, { - room_name: request.roomName, - }); + return this.postJson( + EXTERNAL_API_ENDPOINTS.studyroomCrawler, + { + room_name: request.roomName, + }, + STUDYROOM_JSON_REQUEST_CONFIG, + ); } fetchStudyroomReservations( request: StudentCredentials, - ): Promise> { - return this.postJson(EXTERNAL_API_ENDPOINTS.studyroomReservations, { - student_id: request.userId, - password: request.password, - }); - } - - fetchStudyroomAvailability( - request: StudyroomAvailabilityRequest, - ): Promise> { - return this.postJson(EXTERNAL_API_ENDPOINTS.studyroomAvailability, { - id: request.userId, - password: request.password, - user_name: request.friendName, - student_id: request.friendId, - ...this.toDateParts(request.date), - }); + ): Promise { + return this.postJson( + EXTERNAL_API_ENDPOINTS.studyroomReservations, + { + student_id: request.userId, + password: request.password, + }, + STUDYROOM_JSON_REQUEST_CONFIG, + ).then((response) => FetchStudyroomReservationsResponseDto.from(response)); } createStudyroomReservation( request: CreateStudyroomReservationRequest, - ): Promise> { - return this.postJson(EXTERNAL_API_ENDPOINTS.createStudyroomReservation, { + ): Promise { + console.log({ id: request.userId, password: request.password, room_id: request.studyroomId, @@ -105,18 +106,35 @@ export class ExternalApiService { ...this.toDateParts(request.date), start_time: request.startsAt, hours: request.duration, - }); + },) + return this.postJson( + EXTERNAL_API_ENDPOINTS.createStudyroomReservation, + { + id: request.userId, + password: request.password, + room_id: request.studyroomId, + users: request.users, + ...this.toDateParts(request.date), + start_time: request.startsAt, + hours: request.duration, + }, + STUDYROOM_JSON_REQUEST_CONFIG, + ).then((response) => CreateStudyroomReservationResponseDto.from(response)); } cancelStudyroomReservation( request: CancelStudyroomReservationRequest, - ): Promise> { - return this.postJson(EXTERNAL_API_ENDPOINTS.cancelStudyroomReservation, { - id: request.userId, - password: request.password, - booking_id: request.bookingId, - cancel_msg: request.cancelReason, - }); + ): Promise { + return this.postJson( + EXTERNAL_API_ENDPOINTS.cancelStudyroomReservation, + { + id: request.userId, + password: request.password, + reserve_no: request.reserveNo, + cancel_msg: request.cancelReason, + }, + STUDYROOM_JSON_REQUEST_CONFIG, + ).then((response) => CancelStudyroomReservationResponseDto.from(response)); } fetchGodokCalendar(): Promise> { @@ -176,11 +194,12 @@ export class ExternalApiService { private postJson( endpoint: ExternalApiEndpoint, body: object, + requestConfig = JSON_REQUEST_CONFIG, ): Promise> { return this.axiosService.post( this.getEndpoint(endpoint), JSON.stringify(body), - JSON_REQUEST_CONFIG, + requestConfig, ); } @@ -190,7 +209,7 @@ export class ExternalApiService { private toDateParts(date: Date) { return { - year: date.getFullYear(), + year: String(date.getFullYear()), month: String(date.getMonth() + 1).padStart(2, '0'), day: String(date.getDate()).padStart(2, '0'), }; diff --git a/src/common/types/external-api.type.ts b/src/common/types/external-api.type.ts index 1e799fc..2917c21 100644 --- a/src/common/types/external-api.type.ts +++ b/src/common/types/external-api.type.ts @@ -16,12 +16,6 @@ export type StudentCredentials = { readonly password: string; }; -export type StudyroomAvailabilityRequest = StudentCredentials & { - readonly friendId: string; - readonly friendName: string; - readonly date: Date; -}; - export type StudyroomReservationUser = { readonly student_id: string; readonly name: string; @@ -36,7 +30,7 @@ export type CreateStudyroomReservationRequest = StudentCredentials & { }; export type CancelStudyroomReservationRequest = StudentCredentials & { - readonly bookingId: string; + readonly reserveNo: string; readonly cancelReason?: string; }; diff --git a/src/studyroom/dto/userPid.dto.ts b/src/studyroom/dto/userPid.dto.ts deleted file mode 100644 index a31ee23..0000000 --- a/src/studyroom/dto/userPid.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -export class UserPidDto { - @ApiProperty({ - description: 'User Pid', - }) - sejongPid!: string; - - static from(sejongPid: string): UserPidDto { - return { sejongPid }; - } -} diff --git a/src/studyroom/payload/studyroomUserPayload.payload.ts b/src/studyroom/payload/studyroomUserPayload.payload.ts deleted file mode 100644 index 83ed2fe..0000000 --- a/src/studyroom/payload/studyroomUserPayload.payload.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsDate, IsString } from 'class-validator'; -import { PasswordPayload } from 'src/auth/payload/password.payload'; - -export class StudyroomUserPayload extends PasswordPayload { - @ApiProperty({ - description: '조회할 user id', - type: String, - }) - @IsString() - friendId!: string; - - @ApiProperty({ - description: '조회할 user 이름', - type: String, - }) - @IsString() - friendName!: string; - - @ApiProperty({ - description: '예약 날짜 (예: 2024-02-28 or 2024-02-28T10:13:09.004Z)', - type: Date, - }) - @Type(() => Date) - @IsDate() - date!: Date; -} diff --git a/src/studyroom/reservation.service.spec.ts b/src/studyroom/reservation.service.spec.ts new file mode 100644 index 0000000..673f6ad --- /dev/null +++ b/src/studyroom/reservation.service.spec.ts @@ -0,0 +1,134 @@ +import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { ExternalApiService } from '../common/services/external-api.service'; +import { UserRepository } from '../user/user.repository'; +import { StudyroomRepository } from './studyroom.repository'; +import { ReservationService } from './reservation.service'; + +describe('ReservationService', () => { + const studyroomRepository = { + getReservationById: jest.fn(), + updateReservations: jest.fn(), + }; + const userRepository = { + getUserByStudentId: jest.fn(), + }; + const externalApiService = { + cancelStudyroomReservation: jest.fn(), + fetchStudyroomReservations: jest.fn(), + }; + + let service: ReservationService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const moduleRef = await Test.createTestingModule({ + providers: [ + ReservationService, + { provide: StudyroomRepository, useValue: studyroomRepository }, + { provide: UserRepository, useValue: userRepository }, + { provide: ExternalApiService, useValue: externalApiService }, + ], + }).compile(); + + service = moduleRef.get(ReservationService); + }); + + it('취소 API가 업무 실패를 반환하면 예약 삭제를 진행하지 않는다', async () => { + // Given + studyroomRepository.getReservationById.mockResolvedValue({ + visitorId: '20240001', + bookingId: '2026081908103849', + }); + externalApiService.cancelStudyroomReservation.mockResolvedValue({ + status: 200, + result: { + ok: false, + resultCode: '1', + resultMessage: '예약이 존재하지 않습니다.', + reserveNo: '2026081908103849', + }, + }); + + // When + const cancellation = service.cancelReservation(1, '20240001', { + password: 'secret', + }); + + // Then + await expect(cancellation).rejects.toBeInstanceOf(NotFoundException); + expect(externalApiService.cancelStudyroomReservation).toHaveBeenCalledWith({ + userId: '20240001', + password: 'secret', + reserveNo: '2026081908103849', + cancelReason: undefined, + }); + }); + + it('시간 또는 기간이 없는 외부 예약을 동기화 대상에서 제외한다', async () => { + // Given + userRepository.getUserByStudentId.mockResolvedValue({ + studentId: '20240001', + }); + externalApiService.fetchStudyroomReservations.mockResolvedValue({ + status: 200, + reservations: [ + { + bookingId: null, + ipid: null, + roomName: 'S1층 08스터디룸', + duration: null, + date: '2026.08.19', + startsAt: null, + }, + { + bookingId: 'booking-1', + ipid: 'ipid-1', + roomName: 'S1층 09스터디룸', + duration: '2', + date: '2026.08.20', + startsAt: '10:00', + }, + ], + }); + + // When + await service.updateUserReservations('20240001', 'secret'); + + // Then + expect(studyroomRepository.updateReservations).toHaveBeenCalledWith( + '20240001', + [ + { + booking_id: 'booking-1', + ipid: 'ipid-1', + room_name: 'S1층 09스터디룸', + duration: '2', + date: '2026.08.20', + starts_at: '10:00', + }, + ], + ); + }); + + it('예약 목록의 외부 인증 실패 메시지를 그대로 반환한다', async () => { + // Given + userRepository.getUserByStudentId.mockResolvedValue({ + studentId: '20240001', + }); + externalApiService.fetchStudyroomReservations.mockResolvedValue({ + status: 401, + reservations: [], + errorMessage: '포털 인증에 실패했습니다.', + }); + + // When + const reservations = service.updateUserReservations('20240001', 'secret'); + + // Then + await expect(reservations).rejects.toEqual( + new UnauthorizedException('포털 인증에 실패했습니다.'), + ); + }); +}); diff --git a/src/studyroom/reservation.service.ts b/src/studyroom/reservation.service.ts index 54dfeea..a042586 100644 --- a/src/studyroom/reservation.service.ts +++ b/src/studyroom/reservation.service.ts @@ -7,10 +7,8 @@ import { } from '@nestjs/common'; import { ExternalApiService } from 'src/common/services/external-api.service'; import { UserRepository } from 'src/user/user.repository'; -import { UserPidDto } from './dto/userPid.dto'; import { StudyroomCancelPayload } from './payload/studyroomCancel.payload'; import { StudyroomReservePayload } from './payload/studyroomReserve.payload'; -import { StudyroomUserPayload } from './payload/studyroomUserPayload.payload'; import { StudyroomRepository } from './studyroom.repository'; import { ResultResponse } from './types/resultResponse.type'; @@ -28,65 +26,45 @@ export class ReservationService { if (!user) throw new NotFoundException('해당 학번의 학생이 존재하지 않습니다'); - const res = await this.externalApiService.fetchStudyroomReservations({ + const response = await this.externalApiService.fetchStudyroomReservations({ userId, password, }); - const response = JSON.parse(res.data); - - if (res.status === 404) { + if (response.status === 404) { return await this.studyroomRepository.updateReservations(userId, []); - } else if (res.status === 401) { - throw new UnauthorizedException(response.result); - } else if (res.status >= 400) { + } else if (response.status === 401) { + throw new UnauthorizedException(response.errorMessage); + } else if (response.status >= 400) { throw new InternalServerErrorException('Internal Server Error'); } return await this.studyroomRepository.updateReservations( userId, - response.result, - ); - } - - async checkUserAvailablity( - userId: string, - payload: StudyroomUserPayload, - ): Promise { - const res = await this.externalApiService.fetchStudyroomAvailability({ - userId, - password: payload.password, - friendName: payload.friendName, - friendId: payload.friendId, - date: payload.date, - }); - - const response = JSON.parse(res.data); - if (res.status === 400) { - throw new BadRequestException(response.error); - } else if (res.status === 401) { - throw new UnauthorizedException(response.error); - } else if (res.status >= 400) { - throw new InternalServerErrorException('Internal Server Error'); - } - const friendPid: string = response.ipid.toString(); - if (!friendPid) - throw new InternalServerErrorException('추가할 수 없는 사용자입니다.'); - - await this.userRepository.updateOrCreateUser( - payload.friendId, - payload.friendName, - friendPid, + response.reservations.flatMap((reservation) => { + if (reservation.duration === null || reservation.startsAt === null) { + return []; + } + + return [ + { + booking_id: reservation.bookingId, + ipid: reservation.ipid, + room_name: reservation.roomName, + duration: reservation.duration, + date: reservation.date, + starts_at: reservation.startsAt, + }, + ]; + }), ); - - return UserPidDto.from(friendPid); } async createReservation( userId: string, payload: StudyroomReservePayload, ): Promise { - const res = await this.externalApiService.createStudyroomReservation({ + const response = await this.externalApiService.createStudyroomReservation({ userId, password: payload.password, studyroomId: payload.studyroomId, @@ -95,16 +73,18 @@ export class ReservationService { startsAt: payload.startsAt, duration: payload.duration, }); - const response = JSON.parse(res.data); - if (res.status === 400) { - throw new BadRequestException(response.error); - } else if (res.status === 401) { - throw new UnauthorizedException(response); - } else if (res.status >= 400) { + if (response.status === 400 || response.status === 422) { + throw new BadRequestException({ + message: response.error, + content: response.content, + }); + } else if (response.status === 401) { + throw new UnauthorizedException(response.error); + } else if (response.status >= 400) { throw new InternalServerErrorException('Internal Server Error'); } - return response; + return { result: response.result }; } async cancelReservation( @@ -126,25 +106,29 @@ export class ReservationService { '취소할 수 없는 예약입니다 (bookingId 없음)', ); - const res = await this.externalApiService.cancelStudyroomReservation({ + const response = await this.externalApiService.cancelStudyroomReservation({ userId, password: payload.password, - bookingId: reservation.bookingId, + reserveNo: reservation.bookingId, cancelReason: payload.cancelReason, }); - const response = JSON.parse(res.data); - if (res.status === 400) { - throw new BadRequestException(response.result); - } else if (res.status === 401) { - throw new UnauthorizedException(response.result); - } else if (res.status === 404) { - throw new NotFoundException(response.result); - } else if (res.status >= 400) { - console.error(response); + if (response.status === 401) { + throw new UnauthorizedException(response.errorMessage); + } else if (response.status >= 400) { throw new InternalServerErrorException('Internal Server Error'); } - return response; + if (!response.result) { + throw new InternalServerErrorException( + '예약 취소 응답이 올바르지 않습니다.', + ); + } + + if (!response.result.ok) { + throw new NotFoundException(response.result.resultMessage); + } + + return { result: response.result.resultMessage }; } } diff --git a/src/studyroom/studyroom.controller.ts b/src/studyroom/studyroom.controller.ts index 39018bc..868c95e 100644 --- a/src/studyroom/studyroom.controller.ts +++ b/src/studyroom/studyroom.controller.ts @@ -30,11 +30,9 @@ import { UserInfo } from 'src/auth/types/user-info.type'; import { StudyroomInfoListDto } from './dto/studyroom-infp.dto'; import { StudyroomReservationListDto } from './dto/studyroom-reservation.dto'; import { StudyroomDto, StudyroomListDto } from './dto/studyroom.dto'; -import { UserPidDto } from './dto/userPid.dto'; import { StudyroomCancelPayload } from './payload/studyroomCancel.payload'; import { StudyroomReservePayload } from './payload/studyroomReserve.payload'; import { StudyroomUpdatePayload } from './payload/studyroomUpdate.payload'; -import { StudyroomUserPayload } from './payload/studyroomUserPayload.payload'; import { StudyroomQuery } from './query/studyroom.query'; import { StudyroomDateQuery } from './query/studyroomDateQuery.query'; import { StudyroomService } from './studyroom.service'; @@ -153,30 +151,6 @@ export class StudyroomController { ); } - @Version('1') - @ApiOperation({ - summary: '스터디룸 인원 추가 확인 API', - description: - '추가 가능한 친구인지 확인합니다. 스터디룸 사용자로 추가할 수 없는 경우, 내 친구에도 추가되지 않습니다.', - }) - @ApiCreatedResponse({ type: UserPidDto }) - @ApiBadRequestResponse({ - description: - '예약이 불가능합니다. (스터디원으로 추가 불가능) | 자기 자신을 친구로 등록할 수 없습니다.', - }) - @ApiUnauthorizedResponse({ - description: '포털 로그인 실패', - }) - @ApiBearerAuth() - @UseGuards(JwtAuthGuard) - @Post('user') - async checkUserAvailablity( - @CurrentUser() user: UserInfo, - @Body(PasswordValidationPipe) payload: StudyroomUserPayload, - ): Promise { - return this.studyroomService.checkUserAvailablity(user.studentId, payload); - } - @Version('1') @ApiOperation({ summary: '[어드민] 스터디룸 정보 업데이트 API', diff --git a/src/studyroom/studyroom.service.ts b/src/studyroom/studyroom.service.ts index f99c06d..def3449 100644 --- a/src/studyroom/studyroom.service.ts +++ b/src/studyroom/studyroom.service.ts @@ -1,25 +1,16 @@ -import { - BadRequestException, - Injectable, - Logger, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Cron } from '@nestjs/schedule'; import { PasswordPayload } from 'src/auth/payload/password.payload'; import { DiscordService } from 'src/common/services/discord.service'; import { ExternalApiService } from 'src/common/services/external-api.service'; import { PrismaService } from 'src/common/services/prisma.service'; -import { UserRepository } from 'src/user/user.repository'; -import { UserService } from 'src/user/user.service'; import { StudyroomInfoListDto } from './dto/studyroom-infp.dto'; import { StudyroomReservationListDto } from './dto/studyroom-reservation.dto'; import { StudyroomDto, StudyroomListDto } from './dto/studyroom.dto'; -import { UserPidDto } from './dto/userPid.dto'; import { StudyroomCancelPayload } from './payload/studyroomCancel.payload'; import { StudyroomReservePayload } from './payload/studyroomReserve.payload'; import { StudyroomUpdatePayload } from './payload/studyroomUpdate.payload'; -import { StudyroomUserPayload } from './payload/studyroomUserPayload.payload'; import { StudyroomQuery } from './query/studyroom.query'; import { StudyroomDateQuery } from './query/studyroomDateQuery.query'; import { ReservationService } from './reservation.service'; @@ -38,8 +29,6 @@ export class StudyroomService { private readonly reservationService: ReservationService, private readonly externalApiService: ExternalApiService, private readonly configService: ConfigService, - private readonly userService: UserService, - private readonly userRepository: UserRepository, private readonly discordService: DiscordService, ) {} @@ -54,9 +43,9 @@ export class StudyroomService { name: 'studyroomSlotCrawler', }) async handleCron() { - if (this.configService.get('NODE_ENV') !== 'prod') { - return; - } + // if (this.configService.get('NODE_ENV') !== 'prod') { + // return; + // } const now = new Date(); @@ -169,40 +158,6 @@ export class StudyroomService { return StudyroomReservationListDto.from(reservations); } - async checkUserAvailablity( - userId: string, - payload: StudyroomUserPayload, - ): Promise { - if (userId === payload.friendId) { - throw new BadRequestException('자기 자신을 친구로 등록할 수 없습니다.'); - } - - const friendPid = await this.userService.getUserPid( - { - friendId: payload.friendId, - friendName: payload.friendName, - password: payload.password, - date: payload.date, - }, - userId, - ); - - const relation = await this.userRepository.getFriendRelation( - payload.friendId, - userId, - ); - - if (!relation || relation.deletedAt) { - await this.userRepository.addUserAsFriend( - relation, - payload.friendId, - userId, - ); - } - - return friendPid; - } - async reserveStudyroom( userId: string, payload: StudyroomReservePayload, diff --git a/src/studyroom/types/rawStudyroom.d.ts b/src/studyroom/types/rawStudyroom.d.ts index 8c33adb..d0f68cd 100644 --- a/src/studyroom/types/rawStudyroom.d.ts +++ b/src/studyroom/types/rawStudyroom.d.ts @@ -1,5 +1,5 @@ export type RawStudyroomSlot = { - date: Date; + date: string; time: string; is_reserved: boolean; is_closed: boolean; diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 3cc52f3..31e292b 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -2,16 +2,9 @@ import { Module } from '@nestjs/common'; import { UserController } from './user.controller'; import { UserRepository } from './user.repository'; import { UserService } from './user.service'; -import { ReservationService } from 'src/studyroom/reservation.service'; -import { StudyroomRepository } from 'src/studyroom/studyroom.repository'; @Module({ controllers: [UserController], - providers: [ - UserService, - UserRepository, - ReservationService, - StudyroomRepository, - ], + providers: [UserService, UserRepository], }) export class UserModule {} diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 30b6357..01da697 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,30 +1,22 @@ import { BadRequestException, Injectable, - InternalServerErrorException, NotFoundException, - UnauthorizedException, } from '@nestjs/common'; import { UserInfo } from 'src/auth/types/user-info.type'; -import { UserPidDto } from 'src/studyroom/dto/userPid.dto'; -import { StudyroomUserPayload } from 'src/studyroom/payload/studyroomUserPayload.payload'; -import { ReservationService } from 'src/studyroom/reservation.service'; +import { DiscordService } from 'src/common/services/discord.service'; import { FriendListDto } from './dto/friend.dto'; import { PushTokenPayload } from './payload/pushToken.payload'; import { UserPayload } from './payload/user.payload'; import { UserRepository } from './user.repository'; -import { DiscordService } from 'src/common/services/discord.service'; @Injectable() export class UserService { constructor( private readonly userRepository: UserRepository, - private readonly reservationService: ReservationService, private readonly discordService: DiscordService, ) {} - private static verificationDate = new Date('2023-02-30'); - async updatePushToken( payload: PushTokenPayload, user: UserInfo, @@ -32,44 +24,17 @@ export class UserService { await this.userRepository.updatePushToken(payload, user); } - async getUserPid( - payload: StudyroomUserPayload, - userId: string, - ): Promise { - const friendPid = await this.reservationService.checkUserAvailablity( - userId, - payload, - ); - - return UserPidDto.from(friendPid.sejongPid); - } - async addUserAsFriend(payload: UserPayload, user: UserInfo): Promise { const friend = await this.userRepository.getUserByStudentId( payload.studentId, ); if (!friend) { - try { - await this.getUserPid( - { - friendId: payload.studentId, - friendName: payload.name, - password: payload.password, - date: UserService.verificationDate, - }, - user.studentId, - ); - } catch (error) { - if (error.status === 400) { - throw new BadRequestException('해당 id의 학생을 찾을 수 없습니다.'); - } else if (error.status === 401) { - throw new UnauthorizedException('포털 로그인에 실패했습니다.'); - } else if (error.status >= 400) { - console.error(error); - throw new InternalServerErrorException('Internal Server Error'); - } - } + await this.userRepository.updateOrCreateUser( + payload.studentId, + payload.name, + payload.studentId, + ); } else if (user.studentId === friend.studentId) { throw new BadRequestException('자기 자신을 친구로 등록할 수 없습니다.'); }