From 23ce8e22a86cc8363814fb177e897056924fa965 Mon Sep 17 00:00:00 2001 From: Son Daehyeon Date: Mon, 16 Sep 2024 16:49:19 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=82=98=EB=8F=84=20=EB=AD=90=20?= =?UTF-8?q?=ED=95=9C=EC=A7=80=20=EB=AA=B0=EB=9D=BC=EC=9A=A9...=20=EC=9D=B4?= =?UTF-8?q?=EA=B2=83=EC=A0=80=EA=B2=83=EB=93=A4=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=96=88=EC=96=B4=EC=9A=94=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(db): mongodb의 username 외 2개의 필드를 optional로 수정 * refactor: 몰라요... 이것저것 * feat: 검색 추가 * fix: 테스트 코드 버그 수정 --- src/common/config/mongo.config.ts | 12 +-- .../util/scheduler/purge-unused-image.job.ts | 11 +- .../project/controller/project.controller.ts | 17 ++- src/domain/activity/project/dto/index.ts | 1 + .../dto/request/get-projects.request.dto.ts | 3 + .../request/search-projects.request.dto.ts | 13 +++ .../project/repository/project.repository.ts | 15 ++- .../project/service/project.admin.service.ts | 2 +- .../project/service/project.service.ts | 12 ++- .../social/controller/social.controller.ts | 26 ++++- src/domain/activity/social/dto/index.ts | 3 + .../dto/request/get-socials.request.dto.ts | 16 +++ .../dto/request/search-socials.request.dto.ts | 13 +++ .../response/get-socials-page.response.dto.ts | 9 ++ .../social/repository/social.repository.ts | 22 +++- .../social/service/social.admin.service.ts | 2 +- .../activity/social/service/social.service.ts | 19 +++- .../study/controller/study.controller.ts | 13 ++- src/domain/activity/study/dto/index.ts | 1 + .../dto/request/get-studies.request.dto.ts | 3 + .../dto/request/search-study.request.dto.ts | 13 +++ .../response/get-categories.response.dto.ts | 25 ++++- .../study/repository/study.repository.ts | 17 ++- .../study/service/study.admin.service.ts | 7 +- .../activity/study/service/study.service.ts | 19 +++- src/domain/auth/exception/index.ts | 1 - .../controller/member.admin.controller.ts | 46 +++++--- src/domain/member/dto/index.ts | 3 + .../get-members-for-admin.request.dto.ts | 16 +++ .../dto/request/search-members.request.dto.ts | 13 +++ ...get-members-for-admin-page.response.dto.ts | 9 ++ .../get-waiting-members.response.dto.ts | 6 ++ .../member/repository/member.repository.ts | 100 +++++++++++++++++- src/domain/member/schema/Role.ts | 23 ---- .../member/service/member.admin.service.ts | 36 ++++--- test/member/admin-service.test.ts | 62 +---------- .../mock/repository/member-repository.mock.ts | 8 ++ 37 files changed, 465 insertions(+), 152 deletions(-) create mode 100644 src/domain/activity/project/dto/request/search-projects.request.dto.ts create mode 100644 src/domain/activity/social/dto/request/get-socials.request.dto.ts create mode 100644 src/domain/activity/social/dto/request/search-socials.request.dto.ts create mode 100644 src/domain/activity/social/dto/response/get-socials-page.response.dto.ts create mode 100644 src/domain/activity/study/dto/request/search-study.request.dto.ts create mode 100644 src/domain/member/dto/request/get-members-for-admin.request.dto.ts create mode 100644 src/domain/member/dto/request/search-members.request.dto.ts create mode 100644 src/domain/member/dto/response/get-members-for-admin-page.response.dto.ts diff --git a/src/common/config/mongo.config.ts b/src/common/config/mongo.config.ts index baf23fd..a2101e2 100644 --- a/src/common/config/mongo.config.ts +++ b/src/common/config/mongo.config.ts @@ -9,9 +9,9 @@ export class MongoConfig implements MongooseOptionsFactory { createMongooseOptions(): MongooseModuleOptions { const host = this.configService.getOrThrow('mongo.host'); const port = this.configService.getOrThrow('mongo.port'); - const username = this.configService.getOrThrow('mongo.username'); - const password = this.configService.getOrThrow('mongo.password'); - const authSource = this.configService.getOrThrow('mongo.authSource'); + const username = this.configService.get('mongo.username'); + const password = this.configService.get('mongo.password'); + const authSource = this.configService.get('mongo.authSource'); const database = this.configService.getOrThrow('mongo.database'); return { @@ -22,9 +22,9 @@ export class MongoConfig implements MongooseOptionsFactory { private buildConnectionString( host: string, port: number, - username: string, - password: string, - authSource: string, + username: string | undefined, + password: string | undefined, + authSource: string | undefined, database: string, ): string { return 'mongodb://${credential}${host}/${database}${otherOptions}' diff --git a/src/domain/activity/common/util/scheduler/purge-unused-image.job.ts b/src/domain/activity/common/util/scheduler/purge-unused-image.job.ts index aab4bb1..607f267 100644 --- a/src/domain/activity/common/util/scheduler/purge-unused-image.job.ts +++ b/src/domain/activity/common/util/scheduler/purge-unused-image.job.ts @@ -33,14 +33,17 @@ export class PurgeUnusedImageJob { private async job() { const usedImages = [ - ...(await this.socialRepository.findAll()) - .flatMap((member) => member.contents) - .map((content) => content.image) - .map((image) => this.activityService.extractKeyFromUrl(image)), ...(await this.projectRepository.findAll()) .map((project) => project.content) .flatMap((content) => this.toImagesFromHtml(content)) .map((image) => this.activityService.extractKeyFromUrl(image)), + ...(await this.projectRepository.findAll()) + .map((project) => project.image) + .map((image) => this.activityService.extractKeyFromUrl(image)), + ...(await this.socialRepository.findAll()) + .flatMap((social) => social.contents) + .map((content) => content.image) + .map((image) => this.activityService.extractKeyFromUrl(image)), ]; const savedImages = await this.activityService.getKeys(); diff --git a/src/domain/activity/project/controller/project.controller.ts b/src/domain/activity/project/controller/project.controller.ts index d20def9..6099f91 100644 --- a/src/domain/activity/project/controller/project.controller.ts +++ b/src/domain/activity/project/controller/project.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import { @@ -7,6 +7,7 @@ import { GetProjectsPageResponseDto, GetProjectsRequestDto, GetProjectsResponseDto, + SearchProjectsRequestDto, } from '@wink/activity/dto'; import { ProjectNotFoundException } from '@wink/activity/exception'; import { ProjectService } from '@wink/activity/service'; @@ -23,7 +24,7 @@ export class ProjectController { @ApiProperty({ type: GetProjectRequestDto }) @ApiCustomResponse(GetProjectResponseDto) @ApiCustomErrorResponse([ProjectNotFoundException]) - async getProject(@Body() request: GetProjectRequestDto): Promise { + async getProject(@Query() request: GetProjectRequestDto): Promise { return this.projectService.getProject(request); } @@ -34,11 +35,21 @@ export class ProjectController { return this.projectService.getProjectsPage(); } + @Get('/search') + @ApiOperation({ summary: '프로젝트 검색' }) + @ApiProperty({ type: SearchProjectsRequestDto }) + @ApiCustomResponse(GetProjectsResponseDto) + async searchProjects( + @Query() request: SearchProjectsRequestDto, + ): Promise { + return this.projectService.searchProjects(request); + } + @Get() @ApiOperation({ summary: '프로젝트 목록' }) @ApiProperty({ type: GetProjectsRequestDto }) @ApiCustomResponse(GetProjectsResponseDto) - async getProjects(@Body() request: GetProjectsRequestDto): Promise { + async getProjects(@Query() request: GetProjectsRequestDto): Promise { return this.projectService.getProjects(request); } } diff --git a/src/domain/activity/project/dto/index.ts b/src/domain/activity/project/dto/index.ts index 1d24674..e8cefac 100644 --- a/src/domain/activity/project/dto/index.ts +++ b/src/domain/activity/project/dto/index.ts @@ -3,6 +3,7 @@ export * from './request/create-project.request.dto'; export * from './request/update-project.request.dto'; export * from './request/get-project.request.dto'; export * from './request/get-projects.request.dto'; +export * from './request/search-projects.request.dto'; export * from './request/delete-project.request.dto'; // Response diff --git a/src/domain/activity/project/dto/request/get-projects.request.dto.ts b/src/domain/activity/project/dto/request/get-projects.request.dto.ts index 88ee42f..962a72f 100644 --- a/src/domain/activity/project/dto/request/get-projects.request.dto.ts +++ b/src/domain/activity/project/dto/request/get-projects.request.dto.ts @@ -2,11 +2,14 @@ import { ApiProperty } from '@nestjs/swagger'; import { CommonValidation, TypeValidation } from '@wink/validation'; +import { Type } from 'class-transformer'; + export class GetProjectsRequestDto { @ApiProperty({ description: '페이지', example: 1, }) + @Type(() => Number) @CommonValidation.IsNotEmpty() @TypeValidation.IsNumber() page!: number; diff --git a/src/domain/activity/project/dto/request/search-projects.request.dto.ts b/src/domain/activity/project/dto/request/search-projects.request.dto.ts new file mode 100644 index 0000000..0ec1b48 --- /dev/null +++ b/src/domain/activity/project/dto/request/search-projects.request.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { CommonValidation, TypeValidation } from '@wink/validation'; + +export class SearchProjectsRequestDto { + @ApiProperty({ + description: '검색어', + example: '김', + }) + @CommonValidation.IsNotEmpty() + @TypeValidation.IsString() + query!: string; +} diff --git a/src/domain/activity/project/repository/project.repository.ts b/src/domain/activity/project/repository/project.repository.ts index f74d792..48aac63 100644 --- a/src/domain/activity/project/repository/project.repository.ts +++ b/src/domain/activity/project/repository/project.repository.ts @@ -15,6 +15,10 @@ export class ProjectRepository { } // Read + async count(): Promise { + return this.projectModel.countDocuments().exec(); + } + async findAll(): Promise { return this.projectModel.find().sort({ createdAt: -1 }).exec(); } @@ -23,8 +27,8 @@ export class ProjectRepository { return this.projectModel .find() .sort({ createdAt: -1 }) - .skip((page - 1) * 10) - .limit(10) + .skip((page - 1) * 15) + .limit(15) .exec(); } @@ -32,6 +36,13 @@ export class ProjectRepository { return this.projectModel.findById(id).exec(); } + async findAllByContainsTitle(title: string): Promise { + return this.projectModel + .find({ title: { $regex: title, $options: 'i' } }) + .sort({ createdAt: -1 }) + .exec(); + } + // Delete async deleteById(id: string): Promise { await this.projectModel.deleteOne({ _id: id }).exec(); diff --git a/src/domain/activity/project/service/project.admin.service.ts b/src/domain/activity/project/service/project.admin.service.ts index 3748d99..5a70cfc 100644 --- a/src/domain/activity/project/service/project.admin.service.ts +++ b/src/domain/activity/project/service/project.admin.service.ts @@ -27,7 +27,7 @@ export class ProjectAdminService { member: Member, { title, content, tags, image }: CreateProjectRequestDto, ): Promise { - if (!(await this.projectRepository.existsByTitle(title))) { + if (await this.projectRepository.existsByTitle(title)) { throw new AlreadyExistsProjectException(); } diff --git a/src/domain/activity/project/service/project.service.ts b/src/domain/activity/project/service/project.service.ts index 3f9813a..fdfb8d6 100644 --- a/src/domain/activity/project/service/project.service.ts +++ b/src/domain/activity/project/service/project.service.ts @@ -6,6 +6,7 @@ import { GetProjectsPageResponseDto, GetProjectsRequestDto, GetProjectsResponseDto, + SearchProjectsRequestDto, } from '@wink/activity/dto'; import { ProjectNotFoundException } from '@wink/activity/exception'; import { ProjectRepository } from '@wink/activity/repository'; @@ -25,10 +26,15 @@ export class ProjectService { } async getProjectsPage(): Promise { - const projects = await this.projectRepository.findAll(); - const page = Math.ceil(projects.length / 10); + const count = await this.projectRepository.count(); - return { page }; + return { page: Math.ceil(count / 15) }; + } + + async searchProjects({ query }: SearchProjectsRequestDto): Promise { + const projects = await this.projectRepository.findAllByContainsTitle(query); + + return { projects }; } async getProjects({ page }: GetProjectsRequestDto): Promise { diff --git a/src/domain/activity/social/controller/social.controller.ts b/src/domain/activity/social/controller/social.controller.ts index 890629f..ac37b9e 100644 --- a/src/domain/activity/social/controller/social.controller.ts +++ b/src/domain/activity/social/controller/social.controller.ts @@ -1,10 +1,13 @@ -import { Body, Controller, Get } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import { GetSocialRequestDto, GetSocialResponseDto, + GetSocialsPageResponseDto, + GetSocialsRequestDto, GetSocialsResponseDto, + SearchSocialsRequestDto, } from '@wink/activity/dto'; import { SocialNotFoundException } from '@wink/activity/exception'; import { SocialService } from '@wink/activity/service'; @@ -21,14 +24,29 @@ export class SocialController { @ApiProperty({ type: GetSocialRequestDto }) @ApiCustomResponse(GetSocialResponseDto) @ApiCustomErrorResponse([SocialNotFoundException]) - async getProject(@Body() request: GetSocialRequestDto): Promise { + async getSocial(@Query() request: GetSocialRequestDto): Promise { return this.socialService.getSocial(request); } + @Get('/max') + @ApiOperation({ summary: '친목 활동 최대 페이지' }) + @ApiCustomResponse(GetSocialsPageResponseDto) + async getSocialsPage(): Promise { + return this.socialService.getSocialsPage(); + } + + @Get('/search') + @ApiOperation({ summary: '친목 활동 검색' }) + @ApiProperty({ type: SearchSocialsRequestDto }) + @ApiCustomResponse(GetSocialsResponseDto) + async searchProjects(@Query() request: SearchSocialsRequestDto): Promise { + return this.socialService.searchSocials(request); + } + @Get() @ApiOperation({ summary: '친목 활동 목록' }) @ApiCustomResponse(GetSocialsResponseDto) - async getSocials(): Promise { - return this.socialService.getSocials(); + async getSocials(@Query() request: GetSocialsRequestDto): Promise { + return this.socialService.getSocials(request); } } diff --git a/src/domain/activity/social/dto/index.ts b/src/domain/activity/social/dto/index.ts index 6ed2c43..6e1cf00 100644 --- a/src/domain/activity/social/dto/index.ts +++ b/src/domain/activity/social/dto/index.ts @@ -2,9 +2,12 @@ export * from './request/create-social.request.dto'; export * from './request/delete-social.request.dto'; export * from './request/get-social.request.dto'; +export * from './request/get-socials.request.dto'; +export * from './request/search-socials.request.dto'; export * from './request/update-social.request.dto'; // Response export * from './response/create-social.response.dto'; export * from './response/get-social.response.dto'; export * from './response/get-socials.response.dto'; +export * from './response/get-socials-page.response.dto'; diff --git a/src/domain/activity/social/dto/request/get-socials.request.dto.ts b/src/domain/activity/social/dto/request/get-socials.request.dto.ts new file mode 100644 index 0000000..7f7c4cc --- /dev/null +++ b/src/domain/activity/social/dto/request/get-socials.request.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { CommonValidation, TypeValidation } from '@wink/validation'; + +import { Type } from 'class-transformer'; + +export class GetSocialsRequestDto { + @ApiProperty({ + description: '페이지', + example: 1, + }) + @Type(() => Number) + @CommonValidation.IsNotEmpty() + @TypeValidation.IsNumber() + page!: number; +} diff --git a/src/domain/activity/social/dto/request/search-socials.request.dto.ts b/src/domain/activity/social/dto/request/search-socials.request.dto.ts new file mode 100644 index 0000000..af9e807 --- /dev/null +++ b/src/domain/activity/social/dto/request/search-socials.request.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { CommonValidation, TypeValidation } from '@wink/validation'; + +export class SearchSocialsRequestDto { + @ApiProperty({ + description: '검색어', + example: '김', + }) + @CommonValidation.IsNotEmpty() + @TypeValidation.IsString() + query!: string; +} diff --git a/src/domain/activity/social/dto/response/get-socials-page.response.dto.ts b/src/domain/activity/social/dto/response/get-socials-page.response.dto.ts new file mode 100644 index 0000000..4852794 --- /dev/null +++ b/src/domain/activity/social/dto/response/get-socials-page.response.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class GetSocialsPageResponseDto { + @ApiProperty({ + description: '최대 페이지', + example: 1, + }) + page!: number; +} diff --git a/src/domain/activity/social/repository/social.repository.ts b/src/domain/activity/social/repository/social.repository.ts index fda948c..3e2de6d 100644 --- a/src/domain/activity/social/repository/social.repository.ts +++ b/src/domain/activity/social/repository/social.repository.ts @@ -15,8 +15,28 @@ export class SocialRepository { } // Read + async count(): Promise { + return this.socialModel.countDocuments().exec(); + } + async findAll(): Promise { - return this.socialModel.find().sort({ createdAt: -1 }).limit(6).exec(); + return this.socialModel.find().sort({ createdAt: -1 }).exec(); + } + + async findAllPage(page: number): Promise { + return this.socialModel + .find() + .sort({ createdAt: -1 }) + .skip((page - 1) * 10) + .limit(10) + .exec(); + } + + async findAllByContainsTitle(title: string): Promise { + return this.socialModel + .find({ title: { $regex: title, $options: 'i' } }) + .sort({ createdAt: -1 }) + .exec(); } async findById(id: string): Promise { diff --git a/src/domain/activity/social/service/social.admin.service.ts b/src/domain/activity/social/service/social.admin.service.ts index f52d9bd..f9f68c6 100644 --- a/src/domain/activity/social/service/social.admin.service.ts +++ b/src/domain/activity/social/service/social.admin.service.ts @@ -27,7 +27,7 @@ export class SocialAdminService { member: Member, { title, contents }: CreateSocialRequestDto, ): Promise { - if (!(await this.socialRepository.existsByTitle(title))) { + if (await this.socialRepository.existsByTitle(title)) { throw new AlreadyExistsSocialException(); } diff --git a/src/domain/activity/social/service/social.service.ts b/src/domain/activity/social/service/social.service.ts index c7c84c2..a1f27ea 100644 --- a/src/domain/activity/social/service/social.service.ts +++ b/src/domain/activity/social/service/social.service.ts @@ -3,7 +3,10 @@ import { Injectable } from '@nestjs/common'; import { GetSocialRequestDto, GetSocialResponseDto, + GetSocialsPageResponseDto, + GetSocialsRequestDto, GetSocialsResponseDto, + SearchSocialsRequestDto, } from '@wink/activity/dto'; import { SocialNotFoundException } from '@wink/activity/exception'; import { SocialRepository } from '@wink/activity/repository'; @@ -22,9 +25,21 @@ export class SocialService { return { social }; } - async getSocials(): Promise { - const socials = await this.socialRepository.findAll(); + async getSocials({ page }: GetSocialsRequestDto): Promise { + const socials = await this.socialRepository.findAllPage(page); return { socials }; } + + async searchSocials({ query }: SearchSocialsRequestDto): Promise { + const socials = await this.socialRepository.findAllByContainsTitle(query); + + return { socials }; + } + + async getSocialsPage(): Promise { + const count = await this.socialRepository.count(); + + return { page: Math.ceil(count / 10) }; + } } diff --git a/src/domain/activity/study/controller/study.controller.ts b/src/domain/activity/study/controller/study.controller.ts index d97962b..7ba0c52 100644 --- a/src/domain/activity/study/controller/study.controller.ts +++ b/src/domain/activity/study/controller/study.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import { @@ -6,6 +6,7 @@ import { GetStudiesPageResponseDto, GetStudiesRequestDto, GetStudiesResponseDto, + SearchStudyRequestDto, } from '@wink/activity/dto'; import { StudyService } from '@wink/activity/service'; @@ -30,11 +31,19 @@ export class StudyController { return this.studyService.getStudiesPage(); } + @Get('/search') + @ApiOperation({ summary: '스터디 활동 검색' }) + @ApiProperty({ type: SearchStudyRequestDto }) + @ApiCustomResponse(GetStudiesResponseDto) + async searchStudied(@Query() request: SearchStudyRequestDto): Promise { + return this.studyService.searchStudies(request); + } + @Get() @ApiOperation({ summary: '스터디 목록' }) @ApiProperty({ type: GetStudiesRequestDto }) @ApiCustomResponse(GetStudiesResponseDto) - async getStudies(@Body() request: GetStudiesRequestDto): Promise { + async getStudies(@Query() request: GetStudiesRequestDto): Promise { return this.studyService.getStudies(request); } } diff --git a/src/domain/activity/study/dto/index.ts b/src/domain/activity/study/dto/index.ts index 5ec39e2..2b7f834 100644 --- a/src/domain/activity/study/dto/index.ts +++ b/src/domain/activity/study/dto/index.ts @@ -4,6 +4,7 @@ export * from './request/create-study.request.dto'; export * from './request/delete-category.request.dto'; export * from './request/delete-study.request.dto'; export * from './request/get-studies.request.dto'; +export * from './request/search-study.request.dto'; export * from './request/update-category.request.dto'; // Response diff --git a/src/domain/activity/study/dto/request/get-studies.request.dto.ts b/src/domain/activity/study/dto/request/get-studies.request.dto.ts index 5019498..944dbba 100644 --- a/src/domain/activity/study/dto/request/get-studies.request.dto.ts +++ b/src/domain/activity/study/dto/request/get-studies.request.dto.ts @@ -2,11 +2,14 @@ import { ApiProperty } from '@nestjs/swagger'; import { CommonValidation, TypeValidation } from '@wink/validation'; +import { Type } from 'class-transformer'; + export class GetStudiesRequestDto { @ApiProperty({ description: '페이지', example: 1, }) + @Type(() => Number) @CommonValidation.IsNotEmpty() @TypeValidation.IsNumber() page!: number; diff --git a/src/domain/activity/study/dto/request/search-study.request.dto.ts b/src/domain/activity/study/dto/request/search-study.request.dto.ts new file mode 100644 index 0000000..b657611 --- /dev/null +++ b/src/domain/activity/study/dto/request/search-study.request.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { CommonValidation, TypeValidation } from '@wink/validation'; + +export class SearchStudyRequestDto { + @ApiProperty({ + description: '검색어', + example: '김', + }) + @CommonValidation.IsNotEmpty() + @TypeValidation.IsString() + query!: string; +} diff --git a/src/domain/activity/study/dto/response/get-categories.response.dto.ts b/src/domain/activity/study/dto/response/get-categories.response.dto.ts index 29394b2..670bc15 100644 --- a/src/domain/activity/study/dto/response/get-categories.response.dto.ts +++ b/src/domain/activity/study/dto/response/get-categories.response.dto.ts @@ -1,17 +1,36 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Category } from '@wink/activity/schema'; +export class EachGetCategoriesResponseDto { + @ApiProperty({ + description: '카테고리 ID', + example: '1a2b3c4d5e6f7a8b9c0d1e2f', + }) + _id!: string; + + @ApiProperty({ + description: '카테고리 이름', + example: 'React.js 스터디', + }) + name!: string; + + @ApiProperty({ + description: '참여중인 스터디 수', + example: 10, + }) + dependencies!: number; +} export class GetCategoriesResponseDto { @ApiProperty({ description: '카테고리 목록', - type: [Category], + type: [EachGetCategoriesResponseDto], example: [ { _id: '1a2b3c4d5e6f7a8b9c0d1e2f', name: 'React.js 스터디', + dependencies: 10, }, ], }) - categories!: Category[]; + categories!: EachGetCategoriesResponseDto[]; } diff --git a/src/domain/activity/study/repository/study.repository.ts b/src/domain/activity/study/repository/study.repository.ts index c693178..aba037c 100644 --- a/src/domain/activity/study/repository/study.repository.ts +++ b/src/domain/activity/study/repository/study.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Study } from '@wink/activity/schema'; +import { Category, Study } from '@wink/activity/schema'; import { Model } from 'mongoose'; @@ -15,6 +15,10 @@ export class StudyRepository { } // Read + async count(): Promise { + return this.studyModel.countDocuments().exec(); + } + async findAll(): Promise { return this.studyModel.find().sort({ uploadedAt: -1 }).exec(); } @@ -32,11 +36,22 @@ export class StudyRepository { return this.studyModel.findById(id).exec(); } + async findAllByContainsTitle(title: string): Promise { + return this.studyModel + .find({ title: { $regex: title, $options: 'i' } }) + .sort({ uploadedAt: -1 }) + .exec(); + } + // Delete async deleteById(id: string): Promise { await this.studyModel.deleteOne({ _id: id }).exec(); } + async deleteByCategory(category: Category): Promise { + await this.studyModel.deleteMany({ category }).exec(); + } + // Exists async existsById(id: string): Promise { return !!(await this.studyModel.exists({ _id: id }).exec()); diff --git a/src/domain/activity/study/service/study.admin.service.ts b/src/domain/activity/study/service/study.admin.service.ts index 1c3e434..53913f0 100644 --- a/src/domain/activity/study/service/study.admin.service.ts +++ b/src/domain/activity/study/service/study.admin.service.ts @@ -92,6 +92,7 @@ export class StudyAdminService { const category = (await this.categoryRepository.findById(categoryId))!; + await this.studyRepository.deleteByCategory(category); await this.categoryRepository.deleteById(categoryId); this.eventEmitter.emit( @@ -114,13 +115,15 @@ export class StudyAdminService { const title = $('meta[property="og:title"]').attr('content')!; const content = $('meta[property="og:description"]').attr('content')!; const author = $('meta[property="og.article.author"]').attr('content')!; - const image = $('meta[property="og:image"]').attr('content')!; + const image = decodeURIComponent( + $('meta[property="og:image"]').attr('content')!.split('fname=')[1], + ); const rawUploadedAt = $('meta[property="article:published_time"]').attr('content')!; const uploadedAt = new Date(new Date(rawUploadedAt).getTime() + 9 * 60 * 60 * 1000); const entryInfoMatch = html.match(/window\.T\.entryInfo\s*=\s*({[^}]*});/); const entryInfo = entryInfoMatch ? JSON.parse(entryInfoMatch[1]) : null; - const categoryLabel = entryInfo['categoryLabel']; + const categoryLabel = entryInfo['categoryLabel'].replace('WINK-(Web & App)/', ''); const category = await this.categoryRepository.findByName(categoryLabel); if (!category) { diff --git a/src/domain/activity/study/service/study.service.ts b/src/domain/activity/study/service/study.service.ts index 917543a..c4fc57e 100644 --- a/src/domain/activity/study/service/study.service.ts +++ b/src/domain/activity/study/service/study.service.ts @@ -5,8 +5,10 @@ import { GetStudiesPageResponseDto, GetStudiesRequestDto, GetStudiesResponseDto, + SearchStudyRequestDto, } from '@wink/activity/dto'; import { CategoryRepository, StudyRepository } from '@wink/activity/repository'; +import { Category } from '@wink/activity/study/schema'; @Injectable() export class StudyService { @@ -16,7 +18,11 @@ export class StudyService { ) {} async getCategories(): Promise { - const categories = await this.categoryRepository.findAll(); + const studies = await this.studyRepository.findAll(); + const categories = (await this.categoryRepository.findAll()).map((category) => ({ + ...('_doc' in category ? category._doc : category), + dependencies: studies.filter((study) => study.category.name === category.name).length, + })); return { categories }; } @@ -27,10 +33,15 @@ export class StudyService { return { studies }; } + async searchStudies({ query }: SearchStudyRequestDto): Promise { + const studies = await this.studyRepository.findAllByContainsTitle(query); + + return { studies }; + } + async getStudiesPage(): Promise { - const studies = await this.studyRepository.findAll(); - const page = Math.ceil(studies.length / 10); + const count = await this.studyRepository.count(); - return { page }; + return { page: Math.ceil(count / 10) }; } } diff --git a/src/domain/auth/exception/index.ts b/src/domain/auth/exception/index.ts index aa4547f..f5462c4 100644 --- a/src/domain/auth/exception/index.ts +++ b/src/domain/auth/exception/index.ts @@ -12,4 +12,3 @@ export * from './wrong-password.exception'; export * from './unauthorized.exception'; export * from './permission.exception'; -export * from './super-role.exception'; diff --git a/src/domain/member/controller/member.admin.controller.ts b/src/domain/member/controller/member.admin.controller.ts index 55c3b6f..9c9ed41 100644 --- a/src/domain/member/controller/member.admin.controller.ts +++ b/src/domain/member/controller/member.admin.controller.ts @@ -1,14 +1,16 @@ -import { Body, Controller, Get, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Get, Patch, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; -import { SuperRoleException } from '@wink/auth/exception'; import { AuthAdminAccount, AuthAdminAccountException, ReqMember } from '@wink/auth/guard'; import { ApproveWaitingMemberRequestDto, + GetMembersForAdminPageResponseDto, + GetMembersForAdminRequestDto, GetMembersForAdminResponseDto, GetWaitingMembersResponseDto, RejectWaitingMemberRequestDto, + SearchMembersRequestDto, UpdateMemberFeeRequestDto, UpdateMemberRoleRequestDto, } from '@wink/member/dto'; @@ -58,13 +60,37 @@ export class MemberAdminController { return this.memberAdminService.rejectWaitingMember(member, request); } + @Get('/max') + @AuthAdminAccount() + @ApiOperation({ summary: '부원 목록 최대 페이지' }) + @ApiCustomResponse(GetMembersForAdminPageResponseDto) + @ApiCustomErrorResponse([...AuthAdminAccountException]) + async getMembersPage(): Promise { + return this.memberAdminService.getMembersPage(); + } + + @Get('/search') + @AuthAdminAccount() + @ApiOperation({ summary: '부원 검색' }) + @ApiProperty({ type: SearchMembersRequestDto }) + @ApiCustomResponse(GetMembersForAdminResponseDto) + @ApiCustomErrorResponse([...AuthAdminAccountException]) + async searchMembers( + @Query() request: SearchMembersRequestDto, + ): Promise { + return this.memberAdminService.searchMember(request); + } + @Get() @AuthAdminAccount() @ApiOperation({ summary: '부원 목록' }) + @ApiProperty({ type: GetMembersForAdminRequestDto }) @ApiCustomResponse(GetMembersForAdminResponseDto) @ApiCustomErrorResponse([...AuthAdminAccountException]) - async getMembers(): Promise { - return this.memberAdminService.getMembers(); + async getMembers( + @Query() request: GetMembersForAdminRequestDto, + ): Promise { + return this.memberAdminService.getMembers(request); } @Patch('/role') @@ -72,11 +98,7 @@ export class MemberAdminController { @ApiOperation({ summary: '부원 권한 수정' }) @ApiProperty({ type: UpdateMemberRoleRequestDto }) @ApiCustomResponse() - @ApiCustomErrorResponse([ - ...AuthAdminAccountException, - NotApprovedMemberException, - SuperRoleException, - ]) + @ApiCustomErrorResponse([...AuthAdminAccountException, NotApprovedMemberException]) async updateMemberRole( @ReqMember() member: Member, @Body() request: UpdateMemberRoleRequestDto, @@ -89,11 +111,7 @@ export class MemberAdminController { @ApiOperation({ summary: '부원 회비 납부 여부 수정' }) @ApiProperty({ type: UpdateMemberFeeRequestDto }) @ApiCustomResponse() - @ApiCustomErrorResponse([ - ...AuthAdminAccountException, - NotApprovedMemberException, - SuperRoleException, - ]) + @ApiCustomErrorResponse([...AuthAdminAccountException, NotApprovedMemberException]) async updateMemberFee( @ReqMember() member: Member, @Body() request: UpdateMemberFeeRequestDto, diff --git a/src/domain/member/dto/index.ts b/src/domain/member/dto/index.ts index 0dd66b6..d76d7c8 100644 --- a/src/domain/member/dto/index.ts +++ b/src/domain/member/dto/index.ts @@ -1,6 +1,8 @@ // Request export * from './request/approve-waiting-member.request.dto'; +export * from './request/get-members-for-admin.request.dto'; export * from './request/reject-waiting-member.request.dto'; +export * from './request/search-members.request.dto'; export * from './request/update-member-fee.request.dto'; export * from './request/update-member-role.request.dto'; export * from './request/update-my-info.request.dto'; @@ -8,5 +10,6 @@ export * from './request/update-my-password.request.dto'; // Response export * from './response/get-members.response.dto'; +export * from './response/get-members-for-admin-page.response.dto'; export * from './response/get-waiting-members.response.dto'; export * from './response/update-my-avatar.response.dto'; diff --git a/src/domain/member/dto/request/get-members-for-admin.request.dto.ts b/src/domain/member/dto/request/get-members-for-admin.request.dto.ts new file mode 100644 index 0000000..02159b8 --- /dev/null +++ b/src/domain/member/dto/request/get-members-for-admin.request.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { CommonValidation, TypeValidation } from '@wink/validation'; + +import { Type } from 'class-transformer'; + +export class GetMembersForAdminRequestDto { + @ApiProperty({ + description: '페이지', + example: 1, + }) + @Type(() => Number) + @CommonValidation.IsNotEmpty() + @TypeValidation.IsNumber() + page!: number; +} diff --git a/src/domain/member/dto/request/search-members.request.dto.ts b/src/domain/member/dto/request/search-members.request.dto.ts new file mode 100644 index 0000000..ba22e13 --- /dev/null +++ b/src/domain/member/dto/request/search-members.request.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { CommonValidation, TypeValidation } from '@wink/validation'; + +export class SearchMembersRequestDto { + @ApiProperty({ + description: '검색어', + example: '김', + }) + @CommonValidation.IsNotEmpty() + @TypeValidation.IsString() + query!: string; +} diff --git a/src/domain/member/dto/response/get-members-for-admin-page.response.dto.ts b/src/domain/member/dto/response/get-members-for-admin-page.response.dto.ts new file mode 100644 index 0000000..eeed77a --- /dev/null +++ b/src/domain/member/dto/response/get-members-for-admin-page.response.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class GetMembersForAdminPageResponseDto { + @ApiProperty({ + description: '최대 페이지', + example: 1, + }) + page!: number; +} diff --git a/src/domain/member/dto/response/get-waiting-members.response.dto.ts b/src/domain/member/dto/response/get-waiting-members.response.dto.ts index 25eb488..b446089 100644 --- a/src/domain/member/dto/response/get-waiting-members.response.dto.ts +++ b/src/domain/member/dto/response/get-waiting-members.response.dto.ts @@ -13,6 +13,12 @@ export class EachGetWaitingMembersResponseDto { }) name!: string; + @ApiProperty({ + description: '이메일', + example: 'honggildong@kookmin.ac.kr', + }) + email!: string; + @ApiProperty({ description: '학번', example: '20240001', diff --git a/src/domain/member/repository/member.repository.ts b/src/domain/member/repository/member.repository.ts index b628aa3..84e5a5d 100644 --- a/src/domain/member/repository/member.repository.ts +++ b/src/domain/member/repository/member.repository.ts @@ -1,10 +1,12 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Member } from '@wink/member/schema'; +import { Member, Role } from '@wink/member/schema'; import { Model } from 'mongoose'; +const roleOrder = Object.values(Role); + @Injectable() export class MemberRepository { constructor(@InjectModel(Member.name) private readonly memberModel: Model) {} @@ -15,8 +17,102 @@ export class MemberRepository { } // Read + async count(): Promise { + return this.memberModel.countDocuments().exec(); + } + async findAll(): Promise { - return this.memberModel.find({ approved: true }).exec(); + return this.memberModel.aggregate([ + { + $match: { + approved: true, + }, + }, + { + $addFields: { + roleIndex: { + $switch: { + branches: roleOrder.map((role, index) => ({ + case: { $eq: ['$role', role] }, + then: index, + })), + default: roleOrder.length, + }, + }, + }, + }, + { + $sort: { + roleIndex: 1, + name: 1, + }, + }, + ]); + } + + async findAllPage(page: number): Promise { + return this.memberModel.aggregate([ + { + $match: { + approved: true, + }, + }, + { + $addFields: { + roleIndex: { + $switch: { + branches: roleOrder.map((role, index) => ({ + case: { $eq: ['$role', role] }, + then: index, + })), + default: roleOrder.length, + }, + }, + }, + }, + { + $sort: { + roleIndex: 1, + name: 1, + }, + }, + { + $skip: 10 * (page - 1), + }, + { + $limit: 10, + }, + ]); + } + + async findByContainsName(name: string): Promise { + return this.memberModel.aggregate([ + { + $match: { + approved: true, + name: { $regex: name, $options: 'i' }, + }, + }, + { + $addFields: { + roleIndex: { + $switch: { + branches: roleOrder.map((role, index) => ({ + case: { $eq: ['$role', role] }, + then: index, + })), + default: roleOrder.length, + }, + }, + }, + }, + { + $sort: { + roleIndex: 1, + name: 1, + }, + }, + ]); } async findAllWaitingMember(): Promise { diff --git a/src/domain/member/schema/Role.ts b/src/domain/member/schema/Role.ts index 00b7e5b..41f24f6 100644 --- a/src/domain/member/schema/Role.ts +++ b/src/domain/member/schema/Role.ts @@ -9,26 +9,3 @@ export enum Role { PLANNING_ASSISTANT = 'PLANNING_ASSISTANT', MEMBER = 'MEMBER', } - -const roleHierarchy: { [key: string]: number } = { - [Role.PRESIDENT]: 1, - - [Role.VICE_PRESIDENT]: 2, - - [Role.TREASURY_HEAD]: 3, - [Role.PUBLIC_RELATIONS_HEAD]: 3, - [Role.PLANNING_HEAD]: 3, - - [Role.TREASURY_ASSISTANT]: 4, - [Role.PUBLIC_RELATIONS_ASSISTANT]: 4, - [Role.PLANNING_ASSISTANT]: 4, - - [Role.MEMBER]: 5, -}; - -export const checkRoleHierarchy = (myRole: Role, targetRole: Role): boolean => { - const myRoleIndex = roleHierarchy[myRole]; - const targetRoleIndex = roleHierarchy[targetRole]; - - return myRoleIndex < targetRoleIndex; -}; diff --git a/src/domain/member/service/member.admin.service.ts b/src/domain/member/service/member.admin.service.ts index 9ac3d56..9456e4b 100644 --- a/src/domain/member/service/member.admin.service.ts +++ b/src/domain/member/service/member.admin.service.ts @@ -1,21 +1,24 @@ import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { MemberNotFoundException, SuperRoleException } from '@wink/auth/exception'; +import { MemberNotFoundException } from '@wink/auth/exception'; import { ApproveWaitingMemberRequestDto, EachGetMembersForAdminResponseDto, EachGetWaitingMembersResponseDto, + GetMembersForAdminPageResponseDto, + GetMembersForAdminRequestDto, GetMembersForAdminResponseDto, GetWaitingMembersResponseDto, RejectWaitingMemberRequestDto, + SearchMembersRequestDto, UpdateMemberFeeRequestDto, UpdateMemberRoleRequestDto, } from '@wink/member/dto'; import { NotApprovedMemberException, NotWaitingMemberException } from '@wink/member/exception'; import { MemberRepository } from '@wink/member/repository'; -import { Member, Role, checkRoleHierarchy, omitMember, pickMember } from '@wink/member/schema'; +import { Member, Role, omitMember, pickMember } from '@wink/member/schema'; import { ApproveWaitingMemberEvent, @@ -36,7 +39,8 @@ export class MemberAdminService { async getWaitingMembers(): Promise { const members = (await this.memberRepository.findAllWaitingMember()).map( - (member) => pickMember(member, ['approved', 'role', 'fee']), + (member) => + pickMember(member, ['_id', 'name', 'email', 'studentId']), ); return { members }; @@ -93,8 +97,22 @@ export class MemberAdminService { ); } - async getMembers(): Promise { - const members = (await this.memberRepository.findAll()).map((member) => { + async getMembersPage(): Promise { + const count = await this.memberRepository.count(); + + return { page: Math.ceil(count / 10) }; + } + + async searchMember({ query }: SearchMembersRequestDto): Promise { + const members = (await this.memberRepository.findByContainsName(query)).map((member) => { + return omitMember(member, ['approved']); + }); + + return { members }; + } + + async getMembers({ page }: GetMembersForAdminRequestDto): Promise { + const members = (await this.memberRepository.findAllPage(page)).map((member) => { return omitMember(member, ['approved']); }); @@ -115,10 +133,6 @@ export class MemberAdminService { throw new NotApprovedMemberException(); } - if (!checkRoleHierarchy(from.role!, to.role!)) { - throw new SuperRoleException(); - } - to.role = role; await this.memberRepository.save(to); @@ -137,10 +151,6 @@ export class MemberAdminService { throw new NotApprovedMemberException(); } - if (!checkRoleHierarchy(from.role!, to.role!)) { - throw new SuperRoleException(); - } - to.fee = fee; await this.memberRepository.save(to); diff --git a/test/member/admin-service.test.ts b/test/member/admin-service.test.ts index f4b1aad..b07b65c 100644 --- a/test/member/admin-service.test.ts +++ b/test/member/admin-service.test.ts @@ -1,5 +1,3 @@ -import { SuperRoleException } from '@wink/auth/exception'; - import { ApproveWaitingMemberRequestDto, RejectWaitingMemberRequestDto, @@ -158,7 +156,7 @@ describe('MemberAdminService', () => { // Given // When - const result = memberAdminService.getMembers(); + const result = memberAdminService.getMembers({ page: 1 }); // Then await expect(result).resolves.toStrictEqual({ members: [] }); @@ -169,7 +167,7 @@ describe('MemberAdminService', () => { memoryMemberRepository.push(...MEMBERS); // When - const result = memberAdminService.getMembers(); + const result = memberAdminService.getMembers({ page: 1 }); // Then await expect(result).resolves.toBeInstanceOf(Object); @@ -204,34 +202,6 @@ describe('MemberAdminService', () => { await expect(result).rejects.toThrow(NotApprovedMemberException); }); - it('PermissionException (1)', async () => { - // Given - const member = { ...TARGET }; - member.role = Role.PRESIDENT; - - memoryMemberRepository.push(member); - - // When - const result = memberAdminService.updateRole(ME, PARAM); - - // Then - await expect(result).rejects.toThrow(SuperRoleException); - }); - - it('PermissionException (2)', async () => { - // Given - const member = { ...TARGET }; - member.role = Role.VICE_PRESIDENT; - - memoryMemberRepository.push(member); - - // When - const result = memberAdminService.updateRole(ME, PARAM); - - // Then - await expect(result).rejects.toThrow(SuperRoleException); - }); - it('Update role', async () => { // Given const member = { ...TARGET }; @@ -275,34 +245,6 @@ describe('MemberAdminService', () => { await expect(result).rejects.toThrow(NotApprovedMemberException); }); - it('PermissionException (1)', async () => { - // Given - const member = { ...TARGET }; - member.role = Role.PRESIDENT; - - memoryMemberRepository.push(member); - - // When - const result = memberAdminService.updateFee(ME, PARAM); - - // Then - await expect(result).rejects.toThrow(SuperRoleException); - }); - - it('PermissionException (2)', async () => { - // Given - const member = { ...TARGET }; - member.role = Role.VICE_PRESIDENT; - - memoryMemberRepository.push(member); - - // When - const result = memberAdminService.updateFee(ME, PARAM); - - // Then - await expect(result).rejects.toThrow(SuperRoleException); - }); - it('Change fee', async () => { // Given const member = { ...TARGET }; diff --git a/test/mock/repository/member-repository.mock.ts b/test/mock/repository/member-repository.mock.ts index 9e4f349..bcb0497 100644 --- a/test/mock/repository/member-repository.mock.ts +++ b/test/mock/repository/member-repository.mock.ts @@ -13,10 +13,18 @@ export const mockMemberRepository = (memory: Member[]) => ({ }), // Read + count: jest.fn(async () => { + return memory.length; + }), + findAll: jest.fn(async () => { return memory.filter((member) => member.approved); }), + findAllPage: jest.fn(async () => { + return memory.filter((member) => member.approved); + }), + findAllWaitingMember: jest.fn(async () => { return memory.filter((member) => !member.approved); }),