diff --git a/src/APIs/agreements/agreements.controller.ts b/src/APIs/agreements/agreements.controller.ts index 9f5dc43..856ff4b 100644 --- a/src/APIs/agreements/agreements.controller.ts +++ b/src/APIs/agreements/agreements.controller.ts @@ -10,20 +10,16 @@ import { UseGuards, } from '@nestjs/common'; import { AgreementsService } from './agreements.service'; -import { - ApiCookieAuth, - ApiCreatedResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { Request } from 'express'; import { AgreementGetContractRequestDto } from './dtos/request/agreement-get-contract-request.dto'; import { AgreementCreateRequestDto } from './dtos/request/agreement-create-request.dto'; import { AgreementDto } from './dtos/common/agreement.dto'; import { AgreementPatchRequestDto } from './dtos/request/agreement-patch-request.dto'; +import { AgreementsDocs } from './docs/agreements-docs.decorator'; +@AgreementsDocs @ApiTags('유저 API') @Controller('users') export class AgreementsController { @@ -36,9 +32,6 @@ export class AgreementsController { return data; } - @ApiOperation({ summary: '온보딩 동의' }) - @ApiCookieAuth() - @ApiCreatedResponse({ type: AgreementDto }) @UseGuards(AuthGuardV2) @Post('me/agreement') async agree( @@ -49,9 +42,6 @@ export class AgreementsController { return await this.svc_agreements.createAgreement({ ...body, userId }); } - @ApiOperation({ summary: '로그인된 유저의 온보딩 동의 내용들을 fetch' }) - @ApiCookieAuth() - @ApiOkResponse({ type: [AgreementDto] }) @UseGuards(AuthGuardV2) @Get('me/agreements') async fetchAgreements(@Req() req: Request): Promise { @@ -59,10 +49,6 @@ export class AgreementsController { return await this.svc_agreements.findAgreements({ userId }); } - @ApiTags('어드민 API') - @ApiOperation({ summary: '[어드민용] 특정 유저의 온보딩 동의 내용을 조회' }) - @ApiCookieAuth() - @ApiOkResponse({ type: [AgreementDto] }) @UseGuards(AuthGuardV2) @Get('admin/:userId/agreements') async fetchAgreementAdmin( @@ -76,9 +62,6 @@ export class AgreementsController { }); } - @ApiOperation({ summary: '동의 여부를 수정' }) - @ApiCookieAuth() - @ApiOkResponse({ type: AgreementDto }) @UseGuards(AuthGuardV2) @Patch('me/agreement/:agreementId') async patchAgreement( diff --git a/src/APIs/agreements/agreements.service.ts b/src/APIs/agreements/agreements.service.ts index c70ffd5..f273a93 100644 --- a/src/APIs/agreements/agreements.service.ts +++ b/src/APIs/agreements/agreements.service.ts @@ -1,8 +1,4 @@ -import { - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { AgreementsRepository } from './agreements.repository'; import { IAgreementsServiceCreate, @@ -15,6 +11,9 @@ import path from 'path'; import fs from 'fs'; import { UsersValidateService } from '../users/services/users-validate-service'; import { AgreementDto } from './dtos/common/agreement.dto'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; @Injectable() export class AgreementsService { @@ -23,6 +22,9 @@ export class AgreementsService { private readonly svc_usersValidate: UsersValidateService, ) {} + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'adminCheck' }, + ]) async adminCheck({ userId }: IAgreementsServiceUserId): Promise { await this.svc_usersValidate.adminCheck({ userId }); } @@ -53,6 +55,15 @@ export class AgreementsService { return await this.repo_agreements.findOne({ where: { id: agreementId } }); } + @ExceptionMetadata([EXCEPTIONS.AGREEMENT_NOT_FOUND]) + async existCheck({ + agreementId, + }: IAgreementsServiceId): Promise { + const data = await this.findAgreement({ agreementId }); + if (!data) throw new BlccuException('AGREEMENT_NOT_FOUND'); + return data; + } + async findAgreements({ userId, }: IAgreementsServiceUserId): Promise { @@ -61,14 +72,17 @@ export class AgreementsService { }); } + @MergeExceptionMetadata([ + { service: AgreementsService, methodName: 'existCheck' }, + ]) + @ExceptionMetadata([EXCEPTIONS.NOT_THE_OWNER]) async patchAgreement({ userId, agreementId, isAgreed, }: IAgreementsServicePatchAgreement): Promise { - const data = await this.findAgreement({ agreementId }); - if (!data) throw new NotFoundException('데이터를 찾을 수 없습니다.'); - if (data.userId != userId) throw new ForbiddenException('권한이 없습니다.'); + const data = await this.existCheck({ agreementId }); + if (data.userId != userId) throw new BlccuException('NOT_THE_OWNER'); // if(data.agreementType != AgreementType.MARKETING_CONSENT) data.isAgreed = isAgreed; return await this.repo_agreements.save(data); diff --git a/src/APIs/agreements/docs/agreements-docs.decorator.ts b/src/APIs/agreements/docs/agreements-docs.decorator.ts new file mode 100644 index 0000000..126009e --- /dev/null +++ b/src/APIs/agreements/docs/agreements-docs.decorator.ts @@ -0,0 +1,59 @@ +import { MethodNames } from '@/common/types/method'; +import { AgreementsController } from '../agreements.controller'; +import { applyDocs } from '@/utils/docs.utils'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { AgreementDto } from '../dtos/common/agreement.dto'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { AgreementsService } from '../agreements.service'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; + +type AgreementsEndpoints = MethodNames; + +const AgreementsDocsMap: Record = { + getContract: [ + ApiOperation({ summary: 'contract fetch' }), + ApiResponseFromMetadata([ + { service: AgreementsService, methodName: 'findContract' }, + ]), + ], + agree: [ + ApiOperation({ summary: '온보딩 동의' }), + ApiAuthResponse(), + ApiCreatedResponse({ type: AgreementDto }), + ApiResponseFromMetadata([ + { service: AgreementsService, methodName: 'createAgreement' }, + ]), + ], + fetchAgreements: [ + ApiOperation({ summary: '로그인된 유저의 온보딩 동의 내용들을 fetch' }), + ApiOkResponse({ type: [AgreementDto] }), + ApiAuthResponse(), + ApiResponseFromMetadata([ + { service: AgreementsService, methodName: 'findAgreements' }, + ]), + ], + fetchAgreementAdmin: [ + ApiTags('어드민 API'), + ApiOperation({ summary: '[어드민용] 특정 유저의 온보딩 동의 내용을 조회' }), + ApiAuthResponse(), + ApiOkResponse({ type: [AgreementDto] }), + ApiResponseFromMetadata([ + { service: AgreementsService, methodName: 'findAgreements' }, + ]), + ], + patchAgreement: [ + ApiOperation({ summary: '동의 여부를 수정' }), + ApiAuthResponse(), + ApiOkResponse({ type: AgreementDto }), + ApiResponseFromMetadata([ + { service: AgreementsService, methodName: 'patchAgreement' }, + ]), + ], +}; + +export const AgreementsDocs = applyDocs(AgreementsDocsMap); diff --git a/src/APIs/announcements/announcements.controller.ts b/src/APIs/announcements/announcements.controller.ts index c93f025..079c36d 100644 --- a/src/APIs/announcements/announcements.controller.ts +++ b/src/APIs/announcements/announcements.controller.ts @@ -3,7 +3,6 @@ import { Controller, Delete, Get, - HttpCode, Param, Patch, Post, @@ -11,83 +10,61 @@ import { UseGuards, } from '@nestjs/common'; import { AnnouncementsService } from './announcements.service'; -import { - ApiCookieAuth, - ApiCreatedResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { Request } from 'express'; import { AnnouncementDto } from './dtos/common/announcement.dto'; import { AnnouncementPatchRequestDto } from './dtos/request/announcement-patch-request.dto'; import { AnnouncementCreateRequestDto } from './dtos/request/announcement-create-request.dto'; +import { AnnouncementsDocs } from './docs/announcements-docs.decorator'; +@AnnouncementsDocs @ApiTags('공지 API') @Controller() export class AnnouncementsController { - constructor(private readonly announcementsService: AnnouncementsService) {} + constructor(private readonly svc_annoucements: AnnouncementsService) {} - @ApiTags('어드민 API') - @ApiOperation({ summary: '[어드민용] 공지사항 작성' }) - @ApiCookieAuth() @UseGuards(AuthGuardV2) - @ApiCreatedResponse({ type: AnnouncementDto }) @Post('users/admin/anmts') - @HttpCode(201) - async createAnmt( + async createAnnouncement( @Req() req: Request, @Body() body: AnnouncementCreateRequestDto, ): Promise { const userId = req.user.userId; - return await this.announcementsService.createAnnoucement({ + return await this.svc_annoucements.createAnnoucement({ ...body, userId, }); } - @ApiOperation({ summary: '공지사항 조회' }) - @ApiOkResponse({ type: [AnnouncementDto] }) @Get('anmts') - async fetchAnmts(): Promise { - return await this.announcementsService.getAnnouncements(); + async getAnnouncements(): Promise { + return await this.svc_annoucements.getAnnouncements(); } - @ApiTags('어드민 API') - @ApiOperation({ summary: '[어드민용] 공지사항 수정' }) - @ApiCookieAuth() - @ApiOkResponse({ type: AnnouncementDto }) @UseGuards(AuthGuardV2) @Patch('users/admin/anmts/:announcementId') - async patchAnmt( + async patchAnnouncement( @Req() req: Request, @Body() body: AnnouncementPatchRequestDto, @Param('announcementId') announcementId: number, ): Promise { const userId = req.user.userId; - return await this.announcementsService.patchAnnouncement({ + return await this.svc_annoucements.patchAnnouncement({ ...body, announcementId, userId, }); } - @ApiTags('어드민 API') - @ApiOperation({ - summary: '[어드민용] 공지사항 삭제', - description: 'id에 해당하는 공지사항 삭제, 삭제된 공지사항을 반환', - }) - @ApiCookieAuth() - @ApiOkResponse({ type: AnnouncementDto }) @UseGuards(AuthGuardV2) @Delete('users/admin/anmts/:announcementId') - async removeAnmt( + async removeAnnouncement( @Req() req: Request, @Param('announcementId') announcementId: number, ): Promise { const userId = req.user.userId; - return await this.announcementsService.removeAnnouncement({ + return await this.svc_annoucements.removeAnnouncement({ userId, announcementId, }); diff --git a/src/APIs/announcements/announcements.service.ts b/src/APIs/announcements/announcements.service.ts index 822446e..d927c63 100644 --- a/src/APIs/announcements/announcements.service.ts +++ b/src/APIs/announcements/announcements.service.ts @@ -1,14 +1,18 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Announcement } from './entities/announcement.entity'; import { Repository } from 'typeorm'; import { - IAnnouncementsSerciceCreateAnnouncement, - IAnnouncementsSercicePatchAnnouncement, - IAnnouncementsSerciceRemoveAnnouncement, + IAnnouncementsServiceCreateAnnouncement, + IAnnouncementsServiceId, + IAnnouncementsServicePatchAnnouncement, + IAnnouncementsServiceRemoveAnnouncement, } from './interfaces/announcements.service.interface'; import { UsersValidateService } from '../users/services/users-validate-service'; import { AnnouncementDto } from './dtos/common/announcement.dto'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; @Injectable() export class AnnouncementsService { @@ -18,11 +22,14 @@ export class AnnouncementsService { private readonly svc_usersValidate: UsersValidateService, ) {} + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'adminCheck' }, + ]) async createAnnoucement({ userId, title, content, - }: IAnnouncementsSerciceCreateAnnouncement): Promise { + }: IAnnouncementsServiceCreateAnnouncement): Promise { await this.svc_usersValidate.adminCheck({ userId }); return await this.repo_announcements.save({ title, content }); } @@ -31,32 +38,45 @@ export class AnnouncementsService { return await this.repo_announcements.find(); } + @ExceptionMetadata([EXCEPTIONS.ANNOUNCEMENT_NOT_FOUND]) + async existCheck({ + announcementId, + }: IAnnouncementsServiceId): Promise { + const data = await this.repo_announcements.findOne({ + where: { id: announcementId }, + }); + if (!data) throw new BlccuException('ANNOUNCEMENT_NOT_FOUND'); + return data; + } + + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'adminCheck' }, + { service: AnnouncementsService, methodName: 'existCheck' }, + ]) async patchAnnouncement({ userId, announcementId, title, content, - }: IAnnouncementsSercicePatchAnnouncement): Promise { + }: IAnnouncementsServicePatchAnnouncement): Promise { await this.svc_usersValidate.adminCheck({ userId }); - const anmt = await this.repo_announcements.findOne({ - where: { id: announcementId }, - }); - if (!anmt) throw new NotFoundException('공지를 찾을 수 없습니다.'); + const anmt = await this.existCheck({ announcementId }); if (title) anmt.title = title; if (content) anmt.content = content; await this.repo_announcements.save(anmt); return await this.repo_announcements.findOne({ where: { id: anmt.id } }); } + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'adminCheck' }, + { service: AnnouncementsService, methodName: 'existCheck' }, + ]) async removeAnnouncement({ userId, announcementId, - }: IAnnouncementsSerciceRemoveAnnouncement): Promise { + }: IAnnouncementsServiceRemoveAnnouncement): Promise { await this.svc_usersValidate.adminCheck({ userId }); - const anmt = await this.repo_announcements.findOne({ - where: { id: announcementId }, - }); - if (!anmt) throw new NotFoundException('공지를 찾을 수 없습니다.'); + const anmt = await this.existCheck({ announcementId }); return await this.repo_announcements.softRemove(anmt); } } diff --git a/src/APIs/announcements/docs/announcements-docs.decorator.ts b/src/APIs/announcements/docs/announcements-docs.decorator.ts new file mode 100644 index 0000000..93ff159 --- /dev/null +++ b/src/APIs/announcements/docs/announcements-docs.decorator.ts @@ -0,0 +1,60 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { AnnouncementsController } from '../announcements.controller'; +import { AnnouncementsService } from '../announcements.service'; +import { AnnouncementDto } from '../dtos/common/announcement.dto'; +import { HttpCode } from '@nestjs/common'; + +type AnnouncementsEndpoints = MethodNames; + +const AnnouncementsDocsMap: Record = + { + createAnnouncement: [ + HttpCode(201), + ApiTags('어드민 API'), + ApiOperation({ summary: '[어드민용] 공지사항 작성' }), + ApiAuthResponse(), + ApiCreatedResponse({ type: AnnouncementDto }), + ApiResponseFromMetadata([ + { service: AnnouncementsService, methodName: 'createAnnoucement' }, + ]), + ], + getAnnouncements: [ + ApiOperation({ summary: '공지사항 조회' }), + ApiOkResponse({ type: [AnnouncementDto] }), + ApiResponseFromMetadata([ + { service: AnnouncementsService, methodName: 'getAnnouncements' }, + ]), + ], + patchAnnouncement: [ + ApiTags('어드민 API'), + ApiOperation({ summary: '[어드민용] 공지사항 수정' }), + ApiAuthResponse(), + ApiOkResponse({ type: AnnouncementDto }), + ApiResponseFromMetadata([ + { service: AnnouncementsService, methodName: 'patchAnnouncement' }, + ]), + ], + removeAnnouncement: [ + ApiTags('어드민 API'), + ApiOperation({ + summary: '[어드민용] 공지사항 삭제', + description: 'id에 해당하는 공지사항 삭제, 삭제된 공지사항을 반환', + }), + ApiAuthResponse(), + ApiOkResponse({ type: AnnouncementDto }), + ApiResponseFromMetadata([ + { service: AnnouncementsService, methodName: 'removeAnnouncement' }, + ]), + ], + }; + +export const AnnouncementsDocs = applyDocs(AnnouncementsDocsMap); diff --git a/src/APIs/announcements/interfaces/announcements.service.interface.ts b/src/APIs/announcements/interfaces/announcements.service.interface.ts index aedbf0c..a2194e0 100644 --- a/src/APIs/announcements/interfaces/announcements.service.interface.ts +++ b/src/APIs/announcements/interfaces/announcements.service.interface.ts @@ -1,6 +1,6 @@ import { Announcement } from '../entities/announcement.entity'; -export interface IAnnouncementsSerciceCreateAnnouncement +export interface IAnnouncementsServiceCreateAnnouncement extends Omit< Announcement, 'id' | 'dateCreated' | 'dateUpdated' | 'dateDeleted' @@ -8,12 +8,15 @@ export interface IAnnouncementsSerciceCreateAnnouncement userId: number; } -export interface IAnnouncementsSerciceRemoveAnnouncement { +export interface IAnnouncementsServiceRemoveAnnouncement { userId: number; announcementId: number; } +export interface IAnnouncementsServiceId { + announcementId: number; +} -export interface IAnnouncementsSercicePatchAnnouncement { +export interface IAnnouncementsServicePatchAnnouncement { userId: number; announcementId: number; title?: string; diff --git a/src/APIs/articleBackgrounds/articleBackgrounds.controller.ts b/src/APIs/articleBackgrounds/articleBackgrounds.controller.ts index 508bd28..af423b7 100644 --- a/src/APIs/articleBackgrounds/articleBackgrounds.controller.ts +++ b/src/APIs/articleBackgrounds/articleBackgrounds.controller.ts @@ -11,41 +11,22 @@ import { UseInterceptors, } from '@nestjs/common'; import { ArticleBackgroundsService } from './articleBackgrounds.service'; -import { - ApiBody, - ApiConsumes, - ApiCookieAuth, - ApiCreatedResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; import { FileInterceptor } from '@nestjs/platform-express'; -import { ImageUploadRequestDto } from 'src/modules/images/dtos/image-upload-request.dto'; + import { ImageUploadResponseDto } from 'src/modules/images/dtos/image-upload-response.dto'; import { ArticleBackgroundDto } from './dtos/common/articleBackground.dto'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { Request } from 'express'; +import { ArticleBackgroundsDocs } from './docs/articleBackgrounds-docs.decorator'; -@Controller('') +@ArticleBackgroundsDocs +@Controller() export class ArticleBackgroundsController { constructor( private readonly articleBackgroundsService: ArticleBackgroundsService, ) {} - @ApiTags('어드민 API') - @ApiOperation({ summary: '내지 업로드' }) - @ApiConsumes('multipart/form-data') - @ApiBody({ - description: '업로드 할 파일', - type: ImageUploadRequestDto, - }) - @ApiCreatedResponse({ - description: '이미지 서버에 파일 업로드 완료', - type: ImageUploadResponseDto, - }) @UseGuards(AuthGuardV2) - @ApiCookieAuth() @Post('users/admin/article/background') @UseInterceptors(FileInterceptor('file')) @HttpCode(201) @@ -61,20 +42,11 @@ export class ArticleBackgroundsController { return url; } - @ApiTags('게시글 API') - @ApiOperation({ summary: '내지 모두 불러오기' }) - @ApiOkResponse({ - description: '모든 내지 fetch 완료', - type: [ArticleBackgroundDto], - }) @Get('articles/backgrounds') async getArticleBackgrounds(): Promise { return await this.articleBackgroundsService.findArticleBackgrounds(); } - @ApiCookieAuth() - @ApiTags('어드민 API') - @ApiOperation({ summary: '내지 삭제하기' }) @UseGuards(AuthGuardV2) @Delete('users/admin/articles/background/:articleBackgroundId') async delete( diff --git a/src/APIs/articleBackgrounds/articleBackgrounds.service.ts b/src/APIs/articleBackgrounds/articleBackgrounds.service.ts index 17866d3..96d81ec 100644 --- a/src/APIs/articleBackgrounds/articleBackgrounds.service.ts +++ b/src/APIs/articleBackgrounds/articleBackgrounds.service.ts @@ -6,6 +6,7 @@ import { ImagesService } from 'src/modules/images/images.service'; import { ArticleBackgroundDto } from './dtos/common/articleBackground.dto'; import { ImageUploadResponseDto } from 'src/modules/images/dtos/image-upload-response.dto'; import { UsersValidateService } from '../users/services/users-validate-service'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class ArticleBackgroundsService { @@ -16,6 +17,10 @@ export class ArticleBackgroundsService { private readonly repo_articleBackgrounds: Repository, ) {} + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'adminCheck' }, + { service: ImagesService, methodName: 'imageUpload' }, + ]) async createArticleBackground( userId: number, file: Express.Multer.File, @@ -34,6 +39,10 @@ export class ArticleBackgroundsService { return await this.repo_articleBackgrounds.find(); } + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'adminCheck' }, + { service: ImagesService, methodName: 'deleteImage' }, + ]) async deleteArticleBackground({ articleBackgroundId, userId }) { await this.svc_usersValidate.adminCheck({ userId }); diff --git a/src/APIs/articleBackgrounds/docs/articleBackgrounds-docs.decorator.ts b/src/APIs/articleBackgrounds/docs/articleBackgrounds-docs.decorator.ts new file mode 100644 index 0000000..002846c --- /dev/null +++ b/src/APIs/articleBackgrounds/docs/articleBackgrounds-docs.decorator.ts @@ -0,0 +1,72 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { + ApiBody, + ApiConsumes, + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { ArticleBackgroundsController } from '../articleBackgrounds.controller'; +import { ImageUploadRequestDto } from '@/modules/images/dtos/image-upload-request.dto'; +import { ImageUploadResponseDto } from '@/modules/images/dtos/image-upload-response.dto'; +import { ArticleBackgroundDto } from '../dtos/common/articleBackground.dto'; +import { ArticleBackgroundsService } from '../articleBackgrounds.service'; + +type ArticleBackgroundsEndpoints = MethodNames; + +const ArticleBackgroundsDocsMap: Record< + ArticleBackgroundsEndpoints, + MethodDecorator[] +> = { + createArticleBackground: [ + ApiTags('어드민 API'), + ApiOperation({ summary: '내지 업로드' }), + ApiConsumes('multipart/form-data'), + ApiBody({ + description: '업로드 할 파일', + type: ImageUploadRequestDto, + }), + ApiCreatedResponse({ + description: '이미지 서버에 파일 업로드 완료', + type: ImageUploadResponseDto, + }), + ApiAuthResponse(), + ApiResponseFromMetadata([ + { + service: ArticleBackgroundsService, + methodName: 'createArticleBackground', + }, + ]), + ], + getArticleBackgrounds: [ + ApiTags('게시글 API'), + ApiOperation({ summary: '내지 모두 불러오기' }), + ApiOkResponse({ + description: '모든 내지 fetch 완료', + type: [ArticleBackgroundDto], + }), + ApiResponseFromMetadata([ + { + service: ArticleBackgroundsService, + methodName: 'findArticleBackgrounds', + }, + ]), + ], + delete: [ + ApiAuthResponse(), + ApiTags('어드민 API'), + ApiOperation({ summary: '내지 삭제하기' }), + ApiResponseFromMetadata([ + { + service: ArticleBackgroundsService, + methodName: 'deleteArticleBackground', + }, + ]), + ], +}; + +export const ArticleBackgroundsDocs = applyDocs(ArticleBackgroundsDocsMap); diff --git a/src/APIs/articleCategories/articleCategories.controller.ts b/src/APIs/articleCategories/articleCategories.controller.ts index 9c368b2..e3cd8db 100644 --- a/src/APIs/articleCategories/articleCategories.controller.ts +++ b/src/APIs/articleCategories/articleCategories.controller.ts @@ -3,20 +3,13 @@ import { Controller, Delete, Get, - HttpCode, Param, Patch, Post, Req, UseGuards, } from '@nestjs/common'; -import { - ApiCookieAuth, - ApiCreatedResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { ArticleCategoriesService } from './articleCategories.service'; @@ -24,24 +17,17 @@ import { ArticleCategoriesResponseDto } from './dtos/response/articleCategories- import { ArticleCategoryDto } from './dtos/common/articleCategory.dto'; import { ArticleCategoryCreateRequestDto } from './dtos/request/articleCategory-create-request.dto'; import { ArticleCategoryPatchRequestDto } from './dtos/request/articleCategory-patch-request.dto'; +import { ArticleCategoriesDocs } from './docs/articleCategories-docs.decorator'; @ApiTags('유저 API') @Controller('users') +@ArticleCategoriesDocs export class ArticleCategoriesController { constructor( private readonly svc_articleCategories: ArticleCategoriesService, ) {} - @ApiOperation({ - summary: '특정 유저의 카테고리 전체 조회', - description: - '특정 유저가 생성한 카테고리의 이름과 id, 게시글 개수를 조회한다.', - }) - @ApiOkResponse({ - type: [ArticleCategoriesResponseDto], - }) @Get(':userId/categories') - @HttpCode(200) async fetchArticleCategories( @Req() req: Request, @Param('userId') targetUserId: number, @@ -53,11 +39,6 @@ export class ArticleCategoriesController { }); } - @ApiOperation({ - summary: '특정 카테고리 조회', - description: 'id에 해당하는 카테고리를 조회한다.', - }) - @ApiOkResponse({ type: ArticleCategoryDto }) @Get('categories/:articleCategoryId') async fetchMyCategory( @Req() req: Request, @@ -68,18 +49,8 @@ export class ArticleCategoriesController { }); } - @ApiOperation({ - summary: '게시글 카테고리 생성', - description: '로그인된 유저와 연결된 카테고리를 생성한다.', - }) - @ApiCookieAuth() - @ApiCreatedResponse({ - description: '카테고리 생성 완료', - type: ArticleCategoryDto, - }) @UseGuards(AuthGuardV2) @Post('me/categories') - @HttpCode(201) async createArticleCategory( @Req() req: Request, @Body() body: ArticleCategoryCreateRequestDto, @@ -92,9 +63,6 @@ export class ArticleCategoriesController { }); } - @ApiOperation({ summary: '로그인된 유저의 특정 카테고리 수정' }) - @ApiCookieAuth() - @ApiOkResponse({ type: ArticleCategoryDto }) @UseGuards(AuthGuardV2) @Patch('me/categories/:articleCategoryId') async patchArticleCategory( @@ -110,12 +78,6 @@ export class ArticleCategoriesController { }); } - @ApiOperation({ - summary: '유저의 지정 카테고리 삭제하기', - description: - '로그인된 유저의 카테고리 중 articleCategoryId 일치하는 카테고리를 삭제한다', - }) - @ApiCookieAuth() @Delete('me/categories/:articleCategoryId') @UseGuards(AuthGuardV2) async deleteArticleCategory( diff --git a/src/APIs/articleCategories/articleCategories.service.ts b/src/APIs/articleCategories/articleCategories.service.ts index 7f8be41..88c4985 100644 --- a/src/APIs/articleCategories/articleCategories.service.ts +++ b/src/APIs/articleCategories/articleCategories.service.ts @@ -1,13 +1,11 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { FollowsService } from '../follows/follows.service'; import { ArticleCategoriesRepository } from './articleCategories.repository'; import { ArticleCategoryDto } from './dtos/common/articleCategory.dto'; import { ArticleCategoriesResponseDto } from './dtos/response/articleCategories-response.dto'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class ArticleCategoriesService { @@ -15,6 +13,7 @@ export class ArticleCategoriesService { private readonly svc_follows: FollowsService, private readonly repo_articleCategories: ArticleCategoriesRepository, ) {} + async findArticleCategoryByName({ userId, name, @@ -24,10 +23,11 @@ export class ArticleCategoriesService { }); } + @ExceptionMetadata([EXCEPTIONS.CATEGORY_CONFLICT]) async createArticleCategory({ userId, name }): Promise { const articleData = await this.findArticleCategoryByName({ userId, name }); if (articleData) { - throw new BadRequestException('이미 동명의 카테고리가 존재합니다.'); + throw new BlccuException('CATEGORY_CONFLICT'); } const result = await this.repo_articleCategories.save({ userId, @@ -36,15 +36,24 @@ export class ArticleCategoriesService { return result; } + @MergeExceptionMetadata([ + { + service: ArticleCategoriesService, + methodName: 'findArticleCategoryByName', + }, + ]) + @ExceptionMetadata([ + EXCEPTIONS.ARTICLE_CATEGORY_NOT_FOUND, + EXCEPTIONS.NOT_THE_OWNER, + ]) async patchArticleCategory({ userId, articleCategoryId, name, }): Promise { const data = await this.findArticleCategoryById({ articleCategoryId }); - if (!data) throw new NotFoundException('카테고리를 찾을 수 없습니다.'); - if (data.userId != userId) - throw new ForbiddenException('카테고리를 수정할 권한이 없습니다.'); + if (!data) throw new BlccuException('ARTICLE_CATEGORY_NOT_FOUND'); + if (data.userId != userId) throw new BlccuException('NOT_THE_OWNER'); data.name = name; return await this.repo_articleCategories.save(data); } @@ -57,6 +66,7 @@ export class ArticleCategoriesService { }); } + @MergeExceptionMetadata([{ service: FollowsService, methodName: 'getScope' }]) async fetchAll({ userId, targetUserId, @@ -71,9 +81,16 @@ export class ArticleCategoriesService { }); } - deleteArticleCategory({ userId, articleCategoryId }) { - return this.repo_articleCategories.delete({ - id: articleCategoryId, + @ExceptionMetadata([ + EXCEPTIONS.ARTICLE_CATEGORY_NOT_FOUND, + EXCEPTIONS.NOT_THE_OWNER, + ]) + async deleteArticleCategory({ userId, articleCategoryId }) { + const data = await this.findArticleCategoryById({ articleCategoryId }); + if (!data) throw new BlccuException('ARTICLE_CATEGORY_NOT_FOUND'); + if (data.userId != userId) throw new BlccuException('NOT_THE_OWNER'); + return await this.repo_articleCategories.delete({ + id: data.id, user: { id: userId }, }); } diff --git a/src/APIs/articleCategories/docs/articleCategories-docs.decorator.ts b/src/APIs/articleCategories/docs/articleCategories-docs.decorator.ts new file mode 100644 index 0000000..c801e66 --- /dev/null +++ b/src/APIs/articleCategories/docs/articleCategories-docs.decorator.ts @@ -0,0 +1,88 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { HttpCode } from '@nestjs/common'; +import { ArticleCategoriesController } from '../articleCategories.controller'; +import { ArticleCategoriesResponseDto } from '../dtos/response/articleCategories-response.dto'; +import { ArticleCategoryDto } from '../dtos/common/articleCategory.dto'; +import { ArticleCategoriesService } from '../articleCategories.service'; + +type ArticleCategoriesEndpoints = MethodNames; + +const ArticleCategoriesDocsMap: Record< + ArticleCategoriesEndpoints, + MethodDecorator[] +> = { + fetchArticleCategories: [ + ApiOperation({ + summary: '특정 유저의 카테고리 전체 조회', + description: + '특정 유저가 생성한 카테고리의 이름과 id, 게시글 개수를 조회한다.', + }), + ApiOkResponse({ + type: [ArticleCategoriesResponseDto], + }), + HttpCode(200), + ], + fetchMyCategory: [ + ApiOperation({ + summary: '특정 카테고리 조회', + description: 'id에 해당하는 카테고리를 조회한다.', + }), + ApiOkResponse({ type: ArticleCategoryDto }), + ApiResponseFromMetadata([ + { + service: ArticleCategoriesService, + methodName: 'findArticleCategoryById', + }, + ]), + ], + createArticleCategory: [ + ApiOperation({ + summary: '게시글 카테고리 생성', + description: '로그인된 유저와 연결된 카테고리를 생성한다.', + }), + ApiAuthResponse(), + ApiCreatedResponse({ + description: '카테고리 생성 완료', + type: ArticleCategoryDto, + }), + HttpCode(201), + ApiResponseFromMetadata([ + { + service: ArticleCategoriesService, + methodName: 'createArticleCategory', + }, + ]), + ], + patchArticleCategory: [ + ApiOperation({ summary: '로그인된 유저의 특정 카테고리 수정' }), + ApiAuthResponse(), + ApiOkResponse({ type: ArticleCategoryDto }), + ApiResponseFromMetadata([ + { service: ArticleCategoriesService, methodName: 'patchArticleCategory' }, + ]), + ], + deleteArticleCategory: [ + ApiOperation({ + summary: '유저의 지정 카테고리 삭제하기', + description: + '로그인된 유저의 카테고리 중 articleCategoryId 일치하는 카테고리를 삭제한다', + }), + ApiAuthResponse(), + ApiResponseFromMetadata([ + { + service: ArticleCategoriesService, + methodName: 'deleteArticleCategory', + }, + ]), + ], +}; + +export const ArticleCategoriesDocs = applyDocs(ArticleCategoriesDocsMap); diff --git a/src/APIs/articles/controllers/articles-create.controller.ts b/src/APIs/articles/controllers/articles-create.controller.ts index f5aebf3..a1cd552 100644 --- a/src/APIs/articles/controllers/articles-create.controller.ts +++ b/src/APIs/articles/controllers/articles-create.controller.ts @@ -9,38 +9,22 @@ import { UseInterceptors, } from '@nestjs/common'; import { Request } from 'express'; -import { - ApiBody, - ApiConsumes, - ApiCookieAuth, - ApiCreatedResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { ArticlesCreateService } from '../services/articles-create.service'; -import { ArticleCreateResponseDto } from '../dtos/response/article-create-response.dto'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { ArticleCreateRequestDto } from '../dtos/request/article-create-request.dto'; import { ArticleCreateDraftRequestDto } from '../dtos/request/article-create-draft-request.dto'; import { FileInterceptor } from '@nestjs/platform-express'; -import { ImageUploadRequestDto } from 'src/modules/images/dtos/image-upload-request.dto'; import { ImageUploadResponseDto } from 'src/modules/images/dtos/image-upload-response.dto'; +import { ArticlesCreateDocs } from '../docs/articles-create-docs.decorator'; +@ArticlesCreateDocs @ApiTags('게시글 API') @Controller('articles') export class ArticlesCreateController { constructor(private readonly svc_articlesCreate: ArticlesCreateService) {} - @ApiOperation({ - summary: '게시글 등록', - description: '게시글을 등록한다.', - }) @Post() - @ApiCookieAuth() - @ApiCreatedResponse({ - description: '등록 성공', - type: ArticleCreateResponseDto, - }) @UseGuards(AuthGuardV2) @HttpCode(201) async publishArticle( @@ -52,18 +36,8 @@ export class ArticlesCreateController { return await this.svc_articlesCreate.save(dto); } - @ApiOperation({ - summary: '게시글 임시등록', - description: '게시글을 임시등록한다.', - }) @Post('temp') - @ApiCookieAuth() - @ApiCreatedResponse({ - description: '임시등록 성공', - type: ArticleCreateResponseDto, - }) @UseGuards(AuthGuardV2) - @HttpCode(201) async createDraft( @Req() req: Request, @Body() body: ArticleCreateDraftRequestDto, @@ -73,26 +47,10 @@ export class ArticlesCreateController { return await this.svc_articlesCreate.createDraft(dto); } - @ApiOperation({ - summary: '이미지 업로드', - description: - '이미지를 서버에 업로드한다. url을 반환 받는다. 게시글 내부 이미지 업로드 및 캡처 이미지 업로드용. max_width=1280px', - }) - @ApiConsumes('multipart/form-data') - @ApiBody({ - description: '업로드 할 파일', - type: ImageUploadRequestDto, - }) - @ApiCreatedResponse({ - description: '이미지 서버에 파일 업로드 완료', - type: ImageUploadResponseDto, - }) @UseGuards(AuthGuardV2) - @ApiCookieAuth() @Post('image') @UseInterceptors(FileInterceptor('file')) - @HttpCode(201) - async createPrivateSticker( + async uploadImage( @Req() req: Request, @UploadedFile() file: Express.Multer.File, ): Promise { diff --git a/src/APIs/articles/controllers/articles-delete.controller.ts b/src/APIs/articles/controllers/articles-delete.controller.ts index 9fcfe5b..ac2d536 100644 --- a/src/APIs/articles/controllers/articles-delete.controller.ts +++ b/src/APIs/articles/controllers/articles-delete.controller.ts @@ -11,18 +11,14 @@ import { ArticlesDeleteService } from '../services/articles-delete.service'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { Request } from 'express'; import { ArticleDeleteRequestDto } from '../dtos/request/article-delete-request.dto'; +import { ArticlesDeleteDocs } from '../docs/articles-delete-docs.decorator'; +@ArticlesDeleteDocs @ApiTags('게시글 API') @Controller('articles') export class ArticlesDeleteController { constructor(private readonly svc_articlesDelete: ArticlesDeleteService) {} - @ApiOperation({ - summary: '게시글 삭제', - description: - '로그인 된 유저의 postId에 해당하는 게시글을 삭제한다. isHardDelete(nullable)을 통해 삭제 방식 결정', - }) - @ApiCookieAuth() @UseGuards(AuthGuardV2) @Delete(':articleId') async softDelete( diff --git a/src/APIs/articles/controllers/articles-read.controller.ts b/src/APIs/articles/controllers/articles-read.controller.ts index 364bd1e..e4c9d70 100644 --- a/src/APIs/articles/controllers/articles-read.controller.ts +++ b/src/APIs/articles/controllers/articles-read.controller.ts @@ -1,45 +1,27 @@ -import { - Controller, - Get, - HttpCode, - Param, - Query, - Req, - UseGuards, -} from '@nestjs/common'; -import { - ApiCookieAuth, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { ArticlesReadService } from '../services/articles-read.service'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { Request } from 'express'; import { ArticleDto } from '../dtos/common/article.dto'; import { ArticleDetailResponseDto } from '../dtos/response/article-detail-response.dto'; import { ArticleDetailForUpdateResponseDto } from '../dtos/response/article-detail-for-update-response.dto'; -import { ArticlesGetResponseDto } from '../dtos/response/articles-get-response.dto'; import { ArticlesGetRequestDto } from '../dtos/request/articles-get-request.dto'; import { ArticlesPaginateService } from '../services/articles-paginate.service'; import { SortOption } from 'src/common/enums/sort-option'; import { CustomCursorPageDto } from 'src/utils/cursor-pages/dtos/cursor-page.dto'; import { ArticlesGetUserRequestDto } from '../dtos/request/articles-get-user-request.dto'; +import { ArticlesReadDocs } from '../docs/articles-read-docs.decorator'; @ApiTags('게시글 API') @Controller('articles') +@ArticlesReadDocs export class ArticlesReadController { constructor( private readonly svc_articlesRead: ArticlesReadService, private readonly svc_articlesPaginate: ArticlesPaginateService, ) {} - @ApiOperation({ - summary: '임시작성 게시글 조회', - description: '로그인된 유저의 임시작성 게시글을 조회한다.', - }) - @ApiCookieAuth() - @ApiOkResponse({ type: [ArticleDetailResponseDto] }) @UseGuards(AuthGuardV2) @Get('temp') async fetchTempArticles( @@ -49,13 +31,7 @@ export class ArticlesReadController { return await this.svc_articlesRead.readTempArticles({ userId }); } - @ApiOperation({ - summary: '게시글 디테일 뷰 fetch', - description: - 'id에 해당하는 게시글을 가져온다. 조회수를 올린다. 보호된 게시글은 권한이 있는 사용자만 접근 가능하다.', - }) @Get('detail/:articleId') - @ApiOkResponse({ type: ArticleDetailResponseDto }) async fetchArticleDetail( @Param('articleId') articleId: number, @Req() req: Request, @@ -64,15 +40,7 @@ export class ArticlesReadController { return await this.svc_articlesRead.readArticleDetail({ userId, articleId }); } - @ApiOperation({ - summary: '[수정용] 게시글 및 스티커 상세 데이터 fetch', - description: - '본인 게시글 수정용으로 id에 해당하는 게시글에 조인된 스티커 블록들의 값과 게시글 세부 데이터를 모두 가져온다.', - }) - @ApiCookieAuth() - @ApiOkResponse({ type: ArticleDetailForUpdateResponseDto }) @UseGuards(AuthGuardV2) - @HttpCode(200) @Get('update/:articleId') async fetchArticle( @Req() req: Request, @@ -85,13 +53,7 @@ export class ArticlesReadController { }); } - @ApiOperation({ - summary: '[cursor]전체 게시글 조회 API', - description: - '커서 기반으로 게시글을 조회한다. 최초 조회 시 커서 값을 비워서 요청한다. 쿼리 옵션을 변경할 경우 기존의 커서 값을 쓸 수 없다. PUBLIC 게시글만 조회한다.', - }) @Get('cursor') - @ApiOkResponse({ type: ArticlesGetResponseDto }) async fetchCursor( @Query() cursorOption: ArticlesGetRequestDto, ): Promise> { @@ -111,15 +73,8 @@ export class ArticlesReadController { return this.svc_articlesPaginate.fetchArticlesCursor({ cursorOption }); } - @ApiOperation({ - summary: '[cursor]친구 게시글 조회 API', - description: - '커서 기반으로 게시글을 조회한다. 최초 조회 시 커서 값을 비워서 요청한다. 쿼리 옵션을 변경할 경우 기존의 커서 값을 쓸 수 없다.', - }) - @ApiCookieAuth() @UseGuards(AuthGuardV2) @Get('cursor/friends') - @ApiOkResponse({ type: ArticlesGetResponseDto }) async fetchFriendsCursor( @Query() cursorOption: ArticlesGetRequestDto, @Req() req: Request, @@ -144,13 +99,7 @@ export class ArticlesReadController { }); } - @ApiOperation({ - summary: '[cursor]특정 유저의 게시글 조회', - description: - '로그인 된 유저의 경우 private/protected 게시글 조회 권한 체크 후 조회. 카테고리 이름으로 필터링 가능', - }) @Get('/cursor/user/:userId') - @ApiOkResponse({ type: ArticlesGetResponseDto }) async fetchUserArticles( @Param('userId') targetUserId: number, @Req() req: Request, diff --git a/src/APIs/articles/controllers/articles-update.controller.ts b/src/APIs/articles/controllers/articles-update.controller.ts index 64503a3..b1afc57 100644 --- a/src/APIs/articles/controllers/articles-update.controller.ts +++ b/src/APIs/articles/controllers/articles-update.controller.ts @@ -1,35 +1,20 @@ -import { - Body, - Controller, - HttpCode, - Param, - Patch, - Req, - UseGuards, -} from '@nestjs/common'; -import { - ApiCookieAuth, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { Body, Controller, Param, Patch, Req, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { ArticlesUpdateService } from '../services/articles-update.service'; import { ArticleDto } from '../dtos/common/article.dto'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { ArticlePatchRequestDto } from '../dtos/request/article-patch-request.dto'; import { Request } from 'express'; +import { ArticlesUpdateDocs } from '../docs/articles-update-doc.decorator'; +@ArticlesUpdateDocs @ApiTags('게시글 API') @Controller('articles') export class ArticlesUpdateController { constructor(private readonly svc_articlesUpdate: ArticlesUpdateService) {} - @ApiOperation({ summary: '게시글 patch' }) - @ApiCookieAuth() - @ApiOkResponse({ type: ArticleDto }) @UseGuards(AuthGuardV2) @Patch(':articleId') - @HttpCode(200) async patchArticle( @Req() req: Request, @Body() body: ArticlePatchRequestDto, diff --git a/src/APIs/articles/docs/articles-create-docs.decorator.ts b/src/APIs/articles/docs/articles-create-docs.decorator.ts new file mode 100644 index 0000000..26ae9bb --- /dev/null +++ b/src/APIs/articles/docs/articles-create-docs.decorator.ts @@ -0,0 +1,76 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { + ApiBody, + ApiConsumes, + ApiCreatedResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ArticlesCreateController } from '../controllers/articles-create.controller'; +import { ArticleCreateResponseDto } from '../dtos/response/article-create-response.dto'; +import { HttpCode } from '@nestjs/common'; +import { ArticlesCreateService } from '../services/articles-create.service'; +import { ImageUploadRequestDto } from '@/modules/images/dtos/image-upload-request.dto'; +import { ImageUploadResponseDto } from '@/modules/images/dtos/image-upload-response.dto'; + +type ArticlesCreateEndpoints = MethodNames; + +const ArticlesCreateDocsMap: Record< + ArticlesCreateEndpoints, + MethodDecorator[] +> = { + publishArticle: [ + ApiOperation({ + summary: '게시글 등록', + description: '게시글을 등록한다.', + }), + ApiAuthResponse(), + ApiCreatedResponse({ + description: '등록 성공', + type: ArticleCreateResponseDto, + }), + HttpCode(201), + ApiResponseFromMetadata([ + { service: ArticlesCreateService, methodName: 'save' }, + ]), + ], + createDraft: [ + ApiOperation({ + summary: '게시글 임시등록', + description: '게시글을 임시등록한다.', + }), + ApiAuthResponse(), + ApiCreatedResponse({ + description: '임시등록 성공', + type: ArticleCreateResponseDto, + }), + HttpCode(201), + ApiResponseFromMetadata([ + { service: ArticlesCreateService, methodName: 'createDraft' }, + ]), + ], + uploadImage: [ + ApiOperation({ + summary: '이미지 업로드', + description: + '이미지를 서버에 업로드한다. url을 반환 받는다. 게시글 내부 이미지 업로드 및 캡처 이미지 업로드용. max_width=1280px', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + description: '업로드 할 파일', + type: ImageUploadRequestDto, + }), + ApiCreatedResponse({ + description: '이미지 서버에 파일 업로드 완료', + type: ImageUploadResponseDto, + }), + HttpCode(201), + ApiResponseFromMetadata([ + { service: ArticlesCreateService, methodName: 'imageUpload' }, + ]), + ], +}; + +export const ArticlesCreateDocs = applyDocs(ArticlesCreateDocsMap); diff --git a/src/APIs/articles/docs/articles-delete-docs.decorator.ts b/src/APIs/articles/docs/articles-delete-docs.decorator.ts new file mode 100644 index 0000000..42fb10e --- /dev/null +++ b/src/APIs/articles/docs/articles-delete-docs.decorator.ts @@ -0,0 +1,29 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { ApiOperation } from '@nestjs/swagger'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ArticlesDeleteController } from '../controllers/articles-delete.controller'; +import { ArticlesDeleteService } from '../services/articles-delete.service'; + +type ArticlesDeleteEndpoints = MethodNames; + +const ArticlesDeleteDocsMap: Record< + ArticlesDeleteEndpoints, + MethodDecorator[] +> = { + softDelete: [ + ApiOperation({ + summary: '게시글 삭제', + description: + '로그인 된 유저의 postId에 해당하는 게시글을 삭제한다. isHardDelete(nullable)을 통해 삭제 방식 결정', + }), + ApiAuthResponse(), + ApiResponseFromMetadata([ + { service: ArticlesDeleteService, methodName: 'softDelete' }, + { service: ArticlesDeleteService, methodName: 'hardDelete' }, + ]), + ], +}; + +export const ArticlesDeleteDocs = applyDocs(ArticlesDeleteDocsMap); diff --git a/src/APIs/articles/docs/articles-read-docs.decorator.ts b/src/APIs/articles/docs/articles-read-docs.decorator.ts new file mode 100644 index 0000000..9040cc6 --- /dev/null +++ b/src/APIs/articles/docs/articles-read-docs.decorator.ts @@ -0,0 +1,95 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { ArticlesReadController } from '../controllers/articles-read.controller'; +import { ArticleDetailResponseDto } from '../dtos/response/article-detail-response.dto'; +import { ArticleDetailForUpdateResponseDto } from '../dtos/response/article-detail-for-update-response.dto'; +import { ArticlesGetResponseDto } from '../dtos/response/articles-get-response.dto'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ArticlesReadService } from '../services/articles-read.service'; +import { ArticlesPaginateService } from '../services/articles-paginate.service'; + +type ArticlesReadEndpoints = MethodNames; + +const ArticlesReadDocsMap: Record = { + fetchTempArticles: [ + ApiOperation({ + summary: '임시작성 게시글 조회', + description: '로그인된 유저의 임시작성 게시글을 조회한다.', + }), + ApiAuthResponse(), + ApiOkResponse({ type: [ArticleDetailResponseDto] }), + ApiResponseFromMetadata([ + { service: ArticlesReadService, methodName: 'readTempArticles' }, + ]), + ], + fetchArticleDetail: [ + ApiOperation({ + summary: '게시글 디테일 뷰 fetch', + description: + 'id에 해당하는 게시글을 가져온다. 조회수를 올린다. 보호된 게시글은 권한이 있는 사용자만 접근 가능하다.', + }), + ApiOkResponse({ type: ArticleDetailResponseDto }), + ApiResponseFromMetadata([ + { service: ArticlesReadService, methodName: 'readArticleDetail' }, + ]), + ], + fetchArticle: [ + ApiOperation({ + summary: '[수정용] 게시글 및 스티커 상세 데이터 fetch', + description: + '본인 게시글 수정용으로 id에 해당하는 게시글에 조인된 스티커 블록들의 값과 게시글 세부 데이터를 모두 가져온다.', + }), + ApiAuthResponse(), + ApiOkResponse({ type: ArticleDetailForUpdateResponseDto }), + ApiResponseFromMetadata([ + { service: ArticlesReadService, methodName: 'readArticleUpdateDetail' }, + ]), + ], + fetchCursor: [ + ApiOperation({ + summary: '[cursor]전체 게시글 조회 API', + description: + '커서 기반으로 게시글을 조회한다. 최초 조회 시 커서 값을 비워서 요청한다. 쿼리 옵션을 변경할 경우 기존의 커서 값을 쓸 수 없다. PUBLIC 게시글만 조회한다.', + }), + ApiOkResponse({ type: ArticlesGetResponseDto }), + ApiResponseFromMetadata([ + { service: ArticlesPaginateService, methodName: 'createDefaultCursor' }, + { service: ArticlesPaginateService, methodName: 'createDefaultCursor' }, + ]), + ], + fetchFriendsCursor: [ + ApiOperation({ + summary: '[cursor]친구 게시글 조회 API', + description: + '커서 기반으로 게시글을 조회한다. 최초 조회 시 커서 값을 비워서 요청한다. 쿼리 옵션을 변경할 경우 기존의 커서 값을 쓸 수 없다.', + }), + ApiAuthResponse(), + ApiOkResponse({ type: ArticlesGetResponseDto }), + ApiResponseFromMetadata([ + { service: ArticlesPaginateService, methodName: 'createDefaultCursor' }, + { + service: ArticlesPaginateService, + methodName: 'fetchFriendsArticlesCursor', + }, + ]), + ], + fetchUserArticles: [ + ApiOperation({ + summary: '[cursor]특정 유저의 게시글 조회', + description: + '로그인 된 유저의 경우 private/protected 게시글 조회 권한 체크 후 조회. 카테고리 이름으로 필터링 가능', + }), + ApiOkResponse({ type: ArticlesGetResponseDto }), + ApiResponseFromMetadata([ + { service: ArticlesPaginateService, methodName: 'createDefaultCursor' }, + { + service: ArticlesPaginateService, + methodName: 'fetchUserArticlesCursor', + }, + ]), + ], +}; + +export const ArticlesReadDocs = applyDocs(ArticlesReadDocsMap); diff --git a/src/APIs/articles/docs/articles-update-doc.decorator.ts b/src/APIs/articles/docs/articles-update-doc.decorator.ts new file mode 100644 index 0000000..561f6ec --- /dev/null +++ b/src/APIs/articles/docs/articles-update-doc.decorator.ts @@ -0,0 +1,28 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { ArticlesUpdateController } from '../controllers/articles-update.controller'; +import { HttpCode } from '@nestjs/common'; +import { ArticleDto } from '../dtos/common/article.dto'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ArticlesUpdateService } from '../services/articles-update.service'; + +type ArticlesUpdateEndpoints = MethodNames; + +const ArticlesUpdateDocsMap: Record< + ArticlesUpdateEndpoints, + MethodDecorator[] +> = { + patchArticle: [ + ApiOperation({ summary: '게시글 patch' }), + ApiAuthResponse(), + HttpCode(200), + ApiOkResponse({ type: ArticleDto }), + ApiResponseFromMetadata([ + { service: ArticlesUpdateService, methodName: 'patchArticle' }, + ]), + ], +}; + +export const ArticlesUpdateDocs = applyDocs(ArticlesUpdateDocsMap); diff --git a/src/APIs/articles/entities/article.entity.ts b/src/APIs/articles/entities/article.entity.ts index 3dbd088..8a6642d 100644 --- a/src/APIs/articles/entities/article.entity.ts +++ b/src/APIs/articles/entities/article.entity.ts @@ -62,9 +62,9 @@ export class Article extends IndexedCommonEntity { type: Number, nullable: true, }) - @Column({ name: 'currrent_image_id', nullable: true }) + @Column({ name: 'current_image_id', nullable: true }) @IsNumber() - currrentImageId: number; + currentImageId: number; @ApiProperty({ description: '제목(최대 100자)', type: String, default: '' }) @Column({ length: 100, default: '' }) diff --git a/src/APIs/articles/repositories/articles-paginate.repository.ts b/src/APIs/articles/repositories/articles-paginate.repository.ts index 0bec2e1..048639c 100644 --- a/src/APIs/articles/repositories/articles-paginate.repository.ts +++ b/src/APIs/articles/repositories/articles-paginate.repository.ts @@ -11,7 +11,7 @@ import { } from '../interfaces/articles.repository.interface'; import { Follow } from 'src/APIs/follows/entities/follow.entity'; import { Injectable } from '@nestjs/common'; -import { transformKeysToArgsFormat } from 'src/utils/classUtils'; +import { transformKeysToArgsFormat } from 'src/utils/class.utils'; import { USER_PRIMARY_RESPONSE_DTO_KEYS } from 'src/APIs/users/dtos/response/user-primary-response.dto'; @Injectable() diff --git a/src/APIs/articles/repositories/articles-read.repository.ts b/src/APIs/articles/repositories/articles-read.repository.ts index 7eb06d7..c8595b3 100644 --- a/src/APIs/articles/repositories/articles-read.repository.ts +++ b/src/APIs/articles/repositories/articles-read.repository.ts @@ -2,7 +2,7 @@ import { DataSource, Repository } from 'typeorm'; import { Article } from '../entities/article.entity'; import { Injectable } from '@nestjs/common'; import { ArticleDetailResponseDto } from '../dtos/response/article-detail-response.dto'; -import { transformKeysToArgsFormat } from 'src/utils/classUtils'; +import { transformKeysToArgsFormat } from 'src/utils/class.utils'; import { USER_PRIMARY_RESPONSE_DTO_KEYS } from 'src/APIs/users/dtos/response/user-primary-response.dto'; @Injectable() diff --git a/src/APIs/articles/services/articles-create.service.ts b/src/APIs/articles/services/articles-create.service.ts index 1dd2d5f..19cfe29 100644 --- a/src/APIs/articles/services/articles-create.service.ts +++ b/src/APIs/articles/services/articles-create.service.ts @@ -11,6 +11,8 @@ import { ArticlesReadRepository } from '../repositories/articles-read.repository import { ArticleCreateResponseDto } from '../dtos/response/article-create-response.dto'; import { ImagesService } from 'src/modules/images/images.service'; import { ImageUploadResponseDto } from 'src/modules/images/dtos/image-upload-response.dto'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; +import { ArticleDto } from '../dtos/common/article.dto'; @Injectable() export class ArticlesCreateService { @@ -22,13 +24,17 @@ export class ArticlesCreateService { private readonly repo_articlesRead: ArticlesReadRepository, ) {} + @MergeExceptionMetadata([ + { service: ArticlesValidateService, methodName: 'fkValidCheck' }, + { service: StickerBlocksService, methodName: 'createStickerBlocks' }, + ]) async save( createArticleDto: IArticlesServiceCreate, ): Promise { const queryRunner = this.db_dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); - const article = {}; + const article: Partial = {}; try { Object.keys(createArticleDto).map((el) => { const value = createArticleDto[el]; @@ -40,13 +46,13 @@ export class ArticlesCreateService { articles: article, passNonEssentail: !createArticleDto.isPublished, }); + const queryResult = await queryRunner.manager .createQueryBuilder() .insert() .into(Article, Object.keys(article)) .values(article) .execute(); - await queryRunner.commitTransaction(); const articleData = await this.repo_articlesRead.findOne({ where: { id: queryResult.identifiers[0].id }, }); @@ -56,6 +62,8 @@ export class ArticlesCreateService { stickerBlocks: createArticleDto.stickerBlocks, }, ); + await queryRunner.commitTransaction(); + return { articleData, stickerBlockData }; } catch (e) { await queryRunner.rollbackTransaction(); @@ -65,6 +73,10 @@ export class ArticlesCreateService { } } + @MergeExceptionMetadata([ + { service: ArticlesValidateService, methodName: 'fkValidCheck' }, + { service: StickerBlocksService, methodName: 'createStickerBlocks' }, + ]) async createDraft( dto_createDraft: IArticlesServiceCreateDraft, ): Promise { @@ -72,7 +84,7 @@ export class ArticlesCreateService { await queryRunner.connect(); await queryRunner.startTransaction(); - const article = {}; + const article: Partial = {}; try { Object.keys(dto_createDraft).map((el) => { const value = dto_createDraft[el]; @@ -90,8 +102,8 @@ export class ArticlesCreateService { .into(Article, Object.keys(article)) .values(article) .execute(); - await queryRunner.commitTransaction(); - const articleData = await this.repo_articlesRead.findOne({ + + const articleData = await queryRunner.manager.findOne(Article, { where: { id: queryResult.identifiers[0].id }, }); const stickerBlockData = await this.svc_stickerBlocks.createStickerBlocks( @@ -100,6 +112,8 @@ export class ArticlesCreateService { stickerBlocks: dto_createDraft.stickerBlocks, }, ); + await queryRunner.commitTransaction(); + return { articleData, stickerBlockData }; } catch (e) { await queryRunner.rollbackTransaction(); @@ -109,6 +123,9 @@ export class ArticlesCreateService { } } + @MergeExceptionMetadata([ + { service: ImagesService, methodName: 'imageUpload' }, + ]) async imageUpload( file: Express.Multer.File, ): Promise { diff --git a/src/APIs/articles/services/articles-delete.service.ts b/src/APIs/articles/services/articles-delete.service.ts index 2373fe6..0e134a0 100644 --- a/src/APIs/articles/services/articles-delete.service.ts +++ b/src/APIs/articles/services/articles-delete.service.ts @@ -5,6 +5,7 @@ import { StickerBlocksService } from 'src/APIs/stickerBlocks/stickerBlocks.servi import { IArticlesServiceArticleUserIdPair } from '../interfaces/articles.service.interface'; import { ArticlesReadRepository } from '../repositories/articles-read.repository'; import { ImagesService } from 'src/modules/images/images.service'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class ArticlesDeleteService { @@ -17,6 +18,10 @@ export class ArticlesDeleteService { private readonly repo_articlesDelete: ArticlesDeleteRepository, ) {} + @MergeExceptionMetadata([ + { service: ImagesService, methodName: 'deleteImage' }, + { service: StickerBlocksService, methodName: 'deleteStickerBlocks' }, + ]) async softDelete({ userId, articleId }: IArticlesServiceArticleUserIdPair) { const data = await this.repo_articlesRead.findOne({ where: { user: { id: userId }, id: articleId }, @@ -32,6 +37,10 @@ export class ArticlesDeleteService { }); } + @MergeExceptionMetadata([ + { service: ImagesService, methodName: 'deleteImage' }, + { service: StickerBlocksService, methodName: 'deleteStickerBlocks' }, + ]) async hardDelete({ userId, articleId }: IArticlesServiceArticleUserIdPair) { const data = await this.repo_articlesRead.findOne({ where: { userId, id: articleId }, diff --git a/src/APIs/articles/services/articles-paginate.service.ts b/src/APIs/articles/services/articles-paginate.service.ts index 8d6836f..1072c61 100644 --- a/src/APIs/articles/services/articles-paginate.service.ts +++ b/src/APIs/articles/services/articles-paginate.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { ArticlesValidateService } from './articles-validate.service'; import { ArticlesPaginateRepository } from '../repositories/articles-paginate.repository'; import { ArticleOrderOption } from 'src/common/enums/article-order-option'; @@ -14,7 +14,10 @@ import { IArticlesServiceFetchUserArticlesCursor, } from '../interfaces/articles.service.interface'; import { ArticleDto } from '../dtos/common/article.dto'; -import { getDate } from 'src/utils/dateUtils'; +import { getDate } from '@/utils/date.utils'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; @Injectable() export class ArticlesPaginateService { @@ -46,6 +49,9 @@ export class ArticlesPaginateService { return customCursor; } + @MergeExceptionMetadata([ + { service: ArticlesPaginateService, methodName: 'createCustomCursor' }, + ]) async createCursorResponse({ cursorOption, articles, @@ -80,6 +86,9 @@ export class ArticlesPaginateService { return new CustomCursorPageDto(responseData, customCursorPageMetaDto); } + @MergeExceptionMetadata([ + { service: ArticlesPaginateService, methodName: 'createCursorResponse' }, + ]) async fetchArticlesCursor({ cursorOption, }: IArticlesServiceFetchArticlesCursor): Promise< @@ -105,6 +114,10 @@ export class ArticlesPaginateService { return result; } + @MergeExceptionMetadata([ + { service: ArticlesPaginateService, methodName: 'createCursorResponse' }, + ]) + @ExceptionMetadata([EXCEPTIONS.NOT_LOGGED_IN]) async fetchFriendsArticlesCursor({ cursorOption, userId, @@ -112,7 +125,7 @@ export class ArticlesPaginateService { CustomCursorPageDto > { if (!userId) { - throw new BadRequestException('비로그인 상태입니다.'); + throw new BlccuException('NOT_LOGGED_IN'); } let dateFilter: Date; if (cursorOption.dateCreated) @@ -127,6 +140,10 @@ export class ArticlesPaginateService { return await this.createCursorResponse({ articles, cursorOption }); } + @MergeExceptionMetadata([ + { service: FollowsService, methodName: 'getScope' }, + { service: ArticlesPaginateService, methodName: 'createCursorResponse' }, + ]) async fetchUserArticlesCursor({ userId, targetUserId, diff --git a/src/APIs/articles/services/articles-read.service.ts b/src/APIs/articles/services/articles-read.service.ts index ffa25f9..3d96e90 100644 --- a/src/APIs/articles/services/articles-read.service.ts +++ b/src/APIs/articles/services/articles-read.service.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { ArticlesValidateService } from './articles-validate.service'; import { ArticlesReadRepository } from '../repositories/articles-read.repository'; @@ -10,6 +10,9 @@ import { } from '../interfaces/articles.service.interface'; import { ArticleDetailForUpdateResponseDto } from '../dtos/response/article-detail-for-update-response.dto'; import { ArticleDetailResponseDto } from '../dtos/response/article-detail-response.dto'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class ArticlesReadService { @@ -21,10 +24,20 @@ export class ArticlesReadService { private readonly repo_articlesRead: ArticlesReadRepository, ) {} + @MergeExceptionMetadata([ + { service: ArticlesValidateService, methodName: 'existCheck' }, + ]) async findArticlesById({ articleId }: IArticlesServiceArticleId) { - return await this.repo_articlesRead.findOne({ where: { id: articleId } }); + return await this.svc_articlesValidate.existCheck({ + articleId, + }); } + @MergeExceptionMetadata([ + { service: ArticlesValidateService, methodName: 'fkValidCheck' }, + { service: StickerBlocksService, methodName: 'findStickerBlocks' }, + ]) + @ExceptionMetadata([EXCEPTIONS.NOT_THE_OWNER]) async readArticleUpdateDetail({ articleId, userId, @@ -34,8 +47,7 @@ export class ArticlesReadService { articles: data, passNonEssentail: true, }); - if (data.userId !== userId) - throw new UnauthorizedException('본인이 아닙니다.'); + if (data.userId !== userId) throw new BlccuException('NOT_THE_OWNER'); const article = await this.repo_articlesRead.readUpdateDetail({ articleId, }); @@ -49,6 +61,11 @@ export class ArticlesReadService { return await this.repo_articlesRead.readTemp({ userId }); } + @MergeExceptionMetadata([ + { service: ArticlesValidateService, methodName: 'fkValidCheck' }, + { service: ArticlesValidateService, methodName: 'existCheck' }, + { service: FollowsService, methodName: 'getScope' }, + ]) async readArticleDetail({ userId, articleId, @@ -67,7 +84,6 @@ export class ArticlesReadService { articleId, scope, }); - console.log(data, article); return article; } } diff --git a/src/APIs/articles/services/articles-update.service.ts b/src/APIs/articles/services/articles-update.service.ts index 28e3b15..8b320b1 100644 --- a/src/APIs/articles/services/articles-update.service.ts +++ b/src/APIs/articles/services/articles-update.service.ts @@ -1,8 +1,11 @@ -import { ForbiddenException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ArticlesValidateService } from './articles-validate.service'; import { ArticlesCreateRepository } from '../repositories/articles-create.repository'; import { IArticlesServicePatchArticle } from '../interfaces/articles.service.interface'; import { ArticleDto } from '../dtos/common/article.dto'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; @Injectable() export class ArticlesUpdateService { @@ -10,6 +13,12 @@ export class ArticlesUpdateService { private readonly svc_articlesValidate: ArticlesValidateService, private readonly repo_articlesCreate: ArticlesCreateRepository, ) {} + + @ExceptionMetadata([EXCEPTIONS.NOT_THE_OWNER]) + @MergeExceptionMetadata([ + { service: ArticlesValidateService, methodName: 'existCheck' }, + { service: ArticlesValidateService, methodName: 'fkValidCheck' }, + ]) async patchArticle({ userId, articleId, @@ -18,8 +27,7 @@ export class ArticlesUpdateService { const articleData = await this.svc_articlesValidate.existCheck({ articleId, }); - if (articleData.userId != userId) - throw new ForbiddenException('게시글 작성자가 아닙니다.'); + if (articleData.userId != userId) throw new BlccuException('NOT_THE_OWNER'); Object.keys(rest).forEach((value) => { if (rest[value] != null) articleData[value] = rest[value]; }); diff --git a/src/APIs/articles/services/articles-validate.service.ts b/src/APIs/articles/services/articles-validate.service.ts index 0d88407..af536da 100644 --- a/src/APIs/articles/services/articles-validate.service.ts +++ b/src/APIs/articles/services/articles-validate.service.ts @@ -1,14 +1,12 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { IArticlesServiceArticleId } from '../interfaces/articles.service.interface'; import { DataSource } from 'typeorm'; import { ArticleCategory } from 'src/APIs/articleCategories/entities/articleCategory.entity'; import { ArticleBackground } from 'src/APIs/articleBackgrounds/entities/articleBackground.entity'; import { User } from 'src/APIs/users/entities/user.entity'; import { ArticlesReadRepository } from '../repositories/articles-read.repository'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; @Injectable() export class ArticlesValidateService { @@ -17,14 +15,20 @@ export class ArticlesValidateService { private readonly repo_articlesRead: ArticlesReadRepository, ) {} + @ExceptionMetadata([EXCEPTIONS.ARTICLE_NOT_FOUND]) async existCheck({ articleId }: IArticlesServiceArticleId) { const data = await this.repo_articlesRead.findOne({ where: { id: articleId }, }); - if (!data) throw new NotFoundException('게시글을 찾을 수 없습니다.'); + if (!data) throw new BlccuException('ARTICLE_NOT_FOUND'); return data; } + @ExceptionMetadata([ + EXCEPTIONS.USER_NOT_FOUND, + EXCEPTIONS.ARTICLE_BACKGROUND_NOT_FOUND, + EXCEPTIONS.ARTICLE_CATEGORY_NOT_FOUND, + ]) async fkValidCheck({ articles, passNonEssentail }) { const pc = await this.dataSource .getRepository(ArticleCategory) @@ -32,7 +36,7 @@ export class ArticlesValidateService { .where('pc.id = :id', { id: articles.articleCategoryId }) .getOne(); if (pc == null && !passNonEssentail) - throw new BadRequestException('존재하지 않는 article_category입니다.'); + throw new BlccuException('ARTICLE_CATEGORY_NOT_FOUND'); if (articles.articleBackgroundId != null) { const pg = await this.dataSource .getRepository(ArticleBackground) @@ -40,15 +44,13 @@ export class ArticlesValidateService { .where('pg.id = :id', { id: articles.articleBackgroundId }) .getOne(); if (pg == null && !passNonEssentail) - throw new BadRequestException( - '존재하지 않는 article_background입니다.', - ); + throw new BlccuException('ARTICLE_BACKGROUND_NOT_FOUND'); } const us = await this.dataSource .getRepository(User) .createQueryBuilder('us') .where('us.id = :id', { id: articles.userId }) .getOne(); - if (us == null) throw new BadRequestException('존재하지 않는 user입니다.'); + if (us == null) throw new BlccuException('USER_NOT_FOUND'); } } diff --git a/src/APIs/auth/auth.controller.ts b/src/APIs/auth/auth.controller.ts index e49c4d5..1473867 100644 --- a/src/APIs/auth/auth.controller.ts +++ b/src/APIs/auth/auth.controller.ts @@ -1,42 +1,20 @@ -import { - Controller, - Get, - HttpCode, - Post, - Req, - Res, - UnauthorizedException, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Post, Req, Res, UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service'; import { AuthGuard } from '@nestjs/passport'; import { Request, Response } from 'express'; -import { - ApiCookieAuth, - ApiCreatedResponse, - ApiMovedPermanentlyResponse, - ApiOperation, - ApiTags, - ApiUnauthorizedResponse, -} from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; +import { AuthDocs } from './docs/auth-docs.decorator'; +import { BlccuException } from '@/common/blccu-exception'; @ApiTags('인증 API') @Controller('auth') +@AuthDocs export class AuthController { constructor(private readonly authService: AuthService) {} - @ApiOperation({ - summary: '카카오 로그인', - description: - '[swagger 불가능, url 직접 이동] 카카오 서버에 로그인을 요청한다. 응답으로 도착한 kakaoId를 기반으로 jwt accessToken과 refreshToken을 클라이언트에게 쿠키로 전송한다', - }) - @ApiMovedPermanentlyResponse({ - description: `카카오에서 인증 완료 후 클라이언트 루트 url로 리다이렉트 한다.`, - }) @Get('login/kakao') // 카카오 서버를 거쳐서 도착하게 될 엔드포인트 @UseGuards(AuthGuard('kakao')) // kakao.strategy를 실행시켜 줍니다. - @HttpCode(301) async kakaoLogin(@Req() req: Request, @Res() res: Response) { const { accessToken, refreshToken } = await this.authService.getJWT({ userId: req.user.kakaoId, @@ -44,51 +22,44 @@ export class AuthController { // 클라이언트 도메인 설정 const clientDomain = process.env.CLIENT_DOMAIN; + const oneDay = 24 * 60 * 60 * 1000; // 하루(밀리초) + const accessExpiryDate = new Date(Date.now() + oneDay); + const refreshExpiryDate = new Date(Date.now() + oneDay * 30); res.cookie('accessToken', accessToken, { httpOnly: true, domain: clientDomain, sameSite: 'none', secure: true, + expires: accessExpiryDate, }); res.cookie('refreshToken', refreshToken, { httpOnly: true, domain: clientDomain, sameSite: 'none', secure: true, + expires: refreshExpiryDate, }); - res.cookie('isLoggedIn', true, { httpOnly: false, domain: clientDomain }); return res.redirect(process.env.CLIENT_URL); - // return res.send(); } - @ApiOperation({ - summary: 'accessToken refresh', - description: 'refreshToken을 기반으로 accessToken을 재발급한다.', - }) - @ApiCreatedResponse({ - description: 'accessToken 쿠키를 새로 발급한다.', - }) - @ApiUnauthorizedResponse({ - description: - 'refresh 토큰이 만료되었거나 없을 경우 cookie를 모두 clear한다.', - }) - @ApiCookieAuth() @Get('refresh-token') - @HttpCode(201) async refresh(@Req() req: Request, @Res() res: Response) { try { const newAccessToken = await this.authService.refresh( req.cookies.refreshToken, ); const clientDomain = process.env.CLIENT_DOMAIN; + const oneDay = 24 * 60 * 60 * 1000; // 하루(밀리초) + const accessExpiryDate = new Date(Date.now() + oneDay); res.cookie('accessToken', newAccessToken, { httpOnly: true, domain: clientDomain, sameSite: 'none', secure: true, + expires: accessExpiryDate, }); return res.send(); } catch (e) { @@ -106,20 +77,13 @@ export class AuthController { sameSite: 'none', secure: true, }); - res.clearCookie('isLoggedIn', { httpOnly: false, domain: clientDomain }); - throw new UnauthorizedException(e.message); + throw new BlccuException('INVALID_REFRESH_TOKEN'); } } - @ApiOperation({ - summary: '로그아웃(clear cookie)', - description: '클라이언트의 로그인 관련 쿠키를 초기화한다.', - }) - @ApiCookieAuth() @UseGuards(AuthGuardV2) @Post('logout') - @HttpCode(204) async logout(@Res() res: Response) { const clientDomain = process.env.CLIENT_DOMAIN; @@ -139,5 +103,3 @@ export class AuthController { return res.send(); } } - -//https://velog.io/@leemhoon00/Nestjs-JWT-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84 diff --git a/src/APIs/auth/auth.service.ts b/src/APIs/auth/auth.service.ts index 4dca915..e3301b8 100644 --- a/src/APIs/auth/auth.service.ts +++ b/src/APIs/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { KakaoUserDto } from './dtos/common/kakao-user.dto'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; @@ -6,6 +6,9 @@ import * as bcrypt from 'bcrypt'; import { UsersReadService } from '../users/services/users-read.service'; import { UsersCreateService } from '../users/services/users-create.service'; import { UsersUpdateService } from '../users/services/users-update.service'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class AuthService { @@ -16,6 +19,12 @@ export class AuthService { private readonly svc_jwt: JwtService, private readonly svc_config: ConfigService, ) {} + + @MergeExceptionMetadata([ + { service: AuthService, methodName: 'kakaoValidateUser' }, + { service: AuthService, methodName: 'generateAccessToken' }, + { service: AuthService, methodName: 'generateRefreshToken' }, + ]) async getJWT(kakaoUserDto: KakaoUserDto) { const user = await this.kakaoValidateUser(kakaoUserDto); // 카카오 정보 검증 및 회원가입 로직 const accessToken = this.generateAccessToken({ userId: user.id }); // AccessToken 생성 @@ -23,6 +32,11 @@ export class AuthService { return { accessToken, refreshToken }; } + @MergeExceptionMetadata([ + { service: UsersReadService, methodName: 'findUserByIdWithDelete' }, + { service: UsersCreateService, methodName: 'createUser' }, + { service: UsersUpdateService, methodName: 'activateUser' }, + ]) async kakaoValidateUser(kakaoUserDto: KakaoUserDto) { let user = await this.svc_usersRead.findUserByIdWithDelete({ userId: kakaoUserDto.userId, @@ -46,6 +60,9 @@ export class AuthService { return this.svc_jwt.sign(payload); } + @MergeExceptionMetadata([ + { service: UsersUpdateService, methodName: 'setCurrentRefreshToken' }, + ]) async generateRefreshToken(kakaoUserDto: KakaoUserDto) { const payload = { userId: kakaoUserDto.userId }; const refreshToken = this.svc_jwt.sign(payload, { @@ -61,6 +78,11 @@ export class AuthService { console.log(user); return refreshToken; } + + @MergeExceptionMetadata([ + { service: UsersReadService, methodName: 'findUserByIdWithToken' }, + ]) + @ExceptionMetadata([EXCEPTIONS.INVALID_REFRESH_TOKEN]) async refresh(refreshToken: string): Promise { try { // 1차 검증 @@ -80,7 +102,7 @@ export class AuthService { ); if (!isRefreshTokenMatching) { - throw new UnauthorizedException('Invalid refresh-token'); + throw new BlccuException('INVALID_REFRESH_TOKEN'); } // 새로운 accessToken 생성 @@ -88,7 +110,7 @@ export class AuthService { return accessToken; } catch (err) { - throw new UnauthorizedException('Invalid refresh-token'); + throw new BlccuException('INVALID_REFRESH_TOKEN'); } } } diff --git a/src/APIs/auth/docs/auth-docs.decorator.ts b/src/APIs/auth/docs/auth-docs.decorator.ts new file mode 100644 index 0000000..8f76927 --- /dev/null +++ b/src/APIs/auth/docs/auth-docs.decorator.ts @@ -0,0 +1,57 @@ +import type { MethodNames } from '@/common/types/method'; + +import { + ApiCreatedResponse, + ApiMovedPermanentlyResponse, + ApiOperation, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { ApiResponseFromMetadata } from '../../../common/decorators/api-response-from-metadata.decorator'; +import { AuthController } from '@/APIs/auth/auth.controller'; +import { AuthService } from '@/APIs/auth/auth.service'; +import { HttpCode } from '@nestjs/common'; +import { applyDocs } from '@/utils/docs.utils'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; + +type AuthEndpoints = MethodNames; + +const AuthDocsMap: Record = { + kakaoLogin: [ + ApiOperation({ + summary: '카카오 로그인', + description: + '[swagger 불가능, url 직접 이동] 카카오 서버에 로그인을 요청한다. 응답으로 도착한 kakaoId를 기반으로 jwt accessToken과 refreshToken을 클라이언트에게 쿠키로 전송한다', + }), + ApiMovedPermanentlyResponse({ + description: `카카오에서 인증 완료 후 클라이언트 루트 url로 리다이렉트 한다.`, + }), + ApiResponseFromMetadata([{ service: AuthService, methodName: 'getJWT' }]), + HttpCode(301), + ], + refresh: [ + ApiOperation({ + summary: 'accessToken refresh', + description: 'refreshToken을 기반으로 accessToken을 재발급한다.', + }), + ApiCreatedResponse({ + description: 'accessToken 쿠키를 새로 발급한다.', + }), + ApiUnauthorizedResponse({ + description: + 'refresh 토큰이 만료되었거나 없을 경우 cookie를 모두 clear한다.', + }), + ApiAuthResponse(), + HttpCode(201), + ApiResponseFromMetadata([{ service: AuthService, methodName: 'refresh' }]), + ], + logout: [ + ApiOperation({ + summary: '로그아웃(clear cookie)', + description: '클라이언트의 로그인 관련 쿠키를 초기화한다.', + }), + ApiAuthResponse(), + HttpCode(204), + ], +}; + +export const AuthDocs = applyDocs(AuthDocsMap); diff --git a/src/APIs/auth/strategies/jwt.strategy.ts b/src/APIs/auth/strategies/jwt.strategy.ts index 45028dc..369cf40 100644 --- a/src/APIs/auth/strategies/jwt.strategy.ts +++ b/src/APIs/auth/strategies/jwt.strategy.ts @@ -1,7 +1,8 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { BlccuException } from '@/common/blccu-exception'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -14,7 +15,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { try { return request.cookies.accessToken; } catch (e) { - throw new UnauthorizedException(e.message); + throw new BlccuException('INVALID_ACCESS_TOKEN'); } }, ]), diff --git a/src/APIs/comments/comments.controller.ts b/src/APIs/comments/comments.controller.ts index fb16f94..c3375e7 100644 --- a/src/APIs/comments/comments.controller.ts +++ b/src/APIs/comments/comments.controller.ts @@ -3,7 +3,6 @@ import { Controller, Delete, Get, - HttpCode, Param, Patch, Post, @@ -12,34 +11,21 @@ import { } from '@nestjs/common'; import { CommentsService } from './comments.service'; import { Request } from 'express'; -import { - ApiCookieAuth, - ApiNoContentResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { CommentChildrenDto } from './dtos/common/comment-children.dto'; import { CommentCreateRequestDto } from './dtos/request/comment-create-request.dto'; import { CommentsGetResponseDto } from './dtos/response/comments-get-response.dto'; import { CommentDto } from './dtos/common/comment.dto'; import { CommentPatchRequestDto } from './dtos/request/comment-patch-request.dto'; +import { CommentsDocs } from './docs/comments-docs.decorator'; +@CommentsDocs @Controller() export class CommentsController { constructor(private readonly svc_comments: CommentsService) {} - @ApiTags('게시글 API') - @ApiOperation({ - summary: '댓글을 작성한다.', - description: '댓글을 작성한다.', - }) - @ApiOkResponse({ type: CommentChildrenDto }) - @ApiCookieAuth() @Post('articles/:articleId/comments') @UseGuards(AuthGuardV2) - @HttpCode(200) async createComment( @Req() req: Request, @Param('articleId') articleId: number, @@ -53,11 +39,6 @@ export class CommentsController { }); } - @ApiTags('게시글 API') - @ApiOperation({ - summary: '특정 게시글에 대한 댓글 조회', - }) - @ApiOkResponse({ type: [CommentsGetResponseDto] }) @Get('articles/:articleId/comments') async fetchComments( @Param('articleId') articleId: number, @@ -65,23 +46,13 @@ export class CommentsController { return await this.svc_comments.fetchComments({ articleId }); } - @ApiTags('유저 API') - @ApiOperation({ - summary: '자신의 최근 댓글 10개 조회', - }) @UseGuards(AuthGuardV2) - @ApiCookieAuth() - @ApiOkResponse({ type: [CommentDto] }) @Get('users/me/comments') async fetchUserComments(@Req() req: Request): Promise { const userId = req.user.userId; return await this.svc_comments.fetchUserComments({ userId }); } - @ApiTags('게시글 API') - @ApiOperation({ summary: '특정 게시글에 대한 댓글 수정' }) - @ApiCookieAuth() - @ApiOkResponse({ type: CommentDto }) @UseGuards(AuthGuardV2) @Patch('articles/:articleId/comments/:commentId') async patchComment( @@ -99,16 +70,8 @@ export class CommentsController { }); } - @ApiTags('게시글 API') - @ApiOperation({ - summary: '댓글을 삭제한다.', - description: '댓글을 논리삭제한다. date_deleted 칼럼에 값이 생긴다.', - }) - @ApiCookieAuth() - @ApiNoContentResponse({ description: '삭제 성공' }) @Delete('articles/:articleId/comments/:commentId') @UseGuards(AuthGuardV2) - @HttpCode(204) async deleteComment( @Req() req: Request, @Param('articleId') articleId: number, diff --git a/src/APIs/comments/comments.repository.ts b/src/APIs/comments/comments.repository.ts index c2f3650..38fce69 100644 --- a/src/APIs/comments/comments.repository.ts +++ b/src/APIs/comments/comments.repository.ts @@ -8,7 +8,7 @@ import { } from './interfaces/comments.repository.interface'; import { CommentsGetResponseDto } from './dtos/response/comments-get-response.dto'; import { USER_PRIMARY_RESPONSE_DTO_KEYS } from '../users/dtos/response/user-primary-response.dto'; -import { transformKeysToArgsFormat } from 'src/utils/classUtils'; +import { transformKeysToArgsFormat } from 'src/utils/class.utils'; @Injectable() export class CommentsRepository extends Repository { diff --git a/src/APIs/comments/comments.service.ts b/src/APIs/comments/comments.service.ts index fc2e007..af54c31 100644 --- a/src/APIs/comments/comments.service.ts +++ b/src/APIs/comments/comments.service.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { CommentsRepository } from './comments.repository'; import { DataSource, EntityManager, UpdateResult } from 'typeorm'; import { @@ -22,6 +17,9 @@ import { CommentDto } from './dtos/common/comment.dto'; import { CommentChildrenDto } from './dtos/common/comment-children.dto'; import { Article } from '../articles/entities/article.entity'; import { CommentsGetResponseDto } from './dtos/response/comments-get-response.dto'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class CommentsService { @@ -31,41 +29,47 @@ export class CommentsService { private readonly db_dataSource: DataSource, ) {} - async postsIdValidCheck({ + @ExceptionMetadata([ + EXCEPTIONS.INVALID_ARTICLE_REQUEST, + EXCEPTIONS.INVALID_PARENT_COMMENT_REQUEST, + ]) + async articleIdValidCheck({ parentId, articleId, }: ICommentsServiceArticleIdValidCheck): Promise { const parent = await this.existCheck({ commentId: parentId }); if (parent.articleId != articleId) - throw new BadRequestException( - '게시글 아이디가 루트 댓글이 작성된 게시글 아이디와 일치하지 않습니다.', - ); + throw new BlccuException('INVALID_ARTICLE_REQUEST'); if (parent.parentId) - throw new BadRequestException('부모 댓글이 루트 댓글이 아닙니다.'); + throw new BlccuException('INVALID_PARENT_COMMENT_REQUEST'); } + @ExceptionMetadata([EXCEPTIONS.COMMENT_NOT_FOUND]) async existCheck({ commentId }: ICommentsServiceId): Promise { const comment = await this.repo_comments.findOne({ where: { id: commentId }, }); if (!comment) { - throw new NotFoundException( - '댓글의 아이디를 찾을 수 없습니다. 존재하지 않거나 이미 삭제되었습니다.', - ); + throw new BlccuException('COMMENT_NOT_FOUND'); } return comment; } + @MergeExceptionMetadata([ + { service: CommentsService, methodName: 'articleIdValidCheck' }, + { service: NotificationsService, methodName: 'emitAlarm' }, + ]) + @ExceptionMetadata([EXCEPTIONS.FORBIDDEN_ACCESS]) async createComment( createCommentDto: ICommentsServiceCreateComment, ): Promise { - const post = await this.db_dataSource.manager.findOne(Article, { + const articleData = await this.db_dataSource.manager.findOne(Article, { where: { id: createCommentDto.articleId }, }); - if (post.allowComment === false) - throw new ForbiddenException('댓글이 허용되지 않은 게시물 입니다.'); + if (articleData.allowComment === false) + throw new BlccuException('FORBIDDEN_ACCESS'); if (createCommentDto.parentId) - await this.postsIdValidCheck({ + await this.articleIdValidCheck({ parentId: createCommentDto.parentId, articleId: createCommentDto.articleId, }); @@ -80,7 +84,6 @@ export class CommentsService { const commentData = await this.repo_comments.insertComment({ createCommentDto, }); - console.log(commentData); const commentId = commentData.identifiers[0].id; const { article, parent, ...result } = @@ -107,6 +110,11 @@ export class CommentsService { return result; } + @ExceptionMetadata([ + EXCEPTIONS.NOT_THE_OWNER, + EXCEPTIONS.INVALID_ARTICLE_REQUEST, + EXCEPTIONS.COMMENT_NOT_FOUND, + ]) async patchComment({ userId, articleId, @@ -114,11 +122,10 @@ export class CommentsService { content, }: ICommentsServicePatchComment): Promise { const commentData = await this.existCheck({ commentId }); - if (!commentData) throw new NotFoundException('댓글을 찾을 수 없습니다.'); + if (!commentData) throw new BlccuException('COMMENT_NOT_FOUND'); if (commentData.articleId != articleId) - throw new NotFoundException('루트 게시글의 아이디가 일치하지 않습니다.'); - if (commentData.userId != userId) - throw new ForbiddenException('댓글을 수정할 권한이 없습니다.'); + throw new BlccuException('INVALID_ARTICLE_REQUEST'); + if (commentData.userId != userId) throw new BlccuException('NOT_THE_OWNER'); commentData.content = content; return await this.repo_comments.save(commentData); } @@ -139,6 +146,13 @@ export class CommentsService { }); } + @ExceptionMetadata([ + EXCEPTIONS.ARTICLE_NOT_FOUND, + EXCEPTIONS.COMMENT_NOT_FOUND, + ]) + @MergeExceptionMetadata([ + { service: CommentsService, methodName: 'existCheck' }, + ]) async delete({ commentId, userId, @@ -149,7 +163,7 @@ export class CommentsService { let childrenData = []; let deletedResult: UpdateResult; if (data.articleId !== articleId) { - throw new NotFoundException('게시글을 찾을 수 없습니다.'); + throw new BlccuException('ARTICLE_NOT_FOUND'); } if (data.parentId == null) childrenData = await manager.find(Comment, { @@ -166,7 +180,7 @@ export class CommentsService { id: commentId, }); if (deletedResult.affected < 1) - throw new NotFoundException('삭제할 댓글이 존재하지 않습니다'); + throw new BlccuException('COMMENT_NOT_FOUND'); } }); } diff --git a/src/APIs/comments/docs/comments-docs.decorator.ts b/src/APIs/comments/docs/comments-docs.decorator.ts new file mode 100644 index 0000000..16f7ac5 --- /dev/null +++ b/src/APIs/comments/docs/comments-docs.decorator.ts @@ -0,0 +1,79 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { + ApiCreatedResponse, + ApiNoContentResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { CommentsController } from '../comments.controller'; +import { CommentChildrenDto } from '../dtos/common/comment-children.dto'; +import { CommentsService } from '../comments.service'; +import { HttpCode } from '@nestjs/common'; +import { CommentsGetResponseDto } from '../dtos/response/comments-get-response.dto'; +import { CommentDto } from '../dtos/common/comment.dto'; + +type CommentsEndpoints = MethodNames; + +const CommentsDocsMap: Record = { + createComment: [ + ApiTags('게시글 API'), + ApiOperation({ + summary: '댓글을 작성한다.', + description: '댓글을 작성한다.', + }), + ApiCreatedResponse({ type: CommentChildrenDto }), + ApiAuthResponse(), + HttpCode(200), + ApiResponseFromMetadata([ + { service: CommentsService, methodName: 'createComment' }, + ]), + ], + fetchComments: [ + ApiTags('게시글 API'), + ApiOperation({ + summary: '특정 게시글에 대한 댓글 조회', + }), + ApiOkResponse({ type: [CommentsGetResponseDto] }), + ApiResponseFromMetadata([ + { service: CommentsService, methodName: 'fetchComments' }, + ]), + ], + fetchUserComments: [ + ApiTags('유저 API'), + ApiOperation({ + summary: '자신의 최근 댓글 10개 조회', + }), + ApiAuthResponse(), + ApiOkResponse({ type: [CommentDto] }), + ApiResponseFromMetadata([ + { service: CommentsService, methodName: 'fetchUserComments' }, + ]), + ], + patchComment: [ + ApiTags('게시글 API'), + ApiOperation({ summary: '특정 게시글에 대한 댓글 수정' }), + ApiOkResponse({ type: CommentDto }), + ApiResponseFromMetadata([ + { service: CommentsService, methodName: 'patchComment' }, + ]), + ], + deleteComment: [ + ApiTags('게시글 API'), + ApiOperation({ + summary: '댓글을 삭제한다.', + description: '댓글을 논리삭제한다. date_deleted 칼럼에 값이 생긴다.', + }), + ApiAuthResponse(), + ApiNoContentResponse({ description: '삭제 성공' }), + HttpCode(205), + ApiResponseFromMetadata([ + { service: CommentsService, methodName: 'delete' }, + ]), + ], +}; + +export const CommentsDocs = applyDocs(CommentsDocsMap); diff --git a/src/APIs/feedbacks/docs/feedbacks-docs.decorator.ts b/src/APIs/feedbacks/docs/feedbacks-docs.decorator.ts new file mode 100644 index 0000000..73c9f37 --- /dev/null +++ b/src/APIs/feedbacks/docs/feedbacks-docs.decorator.ts @@ -0,0 +1,38 @@ +import type { MethodNames } from '@/common/types/method'; + +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ApiResponseFromMetadata } from '../../../common/decorators/api-response-from-metadata.decorator'; +import { applyDocs } from '@/utils/docs.utils'; +import { FeedbacksController } from '../feedbacks.controller'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { FeedbackDto } from '../dtos/common/feedback.dto'; +import { FeedbacksService } from '../feedbacks.service'; + +type FeedbacksEndpoints = MethodNames; + +const FeedbacksDocsMap: Record = { + createFeedback: [ + ApiOperation({ summary: '피드백 작성하기' }), + ApiAuthResponse(), + ApiCreatedResponse({ type: FeedbackDto }), + ApiResponseFromMetadata([ + { service: FeedbacksService, methodName: 'createFeedback' }, + ]), + ], + getFeedbacks: [ + ApiTags('어드민 API'), + ApiOperation({ summary: '[어드민용] 피드백 내용 조회' }), + ApiAuthResponse(), + ApiOkResponse({ type: [FeedbackDto] }), + ApiResponseFromMetadata([ + { service: FeedbacksService, methodName: 'fetchFeedbacks' }, + ]), + ], +}; + +export const FeedbacksDocs = applyDocs(FeedbacksDocsMap); diff --git a/src/APIs/feedbacks/feedbacks.controller.ts b/src/APIs/feedbacks/feedbacks.controller.ts index 27a9d48..f73369a 100644 --- a/src/APIs/feedbacks/feedbacks.controller.ts +++ b/src/APIs/feedbacks/feedbacks.controller.ts @@ -12,15 +12,14 @@ import { Request } from 'express'; import { FeedbackType } from 'src/common/enums/feedback-type.enum'; import { FeedbackDto } from './dtos/common/feedback.dto'; import { FeedbackCreateRequestDto } from './dtos/request/feedback-create-request.dto'; +import { FeedbacksDocs } from './docs/feedbacks-docs.decorator'; +@FeedbacksDocs @ApiTags('유저 API') @Controller('users') export class FeedbacksController { constructor(private readonly feedbacksService: FeedbacksService) {} - @ApiOperation({ summary: '피드백 작성하기' }) - @ApiCookieAuth() - @ApiCreatedResponse({ type: FeedbackDto }) @UseGuards(AuthGuardV2) @Post('feedback') async createFeedback( @@ -35,10 +34,6 @@ export class FeedbacksController { }); } - @ApiTags('어드민 API') - @ApiOperation({ summary: '[어드민용] 피드백 내용 조회' }) - @ApiCookieAuth() - @ApiOkResponse({ type: [FeedbackDto] }) @UseGuards(AuthGuardV2) @Get('admin/feedbacks') async getFeedbacks(@Req() req: Request): Promise { diff --git a/src/APIs/feedbacks/feedbacks.service.ts b/src/APIs/feedbacks/feedbacks.service.ts index a9b3eaa..b3c04b8 100644 --- a/src/APIs/feedbacks/feedbacks.service.ts +++ b/src/APIs/feedbacks/feedbacks.service.ts @@ -6,6 +6,7 @@ import { } from './interfaces/feedbacks.service.interface'; import { FeedbackDto } from './dtos/common/feedback.dto'; import { UsersValidateService } from '../users/services/users-validate-service'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class FeedbacksService { @@ -26,6 +27,9 @@ export class FeedbacksService { }); } + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'adminCheck' }, + ]) async fetchFeedbacks({ userId, }: IFeedbacksServiceUserId): Promise { diff --git a/src/APIs/follows/docs/follows-docs.decorator.ts b/src/APIs/follows/docs/follows-docs.decorator.ts new file mode 100644 index 0000000..a08958a --- /dev/null +++ b/src/APIs/follows/docs/follows-docs.decorator.ts @@ -0,0 +1,98 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { + ApiCreatedResponse, + ApiNoContentResponse, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { FollowsController } from '../follows.controller'; +import { FollowDto } from '../dtos/common/follow.dto'; +import { FollowsService } from '../follows.service'; +import { HttpCode } from '@nestjs/common'; +import { UserFollowingResponseDto } from '@/APIs/users/dtos/response/user-following-response.dto'; + +type FollowsEndpoints = MethodNames; + +const FollowsDocsMap: Record = { + followUser: [ + ApiOperation({ + summary: '팔로우 추가하기', + description: '로그인된 유저가 userId를 팔로우한다.', + }), + ApiAuthResponse(), + HttpCode(201), + ApiCreatedResponse({ description: '이웃 추가 성공', type: FollowDto }), + ApiResponseFromMetadata([ + { service: FollowsService, methodName: 'followUser' }, + ]), + ], + unfollowUser: [ + ApiOperation({ + summary: '팔로우 삭제하기', + description: '로그인된 유저가 userId를 언팔로우 한다.', + }), + ApiAuthResponse(), + ApiNoContentResponse({ description: '언팔로우 성공' }), + HttpCode(204), + ApiResponseFromMetadata([ + { service: FollowsService, methodName: 'unfollowUser' }, + ]), + ], + checkFollower: [ + ApiOperation({ + summary: '팔로워 유무 조회', + description: '나와 팔로우되었는지 유무 체크를 한다.', + }), + ApiAuthResponse(), + ApiOkResponse({ type: Boolean }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: FollowsService, methodName: 'existCheckWithoutValidation' }, + ]), + ], + checkFollowing: [ + ApiOperation({ + summary: '팔로잉 유무 조회', + description: '나의 팔로잉인지 유무 체크를 한다.', + }), + ApiAuthResponse(), + ApiOkResponse({ type: Boolean }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: FollowsService, methodName: 'existCheckWithoutValidation' }, + ]), + ], + getFollowers: [ + ApiOperation({ + summary: '팔로워 목록 조회', + description: 'userId의 팔로워 목록을 조회한다.', + }), + ApiOkResponse({ + description: '팔로워 목록 조회 성공', + type: [UserFollowingResponseDto], + }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: FollowsService, methodName: 'findFollowers' }, + ]), + ], + getFollows: [ + ApiOperation({ + summary: '팔로잉 목록 조회', + description: 'userId의 팔로잉 목록을 조회한다.', + }), + ApiOkResponse({ + description: '팔로잉 목록 조회 성공', + type: [UserFollowingResponseDto], + }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: FollowsService, methodName: 'findFollowings' }, + ]), + ], +}; + +export const FollowsDocs = applyDocs(FollowsDocsMap); diff --git a/src/APIs/follows/follows.controller.ts b/src/APIs/follows/follows.controller.ts index f6f110e..17b1f57 100644 --- a/src/APIs/follows/follows.controller.ts +++ b/src/APIs/follows/follows.controller.ts @@ -2,43 +2,27 @@ import { Controller, Delete, Get, - HttpCode, Param, Post, Req, UseGuards, } from '@nestjs/common'; import { Request } from 'express'; -import { - ApiConflictResponse, - ApiCookieAuth, - ApiCreatedResponse, - ApiNoContentResponse, - ApiNotFoundResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { FollowsService } from './follows.service'; import { FollowDto } from './dtos/common/follow.dto'; import { UserFollowingResponseDto } from '../users/dtos/response/user-following-response.dto'; +import { FollowsDocs } from './docs/follows-docs.decorator'; +@FollowsDocs @ApiTags('유저 API') @Controller('users') export class FollowsController { constructor(private readonly followsService: FollowsService) {} - @ApiOperation({ - summary: '팔로우 추가하기', - description: '로그인된 유저가 userId를 팔로우한다.', - }) - @ApiCookieAuth() - @ApiCreatedResponse({ description: '이웃 추가 성공', type: FollowDto }) - @ApiConflictResponse({ description: '이미 팔로우한 상태이다.' }) @UseGuards(AuthGuardV2) @Post(':userId/follow') - @HttpCode(201) async followUser( @Req() req: Request, @Param('userId') toUser: number, @@ -50,16 +34,8 @@ export class FollowsController { }); } - @ApiOperation({ - summary: '팔로우 삭제하기', - description: '로그인된 유저가 userId를 언팔로우 한다.', - }) - @ApiCookieAuth() - @ApiNoContentResponse({ description: '언팔로우 성공' }) - @ApiNotFoundResponse({ description: '존재하지 않는 이웃 정보이다.' }) @UseGuards(AuthGuardV2) @Delete(':userId/follow') - @HttpCode(204) unfollowUser( @Req() req: Request, @Param('userId') toUser: number, @@ -71,48 +47,32 @@ export class FollowsController { }); } - @ApiOperation({ - summary: '팔로워 유무 조회', - description: '나와 팔로우되었는지 유무 체크를 한다.', - }) - @ApiCookieAuth() - @ApiOkResponse({ type: Boolean }) @UseGuards(AuthGuardV2) - @HttpCode(200) @Get('me/follower/:userId') async checkFollower( @Req() req: Request, @Param('userId') toUser: number, ): Promise { const fromUser = req.user.userId; - return await this.followsService.existCheck({ fromUser, toUser }); + return await this.followsService.existCheckWithoutValidation({ + fromUser, + toUser, + }); } - @ApiOperation({ - summary: '팔로잉 유무 조회', - description: '나의 팔로잉인지 유무 체크를 한다.', - }) - @ApiCookieAuth() - @ApiOkResponse({ type: Boolean }) @UseGuards(AuthGuardV2) - @HttpCode(200) @Get('me/following/:userId') async checkFollowing( @Req() req: Request, @Param('userId') fromUser: number, ): Promise { const toUser = req.user.userId; - return await this.followsService.existCheck({ fromUser, toUser }); + return await this.followsService.existCheckWithoutValidation({ + fromUser, + toUser, + }); } - @ApiOperation({ - summary: '팔로워 목록 조회', - description: 'userId의 팔로워 목록을 조회한다.', - }) - @ApiOkResponse({ - description: '팔로워 목록 조회 성공', - type: [UserFollowingResponseDto], - }) - @HttpCode(200) + @Get(':userId/followers') getFollowers( @Req() req: Request, @@ -122,15 +82,6 @@ export class FollowsController { return this.followsService.findFollowers({ userId, loggedUser }); } - @ApiOperation({ - summary: '팔로잉 목록 조회', - description: 'userId의 팔로잉 목록을 조회한다.', - }) - @ApiOkResponse({ - description: '팔로잉 목록 조회 성공', - type: [UserFollowingResponseDto], - }) - @HttpCode(200) @Get(':userId/followings') getFollows( @Req() req: Request, diff --git a/src/APIs/follows/follows.repository.ts b/src/APIs/follows/follows.repository.ts index 6a5a7eb..d3fed21 100644 --- a/src/APIs/follows/follows.repository.ts +++ b/src/APIs/follows/follows.repository.ts @@ -3,7 +3,7 @@ import { Follow } from './entities/follow.entity'; import { Injectable } from '@nestjs/common'; import { IFollowsRepositoryFindList } from './interfaces/follows.repository.interface'; import { UserFollowingResponseDto } from '../users/dtos/response/user-following-response.dto'; -import { convertToCamelCase } from 'src/utils/classUtils'; +import { convertToCamelCase } from 'src/utils/class.utils'; import { plainToClass } from 'class-transformer'; import { USER_PRIMARY_RESPONSE_DTO_KEYS } from '../users/dtos/response/user-primary-response.dto'; diff --git a/src/APIs/follows/follows.service.ts b/src/APIs/follows/follows.service.ts index ffe85d5..d2ade06 100644 --- a/src/APIs/follows/follows.service.ts +++ b/src/APIs/follows/follows.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { OpenScope } from 'src/common/enums/open-scope.enum'; import { FollowsRepository } from './follows.repository'; @@ -11,6 +11,9 @@ import { NotificationsService } from '../notifications/notifications.service'; import { NotType } from 'src/common/enums/not-type.enum'; import { UserFollowingResponseDto } from '../users/dtos/response/user-following-response.dto'; import { FollowDto } from './dtos/common/follow.dto'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; @Injectable() export class FollowsService { @@ -56,7 +59,7 @@ export class FollowsService { return [OpenScope.PUBLIC]; } - async existCheck({ + async existCheckWithoutValidation({ fromUser, toUser, }: IFollowsServiceUsers): Promise { @@ -73,6 +76,14 @@ export class FollowsService { return true; } + @MergeExceptionMetadata([ + { service: FollowsService, methodName: 'existCheckWithoutValidation' }, + { service: NotificationsService, methodName: 'emitAlarm' }, + ]) + @ExceptionMetadata([ + EXCEPTIONS.ALREADY_EXISTS, + EXCEPTIONS.SELF_ACTION_NOT_ALLOWED, + ]) async followUser({ fromUser, toUser, @@ -88,12 +99,15 @@ export class FollowsService { where: { id: fromUser }, }); - const isExist = await this.existCheck({ fromUser, toUser }); + const isExist = await this.existCheckWithoutValidation({ + fromUser, + toUser, + }); if (isExist) { - throw new ConflictException('already exists'); + throw new BlccuException('ALREADY_EXISTS'); } if (this.isSame({ fromUser, toUser })) { - throw new ConflictException('you cannot follow yourself!'); + throw new BlccuException('SELF_ACTION_NOT_ALLOWED'); } const follow = await this.repo_follows.save({ fromUser: { id: fromUser }, @@ -107,7 +121,6 @@ export class FollowsService { }); await queryRunner.commitTransaction(); - console.log('commited'); await this.svc_notifications.emitAlarm({ userId: fromUser, targetUserId: toUser, @@ -123,6 +136,13 @@ export class FollowsService { } } + @MergeExceptionMetadata([ + { service: FollowsService, methodName: 'existCheckWithoutValidation' }, + ]) + @ExceptionMetadata([ + EXCEPTIONS.ALREADY_EXISTS, + EXCEPTIONS.SELF_ACTION_NOT_ALLOWED, + ]) async unfollowUser({ fromUser, toUser, @@ -138,14 +158,17 @@ export class FollowsService { where: { id: fromUser }, }); - const isExist = await this.existCheck({ fromUser, toUser }); + const isExist = await this.existCheckWithoutValidation({ + fromUser, + toUser, + }); if (!isExist) { - throw new ConflictException('no data exists'); + throw new BlccuException('FOLLOW_NOT_FOUND'); } if (this.isSame({ fromUser, toUser })) { - throw new ConflictException('you cannot unfollow yourself!'); + throw new BlccuException('SELF_ACTION_NOT_ALLOWED'); } await queryRunner.manager.update(User, fromUserData.id, { diff --git a/src/APIs/likes/docs/likes-docs.decorator.ts b/src/APIs/likes/docs/likes-docs.decorator.ts new file mode 100644 index 0000000..2cf87b1 --- /dev/null +++ b/src/APIs/likes/docs/likes-docs.decorator.ts @@ -0,0 +1,75 @@ +import type { MethodNames } from '@/common/types/method'; + +import { + ApiCreatedResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { ApiResponseFromMetadata } from '../../../common/decorators/api-response-from-metadata.decorator'; +import { applyDocs } from '@/utils/docs.utils'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { LikesService } from '../likes.service'; +import { LikesGetResponseDto } from '../dtos/response/likes-get-response.dto'; +import { LikesController } from '../likes.controller'; +import { UserFollowingResponseDto } from '@/APIs/users/dtos/response/user-following-response.dto'; +import { HttpCode } from '@nestjs/common'; + +type LikesEndpoints = MethodNames; + +const LikesDocsMap: Record = { + like: [ + ApiOperation({ + summary: '좋아요', + description: '로그인 된 유저가 {id}인 게시글에 좋아요를 한다.', + }), + ApiAuthResponse(), + ApiCreatedResponse({ + description: '좋아요 성공', + type: LikesGetResponseDto, + }), + ApiNotFoundResponse({ description: '게시글을 찾을 수 없는 경우' }), + ApiResponseFromMetadata([{ service: LikesService, methodName: 'like' }]), + ], + deleteLike: [ + ApiOperation({ + summary: '좋아요 취소', + description: '로그인 된 유저가 {id}인 게시글에 좋아요를 취소한다.', + }), + ApiAuthResponse(), + ApiNoContentResponse({ + description: '좋아요 취소 성공', + }), + ApiResponseFromMetadata([ + { service: LikesService, methodName: 'cancleLike' }, + ]), + ], + fetchIfLiked: [ + ApiOperation({ + summary: '게시글 좋아요 여부 체크', + description: '특정 게시글에 내가 좋아요를 눌렀는 지 체크', + }), + ApiOkResponse({ type: Boolean }), + ApiAuthResponse(), + ApiResponseFromMetadata([ + { service: LikesService, methodName: 'checkIfLiked' }, + ]), + ], + fetchLikes: [ + ApiOperation({ + summary: '좋아요 누른 대상 조회하기', + description: '게시글에 좋아요를 누른 사람들을 확인한다.', + }), + ApiOkResponse({ + description: '조회 성공', + type: [UserFollowingResponseDto], + }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: LikesService, methodName: 'findLikes' }, + ]), + ], +}; + +export const LikesDocs = applyDocs(LikesDocsMap); diff --git a/src/APIs/likes/likes.controller.ts b/src/APIs/likes/likes.controller.ts index 7644a66..e0000f8 100644 --- a/src/APIs/likes/likes.controller.ts +++ b/src/APIs/likes/likes.controller.ts @@ -9,35 +9,19 @@ import { UseGuards, } from '@nestjs/common'; import { LikesService } from './likes.service'; -import { - ApiCookieAuth, - ApiCreatedResponse, - ApiNoContentResponse, - ApiNotFoundResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { UserFollowingResponseDto } from '../users/dtos/response/user-following-response.dto'; import { LikesGetResponseDto } from './dtos/response/likes-get-response.dto'; +import { LikesDocs } from './docs/likes-docs.decorator'; +@LikesDocs @ApiTags('게시글 API') @Controller('articles/:articleId') export class LikesController { constructor(private readonly svc_likes: LikesService) {} - @ApiOperation({ - summary: '좋아요', - description: '로그인 된 유저가 {id}인 게시글에 좋아요를 한다.', - }) - @ApiCookieAuth() - @ApiCreatedResponse({ - description: '좋아요 성공', - type: LikesGetResponseDto, - }) - @ApiNotFoundResponse({ description: '게시글을 찾을 수 없는 경우' }) @UseGuards(AuthGuardV2) @HttpCode(201) @Post('like') @@ -49,15 +33,6 @@ export class LikesController { return await this.svc_likes.like({ userId, articleId }); } - @ApiOperation({ - summary: '좋아요 취소', - description: '로그인 된 유저가 {id}인 게시글에 좋아요를 취소한다.', - }) - @ApiCookieAuth() - @ApiNoContentResponse({ - description: '좋아요 취소 성공', - }) - @ApiNotFoundResponse({ description: '게시글을 찾을 수 없는 경우' }) @UseGuards(AuthGuardV2) @HttpCode(204) @Delete('like') @@ -70,12 +45,6 @@ export class LikesController { return; } - @ApiOperation({ - summary: '게시글 좋아요 여부 체크', - description: '특정 게시글에 내가 좋아요를 눌렀는 지 체크', - }) - @ApiCookieAuth() - @ApiOkResponse({ type: Boolean }) @UseGuards(AuthGuardV2) @Get('like') async fetchIfLiked( @@ -86,15 +55,6 @@ export class LikesController { return await this.svc_likes.checkIfLiked({ userId, articleId }); } - @ApiOperation({ - summary: '좋아요 누른 대상 조회하기', - description: '게시글에 좋아요를 누른 사람들을 확인한다.', - }) - @ApiOkResponse({ - description: '조회 성공', - type: [UserFollowingResponseDto], - }) - @HttpCode(200) @Get('like-users') async fetchLikes( @Param('articleId') articleId: number, diff --git a/src/APIs/likes/likes.repository.ts b/src/APIs/likes/likes.repository.ts index 0661b04..5e7a450 100644 --- a/src/APIs/likes/likes.repository.ts +++ b/src/APIs/likes/likes.repository.ts @@ -4,7 +4,7 @@ import { Injectable } from '@nestjs/common'; import { ILikesRepositoryIds } from './interfaces/likes.repository.interface'; import { Like } from './entities/like.entity'; import { UserFollowingResponseDto } from '../users/dtos/response/user-following-response.dto'; -import { convertToCamelCase } from 'src/utils/classUtils'; +import { convertToCamelCase } from 'src/utils/class.utils'; import { plainToClass } from 'class-transformer'; import { USER_PRIMARY_RESPONSE_DTO_KEYS } from '../users/dtos/response/user-primary-response.dto'; diff --git a/src/APIs/likes/likes.service.ts b/src/APIs/likes/likes.service.ts index 56628b2..89cc45b 100644 --- a/src/APIs/likes/likes.service.ts +++ b/src/APIs/likes/likes.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { LikesRepository } from './likes.repository'; import { ILikesServiceIds } from './interfaces/likes.service.interface'; @@ -8,6 +8,9 @@ import { LikesGetResponseDto } from './dtos/response/likes-get-response.dto'; import { Article } from '../articles/entities/article.entity'; import { Like } from './entities/like.entity'; import { UserFollowingResponseDto } from '../users/dtos/response/user-following-response.dto'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class LikesService { @@ -28,6 +31,10 @@ export class LikesService { return false; } + @MergeExceptionMetadata([ + { service: NotificationsService, methodName: 'emitAlarm' }, + ]) + @ExceptionMetadata([EXCEPTIONS.ALREADY_EXISTS]) async like({ articleId, userId, @@ -43,7 +50,7 @@ export class LikesService { where: { articleId, userId }, }); if (alreadyLiked) { - throw new ConflictException('이미 좋아요 한 게시글입니다.'); + throw new BlccuException('ALREADY_EXISTS'); } else { const likeData = await queryRunner.manager.save(Like, { userId, @@ -71,6 +78,7 @@ export class LikesService { } } + @ExceptionMetadata([EXCEPTIONS.LIKE_NOT_FOUND]) async cancleLike({ articleId, userId }: ILikesServiceIds): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); @@ -83,7 +91,7 @@ export class LikesService { where: { articleId, userId }, }); if (!alreadyLiked) { - throw new ConflictException('좋아요 내역을 찾을 수 없습니다.'); + throw new BlccuException('LIKE_NOT_FOUND'); } else { await queryRunner.manager.delete(Like, { id: alreadyLiked.id, diff --git a/src/APIs/notifications/docs/notifications-docs.decorator.ts b/src/APIs/notifications/docs/notifications-docs.decorator.ts new file mode 100644 index 0000000..c59d768 --- /dev/null +++ b/src/APIs/notifications/docs/notifications-docs.decorator.ts @@ -0,0 +1,52 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { ApiOkResponse, ApiOperation, ApiProduces } from '@nestjs/swagger'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { HttpCode } from '@nestjs/common'; +import { NotificationsController } from '../notifications.controller'; +import { NotificationsService } from '../notifications.service'; +import { NotificationsGetResponseDto } from '../dtos/response/notifications-get-response.dto'; + +type NotificationsEndpoints = MethodNames; + +const NotificationsDocsMap: Record = + { + connectUser: [ + ApiOperation({ + summary: '[SSE] 알림을 구독한다.', + description: + '[swagger 지원 x] sse를 연결한다. 로그인된 유저를 타겟으로 하는 알림이 보내졌을경우 sse를 통해 전달받는다.', + }), + ApiAuthResponse(), + ApiProduces('text/event-stream'), + ApiResponseFromMetadata([ + { service: NotificationsService, methodName: 'connectUser' }, + ]), + ], + getNotifications: [ + ApiOperation({ + summary: '알림 조회', + description: + '로그인된 유저들에게 보내진 알림들을 조회한다. query를 통해 알림 조회 옵션 설정. sse 연결 이전 이니셜 데이터 fetch 시 사용', + }), + ApiOkResponse({ type: [NotificationsGetResponseDto] }), + ApiAuthResponse(), + ApiResponseFromMetadata([ + { service: NotificationsService, methodName: 'findNotifications' }, + ]), + ], + readNotification: [ + ApiOperation({ + summary: '알림 읽기', + description: '알림을 읽음 처리한다.', + }), + ApiOkResponse({ type: NotificationsGetResponseDto }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: NotificationsService, methodName: 'readNotification' }, + ]), + ], + }; + +export const NotificationsDocs = applyDocs(NotificationsDocsMap); diff --git a/src/APIs/notifications/notifications.controller.ts b/src/APIs/notifications/notifications.controller.ts index bcf8250..4ba4e62 100644 --- a/src/APIs/notifications/notifications.controller.ts +++ b/src/APIs/notifications/notifications.controller.ts @@ -1,7 +1,6 @@ import { Controller, Get, - HttpCode, Param, Post, Query, @@ -11,38 +10,26 @@ import { UseGuards, } from '@nestjs/common'; import { NotificationsService } from './notifications.service'; -import { - ApiCookieAuth, - ApiOkResponse, - ApiOperation, - ApiProduces, - ApiTags, -} from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { interval, map, merge } from 'rxjs'; import { NotificationsGetResponseDto } from './dtos/response/notifications-get-response.dto'; import { NotificationsGetRequestDto } from './dtos/request/notifications-get-request.dto'; +import { NotificationsDocs } from './docs/notifications-docs.decorator'; +@NotificationsDocs @ApiTags('알림 API') @Controller('notifications') export class NotificationsController { constructor(private readonly notificationsService: NotificationsService) {} - @ApiOperation({ - summary: '[SSE] 알림을 구독한다.', - description: - '[swagger 불가능, postman 권장] sse를 연결한다. 로그인된 유저를 타겟으로 하는 알림이 보내졌을경우 sse를 통해 전달받는다.', - }) - @ApiCookieAuth() - @ApiProduces('text/event-stream') @UseGuards(AuthGuardV2) @Sse('subscribe') connectUser(@Req() req: Request, @Res() res: Response) { const targetUserId = req.user.userId; res.setTimeout(0); // 600초로 설정, 필요에 따라 변경 가능 nginx도 함께 변경할 것. - // res.setTimeout(15 * 1000); const sseStream = this.notificationsService.connectUser({ targetUserId, }); @@ -52,14 +39,7 @@ export class NotificationsController { return merge(sseStream, pingStream); } - @ApiOperation({ - summary: '알림 조회', - description: - '로그인된 유저들에게 보내진 알림들을 조회한다. query를 통해 알림 조회 옵션 설정. sse 연결 이전 이니셜 데이터 fetch 시 사용', - }) - @ApiOkResponse({ type: [NotificationsGetResponseDto] }) @Get() - @ApiCookieAuth() @UseGuards(AuthGuardV2) async getNotifications( @Req() req: Request, @@ -72,14 +52,7 @@ export class NotificationsController { }); } - @ApiOperation({ - summary: '알림 읽기', - description: '알림을 읽음 처리한다.', - }) - @ApiCookieAuth() @UseGuards(AuthGuardV2) - @ApiOkResponse({ type: NotificationsGetResponseDto }) - @HttpCode(200) @Post(':notificationId/read') async readNotification( @Req() req: Request, diff --git a/src/APIs/notifications/notifications.repository.ts b/src/APIs/notifications/notifications.repository.ts index 4602eb9..564b712 100644 --- a/src/APIs/notifications/notifications.repository.ts +++ b/src/APIs/notifications/notifications.repository.ts @@ -7,7 +7,7 @@ import { INotificationsSeviceEmitNotification, } from './interfaces/notifications.service.interface'; import { NotificationsGetResponseDto } from './dtos/response/notifications-get-response.dto'; -import { transformKeysToArgsFormat } from 'src/utils/classUtils'; +import { transformKeysToArgsFormat } from 'src/utils/class.utils'; import { USER_PRIMARY_RESPONSE_DTO_KEYS } from '../users/dtos/response/user-primary-response.dto'; @Injectable() diff --git a/src/APIs/notifications/notifications.service.ts b/src/APIs/notifications/notifications.service.ts index 335af14..fd8e6b6 100644 --- a/src/APIs/notifications/notifications.service.ts +++ b/src/APIs/notifications/notifications.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, MessageEvent } from '@nestjs/common'; +import { Injectable, MessageEvent } from '@nestjs/common'; import { NotificationsRepository } from './notifications.repository'; import { Observable, Subject, filter, map } from 'rxjs'; import { DateOption } from 'src/common/enums/date-option'; @@ -11,6 +11,8 @@ import { import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; import { NotificationsGetResponseDto } from './dtos/response/notifications-get-response.dto'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; @Injectable() export class NotificationsService { @@ -48,6 +50,7 @@ export class NotificationsService { return pipe; } + @ExceptionMetadata([EXCEPTIONS.NOTIFICATION_CREATION_FAILED]) async emitAlarm({ userId, targetUserId, @@ -67,7 +70,7 @@ export class NotificationsService { await this.redisQueue.add(this.queueName, response); return response; } catch (e) { - throw new BadRequestException('대상을 찾을 수 없습니다.'); + throw new BlccuException('NOTIFICATION_CREATION_FAILED'); } } @@ -100,6 +103,7 @@ export class NotificationsService { }); } + @ExceptionMetadata([EXCEPTIONS.NOTIFICATION_NOT_FOUND]) async readNotification({ notificationId, targetUserId, @@ -111,7 +115,7 @@ export class NotificationsService { }, ); if (updateResult.affected < 1) { - throw new BadRequestException('알림을 찾을 수 없거나 권한이 없습니다.'); + throw new BlccuException('NOTIFICATION_NOT_FOUND'); } return await this.notificationsRepository.fetchOne({ notificationId, diff --git a/src/APIs/reports/docs/reports-docs.decorator.ts b/src/APIs/reports/docs/reports-docs.decorator.ts new file mode 100644 index 0000000..6942ded --- /dev/null +++ b/src/APIs/reports/docs/reports-docs.decorator.ts @@ -0,0 +1,57 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { HttpCode } from '@nestjs/common'; +import { ReportsController } from '../reports.controller'; +import { ReportDto } from '../dtos/common/report.dto'; +import { ReportsService } from '../reports.service'; + +type ReportsEndpoints = MethodNames; + +const ReportsDocsMap: Record = { + reportArticle: [ + ApiTags('게시글 API'), + ApiOperation({ + summary: '게시물 신고', + }), + ApiAuthResponse(), + HttpCode(201), + ApiCreatedResponse({ type: ReportDto }), + ApiResponseFromMetadata([ + { service: ReportsService, methodName: 'createReport' }, + ]), + ], + reportComment: [ + ApiTags('게시글 API'), + ApiOperation({ + summary: '댓글 신고', + }), + ApiAuthResponse(), + ApiCreatedResponse({ type: ReportDto }), + HttpCode(201), + ApiResponseFromMetadata([ + { service: ReportsService, methodName: 'createReport' }, + ]), + ], + fetchAll: [ + ApiTags('어드민 API'), + ApiTags('유저 API'), + ApiOperation({ + summary: '[어드민용] 신고 내역 조회', + }), + ApiAuthResponse(), + ApiOkResponse({ type: [ReportDto] }), + ApiResponseFromMetadata([ + { service: ReportsService, methodName: 'findReports' }, + ]), + ], +}; + +export const ReportsDocs = applyDocs(ReportsDocsMap); diff --git a/src/APIs/reports/reports.controller.ts b/src/APIs/reports/reports.controller.ts index cfa3dc0..c1c62fc 100644 --- a/src/APIs/reports/reports.controller.ts +++ b/src/APIs/reports/reports.controller.ts @@ -2,39 +2,26 @@ import { Body, Controller, Get, - HttpCode, Param, Post, Req, UseGuards, } from '@nestjs/common'; import { ReportsService } from './reports.service'; -import { - ApiCookieAuth, - ApiCreatedResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { Request } from 'express'; import { ReportTarget } from 'src/common/enums/report-target.enum'; import { ReportDto } from './dtos/common/report.dto'; import { ReportCreateRequestDto } from './dtos/request/report-create-request.dto'; +import { ReportsDocs } from './docs/reports-docs.decorator'; +@ReportsDocs @Controller('') export class ReportsController { constructor(private readonly reportsService: ReportsService) {} - @ApiTags('게시글 API') - @ApiOperation({ - summary: '게시물 신고', - }) - @ApiCookieAuth() - @ApiCreatedResponse({ type: ReportDto }) @UseGuards(AuthGuardV2) @Post('articles/:articleId/report') - @HttpCode(201) async reportArticle( @Req() req: Request, @Body() body: ReportCreateRequestDto, @@ -49,15 +36,8 @@ export class ReportsController { }); } - @ApiTags('게시글 API') - @ApiOperation({ - summary: '댓글 신고', - }) - @ApiCookieAuth() - @ApiCreatedResponse({ type: ReportDto }) @UseGuards(AuthGuardV2) @Post('articles/comments/:commentId/report') - @HttpCode(201) async reportComment( @Req() req: Request, @Body() body: ReportCreateRequestDto, @@ -72,13 +52,6 @@ export class ReportsController { }); } - @ApiTags('어드민 API') - @ApiTags('유저 API') - @ApiOperation({ - summary: '[어드민용] 신고 내역 조회', - }) - @ApiCookieAuth() - @ApiOkResponse({ type: [ReportDto] }) @UseGuards(AuthGuardV2) @Get('users/admin/reports') async fetchAll(@Req() req: Request): Promise { diff --git a/src/APIs/reports/reports.service.ts b/src/APIs/reports/reports.service.ts index 6f9bf2f..f701a29 100644 --- a/src/APIs/reports/reports.service.ts +++ b/src/APIs/reports/reports.service.ts @@ -1,8 +1,4 @@ -import { - BadRequestException, - ConflictException, - Injectable, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Report } from './entities/report.entity'; import { DataSource, Repository } from 'typeorm'; @@ -12,6 +8,9 @@ import { Comment } from '../comments/entities/comment.entity'; import { IReportsServiceCreateReport } from './interfaces/reports.service.interface'; import { ReportDto } from './dtos/common/report.dto'; import { UsersValidateService } from '../users/services/users-validate-service'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class ReportsService { @@ -22,6 +21,12 @@ export class ReportsService { private readonly db_dataSource: DataSource, ) {} + @ExceptionMetadata([ + EXCEPTIONS.ARTICLE_NOT_FOUND, + EXCEPTIONS.ALREADY_EXISTS, + EXCEPTIONS.COMMENT_NOT_FOUND, + EXCEPTIONS.VALIDATION_ERROR, + ]) async createReport( dto_createReport: IReportsServiceCreateReport, ): Promise { @@ -36,14 +41,12 @@ export class ReportsService { const articleData = await queryRunner.manager.findOne(Article, { where: { id: targetId }, }); - if (!articleData) - throw new BadRequestException('게시글이 존재하지 않습니다.'); + if (!articleData) throw new BlccuException('ARTICLE_NOT_FOUND'); const reportPost = await this.repo_reports.findOne({ where: { userId, articleId: targetId }, }); - if (reportPost) - throw new ConflictException('이미 신고한 게시물입니다.'); + if (reportPost) throw new BlccuException('ALREADY_EXISTS'); await queryRunner.manager.update(Article, articleData.id, { reportCount: () => 'report_count +1', @@ -62,15 +65,12 @@ export class ReportsService { const commentData = await queryRunner.manager.findOne(Comment, { where: { id: targetId }, }); - if (!commentData) - throw new BadRequestException('댓글이 존재하지 않습니다.'); + if (!commentData) throw new BlccuException('COMMENT_NOT_FOUND'); const reportComment = await this.repo_reports.findOne({ where: { userId, commentId: targetId }, }); - if (reportComment) - throw new ConflictException('이미 신고한 게시물입니다.'); - + if (reportComment) throw new BlccuException('ALREADY_EXISTS'); await queryRunner.manager.update(Comment, commentData.id, { reportCount: () => 'report_count +1', }); @@ -85,7 +85,7 @@ export class ReportsService { break; default: - throw new BadRequestException('잘못된 target입니다.'); + throw new BlccuException('VALIDATION_ERROR'); } // end of switch await queryRunner.commitTransaction(); return data; @@ -97,6 +97,9 @@ export class ReportsService { } } + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'adminCheck' }, + ]) async findReports({ userId }): Promise { await this.svc_userValidate.adminCheck({ userId }); const result = await this.repo_reports.find(); diff --git a/src/APIs/stickerBlocks/docs/stickerBlocks-docs.decorator.ts b/src/APIs/stickerBlocks/docs/stickerBlocks-docs.decorator.ts new file mode 100644 index 0000000..4d1d06a --- /dev/null +++ b/src/APIs/stickerBlocks/docs/stickerBlocks-docs.decorator.ts @@ -0,0 +1,26 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { StickerBlocksController } from '../stickerBlocks.controller'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { StickerBlockDto } from '../dtos/common/stickerBlock.dto'; +import { StickerBlocksService } from '../stickerBlocks.service'; +import { ApiCreatedResponse, ApiOperation } from '@nestjs/swagger'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +type StickerBlocksEndpoints = MethodNames; +const StickerBlocksDocsMap: Record = + { + createStickerBlocks: [ + ApiOperation({ + summary: '게시글 속 스티커 생성', + description: + '게시글과 스티커 아이디를 매핑한 스티커 블록을 생성한다. 세부 스타일 좌표값을 저장한다.', + }), + ApiAuthResponse(), + ApiCreatedResponse({ type: [StickerBlockDto] }), + ApiResponseFromMetadata([ + { service: StickerBlocksService, methodName: 'createStickerBlocks' }, + ]), + ], + }; + +export const StickerBlocksDocs = applyDocs(StickerBlocksDocsMap); diff --git a/src/APIs/stickerBlocks/stickerBlocks.controller.ts b/src/APIs/stickerBlocks/stickerBlocks.controller.ts index 414dcb6..c98ab45 100644 --- a/src/APIs/stickerBlocks/stickerBlocks.controller.ts +++ b/src/APIs/stickerBlocks/stickerBlocks.controller.ts @@ -9,20 +9,15 @@ import { StickerBlocksService } from './stickerBlocks.service'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { StickerBlockDto } from './dtos/common/stickerBlock.dto'; import { StickerBlocksCreateRequestDto } from './dtos/request/stickerBlocks-create-request.dto'; +import { StickerBlocksDocs } from './docs/stickerBlocks-docs.decorator'; +@StickerBlocksDocs @ApiTags('게시글 API') @Controller('articles/:articleId/stickers') export class StickerBlocksController { constructor(private readonly svc_stickerBlocks: StickerBlocksService) {} - @ApiOperation({ - summary: '게시글 속 스티커 생성', - description: - '게시글과 스티커 아이디를 매핑한 스티커 블록을 생성한다. 세부 스타일 좌표값을 저장한다.', - }) - @ApiCookieAuth() @UseGuards(AuthGuardV2) - @ApiCreatedResponse({ type: [StickerBlockDto] }) @Post('bulk') async createStickerBlocks( @Body() body: StickerBlocksCreateRequestDto, diff --git a/src/APIs/stickerBlocks/stickerBlocks.service.ts b/src/APIs/stickerBlocks/stickerBlocks.service.ts index b70fbaf..da31823 100644 --- a/src/APIs/stickerBlocks/stickerBlocks.service.ts +++ b/src/APIs/stickerBlocks/stickerBlocks.service.ts @@ -11,6 +11,7 @@ import { } from './interfaces/stickerBlocks.service.interface'; import { StickerBlockDto } from './dtos/common/stickerBlock.dto'; import { StickerBlocksWithStickerResponseDto } from './dtos/response/stickerBlocks-with-sticker-response.dto'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class StickerBlocksService { @@ -20,27 +21,29 @@ export class StickerBlocksService { private readonly repo_stickerBlocks: Repository, ) {} + @MergeExceptionMetadata([ + { service: StickersService, methodName: 'existCheck' }, + ]) async createStickerBlock({ stickerId, articleId, ...rest }: IStickerBlocksServiceCreateStickerBlock): Promise { - try { - // await this.svc_stickers.existCheck({ - // stickerId: stickerId, - // }); + await this.svc_stickers.existCheck({ + stickerId: stickerId, + }); - const data = await this.repo_stickerBlocks.save({ - ...rest, - articleId, - stickerId, - }); - return data; - } catch (e) { - throw e; - } + const data = await this.repo_stickerBlocks.save({ + ...rest, + articleId, + stickerId, + }); + return data; } + @MergeExceptionMetadata([ + { service: StickersService, methodName: 'existCheck' }, + ]) async createStickerBlocks({ stickerBlocks, articleId, @@ -49,11 +52,11 @@ export class StickerBlocksService { ...stickerBlock, articleId, })); - stickerBlocksToInsert.forEach(async (stickerBlock) => { + for (const stickerBlock of stickerBlocksToInsert) { await this.svc_stickers.existCheck({ stickerId: stickerBlock.stickerId, }); - }); + } return await this.repo_stickerBlocks.save(stickerBlocksToInsert); } @@ -68,6 +71,9 @@ export class StickerBlocksService { }); } + @MergeExceptionMetadata([ + { service: StickersService, methodName: 'deleteSticker' }, + ]) async deleteStickerBlocks({ userId, articleId, diff --git a/src/APIs/stickerCategories/docs/stickerCategories-docs.decorator.ts b/src/APIs/stickerCategories/docs/stickerCategories-docs.decorator.ts new file mode 100644 index 0000000..666e3ad --- /dev/null +++ b/src/APIs/stickerCategories/docs/stickerCategories-docs.decorator.ts @@ -0,0 +1,69 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { StickerCategoriesController } from '../stickerCategories.controller'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { StickerCategoriesService } from '../stickerCategories.service'; +import { StickerCategoryDto } from '../dtos/common/stickerCategory.dto'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { StickersCategoryFetchStickersResponseDto } from '../dtos/response/stickerCategories-fetch-stickers-response.dto'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { StickerCategoryMapperDto } from '../dtos/common/stickerCategoryMapper.dto'; + +type StickerCategoriesEndpoints = MethodNames; +const StickerCategoriesDocsMap: Record< + StickerCategoriesEndpoints, + MethodDecorator[] +> = { + fetchCategories: [ + ApiOperation({ + summary: '카테고리 fetchAll', + description: '카테고리를 모두 조회한다.', + }), + ApiOkResponse({ type: [StickerCategoryDto] }), + ], + fetchStickersByCategoryName: [ + ApiOperation({ + summary: '카테고리 id에 해당하는 스티커를 fetchAll', + description: '카테고리를 id로 찾고, 이에 매핑된 스티커들을 가져온다', + }), + ApiOkResponse({ type: [StickersCategoryFetchStickersResponseDto] }), + ApiResponseFromMetadata([ + { + service: StickerCategoriesService, + methodName: 'fetchStickersByCategoryId', + }, + ]), + ], + createCategory: [ + ApiTags('어드민 API'), + ApiOperation({ + summary: '[어드민용] 스티커 카테고리 생성', + description: '[어드민 전용] 스티커 카테고리를 만든다.', + }), + ApiOkResponse({ type: StickerCategoryDto }), + ApiAuthResponse(), + ApiResponseFromMetadata([ + { + service: StickerCategoriesService, + methodName: 'createCategory', + }, + ]), + ], + mapCategory: [ + ApiTags('어드민 API'), + ApiOperation({ + summary: '[어드민용] 스티커와 카테고리 매핑', + description: '[어드민 전용] 스티커에 카테고리를 매핑한다.', + }), + ApiAuthResponse(), + ApiOkResponse({ type: [StickerCategoryMapperDto] }), + ApiResponseFromMetadata([ + { + service: StickerCategoriesService, + methodName: 'mapCategory', + }, + ]), + ], +}; + +export const StickerCategoriesDocs = applyDocs(StickerCategoriesDocsMap); diff --git a/src/APIs/stickerCategories/stickerCategories.controller.ts b/src/APIs/stickerCategories/stickerCategories.controller.ts index 4968545..d198c43 100644 --- a/src/APIs/stickerCategories/stickerCategories.controller.ts +++ b/src/APIs/stickerCategories/stickerCategories.controller.ts @@ -8,12 +8,7 @@ import { UseGuards, } from '@nestjs/common'; import { StickerCategoriesService } from './stickerCategories.service'; -import { - ApiCookieAuth, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { StickerCategoryCreateRequestDto } from './dtos/request/stickerCategory-create-request.dto'; @@ -21,7 +16,9 @@ import { StickerCategoriesMapDto } from './dtos/request/stickerCategories-map-re import { StickerCategoryMapperDto } from './dtos/common/stickerCategoryMapper.dto'; import { StickerCategoryDto } from './dtos/common/stickerCategory.dto'; import { StickersCategoryFetchStickersResponseDto } from './dtos/response/stickerCategories-fetch-stickers-response.dto'; +import { StickerCategoriesDocs } from './docs/stickerCategories-docs.decorator'; +@StickerCategoriesDocs @ApiTags('스티커 API') @Controller() export class StickerCategoriesController { @@ -29,21 +26,11 @@ export class StickerCategoriesController { private readonly stickerCategoriesService: StickerCategoriesService, ) {} - @ApiOperation({ - summary: '카테고리 fetchAll', - description: '카테고리를 모두 조회한다.', - }) - @ApiOkResponse({ type: [StickerCategoryDto] }) @Get('stickers/categories') async fetchCategories(): Promise { return await this.stickerCategoriesService.fetchCategories(); } - @ApiOperation({ - summary: '카테고리 id에 해당하는 스티커를 fetchAll', - description: '카테고리를 id로 찾고, 이에 매핑된 스티커들을 가져온다', - }) - @ApiOkResponse({ type: [StickersCategoryFetchStickersResponseDto] }) @Get('stickers/categories/:stickerCategoryId') async fetchStickersByCategoryName( @Param('stickerCategoryId') stickerCategoryId: number, @@ -53,13 +40,6 @@ export class StickerCategoriesController { }); } - @ApiTags('어드민 API') - @ApiOperation({ - summary: '[어드민용] 스티커 카테고리 생성', - description: '[어드민 전용] 스티커 카테고리를 만든다.', - }) - @ApiOkResponse({ type: StickerCategoryDto }) - @ApiCookieAuth() @UseGuards(AuthGuardV2) @Post('users/admin/stickers/categories') async createCategory( @@ -73,13 +53,6 @@ export class StickerCategoriesController { }); } - @ApiTags('어드민 API') - @ApiOperation({ - summary: '[어드민용] 스티커와 카테고리 매핑', - description: '[어드민 전용] 스티커에 카테고리를 매핑한다.', - }) - @ApiCookieAuth() - @ApiOkResponse({ type: [StickerCategoryMapperDto] }) @UseGuards(AuthGuardV2) @Post('users/admin/stickers/map') async mapCategory( diff --git a/src/APIs/stickerCategories/stickerCategories.service.ts b/src/APIs/stickerCategories/stickerCategories.service.ts index 8e2bfb2..b81fb6f 100644 --- a/src/APIs/stickerCategories/stickerCategories.service.ts +++ b/src/APIs/stickerCategories/stickerCategories.service.ts @@ -1,8 +1,4 @@ -import { - ConflictException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Repository } from 'typeorm'; import { StickerCategory } from './entities/stickerCategory.entity'; import { InjectRepository } from '@nestjs/typeorm'; @@ -19,6 +15,9 @@ import { UsersValidateService } from '../users/services/users-validate-service'; import { StickerCategoryDto } from './dtos/common/stickerCategory.dto'; import { StickerCategoryMapperDto } from './dtos/common/stickerCategoryMapper.dto'; import { StickersCategoryFetchStickersResponseDto } from './dtos/response/stickerCategories-fetch-stickers-response.dto'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class StickerCategoriesService { @@ -44,19 +43,24 @@ export class StickerCategoriesService { where: { id: stickerCategoryId }, }); } + + @ExceptionMetadata([EXCEPTIONS.CATEGORY_CONFLICT]) async existCheckByName({ name, }: IStickerCategoriesServiceName): Promise { const data = await this.findCategoryByName({ name }); - if (data) throw new ConflictException('동명의 카테고리가 존재합니다.'); + if (data) throw new BlccuException('CATEGORY_CONFLICT'); } + + @ExceptionMetadata([EXCEPTIONS.STICKER_CATEGORY_NOT_FOUND]) async existCheckById({ stickerCategoryId, }: IStickerCategoriesServiceId): Promise { const data = await this.findCategoryById({ stickerCategoryId }); - if (!data) throw new NotFoundException('스티커 카테고리가 없습니다.'); + if (!data) throw new BlccuException('STICKER_CATEGORY_NOT_FOUND'); } + @ExceptionMetadata([EXCEPTIONS.MAPPING_CONFLICT]) async existCheckMapper({ stickerId, stickerCategoryId, @@ -64,12 +68,16 @@ export class StickerCategoriesService { const data = await this.repo_stickerCategoryMappers.findOne({ where: { stickerId, stickerCategoryId }, }); - if (data) throw new ConflictException('이미 매핑 된 카테고리입니다.'); + if (data) throw new BlccuException('MAPPING_CONFLICT'); } async fetchCategories(): Promise { return await this.repo_stickerCategories.find(); } + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'adminCheck' }, + { service: StickerCategoriesService, methodName: 'existCheckByName' }, + ]) async createCategory({ userId, name, @@ -79,6 +87,12 @@ export class StickerCategoriesService { return await this.repo_stickerCategories.save({ name }); } + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'adminCheck' }, + { service: StickersService, methodName: 'existCheck' }, + { service: StickerCategoriesService, methodName: 'existCheckMapper' }, + { service: StickerCategoriesService, methodName: 'existCheckById' }, + ]) async mapCategory({ userId, maps, @@ -99,6 +113,9 @@ export class StickerCategoriesService { return await this.repo_stickerCategoryMappers.save(maps); } + @MergeExceptionMetadata([ + { service: StickerCategoriesService, methodName: 'existCheckById' }, + ]) async fetchStickersByCategoryId({ stickerCategoryId, }: IStickerCategoriesServiceId): Promise< diff --git a/src/APIs/stickers/docs/stickers-docs.decorator.ts b/src/APIs/stickers/docs/stickers-docs.decorator.ts new file mode 100644 index 0000000..53d80a2 --- /dev/null +++ b/src/APIs/stickers/docs/stickers-docs.decorator.ts @@ -0,0 +1,112 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { + ApiBody, + ApiConsumes, + ApiCreatedResponse, + ApiNoContentResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { StickersController } from '../stickers.controller'; +import { StickerDto } from '../dtos/common/sticker.dto'; +import { ImageUploadRequestDto } from '@/modules/images/dtos/image-upload-request.dto'; +import { HttpCode } from '@nestjs/common'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { StickersService } from '../stickers.service'; + +type StickersEndpoints = MethodNames; + +const StickersDocsMap: Record = { + createPrivateSticker: [ + ApiOperation({ + summary: '[유저용] 개인 스티커를 업로드한다.', + description: '개인만 조회 가능한 유저용 스티커를 업로드한다.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + description: '업로드 할 파일', + type: ImageUploadRequestDto, + }), + ApiCreatedResponse({ + description: '이미지 서버에 파일 업로드 완료', + type: StickerDto, + }), + HttpCode(201), + ApiAuthResponse(), + ApiResponseFromMetadata([ + { service: StickersService, methodName: 'createPrivateSticker' }, + ]), + ], + fetchPrivateStickers: [ + ApiOperation({ + summary: '재사용 가능한 private 스티커를 fetch한다.', + description: + '본인이 만든 재사용 가능한 스티커들을 fetch한다. toggle이 우선적으로 이루어져야함.', + }), + ApiOkResponse({ description: '조회 성공', type: [StickerDto] }), + ApiAuthResponse(), + HttpCode(200), + ApiResponseFromMetadata([ + { service: StickersService, methodName: 'findUserStickers' }, + ]), + ], + patchSticker: [ + ApiOperation({ + summary: '스티커의 image_url 혹은 재사용 여부를 설정한다.', + description: + '본인이 만든 스티커를 patch한다. image_url 변경 시 기존의 이미지는 s3에서 제거된다.', + }), + ApiAuthResponse(), + ApiOkResponse({ type: StickerDto }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: StickersService, methodName: 'updateSticker' }, + ]), + ], + fetchPublicStickers: [ + ApiOperation({ + summary: 'public 스티커를 fetch한다.', + description: '블꾸가 만든 스티커들을 fetch한다.', + }), + ApiOkResponse({ description: '조회 성공', type: [StickerDto] }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: StickersService, methodName: 'findPublicStickers' }, + ]), + ], + createPublicSticker: [ + ApiTags('어드민 API'), + ApiOperation({ + summary: '[어드민용] 공용 스티커를 업로드한다.', + description: + '블꾸에서 제작한 스티커를 업로드한다. 어드민 권한이 있는 유저 전용. 카테고리와 매핑을 해주어야 조회 가능.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + description: '업로드 할 파일', + type: ImageUploadRequestDto, + }), + ApiCreatedResponse({ + description: '이미지 서버에 파일 업로드 완료', + type: StickerDto, + }), + ApiAuthResponse(), + HttpCode(201), + ApiResponseFromMetadata([ + { service: StickersService, methodName: 'createPublicSticker' }, + ]), + ], + deleteSticker: [ + ApiOperation({ summary: '스티커 삭제', description: '스티커를 삭제한다.' }), + ApiAuthResponse(), + ApiNoContentResponse({ description: '삭제 성공' }), + ApiResponseFromMetadata([ + { service: StickersService, methodName: 'deleteSticker' }, + ]), + ], +}; + +export const StickersDocs = applyDocs(StickersDocsMap); diff --git a/src/APIs/stickers/stickers.controller.ts b/src/APIs/stickers/stickers.controller.ts index eb9f403..d6c9d5f 100644 --- a/src/APIs/stickers/stickers.controller.ts +++ b/src/APIs/stickers/stickers.controller.ts @@ -3,7 +3,6 @@ import { Controller, Delete, Get, - HttpCode, Param, Patch, Post, @@ -13,147 +12,82 @@ import { UseInterceptors, } from '@nestjs/common'; import { StickersService } from './stickers.service'; -import { - ApiBody, - ApiConsumes, - ApiCookieAuth, - ApiCreatedResponse, - ApiNoContentResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { FileInterceptor } from '@nestjs/platform-express'; import { Request } from 'express'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; -import { ImageUploadRequestDto } from 'src/modules/images/dtos/image-upload-request.dto'; import { StickerDto } from './dtos/common/sticker.dto'; import { StickerPatchRequestDto } from './dtos/request/sticker-patch-request.dto'; +import { StickersDocs } from './docs/stickers-docs.decorator'; +@StickersDocs @ApiTags('스티커 API') @Controller() export class StickersController { - constructor(private readonly stickersService: StickersService) {} + constructor(private readonly svc_stickers: StickersService) {} - @ApiOperation({ - summary: '[유저용] 개인 스티커를 업로드한다.', - description: '개인만 조회 가능한 유저용 스티커를 업로드한다.', - }) - @ApiConsumes('multipart/form-data') - @ApiBody({ - description: '업로드 할 파일', - type: ImageUploadRequestDto, - }) - @ApiCreatedResponse({ - description: '이미지 서버에 파일 업로드 완료', - type: StickerDto, - }) @UseGuards(AuthGuardV2) - @ApiCookieAuth() @Post('stickers/private') @UseInterceptors(FileInterceptor('file')) - @HttpCode(201) async createPrivateSticker( @Req() req: Request, @UploadedFile() file: Express.Multer.File, ): Promise { const userId = req.user.userId; - return await this.stickersService.createPrivateSticker({ + return await this.svc_stickers.createPrivateSticker({ userId, file, }); } - @ApiOperation({ - summary: '재사용 가능한 private 스티커를 fetch한다.', - description: - '본인이 만든 재사용 가능한 스티커들을 fetch한다. toggle이 우선적으로 이루어져야함.', - }) - @ApiOkResponse({ description: '조회 성공', type: [StickerDto] }) @UseGuards(AuthGuardV2) - @ApiCookieAuth() - @HttpCode(200) @Get('stickers/private') async fetchPrivateStickers(@Req() req: Request): Promise { const userId = req.user.userId; - return await this.stickersService.findUserStickers({ userId }); + return await this.svc_stickers.findUserStickers({ userId }); } - @ApiOperation({ - summary: '스티커의 image_url 혹은 재사용 여부를 설정한다.', - description: - '본인이 만든 스티커를 patch한다. image_url 변경 시 기존의 이미지는 s3에서 제거된다.', - }) @Patch('stickers/:stickerId') @UseGuards(AuthGuardV2) - @ApiCookieAuth() - @ApiOkResponse({ type: StickerDto }) - @HttpCode(200) async patchSticker( @Req() req: Request, @Param('stickerId') stickerId: number, @Body() body: StickerPatchRequestDto, ): Promise { const userId = req.user.userId; - return await this.stickersService.updateSticker({ + return await this.svc_stickers.updateSticker({ userId, stickerId, ...body, }); } - @ApiOperation({ - summary: 'public 스티커를 fetch한다.', - description: '블꾸가 만든 스티커들을 fetch한다.', - }) - @ApiOkResponse({ description: '조회 성공', type: [StickerDto] }) @Get('stickers') - @HttpCode(200) async fetchPublicStickers(): Promise { - return await this.stickersService.findPublicStickers(); + return await this.svc_stickers.findPublicStickers(); } - @ApiTags('어드민 API') - @ApiOperation({ - summary: '[어드민용] 공용 스티커를 업로드한다.', - description: - '블꾸에서 제작한 스티커를 업로드한다. 어드민 권한이 있는 유저 전용. 카테고리와 매핑을 해주어야 조회 가능.', - }) - @ApiConsumes('multipart/form-data') - @ApiBody({ - description: '업로드 할 파일', - type: ImageUploadRequestDto, - }) - @ApiCreatedResponse({ - description: '이미지 서버에 파일 업로드 완료', - type: StickerDto, - }) @UseGuards(AuthGuardV2) - @ApiCookieAuth() @Post('users/admin/stickers') @UseInterceptors(FileInterceptor('file')) - @HttpCode(201) async createPublicSticker( @Req() req: Request, @UploadedFile() file: Express.Multer.File, ): Promise { const userId = req.user.userId; - return await this.stickersService.createPublicSticker({ + return await this.svc_stickers.createPublicSticker({ userId, file, }); } - @ApiOperation({ summary: '스티커 삭제', description: '스티커를 삭제한다.' }) @UseGuards(AuthGuardV2) - @ApiCookieAuth() - @ApiNoContentResponse({ description: '삭제 성공' }) @Delete('stickers/:stickerId') async deleteSticker( @Req() req: Request, @Param('stickerId') stickerId: number, ): Promise { const userId = req.user.userId; - return await this.stickersService.deleteSticker({ stickerId, userId }); + return await this.svc_stickers.deleteSticker({ stickerId, userId }); } } diff --git a/src/APIs/stickers/stickers.service.ts b/src/APIs/stickers/stickers.service.ts index 5034cc5..465e5a1 100644 --- a/src/APIs/stickers/stickers.service.ts +++ b/src/APIs/stickers/stickers.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Repository } from 'typeorm'; import { Sticker } from './entities/sticker.entity'; import { InjectRepository } from '@nestjs/typeorm'; @@ -12,6 +12,9 @@ import { } from './interfaces/stickers.service.interface'; import { ImagesService } from 'src/modules/images/images.service'; import { StickerDto } from './dtos/common/sticker.dto'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class StickersService { @@ -28,19 +31,16 @@ export class StickersService { return await this.repo_stickers.findOne({ where: { id: stickerId } }); } + @ExceptionMetadata([EXCEPTIONS.STICKER_NOT_FOUND]) async existCheck({ stickerId }: IStickersServiceId): Promise { - try { - const data = await this.findStickerById({ stickerId }); - if (!data) { - throw new NotFoundException('스티커를 찾을 수 없습니다.'); - } - - return data; - } catch (e) { - throw e; - } + const data = await this.findStickerById({ stickerId }); + if (!data) throw new BlccuException('STICKER_NOT_FOUND'); + return data; } + @MergeExceptionMetadata([ + { service: ImagesService, methodName: 'imageUpload' }, + ]) async createPrivateSticker({ userId, file, @@ -60,6 +60,10 @@ export class StickersService { const data = await this.repo_stickers.findOne({ where: { id } }); return data; } + + @MergeExceptionMetadata([ + { service: ImagesService, methodName: 'imageUpload' }, + ]) async createPublicSticker({ userId, file, @@ -95,6 +99,11 @@ export class StickersService { }); } + @MergeExceptionMetadata([ + { service: StickersService, methodName: 'existCheck' }, + { service: ImagesService, methodName: 'deleteImage' }, + ]) + @ExceptionMetadata([EXCEPTIONS.NOT_THE_OWNER]) async updateSticker({ imageUrl, isReusable, @@ -102,13 +111,8 @@ export class StickersService { stickerId, }: IStickersServiceUpdateSticker): Promise { try { - const sticker = await this.repo_stickers.findOne({ - where: { id: stickerId, userId }, - }); - if (!sticker) - throw new NotFoundException( - '스티커가 존재하지 않거나 제작자 본인이 아닙니다.', - ); + const sticker = await this.existCheck({ stickerId }); + if (sticker.userId != userId) throw new BlccuException('NOT_THE_OWNER'); if (isReusable) sticker.isReusable = isReusable; if (imageUrl) { await this.svc_images.deleteImage({ url: sticker.imageUrl }); @@ -121,21 +125,21 @@ export class StickersService { } } + @MergeExceptionMetadata([ + { service: StickersService, methodName: 'existCheck' }, + { service: ImagesService, methodName: 'deleteImage' }, + ]) + @ExceptionMetadata([EXCEPTIONS.NOT_THE_OWNER]) async deleteSticker({ stickerId, userId, }: IStickersServiceDeleteSticker): Promise { - const sticker = await this.repo_stickers.findOne({ - where: { id: stickerId, userId }, - }); - if (!sticker) - throw new NotFoundException( - '스티커가 존재하지 않거나 제작자 본인이 아닙니다.', - ); + const sticker = await this.existCheck({ stickerId }); + if (sticker.userId != userId) throw new BlccuException('NOT_THE_OWNER'); await this.svc_images.deleteImage({ url: sticker.imageUrl, }); - await this.repo_stickers.remove(sticker); + await this.repo_stickers.delete({ id: stickerId }); return; } } diff --git a/src/APIs/users/controllers/users-create.controller.ts b/src/APIs/users/controllers/users-create.controller.ts index 8bdb5a5..59ea0d3 100644 --- a/src/APIs/users/controllers/users-create.controller.ts +++ b/src/APIs/users/controllers/users-create.controller.ts @@ -1,7 +1,9 @@ import { Controller } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { UsersCreateService } from '../services/users-create.service'; +import { UsersCreateDocs } from '../docs/users-create-docs.decorator'; +@UsersCreateDocs @ApiTags('유저 API') @Controller('users') export class UsersCreateController { diff --git a/src/APIs/users/controllers/users-delete.controller.ts b/src/APIs/users/controllers/users-delete.controller.ts index c3ff32f..1ca364f 100644 --- a/src/APIs/users/controllers/users-delete.controller.ts +++ b/src/APIs/users/controllers/users-delete.controller.ts @@ -1,36 +1,18 @@ -import { - Body, - Controller, - Delete, - HttpCode, - Req, - Res, - UseGuards, -} from '@nestjs/common'; -import { - ApiCookieAuth, - ApiNoContentResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { Body, Controller, Delete, Req, Res, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { UsersDeleteService } from '../services/users-delete.service'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { Request, Response } from 'express'; import { UserDeleteRequestDto } from '../dtos/request/user-delete-request.dto'; +import { UsersDeleteDocs } from '../docs/users-delete-docs.decorator'; +@UsersDeleteDocs @ApiTags('유저 API') @Controller('users') export class UsersDeleteController { constructor(private readonly svc_usersDelete: UsersDeleteService) {} - @ApiOperation({ - summary: '회원 탈퇴(soft delete)', - description: '회원을 탈퇴하고 연동된 게시글과 댓글을 soft delete한다.', - }) - @ApiCookieAuth() @UseGuards(AuthGuardV2) - @ApiNoContentResponse() - @HttpCode(204) @Delete('me') async deleteUser( @Req() req: Request, diff --git a/src/APIs/users/controllers/users-read.controller.ts b/src/APIs/users/controllers/users-read.controller.ts index 5791eaf..8fb7518 100644 --- a/src/APIs/users/controllers/users-read.controller.ts +++ b/src/APIs/users/controllers/users-read.controller.ts @@ -1,37 +1,18 @@ -import { - Controller, - Get, - HttpCode, - Param, - Req, - UseGuards, -} from '@nestjs/common'; -import { - ApiCookieAuth, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { UsersReadService } from '../services/users-read.service'; import { UserFollowingResponseDto } from '../dtos/response/user-following-response.dto'; import { Request } from 'express'; import { UserDto } from '../dtos/common/user.dto'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; +import { UsersReadDocs } from '../docs/users-read-docs.decorator'; +@UsersReadDocs @ApiTags('유저 API') @Controller('users') export class UsersReadController { constructor(private readonly svc_usersRead: UsersReadService) {} - @ApiOperation({ - summary: '이름이 포함된 유저 검색', - description: '이름에 username이 포함된 유저를 검색한다.', - }) - @ApiOkResponse({ - description: '조회 성공', - type: [UserFollowingResponseDto], - }) - @HttpCode(200) @Get('username/:username') async getUsersByName( @Req() req: Request, @@ -41,37 +22,18 @@ export class UsersReadController { return await this.svc_usersRead.findUsersByName({ userId, username }); } - @ApiOperation({ - summary: '특정 유저 프로필 조회(id)', - description: 'id가 일치하는 유저 프로필을 조회한다.', - }) - @ApiOkResponse({ description: '조회 성공', type: UserDto }) - @HttpCode(200) @Get('profile/id/:userId') async getUserById(@Param('userId') userId: number): Promise { return await this.svc_usersRead.findUserById({ userId }); } - @ApiOperation({ - summary: '특정 유저 프로필 조회(handle)', - description: 'handle이 일치하는 유저 프로필을 조회한다.', - }) - @ApiOkResponse({ description: '조회 성공', type: UserDto }) - @HttpCode(200) @Get('profile/handle/:handle') async getUserByHandle(@Param('handle') handle: string): Promise { return await this.svc_usersRead.findUserByHandle({ handle }); } - @ApiOperation({ - summary: '로그인된 유저의 프로필 불러오기', - description: '로그인된 유저의 프로필을 불러온다.', - }) - @ApiCookieAuth() - @ApiOkResponse({ description: '불러오기 완료', type: UserDto }) @Get('me') @UseGuards(AuthGuardV2) - @HttpCode(200) async getMyProfile(@Req() req: Request): Promise { const userId = req.user.userId; return await this.svc_usersRead.findUserById({ userId }); diff --git a/src/APIs/users/controllers/users-update.controller.ts b/src/APIs/users/controllers/users-update.controller.ts index e1e8f9d..ec98439 100644 --- a/src/APIs/users/controllers/users-update.controller.ts +++ b/src/APIs/users/controllers/users-update.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, - HttpCode, Patch, Post, Req, @@ -9,37 +8,23 @@ import { UseGuards, UseInterceptors, } from '@nestjs/common'; -import { - ApiBody, - ApiConsumes, - ApiCookieAuth, - ApiCreatedResponse, - ApiOkResponse, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { UsersUpdateService } from '../services/users-update.service'; import { UserDto } from '../dtos/common/user.dto'; import { AuthGuardV2 } from 'src/common/guards/auth.guard'; import { UserPatchRequestDto } from '../dtos/request/user-patch-request.dto'; import { Request } from 'express'; -import { ImageUploadRequestDto } from 'src/modules/images/dtos/image-upload-request.dto'; import { ImageUploadResponseDto } from 'src/modules/images/dtos/image-upload-response.dto'; import { FileInterceptor } from '@nestjs/platform-express'; +import { UsersUpdateDocs } from '../docs/users-update-docs.decorator'; +@UsersUpdateDocs @ApiTags('유저 API') @Controller('users') export class UsersUpdateController { constructor(private readonly svc_usersUpdate: UsersUpdateService) {} - @ApiOperation({ - summary: '로그인된 유저의 이름이나 설명, 핸들을 변경', - description: '로그인된 유저의 이름이나 설명, 핸들, 혹은 모두를 변경한다.', - }) - @ApiOkResponse({ description: '변경 성공', type: UserDto }) - @ApiCookieAuth() @Patch('me') - @HttpCode(200) @UseGuards(AuthGuardV2) async patchUser( @Req() req: Request, @@ -55,52 +40,23 @@ export class UsersUpdateController { }); } - @ApiOperation({ - summary: '로그인된 유저의 프로필 이미지를 변경', - description: '스토리지에 프로필 사진을 업로드하고 변경한다.', - }) - @ApiConsumes('multipart/form-data') - @ApiBody({ - description: '업로드 할 파일', - type: ImageUploadRequestDto, - }) - @ApiCreatedResponse({ - description: '업로드 성공', - type: ImageUploadResponseDto, - }) @UseGuards(AuthGuardV2) - @ApiCookieAuth() @UseInterceptors(FileInterceptor('file')) - @HttpCode(201) @Post('me/profile-image') async postProfileImage( @Req() req: Request, @UploadedFile() file: Express.Multer.File, ): Promise { const userId = req.user.userId; + return await this.svc_usersUpdate.updateProfileImage({ userId, file, }); } - @ApiOperation({ - summary: '로그인된 유저의 배경 이미지를 변경', - description: '스토리지에 배경 사진을 업로드하고 변경한다.', - }) - @ApiConsumes('multipart/form-data') - @ApiBody({ - description: '업로드 할 파일', - type: ImageUploadRequestDto, - }) - @ApiCreatedResponse({ - description: '업로드 성공', - type: ImageUploadResponseDto, - }) @UseGuards(AuthGuardV2) - @ApiCookieAuth() @UseInterceptors(FileInterceptor('file')) - @HttpCode(201) @Post('me/background-image') async uploadBackgroundImage( @Req() req: Request, diff --git a/src/APIs/users/docs/users-create-docs.decorator.ts b/src/APIs/users/docs/users-create-docs.decorator.ts new file mode 100644 index 0000000..365f8c6 --- /dev/null +++ b/src/APIs/users/docs/users-create-docs.decorator.ts @@ -0,0 +1,12 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { ApiOperation } from '@nestjs/swagger'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { UsersCreateController } from '../controllers/users-create.controller'; + +type UsersCreateEndpoints = MethodNames; + +const UsersCreateDocsMap: Record = {}; + +export const UsersCreateDocs = applyDocs(UsersCreateDocsMap); diff --git a/src/APIs/users/docs/users-delete-docs.decorator.ts b/src/APIs/users/docs/users-delete-docs.decorator.ts new file mode 100644 index 0000000..796d7c7 --- /dev/null +++ b/src/APIs/users/docs/users-delete-docs.decorator.ts @@ -0,0 +1,27 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { ApiNoContentResponse, ApiOperation } from '@nestjs/swagger'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { UsersDeleteController } from '../controllers/users-delete.controller'; +import { UsersDeleteService } from '../services/users-delete.service'; +import { HttpCode } from '@nestjs/common'; + +type UsersDeleteEndpoints = MethodNames; + +const UsersDeleteDocsMap: Record = { + deleteUser: [ + ApiOperation({ + summary: '회원 탈퇴(soft delete)', + description: '회원을 탈퇴하고 연동된 게시글과 댓글을 soft delete한다.', + }), + ApiAuthResponse(), + HttpCode(204), + ApiNoContentResponse(), + ApiResponseFromMetadata([ + { service: UsersDeleteService, methodName: 'deleteUser' }, + ]), + ], +}; + +export const UsersDeleteDocs = applyDocs(UsersDeleteDocsMap); diff --git a/src/APIs/users/docs/users-read-docs.decorator.ts b/src/APIs/users/docs/users-read-docs.decorator.ts new file mode 100644 index 0000000..408d8e7 --- /dev/null +++ b/src/APIs/users/docs/users-read-docs.decorator.ts @@ -0,0 +1,65 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { UsersReadController } from '../controllers/users-read.controller'; +import { UserFollowingResponseDto } from '../dtos/response/user-following-response.dto'; +import { HttpCode } from '@nestjs/common'; +import { UsersReadService } from '../services/users-read.service'; +import { UserDto } from '../dtos/common/user.dto'; + +type UsersReadEndpoints = MethodNames; + +const UsersReadDocsMap: Record = { + getUsersByName: [ + ApiOperation({ + summary: '이름이 포함된 유저 검색', + description: '이름에 username이 포함된 유저를 검색한다.', + }), + ApiOkResponse({ + description: '조회 성공', + type: [UserFollowingResponseDto], + }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: UsersReadService, methodName: 'findUsersByName' }, + ]), + ], + getUserById: [ + ApiOperation({ + summary: '특정 유저 프로필 조회(id)', + description: 'id가 일치하는 유저 프로필을 조회한다.', + }), + ApiOkResponse({ description: '조회 성공', type: UserDto }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: UsersReadService, methodName: 'findUserById' }, + ]), + ], + getUserByHandle: [ + ApiOperation({ + summary: '특정 유저 프로필 조회(handle)', + description: 'handle이 일치하는 유저 프로필을 조회한다.', + }), + ApiOkResponse({ description: '조회 성공', type: UserDto }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: UsersReadService, methodName: 'findUserByHandle' }, + ]), + ], + getMyProfile: [ + ApiOperation({ + summary: '로그인된 유저의 프로필 불러오기', + description: '로그인된 유저의 프로필을 불러온다.', + }), + ApiAuthResponse(), + ApiOkResponse({ description: '불러오기 완료', type: UserDto }), + HttpCode(200), + ApiResponseFromMetadata([ + { service: UsersReadService, methodName: 'findUserById' }, + ]), + ], +}; + +export const UsersReadDocs = applyDocs(UsersReadDocsMap); diff --git a/src/APIs/users/docs/users-update-docs.decorator.ts b/src/APIs/users/docs/users-update-docs.decorator.ts new file mode 100644 index 0000000..0e0d92b --- /dev/null +++ b/src/APIs/users/docs/users-update-docs.decorator.ts @@ -0,0 +1,76 @@ +import { MethodNames } from '@/common/types/method'; +import { applyDocs } from '@/utils/docs.utils'; +import { + ApiBody, + ApiConsumes, + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { ApiAuthResponse } from '@/common/decorators/api-auth-response.dto'; +import { ApiResponseFromMetadata } from '@/common/decorators/api-response-from-metadata.decorator'; +import { UsersUpdateController } from '../controllers/users-update.controller'; +import { UserDto } from '../dtos/common/user.dto'; +import { HttpCode } from '@nestjs/common'; +import { UsersUpdateService } from '../services/users-update.service'; +import { ImageUploadRequestDto } from '@/modules/images/dtos/image-upload-request.dto'; +import { ImageUploadResponseDto } from '@/modules/images/dtos/image-upload-response.dto'; + +type UsersUpdateEndpoints = MethodNames; + +const UsersUpdateDocsMap: Record = { + patchUser: [ + ApiOperation({ + summary: '로그인된 유저의 이름이나 설명, 핸들을 변경', + description: '로그인된 유저의 이름이나 설명, 핸들, 혹은 모두를 변경한다.', + }), + ApiOkResponse({ description: '변경 성공', type: UserDto }), + ApiAuthResponse(), + HttpCode(200), + ApiResponseFromMetadata([ + { service: UsersUpdateService, methodName: 'updateUser' }, + ]), + ], + postProfileImage: [ + ApiOperation({ + summary: '로그인된 유저의 프로필 이미지를 변경', + description: '스토리지에 프로필 사진을 업로드하고 변경한다.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + description: '업로드 할 파일', + type: ImageUploadRequestDto, + }), + ApiCreatedResponse({ + description: '업로드 성공', + type: ImageUploadResponseDto, + }), + HttpCode(201), + ApiAuthResponse(), + ApiResponseFromMetadata([ + { service: UsersUpdateService, methodName: 'updateProfileImage' }, + ]), + ], + uploadBackgroundImage: [ + ApiOperation({ + summary: '로그인된 유저의 배경 이미지를 변경', + description: '스토리지에 배경 사진을 업로드하고 변경한다.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + description: '업로드 할 파일', + type: ImageUploadRequestDto, + }), + ApiCreatedResponse({ + description: '업로드 성공', + type: ImageUploadResponseDto, + }), + HttpCode(201), + ApiAuthResponse(), + ApiResponseFromMetadata([ + { service: UsersUpdateService, methodName: 'updateBackgroundImage' }, + ]), + ], +}; + +export const UsersUpdateDocs = applyDocs(UsersUpdateDocsMap); diff --git a/src/APIs/users/dtos/common/user.dto.ts b/src/APIs/users/dtos/common/user.dto.ts index 841395a..d4a8aea 100644 --- a/src/APIs/users/dtos/common/user.dto.ts +++ b/src/APIs/users/dtos/common/user.dto.ts @@ -1,5 +1,5 @@ import { OmitType } from '@nestjs/swagger'; -import { getUserFields } from 'src/utils/classUtils'; +import { getUserFields } from 'src/utils/class.utils'; import { User } from '../../entities/user.entity'; // exclude refreshtoken!! diff --git a/src/APIs/users/services/users-create.service.ts b/src/APIs/users/services/users-create.service.ts index 3eb03d4..3c01217 100644 --- a/src/APIs/users/services/users-create.service.ts +++ b/src/APIs/users/services/users-create.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { getUUID } from 'src/utils/uuidUtils'; +import { getUUID } from '@/utils/uuid.utils'; import { IUsersServiceCreate } from '../interfaces/users.service.interface'; import { UsersRepository } from '../users.repository'; diff --git a/src/APIs/users/services/users-delete.service.ts b/src/APIs/users/services/users-delete.service.ts index 660af37..0dae4d3 100644 --- a/src/APIs/users/services/users-delete.service.ts +++ b/src/APIs/users/services/users-delete.service.ts @@ -7,9 +7,11 @@ import { Notification } from 'src/APIs/notifications/entities/notification.entit import { Follow } from 'src/APIs/follows/entities/follow.entity'; import { User } from '../entities/user.entity'; import { Agreement } from 'src/APIs/agreements/entities/agreement.entity'; -import { getUUID } from 'src/utils/uuidUtils'; +import { getUUID } from '@/utils/uuid.utils'; import { Injectable } from '@nestjs/common'; import { IUsersServiceDelete } from '../interfaces/users.service.interface'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; +import { EXCEPTIONS } from '@/common/blccu-exception'; @Injectable() export class UsersDeleteService { @@ -17,6 +19,14 @@ export class UsersDeleteService { private readonly repo_users: UsersRepository, private readonly db_dataSource: DataSource, ) {} + + @ExceptionMetadata([ + EXCEPTIONS.QUERY_FAILED_ERROR, + EXCEPTIONS.ENTITY_NOT_FOUND_ERROR, + EXCEPTIONS.TRANSACTION_ALREADY_STARTED_ERROR, + EXCEPTIONS.TRANSACTION_NOT_STARTED_ERROR, + EXCEPTIONS.PESSIMISTIC_LOCK_TRANSACTION_REQUIRED_ERROR, + ]) async deleteUser({ userId, type, diff --git a/src/APIs/users/services/users-update.service.ts b/src/APIs/users/services/users-update.service.ts index 506713d..01c2dc1 100644 --- a/src/APIs/users/services/users-update.service.ts +++ b/src/APIs/users/services/users-update.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { UpdateResult } from 'typeorm'; import { User } from '../entities/user.entity'; import { UsersRepository } from '../users.repository'; @@ -7,6 +7,9 @@ import { UserDto } from '../dtos/common/user.dto'; import { ImageUploadResponseDto } from 'src/modules/images/dtos/image-upload-response.dto'; import { IUsersServiceImageUpload } from '../interfaces/users.service.interface'; import { ImagesService } from 'src/modules/images/images.service'; +import { MergeExceptionMetadata } from 'src/common/decorators/merge-exception-metadata.decorator'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; @Injectable() export class UsersUpdateService { @@ -16,18 +19,25 @@ export class UsersUpdateService { private readonly svc_images: ImagesService, ) {} - async activateUser({ userId }): Promise { - return await this.repo_users.update({ id: userId }, { dateDeleted: null }); - } - + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'existCheck' }, + ]) async setCurrentRefreshToken({ userId, currentRefreshToken }): Promise { - const user = await this.repo_users.findOne({ where: { id: userId } }); + const user = await this.svc_usersValidate.existCheck({ userId }); return await this.repo_users.save({ ...user, currentRefreshToken, }); } + async activateUser({ userId }): Promise { + return await this.repo_users.update({ id: userId }, { dateDeleted: null }); + } + + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'existCheck' }, + ]) + @ExceptionMetadata([EXCEPTIONS.UNIQUE_CONSTRAINT_VIOLATION]) async updateUser({ userId, handle, @@ -46,12 +56,15 @@ export class UsersUpdateService { const data = await this.repo_users.save(user); return data; } catch (e) { - throw new ConflictException( - 'username || handle 값이 Unique하지 않습니다.', - ); + throw new BlccuException('UNIQUE_CONSTRAINT_VIOLATION'); } } + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'existCheck' }, + { service: ImagesService, methodName: 'imageUpload' }, + { service: ImagesService, methodName: 'deleteImage' }, + ]) async updateProfileImage({ userId, file, @@ -70,6 +83,11 @@ export class UsersUpdateService { return { imageUrl }; } + @MergeExceptionMetadata([ + { service: UsersValidateService, methodName: 'existCheck' }, + { service: ImagesService, methodName: 'imageUpload' }, + { service: ImagesService, methodName: 'deleteImage' }, + ]) async updateBackgroundImage({ userId, file, diff --git a/src/APIs/users/services/users-validate-service.ts b/src/APIs/users/services/users-validate-service.ts index d51b963..f38ecf1 100644 --- a/src/APIs/users/services/users-validate-service.ts +++ b/src/APIs/users/services/users-validate-service.ts @@ -1,28 +1,29 @@ -import { - BadRequestException, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { UsersRepository } from '../users.repository'; import { UserDto } from '../dtos/common/user.dto'; +import { ExceptionMetadata } from 'src/common/decorators/exception-metadata.decorator'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; @Injectable() export class UsersValidateService { constructor(private readonly repo_users: UsersRepository) {} + + @ExceptionMetadata([EXCEPTIONS.USER_NOT_FOUND, EXCEPTIONS.NOT_AN_ADMIN]) async adminCheck({ userId }): Promise { const user = await this.repo_users.findOne({ where: { id: userId }, }); - if (!user) throw new BadRequestException('존재하지 않는 유저 입니다.'); - if (!user.isAdmin) throw new UnauthorizedException('어드민이 아닙니다.'); + if (!user) throw new BlccuException('USER_NOT_FOUND'); + if (!user.isAdmin) throw new BlccuException('NOT_AN_ADMIN'); return user; } + @ExceptionMetadata([EXCEPTIONS.USER_NOT_FOUND]) async existCheck({ userId }): Promise { const user = await this.repo_users.findOne({ where: { id: userId }, }); - if (!user) throw new BadRequestException('존재하지 않는 유저 입니다.'); + if (!user) throw new BlccuException('USER_NOT_FOUND'); return user; } } diff --git a/src/APIs/users/users.repository.ts b/src/APIs/users/users.repository.ts index f488107..6791f07 100644 --- a/src/APIs/users/users.repository.ts +++ b/src/APIs/users/users.repository.ts @@ -3,7 +3,7 @@ import { User } from './entities/user.entity'; import { DataSource, Repository } from 'typeorm'; import { Follow } from '../follows/entities/follow.entity'; import { plainToClass } from 'class-transformer'; -import { convertToCamelCase } from 'src/utils/classUtils'; +import { convertToCamelCase } from 'src/utils/class.utils'; import { UserFollowingResponseDto } from './dtos/response/user-following-response.dto'; import { USER_PRIMARY_RESPONSE_DTO_KEYS } from './dtos/response/user-primary-response.dto'; diff --git a/src/assets/errors.json b/src/assets/errors.json new file mode 100644 index 0000000..83d9327 --- /dev/null +++ b/src/assets/errors.json @@ -0,0 +1,321 @@ +{ + "VALIDATION_ERROR": { + "errorCode": 4000, + "statusCode": 400, + "name": "VALIDATION_ERROR", + "message": "올바르지 않은 입력 값입니다." + }, + "INVALID_PARENT_COMMENT_REQUEST": { + "errorCode": 4001, + "statusCode": 400, + "name": "INVALID_PARENT_COMMENT", + "message": "부모 댓글에 대한 입력 값이 유효하지 않습니다. 부모 댓글이 존재하는 값인 지, 루트 댓글인 지 확인하십시오." + }, + "INVALID_ARTICLE_REQUEST": { + "errorCode": 4002, + "statusCode": 400, + "name": "INVALID_ARTICLE_REQUEST", + "message": "게시글 아이디가 부모 댓글이 작성된 게시글 아이디와 일치하지 않습니다." + }, + "EMAIL_NOT_IN_KOREA_DOMAIN": { + "errorCode": 4003, + "statusCode": 400, + "name": "EMAIL_NOT_IN_KOREA_DOMAIN", + "message": "korea.ac.kr 이메일이 아닙니다." + }, + "PASSWORD_INVALID_FORMAT": { + "errorCode": 4004, + "statusCode": 400, + "name": "PASSWORD_INVALID_FORMAT", + "message": "비밀번호는 6~16자의 영문 소문자와 숫자로만 입력해주세요." + }, + "CODE_NOT_CORRECT": { + "errorCode": 4005, + "statusCode": 400, + "name": "CODE_NOT_CORRECT", + "message": "인증 코드가 일치하지 않습니다." + }, + "CODE_EXPIRED": { + "errorCode": 4006, + "statusCode": 400, + "name": "CODE_EXPIRED", + "message": "인증 코드가 만료되었습니다. 다시 인증을 시도해주세요." + }, + "CODE_NOT_VALIDATED": { + "errorCode": 4007, + "statusCode": 400, + "name": "CODE_NOT_VALIDATED", + "message": "인증되지 않은 코드입니다." + }, + "CODE_VALIDATION_EXPIRED": { + "errorCode": 4008, + "statusCode": 400, + "name": "CODE_VALIDATION_EXPIRED", + "message": "인증 코드가 만료되었습니다. 다시 인증을 시도해주세요." + }, + + "LOGIN_FAILED": { + "errorCode": 4010, + "statusCode": 401, + "name": "LOGIN_FAILED", + "message": "이메일 또는 비밀번호가 일치하지 않습니다." + }, + "NOT_LOGGED_IN": { + "errorCode": 4010, + "statusCode": 401, + "name": "NOT_LOGGED_IN", + "message": "비로그인 상태입니다." + }, + "INVALID_ACCESS_TOKEN": { + "errorCode": 4012, + "statusCode": 401, + "name": "INVALID_ACCESS_TOKEN", + "message": "유효하지 않은 엑세스 토큰입니다." + }, + "INVALID_REFRESH_TOKEN": { + "errorCode": 4013, + "statusCode": 401, + "name": "INVALID_REFRESH_TOKEN", + "message": "유효하지 않은 리프레쉬 토큰입니다." + }, + "ACCESS_TOKEN_EXPIRED": { + "errorCode": 4014, + "statusCode": 401, + "name": "TOKEN_EXPIRED", + "message": "ACCESS TOKEN 만료. 리프레시를 시도해주세요" + }, + "NOT_AN_ADMIN": { + "errorCode": 4030, + "statusCode": 403, + "name": "NOT_AN_ADMIN", + "message": "어드민이 아닙니다." + }, + "NOT_THE_OWNER": { + "errorCode": 4031, + "statusCode": 403, + "name": "NOT_THE_OWNER", + "message": "본인이 아니므로 이 작업을 수행할 수 없습니다." + }, + "NOT_AUTHORIZED": { + "errorCode": 4032, + "statusCode": 403, + "name": "NOT_AUTHORIZED", + "message": "이 작업을 수행할 권한이 없습니다." + }, + "FORBIDDEN_ACCESS": { + "errorCode": 4033, + "statusCode": 403, + "name": "FORBIDDEN_ACCESS", + "message": "허용되지 않은 접근입니다." + }, + "USER_NOT_FOUND": { + "errorCode": 4040, + "statusCode": 404, + "name": "USER_NOT_FOUND", + "message": "사용자를 찾을 수 없습니다." + }, + "AGREEMENT_NOT_FOUND": { + "errorCode": 4041, + "statusCode": 404, + "name": "AGREEMENT_NOT_FOUND", + "message": "동의 내역을 찾을 수 없습니다." + }, + "ANNOUNCEMENT_NOT_FOUND": { + "errorCode": 4042, + "statusCode": 404, + "name": "ANNOUNCEMENT_NOT_FOUND", + "message": "공지사항을 찾을 수 없습니다." + }, + "ARTICLE_NOT_FOUND": { + "errorCode": 4043, + "statusCode": 404, + "name": "ARTICLE_NOT_FOUND", + "message": "게시글을 찾을 수 없습니다." + }, + "ARTICLE_CATEGORY_NOT_FOUND": { + "errorCode": 4044, + "statusCode": 404, + "name": "ARTICLE_CATEGORY_NOT_FOUND", + "message": "게시글 카테고리를 찾을 수 없습니다." + }, + "ARTICLE_BACKGROUND_NOT_FOUND": { + "errorCode": 4045, + "statusCode": 404, + "name": "ARTICLE_BACKGROUND_NOT_FOUND", + "message": "게시글 배경을 찾을 수 없습니다." + }, + "STICKER_NOT_FOUND": { + "errorCode": 4046, + "statusCode": 404, + "name": "STICKER_NOT_FOUND", + "message": "스티커를 찾을 수 없습니다." + }, + "STICKER_CATEGORY_NOT_FOUND": { + "errorCode": 4047, + "statusCode": 404, + "name": "STICKER_CATEGORY_NOT_FOUND", + "message": "스티커 카테고리를 찾을 수 없습니다." + }, + "FOLLOW_NOT_FOUND": { + "errorCode": 4048, + "statusCode": 404, + "name": "FOLLOW_NOT_FOUND", + "message": "팔로우 정보를 찾을 수 없습니다." + }, + "COMMENT_NOT_FOUND": { + "errorCode": 4049, + "statusCode": 404, + "name": "COMMENT_NOT_FOUND", + "message": "댓글을 찾을 수 없습니다." + }, + "LIKE_NOT_FOUND": { + "errorCode": 40410, + "statusCode": 404, + "name": "LIKE_NOT_FOUND", + "message": "좋아요를 찾을 수 없습니다." + }, + "REPORT_NOT_FOUND": { + "errorCode": 40411, + "statusCode": 404, + "name": "REPORT_NOT_FOUND", + "message": "신고 내역을 찾을 수 없습니다." + }, + "ENTITY_NOT_FOUND_ERROR": { + "errorCode": 40412, + "statusCode": 404, + "name": "ENTITY_NOT_FOUND_ERROR", + "message": "요청한 엔티티를 찾을 수 없습니다." + }, + "NOTIFICATION_NOT_FOUND": { + "errorCode": 40412, + "statusCode": 404, + "name": "NOTIFICATION_NOT_FOUND", + "message": "알림을 찾을 수 없습니다." + }, + "INVALID_PAGE_QUERY": { + "errorCode": 4060, + "statusCode": 406, + "name": "INVALID_PAGE_QUERY", + "message": "page 또는 pageSize 값이 잘못되었습니다. 자연수값이어야 합니다." + }, + "TODO_INVALID": { + "errorCode": 4061, + "statusCode": 406, + "name": "TODO_INVALID", + "message": "할." + }, + "CATEGORY_CONFLICT": { + "errorCode": 4090, + "statusCode": 409, + "name": "CATEGORY_CONFLICT", + "message": "동명의 카테고리가 존재합니다." + }, + "MAPPING_CONFLICT": { + "errorCode": 4091, + "statusCode": 409, + "name": "MAPPING_CONFLICT", + "message": "이미 매핑 된 값입니다." + }, + "ALREADY_EXISTS": { + "errorCode": 4092, + "statusCode": 409, + "name": "ALREADY_EXISTS", + "message": "이미 존재하는 값입니다." + }, + "SELF_ACTION_NOT_ALLOWED": { + "errorCode": 4093, + "statusCode": 409, + "name": "SELF_ACTION_NOT_ALLOWED", + "message": "자기 자신에게는 이 작업을 수행할 수 없습니다." + }, + "UNIQUE_CONSTRAINT_VIOLATION": { + "errorCode": 4094, + "statusCode": 409, + "name": "UNIQUE_CONSTRAINT_VIOLATION", + "message": "해당 필드 값이 유니크해야 합니다." + }, + "TOO_MANY_REQUESTS": { + "errorCode": 4290, + "statusCode": 429, + "name": "TOO_MANY_REQUESTS", + "message": "잠시 후 다시 시도해주세요." + }, + "INTERNAL_SERVER_ERROR": { + "errorCode": 5000, + "statusCode": 500, + "name": "INTERNAL_SERVER_ERROR", + "message": "Internal Server Error" + }, + "IMAGE_UPLOAD_TO_S3_ERROR": { + "errorCode": 5003, + "statusCode": 500, + "name": "IMAGE_UPLOAD_TO_S3_ERROR", + "message": "이미지를 S3로 업로드하는 중에 오류가 발생했습니다." + }, + "IMAGE_DELETE_FROM_S3_ERROR": { + "errorCode": 5002, + "statusCode": 500, + "name": "IMAGE_DELETE_FROM_S3_ERROR", + "message": "S3에서 객체를 삭제하는 중에 오류가 발생했습니다." + }, + "IMAGE_RESIZE_ERROR": { + "errorCode": 5004, + "statusCode": 500, + "name": "IMAGE_RESIZE_ERROR", + "message": "이미지 리사이징 중에 오류가 발생했습니다." + }, + "NOTIFICATION_CREATION_FAILED": { + "errorCode": 5005, + "statusCode": 500, + "name": "NOTIFICATION_CREATION_FAILED", + "message": "알림 생성에 실패했습니다." + }, + "QUERY_FAILED_ERROR": { + "errorCode": 5006, + "statusCode": 500, + "name": "QUERY_FAILED_ERROR", + "message": "데이터베이스 쿼리 실행 중 오류가 발생했습니다." + }, + "CANNOT_CREATE_ENTITY_ID_MAP_ERROR": { + "errorCode": 5007, + "statusCode": 500, + "name": "CANNOT_CREATE_ENTITY_ID_MAP_ERROR", + "message": "엔티티 ID 맵을 생성할 수 없습니다." + }, + "ENTITY_COLUMN_NOT_FOUND": { + "errorCode": 5008, + "statusCode": 500, + "name": "ENTITY_COLUMN_NOT_FOUND", + "message": "엔티티의 열을 찾을 수 없습니다." + }, + "OPTIMISTIC_LOCK_CAN_NOT_BE_USED_ERROR": { + "errorCode": 5009, + "statusCode": 500, + "name": "OPTIMISTIC_LOCK_CAN_NOT_BE_USED_ERROR", + "message": "낙관적 잠금을 사용할 수 없습니다." + }, + "PESSIMISTIC_LOCK_TRANSACTION_REQUIRED_ERROR": { + "errorCode": 50010, + "statusCode": 500, + "name": "PESSIMISTIC_LOCK_TRANSACTION_REQUIRED_ERROR", + "message": "비관적 잠금을 사용하려면 트랜잭션이 필요합니다." + }, + "TRANSACTION_ALREADY_STARTED_ERROR": { + "errorCode": 50011, + "statusCode": 500, + "name": "TRANSACTION_ALREADY_STARTED_ERROR", + "message": "이미 트랜잭션이 시작되었습니다." + }, + "TRANSACTION_NOT_STARTED_ERROR": { + "errorCode": 50012, + "statusCode": 500, + "name": "TRANSACTION_NOT_STARTED_ERROR", + "message": "트랜잭션이 시작되지 않았습니다." + }, + "NO_NEED_TO_RELEASE_ENTITY_MANAGER_ERROR": { + "errorCode": 50013, + "statusCode": 500, + "name": "NO_NEED_TO_RELEASE_ENTITY_MANAGER_ERROR", + "message": "EntityManager를 해제할 필요가 없습니다." + } +} diff --git a/src/common/blccu-exception.ts b/src/common/blccu-exception.ts new file mode 100644 index 0000000..bdc0ab6 --- /dev/null +++ b/src/common/blccu-exception.ts @@ -0,0 +1,29 @@ +import * as ERROR from '@/assets/errors.json'; +import { ExceptionData } from './interfaces/exception-data.interface'; +import { HttpException } from '@nestjs/common'; + +export type ExceptionNames = keyof typeof ERROR; + +export const EXCEPTIONS: { [key in ExceptionNames]: ExceptionData } = ERROR; + +export class BlccuHttpException extends HttpException { + statusCode: number; + errorCode: number; + constructor(name: ExceptionNames) { + const exception = EXCEPTIONS[name]; + super(exception.message, exception.statusCode); + + this.name = exception.name; + this.statusCode = exception.statusCode; + this.errorCode = exception.errorCode; + + // Capturing the stack trace keeps the reference to your error class + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +export function BlccuException(name: ExceptionNames) { + throw new BlccuHttpException(name); +} diff --git a/src/common/decorators/api-auth-response.dto.ts b/src/common/decorators/api-auth-response.dto.ts new file mode 100644 index 0000000..5cc5ba1 --- /dev/null +++ b/src/common/decorators/api-auth-response.dto.ts @@ -0,0 +1,8 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiCookieAuth } from '@nestjs/swagger'; + +export function ApiAuthResponse() { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + applyDecorators(ApiCookieAuth())(target, propertyKey, descriptor); + }; +} diff --git a/src/common/decorators/api-response-from-metadata.decorator.ts b/src/common/decorators/api-response-from-metadata.decorator.ts new file mode 100644 index 0000000..85bc852 --- /dev/null +++ b/src/common/decorators/api-response-from-metadata.decorator.ts @@ -0,0 +1,76 @@ +import { Reflector } from '@nestjs/core'; +import { ExceptionData } from '../interfaces/exception-data.interface'; +import { EXCEPTION_METADATA_KEY } from './exception-metadata.decorator'; +import { ApiResponse } from '@nestjs/swagger'; +import { applyDecorators } from '@nestjs/common'; + +export function ApiResponseFromMetadata( + methods: { service: any; methodName: string }[], +) { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + const reflector = new Reflector(); + let allExceptionMetadata: ExceptionData[] = []; + + methods.forEach(({ service, methodName }) => { + const method = service.prototype[methodName]; + const exceptionMetadata = reflector.get(EXCEPTION_METADATA_KEY, method); + + if (exceptionMetadata) { + if (Array.isArray(exceptionMetadata)) { + allExceptionMetadata = [ + ...allExceptionMetadata, + ...exceptionMetadata, + ]; + } else { + allExceptionMetadata.push(exceptionMetadata); + } + } + }); + + if (allExceptionMetadata.length) { + const mergedByStatus = mergeExceptionDataByStatus(allExceptionMetadata); + const apiResponses = mergedByStatus.map((metadata: ExceptionData) => + ApiResponse({ + status: metadata.statusCode, + description: metadata.message, + }), + ); + + applyDecorators(...apiResponses)(target, propertyKey, descriptor); + } + }; +} +function formatStack(stack, level = 0) { + return stack + .map((call, index) => { + level += 1; + const indent = ' '.repeat(level); + const prefix = index === 0 ? ' ' : '- Calls:'; + return `${indent}${prefix} ${call}`; + }) + .join('
'); +} + +function mergeExceptionDataByStatus( + exceptionDataArray: ExceptionData[], +): ExceptionData[] { + const mergedData: { [status: number]: ExceptionData } = {}; + + exceptionDataArray.forEach((data) => { + const formattedStack = formatStack(data.stack); + + const messageTemplate = `**${data.name}(${data.errorCode})**: ${data.message}
${formattedStack}`; + if (!mergedData[data.statusCode]) { + mergedData[data.statusCode] = { + statusCode: data.statusCode, + message: messageTemplate, + name: data.name, + errorCode: data.errorCode, + }; + } else { + mergedData[data.statusCode].message += `
${messageTemplate}`; + } + }); + + return Object.values(mergedData); +} diff --git a/src/common/decorators/exception-metadata.decorator.ts b/src/common/decorators/exception-metadata.decorator.ts new file mode 100644 index 0000000..0523673 --- /dev/null +++ b/src/common/decorators/exception-metadata.decorator.ts @@ -0,0 +1,34 @@ +import { SetMetadata } from '@nestjs/common'; +import { ExceptionData } from '../interfaces/exception-data.interface'; +import { Reflector } from '@nestjs/core'; + +export const EXCEPTION_METADATA_KEY = 'exceptionMetadata'; + +export function ExceptionMetadata(exceptionInfoArray: ExceptionData[]) { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + const reflector = new Reflector(); + const existingMetadata = + reflector.get(EXCEPTION_METADATA_KEY, target[propertyKey]) || []; + + // 메서드 이름과 클래스 이름을 예외 데이터에 추가 + const methodName = propertyKey; + const className = target.constructor.name; + const stack = `${className}.${methodName}`; + const newExceptionInfoArray = exceptionInfoArray.map((exceptionInfo) => ({ + ...exceptionInfo, + methodName, + className, + stack: [...(exceptionInfo.stack || []), stack], + })); + + const newMetadata = Array.isArray(existingMetadata) + ? [...existingMetadata, ...newExceptionInfoArray] + : [existingMetadata, ...newExceptionInfoArray]; + + SetMetadata(EXCEPTION_METADATA_KEY, newMetadata)( + target, + propertyKey, + descriptor, + ); + }; +} diff --git a/src/common/decorators/merge-exception-metadata.decorator.ts b/src/common/decorators/merge-exception-metadata.decorator.ts new file mode 100644 index 0000000..6cd0f95 --- /dev/null +++ b/src/common/decorators/merge-exception-metadata.decorator.ts @@ -0,0 +1,55 @@ +import { Reflector } from '@nestjs/core'; +import { ExceptionData } from '../interfaces/exception-data.interface'; +import { EXCEPTION_METADATA_KEY } from './exception-metadata.decorator'; +import { SetMetadata } from '@nestjs/common'; + +export function MergeExceptionMetadata( + methods: { service: any; methodName: string }[], +) { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + const reflector = new Reflector(); + let mergedMetadata: ExceptionData[] = []; + + methods.forEach(({ service, methodName }) => { + const method = service.prototype[methodName]; + const existingExceptionMetadata = reflector.get( + EXCEPTION_METADATA_KEY, + method, + ); + const stack = `${target.constructor.name}.${propertyKey}`; + if (Array.isArray(existingExceptionMetadata)) { + mergedMetadata = [ + ...mergedMetadata, + ...existingExceptionMetadata.map((metadata) => ({ + ...metadata, + stack: [stack, ...(metadata.stack || [])], + })), + ]; + } else if (existingExceptionMetadata) { + mergedMetadata.push({ + ...existingExceptionMetadata, + stack: [stack, ...(existingExceptionMetadata.stack || [])], + }); + } + }); + + const targetExceptionMetadata = reflector.get( + EXCEPTION_METADATA_KEY, + target[propertyKey], + ); + + if (Array.isArray(targetExceptionMetadata)) { + mergedMetadata = [...mergedMetadata, ...targetExceptionMetadata]; + } else if (targetExceptionMetadata) { + mergedMetadata.push(targetExceptionMetadata); + } + + if (mergedMetadata.length) { + SetMetadata(EXCEPTION_METADATA_KEY, mergedMetadata)( + target, + propertyKey, + descriptor, + ); + } + }; +} diff --git a/src/common/filter/http-exception.filter.ts b/src/common/filter/http-exception.filter.ts index 5e18e7c..5f6e23f 100644 --- a/src/common/filter/http-exception.filter.ts +++ b/src/common/filter/http-exception.filter.ts @@ -1,50 +1,67 @@ import { ExceptionFilter, - HttpException, Catch, ArgumentsHost, + HttpException, } from '@nestjs/common'; -import { ValidationError } from 'class-validator'; +import { Response } from 'express'; +import { BlccuHttpException, EXCEPTIONS } from '../blccu-exception'; +import { getTypeOrmError } from '@/utils/orm.utils'; -@Catch(HttpException) +@Catch() export class HttpExceptionFilter implements ExceptionFilter { - catch(exception: HttpException, host: ArgumentsHost) { + catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - const status = exception.getStatus(); - const exceptionResponse = exception.getResponse(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const ormError = getTypeOrmError(exception); + let status = EXCEPTIONS.INTERNAL_SERVER_ERROR.statusCode; let message: string | object; + let errorCode = EXCEPTIONS.INTERNAL_SERVER_ERROR.errorCode; + let name = EXCEPTIONS.INTERNAL_SERVER_ERROR.name; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse: any = exception.getResponse(); + message = exceptionResponse?.message || exception.message; - if ( - typeof exceptionResponse === 'object' && - (exceptionResponse as any).message - ) { - const responseMessage = (exceptionResponse as any).message; - if ( - Array.isArray(responseMessage) && - responseMessage[0] instanceof ValidationError - ) { - message = responseMessage - .map((error: ValidationError) => { - return `${error.property} has wrong value ${error.value}, ${Object.values(error.constraints).join(', ')}`; - }) - .join('; '); - } else { - message = responseMessage; + // class validator에서 발생한 에러의 경우(배열로 나옴) + if (Array.isArray(message)) { + errorCode = EXCEPTIONS.VALIDATION_ERROR.errorCode; + name = EXCEPTIONS.VALIDATION_ERROR.name; } + + // 직접 발생시킨 예외의 경우 + if (exception instanceof BlccuHttpException) { + errorCode = exception.errorCode; + name = exception.name; + } + // orm 관련 에러의 경우 + } else if (ormError) { + status = ormError.statusCode; + message = (exception as Error).message || ormError.message; + errorCode = ormError.errorCode; + name = ormError.name; + // 이외 모든 에러 } else { - message = exception.message; + message = + (exception as Error).message || + EXCEPTIONS.INTERNAL_SERVER_ERROR.message; } + const stack = (exception as Error).stack; + console.log('========='); - console.log('예외 코드: ' + status); - console.log('예외 내용: ', message); + console.log(`예외 이름: ${name}`); + console.log(`예외 코드: ${errorCode}`); + console.log(`예외 내용: ${message}`); + console.log(`호출 스택: ${stack}`); console.log('========='); response.status(status).json({ + name, + errorCode, message, timestamp: new Date().toISOString(), path: request.url, diff --git a/src/common/guards/auth.guard.ts b/src/common/guards/auth.guard.ts index 316ec18..5a7d0b7 100644 --- a/src/common/guards/auth.guard.ts +++ b/src/common/guards/auth.guard.ts @@ -1,17 +1,13 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { BlccuException } from '../blccu-exception'; @Injectable() export class AuthGuardV2 implements CanActivate { constructor(private readonly reflector: Reflector) {} public canActivate(context: ExecutionContext): boolean { const req = context.switchToHttp().getRequest(); - if (!req.user.userId) throw new UnauthorizedException('권한이 없습니다'); + if (!req.user.userId) throw new BlccuException('NOT_LOGGED_IN'); return true; } } diff --git a/src/common/interfaces/exception-data.interface.ts b/src/common/interfaces/exception-data.interface.ts new file mode 100644 index 0000000..28dfe2a --- /dev/null +++ b/src/common/interfaces/exception-data.interface.ts @@ -0,0 +1,7 @@ +export interface ExceptionData { + statusCode: number; + errorCode: number; + message: string; + name: string; + stack?: string[]; +} diff --git a/src/common/types/method.ts b/src/common/types/method.ts new file mode 100644 index 0000000..40bcf3d --- /dev/null +++ b/src/common/types/method.ts @@ -0,0 +1,3 @@ +export type MethodNames = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; +}[keyof T]; diff --git a/src/modules/aws/aws.service.ts b/src/modules/aws/aws.service.ts index c22a2e1..9729c03 100644 --- a/src/modules/aws/aws.service.ts +++ b/src/modules/aws/aws.service.ts @@ -7,6 +7,8 @@ import { S3Client, } from '@aws-sdk/client-s3'; import sharp from 'sharp'; +import { BlccuException, EXCEPTIONS } from '@/common/blccu-exception'; +import { ExceptionMetadata } from '@/common/decorators/exception-metadata.decorator'; @Injectable() export class AwsService { @@ -24,20 +26,26 @@ export class AwsService { }); } + @ExceptionMetadata([EXCEPTIONS.IMAGE_UPLOAD_TO_S3_ERROR]) async imageUploadToS3Buffer(fileName: string, file: Buffer, ext: string) { - const resizedImageBuffer = await this.resizeImage(file, 800); + try { + const resizedImageBuffer = await this.resizeImage(file, 800); - const command = new PutObjectCommand({ - Bucket: this.configService.get('AWS_S3_BUCKET_NAME'), // S3 버킷 이름 - Key: fileName, // 업로드될 파일의 이름 - Body: resizedImageBuffer, // 업로드할 파일 - ContentType: `image/${ext}`, // 파일 타입, - }); - await this.s3Client.send(command); - // return `https://s3.${process.env.AWS_REGION}.amazonaws.com/${process.env.AWS_S3_BUCKET_NAME}/${fileName}`; - return `https://${this.configService.get('CLOUDFRONT_DOMAIN_NAME')}/${fileName}`; + const command = new PutObjectCommand({ + Bucket: this.configService.get('AWS_S3_BUCKET_NAME'), // S3 버킷 이름 + Key: fileName, // 업로드될 파일의 이름 + Body: resizedImageBuffer, // 업로드할 파일 + ContentType: `image/${ext}`, // 파일 타입, + }); + await this.s3Client.send(command); + // return `https://s3.${process.env.AWS_REGION}.amazonaws.com/${process.env.AWS_S3_BUCKET_NAME}/${fileName}`; + return `https://${this.configService.get('CLOUDFRONT_DOMAIN_NAME')}/${fileName}`; + } catch (error) { + this.logger.error('Error uploading image to S3', error.stack); + throw new BlccuException('IMAGE_UPLOAD_TO_S3_ERROR'); + } } - + @ExceptionMetadata([EXCEPTIONS.IMAGE_DELETE_FROM_S3_ERROR]) async deleteImageFromS3({ url }) { try { const fileNameRegex = /\/([^\/]+)\.[^.]+$/; @@ -49,38 +57,50 @@ export class AwsService { }; const command = new DeleteObjectCommand(deleteParams); return await this.s3Client.send(command); - } catch (e) { - this.logger.error('Error deleting object from S3', e.stack); + } catch (error) { + this.logger.error('Error deleting object from S3', error.stack); + throw new BlccuException('IMAGE_DELETE_FROM_S3_ERROR'); } } + @ExceptionMetadata([EXCEPTIONS.IMAGE_UPLOAD_TO_S3_ERROR]) async imageUploadToS3( fileName: string, // 업로드될 파일의 이름 file: Express.Multer.File, // 업로드할 파일 ext: string, // 파일 확장자 resize: number, // 리사이징 크기 ) { - const resizedImageBuffer = await this.resizeImage(file.buffer, resize); - // AWS S3에 이미지 업로드 명령을 생성합니다. 파일 이름, 파일 버퍼, 파일 접근 권한, 파일 타입 등을 설정합니다. - const command = new PutObjectCommand({ - Bucket: this.configService.get('AWS_S3_BUCKET_NAME'), // S3 버킷 이름 - Key: fileName, // 업로드될 파일의 이름 - Body: resizedImageBuffer, // 업로드할 파일 - ContentType: `image/${ext}`, // 파일 타입 - }); + try { + const resizedImageBuffer = await this.resizeImage(file.buffer, resize); + // AWS S3에 이미지 업로드 명령을 생성합니다. 파일 이름, 파일 버퍼, 파일 접근 권한, 파일 타입 등을 설정합니다. + const command = new PutObjectCommand({ + Bucket: this.configService.get('AWS_S3_BUCKET_NAME'), // S3 버킷 이름 + Key: fileName, // 업로드될 파일의 이름 + Body: resizedImageBuffer, // 업로드할 파일 + ContentType: `image/${ext}`, // 파일 타입 + }); - // 생성된 명령을 S3 클라이언트에 전달하여 이미지 업로드를 수행합니다. - await this.s3Client.send(command); + // 생성된 명령을 S3 클라이언트에 전달하여 이미지 업로드를 수행합니다. + await this.s3Client.send(command); - // 업로드된 이미지의 URL을 반환합니다. - // return `https://s3.${process.env.AWS_REGION}.amazonaws.com/${process.env.AWS_S3_BUCKET_NAME}/${fileName}`; - return `https://${this.configService.get('CLOUDFRONT_DOMAIN_NAME')}/${fileName}`; + // 업로드된 이미지의 URL을 반환합니다. + return `https://${this.configService.get('CLOUDFRONT_DOMAIN_NAME')}/${fileName}`; + } catch (error) { + this.logger.error('Error uploading image to S3', error.stack); + throw new BlccuException('IMAGE_UPLOAD_TO_S3_ERROR'); + } } + @ExceptionMetadata([EXCEPTIONS.IMAGE_RESIZE_ERROR]) async resizeImage(buffer: Buffer, width: number) { - const resizedImageBuffer = await sharp(buffer, { failOnError: false }) - .resize({ width, withoutEnlargement: true }) - .toBuffer(); - return resizedImageBuffer; + try { + const resizedImageBuffer = await sharp(buffer, { failOnError: false }) + .resize({ width, withoutEnlargement: true }) + .toBuffer(); + return resizedImageBuffer; + } catch (error) { + this.logger.error('Error resizing image', error.stack); + throw new BlccuException('IMAGE_RESIZE_ERROR'); + } } } diff --git a/src/modules/images/images.service.ts b/src/modules/images/images.service.ts index 30b2098..a1b226a 100644 --- a/src/modules/images/images.service.ts +++ b/src/modules/images/images.service.ts @@ -4,13 +4,17 @@ import { IImagesServiceUploadImage, } from './interfaces/images.service.interface'; import { ImageUploadResponseDto } from './dtos/image-upload-response.dto'; -import { getUUID } from 'src/utils/uuidUtils'; +import { getUUID } from '@/utils/uuid.utils'; import { AwsService } from '../aws/aws.service'; +import { MergeExceptionMetadata } from '@/common/decorators/merge-exception-metadata.decorator'; @Injectable() export class ImagesService { constructor(private readonly svc_aws: AwsService) {} + @MergeExceptionMetadata([ + { service: AwsService, methodName: 'imageUploadToS3' }, + ]) async imageUpload({ file, resize, @@ -26,6 +30,9 @@ export class ImagesService { return { imageUrl }; } + @MergeExceptionMetadata([ + { service: AwsService, methodName: 'deleteImageFromS3' }, + ]) async deleteImage({ url }: IImagesServiceDeleteImage): Promise { await this.svc_aws.deleteImageFromS3({ url }); } diff --git a/src/utils/classUtils.ts b/src/utils/class.utils.ts similarity index 100% rename from src/utils/classUtils.ts rename to src/utils/class.utils.ts diff --git a/src/utils/dateUtils.ts b/src/utils/date.utils.ts similarity index 100% rename from src/utils/dateUtils.ts rename to src/utils/date.utils.ts diff --git a/src/utils/docs.utils.ts b/src/utils/docs.utils.ts new file mode 100644 index 0000000..86a365d --- /dev/null +++ b/src/utils/docs.utils.ts @@ -0,0 +1,16 @@ +export function applyDocs(decoratorMap: Record) { + return function (target: any) { + for (const key in decoratorMap) { + const methodDecorators = decoratorMap[key as keyof typeof decoratorMap]; + + const descriptor = Object.getOwnPropertyDescriptor(target.prototype, key); + if (descriptor) { + for (const decorator of methodDecorators) { + decorator(target.prototype, key, descriptor); + } + Object.defineProperty(target.prototype, key, descriptor); + } + } + return target; + }; +} diff --git a/src/utils/orm.utils.ts b/src/utils/orm.utils.ts new file mode 100644 index 0000000..52966f7 --- /dev/null +++ b/src/utils/orm.utils.ts @@ -0,0 +1,39 @@ +import { + QueryFailedError, + EntityNotFoundError, + CannotCreateEntityIdMapError, + OptimisticLockCanNotBeUsedError, + PessimisticLockTransactionRequiredError, + TransactionAlreadyStartedError, + TransactionNotStartedError, + NoNeedToReleaseEntityManagerError, +} from 'typeorm'; +import { EXCEPTIONS } from '../common/blccu-exception'; + +export function getTypeOrmError(exception: any) { + if (exception instanceof QueryFailedError) { + return EXCEPTIONS.QUERY_FAILED_ERROR; + } + if (exception instanceof EntityNotFoundError) { + return EXCEPTIONS.ENTITY_NOT_FOUND_ERROR; + } + if (exception instanceof CannotCreateEntityIdMapError) { + return EXCEPTIONS.CANNOT_CREATE_ENTITY_ID_MAP_ERROR; + } + if (exception instanceof OptimisticLockCanNotBeUsedError) { + return EXCEPTIONS.OPTIMISTIC_LOCK_CAN_NOT_BE_USED_ERROR; + } + if (exception instanceof PessimisticLockTransactionRequiredError) { + return EXCEPTIONS.PESSIMISTIC_LOCK_TRANSACTION_REQUIRED_ERROR; + } + if (exception instanceof TransactionAlreadyStartedError) { + return EXCEPTIONS.TRANSACTION_ALREADY_STARTED_ERROR; + } + if (exception instanceof TransactionNotStartedError) { + return EXCEPTIONS.TRANSACTION_NOT_STARTED_ERROR; + } + if (exception instanceof NoNeedToReleaseEntityManagerError) { + return EXCEPTIONS.NO_NEED_TO_RELEASE_ENTITY_MANAGER_ERROR; + } + return null; +} diff --git a/src/utils/uuidUtils.ts b/src/utils/uuid.utils.ts similarity index 100% rename from src/utils/uuidUtils.ts rename to src/utils/uuid.utils.ts diff --git a/tsconfig.json b/tsconfig.json index 5485d9d..9c5c66b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,10 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, - "esModuleInterop": true + "esModuleInterop": true, + "resolveJsonModule": true, + "paths": { + "@/*": ["src/*"] + } } }