diff --git a/src/activity/activity.controller.ts b/src/activity/activity.controller.ts index a807aea..9bb12ce 100644 --- a/src/activity/activity.controller.ts +++ b/src/activity/activity.controller.ts @@ -1,10 +1,7 @@ -import { Controller, Get, Param, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; +import { Controller, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from 'src/auth/guard/auth.guard'; -import { BaseResponse } from 'src/global/base/base-response'; -import { ExtractPayload } from 'src/global/decorator/extract-payload.decorator'; import { ActivityService } from './activity.service'; -import { ActivityDetailResponse } from './dto/activity-detail-response.dto'; @Controller('api/activities') @UseGuards(JwtAuthGuard) @@ -13,17 +10,17 @@ import { ActivityDetailResponse } from './dto/activity-detail-response.dto'; export class ActivityController { constructor(private readonly activityService: ActivityService) {} - @Get(':activityId') - @ApiOperation({ - summary: '활동 세부 정보 조회', - description: '활동 세부 정보를 조회한다. 리뷰는 따로 API 호출 해주세요!', - }) - @ApiParam({ name: 'activityId', description: '세부 정보를 조회할 활동 아이디' }) - @ApiOkResponse({ type: ActivityDetailResponse }) - async getActivityDetail( - @ExtractPayload() userId: number, - @Param('activityId') activityId: number, - ): Promise> { - return BaseResponse.of(await this.activityService.getActivityDetail(userId, activityId)); - } + // @Get(':activityId') + // @ApiOperation({ + // summary: '활동 세부 정보 조회', + // description: '활동 세부 정보를 조회한다. 리뷰는 따로 API 호출 해주세요!', + // }) + // @ApiParam({ name: 'activityId', description: '세부 정보를 조회할 활동 아이디' }) + // @ApiOkResponse({ type: ActivityDetailResponse }) + // async getActivityDetail( + // @ExtractPayload() userId: number, + // @Param('activityId') activityId: number, + // ): Promise> { + // return BaseResponse.of(await this.activityService.getActivityDetail(userId, activityId)); + // } } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 8e6daf9..b354b17 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,7 +1,6 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiHeaders, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BaseResponse } from 'src/global/base/base-response'; -import { ExtractPayload } from 'src/global/decorator/extract-payload.decorator'; import { ExtractToken } from 'src/global/decorator/extract-token.decorator'; import { AuthService } from './auth.service'; import { AppleOAuthRequest } from './dto/apple-oauth-request.dto'; @@ -12,7 +11,6 @@ import { JwtResponse } from './dto/jwt-response.dto'; import { KakaoOAuthRequest } from './dto/kakao-oauth-request.dto'; import { KakaoOAuthResponse } from './dto/kakao-oauth-response.dto'; import { LoginRequest } from './dto/login-request.dto'; -import { JwtAuthGuard } from './guard/auth.guard'; @Controller('api/auth') @ApiTags('auth') @@ -31,7 +29,6 @@ export class AuthController { } @Get('reissue') - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Access Token 만료시 재발급', description: 'Access Token과 Refresh Token을 재발급한다.', @@ -39,11 +36,8 @@ export class AuthController { @ApiBearerAuth() @ApiHeaders([{ name: 'Authorization', description: 'Refresh Token' }]) @ApiOkResponse({ type: JwtResponse, description: '재발급 성공' }) - async reissueToken( - @ExtractPayload() userId: number, - @ExtractToken() refreshToken: string, - ): Promise> { - return BaseResponse.of(await this.authService.reissueToken(userId, refreshToken)); + async reissueToken(@ExtractToken() refreshToken: string): Promise> { + return BaseResponse.of(await this.authService.reissueToken(refreshToken)); } @Post('email-check') diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index cbf9692..4727e0f 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,12 +3,13 @@ import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from 'src/user/entity/user.entity'; +import { UserModule } from 'src/user/user.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategy/jwt.strategy'; @Module({ - imports: [JwtModule.register({}), TypeOrmModule.forFeature([User]), PassportModule], + imports: [JwtModule.register({}), TypeOrmModule.forFeature([User]), PassportModule, UserModule], controllers: [AuthController], providers: [AuthService, JwtStrategy], exports: [AuthService], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index bb7fdfd..e630163 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -11,7 +11,6 @@ import { BaseException } from 'src/global/base/base-exception'; import { Password } from 'src/user/entity/password'; import { User } from 'src/user/entity/user.entity'; import { SocialType } from 'src/user/enum/social-type'; -import { UserResponseCode } from 'src/user/exception/user-response-code'; import { Repository } from 'typeorm'; import { AppleOAuthResponse } from './dto/apple-oauth-response.dto'; import { JwtResponse } from './dto/jwt-response.dto'; @@ -107,18 +106,15 @@ export class AuthService { return await this.userRepository.exist({ where: { email } }); } - async reissueToken(userId: number, refreshToken: string) { - const user = await this.userRepository.findOneBy({ userId }); - if (!user) { - throw BaseException.of(UserResponseCode.USER_NOT_FOUND); - } + async reissueToken(refreshToken: string) { + const user = await this.userRepository.findOneBy({ refreshToken }); - if (user.refreshToken !== refreshToken) { + if (!user) { throw BaseException.of(AuthResponseCode.INVALID_TOKEN); } - const newAccessToken = await this.generateAccessToken(userId); - const newRefreshToken = await this.generateRefreshToken(userId); + const newAccessToken = await this.generateAccessToken(user.userId); + const newRefreshToken = await this.generateRefreshToken(user.userId); user.refreshToken = newRefreshToken; await this.userRepository.save(user); diff --git a/src/auth/strategy/jwt.strategy.ts b/src/auth/strategy/jwt.strategy.ts index 1782685..2840394 100644 --- a/src/auth/strategy/jwt.strategy.ts +++ b/src/auth/strategy/jwt.strategy.ts @@ -3,18 +3,23 @@ import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { Config } from 'src/global/config/config.type'; +import { User } from 'src/user/entity/user.entity'; +import { UserService } from 'src/user/user.service'; import { JwtPayloadType } from './type/jwt-payload.type'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { - constructor(private configService: ConfigService) { + constructor( + private configService: ConfigService, + private readonly userService: UserService, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: configService.get('auth.secret', { infer: true }), }); } - public validate(payload: JwtPayloadType): JwtPayloadType { - return payload; + public async validate(payload: JwtPayloadType): Promise { + return await this.userService.findUserById(payload.sub); } } diff --git a/src/auth/strategy/type/jwt-payload.type.ts b/src/auth/strategy/type/jwt-payload.type.ts index 1a1b3cf..68c08cb 100644 --- a/src/auth/strategy/type/jwt-payload.type.ts +++ b/src/auth/strategy/type/jwt-payload.type.ts @@ -1,5 +1,5 @@ export type JwtPayloadType = { - userId: number; + sub: number; iat: number; exp: number; iss: string; diff --git a/src/place-review/dto/request/patch-place-review-request.dto.ts b/src/place-review/dto/request/patch-place-review-request.dto.ts new file mode 100644 index 0000000..fb3ed5d --- /dev/null +++ b/src/place-review/dto/request/patch-place-review-request.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsDateString, + IsNumber, + IsOptional, + IsString, + Length, + Max, + Min, +} from 'class-validator'; +import { IsPastOrPresent } from 'src/global/validation/decorator/is-past-or-present.decorator'; + +export class PatchPlaceReviewRequest { + @ApiProperty({ + description: '방문 날짜 (ISO 8601 형식을 따름)', + example: '2023-09-07', + }) + @IsDateString(undefined, { message: '날짜 형식이 아닙니다.' }) + @IsPastOrPresent({ message: '방문 날짜는 현재거나 과거여야 합니다.' }) + readonly visitedAt: Date; + + @ApiProperty({ + description: '참여한 활동의 아이디', + example: 1, + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsNumber(undefined, { message: '참여한 활동 아이디는 정수여야 합니다.' }) + readonly activityId?: number; + + @ApiProperty({ + description: '리뷰 내용', + example: '즐거운 독서 생활', + }) + @IsString({ message: '내용은 문자열이어야 합니다.' }) + @Length(1, 300, { message: '내용은 300자 이하여야 합니다.' }) + readonly contents: string; + + @ApiProperty({ + description: '장소 평점, 0점 ~ 5점 사이', + example: 4.2, + }) + @Type(() => Number) + @IsNumber(undefined, { message: '평점은 숫자여야 합니다.' }) + @Min(0, { message: '평점은 0점 이상 5점 이하여야 합니다.' }) + @Max(5, { message: '평점은 0점 이상 5점 이하여야 합니다.' }) + readonly rating: number; + + @ApiProperty({ + description: '댓글 허용 여부', + example: true, + }) + @Type(() => Boolean) + @IsBoolean({ message: '댓글 비허용 여부는 boolean 값이어야 합니다.' }) + readonly commentAllowed: boolean; + + @ApiProperty({ + type: [Number], + description: '유지할 이미지의 아이디', + example: [1, 2, 3], + required: false, + }) + @IsOptional() + @Transform(params => (params.value ? params.value.split(',').map(Number) : [])) + @IsNumber(undefined, { each: true, message: '이미지의 아이디는 정수여야 합니다.' }) + readonly remainImages: number[]; +} diff --git a/src/place-review/dto/response/patch-place-review-response.dto.ts b/src/place-review/dto/response/patch-place-review-response.dto.ts new file mode 100644 index 0000000..4bb3171 --- /dev/null +++ b/src/place-review/dto/response/patch-place-review-response.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PatchPlaceReviewResponse { + @ApiProperty({ description: '수정된 리뷰 아이디', example: 1 }) + updatedReviewId: number; + + @ApiProperty({ description: '수정된 날짜', example: new Date() }) + updatedAt: Date; +} diff --git a/src/place-review/exception/place-review-response-code.ts b/src/place-review/exception/place-review-response-code.ts index 0dd27da..272b655 100644 --- a/src/place-review/exception/place-review-response-code.ts +++ b/src/place-review/exception/place-review-response-code.ts @@ -22,4 +22,14 @@ export const PlaceReviewResponseCode = { 'REVIEW_004', '자신의 글을 신고할 수 없습니다.', ), + IMAGES_NUM_EXCEEDED: new ResponseCode( + HttpStatus.NOT_ACCEPTABLE, + 'REVIEW_005', + '업로드 할 사진이 너무 많습니다.', + ), + IMAGE_NOT_FOUND: new ResponseCode( + HttpStatus.NOT_FOUND, + 'REVIEW_006', + '해당 이미지를 찾을 수 없습니다.', + ), }; diff --git a/src/place-review/place-review.controller.ts b/src/place-review/place-review.controller.ts index c0c6cba..cc7f94d 100644 --- a/src/place-review/place-review.controller.ts +++ b/src/place-review/place-review.controller.ts @@ -1,19 +1,36 @@ -import { Body, Controller, Delete, Param, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Param, + Patch, + Post, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, + ApiBody, + ApiConsumes, ApiCreatedResponse, + ApiExtraModels, ApiOkResponse, ApiOperation, ApiParam, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import { JwtAuthGuard } from 'src/auth/guard/auth.guard'; import { BaseResponse } from 'src/global/base/base-response'; import { GlobalResponseCode } from 'src/global/base/global-respose-code'; import { ExtractPayload } from 'src/global/decorator/extract-payload.decorator'; import { PlaceReviewExistsValidationPipe } from 'src/global/validation/pipe/place-review-exists-validation.pipe'; +import { PatchPlaceReviewRequest } from './dto/request/patch-place-review-request.dto'; import { ReportPlaceReviewRequest } from './dto/request/report-place-review-request.dto'; import { DeleteReviewResponse } from './dto/response/delete-review-response.dto'; +import { PatchPlaceReviewResponse } from './dto/response/patch-place-review-response.dto'; import { ReportPlaceReviewResponse } from './dto/response/report-place-review-response.dto'; import { PlaceReviewConverter } from './place-review.converter'; import { PlaceReviewService } from './place-review.service'; @@ -57,4 +74,48 @@ export class PlaceReviewController { GlobalResponseCode.CREATED, ); } + + @Patch(':reviewId') + @UseInterceptors(FilesInterceptor('images', 3)) + @ApiConsumes('multipart/form-data') + @ApiExtraModels(PatchPlaceReviewRequest) + @ApiBody({ + schema: { + allOf: [ + { + type: 'object', + properties: { + images: { + description: '업로드 할 사진 (최대 3개)', + type: 'array', + items: { + type: 'string', + format: 'binary', + }, + maxItems: 3, + }, + }, + }, + { $ref: getSchemaPath(PatchPlaceReviewRequest) }, + ], + }, + }) + @ApiOperation({ summary: '리뷰 수정 API' }) + @ApiParam({ name: 'reviewId', description: '수정할 리뷰의 아이디', example: 1 }) + @ApiOkResponse({ type: PatchPlaceReviewResponse }) + async updatePlaceReview( + @ExtractPayload() userId: number, + @Param('reviewId', PlaceReviewExistsValidationPipe) reviewId: number, + @Body() request: PatchPlaceReviewRequest, + @UploadedFiles() newImages: Express.Multer.File[], + ): Promise> { + const updatedReview = await this.placeReviewService.updatePlaceReview( + request, + userId, + reviewId, + newImages, + ); + + return BaseResponse.of(PlaceReviewConverter.toPatchPlaceReviewResponse(updatedReview)); + } } diff --git a/src/place-review/place-review.converter.ts b/src/place-review/place-review.converter.ts index 0504675..82525e7 100644 --- a/src/place-review/place-review.converter.ts +++ b/src/place-review/place-review.converter.ts @@ -8,7 +8,9 @@ import { PlaceReviewReportReason } from 'src/place-review/enum/place-review-repo import { CreatePlaceReviewRequest } from 'src/place/dto/request/create-place-review-request.dto'; import { Place } from 'src/place/entity/place.entity'; import { User } from 'src/user/entity/user.entity'; +import { PatchPlaceReviewRequest } from './dto/request/patch-place-review-request.dto'; import { ReportPlaceReviewRequest } from './dto/request/report-place-review-request.dto'; +import { PatchPlaceReviewResponse } from './dto/response/patch-place-review-response.dto'; import { ReportPlaceReviewResponse } from './dto/response/report-place-review-response.dto'; import { PlaceReviewImage } from './entity/place-review-image.entity'; import { PlaceReview } from './entity/place-review.entity'; @@ -48,7 +50,24 @@ export class PlaceReviewConverter implements OnModuleInit { .build(); } - private static toPlaceReviewImage(imageUrl: string): PlaceReviewImage { + public static async toUpdatePlaceReview( + review: PlaceReview, + request: PatchPlaceReviewRequest, + activity: Activity, + ) { + review.visitedAt = request.visitedAt; + review.contents = request.contents; + review.rating = request.rating; + review.commentAllowed = request.commentAllowed; + if (activity) { + review.activity = activity; + } + review.images = undefined; + + return review; + } + + public static toPlaceReviewImage(imageUrl: string): PlaceReviewImage { return Builder().imageUrl(imageUrl).build(); } @@ -71,4 +90,11 @@ export class PlaceReviewConverter implements OnModuleInit { .reportedPlaceReviewId(contentsReport.placeReivew.reviewId) .build(); } + + public static toPatchPlaceReviewResponse(review: PlaceReview) { + return Builder(PatchPlaceReviewResponse) + .updatedReviewId(review.reviewId) + .updatedAt(review.updatedAt) + .build(); + } } diff --git a/src/place-review/place-review.service.ts b/src/place-review/place-review.service.ts index 54f2f77..8a7e18d 100644 --- a/src/place-review/place-review.service.ts +++ b/src/place-review/place-review.service.ts @@ -11,7 +11,9 @@ import { Place } from 'src/place/entity/place.entity'; import { UserReport } from 'src/user/entity/user-report.entity'; import { User } from 'src/user/entity/user.entity'; import { And, In, LessThan, Not, Repository } from 'typeorm'; +import { PatchPlaceReviewRequest } from './dto/request/patch-place-review-request.dto'; import { ReportPlaceReviewRequest } from './dto/request/report-place-review-request.dto'; +import { PlaceReviewImage } from './entity/place-review-image.entity'; import { PlaceReview } from './entity/place-review.entity'; import { PlaceReviewResponseCode } from './exception/place-review-response-code'; import { PlaceReviewConverter } from './place-review.converter'; @@ -27,6 +29,8 @@ export class PlaceReviewService { private readonly placeRepository: Repository, @InjectRepository(PlaceReview) private readonly placeReviewRepository: Repository, + @InjectRepository(PlaceReviewImage) + private readonly placeReviewImageRepository: Repository, @InjectRepository(Activity) private readonly activityRepository: Repository, @InjectRepository(UserReport) @@ -143,4 +147,74 @@ export class PlaceReviewService { return this.contentsReportRepository.save(contentsReport); } + + async updatePlaceReview( + request: PatchPlaceReviewRequest, + userId: number, + reviewId: number, + newImages: Express.Multer.File[], + ): Promise { + const review = await this.placeReviewRepository.findOne({ + where: { reviewId }, + relations: { author: true, place: true, images: true }, + }); + + // 작성자 본인 확인 + if (review.author.userId !== userId) { + throw BaseException.of(PlaceReviewResponseCode.NOT_OWNER); + } + + // 남긴 이미지 + 새 이미지 개수 확인 + if (request.remainImages.length + newImages.length > 3) { + throw BaseException.of(PlaceReviewResponseCode.IMAGES_NUM_EXCEEDED); + } + + // 남긴 이미지 존재 확인 + await Promise.all( + request.remainImages.map(async imageId => { + if (!(await this.placeReviewImageRepository.exist({ where: { id: imageId } }))) { + throw BaseException.of(PlaceReviewResponseCode.IMAGE_NOT_FOUND); + } + }), + ); + + let activity: Activity; + if (request.activityId) { + activity = await this.activityRepository.findOneBy({ + activityId: request.activityId, + place: { placeId: review.place.placeId }, + }); + + if (activity === null) { + throw BaseException.of(ActivityResponseCode.ACTIVITY_NOT_FOUND); + } + } + + // 기존 이미지 삭제 + await Promise.all( + review.images.map(async image => { + if (!request.remainImages.includes(image.id)) { + await this.s3Service.deleteFileByUrl(image.imageUrl); + await this.placeReviewImageRepository.remove(image); + } + }), + ); + + // 새 이미지 추가 + await Promise.all( + newImages.map(async image => { + const imageUrl = await this.s3Service.uploadPlaceReviewImageFile(image); + const newPlaceImage = PlaceReviewConverter.toPlaceReviewImage(imageUrl); + newPlaceImage.review = review; + + await this.placeReviewImageRepository.save(newPlaceImage); + }), + ); + + const updatedReview = await this.placeReviewRepository.save( + await PlaceReviewConverter.toUpdatePlaceReview(review, request, activity), + ); + + return updatedReview; + } } diff --git a/src/place/place.controller.ts b/src/place/place.controller.ts index bfbd6b1..a707f73 100644 --- a/src/place/place.controller.ts +++ b/src/place/place.controller.ts @@ -263,6 +263,7 @@ export class PlaceController { type: 'object', properties: { images: { + description: '업로드 할 이미지 (최대 3개)', type: 'array', items: { type: 'string', diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 931a8d3..4c7873d 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -28,6 +28,15 @@ export class UserService { return this.userRepository.exist({ where: { userId } }); } + async findUserById(userId: number): Promise { + const user = await this.userRepository.findOneBy({ userId }); + if (!user) { + throw BaseException.of(UserResponseCode.USER_NOT_FOUND); + } + + return user; + } + async create(request: SignUpRequest, profileImage: Express.Multer.File): Promise { const newUser = this.userRepository.save(await UserConverter.toUser(request, profileImage));