diff --git a/.github/workflows/backend-dev-cd.yml b/.github/workflows/backend-dev-cd.yml index a535049b..60285971 100644 --- a/.github/workflows/backend-dev-cd.yml +++ b/.github/workflows/backend-dev-cd.yml @@ -66,7 +66,7 @@ jobs: username: ${{ secrets.BACKEND_DEV_REMOTE_SSH_ID }} password: ${{ secrets.BACKEND_DEV_REMOTE_ADMIN_KEY }} port: ${{ secrets.BACKEND_DEV_REMOTE_SSH_PORT }} - source: 'backend/docker-compose.yml,backend/nginx/dev/nginx.conf,backend/scripts/dev-deploy.sh' + source: 'backend/docker-compose.yml,backend/nginx/dev/conf/nginx.conf,backend/scripts/dev-deploy.sh' target: 'moyeomoyeo' - name: deploy diff --git a/README.md b/README.md index 89d4a54e..e4f796ef 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,43 @@

+# ✨ 주요 기능 + +[👉🏻 시연영상 보러가기](https://www.youtube.com/watch?v=FR3IAFBxvO4) + + + + + + + + + + + + + + + + + + + + +
모집게시글 목록 조회모집 게시글 등록모집 신청 참가/취소모집 성사 알림, 오픈채팅방 링크
무한 스크롤을 통한 페이지네이션이 적용되어 있으며, 필터링이 가능합니다.원하는 모임 모집 게시글을 작성할 수 있습니다.모집중인 모임에 참가 신청/취소 할 수 있습니다.모집이 완료되면 알림이 발송되며, 오픈채팅방 링크를 통해 모임 채팅방에 참여할 수 있습니다.
+ + -# 🫵 프로젝트 포인트 +

