diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 039c3919..129e590e 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -75,6 +75,7 @@ jobs: echo "REDIS_HOST=${{ secrets.REDIS_HOST }}" >> .env.test echo "REDIS_PORT=${{ secrets.REDIS_PORT }}" >> .env.test echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env.test + echo "DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }}" >> .env.test cat .env.test @@ -112,6 +113,7 @@ jobs: echo "REDIS_HOST=${{ secrets.REDIS_HOST }}" >> .env.prod echo "REDIS_PORT=${{ secrets.REDIS_PORT }}" >> .env.prod echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env.prod + echo "DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }}" >> .env.prod cat .env.prod diff --git a/package-lock.json b/package-lock.json index 78076d85..87096edf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "class-validator": "^0.14.1", "cross-env": "^7.0.3", "deepl-node": "^1.13.0", + "discord-webhook-node": "^1.1.8", "express-basic-auth": "^1.2.1", "multer": "^1.4.5-lts.1", "multer-s3": "^3.0.1", @@ -5909,13 +5910,14 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.4.tgz", - "integrity": "sha512-y52q1MxhbHaT3vAgWd08RgiYon0lJgtTa8U6g6gV0KI0IygwZhDQFJVxnrRDUdxQGIP5CKHmfQu3sk9gTNFoEA==", + "version": "10.4.8", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.8.tgz", + "integrity": "sha512-bDz6wQD9LzGeK6uAAFv9l9AbrpyPwHStNObL8J95HBAXJesOblVlQMBAhdfci1YVMQUfOc36qq0IpRSa1II9Mg==", + "license": "MIT", "dependencies": { "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.21.0", + "express": "4.21.1", "multer": "1.4.4-lts.1", "tslib": "2.7.0" }, @@ -10556,9 +10558,10 @@ "dev": true }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -10662,9 +10665,10 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -10874,6 +10878,30 @@ "node": ">=8" } }, + "node_modules/discord-webhook-node": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/discord-webhook-node/-/discord-webhook-node-1.1.8.tgz", + "integrity": "sha512-3u0rrwywwYGc6HrgYirN/9gkBYqmdpvReyQjapoXARAHi0P0fIyf3W5tS5i3U3cc7e44E+e7dIHYUeec7yWaug==", + "license": "MIT", + "dependencies": { + "form-data": "^3.0.0", + "node-fetch": "^2.6.0" + } + }, + "node_modules/discord-webhook-node/node_modules/form-data": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.2.tgz", + "integrity": "sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -11370,16 +11398,17 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", diff --git a/package.json b/package.json index af8492b2..3d9eaeae 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "class-validator": "^0.14.1", "cross-env": "^7.0.3", "deepl-node": "^1.13.0", + "discord-webhook-node": "^1.1.8", "express-basic-auth": "^1.2.1", "multer": "^1.4.5-lts.1", "multer-s3": "^3.0.1", diff --git a/src/app.module.ts b/src/app.module.ts index 2661eb45..991fecbb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,6 +25,7 @@ import { AttendanceCheckModule } from './attendance-check/attendance-check.modul import { APP_FILTER } from '@nestjs/core'; import { UnhandledExceptionFilter } from './common/filter/unhandled-exception.filter'; import { KukeyExceptionFilter } from './common/filter/kukey-exception.filter'; +import { BannerModule } from './home/banner/banner.module'; console.log(`.env.${process.env.NODE_ENV}`); @@ -75,6 +76,7 @@ console.log(`.env.${process.env.NODE_ENV}`); CalendarModule, ReportModule, AttendanceCheckModule, + BannerModule, ], controllers: [AppController], providers: [ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 5daee1ea..86ff7c3b 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -25,6 +25,7 @@ import { ChangePasswordResponseDto } from './dto/change-password-response.dto'; import { SendTempPasswordResponseDto } from './dto/send-temporary-password.dto'; import { EntityManager } from 'typeorm'; import { throwKukeyException } from 'src/utils/exception.util'; +import { Webhook } from 'discord-webhook-node'; @Injectable() export class AuthService { @@ -254,6 +255,21 @@ export class AuthService { user, ); + const discordUrl = await this.configService.get('DISCORD_WEBHOOK_URL'); + const hook = new Webhook(discordUrl); + const totalUsers = await this.userService.getTotalUsersCount(); + + await hook + .send( + 'The ' + + totalUsers + + 'th user has signed up for KU-KEY! (' + + studentNumber + + ')', + ) + .then(() => console.log('Sent new user request to discord')) + .catch((err) => console.log(err.message)); + return new SignUpResponseDto(true, studentNumber); } @@ -343,6 +359,11 @@ export class AuthService { email: string, ): Promise { const user = await this.userService.findUserByEmail(email); + + if (!user) { + throwKukeyException('USER_NOT_FOUND'); + } + const tempPassword = this.generateRandomString(10); const isUpdated = await this.userService.updatePassword( diff --git a/src/common/constant/club-count.constant.ts b/src/common/constant/club-count.constant.ts new file mode 100644 index 00000000..6a4d4f16 --- /dev/null +++ b/src/common/constant/club-count.constant.ts @@ -0,0 +1 @@ +export const CLUB_COUNT = 5; diff --git a/src/community/comment/comment.controller.ts b/src/community/comment/comment.controller.ts index b2eb9917..239f8dc8 100644 --- a/src/community/comment/comment.controller.ts +++ b/src/community/comment/comment.controller.ts @@ -20,11 +20,6 @@ import { GetCommentResponseDto } from './dto/get-comment.dto'; import { UpdateCommentRequestDto } from './dto/update-comment.dto'; import { DeleteCommentResponseDto } from './dto/delete-comment.dto'; import { LikeCommentResponseDto } from './dto/like-comment.dto'; -import { - CreateReportRequestDto, - CreateReportResponseDto, -} from '../report/dto/create-report.dto'; -import { ReportService } from '../report/report.service'; import { CursorPageOptionsDto } from 'src/common/dto/CursorPageOptions.dto'; import { GetMyCommentListResponseDto } from './dto/get-myComment-list.dto'; import { TransactionInterceptor } from 'src/common/interceptors/transaction.interceptor'; @@ -39,10 +34,7 @@ import { CommentDocs } from 'src/decorators/docs/comment.decorator'; @ApiBearerAuth('accessToken') @CommentDocs export class CommentController { - constructor( - private readonly commentService: CommentService, - private readonly reportService: ReportService, - ) {} + constructor(private readonly commentService: CommentService) {} @Get('/my') async getMyCommentList( @@ -112,22 +104,4 @@ export class CommentController { commentId, ); } - - @Post('/:commentId/report') - async reportComment( - @User() user: AuthorizedUserDto, - @Param('commentId') commentId: number, - @Body() body: CreateReportRequestDto, - ): Promise { - const comment = await this.commentService.getComment(commentId); - if (!comment) { - throwKukeyException('COMMENT_NOT_FOUND'); - } - return await this.reportService.createReport( - user.id, - body.reason, - comment.postId, - commentId, - ); - } } diff --git a/src/community/comment/comment.module.ts b/src/community/comment/comment.module.ts index 25adcf55..b157f256 100644 --- a/src/community/comment/comment.module.ts +++ b/src/community/comment/comment.module.ts @@ -8,7 +8,7 @@ import { PostModule } from '../post/post.module'; import { CommentLikeEntity } from 'src/entities/comment-like.entity'; import { CommentAnonymousNumberEntity } from 'src/entities/comment-anonymous-number.entity'; import { NoticeModule } from 'src/notice/notice.module'; -import { ReportModule } from '../report/report.module'; +import { UserModule } from 'src/user/user.module'; @Module({ imports: [ @@ -19,9 +19,10 @@ import { ReportModule } from '../report/report.module'; ]), PostModule, NoticeModule, - ReportModule, + UserModule, ], controllers: [CommentController], providers: [CommentService, CommentRepository], + exports: [CommentService], }) export class CommentModule {} diff --git a/src/community/comment/comment.service.ts b/src/community/comment/comment.service.ts index cf248be0..5eb97a2d 100644 --- a/src/community/comment/comment.service.ts +++ b/src/community/comment/comment.service.ts @@ -19,6 +19,7 @@ import { CursorPageOptionsDto } from 'src/common/dto/CursorPageOptions.dto'; import { CursorPageMetaResponseDto } from 'src/common/dto/CursorPageResponse.dto'; import { GetMyCommentListResponseDto } from './dto/get-myComment-list.dto'; import { throwKukeyException } from 'src/utils/exception.util'; +import { UserBanService } from 'src/user/user-ban.service'; @Injectable() export class CommentService { @@ -28,6 +29,7 @@ export class CommentService { @InjectRepository(CommentAnonymousNumberEntity) private readonly commentAnonymousNumberRepository: Repository, private readonly noticeService: NoticeService, + private readonly userBanService: UserBanService, ) {} async getMyCommentList( @@ -62,6 +64,10 @@ export class CommentService { requestDto: CreateCommentRequestDto, parentCommentId?: number, ) { + if (await this.userBanService.checkUserBan(user.id)) { + throwKukeyException('USER_BANNED'); + } + const post = await this.postService.isExistingPostId(postId); if (!post) { throwKukeyException('POST_NOT_FOUND'); @@ -171,6 +177,10 @@ export class CommentService { commentId: number, requestDto: UpdateCommentRequestDto, ): Promise { + if (await this.userBanService.checkUserBan(user.id)) { + throwKukeyException('USER_BANNED'); + } + const comment = await this.commentRepository.getCommentByCommentId(commentId); if (!comment) { @@ -218,13 +228,20 @@ export class CommentService { if (!comment) { throwKukeyException('COMMENT_NOT_FOUND'); } - if (comment.userId !== user.id) { - throwKukeyException('COMMENT_OWNERSHIP_REQUIRED'); - } - const post = await this.postService.isExistingPostId(comment.postId); - if (Number(post.boardId) === 2) { - throwKukeyException('COMMENT_IN_QUESTION_BOARD'); + + this.checkDeleteAuthority(comment, post, user); + + if (post) { + const updateResult = await transactionManager.decrement( + PostEntity, + { id: comment.postId }, + 'commentCount', + 1, + ); + if (!updateResult.affected) { + throwKukeyException('POST_UPDATE_FAILED'); + } } const deleteResult = await transactionManager.softRemove( @@ -235,19 +252,26 @@ export class CommentService { throwKukeyException('COMMENT_DELETE_FAILED'); } - const updateResult = await transactionManager.decrement( - PostEntity, - { id: comment.postId }, - 'commentCount', - 1, - ); - if (!updateResult.affected) { - throwKukeyException('POST_UPDATE_FAILED'); - } - return new DeleteCommentResponseDto(true); } + private checkDeleteAuthority( + comment: CommentEntity, + post: PostEntity, + user: AuthorizedUserDto, + ) { + if (user.id !== -1) { + if (comment.userId !== user.id) { + throwKukeyException('COMMENT_OWNERSHIP_REQUIRED'); + } + if (post) { + if (Number(post.boardId) === 2) { + throwKukeyException('COMMENT_IN_QUESTION_BOARD'); + } + } + } + } + async likeComment( tranasactionManager: EntityManager, user: AuthorizedUserDto, diff --git a/src/community/post/post.controller.ts b/src/community/post/post.controller.ts index 2d46accd..35cab2c8 100644 --- a/src/community/post/post.controller.ts +++ b/src/community/post/post.controller.ts @@ -35,11 +35,6 @@ import { ReactPostRequestDto, ReactPostResponseDto, } from './dto/react-post.dto'; -import { - CreateReportRequestDto, - CreateReportResponseDto, -} from '../report/dto/create-report.dto'; -import { ReportService } from '../report/report.service'; import { TransactionInterceptor } from 'src/common/interceptors/transaction.interceptor'; import { TransactionManager } from 'src/decorators/manager.decorator'; import { EntityManager } from 'typeorm'; @@ -52,10 +47,7 @@ import { PostDocs } from 'src/decorators/docs/post.decorator'; @PostDocs @ApiBearerAuth('accessToken') export class PostController { - constructor( - private readonly postService: PostService, - private readonly reportService: ReportService, - ) {} + constructor(private readonly postService: PostService) {} @Get() async getPostList( @@ -158,11 +150,13 @@ export class PostController { } @Delete('/:postId') + @UseInterceptors(TransactionInterceptor) async deletePost( + @TransactionManager() transactionManager: EntityManager, @User() user: AuthorizedUserDto, @Param('postId') postId: number, ): Promise { - return await this.postService.deletePost(user, postId); + return await this.postService.deletePost(transactionManager, user, postId); } @Post('/:postId/scrap') @@ -190,16 +184,4 @@ export class PostController { body, ); } - - @Post('/:postId/report') - async reportPost( - @User() user: AuthorizedUserDto, - @Param('postId') postId: number, - @Body() body: CreateReportRequestDto, - ): Promise { - if (!(await this.postService.isExistingPostId(postId))) { - throwKukeyException('POST_NOT_FOUND'); - } - return await this.reportService.createReport(user.id, body.reason, postId); - } } diff --git a/src/community/post/post.module.ts b/src/community/post/post.module.ts index 5941280b..57257245 100644 --- a/src/community/post/post.module.ts +++ b/src/community/post/post.module.ts @@ -11,7 +11,6 @@ import { CommonModule } from 'src/common/common.module'; import { PostScrapRepository } from './post-scrap.repository'; import { PostScrapEntity } from 'src/entities/post-scrap.entity'; import { PostReactionEntity } from 'src/entities/post-reaction.entity'; -import { ReportModule } from '../report/report.module'; import { UserModule } from 'src/user/user.module'; import { NoticeModule } from 'src/notice/notice.module'; @@ -25,7 +24,6 @@ import { NoticeModule } from 'src/notice/notice.module'; ]), BoardModule, CommonModule, - ReportModule, UserModule, NoticeModule, ], diff --git a/src/community/post/post.service.ts b/src/community/post/post.service.ts index e2e7fc00..b2369bc4 100644 --- a/src/community/post/post.service.ts +++ b/src/community/post/post.service.ts @@ -36,6 +36,7 @@ import { PostPreview, PostPreviewWithBoardName } from './dto/post-preview.dto'; import { InjectRepository } from '@nestjs/typeorm'; import { PointService } from 'src/user/point.service'; import { throwKukeyException } from 'src/utils/exception.util'; +import { UserBanService } from 'src/user/user-ban.service'; @Injectable() export class PostService { @@ -48,6 +49,7 @@ export class PostService { private readonly fileService: FileService, private readonly pointService: PointService, private readonly noticeService: NoticeService, + private readonly userBanService: UserBanService, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, ) {} @@ -131,6 +133,10 @@ export class PostService { images: Array, requestDto: CreatePostRequestDto, ): Promise { + if (await this.userBanService.checkUserBan(user.id)) { + throwKukeyException('USER_BANNED'); + } + for (const image of images) { if (!this.fileService.imagefilter(image)) { throwKukeyException('NOT_IMAGE_FILE'); @@ -195,6 +201,10 @@ export class PostService { images: Array, requestDto: UpdatePostRequestDto, ): Promise { + if (await this.userBanService.checkUserBan(user.id)) { + throwKukeyException('USER_BANNED'); + } + const post = await this.postRepository.getPostByPostId(postId); if (!post) { throwKukeyException('POST_NOT_FOUND'); @@ -271,26 +281,32 @@ export class PostService { } async deletePost( + transactionManager: EntityManager, user: AuthorizedUserDto, postId: number, ): Promise { - const post = await this.postRepository.getPostByPostId(postId); + const post = await transactionManager.findOne(PostEntity, { + where: { id: postId }, + relations: [ + 'postImages', + // 댓글에 대해 신고가 들어왔을때 대비해서 삭제x + // 'comments.commentLikes', + 'postScraps', + 'postReactions', + 'commentAnonymousNumbers', + ], + }); if (!post) { throwKukeyException('POST_NOT_FOUND'); } - if (post.userId !== user.id) { - throwKukeyException('POST_OWNERSHIP_REQUIRED'); - } - if (post.boardId == 2 && post.commentCount > 0) { - throwKukeyException('POST_IN_QUESTION_BOARD'); - } + this.checkDeleteAuthority(post, user); for (const image of post.postImages) { await this.fileService.deleteFile(image.imgDir); } - const isDeleted = await this.postRepository.deletePost(postId); + const isDeleted = await transactionManager.softRemove(post); if (!isDeleted) { throwKukeyException('POST_DELETE_FAILED'); } @@ -298,6 +314,17 @@ export class PostService { return new DeletePostResponseDto(true); } + private checkDeleteAuthority(post: PostEntity, user: AuthorizedUserDto) { + if (user.id !== -1) { + if (post.userId !== user.id) { + throwKukeyException('POST_OWNERSHIP_REQUIRED'); + } + if (post.boardId == 2 && post.commentCount > 0) { + throwKukeyException('POST_IN_QUESTION_BOARD'); + } + } + } + async scrapPost( transactionManager: EntityManager, user: AuthorizedUserDto, diff --git a/src/community/report/dto/accept-report.dto.ts b/src/community/report/dto/accept-report.dto.ts new file mode 100644 index 00000000..0ce7fab5 --- /dev/null +++ b/src/community/report/dto/accept-report.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsNumber } from 'class-validator'; + +export class AcceptReportRequestDto { + @ApiProperty({ description: '정지 일 수' }) + @IsNotEmpty() + @IsNumber() + banDays: number; +} diff --git a/src/community/report/dto/create-report.dto.ts b/src/community/report/dto/create-report.dto.ts index c9f53240..d1233bf5 100644 --- a/src/community/report/dto/create-report.dto.ts +++ b/src/community/report/dto/create-report.dto.ts @@ -1,10 +1,22 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; export class CreateReportRequestDto { + @ApiProperty({ description: '게시글 ID' }) @IsNotEmpty() - @IsString() + @IsNumber() + postId: number; + + @ApiPropertyOptional({ + description: '댓글 ID (댓글 신고일 경우 존재, 게시글 신고일 경우 null', + }) + @IsOptional() + @IsNumber() + commentId?: number; + @ApiProperty({ description: '신고 사유' }) + @IsNotEmpty() + @IsString() reason: string; } diff --git a/src/community/report/report.controller.ts b/src/community/report/report.controller.ts index 930a4b3f..d096e62d 100644 --- a/src/community/report/report.controller.ts +++ b/src/community/report/report.controller.ts @@ -1,18 +1,37 @@ -import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Post, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; import { ReportService } from './report.service'; import { GetReportListResponseDto } from './dto/get-report-list.dto'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { GetReportResponseDto } from './dto/get-report.dto'; import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; import { RolesGuard } from 'src/auth/guards/role.guard'; import { Roles } from 'src/decorators/roles.decorator'; import { Role } from 'src/enums/role.enum'; import { ReportDocs } from 'src/decorators/docs/report.decorator'; +import { AcceptReportRequestDto } from 'src/community/report/dto/accept-report.dto'; +import { TransactionInterceptor } from 'src/common/interceptors/transaction.interceptor'; +import { TransactionManager } from 'src/decorators/manager.decorator'; +import { EntityManager } from 'typeorm'; +import { User } from 'src/decorators/user.decorator'; +import { AuthorizedUserDto } from 'src/auth/dto/authorized-user-dto'; +import { + CreateReportRequestDto, + CreateReportResponseDto, +} from 'src/community/report/dto/create-report.dto'; @Controller('report') @UseGuards(JwtAuthGuard, RolesGuard) @ApiTags('report') @ReportDocs +@ApiBearerAuth('accessToken') export class ReportController { constructor(private readonly reportService: ReportService) {} @@ -23,10 +42,40 @@ export class ReportController { } @Roles(Role.admin) - @Post('/:reportId') + @Get('/:reportId') async getReport( @Param('reportId') reportId: number, ): Promise { return await this.reportService.getReport(reportId); } + + @Post() + async createReport( + @User() user: AuthorizedUserDto, + @Body() body: CreateReportRequestDto, + ): Promise { + return await this.reportService.createReport( + user.id, + body.reason, + body.postId, + body.commentId, + ); + } + + @Roles(Role.admin) + @Post('/:reportId/accept') + @UseInterceptors(TransactionInterceptor) + async acceptReport( + @TransactionManager() transactionManager: EntityManager, + @Param('reportId') reportId: number, + @Body() body: AcceptReportRequestDto, + ): Promise { + await this.reportService.acceptReport(transactionManager, reportId, body); + } + + @Roles(Role.admin) + @Post('/:reportId/reject') + async rejectReport(@Param('reportId') reportId: number): Promise { + await this.reportService.rejectReport(reportId); + } } diff --git a/src/community/report/report.module.ts b/src/community/report/report.module.ts index 4f4c82d8..fd19054e 100644 --- a/src/community/report/report.module.ts +++ b/src/community/report/report.module.ts @@ -6,9 +6,17 @@ import { ReportRepository } from './report.repository'; import { ReportController } from './report.controller'; import { CommonModule } from 'src/common/common.module'; import { UserModule } from 'src/user/user.module'; +import { PostModule } from 'src/community/post/post.module'; +import { CommentModule } from 'src/community/comment/comment.module'; @Module({ - imports: [TypeOrmModule.forFeature([ReportEntity]), CommonModule, UserModule], + imports: [ + TypeOrmModule.forFeature([ReportEntity]), + CommonModule, + UserModule, + PostModule, + CommentModule, + ], controllers: [ReportController], providers: [ReportService, ReportRepository], exports: [ReportService], diff --git a/src/community/report/report.repository.ts b/src/community/report/report.repository.ts index 9568d5e9..69bfd4a9 100644 --- a/src/community/report/report.repository.ts +++ b/src/community/report/report.repository.ts @@ -42,6 +42,9 @@ export class ReportRepository extends Repository { async getReportList(): Promise { const reports = await this.find({ + where: { + isAccepted: false, + }, order: { createdAt: 'DESC', }, @@ -83,4 +86,16 @@ export class ReportRepository extends Repository { }, }); } + + async acceptReport(reportId: number): Promise { + const report = await this.getReport(reportId); + if (report.commentId) { + await this.update({ commentId: report.commentId }, { isAccepted: true }); + } else { + await this.update( + { postId: report.postId, commentId: IsNull() }, + { isAccepted: true }, + ); + } + } } diff --git a/src/community/report/report.service.ts b/src/community/report/report.service.ts index bc3c5d95..87b1cfca 100644 --- a/src/community/report/report.service.ts +++ b/src/community/report/report.service.ts @@ -5,35 +5,41 @@ import { GetReportListResponseDto } from './dto/get-report-list.dto'; import { GetReportResponseDto } from './dto/get-report.dto'; import { FileService } from 'src/common/file.service'; import { throwKukeyException } from 'src/utils/exception.util'; +import { UserBanService } from 'src/user/user-ban.service'; +import { AcceptReportRequestDto } from 'src/community/report/dto/accept-report.dto'; +import { PostService } from 'src/community/post/post.service'; +import { CommentService } from 'src/community/comment/comment.service'; +import { EntityManager } from 'typeorm'; @Injectable() export class ReportService { constructor( private readonly reportRepository: ReportRepository, private readonly fileService: FileService, + private readonly userBanService: UserBanService, + private readonly postService: PostService, + private readonly commentService: CommentService, ) {} async createReport( - reporterId: number, + userId: number, reason: string, postId: number, commentId?: number, ): Promise { if ( - await this.reportRepository.checkAlreadyReport( - reporterId, - postId, - commentId, - ) + await this.reportRepository.checkAlreadyReport(userId, postId, commentId) ) { throwKukeyException('ALREADY_REPORTED'); } - await this.reportRepository.createReport( - reporterId, - reason, - postId, - commentId, - ); + if (!(await this.postService.isExistingPostId(postId))) { + throwKukeyException('POST_NOT_FOUND'); + } + if (commentId && !(await this.commentService.getComment(commentId))) { + throwKukeyException('COMMENT_NOT_FOUND'); + } + + await this.reportRepository.createReport(userId, reason, postId, commentId); return new CreateReportResponseDto(true); } @@ -56,4 +62,40 @@ export class ReportService { return response; } + + async acceptReport( + transactionManager: EntityManager, + reportId: number, + dto: AcceptReportRequestDto, + ): Promise { + const report = await this.reportRepository.getReport(reportId); + const isComment = report.commentId ? true : false; + const userId = isComment ? report.comment.userId : report.post.userId; + if (userId) { + await this.userBanService.banUser( + transactionManager, + userId, + report.reason, + dto.banDays, + ); + } + if (isComment) { + await this.commentService.deleteComment( + transactionManager, + { id: -1, username: '' }, + report.commentId, + ); + } else { + await this.postService.deletePost( + transactionManager, + { id: -1, username: '' }, + report.postId, + ); + } + await this.reportRepository.acceptReport(reportId); + } + + async rejectReport(reportId: number): Promise { + await this.reportRepository.acceptReport(reportId); + } } diff --git a/src/course/course.controller.ts b/src/course/course.controller.ts index a5bd4832..09d3ff30 100644 --- a/src/course/course.controller.ts +++ b/src/course/course.controller.ts @@ -1,17 +1,10 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { CourseService } from './course.service'; import { ApiTags } from '@nestjs/swagger'; -import { CommonCourseResponseDto } from './dto/common-course-response.dto'; import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; -import { SearchCourseCodeDto } from './dto/search-course-code.dto'; -import { SearchCourseNameDto } from './dto/search-course-name.dto'; -import { SearchProfessorNameDto } from './dto/search-professor-name.dto'; import { PaginatedCoursesDto } from './dto/paginated-courses.dto'; import { CourseDocs } from 'src/decorators/docs/course.decorator'; -import { GetGeneralCourseDto } from './dto/get-general-course.dto'; -import { GetMajorCourseDto } from './dto/get-major-course.dto'; -import { GetAcademicFoundationCourseDto } from './dto/get-academic-foundation-course.dto'; -import { SearchCoursesWithKeywordDto } from './dto/search-courses-with-keyword.dto'; +import { SearchCourseNewDto } from './dto/search-course-new.dto'; @ApiTags('course') @CourseDocs @@ -20,139 +13,10 @@ export class CourseController { constructor(private courseService: CourseService) {} @UseGuards(JwtAuthGuard) - @Get('search-all') - async searchAllCourses( - @Query() searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, + @Get() + async searchCourses( + @Query() searchCourseNewDto: SearchCourseNewDto, ): Promise { - return await this.courseService.searchAllCourses( - searchCoursesWithKeywordDto, - ); - } - - @UseGuards(JwtAuthGuard) - @Get('search-major') - async searchMajorCourses( - @Query('major') major: string, - @Query() searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, - ): Promise { - return await this.courseService.searchMajorCourses( - major, - searchCoursesWithKeywordDto, - ); - } - - @UseGuards(JwtAuthGuard) - @Get('search-general') - async searchGeneralCourses( - @Query() searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, - ): Promise { - return await this.courseService.searchGeneralCourses( - searchCoursesWithKeywordDto, - ); - } - - @UseGuards(JwtAuthGuard) - @Get('search-academic-foundation') - async searchAcademicFoundationCourses( - @Query('college') college: string, - @Query() searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, - ): Promise { - return await this.courseService.searchAcademicFoundationCourses( - college, - searchCoursesWithKeywordDto, - ); - } - - // 학수번호 검색 - @UseGuards(JwtAuthGuard) - @Get('search-course-code') - async searchCourseCode( - @Query() searchCourseCodeDto: SearchCourseCodeDto, - ): Promise { - return await this.courseService.searchCourseCode(searchCourseCodeDto); - } - - // 전공 -- 과목명 검색 - @UseGuards(JwtAuthGuard) - @Get('search-major-course-name') - async searchMajorCourseName( - @Query('major') major: string, - @Query() searchCourseNameDto: SearchCourseNameDto, - ): Promise { - return await this.courseService.searchMajorCourseName( - major, - searchCourseNameDto, - ); - } - - // 교양 - 과목명 검색 - @UseGuards(JwtAuthGuard) - @Get('search-general-course-name') - async searchGeneralCourseName( - @Query() searchCourseNameDto: SearchCourseNameDto, - ): Promise { - return await this.courseService.searchGeneralCourseName( - searchCourseNameDto, - ); - } - - // 전공 - 교수님 성함 검색 - @UseGuards(JwtAuthGuard) - @Get('search-major-professor-name') - async searchMajorProfessorName( - @Query('major') major: string, - @Query() searchProfessorNameDto: SearchProfessorNameDto, - ): Promise { - return await this.courseService.searchMajorProfessorName( - major, - searchProfessorNameDto, - ); - } - - // 교양 - 교수님 성함 검색 - @UseGuards(JwtAuthGuard) - @Get('search-general-professor-name') - async searchGeneralProfessorName( - @Query() searchProfessorNameDto: SearchProfessorNameDto, - ): Promise { - return await this.courseService.searchGeneralProfessorName( - searchProfessorNameDto, - ); - } - - // 교양 리스트 - @UseGuards(JwtAuthGuard) - @Get('general') - async getGeneralCourses( - @Query() getGeneralCourseDto: GetGeneralCourseDto, - ): Promise { - return await this.courseService.getGeneralCourses(getGeneralCourseDto); - } - - // 전공 리스트 (학부별) - @UseGuards(JwtAuthGuard) - @Get('major') - async getMajorCourses( - @Query() getMajorCourseDto: GetMajorCourseDto, - ): Promise { - return await this.courseService.getMajorCourses(getMajorCourseDto); - } - - // 학문의 기초 리스트 - @UseGuards(JwtAuthGuard) - @Get('academic-foundation') - async getAcademicFoundationCourses( - @Query() getAcademicFoundationCourseDto: GetAcademicFoundationCourseDto, - ): Promise { - return await this.courseService.getAcademicFoundationCourses( - getAcademicFoundationCourseDto, - ); - } - - @Get('/:courseId') - async getCourse( - @Param('courseId') courseId: number, - ): Promise { - return await this.courseService.getCourse(courseId); + return await this.courseService.searchCourses(searchCourseNewDto); } } diff --git a/src/course/course.module.ts b/src/course/course.module.ts index 6b21e6e1..eecf6409 100644 --- a/src/course/course.module.ts +++ b/src/course/course.module.ts @@ -6,11 +6,43 @@ import { CourseDetailRepository } from './course-detail.repository'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CourseEntity } from 'src/entities/course.entity'; import { CourseDetailEntity } from 'src/entities/course-detail.entity'; +import { AcademicFoundationSearchStrategy } from './strategy/academic-foundation-search-strategy'; +import { GeneralSearchStrategy } from './strategy/general-search-strategy'; +import { MajorSearchStrategy } from './strategy/major-search-strategy'; +import { AllCoursesSearchStrategy } from './strategy/all-courses-search-strategy'; @Module({ imports: [TypeOrmModule.forFeature([CourseEntity, CourseDetailEntity])], controllers: [CourseController], - providers: [CourseService, CourseRepository, CourseDetailRepository], + providers: [ + CourseService, + CourseRepository, + CourseDetailRepository, + AcademicFoundationSearchStrategy, + GeneralSearchStrategy, + MajorSearchStrategy, + AllCoursesSearchStrategy, + { + provide: 'CourseSearchStrategy', + useFactory: ( + academicFoundationSearchStrategy: AcademicFoundationSearchStrategy, + generalSearchStrategy: GeneralSearchStrategy, + majorSearchStrategy: MajorSearchStrategy, + allCoursesSearchStrategy: AllCoursesSearchStrategy, + ) => [ + academicFoundationSearchStrategy, + generalSearchStrategy, + majorSearchStrategy, + allCoursesSearchStrategy, + ], + inject: [ + AcademicFoundationSearchStrategy, + GeneralSearchStrategy, + MajorSearchStrategy, + AllCoursesSearchStrategy, + ], + }, + ], exports: [CourseService], }) export class CourseModule {} diff --git a/src/course/course.service.ts b/src/course/course.service.ts index 686fe7a6..f7517a4b 100644 --- a/src/course/course.service.ts +++ b/src/course/course.service.ts @@ -1,79 +1,23 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { CourseRepository } from './course.repository'; import { CourseEntity } from 'src/entities/course.entity'; import { CourseDetailEntity } from 'src/entities/course-detail.entity'; import { CourseDetailRepository } from './course-detail.repository'; -import { Brackets, EntityManager, Like, MoreThan } from 'typeorm'; +import { Brackets, EntityManager, Like } from 'typeorm'; import { CommonCourseResponseDto } from './dto/common-course-response.dto'; -import { SearchCourseCodeDto } from './dto/search-course-code.dto'; -import { SearchCourseNameDto } from './dto/search-course-name.dto'; -import { SearchProfessorNameDto } from './dto/search-professor-name.dto'; import { PaginatedCoursesDto } from './dto/paginated-courses.dto'; import { throwKukeyException } from 'src/utils/exception.util'; -import { GetGeneralCourseDto } from './dto/get-general-course.dto'; -import { GetMajorCourseDto } from './dto/get-major-course.dto'; -import { GetAcademicFoundationCourseDto } from './dto/get-academic-foundation-course.dto'; -import { SearchCoursesWithKeywordDto } from './dto/search-courses-with-keyword.dto'; +import { SearchCourseNewDto } from './dto/search-course-new.dto'; +import { CourseSearchStrategy } from './strategy/course-search-strategy'; @Injectable() export class CourseService { constructor( private courseRepository: CourseRepository, private courseDetailRepository: CourseDetailRepository, + @Inject('CourseSearchStrategy') + private readonly strategies: CourseSearchStrategy[], ) {} - - async searchAllCourses( - searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, - ): Promise { - const courses = await this.runSearchCoursesQuery( - searchCoursesWithKeywordDto, - ); - return await this.mappingCourseDetailsToCourses(courses); - } - - async searchMajorCourses( - major: string, - searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, - ): Promise { - if (!major) throwKukeyException('MAJOR_REQUIRED'); - - const courses = await this.runSearchCoursesQuery( - searchCoursesWithKeywordDto, - { - major, - category: 'Major', - }, - ); - return await this.mappingCourseDetailsToCourses(courses); - } - - async searchGeneralCourses( - searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, - ): Promise { - const courses = await this.runSearchCoursesQuery( - searchCoursesWithKeywordDto, - { - category: 'General Studies', - }, - ); - return await this.mappingCourseDetailsToCourses(courses); - } - - async searchAcademicFoundationCourses( - college: string, - searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, - ): Promise { - if (!college) throwKukeyException('COLLEGE_REQUIRED'); - const courses = await this.runSearchCoursesQuery( - searchCoursesWithKeywordDto, - { - college, - category: 'Academic Foundations', - }, - ); - return await this.mappingCourseDetailsToCourses(courses); - } - async getCourse(courseId: number): Promise { const course = await this.courseRepository.findOne({ where: { id: courseId }, @@ -124,298 +68,6 @@ export class CourseService { }); } - // 학수번호 검색 - async searchCourseCode( - searchCourseCodeDto: SearchCourseCodeDto, - ): Promise { - let courses: CourseEntity[] = []; - if (searchCourseCodeDto.cursorId) { - courses = await this.courseRepository.find({ - where: { - courseCode: Like(`${searchCourseCodeDto.courseCode}%`), - id: MoreThan(searchCourseCodeDto.cursorId), - year: searchCourseCodeDto.year, - semester: searchCourseCodeDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } else { - courses = await this.courseRepository.find({ - where: { - courseCode: Like(`${searchCourseCodeDto.courseCode}%`), - year: searchCourseCodeDto.year, - semester: searchCourseCodeDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } - return await this.mappingCourseDetailsToCourses(courses); - } - - // 전공 과목명 검색 (최소 3글자 이상 입력 ) - async searchMajorCourseName( - major: string, - searchCourseNameDto: SearchCourseNameDto, - ): Promise { - if (!major) throwKukeyException('MAJOR_REQUIRED'); - - let courses = []; - - if (searchCourseNameDto.cursorId) { - courses = await this.courseRepository.find({ - where: { - courseName: Like(`%${searchCourseNameDto.courseName}%`), - major: major, - category: 'Major', - id: MoreThan(searchCourseNameDto.cursorId), - year: searchCourseNameDto.year, - semester: searchCourseNameDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } else { - courses = await this.courseRepository.find({ - where: { - courseName: Like(`%${searchCourseNameDto.courseName}%`), - major: major, - category: 'Major', - year: searchCourseNameDto.year, - semester: searchCourseNameDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } - - return await this.mappingCourseDetailsToCourses(courses); - } - - // 전공 교수님 성함 검색 - async searchMajorProfessorName( - major: string, - searchProfessorNameDto: SearchProfessorNameDto, - ): Promise { - if (!major) { - throwKukeyException('MAJOR_REQUIRED'); - } - let courses = []; - - if (searchProfessorNameDto.cursorId) { - courses = await this.courseRepository.find({ - where: { - professorName: Like(`%${searchProfessorNameDto.professorName}%`), - major: major, - category: 'Major', - id: MoreThan(searchProfessorNameDto.cursorId), - year: searchProfessorNameDto.year, - semester: searchProfessorNameDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } else { - courses = await this.courseRepository.find({ - where: { - professorName: Like(`%${searchProfessorNameDto.professorName}%`), - major: major, - category: 'Major', - year: searchProfessorNameDto.year, - semester: searchProfessorNameDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } - - return await this.mappingCourseDetailsToCourses(courses); - } - - // 교양 과목명 검색 (최소 3글자 이상 입력) - async searchGeneralCourseName( - searchCourseNameDto: SearchCourseNameDto, - ): Promise { - let courses = []; - - if (searchCourseNameDto.cursorId) { - courses = await this.courseRepository.find({ - where: { - courseName: Like(`%${searchCourseNameDto.courseName}%`), - category: 'General Studies', - id: MoreThan(searchCourseNameDto.cursorId), - year: searchCourseNameDto.year, - semester: searchCourseNameDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } else { - courses = await this.courseRepository.find({ - where: { - courseName: Like(`%${searchCourseNameDto.courseName}%`), - category: 'General Studies', - year: searchCourseNameDto.year, - semester: searchCourseNameDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } - - return await this.mappingCourseDetailsToCourses(courses); - } - - // 교양 교수님 성함 검색 - async searchGeneralProfessorName( - searchProfessorNameDto: SearchProfessorNameDto, - ): Promise { - let courses = []; - - if (searchProfessorNameDto.cursorId) { - courses = await this.courseRepository.find({ - where: { - professorName: Like(`%${searchProfessorNameDto.professorName}%`), - category: 'General Studies', - id: MoreThan(searchProfessorNameDto.cursorId), - year: searchProfessorNameDto.year, - semester: searchProfessorNameDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } else { - courses = await this.courseRepository.find({ - where: { - professorName: Like(`%${searchProfessorNameDto.professorName}%`), - category: 'General Studies', - year: searchProfessorNameDto.year, - semester: searchProfessorNameDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } - - return await this.mappingCourseDetailsToCourses(courses); - } - - // 교양 리스트 반환 - async getGeneralCourses( - getGeneralCourseDto: GetGeneralCourseDto, - ): Promise { - let courses = []; - if (getGeneralCourseDto.cursorId) { - courses = await this.courseRepository.find({ - where: { - category: 'General Studies', - id: MoreThan(getGeneralCourseDto.cursorId), - year: getGeneralCourseDto.year, - semester: getGeneralCourseDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } else { - courses = await this.courseRepository.find({ - where: { - category: 'General Studies', - year: getGeneralCourseDto.year, - semester: getGeneralCourseDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } - - return await this.mappingCourseDetailsToCourses(courses); - } - - // 전공 리스트 반환 - async getMajorCourses( - getMajorCourseDto: GetMajorCourseDto, - ): Promise { - if (!getMajorCourseDto.major) throwKukeyException('MAJOR_REQUIRED'); - let courses = []; - if (getMajorCourseDto.cursorId) { - courses = await this.courseRepository.find({ - where: { - category: 'Major', - major: getMajorCourseDto.major, - id: MoreThan(getMajorCourseDto.cursorId), - year: getMajorCourseDto.year, - semester: getMajorCourseDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } else { - courses = await this.courseRepository.find({ - where: { - category: 'Major', - major: getMajorCourseDto.major, - year: getMajorCourseDto.year, - semester: getMajorCourseDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } - - return await this.mappingCourseDetailsToCourses(courses); - } - - // 학문의 기초 리스트 반환 - async getAcademicFoundationCourses( - getAcademicFoundationCourseDto: GetAcademicFoundationCourseDto, - ): Promise { - if (!getAcademicFoundationCourseDto.college) - throwKukeyException('COLLEGE_REQUIRED'); - let courses = []; - if (getAcademicFoundationCourseDto.cursorId) { - courses = await this.courseRepository.find({ - where: { - category: 'Academic Foundations', - college: getAcademicFoundationCourseDto.college, - id: MoreThan(getAcademicFoundationCourseDto.cursorId), - year: getAcademicFoundationCourseDto.year, - semester: getAcademicFoundationCourseDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } else { - courses = await this.courseRepository.find({ - where: { - category: 'Academic Foundations', - college: getAcademicFoundationCourseDto.college, - year: getAcademicFoundationCourseDto.year, - semester: getAcademicFoundationCourseDto.semester, - }, - order: { id: 'ASC' }, - take: 21, - relations: ['courseDetails'], - }); - } - return await this.mappingCourseDetailsToCourses(courses); - } - async updateCourseTotalRate( courseIds: number[], totalRate: number, @@ -437,51 +89,42 @@ export class CourseService { return new PaginatedCoursesDto(courseInformations); } - private async runSearchCoursesQuery( - searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, - options?: { major?: string; college?: string; category?: string }, - ): Promise { - const { keyword, cursorId, year, semester } = searchCoursesWithKeywordDto; - + async searchCourses( + searchCourseNewDto: SearchCourseNewDto, + ): Promise { + const { keyword, cursorId } = searchCourseNewDto; const LIMIT = PaginatedCoursesDto.LIMIT; + // 해당하는 검색 전략 찾아오기 + const searchStrategy = await this.findSearchStrategy(searchCourseNewDto); let queryBuilder = this.courseRepository .createQueryBuilder('course') .leftJoinAndSelect('course.courseDetails', 'courseDetails') - .where('course.year = :year', { year }) - .andWhere('course.semester = :semester', { semester }); - - // Optional: 추가 조건 적용 - if (options?.major) { - queryBuilder = queryBuilder.andWhere('course.major = :major', { - major: options.major, - }); - } - - if (options?.college) { - queryBuilder = queryBuilder.andWhere('course.college = :college', { - college: options.college, + .where('course.year = :year', { year: searchCourseNewDto.year }) + .andWhere('course.semester = :semester', { + semester: searchCourseNewDto.semester, }); - } - if (options?.category) { - queryBuilder = queryBuilder.andWhere('course.category = :category', { - category: options.category, - }); - } + queryBuilder = await searchStrategy.buildQuery( + queryBuilder, + searchCourseNewDto, + ); - // 검색 조건(LIKE) - queryBuilder = queryBuilder.andWhere( - new Brackets((qb) => { - qb.where('course.courseName LIKE :keyword', { keyword: `%${keyword}%` }) - .orWhere('course.professorName LIKE :keyword', { + if (keyword) { + queryBuilder = queryBuilder.andWhere( + new Brackets((qb) => { + qb.where('course.courseName LIKE :keyword', { keyword: `%${keyword}%`, }) - .orWhere('course.courseCode LIKE :keyword', { - keyword: `%${keyword}%`, - }); - }), - ); + .orWhere('course.professorName LIKE :keyword', { + keyword: `%${keyword}%`, + }) + .orWhere('course.courseCode LIKE :keyword', { + keyword: `%${keyword}%`, + }); + }), + ); + } if (cursorId) { queryBuilder = queryBuilder.andWhere('course.id > :cursorId', { @@ -491,6 +134,22 @@ export class CourseService { queryBuilder = queryBuilder.orderBy('course.id', 'ASC').take(LIMIT); - return await queryBuilder.getMany(); + const courses = await queryBuilder.getMany(); + return await this.mappingCourseDetailsToCourses(courses); + } + + private async findSearchStrategy( + searchCourseNewDto: SearchCourseNewDto, + ): Promise { + const { category } = searchCourseNewDto; + const searchStrategy = this.strategies.find((strategy) => + strategy.supports(category), + ); + + if (!searchStrategy) { + throwKukeyException('COURSE_SEARCH_STRATEGY_NOT_FOUND'); + } + + return searchStrategy; } } diff --git a/src/course/dto/search-course-new.dto.ts b/src/course/dto/search-course-new.dto.ts new file mode 100644 index 00000000..f4fb7bae --- /dev/null +++ b/src/course/dto/search-course-new.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsInt, IsOptional, IsString, Length } from 'class-validator'; +import { CourseCategory } from 'src/enums/course-category.enum'; + +export class SearchCourseNewDto { + @ApiPropertyOptional({ + description: '커서 id, 값이 존재하지 않으면 첫 페이지', + }) + @IsInt() + @IsOptional() + cursorId?: number; + + @ApiProperty({ description: '연도' }) + @IsString() + @Length(4, 4) + year: string; + + @ApiProperty({ description: '학기' }) + @IsString() + @Length(1, 1) + semester: string; + + @ApiPropertyOptional({ + description: + '강의 카테고리 (모든 강의, 전공, 교양, 학문의 기초), 모든 강의는 값을 넘겨주지 않음', + enum: CourseCategory, + nullable: true, + }) + @IsOptional() + @IsEnum(CourseCategory) + category?: CourseCategory; + + @ApiPropertyOptional({ + description: '검색 키워드 (강의명, 교수명, 학수번호)', + }) + @Length(2) + @IsOptional() + keyword?: string; + + @ApiPropertyOptional({ + description: + 'category가 Major일때 특정 과를, category가 Academic Foundation일 때 특정 단과대를 넣어주세요.', + }) + @IsString() + @IsOptional() + classification?: string; +} diff --git a/src/course/strategy/academic-foundation-search-strategy.ts b/src/course/strategy/academic-foundation-search-strategy.ts new file mode 100644 index 00000000..3910d8e3 --- /dev/null +++ b/src/course/strategy/academic-foundation-search-strategy.ts @@ -0,0 +1,31 @@ +import { CourseCategory } from 'src/enums/course-category.enum'; +import { CourseSearchStrategy } from './course-search-strategy'; +import { SearchCourseNewDto } from '../dto/search-course-new.dto'; +import { throwKukeyException } from 'src/utils/exception.util'; +import { SelectQueryBuilder } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { CourseEntity } from 'src/entities/course.entity'; + +@Injectable() +export class AcademicFoundationSearchStrategy implements CourseSearchStrategy { + supports(category: CourseCategory): boolean { + return category === CourseCategory.ACADEMIC_FOUNDATIONS; + } + + async buildQuery( + queryBuilder: SelectQueryBuilder, + searchCourseNewDto: SearchCourseNewDto, + ): Promise> { + if (!searchCourseNewDto.classification) { + throwKukeyException('COLLEGE_REQUIRED'); + } + + const { classification } = searchCourseNewDto; + + return queryBuilder + .andWhere('course.category = :category', { + category: CourseCategory.ACADEMIC_FOUNDATIONS, + }) + .andWhere('course.college = :college', { college: classification }); + } +} diff --git a/src/course/strategy/all-courses-search-strategy.ts b/src/course/strategy/all-courses-search-strategy.ts new file mode 100644 index 00000000..eda531a2 --- /dev/null +++ b/src/course/strategy/all-courses-search-strategy.ts @@ -0,0 +1,20 @@ +import { CourseCategory } from 'src/enums/course-category.enum'; +import { Injectable } from '@nestjs/common'; +import { CourseSearchStrategy } from './course-search-strategy'; +import { CourseEntity } from 'src/entities/course.entity'; +import { SelectQueryBuilder } from 'typeorm'; +import { SearchCourseNewDto } from '../dto/search-course-new.dto'; + +@Injectable() +export class AllCoursesSearchStrategy implements CourseSearchStrategy { + supports(category: CourseCategory): boolean { + return !category; + } + + async buildQuery( + queryBuilder: SelectQueryBuilder, + searchCourseNewDto?: SearchCourseNewDto, + ): Promise> { + return queryBuilder; + } +} diff --git a/src/course/strategy/course-search-strategy.ts b/src/course/strategy/course-search-strategy.ts new file mode 100644 index 00000000..0cab2a0a --- /dev/null +++ b/src/course/strategy/course-search-strategy.ts @@ -0,0 +1,13 @@ +import { CourseCategory } from 'src/enums/course-category.enum'; +import { SearchCourseNewDto } from '../dto/search-course-new.dto'; +import { SelectQueryBuilder } from 'typeorm'; +import { CourseEntity } from 'src/entities/course.entity'; + +export interface CourseSearchStrategy { + supports(category: CourseCategory): boolean; + + buildQuery( + queryBuilder: SelectQueryBuilder, + searchCourseNewDto?: SearchCourseNewDto, + ): Promise>; +} diff --git a/src/course/strategy/general-search-strategy.ts b/src/course/strategy/general-search-strategy.ts new file mode 100644 index 00000000..026e6b4c --- /dev/null +++ b/src/course/strategy/general-search-strategy.ts @@ -0,0 +1,20 @@ +import { CourseCategory } from 'src/enums/course-category.enum'; +import { Injectable } from '@nestjs/common'; +import { CourseSearchStrategy } from './course-search-strategy'; +import { SelectQueryBuilder } from 'typeorm'; +import { CourseEntity } from 'src/entities/course.entity'; + +@Injectable() +export class GeneralSearchStrategy implements CourseSearchStrategy { + supports(category: CourseCategory): boolean { + return category === CourseCategory.GENERAL_STUDIES; + } + + async buildQuery( + queryBuilder: SelectQueryBuilder, + ): Promise> { + return queryBuilder.andWhere('course.category = :category', { + category: CourseCategory.GENERAL_STUDIES, + }); + } +} diff --git a/src/course/strategy/major-search-strategy.ts b/src/course/strategy/major-search-strategy.ts new file mode 100644 index 00000000..9d4643bf --- /dev/null +++ b/src/course/strategy/major-search-strategy.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { CourseCategory } from 'src/enums/course-category.enum'; +import { CourseSearchStrategy } from './course-search-strategy'; +import { SearchCourseNewDto } from '../dto/search-course-new.dto'; +import { throwKukeyException } from 'src/utils/exception.util'; +import { SelectQueryBuilder } from 'typeorm'; +import { CourseEntity } from 'src/entities/course.entity'; + +@Injectable() +export class MajorSearchStrategy implements CourseSearchStrategy { + supports(category: CourseCategory): boolean { + return category === CourseCategory.MAJOR; + } + + async buildQuery( + queryBuilder: SelectQueryBuilder, + searchCourseNewDto: SearchCourseNewDto, + ): Promise> { + if (!searchCourseNewDto.classification) { + throwKukeyException('MAJOR_REQUIRED'); + } + + const { classification } = searchCourseNewDto; + + return queryBuilder + .andWhere('course.category = :category', { + category: CourseCategory.MAJOR, + }) + .andWhere('course.major = :major', { major: classification }); + } +} diff --git a/src/decorators/docs/banner.decorator.ts b/src/decorators/docs/banner.decorator.ts new file mode 100644 index 00000000..53c3e2de --- /dev/null +++ b/src/decorators/docs/banner.decorator.ts @@ -0,0 +1,72 @@ +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiResponse, +} from '@nestjs/swagger'; +import { MethodNames } from 'src/common/types/method'; +import { BannerController } from 'src/home/banner/banner.controller'; +import { bannerDto } from 'src/home/banner/dto/banner.dto'; +import { CreateBannerRequestDto } from 'src/home/banner/dto/create-banner-request.dto'; + +type BannerEndPoints = MethodNames; + +const BannerDocsMap: Record = { + getBannerImages: [ + ApiOperation({ + summary: '배너 이미지 목록 조회', + description: '배너 이미지 목록을 조회합니다.(최신순)', + }), + ApiResponse({ + status: 200, + description: '배너 이미지 목록 조회 성공', + isArray: true, + type: bannerDto, + }), + ], + createBannerImage: [ + ApiBearerAuth('accessToken'), + ApiOperation({ + summary: '배너 이미지 생성', + description: '배너 이미지를 생성합니다.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + type: CreateBannerRequestDto, + }), + ApiResponse({ + status: 201, + description: '배너 이미지 생성 성공', + type: bannerDto, + }), + ], + deleteBannerImage: [ + ApiBearerAuth('accessToken'), + ApiOperation({ + summary: '배너 이미지 삭제', + description: '배너 이미지를 삭제합니다.', + }), + ApiParam({ name: 'id', required: true, description: '배너 이미지 id' }), + ApiResponse({ + status: 200, + description: '배너 이미지 삭제 성공', + }), + ], +}; + +export function BannerDocs(target: typeof BannerController) { + for (const key in BannerDocsMap) { + const methodDecorators = BannerDocsMap[key as BannerEndPoints]; + + const descripter = Object.getOwnPropertyDescriptor(target.prototype, key); + if (descripter) { + for (const decorator of methodDecorators) { + decorator(target.prototype, key, descripter); + } + Object.defineProperty(target.prototype, key, descripter); + } + } + return target; +} diff --git a/src/decorators/docs/club.decorator.ts b/src/decorators/docs/club.decorator.ts index bcc39425..f9c7f939 100644 --- a/src/decorators/docs/club.decorator.ts +++ b/src/decorators/docs/club.decorator.ts @@ -19,11 +19,12 @@ import { GetRecommendClubResponseDto } from 'src/home/club/dto/get-recommend-clu import { UpdateClubRequestDto } from 'src/home/club/dto/update-club-request-dto'; import { UpdateClubResponseDto } from 'src/home/club/dto/update-club-response-dto'; import { ApiKukeyExceptionResponse } from '../api-kukey-exception-response'; +import { GetClubDetailResponseDto } from 'src/home/club/dto/get-club-detail-response.dto'; type ClubEndPoints = MethodNames; const ClubDocsMap: Record = { - getClubList: [ + getClubs: [ ApiOperation({ summary: '동아리 목록 조회', description: @@ -61,6 +62,27 @@ const ClubDocsMap: Record = { }), ApiKukeyExceptionResponse(['LOGIN_REQUIRED']), ], + getClubDetail: [ + ApiOperation({ + summary: '동아리 상세 조회', + description: '동아리 상세 정보를 조회합니다.', + }), + ApiQuery({ + name: 'clubId', + description: 'club id', + required: true, + }), + ApiQuery({ + name: 'isLogin', + description: '로그인 여부', + required: true, + }), + ApiOkResponse({ + description: '동아리 상세 정보 반환', + type: GetClubDetailResponseDto, + }), + ApiKukeyExceptionResponse(['LOGIN_REQUIRED', 'CLUB_NOT_FOUND']), + ], toggleLikeClub: [ ApiOperation({ summary: '동아리 좋아요 등록/해제', @@ -78,19 +100,19 @@ const ClubDocsMap: Record = { }), ApiKukeyExceptionResponse(['CLUB_NOT_FOUND']), ], - getHotClubList: [ + getHotClubs: [ ApiOperation({ summary: 'Hot Club 목록 조회', description: - '최근 일주일 동안 좋아요 개수가 가장 많은 동아리 4개를 반환합니다.', + '최근 일주일 동안 좋아요 개수가 가장 많은 동아리 5개를 반환합니다.', }), ApiOkResponse({ - description: 'Hot Club 목록 4개 반환', + description: 'Hot Club 목록 5개 반환', isArray: true, type: GetHotClubResponseDto, }), ], - getRecommendClubList: [ + getRecommendClubs: [ ApiOperation({ summary: 'Recommend Club 목록 조회', description: @@ -102,7 +124,7 @@ const ClubDocsMap: Record = { required: true, }), ApiOkResponse({ - description: 'Recommend Club 목록 4개 반환', + description: 'Recommend Club 목록 5개 반환', isArray: true, type: GetRecommendClubResponseDto, }), diff --git a/src/decorators/docs/comment.decorator.ts b/src/decorators/docs/comment.decorator.ts index c009134f..cd76fa7d 100644 --- a/src/decorators/docs/comment.decorator.ts +++ b/src/decorators/docs/comment.decorator.ts @@ -13,10 +13,6 @@ import { GetCommentResponseDto } from 'src/community/comment/dto/get-comment.dto import { GetMyCommentListResponseDto } from 'src/community/comment/dto/get-myComment-list.dto'; import { LikeCommentResponseDto } from 'src/community/comment/dto/like-comment.dto'; import { UpdateCommentRequestDto } from 'src/community/comment/dto/update-comment.dto'; -import { - CreateReportRequestDto, - CreateReportResponseDto, -} from 'src/community/report/dto/create-report.dto'; import { ApiKukeyExceptionResponse } from '../api-kukey-exception-response'; type CommentEndPoints = MethodNames; @@ -60,6 +56,7 @@ const CommentDocsMap: Record = { 'INVALID_PARENT_COMMENT_REQUEST', 'REPLY_TO_DIFFERENT_POST', 'POST_UPDATE_FAILED', + 'USER_BANNED', ]), ], updateComment: [ @@ -84,6 +81,7 @@ const CommentDocsMap: Record = { 'COMMENT_OWNERSHIP_REQUIRED', 'COMMENT_IN_QUESTION_BOARD', 'COMMENT_UPDATE_FAILED', + 'USER_BANNED', ]), ], deleteComment: [ @@ -128,25 +126,6 @@ const CommentDocsMap: Record = { 'COMMENT_LIKE_CANCEL_FAILED', ]), ], - reportComment: [ - ApiOperation({ - summary: '댓글 신고', - description: '댓글을 신고합니다', - }), - ApiParam({ - name: 'commentId', - description: '댓글의 고유 ID', - }), - ApiBody({ - type: CreateReportRequestDto, - }), - ApiResponse({ - status: 201, - description: '댓글 신고 성공', - type: CreateReportResponseDto, - }), - ApiKukeyExceptionResponse(['COMMENT_NOT_FOUND', 'ALREADY_REPORTED']), - ], }; export function CommentDocs(target: typeof CommentController) { diff --git a/src/decorators/docs/course.decorator.ts b/src/decorators/docs/course.decorator.ts index 50d15880..16f74d3a 100644 --- a/src/decorators/docs/course.decorator.ts +++ b/src/decorators/docs/course.decorator.ts @@ -1,280 +1,61 @@ import { ApiBearerAuth, ApiOperation, - ApiParam, ApiQuery, ApiResponse, } from '@nestjs/swagger'; import { MethodNames } from 'src/common/types/method'; import { CourseController } from 'src/course/course.controller'; -import { CommonCourseResponseDto } from 'src/course/dto/common-course-response.dto'; import { PaginatedCoursesDto } from 'src/course/dto/paginated-courses.dto'; import { ApiKukeyExceptionResponse } from '../api-kukey-exception-response'; +import { CourseCategory } from 'src/enums/course-category.enum'; type CourseEndPoints = MethodNames; const CourseDocsMap: Record = { - searchAllCourses: [ + searchCourses: [ ApiBearerAuth('accessToken'), ApiOperation({ - summary: 'keyword로 전체 강의 검색', - description: 'keyword를 입력하여 전체 강의에서 검색합니다.', - }), - ApiResponse({ - status: 200, - description: 'keyword로 강의 검색 성공 시', - type: PaginatedCoursesDto, - }), - ], - searchMajorCourses: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: 'keyword로 전공 강의 검색', - description: 'keyword를 입력하여 전공 강의에서 검색합니다.', - }), - ApiQuery({ - name: 'major', - required: true, - type: 'string', - }), - ApiResponse({ - status: 200, - description: 'keyword로 강의 검색 성공 시', - type: PaginatedCoursesDto, - }), - ApiKukeyExceptionResponse(['MAJOR_REQUIRED']), - ], - searchGeneralCourses: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: 'keyword로 교양 강의 검색', - description: 'keyword를 입력하여 교양 강의에서 검색합니다.', - }), - ApiResponse({ - status: 200, - description: 'keyword로 강의 검색 성공 시', - type: PaginatedCoursesDto, - }), - ], - searchAcademicFoundationCourses: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: 'keyword로 학문의 기초 강의 검색', - description: - 'keyword를 입력하여 단과대 별 학문의 기초 강의에서 검색합니다.', - }), - ApiQuery({ - name: 'college', - required: true, - type: 'string', - }), - ApiResponse({ - status: 200, - description: 'keyword로 강의 검색 성공 시', - type: PaginatedCoursesDto, - }), - ApiKukeyExceptionResponse(['COLLEGE_REQUIRED']), - ], - searchCourseCode: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: '학수번호로 강의 검색', - description: '학수번호를 입력하여 강의를 검색합니다.', - }), - ApiQuery({ - name: 'courseCode', - required: true, - type: 'string', + summary: '강의 검색', + description: '하나의 엔드포인트로 모든 강의검색 로직을 통합했습니다.', }), ApiQuery({ name: 'cursorId', required: false, type: 'number', }), - ApiResponse({ - status: 200, - description: '학수번호로 강의 검색 성공 시', - type: PaginatedCoursesDto, - }), - ], - searchMajorCourseName: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: '전공 과목명 강의 검색', - description: '전공 과목명을 입력하여 강의를 검색합니다.', - }), ApiQuery({ - name: 'major', + name: 'year', required: true, type: 'string', }), ApiQuery({ - name: 'courseName', + name: 'semester', required: true, type: 'string', }), ApiQuery({ - name: 'cursorId', + name: 'category', required: false, - type: 'number', - }), - ApiResponse({ - status: 200, - description: '전공 과목명으로 강의 검색 성공 시', - type: PaginatedCoursesDto, - }), - ApiKukeyExceptionResponse(['MAJOR_REQUIRED']), - ], - searchGeneralCourseName: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: '교양 과목명 강의 검색', - description: '교양 과목명을 입력하여 강의를 검색합니다.', - }), - ApiQuery({ - name: 'courseName', - required: true, - type: 'string', + type: 'enum', + enum: CourseCategory, }), ApiQuery({ - name: 'cursorId', + name: 'keyword', required: false, - type: 'number', - }), - ApiResponse({ - status: 200, - description: '교양 과목명으로 강의 검색 성공 시', - type: PaginatedCoursesDto, - }), - ], - searchMajorProfessorName: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: '전공 과목 담당 교수님 성함으로 강의 검색', - description: '전공 과목 담당 교수님 성함을 입력하여 강의를 검색합니다.', - }), - ApiQuery({ - name: 'major', - required: true, - type: 'string', - }), - ApiQuery({ - name: 'professorName', - required: true, type: 'string', }), ApiQuery({ - name: 'cursorId', + name: 'classification', required: false, - type: 'number', - }), - ApiResponse({ - status: 200, - description: '전공 과목 담당 교수님 성함으로 강의 검색 성공 시', - type: PaginatedCoursesDto, - }), - ApiKukeyExceptionResponse(['MAJOR_REQUIRED']), - ], - searchGeneralProfessorName: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: '교양 담당 교수님 성함으로 강의 검색', - description: '교양 담당 교수님 성함을 입력하여 강의를 검색합니다.', - }), - ApiQuery({ - name: 'professorName', - required: true, type: 'string', }), - ApiQuery({ - name: 'cursorId', - required: false, - type: 'number', - }), ApiResponse({ status: 200, - description: '교양 담당 교수님 성함으로 강의 검색 성공 시', + description: '강의 검색 성공 시', type: PaginatedCoursesDto, }), - ], - getGeneralCourses: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: '교양 강의 조회', - description: '모든 교양 강의를 조회합니다.', - }), - ApiQuery({ - name: 'cursorId', - required: false, - type: 'number', - }), - ApiResponse({ - status: 200, - description: '교양 강의 조회 성공 시', - type: PaginatedCoursesDto, - }), - ], - getMajorCourses: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: '전공 강의 조회', - description: '해당 과의 모든 전공 강의를 조회합니다.', - }), - ApiQuery({ - name: 'major', - required: true, - type: 'string', - }), - ApiQuery({ - name: 'cursorId', - required: false, - type: 'number', - }), - ApiResponse({ - status: 200, - description: '전공 강의 조회 성공 시', - type: PaginatedCoursesDto, - }), - ApiKukeyExceptionResponse(['MAJOR_REQUIRED']), - ], - getAcademicFoundationCourses: [ - ApiBearerAuth('accessToken'), - ApiOperation({ - summary: '학문의 기초 강의 조회', - description: '해당 단과대의 모든 학문의 기초 강의를 조회합니다.', - }), - ApiQuery({ - name: 'college', - required: true, - type: 'string', - }), - ApiQuery({ - name: 'cursorId', - required: false, - type: 'number', - }), - ApiResponse({ - status: 200, - description: '학문의 기초 강의 조회 성공 시', - type: PaginatedCoursesDto, - }), - ApiKukeyExceptionResponse(['COLLEGE_REQUIRED']), - ], - getCourse: [ - ApiOperation({ - summary: '특정 강의 조회', - description: '특정 강의를 조회합니다.', - }), - ApiParam({ - name: 'courseId', - description: '특정 강의 ID', - }), - ApiResponse({ - status: 200, - description: '특정 강의 조회 성공 시', - type: CommonCourseResponseDto, - }), - ApiKukeyExceptionResponse(['COURSE_NOT_FOUND']), + ApiKukeyExceptionResponse(['MAJOR_REQUIRED', 'COLLEGE_REQUIRED']), ], }; diff --git a/src/decorators/docs/friendship.decorator.ts b/src/decorators/docs/friendship.decorator.ts index 0d928a73..f1674045 100644 --- a/src/decorators/docs/friendship.decorator.ts +++ b/src/decorators/docs/friendship.decorator.ts @@ -17,6 +17,7 @@ import { UpdateFriendshipResponseDto } from 'src/friendship/dto/update-friendshi import { FriendshipController } from 'src/friendship/friendship.controller'; import { GetTimetableByTimetableIdDto } from 'src/timetable/dto/get-timetable-timetable.dto'; import { ApiKukeyExceptionResponse } from '../api-kukey-exception-response'; +import { GetReceivedFriendshipRequestCountDto } from 'src/friendship/dto/get-received-friendship-request-count.dto'; type FriendshipEndPoints = MethodNames; @@ -118,6 +119,17 @@ const FriendshipDocsMap: Record = { type: GetWaitingFriendResponseDto, }), ], + getReceivedFriendshipRequestCount: [ + ApiOperation({ + summary: '나에게 온 친구 요청 개수 조회', + description: + '나에게 온 친구 요청 전체 개수 / 아직 확인하지 않은 개수를 조회합니다.', + }), + ApiOkResponse({ + description: '나에게 온 친구 요청 전체 개수 / 아직 확인하지 않은 개수', + type: GetReceivedFriendshipRequestCountDto, + }), + ], getSentWaitingFriendList: [ ApiOperation({ summary: '내가 친구 요청을 보낸 유저 목록 조회', diff --git a/src/decorators/docs/post.decorator.ts b/src/decorators/docs/post.decorator.ts index 529202c1..f6522460 100644 --- a/src/decorators/docs/post.decorator.ts +++ b/src/decorators/docs/post.decorator.ts @@ -19,10 +19,6 @@ import { import { ScrapPostResponseDto } from 'src/community/post/dto/scrap-post.dto'; import { UpdatePostRequestDto } from 'src/community/post/dto/update-post.dto'; import { PostController } from 'src/community/post/post.controller'; -import { - CreateReportRequestDto, - CreateReportResponseDto, -} from 'src/community/report/dto/create-report.dto'; import { ApiKukeyExceptionResponse } from '../api-kukey-exception-response'; type PostEndPoints = MethodNames; @@ -134,6 +130,7 @@ const PostDocsMap: Record = { 'TOO_MANY_IMAGES', 'BOARD_NOT_FOUND', 'FILE_UPLOAD_FAILED', + 'USER_BANNED', ]), ], updatePost: [ @@ -162,6 +159,7 @@ const PostDocsMap: Record = { 'TOO_MANY_IMAGES', 'FILE_UPLOAD_FAILED', 'FILE_DELETE_FAILED', + 'USER_BANNED', ]), ], deletePost: [ @@ -235,25 +233,6 @@ const PostDocsMap: Record = { 'USER_NOT_FOUND', ]), ], - reportPost: [ - ApiOperation({ - summary: '게시글 신고', - description: '게시글을 신고합니다', - }), - ApiParam({ - name: 'postId', - description: '게시글의 고유 ID', - }), - ApiBody({ - type: CreateReportRequestDto, - }), - ApiResponse({ - status: 201, - description: '게시글 신고 성공', - type: CreateReportResponseDto, - }), - ApiKukeyExceptionResponse(['POST_NOT_FOUND', 'ALREADY_REPORTED']), - ], }; export function PostDocs(target: typeof PostController) { diff --git a/src/decorators/docs/report.decorator.ts b/src/decorators/docs/report.decorator.ts index 47e60c77..b22a7292 100644 --- a/src/decorators/docs/report.decorator.ts +++ b/src/decorators/docs/report.decorator.ts @@ -1,8 +1,14 @@ -import { ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { MethodNames } from 'src/common/types/method'; +import { AcceptReportRequestDto } from 'src/community/report/dto/accept-report.dto'; +import { + CreateReportRequestDto, + CreateReportResponseDto, +} from 'src/community/report/dto/create-report.dto'; import { GetReportListResponseDto } from 'src/community/report/dto/get-report-list.dto'; import { GetReportResponseDto } from 'src/community/report/dto/get-report.dto'; import { ReportController } from 'src/community/report/report.controller'; +import { ApiKukeyExceptionResponse } from 'src/decorators/api-kukey-exception-response'; type ReportEndPoints = MethodNames; @@ -33,6 +39,56 @@ const ReportDocsMap: Record = { type: GetReportResponseDto, }), ], + acceptReport: [ + ApiOperation({ + summary: '신고 승인', + description: '신고를 승인합니다.', + }), + ApiParam({ + name: 'reportId', + description: '신고 고유 ID', + }), + ApiBody({ + type: AcceptReportRequestDto, + }), + ApiResponse({ + status: 201, + description: '신고 승인 성공', + }), + ], + rejectReport: [ + ApiOperation({ + summary: '신고 거부', + description: '신고를 거부합니다.', + }), + ApiParam({ + name: 'reportId', + description: '신고 고유 ID', + }), + ApiResponse({ + status: 201, + description: '신고 거부 성공', + }), + ], + createReport: [ + ApiOperation({ + summary: '신고 생성', + description: '신고를 생성합니다.', + }), + ApiBody({ + type: CreateReportRequestDto, + }), + ApiResponse({ + status: 201, + description: '신고 생성 성공', + type: CreateReportResponseDto, + }), + ApiKukeyExceptionResponse([ + 'ALREADY_REPORTED', + 'POST_NOT_FOUND', + 'COMMENT_NOT_FOUND', + ]), + ], }; export function ReportDocs(target: typeof ReportController) { diff --git a/src/decorators/docs/timetable.decorator.ts b/src/decorators/docs/timetable.decorator.ts index eb756d75..47edf2ca 100644 --- a/src/decorators/docs/timetable.decorator.ts +++ b/src/decorators/docs/timetable.decorator.ts @@ -17,6 +17,7 @@ import { UpdateTimetableNameDto } from 'src/timetable/dto/update-timetable-name. import { GetTimetableByUserIdResponseDto } from 'src/timetable/dto/userId-timetable.dto'; import { TimetableController } from 'src/timetable/timetable.controller'; import { ApiKukeyExceptionResponse } from '../api-kukey-exception-response'; +import { GetTodayTimetableResponse } from 'src/timetable/dto/get-today-timetable-response.dto'; type TimetableEndPoints = MethodNames; @@ -104,6 +105,29 @@ const TimetableDocsMap: Record = { }), ApiKukeyExceptionResponse(['TIMETABLE_NOT_FOUND']), ], + getTodayTimetable: [ + ApiOperation({ + summary: '오늘의 시간표 조회', + description: '오늘의 시간표를 조회합니다.', + }), + ApiQuery({ + name: 'year', + type: 'string', + required: true, + description: '연도', + }), + ApiQuery({ + name: 'semester', + type: 'string', + required: true, + description: '학기', + }), + ApiResponse({ + status: 200, + description: '오늘의 시간표 조회 성공 시', + type: GetTodayTimetableResponse, + }), + ], getTimetableByTimetableId: [ ApiOperation({ summary: '시간표 ID로 시간표 관련 정보 조회', diff --git a/src/entities/banner.entity.ts b/src/entities/banner.entity.ts new file mode 100644 index 00000000..0e9fef2c --- /dev/null +++ b/src/entities/banner.entity.ts @@ -0,0 +1,14 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { CommonEntity } from './common.entity'; + +@Entity('banner') +export class BannerEntity extends CommonEntity { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: number; + + @Column('varchar', { nullable: false }) + imageUrl: string; + + @Column('varchar', { nullable: false }) + title: string; +} diff --git a/src/entities/comment.entity.ts b/src/entities/comment.entity.ts index 6aa8b48b..87077267 100644 --- a/src/entities/comment.entity.ts +++ b/src/entities/comment.entity.ts @@ -43,7 +43,7 @@ export class CommentEntity extends CommonEntity { @JoinColumn({ name: 'postId' }) @ManyToOne(() => PostEntity, (postEntity) => postEntity.comments, { - onDelete: 'CASCADE', + onDelete: 'NO ACTION', }) post: PostEntity; diff --git a/src/entities/friendship.entity.ts b/src/entities/friendship.entity.ts index 87726281..de73b91f 100644 --- a/src/entities/friendship.entity.ts +++ b/src/entities/friendship.entity.ts @@ -33,4 +33,7 @@ export class FriendshipEntity extends CommonEntity { @Column('boolean', { nullable: false }) areWeFriend: boolean; + + @Column('boolean', { nullable: false, default: false }) + isRead: boolean; } diff --git a/src/entities/report.entity.ts b/src/entities/report.entity.ts index b7080f30..31512704 100644 --- a/src/entities/report.entity.ts +++ b/src/entities/report.entity.ts @@ -27,6 +27,9 @@ export class ReportEntity extends CommonEntity { @Column('varchar', { nullable: false }) reason: string; + @Column('boolean', { default: false }) + isAccepted: boolean; + @JoinColumn({ name: 'reporterId' }) @ManyToOne(() => UserEntity, (userEntity) => userEntity.reports, { onDelete: 'SET NULL', diff --git a/src/entities/user-ban.entity.ts b/src/entities/user-ban.entity.ts new file mode 100644 index 00000000..dbc9ad85 --- /dev/null +++ b/src/entities/user-ban.entity.ts @@ -0,0 +1,34 @@ +import { CommonEntity } from 'src/entities/common.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity('user_ban') +export class UserBanEntity extends CommonEntity { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id: number; + + @Column({ nullable: false }) + userId: number; + + @JoinColumn({ name: 'userId' }) + @ManyToOne(() => UserEntity, (userEntity) => userEntity.userBans, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + user: UserEntity; + + @Column({ nullable: false }) + bannedAt: Date; + + @Column({ nullable: false }) + expiredAt: Date; + + @Column({ nullable: false }) + reason: string; +} diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index f2c19355..b42717f3 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -25,6 +25,7 @@ import { AttendanceCheckEntity } from './attendance-check.entity'; import { Role } from 'src/enums/role.enum'; import { CharacterEntity } from './character.entity'; import { UserLanguageEntity } from './user-language.entity'; +import { UserBanEntity } from 'src/entities/user-ban.entity'; @Entity('user') export class UserEntity extends CommonEntity { @@ -172,4 +173,9 @@ export class UserEntity extends CommonEntity { { cascade: true }, ) userLanguages: UserLanguageEntity[]; + + @OneToMany(() => UserBanEntity, (userBanEntity) => userBanEntity.user, { + cascade: true, + }) + userBans: UserBanEntity[]; } diff --git a/src/enums/course-category.enum.ts b/src/enums/course-category.enum.ts new file mode 100644 index 00000000..64497345 --- /dev/null +++ b/src/enums/course-category.enum.ts @@ -0,0 +1,5 @@ +export enum CourseCategory { + MAJOR = 'Major', + GENERAL_STUDIES = 'General Studies', + ACADEMIC_FOUNDATIONS = 'Academic Foundations', +} diff --git a/src/friendship/dto/friend-character.dto.ts b/src/friendship/dto/friend-character.dto.ts new file mode 100644 index 00000000..fd910eec --- /dev/null +++ b/src/friendship/dto/friend-character.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CharacterEntity } from 'src/entities/character.entity'; +import { CharacterType } from 'src/enums/character-type.enum'; + +export class FriendCharacterDto { + @ApiProperty({ description: '캐릭터 종류', enum: CharacterType }) + type: CharacterType; + + @ApiProperty({ description: '캐릭터 레벨' }) + level: number; + + constructor(character: CharacterEntity) { + this.type = character.type; + this.level = character.selectedLevel ?? character.level; + } +} diff --git a/src/friendship/dto/get-friend-response.dto.ts b/src/friendship/dto/get-friend-response.dto.ts index a84add22..a66c5a77 100644 --- a/src/friendship/dto/get-friend-response.dto.ts +++ b/src/friendship/dto/get-friend-response.dto.ts @@ -1,20 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { CharacterEntity } from 'src/entities/character.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { CharacterType } from 'src/enums/character-type.enum'; - -export class Character { - @ApiProperty({ description: '캐릭터 종류', enum: CharacterType }) - type: CharacterType; - - @ApiProperty({ description: '캐릭터 레벨' }) - level: number; - - constructor(character: CharacterEntity) { - this.type = character.type; - this.level = character.selectedLevel ?? character.level; - } -} +import { FriendCharacterDto } from './friend-character.dto'; export class GetFriendResponseDto { @ApiProperty({ description: 'freindship table의 PK' }) @@ -39,7 +25,7 @@ export class GetFriendResponseDto { country: string; @ApiProperty({ description: '유저 캐릭터' }) - character: Character; + character: FriendCharacterDto; constructor(friendshipId: number, friend: UserEntity) { this.friendshipId = friendshipId; @@ -49,6 +35,6 @@ export class GetFriendResponseDto { this.homeUniversity = friend.homeUniversity; this.major = friend.major; this.country = friend.country; - this.character = new Character(friend.character); + this.character = new FriendCharacterDto(friend.character); } } diff --git a/src/friendship/dto/get-received-friendship-request-count.dto.ts b/src/friendship/dto/get-received-friendship-request-count.dto.ts new file mode 100644 index 00000000..fc5e6305 --- /dev/null +++ b/src/friendship/dto/get-received-friendship-request-count.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { FriendCharacterDto } from './friend-character.dto'; +import { CharacterEntity } from 'src/entities/character.entity'; + +export class GetReceivedFriendshipRequestCountDto { + @ApiProperty({ description: '전체 받은 친구 요청 개수' }) + totalCount: number; + + @ApiProperty({ description: '확인하지 않은 받은 친구 요청 개수' }) + unreadCount: number; + + @ApiProperty({ + description: '가장 최근에 요청을 보낸 친구의 캐릭터 (최대 2개)', + }) + friendCharacters: FriendCharacterDto[]; + + constructor( + totalCount: number, + unreadCount: number, + friendChararcters: CharacterEntity[], + ) { + this.totalCount = totalCount; + this.unreadCount = unreadCount; + this.friendCharacters = friendChararcters.map((character) => { + return new FriendCharacterDto(character); + }); + } +} diff --git a/src/friendship/friendship.controller.ts b/src/friendship/friendship.controller.ts index 43ae6257..59bd45e4 100644 --- a/src/friendship/friendship.controller.ts +++ b/src/friendship/friendship.controller.ts @@ -29,6 +29,7 @@ import { TransactionManager } from 'src/decorators/manager.decorator'; import { EntityManager } from 'typeorm'; import { SearchUserRequestDto } from './dto/search-user-query.dto'; import { FriendshipDocs } from 'src/decorators/docs/friendship.decorator'; +import { GetReceivedFriendshipRequestCountDto } from './dto/get-received-friendship-request-count.dto'; @Controller('friendship') @ApiTags('friendship') @@ -43,19 +44,17 @@ export class FriendshipController { @User() user: AuthorizedUserDto, @Query('keyword') keyword?: string, ): Promise { - const userId = user.id; - return await this.friendshipService.getFriendList(userId, keyword); + return await this.friendshipService.getFriendList(user.id, keyword); } @UseGuards(JwtAuthGuard) @Get('search-user') async searchUserForFriendshipRequest( - @Query() searchUserRequestDto: SearchUserRequestDto, @User() user: AuthorizedUserDto, + @Query() searchUserRequestDto: SearchUserRequestDto, ): Promise { - const myId = user.id; return await this.friendshipService.searchUserForFriendshipRequest( - myId, + user.id, searchUserRequestDto, ); } @@ -63,8 +62,8 @@ export class FriendshipController { @UseGuards(JwtAuthGuard) @Get('friend-timetable') async getFriendTimetable( - @Query() getFriendTimetableRequestDto: GetFriendTimetableRequestDto, @User() user: AuthorizedUserDto, + @Query() getFriendTimetableRequestDto: GetFriendTimetableRequestDto, ): Promise { return await this.friendshipService.getFriendTimetable( user.id, @@ -77,24 +76,37 @@ export class FriendshipController { @UseInterceptors(TransactionInterceptor) async sendFriendshipRequest( @TransactionManager() transactionManager: EntityManager, - @Body() sendFriendDto: SendFriendshipRequestDto, @User() user: AuthorizedUserDto, + @Body() sendFriendDto: SendFriendshipRequestDto, ): Promise { - const toUsername = sendFriendDto.toUsername; return await this.friendshipService.sendFriendshipRequest( transactionManager, user, - toUsername, + sendFriendDto.toUsername, ); } @UseGuards(JwtAuthGuard) @Get('received') + @UseInterceptors(TransactionInterceptor) async getReceivedWaitingFriendList( + @TransactionManager() transactionManager: EntityManager, @User() user: AuthorizedUserDto, ): Promise { - const userId = user.id; - return await this.friendshipService.getReceivedWaitingFriendList(userId); + return await this.friendshipService.getReceivedWaitingFriendList( + transactionManager, + user.id, + ); + } + + @UseGuards(JwtAuthGuard) + @Get('received/count') + async getReceivedFriendshipRequestCount( + @User() user: AuthorizedUserDto, + ): Promise { + return await this.friendshipService.getReceivedFriendshipRequestCount( + user.id, + ); } @UseGuards(JwtAuthGuard) @@ -102,8 +114,7 @@ export class FriendshipController { async getSentWaitingFriendList( @User() user: AuthorizedUserDto, ): Promise { - const userId = user.id; - return await this.friendshipService.getSentWaitingFriendList(userId); + return await this.friendshipService.getSentWaitingFriendList(user.id); } @UseGuards(JwtAuthGuard) @@ -129,10 +140,9 @@ export class FriendshipController { @User() user: AuthorizedUserDto, @Param('friendshipId') friendshipId: number, ): Promise { - const userId = user.id; return await this.friendshipService.rejectFriendshipRequest( transactionManager, - userId, + user.id, friendshipId, ); } @@ -145,10 +155,9 @@ export class FriendshipController { @User() user: AuthorizedUserDto, @Param('friendshipId') friendshipId: number, ): Promise { - const userId = user.id; return await this.friendshipService.cancelFriendshipRequest( transactionManager, - userId, + user.id, friendshipId, ); } @@ -161,10 +170,9 @@ export class FriendshipController { @User() user: AuthorizedUserDto, @Param('friendshipId') friendshipId: number, ): Promise { - const userId = user.id; return await this.friendshipService.deleteFriendship( transactionManager, - userId, + user.id, friendshipId, ); } diff --git a/src/friendship/friendship.repository.ts b/src/friendship/friendship.repository.ts index 708d2eeb..f22dcccb 100644 --- a/src/friendship/friendship.repository.ts +++ b/src/friendship/friendship.repository.ts @@ -44,7 +44,7 @@ export class FriendshipRepository extends Repository { }); } - async findFriendshipByUserIdAndKeyword( + async findFriendshipsByUserIdAndKeyword( userId: number, keyword: string, ): Promise { @@ -85,31 +85,36 @@ export class FriendshipRepository extends Repository { .getMany(); } - async findReceivedFriendshipsByUserId( + async findSentFriendshipsByUserId( userId: number, ): Promise { return await this.find({ - where: [{ toUserId: userId, areWeFriend: false }], + where: [{ fromUserId: userId, areWeFriend: false }], relations: [ 'fromUser', 'toUser', 'fromUser.character', 'toUser.character', ], + order: { createdAt: 'DESC' }, }); } - async findSentFriendshipsByUserId( + async countReceivedFriendships( userId: number, - ): Promise { - return await this.find({ - where: [{ fromUserId: userId, areWeFriend: false }], - relations: [ - 'fromUser', - 'toUser', - 'fromUser.character', - 'toUser.character', - ], - }); + ): Promise<{ totalCount: number; unreadCount: number }> { + const result = await this.createQueryBuilder('friendship') + .select([ + 'COUNT(*) AS totalCount', + 'COALESCE(SUM(CASE WHEN friendship.isRead = false THEN 1 ELSE 0 END), 0) AS unreadCount', + ]) + .where('friendship.toUserId = :userId', { userId }) + .andWhere('friendship.areWeFriend = false') + .getRawOne(); + + return { + totalCount: parseInt(result.totalCount, 10), + unreadCount: parseInt(result.unreadCount, 10), + }; } } diff --git a/src/friendship/friendship.service.ts b/src/friendship/friendship.service.ts index 82899859..6d8bc999 100644 --- a/src/friendship/friendship.service.ts +++ b/src/friendship/friendship.service.ts @@ -1,3 +1,4 @@ +import { GetReceivedFriendshipRequestCountDto } from './dto/get-received-friendship-request-count.dto'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FriendshipRepository } from './friendship.repository'; @@ -37,7 +38,7 @@ export class FriendshipService { if (keyword) { friendships = - await this.friendshipRepository.findFriendshipByUserIdAndKeyword( + await this.friendshipRepository.findFriendshipsByUserIdAndKeyword( userId, keyword, ); @@ -156,24 +157,56 @@ export class FriendshipService { } async getReceivedWaitingFriendList( + transactionManager: EntityManager, userId: number, ): Promise { - const friendshipRequests = - await this.friendshipRepository.findReceivedFriendshipsByUserId(userId); + const receivedFriendshipRequests = await transactionManager.find( + FriendshipEntity, + { + where: { toUserId: userId, areWeFriend: false }, + relations: ['fromUser', 'fromUser.character'], + order: { createdAt: 'DESC' }, + }, + ); - if (friendshipRequests.length === 0) { + if (receivedFriendshipRequests.length === 0) { return []; } - const waitingFriendList = friendshipRequests.map((friendshipRequest) => { - const waitingFriend = friendshipRequest.fromUser; - return new GetWaitingFriendResponseDto( - friendshipRequest.id, - waitingFriend, - ); + await transactionManager.update( + FriendshipEntity, + { toUserId: userId, areWeFriend: false, isRead: false }, + { isRead: true }, + ); + + return receivedFriendshipRequests.map((r) => { + return new GetWaitingFriendResponseDto(r.id, r.fromUser); }); + } - return waitingFriendList; + async getReceivedFriendshipRequestCount( + userId: number, + ): Promise { + const { totalCount, unreadCount } = + await this.friendshipRepository.countReceivedFriendships(userId); + + const recentRequests = await this.friendshipRepository.find({ + where: { toUserId: userId, areWeFriend: false }, + relations: ['fromUser', 'fromUser.character'], + order: { createdAt: 'DESC' }, + select: ['fromUser'], + take: 2, + }); + + const recentCharacters = recentRequests.map((req) => { + return req.fromUser.character; + }); + + return new GetReceivedFriendshipRequestCountDto( + totalCount, + unreadCount, + recentCharacters, + ); } async getSentWaitingFriendList( diff --git a/src/home/banner/banner.controller.ts b/src/home/banner/banner.controller.ts new file mode 100644 index 00000000..cf20c28e --- /dev/null +++ b/src/home/banner/banner.controller.ts @@ -0,0 +1,51 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { BannerService } from './banner.service'; +import { bannerDto } from './dto/banner.dto'; +import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; +import { RolesGuard } from 'src/auth/guards/role.guard'; +import { Roles } from 'src/decorators/roles.decorator'; +import { Role } from 'src/enums/role.enum'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags } from '@nestjs/swagger'; +import { BannerDocs } from 'src/decorators/docs/banner.decorator'; +import { CreateBannerRequestDto } from 'src/home/banner/dto/create-banner-request.dto'; + +@Controller('banner') +@ApiTags('banner') +@BannerDocs +export class BannerController { + constructor(private readonly bannerService: BannerService) {} + + @Get() + async getBannerImages(): Promise { + return await this.bannerService.getBannerImages(); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.admin) + @UseInterceptors(FileInterceptor('image')) + @Post() + async createBannerImage( + @UploadedFile() image: Express.Multer.File, + @Body() body: CreateBannerRequestDto, + ): Promise { + return await this.bannerService.createBannerImage(image, body.title); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.admin) + @Delete('/:id') + async deleteBannerImage(@Param('id') id: number): Promise { + return await this.bannerService.deleteBannerImage(id); + } +} diff --git a/src/home/banner/banner.module.ts b/src/home/banner/banner.module.ts new file mode 100644 index 00000000..46c221be --- /dev/null +++ b/src/home/banner/banner.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { CommonModule } from 'src/common/common.module'; +import { BannerService } from './banner.service'; +import { BannerController } from './banner.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BannerEntity } from 'src/entities/banner.entity'; +import { UserRepository } from 'src/user/user.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([BannerEntity]), CommonModule], + providers: [BannerService, UserRepository], + controllers: [BannerController], +}) +export class BannerModule {} diff --git a/src/home/banner/banner.service.ts b/src/home/banner/banner.service.ts new file mode 100644 index 00000000..6554453e --- /dev/null +++ b/src/home/banner/banner.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FileService } from 'src/common/file.service'; +import { BannerEntity } from 'src/entities/banner.entity'; +import { Repository } from 'typeorm'; +import { bannerDto } from './dto/banner.dto'; +import { throwKukeyException } from 'src/utils/exception.util'; + +@Injectable() +export class BannerService { + constructor( + @InjectRepository(BannerEntity) + private readonly bannerRepository: Repository, + private readonly fileService: FileService, + ) {} + + async getBannerImages(): Promise { + const banners = await this.bannerRepository.find({ + order: { createdAt: 'DESC' }, + }); + return banners.map((banner) => { + return { + id: banner.id, + imageUrl: banner.imageUrl, + title: banner.title, + }; + }); + } + + async createBannerImage( + image: Express.Multer.File, + title: string, + ): Promise { + if (!image) { + throwKukeyException('BANNER_IMAGE_REQUIRED'); + } + if (!this.fileService.imagefilter(image)) { + throwKukeyException('NOT_IMAGE_FILE'); + } + const fileDir = await this.fileService.uploadFile(image, 'home', 'banner'); + const imageUrl = this.fileService.makeUrlByFileDir(fileDir); + const banner = this.bannerRepository.create({ + imageUrl, + title, + }); + const savedBanner = await this.bannerRepository.save(banner); + return { + id: savedBanner.id, + imageUrl: savedBanner.imageUrl, + title: savedBanner.title, + }; + } + + async deleteBannerImage(id: number): Promise { + const banner = await this.bannerRepository.findOne({ where: { id } }); + if (!banner) { + throwKukeyException('BANNER_NOT_FOUND'); + } + const deleted = await this.bannerRepository.softDelete({ id }); + if (deleted.affected === 0) { + throwKukeyException('BANNER_DELETE_FAILED'); + } + const fileDir = this.fileService.getFileDirFromUrl(banner.imageUrl); + await this.fileService.deleteFile(fileDir); + } +} diff --git a/src/home/banner/dto/banner.dto.ts b/src/home/banner/dto/banner.dto.ts new file mode 100644 index 00000000..645f8ce6 --- /dev/null +++ b/src/home/banner/dto/banner.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class bannerDto { + @ApiProperty({ description: '배너 id' }) + id: number; + + @ApiProperty({ description: '배너 이미지 URL' }) + imageUrl: string; + + @ApiProperty({ description: '배너 제목' }) + title: string; +} diff --git a/src/home/banner/dto/create-banner-request.dto.ts b/src/home/banner/dto/create-banner-request.dto.ts new file mode 100644 index 00000000..de89ad82 --- /dev/null +++ b/src/home/banner/dto/create-banner-request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty } from 'class-validator'; + +export class CreateBannerRequestDto { + @ApiProperty({ + description: '배너 이미지 파일', + type: 'string', + format: 'binary', + }) + image: any; + + @ApiProperty({ description: '배너 제목' }) + @IsNotEmpty() + title: string; +} diff --git a/src/home/club/club-like.repository.ts b/src/home/club/club-like.repository.ts index 3eef0a88..e933b9c4 100644 --- a/src/home/club/club-like.repository.ts +++ b/src/home/club/club-like.repository.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { CLUB_COUNT } from 'src/common/constant/club-count.constant'; import { ClubLikeEntity } from 'src/entities/club-like.entity'; import { DataSource, Repository } from 'typeorm'; @@ -15,7 +16,7 @@ export class ClubLikeRepository extends Repository { const oneWeekAgo = new Date(); oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); - // 일주일 간 좋아요 개수가 가장 많은 동아리 4개 반환, 좋아요 개수가 같은 경우 랜덤 선택 + // 일주일 간 좋아요 개수가 가장 많은 동아리 5개 반환, 좋아요 개수가 같은 경우 랜덤 선택 const topClubLikes = await this.createQueryBuilder('club_like') .select('club_like.clubId') .addSelect('COUNT(club_like.id)', 'likeCount') @@ -23,7 +24,7 @@ export class ClubLikeRepository extends Repository { .groupBy('club_like.clubId') .orderBy('likeCount', 'DESC') .addOrderBy('RAND()') - .limit(4) + .limit(CLUB_COUNT) .getRawMany(); return topClubLikes; diff --git a/src/home/club/club.controller.ts b/src/home/club/club.controller.ts index 663186fb..56b6ea97 100644 --- a/src/home/club/club.controller.ts +++ b/src/home/club/club.controller.ts @@ -34,6 +34,8 @@ import { CreateClubResponseDto } from './dto/create-club-response-dto'; import { UpdateClubRequestDto } from './dto/update-club-request-dto'; import { DeleteClubResponseDto } from './dto/delete-club-response-dto'; import { ClubDocs } from 'src/decorators/docs/club.decorator'; +import { GetClubDetailResponseDto } from './dto/get-club-detail-response.dto'; +import { GetClubDetailRequestDto } from './dto/get-club-detail-request.dto'; @Controller('club') @ApiTags('club') @@ -44,11 +46,37 @@ export class ClubController { @UseGuards(OptionalJwtAuthGuard) @Get() - async getClubList( + async getClubs( @User() user: AuthorizedUserDto | null, @Query() getClubRequestDto: GetClubRequestDto, ): Promise { - return await this.clubService.getClubList(user, getClubRequestDto); + return await this.clubService.getClubs(user, getClubRequestDto); + } + + @Get('hot') + async getHotClubs(): Promise { + return await this.clubService.getHotClubs(); + } + + @UseGuards(OptionalJwtAuthGuard) + @Get('recommend') + async getRecommendClubs( + @User() user: AuthorizedUserDto | null, + @Query() getRecommendClubRequestDto: GetRecommendClubRequestDto, + ): Promise { + return await this.clubService.getRecommendClubs( + user, + getRecommendClubRequestDto, + ); + } + + @UseGuards(OptionalJwtAuthGuard) + @Get('/:clubId') + async getClubDetail( + @User() user: AuthorizedUserDto | null, + @Query() getClubDetailRequestDto: GetClubDetailRequestDto, + ): Promise { + return await this.clubService.getClubDetail(user, getClubDetailRequestDto); } @UseGuards(JwtAuthGuard) @@ -67,23 +95,6 @@ export class ClubController { ); } - @Get('hot') - async getHotClubList(): Promise { - return await this.clubService.getHotClubList(); - } - - @UseGuards(OptionalJwtAuthGuard) - @Get('recommend') - async getRecommendClubList( - @User() user: AuthorizedUserDto | null, - @Query() getRecommendClubRequestDto: GetRecommendClubRequestDto, - ): Promise { - return await this.clubService.getRecommendClubList( - user, - getRecommendClubRequestDto, - ); - } - @UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.admin) @UseInterceptors(FileInterceptor('clubImage')) diff --git a/src/home/club/club.repository.ts b/src/home/club/club.repository.ts index 682470ad..f58c094b 100644 --- a/src/home/club/club.repository.ts +++ b/src/home/club/club.repository.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { CLUB_COUNT } from 'src/common/constant/club-count.constant'; import { ClubCategory } from 'src/common/types/club-category-type'; import { ClubEntity } from 'src/entities/club.entity'; import { Brackets, DataSource, Repository } from 'typeorm'; @@ -60,19 +61,19 @@ export class ClubRepository extends Repository { } async findClubsByAllLikesAndRandom(): Promise { - // allLikes 순서대로 4개 반환, allLikes 같은 경우 랜덤으로 선택 + // allLikes 순서대로 5개 반환, allLikes 같은 경우 랜덤으로 선택 return await this.createQueryBuilder('club') .orderBy('club.allLikes', 'DESC') .addOrderBy('RAND()') - .limit(4) + .limit(CLUB_COUNT) .getMany(); } async findClubsByRandom(): Promise { - // 랜덤 4개 반환 + // 랜덤 5개 반환 return await this.createQueryBuilder('club') .orderBy('RAND()') - .limit(4) + .limit(CLUB_COUNT) .getMany(); } diff --git a/src/home/club/club.service.ts b/src/home/club/club.service.ts index 1ff3acaf..0873791d 100644 --- a/src/home/club/club.service.ts +++ b/src/home/club/club.service.ts @@ -17,6 +17,9 @@ import { UpdateClubRequestDto } from './dto/update-club-request-dto'; import { UpdateClubResponseDto } from './dto/update-club-response-dto'; import { DeleteClubResponseDto } from './dto/delete-club-response-dto'; import { throwKukeyException } from 'src/utils/exception.util'; +import { CLUB_COUNT } from 'src/common/constant/club-count.constant'; +import { GetClubDetailResponseDto } from './dto/get-club-detail-response.dto'; +import { GetClubDetailRequestDto } from './dto/get-club-detail-request.dto'; @Injectable() export class ClubService { @@ -26,7 +29,7 @@ export class ClubService { private readonly fileService: FileService, ) {} - async getClubList( + async getClubs( user: AuthorizedUserDto | null, requestDto: GetClubRequestDto, ): Promise { @@ -50,7 +53,7 @@ export class ClubService { } // 현재 접속 중인 유저의 각 동아리에 대한 좋아요 여부 함께 반환. 유저 존재하지 않을 시 false - let clubList = clubs.map((club) => { + let response = clubs.map((club) => { const isLiked = club.clubLikes.some((clubLike) => user && isLogin && clubLike.user ? clubLike.user.id === user.id : false, ); @@ -59,9 +62,38 @@ export class ClubService { // 내가 좋아요를 누른 동아리만 보기 (유저 존재한다면) if (user && isLogin && wishList) { - clubList = clubList.filter((club) => club.isLiked === true); + response = response.filter((club) => club.isLiked === true); } - return clubList; + return response; + } + + async getClubDetail( + user: AuthorizedUserDto | null, + requetDto: GetClubDetailRequestDto, + ): Promise { + const { clubId, isLogin } = requetDto; + + // isLogin이 true이나 user가 없을 경우 refresh를 위해 401 던짐 + if (!user && isLogin) { + throwKukeyException('LOGIN_REQUIRED'); + } + + const club = await this.clubRepository.findOne({ + where: { id: clubId }, + relations: ['clubLikes', 'clubLikes.user'], + }); + + if (!club) { + throwKukeyException('CLUB_NOT_FOUND'); + } + + const isLiked = club.clubLikes.some((clubLike) => + user && isLogin && clubLike.user ? clubLike.user.id === user.id : false, + ); + + const linkCount = (club.instagramLink ? 1 : 0) + (club.youtubeLink ? 1 : 0); + + return new GetClubDetailResponseDto(club, isLiked, linkCount); } async toggleLikeClub( @@ -108,22 +140,22 @@ export class ClubService { } } - async getHotClubList(): Promise { + async getHotClubs(): Promise { const topLikedClubsInfo = await this.clubLikeRepository.findTopLikedClubsInfo(); const hotClubIds = topLikedClubsInfo.map((info) => info.clubId); const hotClubs = await this.clubRepository.findClubsByIdOrder(hotClubIds); - // hotClubs의 개수가 4개 미만인 경우, 전체 좋아요 개수 기준으로 높은 것부터 선택(좋아요 개수 같은 경우 랜덤 선택)하여 부족한 수를 채움 - const additionalClubsNeeded = 4 - hotClubs.length; + // hotClubs의 개수가 5개 미만인 경우, 전체 좋아요 개수 기준으로 높은 것부터 선택(좋아요 개수 같은 경우 랜덤 선택)하여 부족한 수를 채움 + const additionalCount = CLUB_COUNT - hotClubs.length; const allClubs = await this.clubRepository.findClubsByAllLikesAndRandom(); - // 전체 찜 개수 기준으로 가져온 동아리 중 hotClubs내에 이미 포함된 경우 제거 + // 전체 좋아요 개수 기준으로 가져온 동아리 중 hotClubs내에 이미 포함된 경우 제거 const existingClubIds = new Set(hotClubs.map((hc) => hc.id)); const additionalClubs = allClubs .filter((club) => !existingClubIds.has(club.id)) - .slice(0, additionalClubsNeeded); + .slice(0, additionalCount); const combinedClubs = [...hotClubs, ...additionalClubs]; let ranking = 1; @@ -133,7 +165,7 @@ export class ClubService { }); } - async getRecommendClubList( + async getRecommendClubs( user: AuthorizedUserDto | null, requestDto: GetRecommendClubRequestDto, ): Promise { @@ -153,7 +185,7 @@ export class ClubService { const likedClubCategories = await this.clubLikeRepository.findLikedClubCategories(userId); - // 좋아요 누른 동아리가 없을 경우 무작위로 4개 선정 + // 좋아요 누른 동아리가 없을 경우 무작위로 5개 선정 if (likedClubCategories.length === 0) { const recommendClubs = await this.clubRepository.findClubsByRandom(); return recommendClubs.map((club) => { @@ -162,10 +194,12 @@ export class ClubService { } const recommendClubList: GetRecommendClubResponseDto[] = []; - const clubsPerCategory = Math.ceil(4 / likedClubCategories.length); + const clubsPerCategory = Math.round( + CLUB_COUNT / likedClubCategories.length, + ); const shuffledCategories = this.shuffleArray(likedClubCategories); - // 좋아요 누른 동아리의 카테고리 수에 따라 비율에 맞게 4개 선정 + // 좋아요 누른 동아리의 카테고리 수에 따라 비율에 맞게 5개 선정 for (const category of shuffledCategories) { const clubs = await this.clubRepository.findClubsByCategoryAndRandom( category, @@ -176,11 +210,11 @@ export class ClubService { }); recommendClubList.push(...recommendClubs); - if (recommendClubs.length >= 4) break; + if (recommendClubs.length >= CLUB_COUNT) break; } // 부족한 경우, 랜덤으로 채움 - if (recommendClubList.length < 4) { + if (recommendClubList.length < CLUB_COUNT) { const existingClubNames = new Set(recommendClubList.map((rc) => rc.name)); const randomClubs = await this.clubRepository.findClubsByRandom(); const additionalClubs = randomClubs @@ -191,8 +225,8 @@ export class ClubService { recommendClubList.push(...additionalClubs); } - // 앞에서부터 4개를 랜덤한 순서로 반환 - return this.shuffleArray(recommendClubList.slice(0, 4)); + // 앞에서부터 5개를 랜덤한 순서로 반환 + return this.shuffleArray(recommendClubList.slice(0, CLUB_COUNT)); } async createClub( diff --git a/src/home/club/dto/get-club-detail-request.dto.ts b/src/home/club/dto/get-club-detail-request.dto.ts new file mode 100644 index 00000000..ac7ae441 --- /dev/null +++ b/src/home/club/dto/get-club-detail-request.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsNumber } from 'class-validator'; +import { ToBoolean } from 'src/decorators/to-boolean.decorator'; + +export class GetClubDetailRequestDto { + @IsNotEmpty() + @IsNumber() + @ApiProperty({ description: 'club id' }) + clubId: number; + + @IsNotEmpty() + @ToBoolean() + @IsBoolean() + @ApiPropertyOptional({ description: '로그인 여부' }) + isLogin: boolean; +} diff --git a/src/home/club/dto/get-club-detail-response.dto.ts b/src/home/club/dto/get-club-detail-response.dto.ts new file mode 100644 index 00000000..90ea0766 --- /dev/null +++ b/src/home/club/dto/get-club-detail-response.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ClubCategory } from 'src/common/types/club-category-type'; +import { ClubEntity } from 'src/entities/club.entity'; + +export class GetClubDetailResponseDto { + @ApiProperty({ description: 'club table의 PK' }) + clubId: number; + + @ApiProperty({ description: '동아리명' }) + name: string; + + @ApiProperty({ description: '동아리 카테고리' }) + category: ClubCategory; + + @ApiProperty({ description: '동아리 요약' }) + summary: string; + + @ApiProperty({ description: '정기 모임' }) + regularMeeting: string; + + @ApiProperty({ description: '모집 기간' }) + recruitmentPeriod: string; + + @ApiProperty({ description: '동아리 설명' }) + description: string; + + @ApiProperty({ description: '동아리 사진 URL 목록' }) + imageUrl: string[]; + + @ApiProperty({ description: '좋아요 개수' }) + likeCount: number; + + @ApiPropertyOptional({ description: '인스타그램 링크' }) + instagramLink: string; + + @ApiPropertyOptional({ description: '유튜브 링크' }) + youtubeLink: string; + + @ApiProperty({ description: '좋아요 여부' }) + isLiked: boolean; + + @ApiProperty({ description: '링크 개수' }) + linkCount: number; + + constructor(club: ClubEntity, isLiked: boolean, linkCount: number) { + this.clubId = club.id; + this.name = club.name; + this.category = club.category; + this.summary = club.summary; + this.regularMeeting = club.regularMeeting; + this.recruitmentPeriod = club.recruitmentPeriod; + this.description = club.description; + this.imageUrl = []; + this.imageUrl[0] = club.imageUrl; + this.likeCount = club.allLikes; + this.instagramLink = club.instagramLink; + this.youtubeLink = club.youtubeLink; + this.isLiked = isLiked; + this.linkCount = linkCount; + } +} diff --git a/src/home/club/dto/get-club-request.ts b/src/home/club/dto/get-club-request.ts index f2099d7e..de709072 100644 --- a/src/home/club/dto/get-club-request.ts +++ b/src/home/club/dto/get-club-request.ts @@ -1,4 +1,4 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsIn, @@ -36,6 +36,6 @@ export class GetClubRequestDto { @IsNotEmpty() @ToBoolean() @IsBoolean() - @ApiPropertyOptional({ description: '로그인 여부' }) + @ApiProperty({ description: '로그인 여부' }) isLogin: boolean; } diff --git a/src/home/club/dto/get-hot-club-response.dto.ts b/src/home/club/dto/get-hot-club-response.dto.ts index 70c4cdec..eba2041e 100644 --- a/src/home/club/dto/get-hot-club-response.dto.ts +++ b/src/home/club/dto/get-hot-club-response.dto.ts @@ -3,6 +3,7 @@ import { GetClubResponseDto } from './get-club-response.dto'; import { ClubEntity } from 'src/entities/club.entity'; export class GetHotClubResponseDto extends PickType(GetClubResponseDto, [ + 'clubId', 'name', 'summary', 'imageUrl', @@ -15,6 +16,7 @@ export class GetHotClubResponseDto extends PickType(GetClubResponseDto, [ constructor(club: ClubEntity, ranking: number) { super(); + this.clubId = club.id; this.name = club.name; this.summary = club.summary; this.imageUrl = club.imageUrl; diff --git a/src/home/club/dto/get-recommend-club-response.dto.ts b/src/home/club/dto/get-recommend-club-response.dto.ts index 6cefa30c..d92e55ab 100644 --- a/src/home/club/dto/get-recommend-club-response.dto.ts +++ b/src/home/club/dto/get-recommend-club-response.dto.ts @@ -3,6 +3,7 @@ import { GetClubResponseDto } from './get-club-response.dto'; import { ClubEntity } from 'src/entities/club.entity'; export class GetRecommendClubResponseDto extends PickType(GetClubResponseDto, [ + 'clubId', 'name', 'summary', 'imageUrl', @@ -12,6 +13,7 @@ export class GetRecommendClubResponseDto extends PickType(GetClubResponseDto, [ constructor(club: ClubEntity) { super(); + this.clubId = club.id; this.name = club.name; this.summary = club.summary; this.imageUrl = club.imageUrl; diff --git a/src/timetable/dto/delete-timetable-response.dto.ts b/src/timetable/dto/delete-timetable-response.dto.ts new file mode 100644 index 00000000..b38cb5d2 --- /dev/null +++ b/src/timetable/dto/delete-timetable-response.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { CommonDeleteResponseDto } from './common-delete-response.dto'; + +export class DeleteTimetableResponseDto extends CommonDeleteResponseDto { + constructor(deleted: boolean, timetableId?: number) { + super(deleted); + this.createdTimetableId = timetableId; + } + + @ApiPropertyOptional({ + description: '시간표 삭제 후 추가로 기본 시간표 생성될 때의 시간표 id', + }) + createdTimetableId?: number; +} diff --git a/src/timetable/dto/get-today-timetable-response.dto.ts b/src/timetable/dto/get-today-timetable-response.dto.ts new file mode 100644 index 00000000..18b682de --- /dev/null +++ b/src/timetable/dto/get-today-timetable-response.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +// 대표시간표의 오늘 수업 + 일정 +export class TodayCourseDto { + @ApiProperty({ description: '강의 이름' }) + courseName: string; + + @ApiProperty({ description: '교수님 성함' }) + professorName: string; + + @ApiPropertyOptional({ description: '강의실' }) + classroom?: string; + + @ApiPropertyOptional({ description: '시작 시간' }) + startTime?: string; + + @ApiPropertyOptional({ description: '종료 시간' }) + endTime?: string; +} + +export class TodayScheduleDto { + @ApiProperty({ description: '일정 이름' }) + scheduleName: string; + + @ApiProperty({ description: '시작 시간' }) + startTime: string; + + @ApiProperty({ description: '종료 시간' }) + endTime: string; + + @ApiPropertyOptional({ description: '위치' }) + location?: string; +} + +export class GetTodayTimetableResponse { + @ApiPropertyOptional({ + type: [TodayCourseDto], + description: '오늘의 강의 목록', + }) + courses?: TodayCourseDto[]; + + @ApiPropertyOptional({ + type: [TodayScheduleDto], + description: '오늘의 일정 목록', + }) + schedules?: TodayScheduleDto[]; + + constructor(courses: TodayCourseDto[], schedules: TodayScheduleDto[]) { + this.courses = courses; + this.schedules = schedules; + } +} diff --git a/src/timetable/timetable.controller.ts b/src/timetable/timetable.controller.ts index c501cbdd..f39c5071 100644 --- a/src/timetable/timetable.controller.ts +++ b/src/timetable/timetable.controller.ts @@ -28,6 +28,7 @@ import { TransactionInterceptor } from 'src/common/interceptors/transaction.inte import { TransactionManager } from 'src/decorators/manager.decorator'; import { EntityManager } from 'typeorm'; import { TimetableDocs } from 'src/decorators/docs/timetable.decorator'; +import { GetTodayTimetableResponse } from './dto/get-today-timetable-response.dto'; @Controller('timetable') @ApiTags('timetable') @@ -83,6 +84,15 @@ export class TimetableController { return await this.timetableService.getTimetableByUserId(user.id); } + // 오늘 시간표 가져오기 + @Get('/today') + async getTodayTimetable( + @Query() timetableDto: TimetableDto, + @User() user: AuthorizedUserDto, + ): Promise { + return await this.timetableService.getTodayTimetable(timetableDto, user); + } + // 특정 시간표 가져오기 @Get('/:timetableId') async getTimetableByTimetableId( diff --git a/src/timetable/timetable.service.ts b/src/timetable/timetable.service.ts index 0e25fd34..041cdecb 100644 --- a/src/timetable/timetable.service.ts +++ b/src/timetable/timetable.service.ts @@ -17,6 +17,8 @@ import { TimetableCourseEntity } from 'src/entities/timetable-course.entity'; import { isConflictingTime } from 'src/utils/time-utils'; import { DayType } from 'src/common/types/day-type.utils'; import { throwKukeyException } from 'src/utils/exception.util'; +import { DeleteTimetableResponseDto } from './dto/delete-timetable-response.dto'; +import { GetTodayTimetableResponse } from './dto/get-today-timetable-response.dto'; @Injectable() export class TimetableService { @@ -247,7 +249,7 @@ export class TimetableService { color: timetable.color, timetableName: timetable.timetableName, }; - timetable.timetableCourses.forEach((courseEntry) => { + for (const courseEntry of timetable.timetableCourses) { const { id: courseId, professorName, @@ -256,7 +258,25 @@ export class TimetableService { syllabus, } = courseEntry.course; - courseEntry.course.courseDetails.forEach((detailEntry) => { + if ( + !courseEntry.course.courseDetails || + courseEntry.course.courseDetails.length === 0 + ) { + getTimetableByTimetableIdResponse.courses.push({ + courseId, + professorName, + courseName, + courseCode, + syllabus, + day: null, + startTime: null, + endTime: null, + classroom: null, + }); + continue; + } + + for (const detailEntry of courseEntry.course.courseDetails) { const { day, startTime, endTime, classroom } = detailEntry; // 강의 정보 객체 @@ -271,8 +291,8 @@ export class TimetableService { endTime, classroom, }); - }); - }); + } + } // 스케줄 정보 객체 schedules.forEach((schedule) => { @@ -360,7 +380,7 @@ export class TimetableService { transactionManager: EntityManager, timetableId: number, user: AuthorizedUserDto, - ): Promise { + ): Promise { const timetable = await transactionManager.findOne(TimetableEntity, { where: { id: timetableId, userId: user.id }, relations: ['timetableCourses', 'schedules'], // soft-remove cascade 조건을 위해 추가 @@ -391,7 +411,33 @@ export class TimetableService { } } await transactionManager.softRemove(timetable); - return new CommonDeleteResponseDto(true); + + // 삭제 후에 해당 학기에 시간표가 하나도 존재하지 않으면 추가로 하나 생성 (그 시간표가 대표시간표) + const remainingTimetable = await transactionManager.findOne( + TimetableEntity, + { + where: { + userId: user.id, + semester: timetable.semester, + year: timetable.year, + }, + }, + ); + + if (!remainingTimetable) { + const newTimetable = transactionManager.create(TimetableEntity, { + userId: user.id, + timetableName: 'timetable 1', + semester: timetable.semester, + year: timetable.year, + mainTimetable: true, + }); + + await transactionManager.save(newTimetable); + return new DeleteTimetableResponseDto(true, newTimetable.id); + } + + return new DeleteTimetableResponseDto(true, null); } async getMainTimetable( @@ -408,7 +454,16 @@ export class TimetableService { }); if (!mainTimetable) { - throwKukeyException('TIMETABLE_NOT_FOUND'); + // 대표 시간표 없으면 시간표 하나 바로 생성 + return await this.createTimetable( + this.timetableRepository.manager, + { + timetableName: 'timetable 1', + semester: timetableDto.semester, + year: timetableDto.year, + }, + user, + ); } return mainTimetable; } @@ -497,4 +552,52 @@ export class TimetableService { newMainTimetable.mainTimetable = true; return newMainTimetable; } + + async getTodayTimetable( + timetableDto: TimetableDto, + user: AuthorizedUserDto, + ): Promise { + const today = new Date().toLocaleDateString('en-US', { + weekday: 'short', + }) as DayType; + + const mainTimetable = await this.getMainTimetable(timetableDto, user); + + const todayCourses = await this.timetableCourseRepository + .createQueryBuilder('timetableCourse') + .leftJoinAndSelect('timetableCourse.course', 'course') + .leftJoinAndSelect('course.courseDetails', 'courseDetail') + .where('timetableCourse.timetableId = :timetableId', { + timetableId: mainTimetable.id, + }) + .andWhere('courseDetail.day = :today', { today }) + .getMany(); + + const schedules = await this.scheduleService.getScheduleByTimetableId( + mainTimetable.id, + ); + const todaySchedules = schedules.filter( + (schedule) => schedule.day === today, + ); + + const todayCoursesResponse = todayCourses.map((timetableCourse) => ({ + courseName: timetableCourse.course.courseName, + classroom: timetableCourse.course.courseDetails[0].classroom, + startTime: timetableCourse.course.courseDetails[0].startTime, + endTime: timetableCourse.course.courseDetails[0].endTime, + professorName: timetableCourse.course.professorName, + })); + + const todaySchedulesResponse = todaySchedules.map((schedule) => ({ + scheduleName: schedule.title, + startTime: schedule.startTime, + endTime: schedule.endTime, + location: schedule.location, + })); + + return new GetTodayTimetableResponse( + todayCoursesResponse, + todaySchedulesResponse, + ); + } } diff --git a/src/user/user-ban.service.ts b/src/user/user-ban.service.ts new file mode 100644 index 00000000..f42f0bff --- /dev/null +++ b/src/user/user-ban.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { UserBanEntity } from 'src/entities/user-ban.entity'; +import { Notice } from 'src/notice/enum/notice.enum'; +import { NoticeService } from 'src/notice/notice.service'; +import { EntityManager, MoreThanOrEqual, Repository } from 'typeorm'; + +@Injectable() +export class UserBanService { + constructor( + @InjectRepository(UserBanEntity) + private readonly userBanRepository: Repository, + private readonly noticeService: NoticeService, + ) {} + + async banUser( + transactionManager: EntityManager, + userId: number, + reason: string, + expireDays: number, + ): Promise { + const bannedAt = new Date(); + const expiredAt = new Date( + bannedAt.getTime() + expireDays * 24 * 60 * 60 * 1000, + ); + + await transactionManager.save(UserBanEntity, { + userId, + bannedAt, + expiredAt, + reason, + }); + + // 알림 발송 + await this.noticeService.emitNotice( + userId, + 'You have been banned!', + Notice.ban, + null, + transactionManager, + ); + } + + async checkUserBan(userId: number): Promise { + const userBan = await this.userBanRepository.findOne({ + where: { + userId, + expiredAt: MoreThanOrEqual(new Date()), + }, + }); + + return !!userBan; + } +} diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 45ef2a4a..562c57a5 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -8,6 +8,9 @@ import { PointHistoryEntity } from 'src/entities/point-history.entity'; import { CharacterEntity } from 'src/entities/character.entity'; import { PointService } from './point.service'; import { UserLanguageEntity } from 'src/entities/user-language.entity'; +import { UserBanEntity } from 'src/entities/user-ban.entity'; +import { NoticeModule } from 'src/notice/notice.module'; +import { UserBanService } from 'src/user/user-ban.service'; @Module({ imports: [ @@ -16,10 +19,12 @@ import { UserLanguageEntity } from 'src/entities/user-language.entity'; PointHistoryEntity, CharacterEntity, UserLanguageEntity, + UserBanEntity, ]), + NoticeModule, ], controllers: [UserController], - providers: [UserService, PointService, UserRepository], - exports: [UserService, PointService, UserRepository], + providers: [UserService, PointService, UserRepository, UserBanService], + exports: [UserService, PointService, UserRepository, UserBanService], }) export class UserModule {} diff --git a/src/user/user.repository.ts b/src/user/user.repository.ts index 979aeef9..1533d962 100644 --- a/src/user/user.repository.ts +++ b/src/user/user.repository.ts @@ -124,4 +124,8 @@ export class UserRepository extends Repository { return updateResult.affected ? true : false; } + + async getTotalUsersCount(): Promise { + return await this.count(); + } } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 9336c24d..dec9d135 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -442,4 +442,8 @@ export class UserService { return new SelectCharacterLevelResponseDto(selectedLevel); } + + async getTotalUsersCount(): Promise { + return await this.userRepository.getTotalUsersCount(); + } } diff --git a/src/utils/exception.util.ts b/src/utils/exception.util.ts index c268328e..fc1a5bed 100644 --- a/src/utils/exception.util.ts +++ b/src/utils/exception.util.ts @@ -207,6 +207,12 @@ export const kukeyExceptions = createKukeyExceptions({ errorCode: 2016, statusCode: 500, }, + USER_BANNED: { + name: 'USER_BANNED', + message: 'User is banned.', + errorCode: 2017, + statusCode: 403, + }, // - 21xx : Point POINT_NOT_ENOUGH: { name: 'POINT_NOT_ENOUGH', @@ -309,6 +315,12 @@ export const kukeyExceptions = createKukeyExceptions({ errorCode: 3004, statusCode: 409, }, + COURSE_SEARCH_STRATEGY_NOT_FOUND: { + name: 'COURSE_SEARCH_STRATEGY_NOT_FOUND', + message: 'Course search strategy not found.', + errorCode: 3005, + statusCode: 404, + }, // - 31xx : Schedule INVALID_TIME_RANGE: { name: 'INVALID_TIME_RANGE', @@ -654,6 +666,31 @@ export const kukeyExceptions = createKukeyExceptions({ errorCode: 5102, statusCode: 500, }, + // - 52xx : Banner + BANNER_NOT_FOUND: { + name: 'BANNER_NOT_FOUND', + message: 'Banner not found.', + errorCode: 5200, + statusCode: 404, + }, + BANNER_DELETE_FAILED: { + name: 'BANNER_DELETE_FAILED', + message: 'Banner delete failed.', + errorCode: 5201, + statusCode: 500, + }, + BANNER_UPDATE_FAILED: { + name: 'BANNER_UPDATE_FAILED', + message: 'Banner update failed.', + errorCode: 5202, + statusCode: 500, + }, + BANNER_IMAGE_REQUIRED: { + name: 'BANNER_IMAGE_REQUIRED', + message: 'Banner image required.', + errorCode: 5203, + statusCode: 400, + }, // 6xxx : S3, File 관련 예외 NOT_IMAGE_FILE: { name: 'NOT_IMAGE_FILE',