-강조하고 싶은 것들 +# 🫵 프로젝트 포인트 + +- 중복 게시글 조회 방지를 위해 페이지네이션 방식 변경 및 쿼리 개선을 진행했습니다. [👉🏻보러가기](https://boostcamp-wm.notion.site/feat-2a1dd8ea684d44ebb7176d5efbbc8aeb) +- `custom hook`, `error class`, `ErrorBoundary`를 이용하여 API 핵심 로직과 에러 처리의 관심사를 분리하였고,
사용자에게 적절한 오류 화면을 보여주도록 했습니다. [👉🏻보러가기](https://boostcamp-wm.notion.site/957e4b7034d64d0c8fa59f47e58c112d) +- 성능 및 사용자 경험 개선을 위해 이미지 최적화와 SSR, Blur, Skeleton을 적용 했습니다. [👉🏻보러가기](https://boostcamp-wm.notion.site/e959fbc871514fb29a407dfe3f2447b9) +- 컴포넌트의 체계적인 설계와 문서화를 통해 개발자 경험을 향상시키고자 스토리북을 도입했습니다. [👉🏻보러가기](https://boostcamp-wm.notion.site/a54eb762007f4ec185ee008a396d7a82) +- CI/CD 파이프라인을 구축하고 지속적으로 개선했습니다. CI 시간을 약 40s 단축했습니다. [👉🏻보러가기](https://boostcamp-wm.notion.site/CI-CD-e6f15386baa34b238884678928ff3c61)

diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 8c881d38..a2c206b7 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -6,14 +6,12 @@ services: image: nginx restart: always volumes: - - ./nginx/dev:/etc/nginx/conf.d + - ./nginx/dev/conf:/etc/nginx/conf.d - /letsencrypt/certbot/conf:/etc/letsencrypt - /letsencrypt/certbot/www:/var/www/certbot ports: - 80:80 - 443:443 - depends_on: - - moyeo-server networks: - backbone command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"''' @@ -31,17 +29,6 @@ services: - backbone entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" - moyeo-server: - container_name: moyeo-server - image: ${DOCKER_SERVER_IMAGE} - environment: - NODE_ENV: development - ports: - - 3000:3000 - command: npm run start:dev - networks: - - backbone - moyeo-db: container_name: moyeo-db image: mysql:8.0 @@ -61,6 +48,28 @@ services: networks: - backbone + moyeo-server-green: + container_name: moyeo-server-green + image: ${DOCKER_SERVER_IMAGE} + environment: + NODE_ENV: development + expose: + - 3000 + command: npm run start:dev + networks: + - backbone + + moyeo-server-blue: + container_name: moyeo-server-blue + image: ${DOCKER_SERVER_IMAGE} + environment: + NODE_ENV: development + expose: + - 3000 + command: npm run start:dev + networks: + - backbone + networks: backbone: driver: bridge diff --git a/backend/local/nginx.conf b/backend/local/nginx.conf index 6d7f6f05..7f2061a3 100644 --- a/backend/local/nginx.conf +++ b/backend/local/nginx.conf @@ -11,4 +11,10 @@ server { proxy_pass http://backend-server; proxy_http_version 1.1; } + + location = /v1/sse { + proxy_pass http://backend-server; + proxy_http_version 1.1; + proxy_read_timeout 600s; + } } diff --git a/backend/nginx/dev/nginx.conf b/backend/nginx/dev/conf/nginx.conf similarity index 83% rename from backend/nginx/dev/nginx.conf rename to backend/nginx/dev/conf/nginx.conf index 7260bdaf..fb47e49b 100644 --- a/backend/nginx/dev/nginx.conf +++ b/backend/nginx/dev/conf/nginx.conf @@ -1,6 +1,6 @@ upstream backend-server { - server moyeo-server:3000; + server moyeo-server-blue:3000; } server { @@ -35,4 +35,10 @@ server { proxy_pass http://backend-server; proxy_http_version 1.1; } + + location = /v1/sse { + proxy_pass http://backend-server; + proxy_http_version 1.1; + proxy_read_timeout 600s; + } } \ No newline at end of file diff --git a/backend/nginx/prod/nginx.conf b/backend/nginx/prod/nginx.conf index a79ce5d1..883528b1 100644 --- a/backend/nginx/prod/nginx.conf +++ b/backend/nginx/prod/nginx.conf @@ -35,4 +35,10 @@ server { proxy_pass http://backend-server; proxy_http_version 1.1; } + + location = /v1/sse { + proxy_pass http://backend-server; + proxy_http_version 1.1; + proxy_read_timeout 600s; + } } \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index ce86f7f2..99bc6173 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,7 @@ "start:prod": "node dist/main", "start:local": "docker compose -f docker-compose.local.yml up -d --build", "stop:local": "docker compose -f docker-compose.local.yml down", - "reload:local": "docker compose -f docker-compose.local.yml up --force-recreate -d", + "reload:local": "docker compose -f docker-compose.local.yml up -d --build", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "test": "env-cmd -f .env.test jest", diff --git a/backend/scripts/dev-deploy.sh b/backend/scripts/dev-deploy.sh index 7a17aeff..a3d90d5c 100644 --- a/backend/scripts/dev-deploy.sh +++ b/backend/scripts/dev-deploy.sh @@ -12,8 +12,45 @@ echo -e $4 > .env echo "create .env" -# docker down -docker compose down --rmi all --remove-orphans - -# docker up -docker compose up -d --build \ No newline at end of file +RUNNING_APPLICATION=$(docker ps | grep moyeo-server-blue) +DEFAULT_CONF="./nginx/dev/conf/nginx.conf" + +if [ -n "$RUNNING_APPLICATION" ];then + echo "green Deploy..." + docker compose pull moyeo-server-green + docker compose up -d moyeo-server-green + docker rmi $(docker images -f "dangling=true" -q) + + while [ 1 == 1 ]; do + echo "green health check...." + REQUEST=$(docker exec moyeo-nginx curl http://moyeo-server-green:3000) + echo $REQUEST + if [ -n "$REQUEST" ]; then + break ; + fi + sleep 3 + done; + + sed -i 's/moyeo-server-blue/moyeo-server-green/g' $DEFAULT_CONF + docker exec moyeo-nginx service nginx reload + docker compose stop moyeo-server-blue +else + echo "blue Deploy..." + docker compose pull moyeo-server-blue + docker compose up -d moyeo-server-blue + docker rmi $(docker images -f "dangling=true" -q) + + while [ 1 == 1 ]; do + echo "blue health check...." + REQUEST=$(docker exec moyeo-nginx curl http://moyeo-server-blue:3000) + echo $REQUEST + if [ -n "$REQUEST" ]; then + break ; + fi + sleep 3 + done; + + sed -i 's/moyeo-server-green/moyeo-server-blue/g' $DEFAULT_CONF + docker exec moyeo-nginx service nginx reload + docker compose stop moyeo-server-green +fi diff --git a/backend/src/app.controller.spec.ts b/backend/src/app.controller.spec.ts deleted file mode 100644 index 2b594af2..00000000 --- a/backend/src/app.controller.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { ResponseEntity } from '@common/response-entity'; -import { JwtAuthGuard } from '@common/guard/jwt-auth.guard'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - imports: [], - controllers: [AppController], - providers: [AppService], - }) - .overrideGuard(JwtAuthGuard) - .useValue(() => { - return null; - }) - .compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - test('example test', async () => { - // given - const id = 11; - - // when - const result = appController.getHello({ id }); - - // then - - expect(result).toEqual(ResponseEntity.OK_WITH_DATA('Hello World!')); - }); - }); -}); diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts deleted file mode 100644 index 8cc65927..00000000 --- a/backend/src/app.controller.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Controller, Get, HttpStatus, Param } from '@nestjs/common'; -import { ApiProperty, ApiTags } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsNumber } from 'class-validator'; -import { ResponseEntity } from '@common/response-entity'; -import { ApiSuccessResponse } from '@decorator/api-success-resposne.decorator'; -import { ApiErrorResponse } from '@decorator/api-error-response.decorator'; -import { BadParameterException } from '@exception/bad-parameter.exception'; -import { ApiNotFoundException } from '@exception/api-not-found.exception'; -import { JwtAuth } from '@decorator/jwt-auth.decorator'; -import { AppService } from '@src/app.service'; - -export class ExampleDto { - @IsNumber() - @Type(() => Number) - @ApiProperty({ example: 1 }) - id: number; -} - -@Controller('test') -@ApiTags('example') -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get(':id') - @JwtAuth() - @ApiSuccessResponse(HttpStatus.OK, String) - @ApiErrorResponse(BadParameterException, ApiNotFoundException) - getHello(@Param() params: ExampleDto) { - if (params.id < 10) throw new ApiNotFoundException(); - return ResponseEntity.OK_WITH_DATA(this.appService.getHello()); - } -} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9842f253..3bd203fa 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,7 +1,5 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { EventEmitterModule } from '@nestjs/event-emitter'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; import { AppConfigModule } from '@config/app/config.module'; import { ApiSuccessLoggerMiddleware } from '@middleware/api-success-logger.middleware'; import { ApiExceptionLoggerMiddleware } from '@middleware/api-exception-logger.middleware'; @@ -17,6 +15,8 @@ import { GroupApplicationModule } from '@app/group-application/group-application import { NotificationModule } from '@app/notification/notification.module'; import { CommentModule } from '@app/comment/comment.module'; import { AppConfigService } from '@common/config/app/config.service'; +import { SseModule } from '@common/module/sse/sse.module'; +import { SseController } from '@src/sse.controller'; @Module({ imports: [ @@ -33,15 +33,15 @@ import { AppConfigService } from '@common/config/app/config.service'; GroupApplicationModule, NotificationModule, CommentModule, + SseModule, ], - controllers: [AppController], - providers: [AppService], + controllers: [SseController], }) export class AppModule implements NestModule { - constructor(private readonly appConfigSerivce: AppConfigService) {} + constructor(private readonly appConfigService: AppConfigService) {} configure(consumer: MiddlewareConsumer) { - if (!this.appConfigSerivce.isTest()) { + if (!this.appConfigService.isTest()) { consumer .apply(ApiSuccessLoggerMiddleware, ApiExceptionLoggerMiddleware) .forRoutes('*'); diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts deleted file mode 100644 index 927d7cca..00000000 --- a/backend/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/backend/src/app/group-article/dto/group-article-register-request.dto.ts b/backend/src/app/group-article/dto/group-article-register-request.dto.ts index 424ee891..555db75a 100644 --- a/backend/src/app/group-article/dto/group-article-register-request.dto.ts +++ b/backend/src/app/group-article/dto/group-article-register-request.dto.ts @@ -32,6 +32,7 @@ export class GroupArticleRegisterRequest { required: true, }) @IsString() + @Length(1) contents: string; @ApiProperty({ @@ -66,6 +67,7 @@ export class GroupArticleRegisterRequest { description: '썸네일 이미지가 저장되어있는 주소(url)', required: true, }) + @Length(1) @IsUrl() thumbnail: string; @@ -75,5 +77,6 @@ export class GroupArticleRegisterRequest { required: false, }) @IsString() + @Length(1) chatUrl: string; } diff --git a/backend/src/app/group-article/group-article.controller.ts b/backend/src/app/group-article/group-article.controller.ts index f951aed7..81c61110 100644 --- a/backend/src/app/group-article/group-article.controller.ts +++ b/backend/src/app/group-article/group-article.controller.ts @@ -144,7 +144,7 @@ export class GroupArticleController { @Get('search') @Version('2') - @ApiSuccessResponse(HttpStatus.OK) + @ApiSuccessResponse(HttpStatus.OK, V2SearchGroupArticlesResponse) async searchV2(@Query() query: V2SearchGroupArticlesRequest) { const result = await this.groupArticleRepository.searchV2({ limit: query.limit, diff --git a/backend/src/app/notification/dto/notification-sse.dto.ts b/backend/src/app/notification/dto/notification-sse.dto.ts new file mode 100644 index 00000000..3464b952 --- /dev/null +++ b/backend/src/app/notification/dto/notification-sse.dto.ts @@ -0,0 +1,23 @@ +import { MessageEvent } from '@nestjs/common'; +import { GetUserNotificationResult } from '@app/notification/dto/get-user-notification-result.dto'; +import { UserNotification } from '@app/notification/entity/user-notification.entity'; +import { randomUUID } from 'crypto'; + +export class NotificationSse implements MessageEvent { + id: string; + data: GetUserNotificationResult; + type: string; + + retry: number; + + static async from(userNotification: UserNotification) { + const notificationSse = new NotificationSse(); + notificationSse.id = randomUUID(); + notificationSse.data = await GetUserNotificationResult.from( + userNotification, + ); + notificationSse.type = 'NOTIFICATION'; + notificationSse.retry = 3 * 1000; + return notificationSse; + } +} diff --git a/backend/src/app/notification/dto/v2-get-user-notifications-response.dto.ts b/backend/src/app/notification/dto/v2-get-user-notifications-response.dto.ts new file mode 100644 index 00000000..66d8e251 --- /dev/null +++ b/backend/src/app/notification/dto/v2-get-user-notifications-response.dto.ts @@ -0,0 +1,12 @@ +import { GetUserNotificationResult } from '@app/notification/dto/get-user-notification-result.dto'; +import { NoOffsetPageResult } from '@common/util/no-offset-page-result'; +import { Expose } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class V2GetUserNotificationsResponse extends NoOffsetPageResult { + @Expose() + @ApiProperty({ type: GetUserNotificationResult, isArray: true }) + get data(): GetUserNotificationResult[] { + return this._data; + } +} diff --git a/backend/src/app/notification/entity/notification.entity.ts b/backend/src/app/notification/entity/notification.entity.ts index 61c1a55c..ce92c8af 100644 --- a/backend/src/app/notification/entity/notification.entity.ts +++ b/backend/src/app/notification/entity/notification.entity.ts @@ -12,6 +12,8 @@ import { } from '@app/notification/entity/notification-contents'; import { GroupArticle } from '@app/group-article/entity/group-article.entity'; import { Comment } from '@src/app/comment/entity/comment.entity'; +import { User } from '@app/user/entity/user.entity'; +import { UserNotification } from '@app/notification/entity/user-notification.entity'; @Entity() export class Notification { @@ -62,4 +64,8 @@ export class Notification { }; return notification; } + + createUserNotifications(users: User[]) { + return users.map((user) => UserNotification.create(user, this)); + } } diff --git a/backend/src/app/notification/notification.controller.ts b/backend/src/app/notification/notification.controller.ts index 40af80ad..271be3cd 100644 --- a/backend/src/app/notification/notification.controller.ts +++ b/backend/src/app/notification/notification.controller.ts @@ -8,6 +8,7 @@ import { ParseIntPipe, Patch, Query, + Version, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { NotificationSettingRepository } from '@app/notification/repository/notification-setting.repository'; @@ -27,6 +28,8 @@ import { UserNotificationRepository } from '@app/notification/repository/user-no import { GetUserNotificationsResponse } from '@app/notification/dto/get-user-notifications-response.dto'; import { GetUserNotificationResult } from '@app/notification/dto/get-user-notification-result.dto'; import { UserNotificationNotFoundException } from '@app/notification/exception/user-notification-not-found.exception'; +import { NoOffsetPageRequest } from '@common/util/no-offset-page-request'; +import { V2GetUserNotificationsResponse } from '@app/notification/dto/v2-get-user-notifications-response.dto'; @Controller('notifications') @ApiTags('Notification') @@ -65,6 +68,34 @@ export class NotificationController { ); } + @Get('/') + @Version('2') + @JwtAuth() + @ApiSuccessResponse(HttpStatus.OK, V2GetUserNotificationsResponse) + async getNotificationsV2( + @CurrentUser() user: User, + @Query() query: NoOffsetPageRequest, + ) { + const userNotifications = + await this.userNotificationRepository.getNotificationsV2({ + user, + limit: query.limit, + nextId: query.nextId, + }); + + return ResponseEntity.OK_WITH_DATA( + new V2GetUserNotificationsResponse( + query.limit, + await Promise.all( + userNotifications.map((userNotification) => + GetUserNotificationResult.from(userNotification), + ), + ), + query.nextId, + ), + ); + } + @Get('settings') @JwtAuth() @ApiSuccessResponse(HttpStatus.OK, GetNotificationSettingsResponse, { diff --git a/backend/src/app/notification/notification.listener.ts b/backend/src/app/notification/notification.listener.ts index 3dd2c07c..786e9bf9 100644 --- a/backend/src/app/notification/notification.listener.ts +++ b/backend/src/app/notification/notification.listener.ts @@ -4,12 +4,13 @@ import { GroupSucceedEvent } from '@app/notification/event/group-succeed.event'; import { DataSource } from 'typeorm'; import { Notification } from '@app/notification/entity/notification.entity'; import { NOTIFICATION_SETTING_TYPE } from '@app/notification/constants/notification.constants'; -import { UserNotification } from '@app/notification/entity/user-notification.entity'; import { GroupApplicationRepository } from '@app/group-application/group-application.repository'; import { GroupFailedEvent } from '@app/notification/event/group-failed.event'; import { NotificationSettingRepository } from '@app/notification/repository/notification-setting.repository'; import { CommentAddedEvent } from '@app/notification/event/comment-added.event'; import { CommentRepository } from '@app/comment/comment.repository'; +import { SseService } from '@common/module/sse/sse.service'; +import { NotificationSse } from '@app/notification/dto/notification-sse.dto'; @Injectable() export class NotificationListener { @@ -20,6 +21,7 @@ export class NotificationListener { private readonly groupApplicationRepository: GroupApplicationRepository, private readonly notificationSettingRepository: NotificationSettingRepository, private readonly commentRepository: CommentRepository, + private readonly sseService: SseService, ) {} @OnEvent('group.succeed') @@ -40,17 +42,29 @@ export class NotificationListener { ), }); + if (targetUsers.length === 0) { + return; + } + const notification = Notification.createGroupSucceedNotification(groupArticle); + const userNotifications = + notification.createUserNotifications(targetUsers); + await this.dataSource.transaction(async (em) => { await em.save(notification); - await em.save( - targetUsers.map((user) => - UserNotification.create(user, notification), - ), - ); + await em.save(userNotifications); }); + + await Promise.all( + userNotifications.map(async (userNotification) => + this.sseService.emit( + await userNotification.user, + await NotificationSse.from(userNotification), + ), + ), + ); } catch (e) { this.logger.error(e); } @@ -74,17 +88,29 @@ export class NotificationListener { ), }); + if (targetUsers.length === 0) { + return; + } + const notification = Notification.createGroupFailedNotification(groupArticle); + const userNotifications = + notification.createUserNotifications(targetUsers); + await this.dataSource.transaction(async (em) => { await em.save(notification); - await em.save( - targetUsers.map((user) => - UserNotification.create(user, notification), - ), - ); + await em.save(userNotifications); }); + + await Promise.all( + userNotifications.map(async (userNotification) => + this.sseService.emit( + await userNotification.user, + await NotificationSse.from(userNotification), + ), + ), + ); } catch (e) { this.logger.error(e); } @@ -107,19 +133,31 @@ export class NotificationListener { .concat(groupArticle.userId), }); + if (targetUsers.length === 0) { + return; + } + const notification = await Notification.createCommentAddedNotification( groupArticle, comment, ); + const userNotifications = + notification.createUserNotifications(targetUsers); + await this.dataSource.transaction(async (em) => { await em.save(notification); - await em.save( - targetUsers.map((user) => - UserNotification.create(user, notification), - ), - ); + await em.save(userNotifications); }); + + await Promise.all( + userNotifications.map(async (userNotification) => + this.sseService.emit( + await userNotification.user, + await NotificationSse.from(userNotification), + ), + ), + ); } catch (e) { this.logger.error(e); } diff --git a/backend/src/app/notification/notification.module.ts b/backend/src/app/notification/notification.module.ts index 2b4ae44d..c89c2c75 100644 --- a/backend/src/app/notification/notification.module.ts +++ b/backend/src/app/notification/notification.module.ts @@ -6,9 +6,10 @@ import { NotificationListener } from '@app/notification/notification.listener'; import { GroupApplicationModule } from '@app/group-application/group-application.module'; import { CommentModule } from '@app/comment/comment.module'; import { UserNotificationRepository } from '@app/notification/repository/user-notification.repository'; +import { SseModule } from '@common/module/sse/sse.module'; @Module({ - imports: [GroupApplicationModule, CommentModule], + imports: [GroupApplicationModule, CommentModule, SseModule], controllers: [NotificationController], providers: [ NotificationService, diff --git a/backend/src/app/notification/repository/user-notification.repository.ts b/backend/src/app/notification/repository/user-notification.repository.ts index 2e84dbba..0375aff5 100644 --- a/backend/src/app/notification/repository/user-notification.repository.ts +++ b/backend/src/app/notification/repository/user-notification.repository.ts @@ -1,6 +1,6 @@ -import { DataSource, IsNull, Repository } from 'typeorm'; -import { UserNotification } from '@app/notification/entity/user-notification.entity'; import { Injectable } from '@nestjs/common'; +import { DataSource, IsNull, Repository, LessThan } from 'typeorm'; +import { UserNotification } from '@app/notification/entity/user-notification.entity'; import { User } from '@app/user/entity/user.entity'; @Injectable() @@ -38,4 +38,27 @@ export class UserNotificationRepository extends Repository { skip: offset, }); } + + getNotificationsV2({ + user, + limit, + nextId, + }: { + user: User; + limit: number; + nextId?: number; + }) { + return this.find({ + relations: { + notification: true, + }, + where: { + userId: user.id, + deletedAt: IsNull(), + ...(nextId ? { id: LessThan(nextId) } : {}), + }, + take: limit, + order: { id: 'DESC' }, + }); + } } diff --git a/backend/src/common/module/sse/sse-type.ts b/backend/src/common/module/sse/sse-type.ts new file mode 100644 index 00000000..f8ec6e50 --- /dev/null +++ b/backend/src/common/module/sse/sse-type.ts @@ -0,0 +1,3 @@ +export enum SseType { + NOTIFICATION = 'NOTIFICATION', +} diff --git a/backend/src/common/module/sse/sse.module.ts b/backend/src/common/module/sse/sse.module.ts new file mode 100644 index 00000000..5761fb32 --- /dev/null +++ b/backend/src/common/module/sse/sse.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SseService } from '@common/module/sse/sse.service'; + +@Module({ + providers: [SseService], + exports: [SseService], +}) +export class SseModule {} diff --git a/backend/src/common/module/sse/sse.service.ts b/backend/src/common/module/sse/sse.service.ts new file mode 100644 index 00000000..0fd51acd --- /dev/null +++ b/backend/src/common/module/sse/sse.service.ts @@ -0,0 +1,21 @@ +import { Injectable, MessageEvent } from '@nestjs/common'; +import { EventEmitter } from 'events'; +import { fromEvent } from 'rxjs'; +import { User } from '@app/user/entity/user.entity'; + +@Injectable() +export class SseService { + private readonly eventEmitter: EventEmitter; + + constructor() { + this.eventEmitter = new EventEmitter(); + } + + subscribe(user: User) { + return fromEvent(this.eventEmitter, `${user.id}`); + } + + async emit(user: User, event: MessageEvent) { + this.eventEmitter.emit(`${user.id}`, event); + } +} diff --git a/backend/src/sse.controller.ts b/backend/src/sse.controller.ts new file mode 100644 index 00000000..66e1bd72 --- /dev/null +++ b/backend/src/sse.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Sse } from '@nestjs/common'; +import { SseService } from '@common/module/sse/sse.service'; +import { JwtAuth } from '@decorator/jwt-auth.decorator'; +import { CurrentUser } from '@decorator/current-user.decorator'; +import { User } from '@app/user/entity/user.entity'; + +@Controller('sse') +export class SseController { + constructor(private readonly sseService: SseService) {} + + @Sse() + @JwtAuth() + sse(@CurrentUser() user: User) { + return this.sseService.subscribe(user); + } +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts deleted file mode 100644 index 032894da..00000000 --- a/backend/test/app.e2e-spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { DataSource } from 'typeorm'; -import { AppModule } from '@src/app.module'; -import { setNestApp } from '@src/setNestApp'; - -describe('AppController (e2e)', () => { - let app: INestApplication; - let dataSource: DataSource; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - - setNestApp(app); - - dataSource = app.get(DataSource); - - await app.init(); - }); - - afterAll(async () => { - await dataSource.destroy(); - }); - - describe('GET /v1/test/:id', () => { - const url = (id) => `/v1/test/${id}`; - - test('example test', async () => { - // given - const id = 11; - - // when - const result = await request(app.getHttpServer()).get(url(id)); - - // then - expect(result.status).toEqual(401); - }); - }); -}); diff --git a/backend/test/group-article.e2e-spec.ts b/backend/test/group-article.e2e-spec.ts new file mode 100644 index 00000000..189ee95b --- /dev/null +++ b/backend/test/group-article.e2e-spec.ts @@ -0,0 +1,1773 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { AppModule } from '@src/app.module'; +import { GroupArticle } from '@app/group-article/entity/group-article.entity'; +import { GroupCategory } from '@app/group-article/entity/group-category.entity'; +import { getGroupArticleFixture } from '@app/group-article/__test__/group-article.fixture'; +import { getGroupCategoryFixture } from '@app/group-article/__test__/group-category.fixture'; +import { getGroupFixture } from '@app/group-article/__test__/group.fixture'; +import { User } from '@app/user/entity/user.entity'; +import { getUserFixture } from '@app/user/__test__/user.fixture'; +import { setNestApp } from '@src/setNestApp'; +import { DataSource } from 'typeorm'; +import * as request from 'supertest'; +import { JwtTokenService } from '@common/module/jwt-token/jwt-token.service'; +import { setCookie } from './utils/jwt-test.utils'; +import { + CATEGORY, + GROUP_STATUS, + LOCATION, +} from '@app/group-article/constants/group-article.constants'; +import { GroupArticleRegisterRequest } from '@app/group-article/dto/group-article-register-request.dto'; +import { UpdateGroupArticleRequest } from '@src/app/group-article/dto/update-group-article-request.dto'; +import { GroupApplicationService } from '@src/app/group-application/group-application.service'; + +describe('Group Application (e2e)', () => { + let app: INestApplication; + let dataSource: DataSource; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + + setNestApp(app); + + dataSource = app.get(DataSource); + + await app.init(); + }); + + beforeEach(async () => { + const groupCategoryRepository = dataSource.getRepository(GroupCategory); + const categories = getGroupCategoryFixture(); + await groupCategoryRepository.save(categories); + + const userRepository = dataSource.getRepository(User); + const user1 = getUserFixture({ id: 1 }); + const user2 = getUserFixture({ id: 2 }); + await userRepository.save([user1, user2]); + + const group1 = getGroupFixture(categories[1], { id: 1, maxCapacity: 2 }); + const group2 = getGroupFixture(categories[1], { id: 2 }); + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle1 = await getGroupArticleFixture(group1, { + id: 1, + user: new Promise((res) => res(user1)), + userId: user1.id, + }); + const groupArticle2 = await getGroupArticleFixture(group2, { + id: 2, + user: new Promise((res) => res(user1)), + userId: user1.id, + }); + await groupArticleRepository.save([groupArticle1, groupArticle2]); + }); + + afterEach(async () => { + await dataSource.synchronize(true); + }); + + afterAll(async () => { + await dataSource.destroy(); + }); + + describe('모집 게시글 생성 POST /v1/group-articles', () => { + const url = () => `/v1/group-articles`; + + test('모집게시글을 등록하면 201 코드와 group article의 아이디(id: 3)를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleRquest = new GroupArticleRegisterRequest(); + groupArticleRquest.title = 'CS 스터디'; + groupArticleRquest.contents = 'Hello'; + groupArticleRquest.category = CATEGORY.STUDY; + groupArticleRquest.location = LOCATION.ONLINE; + groupArticleRquest.maxCapacity = 5; + groupArticleRquest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + groupArticleRquest.chatUrl = '채팅 url'; + + // when + const result = await request(app.getHttpServer()) + .post(url()) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(groupArticleRquest); + + // then + expect(result.status).toEqual(201); + expect(result.body.data.id).toEqual(3); + }); + + test('모집게시글을 등록할 때 contents를 적지 않으면 400 에러를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleRquest = new GroupArticleRegisterRequest(); + groupArticleRquest.title = 'CS 스터디'; + groupArticleRquest.contents = ''; + groupArticleRquest.category = CATEGORY.STUDY; + groupArticleRquest.location = LOCATION.ONLINE; + groupArticleRquest.maxCapacity = 5; + groupArticleRquest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + groupArticleRquest.chatUrl = '채팅 url'; + + // when + const result = await request(app.getHttpServer()) + .post(url()) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(groupArticleRquest); + + // then + expect(result.status).toEqual(400); + }); + + test('모집게시글을 등록할 때 타이틀이 0자면 400 에러를 보낸다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleRquest = new GroupArticleRegisterRequest(); + groupArticleRquest.title = ''; + groupArticleRquest.contents = 'Hello'; + groupArticleRquest.category = CATEGORY.STUDY; + groupArticleRquest.location = LOCATION.ONLINE; + groupArticleRquest.maxCapacity = 5; + groupArticleRquest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + groupArticleRquest.chatUrl = '채팅 url'; + + // when + const result = await request(app.getHttpServer()) + .post(url()) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(groupArticleRquest); + + // then + expect(result.status).toEqual(400); + }); + + test('모집게시글을 등록할 때 타이틀이 100자 초과면 400 에러를 보낸다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleRquest = new GroupArticleRegisterRequest(); + groupArticleRquest.title = 'a'.repeat(101); + groupArticleRquest.contents = 'Hello'; + groupArticleRquest.category = CATEGORY.STUDY; + groupArticleRquest.location = LOCATION.ONLINE; + groupArticleRquest.maxCapacity = 5; + groupArticleRquest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + groupArticleRquest.chatUrl = '채팅 url'; + + // when + const result = await request(app.getHttpServer()) + .post(url()) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(groupArticleRquest); + + // then + expect(result.status).toEqual(400); + }); + + test('모집게시글을 등록할 때 정해진 카테고리값을 입력하지 않으면 400에러를 뱉는다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + + // when + const result = await request(app.getHttpServer()) + .post(url()) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send({ + title: 'study', + contents: 'Hello', + category: 'nothing', + location: LOCATION.ONLINE, + maxCapacity: 10, + thumbnail: + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png', + chatUrl: 'url', + }); + + // then + expect(result.status).toEqual(400); + }); + + test('모집게시글을 등록할 때 정해진 위치값을 입력하지 않으면 400에러를 뱉는다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + + // when + const result = await request(app.getHttpServer()) + .post(url()) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send({ + title: 'study', + contents: 'Hello', + category: CATEGORY.STUDY, + location: 'nothing', + maxCapacity: 10, + thumbnail: + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png', + chatUrl: 'url', + }); + + // then + expect(result.status).toEqual(400); + }); + + test('모집게시글을 등록할 때 최대 인원을 초과하면 400 에러를 보여준다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleRquest = new GroupArticleRegisterRequest(); + groupArticleRquest.title = 'CS 스터디'; + groupArticleRquest.contents = 'Hello'; + groupArticleRquest.category = CATEGORY.STUDY; + groupArticleRquest.location = LOCATION.ONLINE; + groupArticleRquest.maxCapacity = 30; + groupArticleRquest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + groupArticleRquest.chatUrl = '채팅 url'; + + // when + const result = await request(app.getHttpServer()) + .post(url()) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(groupArticleRquest); + + // then + expect(result.status).toEqual(400); + }); + + test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => { + // given + + // when + const result = await request(app.getHttpServer()).post(url()); + + // then + expect(result.status).toEqual(401); + }); + }); + + describe('모집 게시글 모집 중단 POST /group-articles/:id/recruitment-cancel', () => { + const url = (id: number) => `/v1/group-articles/${id}/recruitment-cancel`; + + test('모집게시글의 상태를 정상적으로 모집 취소로 바꾼다면 204 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .post(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(204); + }); + + test('모집게시글의 상태가 이미 모집완료라면 400 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + + const groupArticleId = 1; + const groupAriticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle = await groupAriticleRepository.findOneBy({ + id: groupArticleId, + }); + groupArticle.complete(user); + await groupAriticleRepository.save(groupArticle); + + // when + const result = await request(app.getHttpServer()) + .post(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(400); + }); + + test('모집게시글의 상태가 이미 모집취소 상태라면 400 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + + const groupArticleId = 1; + const groupAriticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle = await groupAriticleRepository.findOneBy({ + id: groupArticleId, + }); + groupArticle.cancel(user); + await groupAriticleRepository.save(groupArticle); + + // when + const result = await request(app.getHttpServer()) + .post(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(400); + }); + + test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => { + // given + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()).post( + url(groupArticleId), + ); + + // then + expect(result.status).toEqual(401); + }); + + test('글 작성자가 아닌 다른 유저가 모집 상태를 바꾸려고 하면 403 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 2 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .post(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(403); + }); + + test('아이디에 해당하는 모집게시글이 없다면 404 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 10000; + + // when + const result = await request(app.getHttpServer()) + .post(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(404); + }); + }); + + describe('모집 게시글 모집 완료 POST /group-articles/:id/recruitment-complete', () => { + const url = (id: number) => `/v1/group-articles/${id}/recruitment-complete`; + + test('모집게시글의 상태를 정상적으로 모집 완료로 바꾼다면 204 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .post(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(204); + }); + + test('모집게시글의 상태가 이미 모집완료라면 400 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + + const groupArticleId = 1; + const groupAriticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle = await groupAriticleRepository.findOneBy({ + id: groupArticleId, + }); + groupArticle.complete(user); + await groupAriticleRepository.save(groupArticle); + + // when + const result = await request(app.getHttpServer()) + .post(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(400); + }); + + test('모집게시글의 상태가 이미 모집취소 상태라면 400 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + + const groupArticleId = 1; + const groupAriticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle = await groupAriticleRepository.findOneBy({ + id: groupArticleId, + }); + groupArticle.cancel(user); + await groupAriticleRepository.save(groupArticle); + + // when + const result = await request(app.getHttpServer()) + .post(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(400); + }); + + test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => { + // given + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()).post( + url(groupArticleId), + ); + + // then + expect(result.status).toEqual(401); + }); + + test('글 작성자가 아닌 다른 유저가 모집 상태를 바꾸려고 하면 403 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 2 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .post(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(403); + }); + + test('아이디에 해당하는 모집게시글이 없다면 404 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 10000; + + // when + const result = await request(app.getHttpServer()) + .post(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(404); + }); + }); + + describe('모집 게시글 수정 PUT /group-articles/:id', () => { + const url = (id: number) => `/v1/group-articles/${id}`; + + test('모집게시글을 정상적으로 수정하면 204 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const updateGroupArticleRequest = new UpdateGroupArticleRequest(); + updateGroupArticleRequest.title = 'CS 스터디'; + updateGroupArticleRequest.contents = 'Hello'; + updateGroupArticleRequest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + updateGroupArticleRequest.chatUrl = '채팅 url'; + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .put(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(updateGroupArticleRequest); + + // then + expect(result.status).toEqual(204); + }); + + test('모집게시글을 수정할 때 제목이 없으면 400 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const updateGroupArticleRequest = new UpdateGroupArticleRequest(); + updateGroupArticleRequest.title = ''; + updateGroupArticleRequest.contents = 'Hello'; + updateGroupArticleRequest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + updateGroupArticleRequest.chatUrl = '채팅 url'; + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .put(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(updateGroupArticleRequest); + + // then + expect(result.status).toEqual(400); + }); + + test('모집게시글을 수정할 때 콘텐츠가 없으면 400 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const updateGroupArticleRequest = new UpdateGroupArticleRequest(); + updateGroupArticleRequest.title = 'CS 스터디'; + updateGroupArticleRequest.contents = ''; + updateGroupArticleRequest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + updateGroupArticleRequest.chatUrl = '채팅 url'; + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .put(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(updateGroupArticleRequest); + + // then + expect(result.status).toEqual(400); + }); + + test('모집게시글을 수정할 때 썸네일이 없으면 400 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const updateGroupArticleRequest = new UpdateGroupArticleRequest(); + updateGroupArticleRequest.title = 'CS 스터디'; + updateGroupArticleRequest.contents = 'Hello'; + updateGroupArticleRequest.thumbnail = ''; + updateGroupArticleRequest.chatUrl = '채팅 url'; + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .put(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(updateGroupArticleRequest); + + // then + expect(result.status).toEqual(400); + }); + + test('모집게시글을 수정할 때 채팅 URL이 없으면 400 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const updateGroupArticleRequest = new UpdateGroupArticleRequest(); + updateGroupArticleRequest.title = 'CS 스터디'; + updateGroupArticleRequest.contents = 'Hello'; + updateGroupArticleRequest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + updateGroupArticleRequest.chatUrl = ''; + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .put(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(updateGroupArticleRequest); + + // then + expect(result.status).toEqual(400); + }); + + test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => { + // given + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()).put( + url(groupArticleId), + ); + + // then + expect(result.status).toEqual(401); + }); + + test('모집게시글을 수정할 때 작성자가 아니라면 403 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 2 }); + const accessToken = jwtService.generateAccessToken(user); + const updateGroupArticleRequest = new UpdateGroupArticleRequest(); + updateGroupArticleRequest.title = 'CS 스터디'; + updateGroupArticleRequest.contents = 'Hello'; + updateGroupArticleRequest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + updateGroupArticleRequest.chatUrl = 'chat url'; + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .put(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(updateGroupArticleRequest); + + // then + expect(result.status).toEqual(403); + }); + + test('모집게시글을 수정할 때 없는 게시물에 접근하면 404 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const updateGroupArticleRequest = new UpdateGroupArticleRequest(); + updateGroupArticleRequest.title = 'CS 스터디'; + updateGroupArticleRequest.contents = 'Hello'; + updateGroupArticleRequest.thumbnail = + 'https://kr.object.ncloudstorage.com/moyeo-images/uploads/images/1669282011949-761671c7-cc43-4cee-bcb5-4bf3fea9478b.png'; + updateGroupArticleRequest.chatUrl = 'chat url'; + const groupArticleId = 10000; + + // when + const result = await request(app.getHttpServer()) + .put(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }) + .send(updateGroupArticleRequest); + + // then + expect(result.status).toEqual(404); + }); + }); + + describe('모집 게시글 단일 조회 GET /group-articles/:id', () => { + const url = (id: number) => `/v1/group-articles/${id}`; + + test('모집게시글을 정상조회하면 200코드와 게시글 상세정보를 준다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 1; + + const groupArticle = await dataSource + .getRepository(GroupArticle) + .findOneBy({ id: 1 }); + + // when + const result = await request(app.getHttpServer()) + .get(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.id).toEqual(groupArticle.id); + expect(result.body.data.title).toEqual(groupArticle.title); + expect(result.body.data.contents).toEqual(groupArticle.contents); + expect(result.body.data.author).toEqual({ + id: user.id, + userName: user.userName, + profileImage: user.profileImage, + }); + expect(result.body.data.category).toEqual( + groupArticle.group.category.name, + ); + expect(result.body.data.location).toEqual(groupArticle.group.location); + expect(result.body.data.thumbnail).toEqual(groupArticle.group.thumbnail); + expect(result.body.data.status).toEqual(groupArticle.group.status); + }); + + test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => { + // given + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()).get( + url(groupArticleId), + ); + + // then + expect(result.status).toEqual(401); + }); + + test('해당하는 모집 게시글이 존재하지 않는다면 404코드를 준다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 10000; + + // when + const result = await request(app.getHttpServer()) + .get(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(404); + }); + }); + + describe('모집 게시글 채팅방 URL 조회 GET /group-articles/:id/chat-url', () => { + const url = (id: number) => `/v1/group-articles/${id}/chat-url`; + + test('모집게시글이 모집 완료된 상태로 Chat URL을 정상조회하면 200코드와 채팅 URL을 전달한다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const userRepository = dataSource.getRepository(User); + const author = await userRepository.findOneBy({ id: 1 }); + const user = await userRepository.findOneBy({ id: 2 }); + const accessToken = jwtService.generateAccessToken(user); + + const groupArticleId = 2; + const groupApplicationService = app.get(GroupApplicationService); + await groupApplicationService.joinGroup(user, groupArticleId); + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle = await groupArticleRepository.findOneBy({ + id: groupArticleId, + }); + groupArticle.complete(author); + await groupArticleRepository.save(groupArticle); + + // when + const result = await request(app.getHttpServer()) + .get(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.chatUrl).toEqual(groupArticle.group.chatUrl); + }); + + test('모임게시글이 아직 모집 중이라면 400코드를 준다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .get(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(400); + }); + + test('모임게시글이 모집 실패 상태라면 400코드를 준다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 1; + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle = await groupArticleRepository.findOneBy({ id: 1 }); + groupArticle.cancel(user); + await groupArticleRepository.save(groupArticle); + + // when + const result = await request(app.getHttpServer()) + .get(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(400); + }); + + test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => { + // given + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()).get( + url(groupArticleId), + ); + + // then + expect(result.status).toEqual(401); + }); + + test('모집게시글의 참가자가 아니라면 403 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const author = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const user = await dataSource.getRepository(User).findOneBy({ id: 2 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 1; + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle = await groupArticleRepository.findOneBy({ id: 1 }); + groupArticle.complete(author); + await groupArticleRepository.save(groupArticle); + + // when + const result = await request(app.getHttpServer()) + .get(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(403); + }); + + test('해당하는 모집 게시글이 존재하지 않는다면 404코드를 준다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 10000; + + // when + const result = await request(app.getHttpServer()) + .get(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(404); + }); + }); + + describe('모집게시글 카테고리 조회 GET /group-articles/categories', () => { + const url = () => `/v1/group-articles/categories`; + + test('카테고리를 정상 조회하면 200 OK와 카테고리 정보를 던져준다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const userRepository = dataSource.getRepository(User); + const user = await userRepository.findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + + // when + const result = await request(app.getHttpServer()) + .get(url()) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(200); + expect(result.body.data[0]).toEqual({ + id: 1, + name: 'MEAL', + }); + expect(result.body.data[1]).toEqual({ + id: 2, + name: 'STUDY', + }); + expect(result.body.data[2]).toEqual({ + id: 3, + name: 'ETC', + }); + expect(result.body.data[3]).toEqual({ + id: 4, + name: 'COMPETITION', + }); + expect(result.body.data[4]).toEqual({ + id: 5, + name: 'PROJECT', + }); + }); + + test('JWT 토큰이 없어도 200 OK와 카테고리 데이터를 준다.', async () => { + // given + + // when + const result = await request(app.getHttpServer()).get(url()); + + // then + expect(result.status).toEqual(200); + expect(result.body.data[0]).toEqual({ + id: 1, + name: 'MEAL', + }); + expect(result.body.data[1]).toEqual({ + id: 2, + name: 'STUDY', + }); + expect(result.body.data[2]).toEqual({ + id: 3, + name: 'ETC', + }); + expect(result.body.data[3]).toEqual({ + id: 4, + name: 'COMPETITION', + }); + expect(result.body.data[4]).toEqual({ + id: 5, + name: 'PROJECT', + }); + }); + }); + + describe('모집 게시글 리스트 조회 GET /v1/group-articles/search?currentPage={currentPage}&countPerPage={countPerPage}category={category}&location={location}&status={status}', () => { + const url = ({ + currentPage = 1, + countPerPage = 10, + category = '', + location = '', + status = '', + }: { + currentPage: number; + countPerPage: number; + category: string; + location: string; + status: string; + }) => { + let url = `/v1/group-articles/search?currentPage=${currentPage}&countPerPage=${countPerPage}`; + if (category.length > 0) url += `&category=${category}`; + if (location.length > 0) url += `&location=${location}`; + if (status.length > 0) url += `&status=${status}`; + return url; + }; + + test('모집게시글을 조건에 맞게 검색하면 200 코드와 게시글 데이터를 전달해준다.', async () => { + // given + const currentPage = 1; + const countPerPage = 10; + const category = CATEGORY.STUDY; + const location = LOCATION.ONLINE; + const status = GROUP_STATUS.PROGRESS; + + // when + const result = await request(app.getHttpServer()).get( + url({ countPerPage, currentPage, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.totalPage).toEqual(1); + expect(result.body.data.totalCount).toEqual(2); + expect(result.body.data.currentPage).toEqual(1); + expect(result.body.data.countPerPage).toEqual(10); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(2); + expect(result.body.data.data[1].id).toEqual(1); + + // 검색 조건이 맞는지 + expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS); + }); + + test('모집게시글을 조건에 맞게 검색하면 200 코드와 게시글 데이터를 전달해준다.', async () => { + // given + const currentPage = 1; + const countPerPage = 10; + const category = CATEGORY.STUDY; + const location = LOCATION.ONLINE; + const status = GROUP_STATUS.PROGRESS; + + // when + const result = await request(app.getHttpServer()).get( + url({ countPerPage, currentPage, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.totalPage).toEqual(1); + expect(result.body.data.totalCount).toEqual(2); + expect(result.body.data.currentPage).toEqual(1); + expect(result.body.data.countPerPage).toEqual(10); + expect(result.body.data.data[0].id).toEqual(2); + expect(result.body.data.data[1].id).toEqual(1); + }); + + test('모집게시글을 카테고리 없이 조회하면 모든 카테고리에 해당하는 게시글을 불러온다.', async () => { + // given + const categories = getGroupCategoryFixture(); + + const userRepository = dataSource.getRepository(User); + const user = getUserFixture({ id: 3 }); + await userRepository.save(user); + + const group1 = getGroupFixture(categories[2], { + id: 3, + maxCapacity: 5, + status: GROUP_STATUS.SUCCEED, + }); + const group2 = getGroupFixture(categories[3], { + id: 4, + status: GROUP_STATUS.FAIL, + }); + const group3 = getGroupFixture(categories[3], { + id: 5, + status: GROUP_STATUS.PROGRESS, + }); + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle1 = await getGroupArticleFixture(group1, { + id: 3, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle2 = await getGroupArticleFixture(group2, { + id: 4, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle3 = await getGroupArticleFixture(group3, { + id: 5, + user: new Promise((res) => res(user)), + userId: user.id, + }); + await groupArticleRepository.save([ + groupArticle1, + groupArticle2, + groupArticle3, + ]); + + const currentPage = 1; + const countPerPage = 10; + const category = ''; + const location = LOCATION.ONLINE; + const status = GROUP_STATUS.PROGRESS; + + // when + const result = await request(app.getHttpServer()).get( + url({ countPerPage, currentPage, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.totalPage).toEqual(1); + expect(result.body.data.totalCount).toEqual(3); + expect(result.body.data.currentPage).toEqual(1); + expect(result.body.data.countPerPage).toEqual(10); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(5); + expect(result.body.data.data[1].id).toEqual(2); + expect(result.body.data.data[2].id).toEqual(1); + + //검색조건이 맞는지 + expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[2].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[2].status).toEqual(GROUP_STATUS.PROGRESS); + }); + + test('모집게시글을 location 없이 조회하면 모든 location에 해당하고 검색 조건이 맞는 게시글을 불러온다.', async () => { + // given + const categories = getGroupCategoryFixture(); + + const userRepository = dataSource.getRepository(User); + const user = getUserFixture({ id: 3 }); + await userRepository.save(user); + + const group1 = getGroupFixture(categories[2], { + id: 3, + maxCapacity: 5, + status: GROUP_STATUS.SUCCEED, + }); + const group2 = getGroupFixture(categories[3], { + id: 4, + status: GROUP_STATUS.PROGRESS, + location: LOCATION.SEOUL, + }); + const group3 = getGroupFixture(categories[1], { + id: 5, + status: GROUP_STATUS.PROGRESS, + location: LOCATION.BUSAN, + }); + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle1 = await getGroupArticleFixture(group1, { + id: 3, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle2 = await getGroupArticleFixture(group2, { + id: 4, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle3 = await getGroupArticleFixture(group3, { + id: 5, + user: new Promise((res) => res(user)), + userId: user.id, + }); + await groupArticleRepository.save([ + groupArticle1, + groupArticle2, + groupArticle3, + ]); + + const currentPage = 1; + const countPerPage = 10; + const category = CATEGORY.STUDY; + const location = ''; + const status = GROUP_STATUS.PROGRESS; + + // when + const result = await request(app.getHttpServer()).get( + url({ countPerPage, currentPage, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.totalPage).toEqual(1); + expect(result.body.data.totalCount).toEqual(3); + expect(result.body.data.currentPage).toEqual(1); + expect(result.body.data.countPerPage).toEqual(10); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(5); + expect(result.body.data.data[1].id).toEqual(2); + expect(result.body.data.data[2].id).toEqual(1); + + //검색조건이 맞는지 + expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[2].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[2].status).toEqual(GROUP_STATUS.PROGRESS); + }); + + test('모집게시글을 location 없이 조회하면 모든 status에 해당하고 검색 조건이 맞는 해당하는 게시글을 불러온다.', async () => { + // given + const categories = getGroupCategoryFixture(); + + const userRepository = dataSource.getRepository(User); + const user = getUserFixture({ id: 3 }); + await userRepository.save(user); + + const group1 = getGroupFixture(categories[1], { + id: 3, + maxCapacity: 5, + status: GROUP_STATUS.SUCCEED, + location: LOCATION.ONLINE, + }); + const group2 = getGroupFixture(categories[3], { + id: 4, + status: GROUP_STATUS.PROGRESS, + location: LOCATION.SEOUL, + }); + const group3 = getGroupFixture(categories[1], { + id: 5, + status: GROUP_STATUS.SUCCEED, + location: LOCATION.ONLINE, + }); + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle1 = await getGroupArticleFixture(group1, { + id: 3, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle2 = await getGroupArticleFixture(group2, { + id: 4, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle3 = await getGroupArticleFixture(group3, { + id: 5, + user: new Promise((res) => res(user)), + userId: user.id, + }); + await groupArticleRepository.save([ + groupArticle1, + groupArticle2, + groupArticle3, + ]); + + const currentPage = 1; + const countPerPage = 10; + const category = CATEGORY.STUDY; + const location = LOCATION.ONLINE; + const status = ''; + + // when + const result = await request(app.getHttpServer()).get( + url({ countPerPage, currentPage, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.totalPage).toEqual(1); + expect(result.body.data.totalCount).toEqual(4); + expect(result.body.data.currentPage).toEqual(1); + expect(result.body.data.countPerPage).toEqual(10); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(5); + expect(result.body.data.data[1].id).toEqual(3); + expect(result.body.data.data[2].id).toEqual(2); + expect(result.body.data.data[3].id).toEqual(1); + + //검색조건이 맞는지 + expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[2].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[3].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[2].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[3].location).toEqual(LOCATION.ONLINE); + }); + + test('모집게시글을 검색 조건 없이 조회하면 모든 게시글을 조회한다.', async () => { + // given + const categories = getGroupCategoryFixture(); + + const userRepository = dataSource.getRepository(User); + const user = getUserFixture({ id: 3 }); + await userRepository.save(user); + + const group1 = getGroupFixture(categories[1], { + id: 3, + maxCapacity: 5, + status: GROUP_STATUS.FAIL, + location: LOCATION.BUSAN, + }); + const group2 = getGroupFixture(categories[3], { + id: 4, + status: GROUP_STATUS.PROGRESS, + location: LOCATION.SEOUL, + }); + const group3 = getGroupFixture(categories[2], { + id: 5, + status: GROUP_STATUS.SUCCEED, + location: LOCATION.ONLINE, + }); + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle1 = await getGroupArticleFixture(group1, { + id: 3, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle2 = await getGroupArticleFixture(group2, { + id: 4, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle3 = await getGroupArticleFixture(group3, { + id: 5, + user: new Promise((res) => res(user)), + userId: user.id, + }); + await groupArticleRepository.save([ + groupArticle1, + groupArticle2, + groupArticle3, + ]); + + const currentPage = 1; + const countPerPage = 10; + const category = ''; + const location = ''; + const status = ''; + + // when + const result = await request(app.getHttpServer()).get( + url({ countPerPage, currentPage, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.totalPage).toEqual(1); + expect(result.body.data.totalCount).toEqual(5); + expect(result.body.data.currentPage).toEqual(1); + expect(result.body.data.countPerPage).toEqual(10); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(5); + expect(result.body.data.data[1].id).toEqual(4); + expect(result.body.data.data[2].id).toEqual(3); + expect(result.body.data.data[3].id).toEqual(2); + expect(result.body.data.data[4].id).toEqual(1); + }); + }); + + describe('모집 게시글 리스트 조회 GET /v2/group-articles/search?currentPage={currentPage}&countPerPage={countPerPage}category={category}&location={location}&status={status}', () => { + const url = ({ + limit = 10, + nextId = 50, + category = '', + location = '', + status = '', + }: { + limit: number; + nextId: number; + category: string; + location: string; + status: string; + }) => { + let url = `/v2/group-articles/search?limit=${limit}&nextId=${nextId}`; + if (category.length > 0) url += `&category=${category}`; + if (location.length > 0) url += `&location=${location}`; + if (status.length > 0) url += `&status=${status}`; + return url; + }; + + test('모집게시글을 조건에 맞게 검색하면 200 코드와 게시글 데이터를 전달해준다.', async () => { + // given + const limit = 10; + const nextId = 50; + const category = CATEGORY.STUDY; + const location = LOCATION.ONLINE; + const status = GROUP_STATUS.PROGRESS; + + // when + const result = await request(app.getHttpServer()).get( + url({ limit, nextId, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.limit).toEqual(limit); + expect(result.body.data.beforeNextId).toEqual(nextId); + expect(result.body.data.nextId).toEqual(1); + expect(result.body.data.isLast).toEqual(true); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(2); + expect(result.body.data.data[1].id).toEqual(1); + + // 검색 조건이 맞는지 + expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS); + }); + + test('모집게시글을 조건에 맞게 검색하면 200 코드와 게시글 데이터를 전달해준다.', async () => { + // given + const limit = 10; + const nextId = 50; + const category = CATEGORY.STUDY; + const location = LOCATION.ONLINE; + const status = GROUP_STATUS.PROGRESS; + + // when + const result = await request(app.getHttpServer()).get( + url({ limit, nextId, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.limit).toEqual(limit); + expect(result.body.data.beforeNextId).toEqual(nextId); + expect(result.body.data.nextId).toEqual(1); + expect(result.body.data.isLast).toEqual(true); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(2); + expect(result.body.data.data[1].id).toEqual(1); + + //검색조건이 맞는지 + expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS); + }); + + test('모집게시글을 카테고리 없이 조회하면 모든 카테고리에 해당하는 게시글을 불러온다.', async () => { + // given + const categories = getGroupCategoryFixture(); + + const userRepository = dataSource.getRepository(User); + const user = getUserFixture({ id: 3 }); + await userRepository.save(user); + + const group1 = getGroupFixture(categories[2], { + id: 3, + maxCapacity: 5, + status: GROUP_STATUS.SUCCEED, + }); + const group2 = getGroupFixture(categories[3], { + id: 4, + status: GROUP_STATUS.FAIL, + }); + const group3 = getGroupFixture(categories[3], { + id: 5, + status: GROUP_STATUS.PROGRESS, + }); + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle1 = await getGroupArticleFixture(group1, { + id: 3, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle2 = await getGroupArticleFixture(group2, { + id: 4, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle3 = await getGroupArticleFixture(group3, { + id: 5, + user: new Promise((res) => res(user)), + userId: user.id, + }); + await groupArticleRepository.save([ + groupArticle1, + groupArticle2, + groupArticle3, + ]); + + const limit = 10; + const nextId = 50; + const category = ''; + const location = LOCATION.ONLINE; + const status = GROUP_STATUS.PROGRESS; + + // when + const result = await request(app.getHttpServer()).get( + url({ limit, nextId, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.limit).toEqual(limit); + expect(result.body.data.beforeNextId).toEqual(nextId); + expect(result.body.data.nextId).toEqual(1); + expect(result.body.data.isLast).toEqual(true); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(5); + expect(result.body.data.data[1].id).toEqual(2); + expect(result.body.data.data[2].id).toEqual(1); + + //검색조건이 맞는지 + expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[2].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[2].status).toEqual(GROUP_STATUS.PROGRESS); + }); + + test('모집게시글을 location 없이 조회하면 모든 location에 해당하고 검색 조건이 맞는 게시글을 불러온다.', async () => { + // given + const categories = getGroupCategoryFixture(); + + const userRepository = dataSource.getRepository(User); + const user = getUserFixture({ id: 3 }); + await userRepository.save(user); + + const group1 = getGroupFixture(categories[2], { + id: 3, + maxCapacity: 5, + status: GROUP_STATUS.SUCCEED, + }); + const group2 = getGroupFixture(categories[3], { + id: 4, + status: GROUP_STATUS.PROGRESS, + location: LOCATION.SEOUL, + }); + const group3 = getGroupFixture(categories[1], { + id: 5, + status: GROUP_STATUS.PROGRESS, + location: LOCATION.BUSAN, + }); + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle1 = await getGroupArticleFixture(group1, { + id: 3, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle2 = await getGroupArticleFixture(group2, { + id: 4, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle3 = await getGroupArticleFixture(group3, { + id: 5, + user: new Promise((res) => res(user)), + userId: user.id, + }); + await groupArticleRepository.save([ + groupArticle1, + groupArticle2, + groupArticle3, + ]); + + const limit = 10; + const nextId = 50; + const category = CATEGORY.STUDY; + const location = ''; + const status = GROUP_STATUS.PROGRESS; + + // when + const result = await request(app.getHttpServer()).get( + url({ limit, nextId, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.limit).toEqual(limit); + expect(result.body.data.beforeNextId).toEqual(nextId); + expect(result.body.data.nextId).toEqual(1); + expect(result.body.data.isLast).toEqual(true); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(5); + expect(result.body.data.data[1].id).toEqual(2); + expect(result.body.data.data[2].id).toEqual(1); + + //검색조건이 맞는지 + expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[2].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[0].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[1].status).toEqual(GROUP_STATUS.PROGRESS); + expect(result.body.data.data[2].status).toEqual(GROUP_STATUS.PROGRESS); + }); + + test('모집게시글을 location 없이 조회하면 모든 status에 해당하고 검색 조건이 맞는 해당하는 게시글을 불러온다.', async () => { + // given + const categories = getGroupCategoryFixture(); + + const userRepository = dataSource.getRepository(User); + const user = getUserFixture({ id: 3 }); + await userRepository.save(user); + + const group1 = getGroupFixture(categories[1], { + id: 3, + maxCapacity: 5, + status: GROUP_STATUS.SUCCEED, + location: LOCATION.ONLINE, + }); + const group2 = getGroupFixture(categories[3], { + id: 4, + status: GROUP_STATUS.PROGRESS, + location: LOCATION.SEOUL, + }); + const group3 = getGroupFixture(categories[1], { + id: 5, + status: GROUP_STATUS.SUCCEED, + location: LOCATION.ONLINE, + }); + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle1 = await getGroupArticleFixture(group1, { + id: 3, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle2 = await getGroupArticleFixture(group2, { + id: 4, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle3 = await getGroupArticleFixture(group3, { + id: 5, + user: new Promise((res) => res(user)), + userId: user.id, + }); + await groupArticleRepository.save([ + groupArticle1, + groupArticle2, + groupArticle3, + ]); + + const limit = 10; + const nextId = 50; + const category = CATEGORY.STUDY; + const location = LOCATION.ONLINE; + const status = ''; + + // when + const result = await request(app.getHttpServer()).get( + url({ limit, nextId, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.limit).toEqual(limit); + expect(result.body.data.beforeNextId).toEqual(nextId); + expect(result.body.data.nextId).toEqual(1); + expect(result.body.data.isLast).toEqual(true); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(5); + expect(result.body.data.data[1].id).toEqual(3); + expect(result.body.data.data[2].id).toEqual(2); + expect(result.body.data.data[3].id).toEqual(1); + + //검색조건이 맞는지 + expect(result.body.data.data[0].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[1].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[2].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[3].category).toEqual(CATEGORY.STUDY); + expect(result.body.data.data[0].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[1].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[2].location).toEqual(LOCATION.ONLINE); + expect(result.body.data.data[3].location).toEqual(LOCATION.ONLINE); + }); + + test('모집게시글을 검색 조건 없이 조회하면 모든 게시글을 조회한다.', async () => { + // given + const categories = getGroupCategoryFixture(); + + const userRepository = dataSource.getRepository(User); + const user = getUserFixture({ id: 3 }); + await userRepository.save(user); + + const group1 = getGroupFixture(categories[1], { + id: 3, + maxCapacity: 5, + status: GROUP_STATUS.FAIL, + location: LOCATION.BUSAN, + }); + const group2 = getGroupFixture(categories[3], { + id: 4, + status: GROUP_STATUS.PROGRESS, + location: LOCATION.SEOUL, + }); + const group3 = getGroupFixture(categories[2], { + id: 5, + status: GROUP_STATUS.SUCCEED, + location: LOCATION.ONLINE, + }); + + const groupArticleRepository = dataSource.getRepository(GroupArticle); + const groupArticle1 = await getGroupArticleFixture(group1, { + id: 3, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle2 = await getGroupArticleFixture(group2, { + id: 4, + user: new Promise((res) => res(user)), + userId: user.id, + }); + const groupArticle3 = await getGroupArticleFixture(group3, { + id: 5, + user: new Promise((res) => res(user)), + userId: user.id, + }); + await groupArticleRepository.save([ + groupArticle1, + groupArticle2, + groupArticle3, + ]); + + const limit = 10; + const nextId = 50; + const category = ''; + const location = ''; + const status = ''; + + // when + const result = await request(app.getHttpServer()).get( + url({ limit, nextId, category, location, status }), + ); + + // then + expect(result.status).toEqual(200); + expect(result.body.data.limit).toEqual(limit); + expect(result.body.data.beforeNextId).toEqual(nextId); + expect(result.body.data.nextId).toEqual(1); + expect(result.body.data.isLast).toEqual(true); + + // 최신순인지 + expect(result.body.data.data[0].id).toEqual(5); + expect(result.body.data.data[1].id).toEqual(4); + expect(result.body.data.data[2].id).toEqual(3); + expect(result.body.data.data[3].id).toEqual(2); + expect(result.body.data.data[4].id).toEqual(1); + }); + }); + + describe('모집 게시글 삭제 DELETE /group-articles/:id', () => { + const url = (id: number) => `/v1/group-articles/${id}`; + + test('모집게시글을 정상적으로 삭제하면 204 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .delete(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(204); + }); + + test('JWT 토큰이 없을 때 401 에러를 던진다.', async () => { + // given + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()).delete( + url(groupArticleId), + ); + + // then + expect(result.status).toEqual(401); + }); + + test('모집게시글을 삭제할 때 작성자가 아니라면 403 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 2 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 1; + + // when + const result = await request(app.getHttpServer()) + .delete(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(403); + }); + + test('모집게시글을 삭제할 때 없는 게시물에 접근하면 404 코드를 던진다.', async () => { + // given + const jwtService = app.get(JwtTokenService); + const user = await dataSource.getRepository(User).findOneBy({ id: 1 }); + const accessToken = jwtService.generateAccessToken(user); + const groupArticleId = 10000; + + // when + const result = await request(app.getHttpServer()) + .delete(url(groupArticleId)) + .set({ Cookie: setCookie(accessToken.accessToken) }); + + // then + expect(result.status).toEqual(404); + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 816c612e..7ea03a2e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@tanstack/react-query-devtools": "^4.16.1", "axios": "^1.1.3", "browser-image-compression": "^2.0.0", + "framer-motion": "6.2.4", "heic2any": "^0.0.3", "next": "^12.3.3", "next-seo": "^5.15.0", @@ -17472,6 +17473,48 @@ "node": ">=0.10.0" } }, + "node_modules/framer-motion": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.2.4.tgz", + "integrity": "sha512-1UfnSG4c4CefKft6QMYGx8AWt3TtaFoR/Ax4dkuDDD5BDDeIuUm7gesmJrF8GzxeX/i6fMm8+MEdPngUyPVdLA==", + "dependencies": { + "framesync": "6.0.1", + "hey-listen": "^1.0.8", + "popmotion": "11.0.3", + "style-value-types": "5.0.0", + "tslib": "^2.1.0" + }, + "optionalDependencies": { + "@emotion/is-prop-valid": "^0.8.2" + }, + "peerDependencies": { + "react": ">=16.8 || ^17.0.0", + "react-dom": ">=16.8 || ^17.0.0" + } + }, + "node_modules/framer-motion/node_modules/@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "optional": true, + "dependencies": { + "@emotion/memoize": "0.7.4" + } + }, + "node_modules/framer-motion/node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "optional": true + }, + "node_modules/framesync": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz", + "integrity": "sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -18295,6 +18338,11 @@ "resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.3.tgz", "integrity": "sha512-1KG0LzZuIPiqyJjwLgGlgrgWd3UBwUE9g5+tOuHy8PbeH2hF0U4gc4ZWT4ChlCmcdISr1xVRimSehsTOPdRXnQ==" }, + "node_modules/hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" + }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -23366,6 +23414,17 @@ "node": ">=10" } }, + "node_modules/popmotion": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.3.tgz", + "integrity": "sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==", + "dependencies": { + "framesync": "6.0.1", + "hey-listen": "^1.0.8", + "style-value-types": "5.0.0", + "tslib": "^2.1.0" + } + }, "node_modules/posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -26766,6 +26825,15 @@ "inline-style-parser": "0.1.1" } }, + "node_modules/style-value-types": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz", + "integrity": "sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==", + "dependencies": { + "hey-listen": "^1.0.8", + "tslib": "^2.1.0" + } + }, "node_modules/styled-jsx": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.7.tgz", @@ -42298,6 +42366,44 @@ "map-cache": "^0.2.2" } }, + "framer-motion": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.2.4.tgz", + "integrity": "sha512-1UfnSG4c4CefKft6QMYGx8AWt3TtaFoR/Ax4dkuDDD5BDDeIuUm7gesmJrF8GzxeX/i6fMm8+MEdPngUyPVdLA==", + "requires": { + "@emotion/is-prop-valid": "^0.8.2", + "framesync": "6.0.1", + "hey-listen": "^1.0.8", + "popmotion": "11.0.3", + "style-value-types": "5.0.0", + "tslib": "^2.1.0" + }, + "dependencies": { + "@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "optional": true, + "requires": { + "@emotion/memoize": "0.7.4" + } + }, + "@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "optional": true + } + } + }, + "framesync": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz", + "integrity": "sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==", + "requires": { + "tslib": "^2.1.0" + } + }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -42933,6 +43039,11 @@ "resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.3.tgz", "integrity": "sha512-1KG0LzZuIPiqyJjwLgGlgrgWd3UBwUE9g5+tOuHy8PbeH2hF0U4gc4ZWT4ChlCmcdISr1xVRimSehsTOPdRXnQ==" }, + "hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" + }, "hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -46744,6 +46855,17 @@ "@babel/runtime": "^7.17.8" } }, + "popmotion": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.3.tgz", + "integrity": "sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==", + "requires": { + "framesync": "6.0.1", + "hey-listen": "^1.0.8", + "style-value-types": "5.0.0", + "tslib": "^2.1.0" + } + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -49382,6 +49504,15 @@ "inline-style-parser": "0.1.1" } }, + "style-value-types": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz", + "integrity": "sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==", + "requires": { + "hey-listen": "^1.0.8", + "tslib": "^2.1.0" + } + }, "styled-jsx": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index c142c06f..1843ac56 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "@tanstack/react-query-devtools": "^4.16.1", "axios": "^1.1.3", "browser-image-compression": "^2.0.0", + "framer-motion": "6.2.4", "heic2any": "^0.0.3", "next": "^12.3.3", "next-seo": "^5.15.0", diff --git a/frontend/src/components/common/BrowserCheck/index.tsx b/frontend/src/components/common/BrowserCheck/index.tsx deleted file mode 100644 index 6d4be8d7..00000000 --- a/frontend/src/components/common/BrowserCheck/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { isChrome, isChromium } from 'react-device-detect'; - -import AlertModal from '@components/common/AlertModal'; - -const BrowserCheck = () => { - const [modalOpen, setModalOpen] = useState(false); - - useEffect(() => { - const browserChecked = localStorage.getItem('browser-checked'); - if (browserChecked) return; - if (!(isChrome || isChromium)) { - setModalOpen(true); - } - localStorage.setItem('browser-checked', 'true'); - }, []); - - return ( - <> - setModalOpen(false)} - /> - - ); -}; - -export default BrowserCheck; diff --git a/frontend/src/components/common/GroupArticleCard/index.tsx b/frontend/src/components/common/GroupArticleCard/index.tsx index 1966516c..0f69d706 100644 --- a/frontend/src/components/common/GroupArticleCard/index.tsx +++ b/frontend/src/components/common/GroupArticleCard/index.tsx @@ -45,10 +45,11 @@ const GroupArticleCard = ({ article }: Props) => { alt={'thumbnail-image'} layout="fill" objectFit="cover" - sizes="(min-width: 800px) 300px,150px" + sizes="(min-width: 600px) 300px,150px" placeholder="blur" blurDataURL={blurUrl} - style={{ transition: '0.3s ease-in-out' }} + style={{ transition: '0.2s ease-in-out' }} + priority /> diff --git a/frontend/src/components/common/NotificationToast/index.tsx b/frontend/src/components/common/NotificationToast/index.tsx new file mode 100644 index 00000000..ab78f2b9 --- /dev/null +++ b/frontend/src/components/common/NotificationToast/index.tsx @@ -0,0 +1,19 @@ +import { useRouter } from 'next/router'; + +import useNotificationEvent from '@hooks/useNotificationEvent'; +import { showToast } from '@utils/toast'; + +const NotificationToast = () => { + const { isReady, pathname } = useRouter(); + + useNotificationEvent({ + onNotification: (e) => { + showToast({ title: '알림 도착!', message: '알림 페이지에서 내용을 확인해보세요.' }); + }, + enabled: isReady && pathname !== '/notification', + }); + + return <>; +}; + +export default NotificationToast; diff --git a/frontend/src/components/notification/NotificationItem/NotificationItem.stories.tsx b/frontend/src/components/notification/NotificationItem/NotificationItem.stories.tsx index 9637792c..6de62a13 100644 --- a/frontend/src/components/notification/NotificationItem/NotificationItem.stories.tsx +++ b/frontend/src/components/notification/NotificationItem/NotificationItem.stories.tsx @@ -1,5 +1,7 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; +import styled from '@emotion/styled'; + import { Notification } from '@constants/notification'; import NotificationItem from '.'; @@ -46,3 +48,35 @@ GroupFail.args = { createdAt: '2021-08-01T00:00:00.000Z', }, }; + +const PageWrapper = styled.div` + width: 100%; + display: flex; + flex-direction: column; + flex: 1; + padding: 1.6rem; + gap: 1.6rem; +`; + +const PageTemplate: ComponentStory = (args) => ( + + + + + + + + +); + +export const Collection = PageTemplate.bind({}); +Collection.args = { + notification: { + id: 1, + type: Notification.GROUP_FAILED, + title: '모임이 무산되었어요.', + subTitle: '캐럿스터디 - 인천', + groupArticleId: 3, + createdAt: '2021-08-01T00:00:00.000Z', + }, +}; diff --git a/frontend/src/components/notification/NotificationItem/index.tsx b/frontend/src/components/notification/NotificationItem/index.tsx index 4e3e4c91..39367dad 100644 --- a/frontend/src/components/notification/NotificationItem/index.tsx +++ b/frontend/src/components/notification/NotificationItem/index.tsx @@ -1,6 +1,8 @@ import Link from 'next/link'; import { useState } from 'react'; +import { motion } from 'framer-motion'; + import styled from '@emotion/styled'; import { ActionIcon, Text } from '@mantine/core'; import { IconX } from '@tabler/icons'; @@ -25,58 +27,65 @@ const NotificationItem = ({ notification }: Props) => { const [confirmModalOpen, setConfirmModalOpen] = useState(false); return ( - + <> deleteNotification(notification.id)} onCancelButtonClick={() => setConfirmModalOpen(false)} /> - - - - - - - - {title} - - - {subTitle} - - - - - - setConfirmModalOpen(true)} - > - - - - {dateTimeFormat(createdAt)} - - - + + + + + + + + + {title} + + + {subTitle} + + + + + + setConfirmModalOpen(true)} + > + + + + {dateTimeFormat(createdAt)} + + + + ); }; export default NotificationItem; -const NotificationWrapper = styled.div` - display: grid; +const NotificationWrapper = styled(motion.div)` align-items: center; - grid-template-columns: 1fr 5rem; + display: flex; gap: 1.6rem; padding: 1.6rem; width: 100%; + height: 100%; border: 1px solid ${({ theme }) => theme.colors.gray[2]}; border-radius: 0.8rem; `; @@ -102,13 +111,15 @@ const TitleWrapper = styled.div` flex-direction: column; gap: 0.8rem; overflow-x: hidden; + white-space: nowrap; `; const AsideSection = styled.div` height: 100%; display: flex; + flex: 1; flex-direction: column; align-items: flex-end; justify-content: space-between; - min-width: 4rem; + white-space: nowrap; `; diff --git a/frontend/src/hooks/queries/useFetchGroupArticles.ts b/frontend/src/hooks/queries/useFetchGroupArticles.ts index b81c10c4..3957d0d9 100644 --- a/frontend/src/hooks/queries/useFetchGroupArticles.ts +++ b/frontend/src/hooks/queries/useFetchGroupArticles.ts @@ -10,9 +10,8 @@ import { ArticlePreviewType } from '@typings/types'; import { clientAxios } from '@utils/commonAxios'; interface ArticlePagingData { - totalPage: number; - currentPage: number; - countPerPage: number; + isLast: boolean; + nextId: number; data: ArticlePreviewType[]; } interface ArticleResponseType { @@ -22,7 +21,7 @@ interface ArticleResponseType { } export const getGroupArticles = async ( - currentPage: number, + nextId: number, category: Category, location: Location, filterProgress: boolean @@ -30,8 +29,8 @@ export const getGroupArticles = async ( const status = filterProgress ? ArticleStatus.PROGRESS : null; const { data: { data }, - } = await clientAxios('/v1/group-articles/search', { - params: { category, location, status, currentPage, countPerPage: 8 }, + } = await clientAxios('v2/group-articles/search', { + params: { category, location, status, nextId, limit: 8 }, }); return data; }; @@ -43,10 +42,9 @@ const useFetchGroupArticles = ( ) => { const { data, ...rest } = useAuthInfiniteQuery( ['articles', category, location, filterProgress], - ({ pageParam = 1 }) => getGroupArticles(pageParam, category, location, filterProgress), + ({ pageParam }) => getGroupArticles(pageParam, category, location, filterProgress), { - getNextPageParam: (lastPage) => - lastPage.data.length === 0 ? undefined : lastPage.currentPage + 1, + getNextPageParam: (lastPage) => (lastPage.isLast ? undefined : lastPage.nextId), } ); diff --git a/frontend/src/hooks/queries/useFetchNotifications.ts b/frontend/src/hooks/queries/useFetchNotifications.ts index a9003568..0419f8e4 100644 --- a/frontend/src/hooks/queries/useFetchNotifications.ts +++ b/frontend/src/hooks/queries/useFetchNotifications.ts @@ -3,6 +3,7 @@ import { useMemo } from 'react'; import { AxiosError } from 'axios'; import useAuthInfiniteQuery from '@hooks/useAuthInfiniteQuery'; +import useNotificationEvent from '@hooks/useNotificationEvent'; import { NotificationType } from '@typings/types'; import { clientAxios } from '@utils/commonAxios'; @@ -29,14 +30,13 @@ const getNotifications = async (currentPage: number) => { }; const useFetchNotifications = () => { - const { data, ...queryResult } = useAuthInfiniteQuery< + const { data, refetch, ...queryResult } = useAuthInfiniteQuery< NotificationPagingData, AxiosError, NotificationPagingData >(['notifications'], ({ pageParam = 1 }) => getNotifications(pageParam), { getNextPageParam: (lastPage) => lastPage.data.length === 0 ? undefined : lastPage.currentPage + 1, - refetchInterval: 3000, }); const notifications = useMemo( @@ -44,6 +44,12 @@ const useFetchNotifications = () => { [data] ); + useNotificationEvent({ + onNotification: (e) => { + void refetch(); + }, + }); + return { data: notifications, ...queryResult }; }; diff --git a/frontend/src/hooks/useNotificationEvent.ts b/frontend/src/hooks/useNotificationEvent.ts new file mode 100644 index 00000000..d915d887 --- /dev/null +++ b/frontend/src/hooks/useNotificationEvent.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; + +import useFetchMyInfo from '@hooks/queries/useFetchMyInfo'; + +interface Props { + onNotification: (e: MessageEvent) => void; + enabled?: boolean; +} + +const useNotificationEvent = ({ onNotification, enabled = true }: Props) => { + const { data: myData } = useFetchMyInfo(); + useEffect(() => { + if (!myData) return; + let sse: EventSource | null = null; + try { + sse = new EventSource(`${process.env.NEXT_PUBLIC_API_URL}/v1/sse`, { + withCredentials: true, + }); + + sse.addEventListener('NOTIFICATION', (e) => { + if (enabled) onNotification(e); + }); + + sse.onerror = (event) => { + sse.close(); + }; + } catch (err) { + throw Error('Server Sent Event Error'); + } + return () => { + if (sse) sse.close(); + }; + }, [myData, onNotification, enabled]); +}; + +export default useNotificationEvent; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index d7da2a2f..7062fc12 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -8,11 +8,11 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { RecoilRoot } from 'recoil'; import { v4 as uuid } from 'uuid'; -import BrowserCheck from '@components/common/BrowserCheck'; import ApiErrorBoundary from '@components/common/ErrorBoundary/ApiErrorBoundary'; import AuthErrorBoundary from '@components/common/ErrorBoundary/AuthErrorBoundary'; import ErrorBoundary from '@components/common/ErrorBoundary/ErrorBoundary'; import LoginRedirect from '@components/common/LoginRedirect'; +import NotificationToast from '@components/common/NotificationToast'; import RouterTransition from '@components/common/RouterTransition'; import ScrollHandler from '@components/common/ScrollHandler'; import initMockApi from '@mocks/.'; @@ -61,7 +61,7 @@ export default function App({ Component, pageProps }: AppProps<{ dehydratedState - + diff --git a/frontend/src/pages/article/[id].tsx b/frontend/src/pages/article/[id].tsx index b74bc53a..e67143a2 100644 --- a/frontend/src/pages/article/[id].tsx +++ b/frontend/src/pages/article/[id].tsx @@ -167,7 +167,11 @@ const ArticleDetail = () => { 0 && { before: true })} components={comments.map((comment) => ( - + setAddedComment(null)} + /> ))} />
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 0ea43b73..18ef8e77 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -33,13 +33,8 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { } const queryClient = new QueryClient(); - await queryClient.prefetchInfiniteQuery( - ['articles', null, null, false], - ({ pageParam = 1 }) => getGroupArticles(pageParam, null, null, false), - { - getNextPageParam: (lastPage) => - lastPage.totalPage === lastPage.currentPage ? undefined : lastPage.currentPage + 1, - } + await queryClient.prefetchInfiniteQuery(['articles', null, null, false], ({ pageParam }) => + getGroupArticles(pageParam, null, null, false) ); return { props: { dehydratedState: JSON.parse(JSON.stringify(dehydrate(queryClient))) } }; };