From b793ffbb496bcff89f3df171cdfe925c7d8e42f0 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Mon, 28 Jul 2025 11:45:07 +0900 Subject: [PATCH 01/28] =?UTF-8?q?Sprint=206=20=EA=B3=BC=EC=A0=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # .gitignore # build.gradle # gradle/wrapper/gradle-wrapper.jar # gradle/wrapper/gradle-wrapper.properties # gradlew # gradlew.bat # settings.gradle # src/main/java/com/sprint/mission/discodeit/entity/Channel.java # src/main/java/com/sprint/mission/discodeit/entity/Message.java # src/main/java/com/sprint/mission/discodeit/entity/User.java # src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java # src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java # src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java # src/main/java/com/sprint/mission/discodeit/service/ChannelService.java # src/main/java/com/sprint/mission/discodeit/service/MessageService.java # src/main/java/com/sprint/mission/discodeit/service/UserService.java --- .gitignore | 11 +- HELP.md | 22 + README.md | 10 + api-docs.json | 1276 +++++++++++++++++ build.gradle | 38 +- gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 44 +- gradlew.bat | 37 +- settings.gradle | 2 +- .../discodeit/DiscodeitApplication.java | 14 + .../discodeit/config/SwaggerConfig.java | 25 + .../discodeit/controller/AuthController.java | 30 + .../controller/BinaryContentController.java | 43 + .../controller/ChannelController.java | 73 + .../controller/MessageController.java | 86 ++ .../controller/ReadStatusController.java | 54 + .../discodeit/controller/UserController.java | 121 ++ .../discodeit/controller/api/AuthApi.java | 37 + .../controller/api/BinaryContentApi.java | 47 + .../discodeit/controller/api/ChannelApi.java | 92 ++ .../discodeit/controller/api/MessageApi.java | 88 ++ .../controller/api/ReadStatusApi.java | 69 + .../discodeit/controller/api/UserApi.java | 113 ++ .../discodeit/dto/data/BinaryContentDto.java | 12 + .../discodeit/dto/data/ChannelDto.java | 18 + .../discodeit/dto/data/MessageDto.java | 18 + .../discodeit/dto/data/ReadStatusDto.java | 12 + .../mission/discodeit/dto/data/UserDto.java | 17 + .../discodeit/dto/data/UserStatusDto.java | 11 + .../request/BinaryContentCreateRequest.java | 9 + .../discodeit/dto/request/LoginRequest.java | 8 + .../dto/request/MessageCreateRequest.java | 11 + .../dto/request/MessageUpdateRequest.java | 7 + .../request/PrivateChannelCreateRequest.java | 10 + .../request/PublicChannelCreateRequest.java | 8 + .../request/PublicChannelUpdateRequest.java | 8 + .../dto/request/ReadStatusCreateRequest.java | 12 + .../dto/request/ReadStatusUpdateRequest.java | 9 + .../dto/request/UserCreateRequest.java | 9 + .../dto/request/UserStatusCreateRequest.java | 11 + .../dto/request/UserStatusUpdateRequest.java | 9 + .../dto/request/UserUpdateRequest.java | 9 + .../response/BinaryContentResponseDto.java | 10 + .../dto/response/ChannelResponseDto.java | 17 + .../dto/response/LoginResponseDto.java | 10 + .../dto/response/MessageResponseDto.java | 19 + .../dto/response/ReadStatusResponseDto.java | 12 + .../dto/response/UserResponseDto.java | 14 + .../dto/response/UserStatusResponseDto.java | 11 + .../discodeit/entity/BinaryContent.java | 43 + .../mission/discodeit/entity/Channel.java | 97 +- .../mission/discodeit/entity/ChannelType.java | 6 + .../mission/discodeit/entity/Message.java | 82 +- .../mission/discodeit/entity/ReadStatus.java | 52 + .../sprint/mission/discodeit/entity/User.java | 127 +- .../mission/discodeit/entity/UserStatus.java | 52 + .../discodeit/entity/base/BaseEntity.java | 32 + .../entity/base/BaseUpdatableEntity.java | 24 + .../exception/GlobalExceptionHandler.java | 36 + .../discodeit/mapper/BinaryContentMapper.java | 13 + .../discodeit/mapper/ChannelMapper.java | 11 + .../discodeit/mapper/MessageMapper.java | 16 + .../discodeit/mapper/ReadStatusMapper.java | 17 + .../mission/discodeit/mapper/UserMapper.java | 21 + .../discodeit/mapper/UserStatusMapper.java | 13 + .../repository/BinaryContentRepository.java | 21 + .../repository/ChannelRepository.java | 24 +- .../repository/MessageRepository.java | 27 +- .../repository/ReadStatusRepository.java | 25 + .../discodeit/repository/UserRepository.java | 28 +- .../repository/UserStatusRepository.java | 25 + .../discodeit/service/AuthService.java | 9 + .../service/BinaryContentService.java | 18 + .../discodeit/service/ChannelService.java | 25 +- .../discodeit/service/MessageService.java | 44 +- .../discodeit/service/ReadStatusService.java | 21 + .../discodeit/service/UserService.java | 25 +- .../discodeit/service/UserStatusService.java | 23 + .../service/basic/BasicAuthService.java | 40 + .../basic/BasicBinaryContentService.java | 63 + .../service/basic/BasicChannelService.java | 138 ++ .../service/basic/BasicMessageService.java | 112 ++ .../service/basic/BasicReadStatusService.java | 86 ++ .../service/basic/BasicUserService.java | 144 ++ .../service/basic/BasicUserStatusService.java | 97 ++ src/main/resources/application.yaml | 20 + .../resources/static/assets/index-CRrRqFH4.js | 956 ++++++++++++ .../static/assets/index-kQJbKSsj.css | 1 + src/main/resources/static/favicon.ico | Bin 0 -> 1588 bytes src/main/resources/static/index.html | 26 + .../discodeit/DiscodeitApplicationTests.java | 13 + 92 files changed, 5006 insertions(+), 285 deletions(-) create mode 100644 HELP.md create mode 100644 README.md create mode 100644 api-docs.json create mode 100644 src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/AuthController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/MessageController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/UserController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/response/BinaryContentResponseDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/response/ChannelResponseDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/response/LoginResponseDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/response/MessageResponseDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/response/ReadStatusResponseDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/response/UserResponseDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/response/UserStatusResponseDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/AuthService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/static/assets/index-CRrRqFH4.js create mode 100644 src/main/resources/static/assets/index-kQJbKSsj.css create mode 100644 src/main/resources/static/favicon.ico create mode 100644 src/main/resources/static/index.html create mode 100644 src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java diff --git a/.gitignore b/.gitignore index 869d741f3..43e6c09a5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,17 +5,13 @@ build/ !**/src/test/**/build/ ### IntelliJ IDEA ### -.idea/modules.xml -.idea/jarRepositories.xml -.idea/compiler.xml -.idea/libraries/ +.idea *.iws *.iml *.ipr out/ !**/src/main/**/out/ !**/src/test/**/out/ -.idea ### Eclipse ### .apt_generated @@ -40,4 +36,7 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +### Discodeit ### +.discodeit \ No newline at end of file diff --git a/HELP.md b/HELP.md new file mode 100644 index 000000000..42c5f0023 --- /dev/null +++ b/HELP.md @@ -0,0 +1,22 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.0/gradle-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.0/gradle-plugin/packaging-oci-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/3.4.0/reference/web/servlet.html) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/README.md b/README.md new file mode 100644 index 000000000..df97d57a1 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +| 엔티티 관계 | 다중성 | 방향성 | 부모-자식 관계 | 연관관계의 주인 | +|-------------|--------|----------------|---------------------|------------------| +| A:B | 1:N | B → A 단방향 | 부모: A, 자식: B | B | +| User : UserStatus| 1:1 | User ↔ UserStatus | 부모: User, 자식: UserStatus| UserStatus| +| User : BinaryContent| 1:1 | User → BinaryContent| 부모: User, 자식: BinyryContent| User| +| ReadStatus : User| N:1 | ReadStatus → User | 부모: User, 자식: ReadStatus| ReadStatus| +| ReadStatus : Channel| N:1 | ReadStatus → Channel | 부모: Channel, 자식: ReadStatus| ReadStatus| +| Message : User| N:1 | Message → User | 부모: User, 자식: Message| Message| +| Message : Channel| N:1 | Message → Channel | 부모: Channel, 자식: Message| Message| +| Message : BinaryContent| 1:N | Message → BinaryContent | 부모: Message, 자식:BinaryContent| Message| diff --git a/api-docs.json b/api-docs.json new file mode 100644 index 000000000..23d9b9bcb --- /dev/null +++ b/api-docs.json @@ -0,0 +1,1276 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Discodeit API 문서", + "description": "Discodeit 프로젝트의 Swagger API 문서입니다." + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "로컬 서버" + } + ], + "tags": [ + { + "name": "Channel", + "description": "Channel API" + }, + { + "name": "ReadStatus", + "description": "Message 읽음 상태 API" + }, + { + "name": "Message", + "description": "Message API" + }, + { + "name": "User", + "description": "User API" + }, + { + "name": "BinaryContent", + "description": "첨부 파일 API" + }, + { + "name": "Auth", + "description": "인증 API" + } + ], + "paths": { + "/api/users": { + "get": { + "tags": [ + "User" + ], + "summary": "전체 User 목록 조회", + "operationId": "findAll", + "responses": { + "200": { + "description": "User 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "User" + ], + "summary": "User 등록", + "operationId": "create", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "userCreateRequest": { + "$ref": "#/components/schemas/UserCreateRequest" + }, + "profile": { + "type": "string", + "format": "binary", + "description": "User 프로필 이미지" + } + }, + "required": [ + "userCreateRequest" + ] + } + } + } + }, + "responses": { + "201": { + "description": "User가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "같은 email 또는 username를 사용하는 User가 이미 존재함", + "content": { + "*/*": { + "example": "User with email {email} already exists" + } + } + } + } + } + }, + "/api/readStatuses": { + "get": { + "tags": [ + "ReadStatus" + ], + "summary": "User의 Message 읽음 상태 목록 조회", + "operationId": "findAllByUserId", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "조회할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Message 읽음 상태 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadStatus" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "ReadStatus" + ], + "summary": "Message 읽음 상태 생성", + "operationId": "create_1", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadStatusCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Channel 또는 User를 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel | User with id {channelId | userId} not found" + } + } + }, + "400": { + "description": "이미 읽음 상태가 존재함", + "content": { + "*/*": { + "example": "ReadStatus with userId {userId} and channelId {channelId} already exists" + } + } + }, + "201": { + "description": "Message 읽음 상태가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReadStatus" + } + } + } + } + } + } + }, + "/api/messages": { + "get": { + "tags": [ + "Message" + ], + "summary": "Channel의 Message 목록 조회", + "operationId": "findAllByChannelId", + "parameters": [ + { + "name": "channelId", + "in": "query", + "description": "조회할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Message 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Message" + ], + "summary": "Message 생성", + "operationId": "create_2", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "messageCreateRequest": { + "$ref": "#/components/schemas/MessageCreateRequest" + }, + "attachments": { + "type": "array", + "description": "Message 첨부 파일들", + "items": { + "type": "string", + "format": "binary" + } + } + }, + "required": [ + "messageCreateRequest" + ] + } + } + } + }, + "responses": { + "404": { + "description": "Channel 또는 User를 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel | Author with id {channelId | authorId} not found" + } + } + }, + "201": { + "description": "Message가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + } + } + } + }, + "/api/channels/public": { + "post": { + "tags": [ + "Channel" + ], + "summary": "Public Channel 생성", + "operationId": "create_3", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicChannelCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Public Channel이 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + } + } + } + } + }, + "/api/channels/private": { + "post": { + "tags": [ + "Channel" + ], + "summary": "Private Channel 생성", + "operationId": "create_4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivateChannelCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Private Channel이 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + } + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "summary": "로그인", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "로그인 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "404": { + "description": "사용자를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with username {username} not found" + } + } + }, + "400": { + "description": "비밀번호가 일치하지 않음", + "content": { + "*/*": { + "example": "Wrong password" + } + } + } + } + } + }, + "/api/users/{userId}": { + "delete": { + "tags": [ + "User" + ], + "summary": "User 삭제", + "operationId": "delete", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "삭제할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "User가 성공적으로 삭제됨" + }, + "404": { + "description": "User를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with id {id} not found" + } + } + } + } + }, + "patch": { + "tags": [ + "User" + ], + "summary": "User 정보 수정", + "operationId": "update", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "수정할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "userUpdateRequest": { + "$ref": "#/components/schemas/UserUpdateRequest" + }, + "profile": { + "type": "string", + "format": "binary", + "description": "수정할 User 프로필 이미지" + } + }, + "required": [ + "userUpdateRequest" + ] + } + } + } + }, + "responses": { + "404": { + "description": "User를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with id {userId} not found" + } + } + }, + "400": { + "description": "같은 email 또는 username를 사용하는 User가 이미 존재함", + "content": { + "*/*": { + "example": "user with email {newEmail} already exists" + } + } + }, + "200": { + "description": "User 정보가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/api/users/{userId}/userStatus": { + "patch": { + "tags": [ + "User" + ], + "summary": "User 온라인 상태 업데이트", + "operationId": "updateUserStatusByUserId", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "상태를 변경할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatusUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "해당 User의 UserStatus를 찾을 수 없음", + "content": { + "*/*": { + "example": "UserStatus with userId {userId} not found" + } + } + }, + "200": { + "description": "User 온라인 상태가 성공적으로 업데이트됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserStatus" + } + } + } + } + } + } + }, + "/api/readStatuses/{readStatusId}": { + "patch": { + "tags": [ + "ReadStatus" + ], + "summary": "Message 읽음 상태 수정", + "operationId": "update_1", + "parameters": [ + { + "name": "readStatusId", + "in": "path", + "description": "수정할 읽음 상태 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadStatusUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Message 읽음 상태가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReadStatus" + } + } + } + }, + "404": { + "description": "Message 읽음 상태를 찾을 수 없음", + "content": { + "*/*": { + "example": "ReadStatus with id {readStatusId} not found" + } + } + } + } + } + }, + "/api/messages/{messageId}": { + "delete": { + "tags": [ + "Message" + ], + "summary": "Message 삭제", + "operationId": "delete_1", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "삭제할 Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Message가 성공적으로 삭제됨" + }, + "404": { + "description": "Message를 찾을 수 없음", + "content": { + "*/*": { + "example": "Message with id {messageId} not found" + } + } + } + } + }, + "patch": { + "tags": [ + "Message" + ], + "summary": "Message 내용 수정", + "operationId": "update_2", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "수정할 Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Message가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "404": { + "description": "Message를 찾을 수 없음", + "content": { + "*/*": { + "example": "Message with id {messageId} not found" + } + } + } + } + } + }, + "/api/channels/{channelId}": { + "delete": { + "tags": [ + "Channel" + ], + "summary": "Channel 삭제", + "operationId": "delete_2", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "삭제할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "Channel을 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel with id {channelId} not found" + } + } + }, + "204": { + "description": "Channel이 성공적으로 삭제됨" + } + } + }, + "patch": { + "tags": [ + "Channel" + ], + "summary": "Channel 정보 수정", + "operationId": "update_3", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "수정할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicChannelUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Channel을 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel with id {channelId} not found" + } + } + }, + "400": { + "description": "Private Channel은 수정할 수 없음", + "content": { + "*/*": { + "example": "Private channel cannot be updated" + } + } + }, + "200": { + "description": "Channel 정보가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + } + } + } + } + }, + "/api/channels": { + "get": { + "tags": [ + "Channel" + ], + "summary": "User가 참여 중인 Channel 목록 조회", + "operationId": "findAll_1", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "조회할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Channel 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + } + }, + "/api/binaryContents": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "여러 첨부 파일 조회", + "operationId": "findAllByIdIn", + "parameters": [ + { + "name": "binaryContentIds", + "in": "query", + "description": "조회할 첨부 파일 ID 목록", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "첨부 파일 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinaryContent" + } + } + } + } + } + } + } + }, + "/api/binaryContents/{binaryContentId}": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "첨부 파일 조회", + "operationId": "find", + "parameters": [ + { + "name": "binaryContentId", + "in": "path", + "description": "조회할 첨부 파일 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "첨부 파일 조회 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/BinaryContent" + } + } + } + }, + "404": { + "description": "첨부 파일을 찾을 수 없음", + "content": { + "*/*": { + "example": "BinaryContent with id {binaryContentId} not found" + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UserCreateRequest": { + "type": "object", + "description": "User 생성 정보", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "profileId": { + "type": "string", + "format": "uuid" + } + } + }, + "ReadStatusCreateRequest": { + "type": "object", + "description": "Message 읽음 상태 생성 정보", + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadStatus": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MessageCreateRequest": { + "type": "object", + "description": "Message 생성 정보", + "properties": { + "content": { + "type": "string" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "authorId": { + "type": "string", + "format": "uuid" + } + } + }, + "Message": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "content": { + "type": "string" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "authorId": { + "type": "string", + "format": "uuid" + }, + "attachmentIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "PublicChannelCreateRequest": { + "type": "object", + "description": "Public Channel 생성 정보", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "Channel": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string", + "enum": [ + "PUBLIC", + "PRIVATE" + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "PrivateChannelCreateRequest": { + "type": "object", + "description": "Private Channel 생성 정보", + "properties": { + "participantIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "LoginRequest": { + "type": "object", + "description": "로그인 정보", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "UserUpdateRequest": { + "type": "object", + "description": "수정할 User 정보", + "properties": { + "newUsername": { + "type": "string" + }, + "newEmail": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "UserStatusUpdateRequest": { + "type": "object", + "description": "변경할 User 온라인 상태 정보", + "properties": { + "newLastActiveAt": { + "type": "string", + "format": "date-time" + } + } + }, + "UserStatus": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "type": "string", + "format": "date-time" + }, + "online": { + "type": "boolean" + } + } + }, + "ReadStatusUpdateRequest": { + "type": "object", + "description": "수정할 읽음 상태 정보", + "properties": { + "newLastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MessageUpdateRequest": { + "type": "object", + "description": "수정할 Message 내용", + "properties": { + "newContent": { + "type": "string" + } + } + }, + "PublicChannelUpdateRequest": { + "type": "object", + "description": "수정할 Channel 정보", + "properties": { + "newName": { + "type": "string" + }, + "newDescription": { + "type": "string" + } + } + }, + "UserDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "profileId": { + "type": "string", + "format": "uuid" + }, + "online": { + "type": "boolean" + } + } + }, + "ChannelDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "PUBLIC", + "PRIVATE" + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "participantIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "lastMessageAt": { + "type": "string", + "format": "date-time" + } + } + }, + "BinaryContent": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "fileName": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64" + }, + "contentType": { + "type": "string" + }, + "bytes": { + "type": "string", + "format": "byte" + } + } + } + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 668101a29..ab73a0f54 100644 --- a/build.gradle +++ b/build.gradle @@ -1,19 +1,41 @@ plugins { - id 'java' + id 'java' + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.6' } group = 'com.sprint.mission' -version = '1.0-SNAPSHOT' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} repositories { - mavenCentral() + mavenCentral() } dependencies { - testImplementation platform('org.junit:junit-bom:5.10.0') - testImplementation 'org.junit.jupiter:junit-jupiter' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.mapstruct:mapstruct:1.4.2.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final' + runtimeOnly 'org.postgresql:postgresql' } -test { - useJUnitPlatform() -} \ No newline at end of file +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f090a2944b7473328c07c9755baa3196..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 60756 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 /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +134,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,11 +201,15 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ @@ -205,6 +217,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index 107acd32c..9d21a2183 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -75,13 +78,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle b/settings.gradle index 84879dca0..2437dfb29 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = '1-sprint-mission' \ No newline at end of file +rootProject.name = 'discodeit' diff --git a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java new file mode 100644 index 000000000..a178f6631 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +public class DiscodeitApplication { + + public static void main(String[] args) { + SpringApplication.run(DiscodeitApplication.class, args); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java new file mode 100644 index 000000000..c885042a0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Discodeit API 문서") + .description("Discodeit 프로젝트의 Swagger API 문서입니다.") + ) + .servers(List.of( + new Server().url("http://localhost:8080").description("로컬 서버") + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java new file mode 100644 index 000000000..569d7b930 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -0,0 +1,30 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.AuthApi; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.response.LoginResponseDto; +import com.sprint.mission.discodeit.service.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/auth") +public class AuthController implements AuthApi { + + private final AuthService authService; + + @PostMapping(path = "login") + public ResponseEntity login(@RequestBody LoginRequest loginRequest) { + LoginResponseDto loginResponseDto = authService.login(loginRequest); + return ResponseEntity + .status(HttpStatus.OK) + .body(loginResponseDto); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java new file mode 100644 index 000000000..60bb90f8d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java @@ -0,0 +1,43 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.BinaryContentApi; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.response.BinaryContentResponseDto; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.service.BinaryContentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/binaryContents") +public class BinaryContentController implements BinaryContentApi { + + private final BinaryContentService binaryContentService; + private final BinaryContentMapper binaryContentMapper; + + @GetMapping(path = "{binaryContentId}") + public ResponseEntity find(@PathVariable("binaryContentId") UUID binaryContentId) { + BinaryContentDto binaryContent = binaryContentService.find(binaryContentId); + BinaryContentResponseDto binaryContentResponseDto = binaryContentMapper.toResponseDto(binaryContent); + return ResponseEntity + .status(HttpStatus.OK) + .body(binaryContentResponseDto); + } + + @GetMapping + public ResponseEntity> findAllByIdIn( + @RequestParam("binaryContentIds") List binaryContentIds) { + List binaryContents = binaryContentService.findAllByIdIn(binaryContentIds); + List responseDtoList = binaryContents.stream().map(binaryContentMapper::toResponseDto).toList(); + return ResponseEntity + .status(HttpStatus.OK) + .body(responseDtoList); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java new file mode 100644 index 000000000..22e3f679f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java @@ -0,0 +1,73 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.ChannelApi; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.dto.response.ChannelResponseDto; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.service.ChannelService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/channels") +public class ChannelController implements ChannelApi { + + private final ChannelService channelService; + private final ChannelMapper channelMapper; + + @PostMapping(path = "public") + public ResponseEntity create(@RequestBody PublicChannelCreateRequest request) { + ChannelDto createdChannel = channelService.create(request); + ChannelResponseDto channelResponseDto = channelMapper.toResponseDto(createdChannel); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(channelResponseDto); + } + + @PostMapping(path = "private") + public ResponseEntity create(@RequestBody PrivateChannelCreateRequest request) { + ChannelDto createdChannel = channelService.create(request); + ChannelResponseDto channelResponseDto = channelMapper.toResponseDto(createdChannel); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(channelResponseDto); + } + + @PatchMapping(path = "{channelId}") + public ResponseEntity update(@PathVariable("channelId") UUID channelId, + @RequestBody PublicChannelUpdateRequest request) { + ChannelDto updatedChannel = channelService.update(channelId, request); + ChannelResponseDto channelResponseDto = channelMapper.toResponseDto(updatedChannel); + + return ResponseEntity + .status(HttpStatus.OK) + .body(channelResponseDto); + } + + @DeleteMapping(path = "{channelId}") + public ResponseEntity delete(@PathVariable("channelId") UUID channelId) { + channelService.delete(channelId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + public ResponseEntity> findAll(@RequestParam("userId") UUID userId) { + List channels = channelService.findAllByUserId(userId); + List channelResponseDto = channels.stream().map(channelMapper::toResponseDto).toList(); + return ResponseEntity + .status(HttpStatus.OK) + .body(channelResponseDto); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java new file mode 100644 index 000000000..5751025ec --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java @@ -0,0 +1,86 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.MessageApi; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.MessageResponseDto; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.service.MessageService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/messages") +public class MessageController implements MessageApi { + + private final MessageService messageService; + private final MessageMapper messageMapper; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity create( + @RequestPart("messageCreateRequest") MessageCreateRequest messageCreateRequest, + @RequestPart(value = "attachments", required = false) List attachments + ) { + List attachmentRequests = Optional.ofNullable(attachments) + .map(files -> files.stream() + .map(file -> { + try { + return new BinaryContentCreateRequest( + file.getOriginalFilename(), + file.getContentType(), + file.getBytes() + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .toList()) + .orElse(new ArrayList<>()); + MessageDto createdMessage = messageService.create(messageCreateRequest, attachmentRequests); + MessageResponseDto messageResponseDto = messageMapper.toResponseDto(createdMessage); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(messageResponseDto); + } + + @PatchMapping(path = "{messageId}") + public ResponseEntity update(@PathVariable("messageId") UUID messageId, + @RequestBody MessageUpdateRequest request) { + MessageDto updatedMessage = messageService.update(messageId, request); + MessageResponseDto messageResponseDto = messageMapper.toResponseDto(updatedMessage); + return ResponseEntity + .status(HttpStatus.OK) + .body(messageResponseDto); + } + + @DeleteMapping(path = "{messageId}") + public ResponseEntity delete(@PathVariable("messageId") UUID messageId) { + messageService.delete(messageId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + public ResponseEntity> findAllByChannelId( + @RequestParam("channelId") UUID channelId) { + List messages = messageService.findAllByChannelId(channelId); + List messageResponseDto = messages.stream().map(messageMapper::toResponseDto).toList(); + return ResponseEntity + .status(HttpStatus.OK) + .body(messageResponseDto); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java new file mode 100644 index 000000000..dd0667c87 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java @@ -0,0 +1,54 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.ReadStatusApi; +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.response.ReadStatusResponseDto; +import com.sprint.mission.discodeit.mapper.ReadStatusMapper; +import com.sprint.mission.discodeit.service.ReadStatusService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/readStatuses") +public class ReadStatusController implements ReadStatusApi { + + private final ReadStatusService readStatusService; + private final ReadStatusMapper readStatusMapper; + + @PostMapping + public ResponseEntity create(@RequestBody ReadStatusCreateRequest request) { + ReadStatusDto createdReadStatus = readStatusService.create(request); + ReadStatusResponseDto readStatusResponseDto = readStatusMapper.toResponseDto(createdReadStatus); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(readStatusResponseDto); + } + + @PatchMapping(path = "{readStatusId}") + public ResponseEntity update(@PathVariable("readStatusId") UUID readStatusId, + @RequestBody ReadStatusUpdateRequest request) { + ReadStatusDto updatedReadStatus = readStatusService.update(readStatusId, request); + ReadStatusResponseDto readStatusResponseDto = readStatusMapper.toResponseDto(updatedReadStatus); + return ResponseEntity + .status(HttpStatus.OK) + .body(readStatusResponseDto); + } + + @GetMapping + public ResponseEntity> findAllByUserId(@RequestParam("userId") UUID userId) { + List readStatuses = readStatusService.findAllByUserId(userId); + List readStatusResponseDto = readStatuses.stream().map(readStatusMapper::toResponseDto).toList(); + return ResponseEntity + .status(HttpStatus.OK) + .body(readStatusResponseDto); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java new file mode 100644 index 000000000..342ddbad4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java @@ -0,0 +1,121 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.UserApi; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.dto.response.UserResponseDto; +import com.sprint.mission.discodeit.dto.response.UserStatusResponseDto; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.mapper.UserStatusMapper; +import com.sprint.mission.discodeit.service.UserService; +import com.sprint.mission.discodeit.service.UserStatusService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/users") +public class UserController implements UserApi { + + private final UserService userService; + private final UserStatusService userStatusService; + private final UserMapper userMapper; + private final UserStatusMapper userStatusMapper; + + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) + @Override + public ResponseEntity create( + @RequestPart("userCreateRequest") UserCreateRequest userCreateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + Optional profileRequest = Optional.ofNullable(profile) + .flatMap(this::resolveProfileRequest); + + UserDto createdUser = userService.create(userCreateRequest, profileRequest); + UserResponseDto responseUser = userMapper.toResponseDto(createdUser); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(responseUser); + } + + @PatchMapping( + path = "{userId}", + consumes = {MediaType.MULTIPART_FORM_DATA_VALUE} + ) + @Override + public ResponseEntity update( + @PathVariable("userId") UUID userId, + @RequestPart("userUpdateRequest") UserUpdateRequest userUpdateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + Optional profileRequest = Optional.ofNullable(profile) + .flatMap(this::resolveProfileRequest); + UserDto updatedUser = userService.update(userId, userUpdateRequest, profileRequest); + UserResponseDto responseUser = userMapper.toResponseDto(updatedUser); + return ResponseEntity + .status(HttpStatus.OK) + .body(responseUser); + } + + @DeleteMapping(path = "{userId}") + @Override + public ResponseEntity delete(@PathVariable("userId") UUID userId) { + userService.delete(userId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + @Override + public ResponseEntity> findAll() { + List users = userService.findAll(); + List userResponseDto = users.stream().map(userMapper::toResponseDto).toList(); + + return ResponseEntity + .status(HttpStatus.OK) + .body(userResponseDto); + } + + @PatchMapping(path = "{userId}/userStatus") + @Override + public ResponseEntity updateUserStatusByUserId(@PathVariable("userId") UUID userId, + @RequestBody UserStatusUpdateRequest request) { + UserStatusDto updatedUserStatus = userStatusService.updateByUserId(userId, request); + + UserStatusResponseDto userStatusResponseDto = userStatusMapper.toResponseDto(updatedUserStatus); + return ResponseEntity + .status(HttpStatus.OK) + .body(userStatusResponseDto); + } + + private Optional resolveProfileRequest(MultipartFile profileFile) { + if (profileFile.isEmpty()) { + return Optional.empty(); + } else { + try { + BinaryContentCreateRequest binaryContentCreateRequest = new BinaryContentCreateRequest( + profileFile.getOriginalFilename(), + profileFile.getContentType(), + profileFile.getBytes() + ); + return Optional.of(binaryContentCreateRequest); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java new file mode 100644 index 000000000..ab38fd2a3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java @@ -0,0 +1,37 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.response.LoginResponseDto; +import com.sprint.mission.discodeit.entity.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Auth", description = "인증 API") +public interface AuthApi { + + @Operation(summary = "로그인") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "로그인 성공", + content = @Content(schema = @Schema(implementation = User.class)) + ), + @ApiResponse( + responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "User with username {username} not found")) + ), + @ApiResponse( + responseCode = "400", description = "비밀번호가 일치하지 않음", + content = @Content(examples = @ExampleObject(value = "Wrong password")) + ) + }) + ResponseEntity login( + @Parameter(description = "로그인 정보") LoginRequest loginRequest + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java new file mode 100644 index 000000000..b337333af --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java @@ -0,0 +1,47 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.response.BinaryContentResponseDto; +import com.sprint.mission.discodeit.entity.BinaryContent; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.UUID; + +@Tag(name = "BinaryContent", description = "첨부 파일 API") +public interface BinaryContentApi { + + @Operation(summary = "첨부 파일 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "첨부 파일 조회 성공", + content = @Content(schema = @Schema(implementation = BinaryContent.class)) + ), + @ApiResponse( + responseCode = "404", description = "첨부 파일을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "BinaryContent with id {binaryContentId} not found")) + ) + }) + ResponseEntity find( + @Parameter(description = "조회할 첨부 파일 ID") UUID binaryContentId + ); + + @Operation(summary = "여러 첨부 파일 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "첨부 파일 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = BinaryContent.class))) + ) + }) + ResponseEntity> findAllByIdIn( + @Parameter(description = "조회할 첨부 파일 ID 목록") List binaryContentIds + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java new file mode 100644 index 000000000..70c34d03b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java @@ -0,0 +1,92 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.dto.response.ChannelResponseDto; +import com.sprint.mission.discodeit.entity.Channel; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.UUID; + +@Tag(name = "Channel", description = "Channel API") +public interface ChannelApi { + + @Operation(summary = "Public Channel 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Public Channel이 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = Channel.class)) + ) + }) + ResponseEntity create( + @Parameter(description = "Public Channel 생성 정보") PublicChannelCreateRequest request + ); + + @Operation(summary = "Private Channel 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Private Channel이 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = Channel.class)) + ) + }) + ResponseEntity create( + @Parameter(description = "Private Channel 생성 정보") PrivateChannelCreateRequest request + ); + + @Operation(summary = "Channel 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Channel 정보가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = Channel.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel with id {channelId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "Private Channel은 수정할 수 없음", + content = @Content(examples = @ExampleObject(value = "Private channel cannot be updated")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 Channel ID") UUID channelId, + @Parameter(description = "수정할 Channel 정보") PublicChannelUpdateRequest request + ); + + @Operation(summary = "Channel 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", description = "Channel이 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", description = "Channel을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel with id {channelId} not found")) + ) + }) + ResponseEntity delete( + @Parameter(description = "삭제할 Channel ID") UUID channelId + ); + + @Operation(summary = "User가 참여 중인 Channel 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Channel 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class))) + ) + }) + ResponseEntity> findAll( + @Parameter(description = "조회할 User ID") UUID userId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java new file mode 100644 index 000000000..fffa59791 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java @@ -0,0 +1,88 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.MessageResponseDto; +import com.sprint.mission.discodeit.entity.Message; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; + +@Tag(name = "Message", description = "Message API") +public interface MessageApi { + + @Operation(summary = "Message 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Message가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = Message.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel 또는 User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel | Author with id {channelId | authorId} not found")) + ), + }) + ResponseEntity create( + @Parameter( + description = "Message 생성 정보", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) MessageCreateRequest messageCreateRequest, + @Parameter( + description = "Message 첨부 파일들", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) List attachments + ); + + @Operation(summary = "Message 내용 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = Message.class)) + ), + @ApiResponse( + responseCode = "404", description = "Message를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Message with id {messageId} not found")) + ), + }) + ResponseEntity update( + @Parameter(description = "수정할 Message ID") UUID messageId, + @Parameter(description = "수정할 Message 내용") MessageUpdateRequest request + ); + + @Operation(summary = "Message 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", description = "Message가 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", description = "Message를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Message with id {messageId} not found")) + ), + }) + ResponseEntity delete( + @Parameter(description = "삭제할 Message ID") UUID messageId + ); + + @Operation(summary = "Channel의 Message 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = Message.class))) + ) + }) + ResponseEntity> findAllByChannelId( + @Parameter(description = "조회할 Channel ID") UUID channelId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java new file mode 100644 index 000000000..cf1ff1751 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java @@ -0,0 +1,69 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.response.ReadStatusResponseDto; +import com.sprint.mission.discodeit.entity.ReadStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.UUID; + +@Tag(name = "ReadStatus", description = "Message 읽음 상태 API") +public interface ReadStatusApi { + + @Operation(summary = "Message 읽음 상태 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Message 읽음 상태가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ReadStatus.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel 또는 User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel | User with id {channelId | userId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "이미 읽음 상태가 존재함", + content = @Content(examples = @ExampleObject(value = "ReadStatus with userId {userId} and channelId {channelId} already exists")) + ) + }) + ResponseEntity create( + @Parameter(description = "Message 읽음 상태 생성 정보") ReadStatusCreateRequest request + ); + + @Operation(summary = "Message 읽음 상태 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 읽음 상태가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = ReadStatus.class)) + ), + @ApiResponse( + responseCode = "404", description = "Message 읽음 상태를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "ReadStatus with id {readStatusId} not found")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 읽음 상태 ID") UUID readStatusId, + @Parameter(description = "수정할 읽음 상태 정보") ReadStatusUpdateRequest request + ); + + @Operation(summary = "User의 Message 읽음 상태 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 읽음 상태 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ReadStatus.class))) + ) + }) + ResponseEntity> findAllByUserId( + @Parameter(description = "조회할 User ID") UUID userId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java new file mode 100644 index 000000000..d3197782b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java @@ -0,0 +1,113 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.dto.response.UserResponseDto; +import com.sprint.mission.discodeit.dto.response.UserStatusResponseDto; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; + +@Tag(name = "User", description = "User API") +public interface UserApi { + + @Operation(summary = "User 등록") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "User가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = User.class)) + ), + @ApiResponse( + responseCode = "400", description = "같은 email 또는 username를 사용하는 User가 이미 존재함", + content = @Content(examples = @ExampleObject(value = "User with email {email} already exists")) + ), + }) + ResponseEntity create( + @Parameter( + description = "User 생성 정보", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) UserCreateRequest userCreateRequest, + @Parameter( + description = "User 프로필 이미지", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) MultipartFile profile + ); + + @Operation(summary = "User 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 정보가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = User.class)) + ), + @ApiResponse( + responseCode = "404", description = "User를 찾을 수 없음", + content = @Content(examples = @ExampleObject("User with id {userId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "같은 email 또는 username를 사용하는 User가 이미 존재함", + content = @Content(examples = @ExampleObject("user with email {newEmail} already exists")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 User ID") UUID userId, + @Parameter(description = "수정할 User 정보") UserUpdateRequest userUpdateRequest, + @Parameter(description = "수정할 User 프로필 이미지") MultipartFile profile + ); + + @Operation(summary = "User 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "User가 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", + description = "User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "User with id {id} not found")) + ) + }) + ResponseEntity delete( + @Parameter(description = "삭제할 User ID") UUID userId + ); + + @Operation(summary = "전체 User 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) + ) + }) + ResponseEntity> findAll(); + + @Operation(summary = "User 온라인 상태 업데이트") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 온라인 상태가 성공적으로 업데이트됨", + content = @Content(schema = @Schema(implementation = UserStatus.class)) + ), + @ApiResponse( + responseCode = "404", description = "해당 User의 UserStatus를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "UserStatus with userId {userId} not found")) + ) + }) + ResponseEntity updateUserStatusByUserId( + @Parameter(description = "상태를 변경할 User ID") UUID userId, + @Parameter(description = "변경할 User 온라인 상태 정보") UserStatusUpdateRequest request + ); +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java new file mode 100644 index 000000000..21c85336f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.util.UUID; + +public record BinaryContentDto( + UUID id, + String fileName, + Long size, + String contentType, + byte[] bytes +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java new file mode 100644 index 000000000..21c5d7605 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.dto.data; + +import com.sprint.mission.discodeit.entity.ChannelType; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record ChannelDto( + UUID id, + ChannelType type, + String name, + String description, + List participantIds, + Instant lastMessageAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java new file mode 100644 index 000000000..9b9605e43 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.dto.data; + +import com.sprint.mission.discodeit.entity.BinaryContent; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record MessageDto( + UUID id, + Instant createdAt, + Instant updatedAt, + String content, + UUID channelId, + UserDto author, + List attachments +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java new file mode 100644 index 000000000..11e91b8ee --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.time.Instant; +import java.util.UUID; + +public record ReadStatusDto( + UUID id, + UUID userId, + UUID channelId, + Instant lastReadAt +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java new file mode 100644 index 000000000..c22149239 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.data; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import jakarta.websocket.Decoder; + +import java.time.Instant; +import java.util.UUID; + +public record UserDto( + UUID id, + String username, + String email, + BinaryContent profile, + Boolean online +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java new file mode 100644 index 000000000..47fad377d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.time.Instant; +import java.util.UUID; + +public record UserStatusDto( + UUID id, + UUID userId, + Instant lastActiveAt +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java new file mode 100644 index 000000000..d86eb9898 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.dto.request; + +public record BinaryContentCreateRequest( + String fileName, + String contentType, + byte[] bytes +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java new file mode 100644 index 000000000..51ca9e620 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java @@ -0,0 +1,8 @@ +package com.sprint.mission.discodeit.dto.request; + +public record LoginRequest( + String username, + String password +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java new file mode 100644 index 000000000..0f65742b1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.dto.request; + +import java.util.UUID; + +public record MessageCreateRequest( + String content, + UUID channelId, + UUID authorId +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java new file mode 100644 index 000000000..d786b1e8c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java @@ -0,0 +1,7 @@ +package com.sprint.mission.discodeit.dto.request; + +public record MessageUpdateRequest( + String newContent +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java new file mode 100644 index 000000000..7edd4e823 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.dto.request; + +import java.util.List; +import java.util.UUID; + +public record PrivateChannelCreateRequest( + List participantIds +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java new file mode 100644 index 000000000..48e26327a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java @@ -0,0 +1,8 @@ +package com.sprint.mission.discodeit.dto.request; + +public record PublicChannelCreateRequest( + String name, + String description +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java new file mode 100644 index 000000000..d6e515410 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java @@ -0,0 +1,8 @@ +package com.sprint.mission.discodeit.dto.request; + +public record PublicChannelUpdateRequest( + String newName, + String newDescription +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java new file mode 100644 index 000000000..046a48808 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.request; + +import java.time.Instant; +import java.util.UUID; + +public record ReadStatusCreateRequest( + UUID userId, + UUID channelId, + Instant lastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java new file mode 100644 index 000000000..16b0c27ce --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.dto.request; + +import java.time.Instant; + +public record ReadStatusUpdateRequest( + Instant newLastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java new file mode 100644 index 000000000..e10e0ec57 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.dto.request; + +public record UserCreateRequest( + String username, + String email, + String password +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java new file mode 100644 index 000000000..71c92abba --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.dto.request; + +import java.time.Instant; +import java.util.UUID; + +public record UserStatusCreateRequest( + UUID userId, + Instant lastActiveAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java new file mode 100644 index 000000000..c69b2610f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.dto.request; + +import java.time.Instant; + +public record UserStatusUpdateRequest( + Instant newLastActiveAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java new file mode 100644 index 000000000..1e14e2cbd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.dto.request; + +public record UserUpdateRequest( + String newUsername, + String newEmail, + String newPassword +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/BinaryContentResponseDto.java b/src/main/java/com/sprint/mission/discodeit/dto/response/BinaryContentResponseDto.java new file mode 100644 index 000000000..92be4b7ad --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/BinaryContentResponseDto.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.dto.response; + +import java.util.UUID; + +public record BinaryContentResponseDto( + UUID id, + String fileName, + String contentType +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/ChannelResponseDto.java b/src/main/java/com/sprint/mission/discodeit/dto/response/ChannelResponseDto.java new file mode 100644 index 000000000..b5ef54673 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/ChannelResponseDto.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.response; + +import com.sprint.mission.discodeit.entity.ChannelType; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record ChannelResponseDto( + UUID id, + ChannelType type, + String name, + String description, + List participantIds, + Instant lastMessageAt +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/LoginResponseDto.java b/src/main/java/com/sprint/mission/discodeit/dto/response/LoginResponseDto.java new file mode 100644 index 000000000..c82b574fe --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/LoginResponseDto.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.dto.response; + +import java.util.UUID; + +public record LoginResponseDto( + UUID userId, + String username, + String email +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/MessageResponseDto.java b/src/main/java/com/sprint/mission/discodeit/dto/response/MessageResponseDto.java new file mode 100644 index 000000000..41a842d85 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/MessageResponseDto.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.dto.response; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.BinaryContent; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record MessageResponseDto ( + UUID id, + Instant createdAt, + Instant updatedAt, + String content, + UUID channelId, + UserDto author, + List attachments +){ +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/ReadStatusResponseDto.java b/src/main/java/com/sprint/mission/discodeit/dto/response/ReadStatusResponseDto.java new file mode 100644 index 000000000..78962bc81 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/ReadStatusResponseDto.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.response; + +import java.time.Instant; +import java.util.UUID; + +public record ReadStatusResponseDto( + UUID id, + UUID userId, + UUID channelId, + Instant lastReadAt +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/UserResponseDto.java b/src/main/java/com/sprint/mission/discodeit/dto/response/UserResponseDto.java new file mode 100644 index 000000000..820cea67f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/UserResponseDto.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.dto.response; + +import com.sprint.mission.discodeit.entity.BinaryContent; + +import java.util.UUID; + +public record UserResponseDto( + UUID id, + String username, + String email, + BinaryContent profile, + Boolean online +) { +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/UserStatusResponseDto.java b/src/main/java/com/sprint/mission/discodeit/dto/response/UserStatusResponseDto.java new file mode 100644 index 000000000..63ecf1241 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/UserStatusResponseDto.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.dto.response; + +import java.time.Instant; +import java.util.UUID; + +public record UserStatusResponseDto( + UUID id, + UUID userId, + Instant lastActiveAt +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java new file mode 100644 index 000000000..6f8af6aa4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java @@ -0,0 +1,43 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "BINARY_CONTENTS") +@RequiredArgsConstructor +public class BinaryContent extends BaseEntity{ + + @Column(nullable = false) + private String fileName; + + @Column(nullable = false) + private Long size; + + @Column(nullable = false) + private String contentType; + + @Column(nullable = false) + private byte[] bytes; + + @ManyToOne + @JoinColumn(name = "message_id") + private Message message; + + public BinaryContent(String fileName, Long size, String contentType, byte[] bytes) { + this.fileName = fileName; + this.size = size; + this.contentType = contentType; + this.bytes = bytes; + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Channel.java b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java index d7b83e104..6bd239dcc 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/Channel.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java @@ -1,70 +1,53 @@ package com.sprint.mission.discodeit.entity; -import java.io.Serial; -import java.io.Serializable; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; -public class Channel extends BaseEntity implements Serializable { - @Serial - private static final long serialVersionUID = 1L; - private String title; - private final List messages; - private final List users; +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CurrentTimestamp; - public Channel(String channel) { - super(); - this.title = channel; +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; - this.messages = new ArrayList<>(); - this.users = new ArrayList<>(); - } +@Getter +@Setter +@Entity +@Table(name = "CHANNELS") +@RequiredArgsConstructor +public class Channel extends BaseUpdatableEntity{ - public List getUsers() { - return users; - } + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ChannelType type; + @Column(nullable = false) + private String name; + @Column(nullable = false) + private String description; - public String getChannel() { - return title; - } + public Channel(ChannelType type, String name, String description) { + this.type = type; + this.name = name; + this.description = description; + } - public void updateChannel(String updateChannel){ - this.title = updateChannel; - updateTimeStamp(); - } - public void addUser(User user){ - if(!users.contains(user)) { - users.add(user); - user.addChannel(this); - } - } - public void addMessage(Message message){ - if(!messages.contains(message)) { - messages.add(message); - message.addChannel(this); - } - } - public void deleteMessage(Message message){ - if(!messages.contains(message)){ - messages.remove(message); - message.deleteChannel(this); - } - } - public void deleteUser(User user){ - if(!users.contains(user)){ - users.remove(user); - user.deleteChannel(this); - } + public void update(String newName, String newDescription) { + boolean anyValueUpdated = false; + if (newName != null && !newName.equals(this.name)) { + this.name = newName; + anyValueUpdated = true; } - - public List getMessages(){ - return messages; + if (newDescription != null && !newDescription.equals(this.description)) { + this.description = newDescription; + anyValueUpdated = true; } - @Override - public UUID getId(){ - return super.getId(); + if (anyValueUpdated) { + this.setUpdatedAt(Instant.now()); } -} \ No newline at end of file + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java new file mode 100644 index 000000000..4fca37721 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java @@ -0,0 +1,6 @@ +package com.sprint.mission.discodeit.entity; + +public enum ChannelType { + PUBLIC, + PRIVATE, +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Message.java b/src/main/java/com/sprint/mission/discodeit/entity/Message.java index ce42fcd4a..77b21d783 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/Message.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/Message.java @@ -1,48 +1,52 @@ package com.sprint.mission.discodeit.entity; -import java.io.Serial; +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + import java.io.Serializable; -import java.util.ArrayList; +import java.time.Instant; import java.util.List; -import java.util.UUID; - -public class Message extends BaseEntity implements Serializable { - @Serial - private static final long serialVersionUID = 1L; - private String content; - private User user; - private Channel channel; - - public Message(String content, User user, Channel channel) { - super(); - this.content = content; - this.user = user; - this.channel = channel; - } - - public String getContent() { - return content; - } - - public void addUser(User user){ - this.user = user; - } - public void deleteUser(User user){ - this.user = user; +@Getter +@Setter +@Entity +@Table(name = "MESSAGES") +@RequiredArgsConstructor +public class Message extends BaseUpdatableEntity{ + + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) + @JoinColumn(name = "CHANNEL_ID", nullable = false) + private Channel channel; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) + @JoinColumn(name = "USER_ID", nullable = false) + private User author; + + @OneToMany(mappedBy = "message", cascade = CascadeType.ALL, orphanRemoval = true) + private List attachmentIds; + + public Message(String content, Channel channel, User author, List attachmentIds) { + this.content = content; + this.channel = channel; + this.author = author; + this.attachmentIds = attachmentIds; + + } + public void update(String newContent) { + boolean anyValueUpdated = false; + if (newContent != null && !newContent.equals(this.content)) { + this.content = newContent; + anyValueUpdated = true; } - public void addChannel(Channel channel){ - this.channel = channel; + if (anyValueUpdated) { + this.setUpdatedAt(Instant.now()); } - - public void deleteChannel(Channel channel){ - this.channel = channel; - } - - public void updateContent(String newContent){ - this.content = newContent; - updateTimeStamp(); - } - + } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java new file mode 100644 index 000000000..492f2d0e1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java @@ -0,0 +1,52 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.LastModifiedDate; + +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "READ_STATUSES") +@RequiredArgsConstructor +public class ReadStatus extends BaseUpdatableEntity{ + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) + @JoinColumn(name = "USER_ID", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) + @JoinColumn(name = "CHANNEL_ID", nullable = false) + private Channel channel; + + @Column(name = "LAST_READ_AT") + @LastModifiedDate + private Instant lastReadAt; + + public ReadStatus(User user, Channel channel, Instant lastReadAt) { + this.user = user; + this.channel = channel; + this.lastReadAt = lastReadAt; + } + + + public void update(Instant newLastReadAt) { + boolean anyValueUpdated = false; + if (newLastReadAt != null && !newLastReadAt.equals(this.lastReadAt)) { + this.lastReadAt = newLastReadAt; + anyValueUpdated = true; + } + + if (anyValueUpdated) { + this.setUpdatedAt(Instant.now()); + } + + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/User.java b/src/main/java/com/sprint/mission/discodeit/entity/User.java index 5dc9345d0..5b2a9a731 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/User.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/User.java @@ -1,69 +1,62 @@ package com.sprint.mission.discodeit.entity; -import java.io.*; -import java.lang.reflect.Array; -import java.nio.channels.Channels; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public class User extends BaseEntity implements Serializable { - @Serial - private static final long serialVersionUID = 1L; - private String username; - private final List messages; - private final List channels; - - public User(String name) { - super(); - this.username = name; - - this.messages = new ArrayList(); - this.channels = new ArrayList(); - } - - public String getUsername() { - return username; - } - - public void updateName(String userName){ - this.username = userName; - } - - //유저의 Message 목록에 추가 - public void addMessage(Message message){ - if(!messages.contains(message)) { - messages.add(message); - message.addUser(this); - } - } - - //유저의 Channel 목록에 추가 - public void addChannel(Channel channel){ - if(!channels.contains(channel)) { - channels.add(channel); - channel.addUser(this); - } - } - - public void deleteChannel(Channel channel){ - if(!channels.contains(channel)){ - channels.remove(channel); - channel.deleteUser(this); - } - } - - public void deleteMessage(Message message){ - if(!messages.contains(message)) { - messages.remove(message); - message.deleteUser(this); - } - } - public List getChannels(){ - return channels; - } - - public List getMessages(){ - return messages; - } -} \ No newline at end of file +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "USERS") +@RequiredArgsConstructor +public class User extends BaseUpdatableEntity{ + + @Column(nullable = false) + private String username; + @Column(nullable = false) + private String email; + @Column(nullable = false) + private String password; + + @OneToOne(optional = true, fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "profile_id") + private BinaryContent profile; // BinaryContent + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private UserStatus userStatus; + + public User(String username, String email, String password, BinaryContent profile) { + this.username = username; + this.email = email; + this.password = password; + this.profile = profile; + } + + public void update(String newUsername, String newEmail, String newPassword, BinaryContent newProfileId) { + boolean anyValueUpdated = false; + if (newUsername != null && !newUsername.equals(this.username)) { + this.username = newUsername; + anyValueUpdated = true; + } + if (newEmail != null && !newEmail.equals(this.email)) { + this.email = newEmail; + anyValueUpdated = true; + } + if (newPassword != null && !newPassword.equals(this.password)) { + this.password = newPassword; + anyValueUpdated = true; + } + if (newProfileId != null && !newProfileId.equals(this.profile)) { + this.profile = newProfileId; + anyValueUpdated = true; + } + + if (anyValueUpdated) { + this.setUpdatedAt(Instant.now()); + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java new file mode 100644 index 000000000..e08b9e099 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java @@ -0,0 +1,52 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.LastModifiedDate; + +import java.io.Serializable; +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "USERS_STATUSES") +@RequiredArgsConstructor +public class UserStatus extends BaseUpdatableEntity{ + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) + @JoinColumn(name = "USER_ID") + private User user; + + @Column(name = "LAST_ACTIVE_AT", nullable = false) + @LastModifiedDate + private Instant lastActiveAt; + + public UserStatus(User user, Instant lastActiveAt) { + this.user = user; + this.lastActiveAt = lastActiveAt; + } + + public void update(Instant lastActiveAt) { + boolean anyValueUpdated = false; + if (lastActiveAt != null && !lastActiveAt.equals(this.lastActiveAt)) { + this.lastActiveAt = lastActiveAt; + anyValueUpdated = true; + } + + if (anyValueUpdated) { + this.setUpdatedAt(Instant.now()); + } + } + + public Boolean isOnline() { + Instant instantFiveMinutesAgo = Instant.now().minus(Duration.ofMinutes(5)); + + return lastActiveAt.isAfter(instantFiveMinutesAgo); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java new file mode 100644 index 000000000..765dab7f4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java @@ -0,0 +1,32 @@ +package com.sprint.mission.discodeit.entity.base; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.GenericGenerator; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Random; +import java.util.UUID; + +@Getter +@Setter +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +@NoArgsConstructor +public abstract class BaseEntity{ + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(nullable = false, updatable = false) + private UUID id; + + @CreatedDate + @Column(name = "created_at", nullable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java new file mode 100644 index 000000000..273380405 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java @@ -0,0 +1,24 @@ +package com.sprint.mission.discodeit.entity.base; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.io.Serializable; +import java.time.Instant; + +@Setter +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseUpdatableEntity extends BaseEntity{ + + @LastModifiedDate + @Column(name = "updated_at") + Instant updatedAt; + +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..0b2a683d6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -0,0 +1,36 @@ +package com.sprint.mission.discodeit.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.NoSuchElementException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleException(IllegalArgumentException e) { + e.printStackTrace(); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(e.getMessage()); + } + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity handleException(NoSuchElementException e) { + e.printStackTrace(); + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(e.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + e.printStackTrace(); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(e.getMessage()); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java new file mode 100644 index 000000000..e15225e34 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.response.BinaryContentResponseDto; +import com.sprint.mission.discodeit.entity.BinaryContent; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface BinaryContentMapper { + BinaryContentDto toDto(BinaryContent binaryContent); + + BinaryContentResponseDto toResponseDto(BinaryContentDto binaryContentDto); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java new file mode 100644 index 000000000..372595c24 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.response.ChannelResponseDto; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring", uses = {UserMapper.class, MessageMapper.class, ReadStatusMapper.class}) +public interface ChannelMapper { + //ChannelDto toDto(Channel channel); + ChannelResponseDto toResponseDto(ChannelDto channelDto); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java new file mode 100644 index 000000000..687647a0f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.mapper; + + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.response.MessageResponseDto; +import com.sprint.mission.discodeit.entity.Message; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", uses ={BinaryContentMapper.class, UserMapper.class}) +public interface MessageMapper { + @Mapping(source = "channel.id", target = "channelId") + MessageDto toDto(Message message); + MessageResponseDto toResponseDto(MessageDto messageDto); + +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java new file mode 100644 index 000000000..d5a08417a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.response.ReadStatusResponseDto; +import com.sprint.mission.discodeit.entity.ReadStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ReadStatusMapper { + @Mapping(source = "user.id", target = "userId") + @Mapping(source = "channel.id", target = "channelId") + ReadStatusDto toDto(ReadStatus readStatus); + + ReadStatusResponseDto toResponseDto(ReadStatusDto readStatusDto); + +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java new file mode 100644 index 000000000..384ddb5c3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.response.LoginResponseDto; +import com.sprint.mission.discodeit.dto.response.UserResponseDto; +import com.sprint.mission.discodeit.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", uses = {BinaryContentMapper.class}) +public interface UserMapper { + + UserDto toDto(User user); + + UserDto toDto(User user, boolean online); + + UserResponseDto toResponseDto(UserDto userDto); + + @Mapping(source = "id", target = "userId") + LoginResponseDto toLoginResponseDto(UserDto userDto); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java new file mode 100644 index 000000000..50a33cbdd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.response.UserStatusResponseDto; +import com.sprint.mission.discodeit.entity.UserStatus; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface UserStatusMapper { + UserStatusDto toDto(UserStatus userStatus); + UserStatusResponseDto toResponseDto(UserStatusDto userStatusDto); + +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java new file mode 100644 index 000000000..830de7e78 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface BinaryContentRepository extends JpaRepository { + + BinaryContent save(BinaryContent binaryContent); + + Optional findById(UUID id); + + List findAllByIdIn(List ids); + + boolean existsById(UUID id); + + void deleteById(UUID id); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java index 9170f0a48..4502694ee 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java @@ -1,21 +1,21 @@ package com.sprint.mission.discodeit.repository; import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.UUID; -public interface ChannelRepository { -// Channel createChannel(Channel name); - Channel getChannel(UUID name); - List getChannels(); -// void updateChannel(UUID name, String updateChannel); -// void deleteChannel(UUID name); -//Optional findByChannelName(String title); - Optional findByChannelName(String title); - void save(Channel channel); // create or update - void delete(UUID id); +public interface ChannelRepository extends JpaRepository { + + Channel save(Channel channel); + + Optional findById(UUID id); + + List findAll(); + + boolean existsById(UUID id); + + void deleteById(UUID id); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java index b419eae56..f04eba437 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java @@ -1,22 +1,23 @@ package com.sprint.mission.discodeit.repository; -import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.Message; -import com.sprint.mission.discodeit.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.UUID; -public interface MessageRepository { - // Message createMessage(Message create, User user, Channel channel); - Message getMessage(UUID id); - List getMessages(); - // void updateMessage (UUID id, String message); - // void deleteMessage (UUID id); - //Optional findByMessage(String content); - Optional validationMessage(UUID id); - void save(Message message); - void delete(UUID id); +public interface MessageRepository extends JpaRepository { + + Message save(Message message); + + Optional findById(UUID id); + + List findAllByChannelId(UUID channelId); + + boolean existsById(UUID id); + + void deleteById(UUID id); + + void deleteAllByChannelId(UUID channelId); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java new file mode 100644 index 000000000..f49e59903 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.ReadStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ReadStatusRepository extends JpaRepository { + + ReadStatus save(ReadStatus readStatus); + + Optional findById(UUID id); + + List findAllByUserId(UUID userId); + + List findAllByChannelId(UUID channelId); + + boolean existsById(UUID id); + + void deleteById(UUID id); + + void deleteAllByChannelId(UUID channelId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java index 8e330bd8e..c40eb0ea1 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java @@ -1,19 +1,27 @@ package com.sprint.mission.discodeit.repository; import com.sprint.mission.discodeit.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.UUID; -public interface UserRepository { -// User createUser(User user); - User getUser(UUID id); - List getUsers(); -// void updateUser (UUID id, String username); -// void deleteUser (UUID id); - Optional findByUserId(UUID id); - void save(User user); // create or update - void delete(UUID id); +public interface UserRepository extends JpaRepository { + + User save(User user); + + Optional findById(UUID id); + + Optional findByUsername(String username); + + List findAll(); + + boolean existsById(UUID id); + + void deleteById(UUID id); + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java new file mode 100644 index 000000000..7c4bd83ff --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.UserStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserStatusRepository extends JpaRepository { + + UserStatus save(UserStatus userStatus); + + Optional findById(UUID id); + + Optional findByUserId(UUID userId); + + List findAll(); + + boolean existsById(UUID id); + + void deleteById(UUID id); + + void deleteByUserId(UUID userId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java new file mode 100644 index 000000000..3b7f57dc8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.response.LoginResponseDto; + +public interface AuthService { + + LoginResponseDto login(LoginRequest loginRequest); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java new file mode 100644 index 000000000..f5a9aa479 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; + +import java.util.List; +import java.util.UUID; + +public interface BinaryContentService { + + BinaryContentDto create(BinaryContentCreateRequest request); + + BinaryContentDto find(UUID binaryContentId); + + List findAllByIdIn(List binaryContentIds); + + void delete(UUID binaryContentId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java index f05ade88b..bbeedbb09 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java @@ -1,19 +1,24 @@ package com.sprint.mission.discodeit.service; -import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; import java.util.List; -import java.util.Map; -import java.util.Optional; import java.util.UUID; public interface ChannelService { - Channel createChannel(Channel name); - Channel getChannel(UUID name); - List getChannels(); - void updateChannel(UUID name, String updateChannel); - void deleteChannel(UUID name); + ChannelDto create(PublicChannelCreateRequest request); - public Optional findByChannelName(String title); -} + ChannelDto create(PrivateChannelCreateRequest request); + + ChannelDto find(UUID channelId); + + List findAllByUserId(UUID userId); + + ChannelDto update(UUID channelId, PublicChannelUpdateRequest request); + + void delete(UUID channelId); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java index 6e6fa0a1a..0a1bcf164 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java @@ -1,21 +1,23 @@ -//package com.sprint.mission.discodeit.service; -// -//import com.sprint.mission.discodeit.entity.Channel; -//import com.sprint.mission.discodeit.entity.Message; -//import com.sprint.mission.discodeit.entity.User; -// -//import java.util.List; -//import java.util.Map; -//import java.util.Optional; -//import java.util.UUID; -// -//public interface MessageService { -// Message createMessage(Message create, User user, Channel channel); -// Message getMessage(UUID id); -// List getMessages(); -// void updateMessage (UUID id, String message); -// void deleteMessage (UUID id); -// Optional findByMessage(String content); -// Optional validationMessage(UUID id, Map data); -// -//} \ No newline at end of file +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; + +import java.util.List; +import java.util.UUID; + +public interface MessageService { + + MessageDto create(MessageCreateRequest messageCreateRequest, + List binaryContentCreateRequests); + + MessageDto find(UUID messageId); + + List findAllByChannelId(UUID channelId); + + MessageDto update(UUID messageId, MessageUpdateRequest request); + + void delete(UUID messageId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java new file mode 100644 index 000000000..07c2459ce --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; + +import java.util.List; +import java.util.UUID; + +public interface ReadStatusService { + + ReadStatusDto create(ReadStatusCreateRequest request); + + ReadStatusDto find(UUID readStatusId); + + List findAllByUserId(UUID userId); + + ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request); + + void delete(UUID readStatusId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserService.java b/src/main/java/com/sprint/mission/discodeit/service/UserService.java index 78b0d260b..86644daf0 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/UserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/UserService.java @@ -1,18 +1,23 @@ package com.sprint.mission.discodeit.service; -import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.UUID; public interface UserService { - User createUser(User user); - User getUser(UUID id); - List getUsers(); - void updateUser (UUID id, String username); - void deleteUser (UUID id); - Optional findByUserId(UUID id); - -} \ No newline at end of file + + UserDto create(UserCreateRequest userCreateRequest, Optional profileCreateRequest); + + UserDto find(UUID userId); + + List findAll(); + + UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, Optional profileCreateRequest); + + void delete(UUID userId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java new file mode 100644 index 000000000..232d66205 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; + +import java.util.List; +import java.util.UUID; + +public interface UserStatusService { + + UserStatusDto create(UserStatusCreateRequest request); + + UserStatusDto find(UUID userStatusId); + + List findAll(); + + UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request); + + UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request); + + void delete(UUID userStatusId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java new file mode 100644 index 000000000..93e8f2ec5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -0,0 +1,40 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.response.LoginResponseDto; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.NoSuchElementException; + +@RequiredArgsConstructor +@Service +public class BasicAuthService implements AuthService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + + @Override + @Transactional + public LoginResponseDto login(LoginRequest loginRequest) { + String username = loginRequest.username(); + String password = loginRequest.password(); + + User user = userRepository.findByUsername(username) + .orElseThrow( + () -> new NoSuchElementException("User with username " + username + " not found")); + + if (!user.getPassword().equals(password)) { + throw new IllegalArgumentException("Wrong password"); + } + + UserDto userDto = userMapper.toDto(user); + return userMapper.toLoginResponseDto(userDto); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java new file mode 100644 index 000000000..a46d5a040 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java @@ -0,0 +1,63 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.service.BinaryContentService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; + +@RequiredArgsConstructor +@Service +public class BasicBinaryContentService implements BinaryContentService { + + private final BinaryContentRepository binaryContentRepository; + private final BinaryContentMapper binaryContentMapper; + + @Override + @Transactional + public BinaryContentDto create(BinaryContentCreateRequest request) { + String fileName = request.fileName(); + byte[] bytes = request.bytes(); + String contentType = request.contentType(); + BinaryContent binaryContent = new BinaryContent( + fileName, + (long) bytes.length, + contentType, + bytes + ); + binaryContentRepository.save(binaryContent); + return binaryContentMapper.toDto(binaryContent); + } + + @Override + @Transactional(readOnly = true) + public BinaryContentDto find(UUID binaryContentId) { + return binaryContentRepository.findById(binaryContentId).map(binaryContentMapper::toDto) + .orElseThrow(() -> new NoSuchElementException( + "BinaryContent with id " + binaryContentId + " not found")); + } + + @Override + @Transactional(readOnly = true) + public List findAllByIdIn(List binaryContentIds) { + return binaryContentRepository.findAllByIdIn(binaryContentIds).stream().map(binaryContentMapper::toDto) + .toList(); + } + + @Override + @Transactional + public void delete(UUID binaryContentId) { + if (!binaryContentRepository.existsById(binaryContentId)) { + throw new NoSuchElementException("BinaryContent with id " + binaryContentId + " not found"); + } + binaryContentRepository.deleteById(binaryContentId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java new file mode 100644 index 000000000..c0c85e62f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java @@ -0,0 +1,138 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.*; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.ChannelService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.*; + +@RequiredArgsConstructor +@Service +public class BasicChannelService implements ChannelService { + + private final ChannelRepository channelRepository; + // + private final ReadStatusRepository readStatusRepository; + private final MessageRepository messageRepository; + private final UserRepository userRepository; + private final ChannelMapper channelMapper; + + @Override + @Transactional + public ChannelDto create(PublicChannelCreateRequest request) { + String name = request.name(); + String description = request.description(); + Channel channel = new Channel(ChannelType.PUBLIC, name, description); + Channel savedChannel = channelRepository.save(channel); + return this.toDto(savedChannel); + } + + @Override + @Transactional + public ChannelDto create(PrivateChannelCreateRequest request) { + Channel channel = new Channel(ChannelType.PRIVATE, null, null); + Channel createdChannel = channelRepository.save(channel); + + request.participantIds().stream() + .map(userId -> { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User with id " + userId + " not found")); + return new ReadStatus(user, createdChannel, channel.getCreatedAt()); + }) + .forEach(readStatusRepository::save); + + return this.toDto(channel); + } + + @Override + @Transactional(readOnly = true) + public ChannelDto find(UUID channelId) { + return channelRepository.findById(channelId) + .map(this::toDto) + .orElseThrow( + () -> new NoSuchElementException("Channel with id " + channelId + " not found")); + } + + @Override + @Transactional(readOnly = true) + public List findAllByUserId(UUID userId) { + List mySubscribedChannelIds = readStatusRepository.findAllByUserId(userId).stream() + .map(readStatus -> readStatus.getChannel().getId()) + .toList(); + + return channelRepository.findAll().stream() + .filter(channel -> + channel.getType().equals(ChannelType.PUBLIC) + || mySubscribedChannelIds.contains(channel.getId()) + ) + .map(this::toDto) + .toList(); + } + + @Override + @Transactional + public ChannelDto update(UUID channelId, PublicChannelUpdateRequest request) { + String newName = request.newName(); + String newDescription = request.newDescription(); + Channel channel = channelRepository.findById(channelId) + .orElseThrow( + () -> new NoSuchElementException("Channel with id " + channelId + " not found")); + if (channel.getType().equals(ChannelType.PRIVATE)) { + throw new IllegalArgumentException("Private channel cannot be updated"); + } + channel.update(newName, newDescription); + return this.toDto(channel); + } + + @Override + @Transactional + public void delete(UUID channelId) { + Channel channel = channelRepository.findById(channelId) + .orElseThrow( + () -> new NoSuchElementException("Channel with id " + channelId + " not found")); + + messageRepository.deleteAllByChannelId(channel.getId()); + readStatusRepository.deleteAllByChannelId(channel.getId()); + + channelRepository.deleteById(channelId); + } + @Transactional(readOnly = true) + protected ChannelDto toDto(Channel channel) { + Instant lastMessageAt = messageRepository.findAllByChannelId(channel.getId()) + .stream() + .sorted(Comparator.comparing(Message::getCreatedAt).reversed()) + .map(Message::getCreatedAt) + .limit(1) + .findFirst() + .orElse(Instant.MIN); + + List participantIds = new ArrayList<>(); + if (channel.getType().equals(ChannelType.PRIVATE)) { + readStatusRepository.findAllByChannelId(channel.getId()) + .stream() + .map(readStatus -> readStatus.getUser().getId()) + .forEach(participantIds::add); + } + + return new ChannelDto( + channel.getId(), + channel.getType(), + channel.getName(), + channel.getDescription(), + participantIds, + lastMessageAt + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java new file mode 100644 index 000000000..46d30fedb --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java @@ -0,0 +1,112 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.MessageService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; + +@RequiredArgsConstructor +@Service +public class BasicMessageService implements MessageService { + + private final MessageRepository messageRepository; + // + private final ChannelRepository channelRepository; + private final UserRepository userRepository; + private final BinaryContentRepository binaryContentRepository; + private final MessageMapper messageMapper; + + @Override + @Transactional + public MessageDto create(MessageCreateRequest messageCreateRequest, + List binaryContentCreateRequests) { + UUID channelId = messageCreateRequest.channelId(); + UUID authorId = messageCreateRequest.authorId(); + + List attachmentIds = binaryContentCreateRequests.stream() + .map(attachmentRequest -> { + String fileName = attachmentRequest.fileName(); + String contentType = attachmentRequest.contentType(); + byte[] bytes = attachmentRequest.bytes(); + + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType, bytes); + return binaryContentRepository.save(binaryContent); + }) + .toList(); + + String content = messageCreateRequest.content(); + Channel channel = channelRepository.findById(channelId).orElseThrow( + () -> new NoSuchElementException("Channel with id " + channelId + " does not exist") + ); + User userAuthor = userRepository.findById(authorId).orElseThrow( + () -> new NoSuchElementException("Author with id " + authorId + " does not exist") + ); + + Message message = new Message( + content, + channel, + userAuthor, + attachmentIds + ); + Message savedMessage = messageRepository.save(message); + return messageMapper.toDto(savedMessage); + } + + @Override + @Transactional(readOnly = true) + public MessageDto find(UUID messageId) { + return messageRepository.findById(messageId).map(messageMapper::toDto) + .orElseThrow( + () -> new NoSuchElementException("Message with id " + messageId + " not found")); + } + + @Override + @Transactional(readOnly = true) + public List findAllByChannelId(UUID channelId) { + return messageRepository.findAllByChannelId(channelId).stream() + .map(messageMapper::toDto) + .toList(); + } + + @Override + @Transactional + public MessageDto update(UUID messageId, MessageUpdateRequest request) { + String newContent = request.newContent(); + Message message = messageRepository.findById(messageId) + .orElseThrow( + () -> new NoSuchElementException("Message with id " + messageId + " not found")); + message.update(newContent); + return messageMapper.toDto(message); + } + + @Override + @Transactional + public void delete(UUID messageId) { + Message message = messageRepository.findById(messageId) + .orElseThrow( + () -> new NoSuchElementException("Message with id " + messageId + " not found")); + + message.getAttachmentIds() + .forEach(attachment -> binaryContentRepository.deleteById(attachment.getId())); + + messageRepository.deleteById(messageId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java new file mode 100644 index 000000000..b2f8cbcbd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java @@ -0,0 +1,86 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.mapper.ReadStatusMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.ReadStatusService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; + +@RequiredArgsConstructor +@Service +public class BasicReadStatusService implements ReadStatusService { + + private final ReadStatusRepository readStatusRepository; + private final UserRepository userRepository; + private final ChannelRepository channelRepository; + private final ReadStatusMapper readStatusMapper; + + @Override + @Transactional + public ReadStatusDto create(ReadStatusCreateRequest request) { + UUID userId = request.userId(); + UUID channelId = request.channelId(); + + if (readStatusRepository.findAllByUserId(userId).stream() + .anyMatch(readStatus -> readStatus.getChannel().getId().equals(channelId))) { + throw new IllegalArgumentException( + "ReadStatus with userId " + userId + " and channelId " + channelId + " already exists"); + } + User user = userRepository.findById(userId).orElseThrow(() -> new NoSuchElementException("User with id " + userId + " does not exist")); + Channel channel = channelRepository.findById(channelId).orElseThrow(() -> new NoSuchElementException("Channel with id " + channelId + " does not exist")); + + Instant lastReadAt = request.lastReadAt(); + ReadStatus saveReadStatus = readStatusRepository.save(new ReadStatus(user, channel, lastReadAt)); + return readStatusMapper.toDto(saveReadStatus); + } + + @Override + @Transactional(readOnly = true) + public ReadStatusDto find(UUID readStatusId) { + return readStatusRepository.findById(readStatusId).map(readStatusMapper::toDto) + .orElseThrow( + () -> new NoSuchElementException("ReadStatus with id " + readStatusId + " not found")); + } + + @Override + @Transactional(readOnly = true) + public List findAllByUserId(UUID userId) { + return readStatusRepository.findAllByUserId(userId).stream() + .map(readStatusMapper::toDto) + .toList(); + } + + @Override + @Transactional + public ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request) { + Instant newLastReadAt = request.newLastReadAt(); + ReadStatus readStatus = readStatusRepository.findById(readStatusId) + .orElseThrow( + () -> new NoSuchElementException("ReadStatus with id " + readStatusId + " not found")); + readStatus.update(newLastReadAt); + return readStatusMapper.toDto(readStatus); + } + + @Override + @Transactional + public void delete(UUID readStatusId) { + if (!readStatusRepository.existsById(readStatusId)) { + throw new NoSuchElementException("ReadStatus with id " + readStatusId + " not found"); + } + readStatusRepository.deleteById(readStatusId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java new file mode 100644 index 000000000..62970e2c8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -0,0 +1,144 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; + +@RequiredArgsConstructor +@Service +public class BasicUserService implements UserService { + + private final UserRepository userRepository; + // + private final BinaryContentRepository binaryContentRepository; + private final UserStatusRepository userStatusRepository; + private final UserMapper userMapper; + + @Override + @Transactional + public UserDto create(UserCreateRequest userCreateRequest, + Optional optionalProfileCreateRequest) { + String username = userCreateRequest.username(); + String email = userCreateRequest.email(); + String password = userCreateRequest.password(); + + if (userRepository.existsByEmail(email)) { + throw new IllegalArgumentException("User with email " + email + " already exists"); + } + if (userRepository.existsByUsername(username)) { + throw new IllegalArgumentException("User with username " + username + " already exists"); + } + + BinaryContent nullableProfileId = optionalProfileCreateRequest + .map(profileRequest -> { + String fileName = profileRequest.fileName(); + String contentType = profileRequest.contentType(); + byte[] bytes = profileRequest.bytes(); + + return binaryContentRepository.save(new BinaryContent(fileName, (long) bytes.length, + contentType, bytes)); + }) + .orElse(null); + + User createdUser = userRepository.save(new User(username, email, password, nullableProfileId)); + + Instant now = Instant.now(); + UserStatus userStatus = new UserStatus(createdUser, now); + userStatusRepository.save(userStatus); + + return userMapper.toDto(createdUser); + } + + @Override + @Transactional(readOnly = true) + public UserDto find(UUID userId) { + return userRepository.findById(userId) + .map(this::toDto) + .orElseThrow(() -> new NoSuchElementException("User with id " + userId + " not found")); + } + + @Override + @Transactional(readOnly = true) + public List findAll() { + return userRepository.findAll() + .stream() + .map(this::toDto) + .toList(); + } + + @Override + @Transactional + public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, + Optional optionalProfileCreateRequest) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User with id " + userId + " not found")); + + String newUsername = userUpdateRequest.newUsername(); + String newEmail = userUpdateRequest.newEmail(); + if (userRepository.existsByEmail(newEmail)) { + throw new IllegalArgumentException("User with email " + newEmail + " already exists"); + } + if (userRepository.existsByUsername(newUsername)) { + throw new IllegalArgumentException("User with username " + newUsername + " already exists"); + } + + BinaryContent nullableProfileId = optionalProfileCreateRequest + .map(profileRequest -> { + Optional.ofNullable(user.getProfile()) + .ifPresent(profile -> binaryContentRepository.deleteById(profile.getId())); + + String fileName = profileRequest.fileName(); + String contentType = profileRequest.contentType(); + byte[] bytes = profileRequest.bytes(); + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType, bytes); + return binaryContentRepository.save(binaryContent); + }) + .orElse(null); + + String newPassword = userUpdateRequest.newPassword(); + user.update(newUsername, newEmail, newPassword, nullableProfileId); + + return userMapper.toDto(user); + } + + @Override + @Transactional + public void delete(UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User with id " + userId + " not found")); + + Optional.ofNullable(user.getProfile()) + .ifPresent(profile -> binaryContentRepository.deleteById(profile.getId())); + userStatusRepository.deleteByUserId(userId); + + userRepository.deleteById(userId); + } + + @Transactional(readOnly = true) + protected UserDto toDto(User user) { + Boolean online = userStatusRepository.findByUserId(user.getId()) + .map(UserStatus::isOnline) + .orElse(null); + + return userMapper.toDto(user, Boolean.TRUE.equals(online)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java new file mode 100644 index 000000000..38792b24b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java @@ -0,0 +1,97 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.mapper.UserStatusMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.service.UserStatusService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; + +@RequiredArgsConstructor +@Service +public class BasicUserStatusService implements UserStatusService { + + private final UserStatusRepository userStatusRepository; + private final UserRepository userRepository; + private final UserStatusMapper userStatusMapper; + + @Override + @Transactional + public UserStatusDto create(UserStatusCreateRequest request) { + UUID userId = request.userId(); + + if (!userRepository.existsById(userId)) { + throw new NoSuchElementException("User with id " + userId + " does not exist"); + } + if (userStatusRepository.findByUserId(userId).isPresent()) { + throw new IllegalArgumentException("UserStatus with id " + userId + " already exists"); + } + + Instant lastActiveAt = request.lastActiveAt(); + User user = userRepository.findById(userId).get(); + + UserStatus userStatus = new UserStatus(user, lastActiveAt); + return userStatusMapper.toDto(userStatus); + } + + @Override + @Transactional(readOnly = true) + public UserStatusDto find(UUID userStatusId) { + return userStatusRepository.findById(userStatusId).map(userStatusMapper::toDto) + .orElseThrow( + () -> new NoSuchElementException("UserStatus with id " + userStatusId + " not found")); + } + + @Override + @Transactional(readOnly = true) + public List findAll() { + return userStatusRepository.findAll().stream().map(userStatusMapper::toDto) + .toList(); + } + + @Override + @Transactional + public UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request) { + Instant newLastActiveAt = request.newLastActiveAt(); + + UserStatus userStatus = userStatusRepository.findById(userStatusId) + .orElseThrow( + () -> new NoSuchElementException("UserStatus with id " + userStatusId + " not found")); + userStatus.update(newLastActiveAt); + + return userStatusMapper.toDto(userStatus); + } + + @Override + @Transactional + public UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request) { + Instant newLastActiveAt = request.newLastActiveAt(); + + UserStatus userStatus = userStatusRepository.findByUserId(userId) + .orElseThrow( + () -> new NoSuchElementException("UserStatus with userId " + userId + " not found")); + userStatus.update(newLastActiveAt); + + return userStatusMapper.toDto(userStatus); + } + + @Override + @Transactional + public void delete(UUID userStatusId) { + if (!userStatusRepository.existsById(userStatusId)) { + throw new NoSuchElementException("UserStatus with id " + userStatusId + " not found"); + } + userStatusRepository.deleteById(userStatusId); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 000000000..8b9e156ef --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,20 @@ +spring : + datasource: + url: jdbc:postgresql://localhost:5432/discodeit + username: discodeit_user + password: discodeit1234 + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + output: + ansi: + enabled: always +logging: + level: + org.hibernate.sql: debug + org.hibernate.orm.jdbc.bind: trace diff --git a/src/main/resources/static/assets/index-CRrRqFH4.js b/src/main/resources/static/assets/index-CRrRqFH4.js new file mode 100644 index 000000000..ffeaa39b4 --- /dev/null +++ b/src/main/resources/static/assets/index-CRrRqFH4.js @@ -0,0 +1,956 @@ +(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))u(c);new MutationObserver(c=>{for(const d of c)if(d.type==="childList")for(const p of d.addedNodes)p.tagName==="LINK"&&p.rel==="modulepreload"&&u(p)}).observe(document,{childList:!0,subtree:!0});function s(c){const d={};return c.integrity&&(d.integrity=c.integrity),c.referrerPolicy&&(d.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?d.credentials="include":c.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function u(c){if(c.ep)return;c.ep=!0;const d=s(c);fetch(c.href,d)}})();function Qm(r){return r&&r.__esModule&&Object.prototype.hasOwnProperty.call(r,"default")?r.default:r}var lu={exports:{}},ho={},uu={exports:{}},fe={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Wf;function qm(){if(Wf)return fe;Wf=1;var r=Symbol.for("react.element"),i=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),d=Symbol.for("react.provider"),p=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),v=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),E=Symbol.for("react.lazy"),j=Symbol.iterator;function O(S){return S===null||typeof S!="object"?null:(S=j&&S[j]||S["@@iterator"],typeof S=="function"?S:null)}var P={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},I=Object.assign,R={};function L(S,D,oe){this.props=S,this.context=D,this.refs=R,this.updater=oe||P}L.prototype.isReactComponent={},L.prototype.setState=function(S,D){if(typeof S!="object"&&typeof S!="function"&&S!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,S,D,"setState")},L.prototype.forceUpdate=function(S){this.updater.enqueueForceUpdate(this,S,"forceUpdate")};function V(){}V.prototype=L.prototype;function F(S,D,oe){this.props=S,this.context=D,this.refs=R,this.updater=oe||P}var W=F.prototype=new V;W.constructor=F,I(W,L.prototype),W.isPureReactComponent=!0;var K=Array.isArray,$=Object.prototype.hasOwnProperty,T={current:null},H={key:!0,ref:!0,__self:!0,__source:!0};function se(S,D,oe){var le,de={},ce=null,ve=null;if(D!=null)for(le in D.ref!==void 0&&(ve=D.ref),D.key!==void 0&&(ce=""+D.key),D)$.call(D,le)&&!H.hasOwnProperty(le)&&(de[le]=D[le]);var pe=arguments.length-2;if(pe===1)de.children=oe;else if(1>>1,D=Q[S];if(0>>1;Sc(de,q))cec(ve,de)?(Q[S]=ve,Q[ce]=q,S=ce):(Q[S]=de,Q[le]=q,S=le);else if(cec(ve,q))Q[S]=ve,Q[ce]=q,S=ce;else break e}}return ee}function c(Q,ee){var q=Q.sortIndex-ee.sortIndex;return q!==0?q:Q.id-ee.id}if(typeof performance=="object"&&typeof performance.now=="function"){var d=performance;r.unstable_now=function(){return d.now()}}else{var p=Date,m=p.now();r.unstable_now=function(){return p.now()-m}}var v=[],x=[],E=1,j=null,O=3,P=!1,I=!1,R=!1,L=typeof setTimeout=="function"?setTimeout:null,V=typeof clearTimeout=="function"?clearTimeout:null,F=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function W(Q){for(var ee=s(x);ee!==null;){if(ee.callback===null)u(x);else if(ee.startTime<=Q)u(x),ee.sortIndex=ee.expirationTime,i(v,ee);else break;ee=s(x)}}function K(Q){if(R=!1,W(Q),!I)if(s(v)!==null)I=!0,We($);else{var ee=s(x);ee!==null&&Se(K,ee.startTime-Q)}}function $(Q,ee){I=!1,R&&(R=!1,V(se),se=-1),P=!0;var q=O;try{for(W(ee),j=s(v);j!==null&&(!(j.expirationTime>ee)||Q&&!qt());){var S=j.callback;if(typeof S=="function"){j.callback=null,O=j.priorityLevel;var D=S(j.expirationTime<=ee);ee=r.unstable_now(),typeof D=="function"?j.callback=D:j===s(v)&&u(v),W(ee)}else u(v);j=s(v)}if(j!==null)var oe=!0;else{var le=s(x);le!==null&&Se(K,le.startTime-ee),oe=!1}return oe}finally{j=null,O=q,P=!1}}var T=!1,H=null,se=-1,Ve=5,At=-1;function qt(){return!(r.unstable_now()-AtQ||125S?(Q.sortIndex=q,i(x,Q),s(v)===null&&Q===s(x)&&(R?(V(se),se=-1):R=!0,Se(K,q-S))):(Q.sortIndex=D,i(v,Q),I||P||(I=!0,We($))),Q},r.unstable_shouldYield=qt,r.unstable_wrapCallback=function(Q){var ee=O;return function(){var q=O;O=ee;try{return Q.apply(this,arguments)}finally{O=q}}}}(fu)),fu}var Yf;function Km(){return Yf||(Yf=1,cu.exports=Ym()),cu.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Kf;function Xm(){if(Kf)return st;Kf=1;var r=Bu(),i=Km();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),v=Object.prototype.hasOwnProperty,x=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,E={},j={};function O(e){return v.call(j,e)?!0:v.call(E,e)?!1:x.test(e)?j[e]=!0:(E[e]=!0,!1)}function P(e,t,n,o){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return o?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function I(e,t,n,o){if(t===null||typeof t>"u"||P(e,t,n,o))return!0;if(o)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function R(e,t,n,o,l,a,f){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=o,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=f}var L={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){L[e]=new R(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];L[t]=new R(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){L[e]=new R(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){L[e]=new R(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){L[e]=new R(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){L[e]=new R(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){L[e]=new R(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){L[e]=new R(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){L[e]=new R(e,5,!1,e.toLowerCase(),null,!1,!1)});var V=/[\-:]([a-z])/g;function F(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(V,F);L[t]=new R(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(V,F);L[t]=new R(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(V,F);L[t]=new R(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){L[e]=new R(e,1,!1,e.toLowerCase(),null,!1,!1)}),L.xlinkHref=new R("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){L[e]=new R(e,1,!1,e.toLowerCase(),null,!0,!0)});function W(e,t,n,o){var l=L.hasOwnProperty(t)?L[t]:null;(l!==null?l.type!==0:o||!(2h||l[f]!==a[h]){var y=` +`+l[f].replace(" at new "," at ");return e.displayName&&y.includes("")&&(y=y.replace("",e.displayName)),y}while(1<=f&&0<=h);break}}}finally{oe=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?D(e):""}function de(e){switch(e.tag){case 5:return D(e.type);case 16:return D("Lazy");case 13:return D("Suspense");case 19:return D("SuspenseList");case 0:case 2:case 15:return e=le(e.type,!1),e;case 11:return e=le(e.type.render,!1),e;case 1:return e=le(e.type,!0),e;default:return""}}function ce(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case H:return"Fragment";case T:return"Portal";case Ve:return"Profiler";case se:return"StrictMode";case Je:return"Suspense";case at:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case qt:return(e.displayName||"Context")+".Consumer";case At:return(e._context.displayName||"Context")+".Provider";case gt:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case yt:return t=e.displayName||null,t!==null?t:ce(e.type)||"Memo";case We:t=e._payload,e=e._init;try{return ce(e(t))}catch{}}return null}function ve(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ce(t);case 8:return t===se?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function pe(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ge(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Be(e){var t=ge(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),o=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(f){o=""+f,a.call(this,f)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return o},setValue:function(f){o=""+f},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function bt(e){e._valueTracker||(e._valueTracker=Be(e))}function Rt(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),o="";return e&&(o=ge(e)?e.checked?"true":"false":e.value),e=o,e!==n?(t.setValue(e),!0):!1}function Ao(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function hs(e,t){var n=t.checked;return q({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Ku(e,t){var n=t.defaultValue==null?"":t.defaultValue,o=t.checked!=null?t.checked:t.defaultChecked;n=pe(t.value!=null?t.value:n),e._wrapperState={initialChecked:o,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Xu(e,t){t=t.checked,t!=null&&W(e,"checked",t,!1)}function ms(e,t){Xu(e,t);var n=pe(t.value),o=t.type;if(n!=null)o==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(o==="submit"||o==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?gs(e,t.type,n):t.hasOwnProperty("defaultValue")&&gs(e,t.type,pe(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Ju(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var o=t.type;if(!(o!=="submit"&&o!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function gs(e,t,n){(t!=="number"||Ao(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var jr=Array.isArray;function Gn(e,t,n,o){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Ro.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Ir(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var _r={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Kp=["Webkit","ms","Moz","O"];Object.keys(_r).forEach(function(e){Kp.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),_r[t]=_r[e]})});function oa(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||_r.hasOwnProperty(e)&&_r[e]?(""+t).trim():t+"px"}function ia(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var o=n.indexOf("--")===0,l=oa(n,t[n],o);n==="float"&&(n="cssFloat"),o?e.setProperty(n,l):e[n]=l}}var Xp=q({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ws(e,t){if(t){if(Xp[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(s(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(s(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(s(61))}if(t.style!=null&&typeof t.style!="object")throw Error(s(62))}}function xs(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Ss=null;function ks(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Es=null,Yn=null,Kn=null;function sa(e){if(e=Jr(e)){if(typeof Es!="function")throw Error(s(280));var t=e.stateNode;t&&(t=Yo(t),Es(e.stateNode,e.type,t))}}function la(e){Yn?Kn?Kn.push(e):Kn=[e]:Yn=e}function ua(){if(Yn){var e=Yn,t=Kn;if(Kn=Yn=null,sa(e),t)for(e=0;e>>=0,e===0?32:31-(uh(e)/ah|0)|0}var No=64,Oo=4194304;function Lr(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function To(e,t){var n=e.pendingLanes;if(n===0)return 0;var o=0,l=e.suspendedLanes,a=e.pingedLanes,f=n&268435455;if(f!==0){var h=f&~l;h!==0?o=Lr(h):(a&=f,a!==0&&(o=Lr(a)))}else f=n&~l,f!==0?o=Lr(f):a!==0&&(o=Lr(a));if(o===0)return 0;if(t!==0&&t!==o&&!(t&l)&&(l=o&-o,a=t&-t,l>=a||l===16&&(a&4194240)!==0))return t;if(o&4&&(o|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=o;0n;n++)t.push(e);return t}function Dr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Pt(t),e[t]=n}function ph(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var o=e.eventTimes;for(e=e.expirationTimes;0=Vr),za=" ",Ma=!1;function Ua(e,t){switch(e){case"keyup":return $h.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Fa(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Zn=!1;function Vh(e,t){switch(e){case"compositionend":return Fa(t);case"keypress":return t.which!==32?null:(Ma=!0,za);case"textInput":return e=t.data,e===za&&Ma?null:e;default:return null}}function Wh(e,t){if(Zn)return e==="compositionend"||!$s&&Ua(e,t)?(e=_a(),Uo=Ds=an=null,Zn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=o}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=qa(n)}}function Ga(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ga(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ya(){for(var e=window,t=Ao();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Ao(e.document)}return t}function Ws(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Zh(e){var t=Ya(),n=e.focusedElem,o=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ga(n.ownerDocument.documentElement,n)){if(o!==null&&Ws(n)){if(t=o.start,e=o.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,a=Math.min(o.start,l);o=o.end===void 0?a:Math.min(o.end,l),!e.extend&&a>o&&(l=o,o=a,a=l),l=ba(n,a);var f=ba(n,o);l&&f&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==f.node||e.focusOffset!==f.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),a>o?(e.addRange(t),e.extend(f.node,f.offset)):(t.setEnd(f.node,f.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,er=null,Qs=null,br=null,qs=!1;function Ka(e,t,n){var o=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;qs||er==null||er!==Ao(o)||(o=er,"selectionStart"in o&&Ws(o)?o={start:o.selectionStart,end:o.selectionEnd}:(o=(o.ownerDocument&&o.ownerDocument.defaultView||window).getSelection(),o={anchorNode:o.anchorNode,anchorOffset:o.anchorOffset,focusNode:o.focusNode,focusOffset:o.focusOffset}),br&&qr(br,o)||(br=o,o=qo(Qs,"onSelect"),0ir||(e.current=ol[ir],ol[ir]=null,ir--)}function ke(e,t){ir++,ol[ir]=e.current,e.current=t}var pn={},Qe=dn(pn),tt=dn(!1),Pn=pn;function sr(e,t){var n=e.type.contextTypes;if(!n)return pn;var o=e.stateNode;if(o&&o.__reactInternalMemoizedUnmaskedChildContext===t)return o.__reactInternalMemoizedMaskedChildContext;var l={},a;for(a in n)l[a]=t[a];return o&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function nt(e){return e=e.childContextTypes,e!=null}function Ko(){Ce(tt),Ce(Qe)}function fc(e,t,n){if(Qe.current!==pn)throw Error(s(168));ke(Qe,t),ke(tt,n)}function dc(e,t,n){var o=e.stateNode;if(t=t.childContextTypes,typeof o.getChildContext!="function")return n;o=o.getChildContext();for(var l in o)if(!(l in t))throw Error(s(108,ve(e)||"Unknown",l));return q({},n,o)}function Xo(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||pn,Pn=Qe.current,ke(Qe,e),ke(tt,tt.current),!0}function pc(e,t,n){var o=e.stateNode;if(!o)throw Error(s(169));n?(e=dc(e,t,Pn),o.__reactInternalMemoizedMergedChildContext=e,Ce(tt),Ce(Qe),ke(Qe,e)):Ce(tt),ke(tt,n)}var Yt=null,Jo=!1,il=!1;function hc(e){Yt===null?Yt=[e]:Yt.push(e)}function fm(e){Jo=!0,hc(e)}function hn(){if(!il&&Yt!==null){il=!0;var e=0,t=xe;try{var n=Yt;for(xe=1;e>=f,l-=f,Kt=1<<32-Pt(t)+l|n<re?(Ue=ne,ne=null):Ue=ne.sibling;var ye=z(k,ne,C[re],B);if(ye===null){ne===null&&(ne=Ue);break}e&&ne&&ye.alternate===null&&t(k,ne),w=a(ye,w,re),te===null?Z=ye:te.sibling=ye,te=ye,ne=Ue}if(re===C.length)return n(k,ne),Re&&In(k,re),Z;if(ne===null){for(;rere?(Ue=ne,ne=null):Ue=ne.sibling;var En=z(k,ne,ye.value,B);if(En===null){ne===null&&(ne=Ue);break}e&&ne&&En.alternate===null&&t(k,ne),w=a(En,w,re),te===null?Z=En:te.sibling=En,te=En,ne=Ue}if(ye.done)return n(k,ne),Re&&In(k,re),Z;if(ne===null){for(;!ye.done;re++,ye=C.next())ye=U(k,ye.value,B),ye!==null&&(w=a(ye,w,re),te===null?Z=ye:te.sibling=ye,te=ye);return Re&&In(k,re),Z}for(ne=o(k,ne);!ye.done;re++,ye=C.next())ye=b(ne,k,re,ye.value,B),ye!==null&&(e&&ye.alternate!==null&&ne.delete(ye.key===null?re:ye.key),w=a(ye,w,re),te===null?Z=ye:te.sibling=ye,te=ye);return e&&ne.forEach(function(Wm){return t(k,Wm)}),Re&&In(k,re),Z}function Ne(k,w,C,B){if(typeof C=="object"&&C!==null&&C.type===H&&C.key===null&&(C=C.props.children),typeof C=="object"&&C!==null){switch(C.$$typeof){case $:e:{for(var Z=C.key,te=w;te!==null;){if(te.key===Z){if(Z=C.type,Z===H){if(te.tag===7){n(k,te.sibling),w=l(te,C.props.children),w.return=k,k=w;break e}}else if(te.elementType===Z||typeof Z=="object"&&Z!==null&&Z.$$typeof===We&&xc(Z)===te.type){n(k,te.sibling),w=l(te,C.props),w.ref=Zr(k,te,C),w.return=k,k=w;break e}n(k,te);break}else t(k,te);te=te.sibling}C.type===H?(w=Mn(C.props.children,k.mode,B,C.key),w.return=k,k=w):(B=Ri(C.type,C.key,C.props,null,k.mode,B),B.ref=Zr(k,w,C),B.return=k,k=B)}return f(k);case T:e:{for(te=C.key;w!==null;){if(w.key===te)if(w.tag===4&&w.stateNode.containerInfo===C.containerInfo&&w.stateNode.implementation===C.implementation){n(k,w.sibling),w=l(w,C.children||[]),w.return=k,k=w;break e}else{n(k,w);break}else t(k,w);w=w.sibling}w=nu(C,k.mode,B),w.return=k,k=w}return f(k);case We:return te=C._init,Ne(k,w,te(C._payload),B)}if(jr(C))return Y(k,w,C,B);if(ee(C))return X(k,w,C,B);ni(k,C)}return typeof C=="string"&&C!==""||typeof C=="number"?(C=""+C,w!==null&&w.tag===6?(n(k,w.sibling),w=l(w,C),w.return=k,k=w):(n(k,w),w=tu(C,k.mode,B),w.return=k,k=w),f(k)):n(k,w)}return Ne}var cr=Sc(!0),kc=Sc(!1),ri=dn(null),oi=null,fr=null,fl=null;function dl(){fl=fr=oi=null}function pl(e){var t=ri.current;Ce(ri),e._currentValue=t}function hl(e,t,n){for(;e!==null;){var o=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,o!==null&&(o.childLanes|=t)):o!==null&&(o.childLanes&t)!==t&&(o.childLanes|=t),e===n)break;e=e.return}}function dr(e,t){oi=e,fl=fr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(rt=!0),e.firstContext=null)}function xt(e){var t=e._currentValue;if(fl!==e)if(e={context:e,memoizedValue:t,next:null},fr===null){if(oi===null)throw Error(s(308));fr=e,oi.dependencies={lanes:0,firstContext:e}}else fr=fr.next=e;return t}var _n=null;function ml(e){_n===null?_n=[e]:_n.push(e)}function Ec(e,t,n,o){var l=t.interleaved;return l===null?(n.next=n,ml(t)):(n.next=l.next,l.next=n),t.interleaved=n,Jt(e,o)}function Jt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var mn=!1;function gl(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Cc(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Zt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function gn(e,t,n){var o=e.updateQueue;if(o===null)return null;if(o=o.shared,me&2){var l=o.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),o.pending=t,Jt(e,n)}return l=o.interleaved,l===null?(t.next=t,ml(o)):(t.next=l.next,l.next=t),o.interleaved=t,Jt(e,n)}function ii(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var o=t.lanes;o&=e.pendingLanes,n|=o,t.lanes=n,_s(e,n)}}function Ac(e,t){var n=e.updateQueue,o=e.alternate;if(o!==null&&(o=o.updateQueue,n===o)){var l=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var f={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?l=a=f:a=a.next=f,n=n.next}while(n!==null);a===null?l=a=t:a=a.next=t}else l=a=t;n={baseState:o.baseState,firstBaseUpdate:l,lastBaseUpdate:a,shared:o.shared,effects:o.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function si(e,t,n,o){var l=e.updateQueue;mn=!1;var a=l.firstBaseUpdate,f=l.lastBaseUpdate,h=l.shared.pending;if(h!==null){l.shared.pending=null;var y=h,A=y.next;y.next=null,f===null?a=A:f.next=A,f=y;var M=e.alternate;M!==null&&(M=M.updateQueue,h=M.lastBaseUpdate,h!==f&&(h===null?M.firstBaseUpdate=A:h.next=A,M.lastBaseUpdate=y))}if(a!==null){var U=l.baseState;f=0,M=A=y=null,h=a;do{var z=h.lane,b=h.eventTime;if((o&z)===z){M!==null&&(M=M.next={eventTime:b,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,next:null});e:{var Y=e,X=h;switch(z=t,b=n,X.tag){case 1:if(Y=X.payload,typeof Y=="function"){U=Y.call(b,U,z);break e}U=Y;break e;case 3:Y.flags=Y.flags&-65537|128;case 0:if(Y=X.payload,z=typeof Y=="function"?Y.call(b,U,z):Y,z==null)break e;U=q({},U,z);break e;case 2:mn=!0}}h.callback!==null&&h.lane!==0&&(e.flags|=64,z=l.effects,z===null?l.effects=[h]:z.push(h))}else b={eventTime:b,lane:z,tag:h.tag,payload:h.payload,callback:h.callback,next:null},M===null?(A=M=b,y=U):M=M.next=b,f|=z;if(h=h.next,h===null){if(h=l.shared.pending,h===null)break;z=h,h=z.next,z.next=null,l.lastBaseUpdate=z,l.shared.pending=null}}while(!0);if(M===null&&(y=U),l.baseState=y,l.firstBaseUpdate=A,l.lastBaseUpdate=M,t=l.shared.interleaved,t!==null){l=t;do f|=l.lane,l=l.next;while(l!==t)}else a===null&&(l.shared.lanes=0);Tn|=f,e.lanes=f,e.memoizedState=U}}function Rc(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var o=Sl.transition;Sl.transition={};try{e(!1),t()}finally{xe=n,Sl.transition=o}}function Qc(){return St().memoizedState}function mm(e,t,n){var o=xn(e);if(n={lane:o,action:n,hasEagerState:!1,eagerState:null,next:null},qc(e))bc(t,n);else if(n=Ec(e,t,n,o),n!==null){var l=et();Tt(n,e,o,l),Gc(n,t,o)}}function gm(e,t,n){var o=xn(e),l={lane:o,action:n,hasEagerState:!1,eagerState:null,next:null};if(qc(e))bc(t,l);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var f=t.lastRenderedState,h=a(f,n);if(l.hasEagerState=!0,l.eagerState=h,jt(h,f)){var y=t.interleaved;y===null?(l.next=l,ml(t)):(l.next=y.next,y.next=l),t.interleaved=l;return}}catch{}finally{}n=Ec(e,t,l,o),n!==null&&(l=et(),Tt(n,e,o,l),Gc(n,t,o))}}function qc(e){var t=e.alternate;return e===je||t!==null&&t===je}function bc(e,t){ro=ai=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Gc(e,t,n){if(n&4194240){var o=t.lanes;o&=e.pendingLanes,n|=o,t.lanes=n,_s(e,n)}}var di={readContext:xt,useCallback:qe,useContext:qe,useEffect:qe,useImperativeHandle:qe,useInsertionEffect:qe,useLayoutEffect:qe,useMemo:qe,useReducer:qe,useRef:qe,useState:qe,useDebugValue:qe,useDeferredValue:qe,useTransition:qe,useMutableSource:qe,useSyncExternalStore:qe,useId:qe,unstable_isNewReconciler:!1},ym={readContext:xt,useCallback:function(e,t){return Bt().memoizedState=[e,t===void 0?null:t],e},useContext:xt,useEffect:Mc,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,ci(4194308,4,Bc.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ci(4194308,4,e,t)},useInsertionEffect:function(e,t){return ci(4,2,e,t)},useMemo:function(e,t){var n=Bt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var o=Bt();return t=n!==void 0?n(t):t,o.memoizedState=o.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},o.queue=e,e=e.dispatch=mm.bind(null,je,e),[o.memoizedState,e]},useRef:function(e){var t=Bt();return e={current:e},t.memoizedState=e},useState:Dc,useDebugValue:jl,useDeferredValue:function(e){return Bt().memoizedState=e},useTransition:function(){var e=Dc(!1),t=e[0];return e=hm.bind(null,e[1]),Bt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var o=je,l=Bt();if(Re){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),Me===null)throw Error(s(349));On&30||_c(o,t,n)}l.memoizedState=n;var a={value:n,getSnapshot:t};return l.queue=a,Mc(Oc.bind(null,o,a,e),[e]),o.flags|=2048,so(9,Nc.bind(null,o,a,n,t),void 0,null),n},useId:function(){var e=Bt(),t=Me.identifierPrefix;if(Re){var n=Xt,o=Kt;n=(o&~(1<<32-Pt(o)-1)).toString(32)+n,t=":"+t+"R"+n,n=oo++,0<\/script>",e=e.removeChild(e.firstChild)):typeof o.is=="string"?e=f.createElement(n,{is:o.is}):(e=f.createElement(n),n==="select"&&(f=e,o.multiple?f.multiple=!0:o.size&&(f.size=o.size))):e=f.createElementNS(e,n),e[Ut]=t,e[Xr]=o,mf(e,t,!1,!1),t.stateNode=e;e:{switch(f=xs(n,o),n){case"dialog":Ee("cancel",e),Ee("close",e),l=o;break;case"iframe":case"object":case"embed":Ee("load",e),l=o;break;case"video":case"audio":for(l=0;lyr&&(t.flags|=128,o=!0,lo(a,!1),t.lanes=4194304)}else{if(!o)if(e=li(f),e!==null){if(t.flags|=128,o=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),lo(a,!0),a.tail===null&&a.tailMode==="hidden"&&!f.alternate&&!Re)return be(t),null}else 2*_e()-a.renderingStartTime>yr&&n!==1073741824&&(t.flags|=128,o=!0,lo(a,!1),t.lanes=4194304);a.isBackwards?(f.sibling=t.child,t.child=f):(n=a.last,n!==null?n.sibling=f:t.child=f,a.last=f)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=_e(),t.sibling=null,n=Pe.current,ke(Pe,o?n&1|2:n&1),t):(be(t),null);case 22:case 23:return Jl(),o=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==o&&(t.flags|=8192),o&&t.mode&1?pt&1073741824&&(be(t),t.subtreeFlags&6&&(t.flags|=8192)):be(t),null;case 24:return null;case 25:return null}throw Error(s(156,t.tag))}function Am(e,t){switch(ll(t),t.tag){case 1:return nt(t.type)&&Ko(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return pr(),Ce(tt),Ce(Qe),xl(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return vl(t),null;case 13:if(Ce(Pe),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));ar()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Ce(Pe),null;case 4:return pr(),null;case 10:return pl(t.type._context),null;case 22:case 23:return Jl(),null;case 24:return null;default:return null}}var gi=!1,Ge=!1,Rm=typeof WeakSet=="function"?WeakSet:Set,G=null;function mr(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(o){Ie(e,t,o)}else n.current=null}function Bl(e,t,n){try{n()}catch(o){Ie(e,t,o)}}var vf=!1;function Pm(e,t){if(Js=zo,e=Ya(),Ws(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var o=n.getSelection&&n.getSelection();if(o&&o.rangeCount!==0){n=o.anchorNode;var l=o.anchorOffset,a=o.focusNode;o=o.focusOffset;try{n.nodeType,a.nodeType}catch{n=null;break e}var f=0,h=-1,y=-1,A=0,M=0,U=e,z=null;t:for(;;){for(var b;U!==n||l!==0&&U.nodeType!==3||(h=f+l),U!==a||o!==0&&U.nodeType!==3||(y=f+o),U.nodeType===3&&(f+=U.nodeValue.length),(b=U.firstChild)!==null;)z=U,U=b;for(;;){if(U===e)break t;if(z===n&&++A===l&&(h=f),z===a&&++M===o&&(y=f),(b=U.nextSibling)!==null)break;U=z,z=U.parentNode}U=b}n=h===-1||y===-1?null:{start:h,end:y}}else n=null}n=n||{start:0,end:0}}else n=null;for(Zs={focusedElem:e,selectionRange:n},zo=!1,G=t;G!==null;)if(t=G,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,G=e;else for(;G!==null;){t=G;try{var Y=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(Y!==null){var X=Y.memoizedProps,Ne=Y.memoizedState,k=t.stateNode,w=k.getSnapshotBeforeUpdate(t.elementType===t.type?X:_t(t.type,X),Ne);k.__reactInternalSnapshotBeforeUpdate=w}break;case 3:var C=t.stateNode.containerInfo;C.nodeType===1?C.textContent="":C.nodeType===9&&C.documentElement&&C.removeChild(C.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(s(163))}}catch(B){Ie(t,t.return,B)}if(e=t.sibling,e!==null){e.return=t.return,G=e;break}G=t.return}return Y=vf,vf=!1,Y}function uo(e,t,n){var o=t.updateQueue;if(o=o!==null?o.lastEffect:null,o!==null){var l=o=o.next;do{if((l.tag&e)===e){var a=l.destroy;l.destroy=void 0,a!==void 0&&Bl(t,n,a)}l=l.next}while(l!==o)}}function yi(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var o=n.create;n.destroy=o()}n=n.next}while(n!==t)}}function $l(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function wf(e){var t=e.alternate;t!==null&&(e.alternate=null,wf(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ut],delete t[Xr],delete t[rl],delete t[am],delete t[cm])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function xf(e){return e.tag===5||e.tag===3||e.tag===4}function Sf(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||xf(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Hl(e,t,n){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Go));else if(o!==4&&(e=e.child,e!==null))for(Hl(e,t,n),e=e.sibling;e!==null;)Hl(e,t,n),e=e.sibling}function Vl(e,t,n){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(o!==4&&(e=e.child,e!==null))for(Vl(e,t,n),e=e.sibling;e!==null;)Vl(e,t,n),e=e.sibling}var $e=null,Nt=!1;function yn(e,t,n){for(n=n.child;n!==null;)kf(e,t,n),n=n.sibling}function kf(e,t,n){if(Mt&&typeof Mt.onCommitFiberUnmount=="function")try{Mt.onCommitFiberUnmount(_o,n)}catch{}switch(n.tag){case 5:Ge||mr(n,t);case 6:var o=$e,l=Nt;$e=null,yn(e,t,n),$e=o,Nt=l,$e!==null&&(Nt?(e=$e,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):$e.removeChild(n.stateNode));break;case 18:$e!==null&&(Nt?(e=$e,n=n.stateNode,e.nodeType===8?nl(e.parentNode,n):e.nodeType===1&&nl(e,n),Br(e)):nl($e,n.stateNode));break;case 4:o=$e,l=Nt,$e=n.stateNode.containerInfo,Nt=!0,yn(e,t,n),$e=o,Nt=l;break;case 0:case 11:case 14:case 15:if(!Ge&&(o=n.updateQueue,o!==null&&(o=o.lastEffect,o!==null))){l=o=o.next;do{var a=l,f=a.destroy;a=a.tag,f!==void 0&&(a&2||a&4)&&Bl(n,t,f),l=l.next}while(l!==o)}yn(e,t,n);break;case 1:if(!Ge&&(mr(n,t),o=n.stateNode,typeof o.componentWillUnmount=="function"))try{o.props=n.memoizedProps,o.state=n.memoizedState,o.componentWillUnmount()}catch(h){Ie(n,t,h)}yn(e,t,n);break;case 21:yn(e,t,n);break;case 22:n.mode&1?(Ge=(o=Ge)||n.memoizedState!==null,yn(e,t,n),Ge=o):yn(e,t,n);break;default:yn(e,t,n)}}function Ef(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Rm),t.forEach(function(o){var l=zm.bind(null,e,o);n.has(o)||(n.add(o),o.then(l,l))})}}function Ot(e,t){var n=t.deletions;if(n!==null)for(var o=0;ol&&(l=f),o&=~a}if(o=l,o=_e()-o,o=(120>o?120:480>o?480:1080>o?1080:1920>o?1920:3e3>o?3e3:4320>o?4320:1960*Im(o/1960))-o,10e?16:e,wn===null)var o=!1;else{if(e=wn,wn=null,ki=0,me&6)throw Error(s(331));var l=me;for(me|=4,G=e.current;G!==null;){var a=G,f=a.child;if(G.flags&16){var h=a.deletions;if(h!==null){for(var y=0;y_e()-ql?Dn(e,0):Ql|=n),it(e,t)}function zf(e,t){t===0&&(e.mode&1?(t=Oo,Oo<<=1,!(Oo&130023424)&&(Oo=4194304)):t=1);var n=et();e=Jt(e,t),e!==null&&(Dr(e,t,n),it(e,n))}function Dm(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),zf(e,n)}function zm(e,t){var n=0;switch(e.tag){case 13:var o=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:o=e.stateNode;break;default:throw Error(s(314))}o!==null&&o.delete(t),zf(e,n)}var Mf;Mf=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||tt.current)rt=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return rt=!1,Em(e,t,n);rt=!!(e.flags&131072)}else rt=!1,Re&&t.flags&1048576&&mc(t,ei,t.index);switch(t.lanes=0,t.tag){case 2:var o=t.type;mi(e,t),e=t.pendingProps;var l=sr(t,Qe.current);dr(t,n),l=El(null,t,o,e,l,n);var a=Cl();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,nt(o)?(a=!0,Xo(t)):a=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,gl(t),l.updater=pi,t.stateNode=l,l._reactInternals=t,_l(t,o,e,n),t=Ll(null,t,o,!0,a,n)):(t.tag=0,Re&&a&&sl(t),Ze(null,t,l,n),t=t.child),t;case 16:o=t.elementType;e:{switch(mi(e,t),e=t.pendingProps,l=o._init,o=l(o._payload),t.type=o,l=t.tag=Um(o),e=_t(o,e),l){case 0:t=Tl(null,t,o,e,n);break e;case 1:t=af(null,t,o,e,n);break e;case 11:t=rf(null,t,o,e,n);break e;case 14:t=of(null,t,o,_t(o.type,e),n);break e}throw Error(s(306,o,""))}return t;case 0:return o=t.type,l=t.pendingProps,l=t.elementType===o?l:_t(o,l),Tl(e,t,o,l,n);case 1:return o=t.type,l=t.pendingProps,l=t.elementType===o?l:_t(o,l),af(e,t,o,l,n);case 3:e:{if(cf(t),e===null)throw Error(s(387));o=t.pendingProps,a=t.memoizedState,l=a.element,Cc(e,t),si(t,o,null,n);var f=t.memoizedState;if(o=f.element,a.isDehydrated)if(a={element:o,isDehydrated:!1,cache:f.cache,pendingSuspenseBoundaries:f.pendingSuspenseBoundaries,transitions:f.transitions},t.updateQueue.baseState=a,t.memoizedState=a,t.flags&256){l=hr(Error(s(423)),t),t=ff(e,t,o,n,l);break e}else if(o!==l){l=hr(Error(s(424)),t),t=ff(e,t,o,n,l);break e}else for(dt=fn(t.stateNode.containerInfo.firstChild),ft=t,Re=!0,It=null,n=kc(t,null,o,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(ar(),o===l){t=en(e,t,n);break e}Ze(e,t,o,n)}t=t.child}return t;case 5:return Pc(t),e===null&&al(t),o=t.type,l=t.pendingProps,a=e!==null?e.memoizedProps:null,f=l.children,el(o,l)?f=null:a!==null&&el(o,a)&&(t.flags|=32),uf(e,t),Ze(e,t,f,n),t.child;case 6:return e===null&&al(t),null;case 13:return df(e,t,n);case 4:return yl(t,t.stateNode.containerInfo),o=t.pendingProps,e===null?t.child=cr(t,null,o,n):Ze(e,t,o,n),t.child;case 11:return o=t.type,l=t.pendingProps,l=t.elementType===o?l:_t(o,l),rf(e,t,o,l,n);case 7:return Ze(e,t,t.pendingProps,n),t.child;case 8:return Ze(e,t,t.pendingProps.children,n),t.child;case 12:return Ze(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(o=t.type._context,l=t.pendingProps,a=t.memoizedProps,f=l.value,ke(ri,o._currentValue),o._currentValue=f,a!==null)if(jt(a.value,f)){if(a.children===l.children&&!tt.current){t=en(e,t,n);break e}}else for(a=t.child,a!==null&&(a.return=t);a!==null;){var h=a.dependencies;if(h!==null){f=a.child;for(var y=h.firstContext;y!==null;){if(y.context===o){if(a.tag===1){y=Zt(-1,n&-n),y.tag=2;var A=a.updateQueue;if(A!==null){A=A.shared;var M=A.pending;M===null?y.next=y:(y.next=M.next,M.next=y),A.pending=y}}a.lanes|=n,y=a.alternate,y!==null&&(y.lanes|=n),hl(a.return,n,t),h.lanes|=n;break}y=y.next}}else if(a.tag===10)f=a.type===t.type?null:a.child;else if(a.tag===18){if(f=a.return,f===null)throw Error(s(341));f.lanes|=n,h=f.alternate,h!==null&&(h.lanes|=n),hl(f,n,t),f=a.sibling}else f=a.child;if(f!==null)f.return=a;else for(f=a;f!==null;){if(f===t){f=null;break}if(a=f.sibling,a!==null){a.return=f.return,f=a;break}f=f.return}a=f}Ze(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,o=t.pendingProps.children,dr(t,n),l=xt(l),o=o(l),t.flags|=1,Ze(e,t,o,n),t.child;case 14:return o=t.type,l=_t(o,t.pendingProps),l=_t(o.type,l),of(e,t,o,l,n);case 15:return sf(e,t,t.type,t.pendingProps,n);case 17:return o=t.type,l=t.pendingProps,l=t.elementType===o?l:_t(o,l),mi(e,t),t.tag=1,nt(o)?(e=!0,Xo(t)):e=!1,dr(t,n),Kc(t,o,l),_l(t,o,l,n),Ll(null,t,o,!0,e,n);case 19:return hf(e,t,n);case 22:return lf(e,t,n)}throw Error(s(156,t.tag))};function Uf(e,t){return ga(e,t)}function Mm(e,t,n,o){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=o,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Et(e,t,n,o){return new Mm(e,t,n,o)}function eu(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Um(e){if(typeof e=="function")return eu(e)?1:0;if(e!=null){if(e=e.$$typeof,e===gt)return 11;if(e===yt)return 14}return 2}function kn(e,t){var n=e.alternate;return n===null?(n=Et(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Ri(e,t,n,o,l,a){var f=2;if(o=e,typeof e=="function")eu(e)&&(f=1);else if(typeof e=="string")f=5;else e:switch(e){case H:return Mn(n.children,l,a,t);case se:f=8,l|=8;break;case Ve:return e=Et(12,n,t,l|2),e.elementType=Ve,e.lanes=a,e;case Je:return e=Et(13,n,t,l),e.elementType=Je,e.lanes=a,e;case at:return e=Et(19,n,t,l),e.elementType=at,e.lanes=a,e;case Se:return Pi(n,l,a,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case At:f=10;break e;case qt:f=9;break e;case gt:f=11;break e;case yt:f=14;break e;case We:f=16,o=null;break e}throw Error(s(130,e==null?e:typeof e,""))}return t=Et(f,n,t,l),t.elementType=e,t.type=o,t.lanes=a,t}function Mn(e,t,n,o){return e=Et(7,e,o,t),e.lanes=n,e}function Pi(e,t,n,o){return e=Et(22,e,o,t),e.elementType=Se,e.lanes=n,e.stateNode={isHidden:!1},e}function tu(e,t,n){return e=Et(6,e,null,t),e.lanes=n,e}function nu(e,t,n){return t=Et(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Fm(e,t,n,o,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Is(0),this.expirationTimes=Is(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Is(0),this.identifierPrefix=o,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function ru(e,t,n,o,l,a,f,h,y){return e=new Fm(e,t,n,h,y),t===1?(t=1,a===!0&&(t|=8)):t=0,a=Et(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:o,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},gl(a),e}function Bm(e,t,n){var o=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(i){console.error(i)}}return r(),au.exports=Xm(),au.exports}var Jf;function Zm(){if(Jf)return Li;Jf=1;var r=Jm();return Li.createRoot=r.createRoot,Li.hydrateRoot=r.hydrateRoot,Li}var eg=Zm(),Ke=function(){return Ke=Object.assign||function(i){for(var s,u=1,c=arguments.length;u0?Fe(Ar,--Ct):0,kr--,Te===10&&(kr=1,rs--),Te}function Lt(){return Te=Ct2||Au(Te)>3?"":" "}function cg(r,i){for(;--i&&Lt()&&!(Te<48||Te>102||Te>57&&Te<65||Te>70&&Te<97););return is(r,Hi()+(i<6&&$n()==32&&Lt()==32))}function Ru(r){for(;Lt();)switch(Te){case r:return Ct;case 34:case 39:r!==34&&r!==39&&Ru(Te);break;case 40:r===41&&Ru(r);break;case 92:Lt();break}return Ct}function fg(r,i){for(;Lt()&&r+Te!==57;)if(r+Te===84&&$n()===47)break;return"/*"+is(i,Ct-1)+"*"+Hu(r===47?r:Lt())}function dg(r){for(;!Au($n());)Lt();return is(r,Ct)}function pg(r){return ug(Vi("",null,null,null,[""],r=lg(r),0,[0],r))}function Vi(r,i,s,u,c,d,p,m,v){for(var x=0,E=0,j=p,O=0,P=0,I=0,R=1,L=1,V=1,F=0,W="",K=c,$=d,T=u,H=W;L;)switch(I=F,F=Lt()){case 40:if(I!=108&&Fe(H,j-1)==58){$i(H+=ae(du(F),"&","&\f"),"&\f",Kd(x?m[x-1]:0))!=-1&&(V=-1);break}case 34:case 39:case 91:H+=du(F);break;case 9:case 10:case 13:case 32:H+=ag(I);break;case 92:H+=cg(Hi()-1,7);continue;case 47:switch($n()){case 42:case 47:go(hg(fg(Lt(),Hi()),i,s,v),v);break;default:H+="/"}break;case 123*R:m[x++]=Vt(H)*V;case 125*R:case 59:case 0:switch(F){case 0:case 125:L=0;case 59+E:V==-1&&(H=ae(H,/\f/g,"")),P>0&&Vt(H)-j&&go(P>32?td(H+";",u,s,j-1,v):td(ae(H," ","")+";",u,s,j-2,v),v);break;case 59:H+=";";default:if(go(T=ed(H,i,s,x,E,c,m,W,K=[],$=[],j,d),d),F===123)if(E===0)Vi(H,i,T,T,K,d,j,m,$);else switch(O===99&&Fe(H,3)===110?100:O){case 100:case 108:case 109:case 115:Vi(r,T,T,u&&go(ed(r,T,T,0,0,c,m,W,c,K=[],j,$),$),c,$,j,m,u?K:$);break;default:Vi(H,T,T,T,[""],$,0,m,$)}}x=E=P=0,R=V=1,W=H="",j=p;break;case 58:j=1+Vt(H),P=I;default:if(R<1){if(F==123)--R;else if(F==125&&R++==0&&sg()==125)continue}switch(H+=Hu(F),F*R){case 38:V=E>0?1:(H+="\f",-1);break;case 44:m[x++]=(Vt(H)-1)*V,V=1;break;case 64:$n()===45&&(H+=du(Lt())),O=$n(),E=j=Vt(W=H+=dg(Hi())),F++;break;case 45:I===45&&Vt(H)==2&&(R=0)}}return d}function ed(r,i,s,u,c,d,p,m,v,x,E,j){for(var O=c-1,P=c===0?d:[""],I=Jd(P),R=0,L=0,V=0;R0?P[F]+" "+W:ae(W,/&\f/g,P[F])))&&(v[V++]=K);return os(r,i,s,c===0?ns:m,v,x,E,j)}function hg(r,i,s,u){return os(r,i,s,Gd,Hu(ig()),Sr(r,2,-2),0,u)}function td(r,i,s,u,c){return os(r,i,s,$u,Sr(r,0,u),Sr(r,u+1,-1),u,c)}function ep(r,i,s){switch(rg(r,i)){case 5103:return we+"print-"+r+r;case 5737:case 4201:case 3177:case 3433:case 1641:case 4457:case 2921:case 5572:case 6356:case 5844:case 3191:case 6645:case 3005:case 6391:case 5879:case 5623:case 6135:case 4599:case 4855:case 4215:case 6389:case 5109:case 5365:case 5621:case 3829:return we+r+r;case 4789:return wo+r+r;case 5349:case 4246:case 4810:case 6968:case 2756:return we+r+wo+r+Ae+r+r;case 5936:switch(Fe(r,i+11)){case 114:return we+r+Ae+ae(r,/[svh]\w+-[tblr]{2}/,"tb")+r;case 108:return we+r+Ae+ae(r,/[svh]\w+-[tblr]{2}/,"tb-rl")+r;case 45:return we+r+Ae+ae(r,/[svh]\w+-[tblr]{2}/,"lr")+r}case 6828:case 4268:case 2903:return we+r+Ae+r+r;case 6165:return we+r+Ae+"flex-"+r+r;case 5187:return we+r+ae(r,/(\w+).+(:[^]+)/,we+"box-$1$2"+Ae+"flex-$1$2")+r;case 5443:return we+r+Ae+"flex-item-"+ae(r,/flex-|-self/g,"")+(nn(r,/flex-|baseline/)?"":Ae+"grid-row-"+ae(r,/flex-|-self/g,""))+r;case 4675:return we+r+Ae+"flex-line-pack"+ae(r,/align-content|flex-|-self/g,"")+r;case 5548:return we+r+Ae+ae(r,"shrink","negative")+r;case 5292:return we+r+Ae+ae(r,"basis","preferred-size")+r;case 6060:return we+"box-"+ae(r,"-grow","")+we+r+Ae+ae(r,"grow","positive")+r;case 4554:return we+ae(r,/([^-])(transform)/g,"$1"+we+"$2")+r;case 6187:return ae(ae(ae(r,/(zoom-|grab)/,we+"$1"),/(image-set)/,we+"$1"),r,"")+r;case 5495:case 3959:return ae(r,/(image-set\([^]*)/,we+"$1$`$1");case 4968:return ae(ae(r,/(.+:)(flex-)?(.*)/,we+"box-pack:$3"+Ae+"flex-pack:$3"),/s.+-b[^;]+/,"justify")+we+r+r;case 4200:if(!nn(r,/flex-|baseline/))return Ae+"grid-column-align"+Sr(r,i)+r;break;case 2592:case 3360:return Ae+ae(r,"template-","")+r;case 4384:case 3616:return s&&s.some(function(u,c){return i=c,nn(u.props,/grid-\w+-end/)})?~$i(r+(s=s[i].value),"span",0)?r:Ae+ae(r,"-start","")+r+Ae+"grid-row-span:"+(~$i(s,"span",0)?nn(s,/\d+/):+nn(s,/\d+/)-+nn(r,/\d+/))+";":Ae+ae(r,"-start","")+r;case 4896:case 4128:return s&&s.some(function(u){return nn(u.props,/grid-\w+-start/)})?r:Ae+ae(ae(r,"-end","-span"),"span ","")+r;case 4095:case 3583:case 4068:case 2532:return ae(r,/(.+)-inline(.+)/,we+"$1$2")+r;case 8116:case 7059:case 5753:case 5535:case 5445:case 5701:case 4933:case 4677:case 5533:case 5789:case 5021:case 4765:if(Vt(r)-1-i>6)switch(Fe(r,i+1)){case 109:if(Fe(r,i+4)!==45)break;case 102:return ae(r,/(.+:)(.+)-([^]+)/,"$1"+we+"$2-$3$1"+wo+(Fe(r,i+3)==108?"$3":"$2-$3"))+r;case 115:return~$i(r,"stretch",0)?ep(ae(r,"stretch","fill-available"),i,s)+r:r}break;case 5152:case 5920:return ae(r,/(.+?):(\d+)(\s*\/\s*(span)?\s*(\d+))?(.*)/,function(u,c,d,p,m,v,x){return Ae+c+":"+d+x+(p?Ae+c+"-span:"+(m?v:+v-+d)+x:"")+r});case 4949:if(Fe(r,i+6)===121)return ae(r,":",":"+we)+r;break;case 6444:switch(Fe(r,Fe(r,14)===45?18:11)){case 120:return ae(r,/(.+:)([^;\s!]+)(;|(\s+)?!.+)?/,"$1"+we+(Fe(r,14)===45?"inline-":"")+"box$3$1"+we+"$2$3$1"+Ae+"$2box$3")+r;case 100:return ae(r,":",":"+Ae)+r}break;case 5719:case 2647:case 2135:case 3927:case 2391:return ae(r,"scroll-","scroll-snap-")+r}return r}function Ki(r,i){for(var s="",u=0;u-1&&!r.return)switch(r.type){case $u:r.return=ep(r.value,r.length,s);return;case Yd:return Ki([Cn(r,{value:ae(r.value,"@","@"+we)})],u);case ns:if(r.length)return og(s=r.props,function(c){switch(nn(c,u=/(::plac\w+|:read-\w+)/)){case":read-only":case":read-write":wr(Cn(r,{props:[ae(c,/:(read-\w+)/,":"+wo+"$1")]})),wr(Cn(r,{props:[c]})),Cu(r,{props:Zf(s,u)});break;case"::placeholder":wr(Cn(r,{props:[ae(c,/:(plac\w+)/,":"+we+"input-$1")]})),wr(Cn(r,{props:[ae(c,/:(plac\w+)/,":"+wo+"$1")]})),wr(Cn(r,{props:[ae(c,/:(plac\w+)/,Ae+"input-$1")]})),wr(Cn(r,{props:[c]})),Cu(r,{props:Zf(s,u)});break}return""})}}var wg={animationIterationCount:1,aspectRatio:1,borderImageOutset:1,borderImageSlice:1,borderImageWidth:1,boxFlex:1,boxFlexGroup:1,boxOrdinalGroup:1,columnCount:1,columns:1,flex:1,flexGrow:1,flexPositive:1,flexShrink:1,flexNegative:1,flexOrder:1,gridRow:1,gridRowEnd:1,gridRowSpan:1,gridRowStart:1,gridColumn:1,gridColumnEnd:1,gridColumnSpan:1,gridColumnStart:1,msGridRow:1,msGridRowSpan:1,msGridColumn:1,msGridColumnSpan:1,fontWeight:1,lineHeight:1,opacity:1,order:1,orphans:1,tabSize:1,widows:1,zIndex:1,zoom:1,WebkitLineClamp:1,fillOpacity:1,floodOpacity:1,stopOpacity:1,strokeDasharray:1,strokeDashoffset:1,strokeMiterlimit:1,strokeOpacity:1,strokeWidth:1},ht={},Er=typeof process<"u"&&ht!==void 0&&(ht.REACT_APP_SC_ATTR||ht.SC_ATTR)||"data-styled",tp="active",np="data-styled-version",ss="6.1.14",Vu=`/*!sc*/ +`,Xi=typeof window<"u"&&"HTMLElement"in window,xg=!!(typeof SC_DISABLE_SPEEDY=="boolean"?SC_DISABLE_SPEEDY:typeof process<"u"&&ht!==void 0&&ht.REACT_APP_SC_DISABLE_SPEEDY!==void 0&&ht.REACT_APP_SC_DISABLE_SPEEDY!==""?ht.REACT_APP_SC_DISABLE_SPEEDY!=="false"&&ht.REACT_APP_SC_DISABLE_SPEEDY:typeof process<"u"&&ht!==void 0&&ht.SC_DISABLE_SPEEDY!==void 0&&ht.SC_DISABLE_SPEEDY!==""&&ht.SC_DISABLE_SPEEDY!=="false"&&ht.SC_DISABLE_SPEEDY),ls=Object.freeze([]),Cr=Object.freeze({});function Sg(r,i,s){return s===void 0&&(s=Cr),r.theme!==s.theme&&r.theme||i||s.theme}var rp=new Set(["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","big","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","keygen","label","legend","li","link","main","map","mark","menu","menuitem","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","section","select","small","source","span","strong","style","sub","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","track","u","ul","use","var","video","wbr","circle","clipPath","defs","ellipse","foreignObject","g","image","line","linearGradient","marker","mask","path","pattern","polygon","polyline","radialGradient","rect","stop","svg","text","tspan"]),kg=/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~-]+/g,Eg=/(^-|-$)/g;function nd(r){return r.replace(kg,"-").replace(Eg,"")}var Cg=/(a)(d)/gi,Di=52,rd=function(r){return String.fromCharCode(r+(r>25?39:97))};function Pu(r){var i,s="";for(i=Math.abs(r);i>Di;i=i/Di|0)s=rd(i%Di)+s;return(rd(i%Di)+s).replace(Cg,"$1-$2")}var pu,op=5381,xr=function(r,i){for(var s=i.length;s;)r=33*r^i.charCodeAt(--s);return r},ip=function(r){return xr(op,r)};function Ag(r){return Pu(ip(r)>>>0)}function Rg(r){return r.displayName||r.name||"Component"}function hu(r){return typeof r=="string"&&!0}var sp=typeof Symbol=="function"&&Symbol.for,lp=sp?Symbol.for("react.memo"):60115,Pg=sp?Symbol.for("react.forward_ref"):60112,jg={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},Ig={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},up={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},_g=((pu={})[Pg]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},pu[lp]=up,pu);function od(r){return("type"in(i=r)&&i.type.$$typeof)===lp?up:"$$typeof"in r?_g[r.$$typeof]:jg;var i}var Ng=Object.defineProperty,Og=Object.getOwnPropertyNames,id=Object.getOwnPropertySymbols,Tg=Object.getOwnPropertyDescriptor,Lg=Object.getPrototypeOf,sd=Object.prototype;function ap(r,i,s){if(typeof i!="string"){if(sd){var u=Lg(i);u&&u!==sd&&ap(r,u,s)}var c=Og(i);id&&(c=c.concat(id(i)));for(var d=od(r),p=od(i),m=0;m0?" Args: ".concat(i.join(", ")):""))}var Dg=function(){function r(i){this.groupSizes=new Uint32Array(512),this.length=512,this.tag=i}return r.prototype.indexOfGroup=function(i){for(var s=0,u=0;u=this.groupSizes.length){for(var u=this.groupSizes,c=u.length,d=c;i>=d;)if((d<<=1)<0)throw Qn(16,"".concat(i));this.groupSizes=new Uint32Array(d),this.groupSizes.set(u),this.length=d;for(var p=c;p=this.length||this.groupSizes[i]===0)return s;for(var u=this.groupSizes[i],c=this.indexOfGroup(i),d=c+u,p=c;p=0){var u=document.createTextNode(s);return this.element.insertBefore(u,this.nodes[i]||null),this.length++,!0}return!1},r.prototype.deleteRule=function(i){this.element.removeChild(this.nodes[i]),this.length--},r.prototype.getRule=function(i){return i0&&(L+="".concat(V,","))}),v+="".concat(I).concat(R,'{content:"').concat(L,'"}').concat(Vu)},E=0;E0?".".concat(i):O},E=v.slice();E.push(function(O){O.type===ns&&O.value.includes("&")&&(O.props[0]=O.props[0].replace(qg,s).replace(u,x))}),p.prefix&&E.push(vg),E.push(mg);var j=function(O,P,I,R){P===void 0&&(P=""),I===void 0&&(I=""),R===void 0&&(R="&"),i=R,s=P,u=new RegExp("\\".concat(s,"\\b"),"g");var L=O.replace(bg,""),V=pg(I||P?"".concat(I," ").concat(P," { ").concat(L," }"):L);p.namespace&&(V=dp(V,p.namespace));var F=[];return Ki(V,gg(E.concat(yg(function(W){return F.push(W)})))),F};return j.hash=v.length?v.reduce(function(O,P){return P.name||Qn(15),xr(O,P.name)},op).toString():"",j}var Yg=new fp,Iu=Gg(),pp=rn.createContext({shouldForwardProp:void 0,styleSheet:Yg,stylis:Iu});pp.Consumer;rn.createContext(void 0);function cd(){return ue.useContext(pp)}var Kg=function(){function r(i,s){var u=this;this.inject=function(c,d){d===void 0&&(d=Iu);var p=u.name+d.hash;c.hasNameForId(u.id,p)||c.insertRules(u.id,p,d(u.rules,p,"@keyframes"))},this.name=i,this.id="sc-keyframes-".concat(i),this.rules=s,Qu(this,function(){throw Qn(12,String(u.name))})}return r.prototype.getName=function(i){return i===void 0&&(i=Iu),this.name+i.hash},r}(),Xg=function(r){return r>="A"&&r<="Z"};function fd(r){for(var i="",s=0;s>>0);if(!s.hasNameForId(this.componentId,p)){var m=u(d,".".concat(p),void 0,this.componentId);s.insertRules(this.componentId,p,m)}c=Un(c,p),this.staticRulesId=p}else{for(var v=xr(this.baseHash,u.hash),x="",E=0;E>>0);s.hasNameForId(this.componentId,P)||s.insertRules(this.componentId,P,u(x,".".concat(P),void 0,this.componentId)),c=Un(c,P)}}return c},r}(),Zi=rn.createContext(void 0);Zi.Consumer;function ty(r){var i=rn.useContext(Zi),s=ue.useMemo(function(){return function(u,c){if(!u)throw Qn(14);if(Wn(u)){var d=u(c);return d}if(Array.isArray(u)||typeof u!="object")throw Qn(8);return c?Ke(Ke({},c),u):u}(r.theme,i)},[r.theme,i]);return r.children?rn.createElement(Zi.Provider,{value:s},r.children):null}var mu={};function ny(r,i,s){var u=Wu(r),c=r,d=!hu(r),p=i.attrs,m=p===void 0?ls:p,v=i.componentId,x=v===void 0?function(K,$){var T=typeof K!="string"?"sc":nd(K);mu[T]=(mu[T]||0)+1;var H="".concat(T,"-").concat(Ag(ss+T+mu[T]));return $?"".concat($,"-").concat(H):H}(i.displayName,i.parentComponentId):v,E=i.displayName,j=E===void 0?function(K){return hu(K)?"styled.".concat(K):"Styled(".concat(Rg(K),")")}(r):E,O=i.displayName&&i.componentId?"".concat(nd(i.displayName),"-").concat(i.componentId):i.componentId||x,P=u&&c.attrs?c.attrs.concat(m).filter(Boolean):m,I=i.shouldForwardProp;if(u&&c.shouldForwardProp){var R=c.shouldForwardProp;if(i.shouldForwardProp){var L=i.shouldForwardProp;I=function(K,$){return R(K,$)&&L(K,$)}}else I=R}var V=new ey(s,O,u?c.componentStyle:void 0);function F(K,$){return function(T,H,se){var Ve=T.attrs,At=T.componentStyle,qt=T.defaultProps,gt=T.foldedComponentIds,Je=T.styledComponentId,at=T.target,yt=rn.useContext(Zi),We=cd(),Se=T.shouldForwardProp||We.shouldForwardProp,Q=Sg(H,yt,qt)||Cr,ee=function(de,ce,ve){for(var pe,ge=Ke(Ke({},ce),{className:void 0,theme:ve}),Be=0;Ber.$hasUnread?r.theme.colors.text.primary:r.theme.colors.text.muted}; + font-weight: ${r=>r.$hasUnread?"600":"normal"}; + cursor: pointer; + background: ${r=>r.$isActive?r.theme.colors.background.hover:"transparent"}; + border-radius: 4px; + + &:hover { + background: ${r=>r.theme.colors.background.hover}; + color: ${r=>r.theme.colors.text.primary}; + } +`,hd=N.div` + margin-bottom: 8px; +`,Nu=N.div` + padding: 8px 16px; + display: flex; + align-items: center; + color: ${J.colors.text.muted}; + text-transform: uppercase; + font-size: 12px; + font-weight: 600; + cursor: pointer; + user-select: none; + + & > span:nth-child(2) { + flex: 1; + margin-right: auto; + } + + &:hover { + color: ${J.colors.text.primary}; + } +`,md=N.span` + margin-right: 4px; + font-size: 10px; + transition: transform 0.2s; + transform: rotate(${r=>r.$folded?"-90deg":"0deg"}); +`,gd=N.div` + display: ${r=>r.$folded?"none":"block"}; +`,yd=N(yp)` + height: ${r=>r.hasSubtext?"42px":"34px"}; +`,ly=N.div` + position: relative; + width: 32px; + height: 32px; + margin: 0 8px; + flex-shrink: 0; + min-width: 40px; + + img { + width: 32px; + height: 32px; + border-radius: 50%; + } +`,vd=N.div` + font-size: 16px; + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${r=>r.$isActive||r.$hasUnread?r.theme.colors.text.primary:r.theme.colors.text.muted}; + font-weight: ${r=>r.$hasUnread?"600":"normal"}; +`,vp=N.div` + position: absolute; + bottom: 0; + right: 0; + width: 10px; + height: 10px; + border-radius: 50%; + background: ${r=>r.$online?J.colors.status.online:J.colors.status.offline}; + border: 2px solid ${J.colors.background.secondary}; + transform: translate(20%, 20%); +`;N(vp)` + border-color: ${J.colors.background.primary}; +`;const wd=N.button` + background: none; + border: none; + color: ${J.colors.text.muted}; + font-size: 18px; + padding: 0; + cursor: pointer; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s, color 0.2s; + + ${Nu}:hover & { + opacity: 1; + } + + &:hover { + color: ${J.colors.text.primary}; + } +`,uy=N.div` + width: 40px; + min-width: 40px; + height: 24px; + margin: 0 8px; + flex-shrink: 0; + position: relative; +`,ay=N.div` + font-size: 12px; + line-height: 13px; + color: ${J.colors.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,xd=N.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; +`,cy=N.img` + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid ${J.colors.background.secondary}; + position: absolute; +`;function fy(){return g.jsx(sy,{children:"채널 목록"})}const Sd=r=>{let i;const s=new Set,u=(x,E)=>{const j=typeof x=="function"?x(i):x;if(!Object.is(j,i)){const O=i;i=E??(typeof j!="object"||j===null)?j:Object.assign({},i,j),s.forEach(P=>P(i,O))}},c=()=>i,m={setState:u,getState:c,getInitialState:()=>v,subscribe:x=>(s.add(x),()=>s.delete(x))},v=i=r(u,c,m);return m},dy=r=>r?Sd(r):Sd,py=r=>r;function hy(r,i=py){const s=rn.useSyncExternalStore(r.subscribe,()=>i(r.getState()),()=>i(r.getInitialState()));return rn.useDebugValue(s),s}const kd=r=>{const i=dy(r),s=u=>hy(i,u);return Object.assign(s,i),s},bn=r=>r?kd(r):kd;function wp(r,i){return function(){return r.apply(i,arguments)}}const{toString:my}=Object.prototype,{getPrototypeOf:qu}=Object,us=(r=>i=>{const s=my.call(i);return r[s]||(r[s]=s.slice(8,-1).toLowerCase())})(Object.create(null)),zt=r=>(r=r.toLowerCase(),i=>us(i)===r),as=r=>i=>typeof i===r,{isArray:Rr}=Array,ko=as("undefined");function gy(r){return r!==null&&!ko(r)&&r.constructor!==null&&!ko(r.constructor)&&mt(r.constructor.isBuffer)&&r.constructor.isBuffer(r)}const xp=zt("ArrayBuffer");function yy(r){let i;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?i=ArrayBuffer.isView(r):i=r&&r.buffer&&xp(r.buffer),i}const vy=as("string"),mt=as("function"),Sp=as("number"),cs=r=>r!==null&&typeof r=="object",wy=r=>r===!0||r===!1,qi=r=>{if(us(r)!=="object")return!1;const i=qu(r);return(i===null||i===Object.prototype||Object.getPrototypeOf(i)===null)&&!(Symbol.toStringTag in r)&&!(Symbol.iterator in r)},xy=zt("Date"),Sy=zt("File"),ky=zt("Blob"),Ey=zt("FileList"),Cy=r=>cs(r)&&mt(r.pipe),Ay=r=>{let i;return r&&(typeof FormData=="function"&&r instanceof FormData||mt(r.append)&&((i=us(r))==="formdata"||i==="object"&&mt(r.toString)&&r.toString()==="[object FormData]"))},Ry=zt("URLSearchParams"),[Py,jy,Iy,_y]=["ReadableStream","Request","Response","Headers"].map(zt),Ny=r=>r.trim?r.trim():r.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function Eo(r,i,{allOwnKeys:s=!1}={}){if(r===null||typeof r>"u")return;let u,c;if(typeof r!="object"&&(r=[r]),Rr(r))for(u=0,c=r.length;u0;)if(c=s[u],i===c.toLowerCase())return c;return null}const Fn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,Ep=r=>!ko(r)&&r!==Fn;function Ou(){const{caseless:r}=Ep(this)&&this||{},i={},s=(u,c)=>{const d=r&&kp(i,c)||c;qi(i[d])&&qi(u)?i[d]=Ou(i[d],u):qi(u)?i[d]=Ou({},u):Rr(u)?i[d]=u.slice():i[d]=u};for(let u=0,c=arguments.length;u(Eo(i,(c,d)=>{s&&mt(c)?r[d]=wp(c,s):r[d]=c},{allOwnKeys:u}),r),Ty=r=>(r.charCodeAt(0)===65279&&(r=r.slice(1)),r),Ly=(r,i,s,u)=>{r.prototype=Object.create(i.prototype,u),r.prototype.constructor=r,Object.defineProperty(r,"super",{value:i.prototype}),s&&Object.assign(r.prototype,s)},Dy=(r,i,s,u)=>{let c,d,p;const m={};if(i=i||{},r==null)return i;do{for(c=Object.getOwnPropertyNames(r),d=c.length;d-- >0;)p=c[d],(!u||u(p,r,i))&&!m[p]&&(i[p]=r[p],m[p]=!0);r=s!==!1&&qu(r)}while(r&&(!s||s(r,i))&&r!==Object.prototype);return i},zy=(r,i,s)=>{r=String(r),(s===void 0||s>r.length)&&(s=r.length),s-=i.length;const u=r.indexOf(i,s);return u!==-1&&u===s},My=r=>{if(!r)return null;if(Rr(r))return r;let i=r.length;if(!Sp(i))return null;const s=new Array(i);for(;i-- >0;)s[i]=r[i];return s},Uy=(r=>i=>r&&i instanceof r)(typeof Uint8Array<"u"&&qu(Uint8Array)),Fy=(r,i)=>{const u=(r&&r[Symbol.iterator]).call(r);let c;for(;(c=u.next())&&!c.done;){const d=c.value;i.call(r,d[0],d[1])}},By=(r,i)=>{let s;const u=[];for(;(s=r.exec(i))!==null;)u.push(s);return u},$y=zt("HTMLFormElement"),Hy=r=>r.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(s,u,c){return u.toUpperCase()+c}),Ed=(({hasOwnProperty:r})=>(i,s)=>r.call(i,s))(Object.prototype),Vy=zt("RegExp"),Cp=(r,i)=>{const s=Object.getOwnPropertyDescriptors(r),u={};Eo(s,(c,d)=>{let p;(p=i(c,d,r))!==!1&&(u[d]=p||c)}),Object.defineProperties(r,u)},Wy=r=>{Cp(r,(i,s)=>{if(mt(r)&&["arguments","caller","callee"].indexOf(s)!==-1)return!1;const u=r[s];if(mt(u)){if(i.enumerable=!1,"writable"in i){i.writable=!1;return}i.set||(i.set=()=>{throw Error("Can not rewrite read-only method '"+s+"'")})}})},Qy=(r,i)=>{const s={},u=c=>{c.forEach(d=>{s[d]=!0})};return Rr(r)?u(r):u(String(r).split(i)),s},qy=()=>{},by=(r,i)=>r!=null&&Number.isFinite(r=+r)?r:i,gu="abcdefghijklmnopqrstuvwxyz",Cd="0123456789",Ap={DIGIT:Cd,ALPHA:gu,ALPHA_DIGIT:gu+gu.toUpperCase()+Cd},Gy=(r=16,i=Ap.ALPHA_DIGIT)=>{let s="";const{length:u}=i;for(;r--;)s+=i[Math.random()*u|0];return s};function Yy(r){return!!(r&&mt(r.append)&&r[Symbol.toStringTag]==="FormData"&&r[Symbol.iterator])}const Ky=r=>{const i=new Array(10),s=(u,c)=>{if(cs(u)){if(i.indexOf(u)>=0)return;if(!("toJSON"in u)){i[c]=u;const d=Rr(u)?[]:{};return Eo(u,(p,m)=>{const v=s(p,c+1);!ko(v)&&(d[m]=v)}),i[c]=void 0,d}}return u};return s(r,0)},Xy=zt("AsyncFunction"),Jy=r=>r&&(cs(r)||mt(r))&&mt(r.then)&&mt(r.catch),Rp=((r,i)=>r?setImmediate:i?((s,u)=>(Fn.addEventListener("message",({source:c,data:d})=>{c===Fn&&d===s&&u.length&&u.shift()()},!1),c=>{u.push(c),Fn.postMessage(s,"*")}))(`axios@${Math.random()}`,[]):s=>setTimeout(s))(typeof setImmediate=="function",mt(Fn.postMessage)),Zy=typeof queueMicrotask<"u"?queueMicrotask.bind(Fn):typeof process<"u"&&process.nextTick||Rp,_={isArray:Rr,isArrayBuffer:xp,isBuffer:gy,isFormData:Ay,isArrayBufferView:yy,isString:vy,isNumber:Sp,isBoolean:wy,isObject:cs,isPlainObject:qi,isReadableStream:Py,isRequest:jy,isResponse:Iy,isHeaders:_y,isUndefined:ko,isDate:xy,isFile:Sy,isBlob:ky,isRegExp:Vy,isFunction:mt,isStream:Cy,isURLSearchParams:Ry,isTypedArray:Uy,isFileList:Ey,forEach:Eo,merge:Ou,extend:Oy,trim:Ny,stripBOM:Ty,inherits:Ly,toFlatObject:Dy,kindOf:us,kindOfTest:zt,endsWith:zy,toArray:My,forEachEntry:Fy,matchAll:By,isHTMLForm:$y,hasOwnProperty:Ed,hasOwnProp:Ed,reduceDescriptors:Cp,freezeMethods:Wy,toObjectSet:Qy,toCamelCase:Hy,noop:qy,toFiniteNumber:by,findKey:kp,global:Fn,isContextDefined:Ep,ALPHABET:Ap,generateString:Gy,isSpecCompliantForm:Yy,toJSONObject:Ky,isAsyncFn:Xy,isThenable:Jy,setImmediate:Rp,asap:Zy};function ie(r,i,s,u,c){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack,this.message=r,this.name="AxiosError",i&&(this.code=i),s&&(this.config=s),u&&(this.request=u),c&&(this.response=c,this.status=c.status?c.status:null)}_.inherits(ie,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:_.toJSONObject(this.config),code:this.code,status:this.status}}});const Pp=ie.prototype,jp={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach(r=>{jp[r]={value:r}});Object.defineProperties(ie,jp);Object.defineProperty(Pp,"isAxiosError",{value:!0});ie.from=(r,i,s,u,c,d)=>{const p=Object.create(Pp);return _.toFlatObject(r,p,function(v){return v!==Error.prototype},m=>m!=="isAxiosError"),ie.call(p,r.message,i,s,u,c),p.cause=r,p.name=r.name,d&&Object.assign(p,d),p};const e0=null;function Tu(r){return _.isPlainObject(r)||_.isArray(r)}function Ip(r){return _.endsWith(r,"[]")?r.slice(0,-2):r}function Ad(r,i,s){return r?r.concat(i).map(function(c,d){return c=Ip(c),!s&&d?"["+c+"]":c}).join(s?".":""):i}function t0(r){return _.isArray(r)&&!r.some(Tu)}const n0=_.toFlatObject(_,{},null,function(i){return/^is[A-Z]/.test(i)});function fs(r,i,s){if(!_.isObject(r))throw new TypeError("target must be an object");i=i||new FormData,s=_.toFlatObject(s,{metaTokens:!0,dots:!1,indexes:!1},!1,function(R,L){return!_.isUndefined(L[R])});const u=s.metaTokens,c=s.visitor||E,d=s.dots,p=s.indexes,v=(s.Blob||typeof Blob<"u"&&Blob)&&_.isSpecCompliantForm(i);if(!_.isFunction(c))throw new TypeError("visitor must be a function");function x(I){if(I===null)return"";if(_.isDate(I))return I.toISOString();if(!v&&_.isBlob(I))throw new ie("Blob is not supported. Use a Buffer instead.");return _.isArrayBuffer(I)||_.isTypedArray(I)?v&&typeof Blob=="function"?new Blob([I]):Buffer.from(I):I}function E(I,R,L){let V=I;if(I&&!L&&typeof I=="object"){if(_.endsWith(R,"{}"))R=u?R:R.slice(0,-2),I=JSON.stringify(I);else if(_.isArray(I)&&t0(I)||(_.isFileList(I)||_.endsWith(R,"[]"))&&(V=_.toArray(I)))return R=Ip(R),V.forEach(function(W,K){!(_.isUndefined(W)||W===null)&&i.append(p===!0?Ad([R],K,d):p===null?R:R+"[]",x(W))}),!1}return Tu(I)?!0:(i.append(Ad(L,R,d),x(I)),!1)}const j=[],O=Object.assign(n0,{defaultVisitor:E,convertValue:x,isVisitable:Tu});function P(I,R){if(!_.isUndefined(I)){if(j.indexOf(I)!==-1)throw Error("Circular reference detected in "+R.join("."));j.push(I),_.forEach(I,function(V,F){(!(_.isUndefined(V)||V===null)&&c.call(i,V,_.isString(F)?F.trim():F,R,O))===!0&&P(V,R?R.concat(F):[F])}),j.pop()}}if(!_.isObject(r))throw new TypeError("data must be an object");return P(r),i}function Rd(r){const i={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(r).replace(/[!'()~]|%20|%00/g,function(u){return i[u]})}function bu(r,i){this._pairs=[],r&&fs(r,this,i)}const _p=bu.prototype;_p.append=function(i,s){this._pairs.push([i,s])};_p.toString=function(i){const s=i?function(u){return i.call(this,u,Rd)}:Rd;return this._pairs.map(function(c){return s(c[0])+"="+s(c[1])},"").join("&")};function r0(r){return encodeURIComponent(r).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}function Np(r,i,s){if(!i)return r;const u=s&&s.encode||r0;_.isFunction(s)&&(s={serialize:s});const c=s&&s.serialize;let d;if(c?d=c(i,s):d=_.isURLSearchParams(i)?i.toString():new bu(i,s).toString(u),d){const p=r.indexOf("#");p!==-1&&(r=r.slice(0,p)),r+=(r.indexOf("?")===-1?"?":"&")+d}return r}class Pd{constructor(){this.handlers=[]}use(i,s,u){return this.handlers.push({fulfilled:i,rejected:s,synchronous:u?u.synchronous:!1,runWhen:u?u.runWhen:null}),this.handlers.length-1}eject(i){this.handlers[i]&&(this.handlers[i]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(i){_.forEach(this.handlers,function(u){u!==null&&i(u)})}}const Op={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},o0=typeof URLSearchParams<"u"?URLSearchParams:bu,i0=typeof FormData<"u"?FormData:null,s0=typeof Blob<"u"?Blob:null,l0={isBrowser:!0,classes:{URLSearchParams:o0,FormData:i0,Blob:s0},protocols:["http","https","file","blob","url","data"]},Gu=typeof window<"u"&&typeof document<"u",Lu=typeof navigator=="object"&&navigator||void 0,u0=Gu&&(!Lu||["ReactNative","NativeScript","NS"].indexOf(Lu.product)<0),a0=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",c0=Gu&&window.location.href||"http://localhost",f0=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:Gu,hasStandardBrowserEnv:u0,hasStandardBrowserWebWorkerEnv:a0,navigator:Lu,origin:c0},Symbol.toStringTag,{value:"Module"})),Ye={...f0,...l0};function d0(r,i){return fs(r,new Ye.classes.URLSearchParams,Object.assign({visitor:function(s,u,c,d){return Ye.isNode&&_.isBuffer(s)?(this.append(u,s.toString("base64")),!1):d.defaultVisitor.apply(this,arguments)}},i))}function p0(r){return _.matchAll(/\w+|\[(\w*)]/g,r).map(i=>i[0]==="[]"?"":i[1]||i[0])}function h0(r){const i={},s=Object.keys(r);let u;const c=s.length;let d;for(u=0;u=s.length;return p=!p&&_.isArray(c)?c.length:p,v?(_.hasOwnProp(c,p)?c[p]=[c[p],u]:c[p]=u,!m):((!c[p]||!_.isObject(c[p]))&&(c[p]=[]),i(s,u,c[p],d)&&_.isArray(c[p])&&(c[p]=h0(c[p])),!m)}if(_.isFormData(r)&&_.isFunction(r.entries)){const s={};return _.forEachEntry(r,(u,c)=>{i(p0(u),c,s,0)}),s}return null}function m0(r,i,s){if(_.isString(r))try{return(i||JSON.parse)(r),_.trim(r)}catch(u){if(u.name!=="SyntaxError")throw u}return(0,JSON.stringify)(r)}const Co={transitional:Op,adapter:["xhr","http","fetch"],transformRequest:[function(i,s){const u=s.getContentType()||"",c=u.indexOf("application/json")>-1,d=_.isObject(i);if(d&&_.isHTMLForm(i)&&(i=new FormData(i)),_.isFormData(i))return c?JSON.stringify(Tp(i)):i;if(_.isArrayBuffer(i)||_.isBuffer(i)||_.isStream(i)||_.isFile(i)||_.isBlob(i)||_.isReadableStream(i))return i;if(_.isArrayBufferView(i))return i.buffer;if(_.isURLSearchParams(i))return s.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),i.toString();let m;if(d){if(u.indexOf("application/x-www-form-urlencoded")>-1)return d0(i,this.formSerializer).toString();if((m=_.isFileList(i))||u.indexOf("multipart/form-data")>-1){const v=this.env&&this.env.FormData;return fs(m?{"files[]":i}:i,v&&new v,this.formSerializer)}}return d||c?(s.setContentType("application/json",!1),m0(i)):i}],transformResponse:[function(i){const s=this.transitional||Co.transitional,u=s&&s.forcedJSONParsing,c=this.responseType==="json";if(_.isResponse(i)||_.isReadableStream(i))return i;if(i&&_.isString(i)&&(u&&!this.responseType||c)){const p=!(s&&s.silentJSONParsing)&&c;try{return JSON.parse(i)}catch(m){if(p)throw m.name==="SyntaxError"?ie.from(m,ie.ERR_BAD_RESPONSE,this,null,this.response):m}}return i}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Ye.classes.FormData,Blob:Ye.classes.Blob},validateStatus:function(i){return i>=200&&i<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};_.forEach(["delete","get","head","post","put","patch"],r=>{Co.headers[r]={}});const g0=_.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),y0=r=>{const i={};let s,u,c;return r&&r.split(` +`).forEach(function(p){c=p.indexOf(":"),s=p.substring(0,c).trim().toLowerCase(),u=p.substring(c+1).trim(),!(!s||i[s]&&g0[s])&&(s==="set-cookie"?i[s]?i[s].push(u):i[s]=[u]:i[s]=i[s]?i[s]+", "+u:u)}),i},jd=Symbol("internals");function mo(r){return r&&String(r).trim().toLowerCase()}function bi(r){return r===!1||r==null?r:_.isArray(r)?r.map(bi):String(r)}function v0(r){const i=Object.create(null),s=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let u;for(;u=s.exec(r);)i[u[1]]=u[2];return i}const w0=r=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(r.trim());function yu(r,i,s,u,c){if(_.isFunction(u))return u.call(this,i,s);if(c&&(i=s),!!_.isString(i)){if(_.isString(u))return i.indexOf(u)!==-1;if(_.isRegExp(u))return u.test(i)}}function x0(r){return r.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(i,s,u)=>s.toUpperCase()+u)}function S0(r,i){const s=_.toCamelCase(" "+i);["get","set","has"].forEach(u=>{Object.defineProperty(r,u+s,{value:function(c,d,p){return this[u].call(this,i,c,d,p)},configurable:!0})})}class lt{constructor(i){i&&this.set(i)}set(i,s,u){const c=this;function d(m,v,x){const E=mo(v);if(!E)throw new Error("header name must be a non-empty string");const j=_.findKey(c,E);(!j||c[j]===void 0||x===!0||x===void 0&&c[j]!==!1)&&(c[j||v]=bi(m))}const p=(m,v)=>_.forEach(m,(x,E)=>d(x,E,v));if(_.isPlainObject(i)||i instanceof this.constructor)p(i,s);else if(_.isString(i)&&(i=i.trim())&&!w0(i))p(y0(i),s);else if(_.isHeaders(i))for(const[m,v]of i.entries())d(v,m,u);else i!=null&&d(s,i,u);return this}get(i,s){if(i=mo(i),i){const u=_.findKey(this,i);if(u){const c=this[u];if(!s)return c;if(s===!0)return v0(c);if(_.isFunction(s))return s.call(this,c,u);if(_.isRegExp(s))return s.exec(c);throw new TypeError("parser must be boolean|regexp|function")}}}has(i,s){if(i=mo(i),i){const u=_.findKey(this,i);return!!(u&&this[u]!==void 0&&(!s||yu(this,this[u],u,s)))}return!1}delete(i,s){const u=this;let c=!1;function d(p){if(p=mo(p),p){const m=_.findKey(u,p);m&&(!s||yu(u,u[m],m,s))&&(delete u[m],c=!0)}}return _.isArray(i)?i.forEach(d):d(i),c}clear(i){const s=Object.keys(this);let u=s.length,c=!1;for(;u--;){const d=s[u];(!i||yu(this,this[d],d,i,!0))&&(delete this[d],c=!0)}return c}normalize(i){const s=this,u={};return _.forEach(this,(c,d)=>{const p=_.findKey(u,d);if(p){s[p]=bi(c),delete s[d];return}const m=i?x0(d):String(d).trim();m!==d&&delete s[d],s[m]=bi(c),u[m]=!0}),this}concat(...i){return this.constructor.concat(this,...i)}toJSON(i){const s=Object.create(null);return _.forEach(this,(u,c)=>{u!=null&&u!==!1&&(s[c]=i&&_.isArray(u)?u.join(", "):u)}),s}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([i,s])=>i+": "+s).join(` +`)}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(i){return i instanceof this?i:new this(i)}static concat(i,...s){const u=new this(i);return s.forEach(c=>u.set(c)),u}static accessor(i){const u=(this[jd]=this[jd]={accessors:{}}).accessors,c=this.prototype;function d(p){const m=mo(p);u[m]||(S0(c,p),u[m]=!0)}return _.isArray(i)?i.forEach(d):d(i),this}}lt.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);_.reduceDescriptors(lt.prototype,({value:r},i)=>{let s=i[0].toUpperCase()+i.slice(1);return{get:()=>r,set(u){this[s]=u}}});_.freezeMethods(lt);function vu(r,i){const s=this||Co,u=i||s,c=lt.from(u.headers);let d=u.data;return _.forEach(r,function(m){d=m.call(s,d,c.normalize(),i?i.status:void 0)}),c.normalize(),d}function Lp(r){return!!(r&&r.__CANCEL__)}function Pr(r,i,s){ie.call(this,r??"canceled",ie.ERR_CANCELED,i,s),this.name="CanceledError"}_.inherits(Pr,ie,{__CANCEL__:!0});function Dp(r,i,s){const u=s.config.validateStatus;!s.status||!u||u(s.status)?r(s):i(new ie("Request failed with status code "+s.status,[ie.ERR_BAD_REQUEST,ie.ERR_BAD_RESPONSE][Math.floor(s.status/100)-4],s.config,s.request,s))}function k0(r){const i=/^([-+\w]{1,25})(:?\/\/|:)/.exec(r);return i&&i[1]||""}function E0(r,i){r=r||10;const s=new Array(r),u=new Array(r);let c=0,d=0,p;return i=i!==void 0?i:1e3,function(v){const x=Date.now(),E=u[d];p||(p=x),s[c]=v,u[c]=x;let j=d,O=0;for(;j!==c;)O+=s[j++],j=j%r;if(c=(c+1)%r,c===d&&(d=(d+1)%r),x-p{s=E,c=null,d&&(clearTimeout(d),d=null),r.apply(null,x)};return[(...x)=>{const E=Date.now(),j=E-s;j>=u?p(x,E):(c=x,d||(d=setTimeout(()=>{d=null,p(c)},u-j)))},()=>c&&p(c)]}const es=(r,i,s=3)=>{let u=0;const c=E0(50,250);return C0(d=>{const p=d.loaded,m=d.lengthComputable?d.total:void 0,v=p-u,x=c(v),E=p<=m;u=p;const j={loaded:p,total:m,progress:m?p/m:void 0,bytes:v,rate:x||void 0,estimated:x&&m&&E?(m-p)/x:void 0,event:d,lengthComputable:m!=null,[i?"download":"upload"]:!0};r(j)},s)},Id=(r,i)=>{const s=r!=null;return[u=>i[0]({lengthComputable:s,total:r,loaded:u}),i[1]]},_d=r=>(...i)=>_.asap(()=>r(...i)),A0=Ye.hasStandardBrowserEnv?((r,i)=>s=>(s=new URL(s,Ye.origin),r.protocol===s.protocol&&r.host===s.host&&(i||r.port===s.port)))(new URL(Ye.origin),Ye.navigator&&/(msie|trident)/i.test(Ye.navigator.userAgent)):()=>!0,R0=Ye.hasStandardBrowserEnv?{write(r,i,s,u,c,d){const p=[r+"="+encodeURIComponent(i)];_.isNumber(s)&&p.push("expires="+new Date(s).toGMTString()),_.isString(u)&&p.push("path="+u),_.isString(c)&&p.push("domain="+c),d===!0&&p.push("secure"),document.cookie=p.join("; ")},read(r){const i=document.cookie.match(new RegExp("(^|;\\s*)("+r+")=([^;]*)"));return i?decodeURIComponent(i[3]):null},remove(r){this.write(r,"",Date.now()-864e5)}}:{write(){},read(){return null},remove(){}};function P0(r){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(r)}function j0(r,i){return i?r.replace(/\/?\/$/,"")+"/"+i.replace(/^\/+/,""):r}function zp(r,i){return r&&!P0(i)?j0(r,i):i}const Nd=r=>r instanceof lt?{...r}:r;function qn(r,i){i=i||{};const s={};function u(x,E,j,O){return _.isPlainObject(x)&&_.isPlainObject(E)?_.merge.call({caseless:O},x,E):_.isPlainObject(E)?_.merge({},E):_.isArray(E)?E.slice():E}function c(x,E,j,O){if(_.isUndefined(E)){if(!_.isUndefined(x))return u(void 0,x,j,O)}else return u(x,E,j,O)}function d(x,E){if(!_.isUndefined(E))return u(void 0,E)}function p(x,E){if(_.isUndefined(E)){if(!_.isUndefined(x))return u(void 0,x)}else return u(void 0,E)}function m(x,E,j){if(j in i)return u(x,E);if(j in r)return u(void 0,x)}const v={url:d,method:d,data:d,baseURL:p,transformRequest:p,transformResponse:p,paramsSerializer:p,timeout:p,timeoutMessage:p,withCredentials:p,withXSRFToken:p,adapter:p,responseType:p,xsrfCookieName:p,xsrfHeaderName:p,onUploadProgress:p,onDownloadProgress:p,decompress:p,maxContentLength:p,maxBodyLength:p,beforeRedirect:p,transport:p,httpAgent:p,httpsAgent:p,cancelToken:p,socketPath:p,responseEncoding:p,validateStatus:m,headers:(x,E,j)=>c(Nd(x),Nd(E),j,!0)};return _.forEach(Object.keys(Object.assign({},r,i)),function(E){const j=v[E]||c,O=j(r[E],i[E],E);_.isUndefined(O)&&j!==m||(s[E]=O)}),s}const Mp=r=>{const i=qn({},r);let{data:s,withXSRFToken:u,xsrfHeaderName:c,xsrfCookieName:d,headers:p,auth:m}=i;i.headers=p=lt.from(p),i.url=Np(zp(i.baseURL,i.url),r.params,r.paramsSerializer),m&&p.set("Authorization","Basic "+btoa((m.username||"")+":"+(m.password?unescape(encodeURIComponent(m.password)):"")));let v;if(_.isFormData(s)){if(Ye.hasStandardBrowserEnv||Ye.hasStandardBrowserWebWorkerEnv)p.setContentType(void 0);else if((v=p.getContentType())!==!1){const[x,...E]=v?v.split(";").map(j=>j.trim()).filter(Boolean):[];p.setContentType([x||"multipart/form-data",...E].join("; "))}}if(Ye.hasStandardBrowserEnv&&(u&&_.isFunction(u)&&(u=u(i)),u||u!==!1&&A0(i.url))){const x=c&&d&&R0.read(d);x&&p.set(c,x)}return i},I0=typeof XMLHttpRequest<"u",_0=I0&&function(r){return new Promise(function(s,u){const c=Mp(r);let d=c.data;const p=lt.from(c.headers).normalize();let{responseType:m,onUploadProgress:v,onDownloadProgress:x}=c,E,j,O,P,I;function R(){P&&P(),I&&I(),c.cancelToken&&c.cancelToken.unsubscribe(E),c.signal&&c.signal.removeEventListener("abort",E)}let L=new XMLHttpRequest;L.open(c.method.toUpperCase(),c.url,!0),L.timeout=c.timeout;function V(){if(!L)return;const W=lt.from("getAllResponseHeaders"in L&&L.getAllResponseHeaders()),$={data:!m||m==="text"||m==="json"?L.responseText:L.response,status:L.status,statusText:L.statusText,headers:W,config:r,request:L};Dp(function(H){s(H),R()},function(H){u(H),R()},$),L=null}"onloadend"in L?L.onloadend=V:L.onreadystatechange=function(){!L||L.readyState!==4||L.status===0&&!(L.responseURL&&L.responseURL.indexOf("file:")===0)||setTimeout(V)},L.onabort=function(){L&&(u(new ie("Request aborted",ie.ECONNABORTED,r,L)),L=null)},L.onerror=function(){u(new ie("Network Error",ie.ERR_NETWORK,r,L)),L=null},L.ontimeout=function(){let K=c.timeout?"timeout of "+c.timeout+"ms exceeded":"timeout exceeded";const $=c.transitional||Op;c.timeoutErrorMessage&&(K=c.timeoutErrorMessage),u(new ie(K,$.clarifyTimeoutError?ie.ETIMEDOUT:ie.ECONNABORTED,r,L)),L=null},d===void 0&&p.setContentType(null),"setRequestHeader"in L&&_.forEach(p.toJSON(),function(K,$){L.setRequestHeader($,K)}),_.isUndefined(c.withCredentials)||(L.withCredentials=!!c.withCredentials),m&&m!=="json"&&(L.responseType=c.responseType),x&&([O,I]=es(x,!0),L.addEventListener("progress",O)),v&&L.upload&&([j,P]=es(v),L.upload.addEventListener("progress",j),L.upload.addEventListener("loadend",P)),(c.cancelToken||c.signal)&&(E=W=>{L&&(u(!W||W.type?new Pr(null,r,L):W),L.abort(),L=null)},c.cancelToken&&c.cancelToken.subscribe(E),c.signal&&(c.signal.aborted?E():c.signal.addEventListener("abort",E)));const F=k0(c.url);if(F&&Ye.protocols.indexOf(F)===-1){u(new ie("Unsupported protocol "+F+":",ie.ERR_BAD_REQUEST,r));return}L.send(d||null)})},N0=(r,i)=>{const{length:s}=r=r?r.filter(Boolean):[];if(i||s){let u=new AbortController,c;const d=function(x){if(!c){c=!0,m();const E=x instanceof Error?x:this.reason;u.abort(E instanceof ie?E:new Pr(E instanceof Error?E.message:E))}};let p=i&&setTimeout(()=>{p=null,d(new ie(`timeout ${i} of ms exceeded`,ie.ETIMEDOUT))},i);const m=()=>{r&&(p&&clearTimeout(p),p=null,r.forEach(x=>{x.unsubscribe?x.unsubscribe(d):x.removeEventListener("abort",d)}),r=null)};r.forEach(x=>x.addEventListener("abort",d));const{signal:v}=u;return v.unsubscribe=()=>_.asap(m),v}},O0=function*(r,i){let s=r.byteLength;if(s{const c=T0(r,i);let d=0,p,m=v=>{p||(p=!0,u&&u(v))};return new ReadableStream({async pull(v){try{const{done:x,value:E}=await c.next();if(x){m(),v.close();return}let j=E.byteLength;if(s){let O=d+=j;s(O)}v.enqueue(new Uint8Array(E))}catch(x){throw m(x),x}},cancel(v){return m(v),c.return()}},{highWaterMark:2})},ds=typeof fetch=="function"&&typeof Request=="function"&&typeof Response=="function",Up=ds&&typeof ReadableStream=="function",D0=ds&&(typeof TextEncoder=="function"?(r=>i=>r.encode(i))(new TextEncoder):async r=>new Uint8Array(await new Response(r).arrayBuffer())),Fp=(r,...i)=>{try{return!!r(...i)}catch{return!1}},z0=Up&&Fp(()=>{let r=!1;const i=new Request(Ye.origin,{body:new ReadableStream,method:"POST",get duplex(){return r=!0,"half"}}).headers.has("Content-Type");return r&&!i}),Td=64*1024,Du=Up&&Fp(()=>_.isReadableStream(new Response("").body)),ts={stream:Du&&(r=>r.body)};ds&&(r=>{["text","arrayBuffer","blob","formData","stream"].forEach(i=>{!ts[i]&&(ts[i]=_.isFunction(r[i])?s=>s[i]():(s,u)=>{throw new ie(`Response type '${i}' is not supported`,ie.ERR_NOT_SUPPORT,u)})})})(new Response);const M0=async r=>{if(r==null)return 0;if(_.isBlob(r))return r.size;if(_.isSpecCompliantForm(r))return(await new Request(Ye.origin,{method:"POST",body:r}).arrayBuffer()).byteLength;if(_.isArrayBufferView(r)||_.isArrayBuffer(r))return r.byteLength;if(_.isURLSearchParams(r)&&(r=r+""),_.isString(r))return(await D0(r)).byteLength},U0=async(r,i)=>{const s=_.toFiniteNumber(r.getContentLength());return s??M0(i)},F0=ds&&(async r=>{let{url:i,method:s,data:u,signal:c,cancelToken:d,timeout:p,onDownloadProgress:m,onUploadProgress:v,responseType:x,headers:E,withCredentials:j="same-origin",fetchOptions:O}=Mp(r);x=x?(x+"").toLowerCase():"text";let P=N0([c,d&&d.toAbortSignal()],p),I;const R=P&&P.unsubscribe&&(()=>{P.unsubscribe()});let L;try{if(v&&z0&&s!=="get"&&s!=="head"&&(L=await U0(E,u))!==0){let $=new Request(i,{method:"POST",body:u,duplex:"half"}),T;if(_.isFormData(u)&&(T=$.headers.get("content-type"))&&E.setContentType(T),$.body){const[H,se]=Id(L,es(_d(v)));u=Od($.body,Td,H,se)}}_.isString(j)||(j=j?"include":"omit");const V="credentials"in Request.prototype;I=new Request(i,{...O,signal:P,method:s.toUpperCase(),headers:E.normalize().toJSON(),body:u,duplex:"half",credentials:V?j:void 0});let F=await fetch(I);const W=Du&&(x==="stream"||x==="response");if(Du&&(m||W&&R)){const $={};["status","statusText","headers"].forEach(Ve=>{$[Ve]=F[Ve]});const T=_.toFiniteNumber(F.headers.get("content-length")),[H,se]=m&&Id(T,es(_d(m),!0))||[];F=new Response(Od(F.body,Td,H,()=>{se&&se(),R&&R()}),$)}x=x||"text";let K=await ts[_.findKey(ts,x)||"text"](F,r);return!W&&R&&R(),await new Promise(($,T)=>{Dp($,T,{data:K,headers:lt.from(F.headers),status:F.status,statusText:F.statusText,config:r,request:I})})}catch(V){throw R&&R(),V&&V.name==="TypeError"&&/fetch/i.test(V.message)?Object.assign(new ie("Network Error",ie.ERR_NETWORK,r,I),{cause:V.cause||V}):ie.from(V,V&&V.code,r,I)}}),zu={http:e0,xhr:_0,fetch:F0};_.forEach(zu,(r,i)=>{if(r){try{Object.defineProperty(r,"name",{value:i})}catch{}Object.defineProperty(r,"adapterName",{value:i})}});const Ld=r=>`- ${r}`,B0=r=>_.isFunction(r)||r===null||r===!1,Bp={getAdapter:r=>{r=_.isArray(r)?r:[r];const{length:i}=r;let s,u;const c={};for(let d=0;d`adapter ${m} `+(v===!1?"is not supported by the environment":"is not available in the build"));let p=i?d.length>1?`since : +`+d.map(Ld).join(` +`):" "+Ld(d[0]):"as no adapter specified";throw new ie("There is no suitable adapter to dispatch the request "+p,"ERR_NOT_SUPPORT")}return u},adapters:zu};function wu(r){if(r.cancelToken&&r.cancelToken.throwIfRequested(),r.signal&&r.signal.aborted)throw new Pr(null,r)}function Dd(r){return wu(r),r.headers=lt.from(r.headers),r.data=vu.call(r,r.transformRequest),["post","put","patch"].indexOf(r.method)!==-1&&r.headers.setContentType("application/x-www-form-urlencoded",!1),Bp.getAdapter(r.adapter||Co.adapter)(r).then(function(u){return wu(r),u.data=vu.call(r,r.transformResponse,u),u.headers=lt.from(u.headers),u},function(u){return Lp(u)||(wu(r),u&&u.response&&(u.response.data=vu.call(r,r.transformResponse,u.response),u.response.headers=lt.from(u.response.headers))),Promise.reject(u)})}const $p="1.7.9",ps={};["object","boolean","number","function","string","symbol"].forEach((r,i)=>{ps[r]=function(u){return typeof u===r||"a"+(i<1?"n ":" ")+r}});const zd={};ps.transitional=function(i,s,u){function c(d,p){return"[Axios v"+$p+"] Transitional option '"+d+"'"+p+(u?". "+u:"")}return(d,p,m)=>{if(i===!1)throw new ie(c(p," has been removed"+(s?" in "+s:"")),ie.ERR_DEPRECATED);return s&&!zd[p]&&(zd[p]=!0,console.warn(c(p," has been deprecated since v"+s+" and will be removed in the near future"))),i?i(d,p,m):!0}};ps.spelling=function(i){return(s,u)=>(console.warn(`${u} is likely a misspelling of ${i}`),!0)};function $0(r,i,s){if(typeof r!="object")throw new ie("options must be an object",ie.ERR_BAD_OPTION_VALUE);const u=Object.keys(r);let c=u.length;for(;c-- >0;){const d=u[c],p=i[d];if(p){const m=r[d],v=m===void 0||p(m,d,r);if(v!==!0)throw new ie("option "+d+" must be "+v,ie.ERR_BAD_OPTION_VALUE);continue}if(s!==!0)throw new ie("Unknown option "+d,ie.ERR_BAD_OPTION)}}const Gi={assertOptions:$0,validators:ps},Ht=Gi.validators;class Vn{constructor(i){this.defaults=i,this.interceptors={request:new Pd,response:new Pd}}async request(i,s){try{return await this._request(i,s)}catch(u){if(u instanceof Error){let c={};Error.captureStackTrace?Error.captureStackTrace(c):c=new Error;const d=c.stack?c.stack.replace(/^.+\n/,""):"";try{u.stack?d&&!String(u.stack).endsWith(d.replace(/^.+\n.+\n/,""))&&(u.stack+=` +`+d):u.stack=d}catch{}}throw u}}_request(i,s){typeof i=="string"?(s=s||{},s.url=i):s=i||{},s=qn(this.defaults,s);const{transitional:u,paramsSerializer:c,headers:d}=s;u!==void 0&&Gi.assertOptions(u,{silentJSONParsing:Ht.transitional(Ht.boolean),forcedJSONParsing:Ht.transitional(Ht.boolean),clarifyTimeoutError:Ht.transitional(Ht.boolean)},!1),c!=null&&(_.isFunction(c)?s.paramsSerializer={serialize:c}:Gi.assertOptions(c,{encode:Ht.function,serialize:Ht.function},!0)),Gi.assertOptions(s,{baseUrl:Ht.spelling("baseURL"),withXsrfToken:Ht.spelling("withXSRFToken")},!0),s.method=(s.method||this.defaults.method||"get").toLowerCase();let p=d&&_.merge(d.common,d[s.method]);d&&_.forEach(["delete","get","head","post","put","patch","common"],I=>{delete d[I]}),s.headers=lt.concat(p,d);const m=[];let v=!0;this.interceptors.request.forEach(function(R){typeof R.runWhen=="function"&&R.runWhen(s)===!1||(v=v&&R.synchronous,m.unshift(R.fulfilled,R.rejected))});const x=[];this.interceptors.response.forEach(function(R){x.push(R.fulfilled,R.rejected)});let E,j=0,O;if(!v){const I=[Dd.bind(this),void 0];for(I.unshift.apply(I,m),I.push.apply(I,x),O=I.length,E=Promise.resolve(s);j{if(!u._listeners)return;let d=u._listeners.length;for(;d-- >0;)u._listeners[d](c);u._listeners=null}),this.promise.then=c=>{let d;const p=new Promise(m=>{u.subscribe(m),d=m}).then(c);return p.cancel=function(){u.unsubscribe(d)},p},i(function(d,p,m){u.reason||(u.reason=new Pr(d,p,m),s(u.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(i){if(this.reason){i(this.reason);return}this._listeners?this._listeners.push(i):this._listeners=[i]}unsubscribe(i){if(!this._listeners)return;const s=this._listeners.indexOf(i);s!==-1&&this._listeners.splice(s,1)}toAbortSignal(){const i=new AbortController,s=u=>{i.abort(u)};return this.subscribe(s),i.signal.unsubscribe=()=>this.unsubscribe(s),i.signal}static source(){let i;return{token:new Yu(function(c){i=c}),cancel:i}}}function H0(r){return function(s){return r.apply(null,s)}}function V0(r){return _.isObject(r)&&r.isAxiosError===!0}const Mu={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511};Object.entries(Mu).forEach(([r,i])=>{Mu[i]=r});function Hp(r){const i=new Vn(r),s=wp(Vn.prototype.request,i);return _.extend(s,Vn.prototype,i,{allOwnKeys:!0}),_.extend(s,i,null,{allOwnKeys:!0}),s.create=function(c){return Hp(qn(r,c))},s}const he=Hp(Co);he.Axios=Vn;he.CanceledError=Pr;he.CancelToken=Yu;he.isCancel=Lp;he.VERSION=$p;he.toFormData=fs;he.AxiosError=ie;he.Cancel=he.CanceledError;he.all=function(i){return Promise.all(i)};he.spread=H0;he.isAxiosError=V0;he.mergeConfig=qn;he.AxiosHeaders=lt;he.formToJSON=r=>Tp(_.isHTMLForm(r)?new FormData(r):r);he.getAdapter=Bp.getAdapter;he.HttpStatusCode=Mu;he.default=he;const Xe={apiBaseUrl:"/api"},Dt=bn(r=>({users:[],fetchUsers:async()=>{try{const i=await he.get(`${Xe.apiBaseUrl}/users`);r({users:i.data})}catch(i){console.error("사용자 목록 조회 실패:",i)}},updateUserStatus:async i=>{try{await he.patch(`${Xe.apiBaseUrl}/users/${i}/userStatus`,{newLastActiveAt:new Date().toISOString()})}catch(s){console.error("사용자 상태 업데이트 실패:",s)}}})),Wt=bn(r=>({profileImages:{},fetchProfileImage:async i=>{try{const s=await he.get(`${Xe.apiBaseUrl}/binaryContents/${i}`),u=s.data.bytes,d=`data:${s.data.contentType};base64,${u}`;return r(p=>({profileImages:{...p.profileImages,[i]:d}})),d}catch(s){return console.error("프로필 이미지 로딩 실패:",s),null}}}));function Vp(r,i){let s;try{s=r()}catch{return}return{getItem:c=>{var d;const p=v=>v===null?null:JSON.parse(v,void 0),m=(d=s.getItem(c))!=null?d:null;return m instanceof Promise?m.then(p):p(m)},setItem:(c,d)=>s.setItem(c,JSON.stringify(d,void 0)),removeItem:c=>s.removeItem(c)}}const Uu=r=>i=>{try{const s=r(i);return s instanceof Promise?s:{then(u){return Uu(u)(s)},catch(u){return this}}}catch(s){return{then(u){return this},catch(u){return Uu(u)(s)}}}},W0=(r,i)=>(s,u,c)=>{let d={storage:Vp(()=>localStorage),partialize:R=>R,version:0,merge:(R,L)=>({...L,...R}),...i},p=!1;const m=new Set,v=new Set;let x=d.storage;if(!x)return r((...R)=>{console.warn(`[zustand persist middleware] Unable to update item '${d.name}', the given storage is currently unavailable.`),s(...R)},u,c);const E=()=>{const R=d.partialize({...u()});return x.setItem(d.name,{state:R,version:d.version})},j=c.setState;c.setState=(R,L)=>{j(R,L),E()};const O=r((...R)=>{s(...R),E()},u,c);c.getInitialState=()=>O;let P;const I=()=>{var R,L;if(!x)return;p=!1,m.forEach(F=>{var W;return F((W=u())!=null?W:O)});const V=((L=d.onRehydrateStorage)==null?void 0:L.call(d,(R=u())!=null?R:O))||void 0;return Uu(x.getItem.bind(x))(d.name).then(F=>{if(F)if(typeof F.version=="number"&&F.version!==d.version){if(d.migrate){const W=d.migrate(F.state,F.version);return W instanceof Promise?W.then(K=>[!0,K]):[!0,W]}console.error("State loaded from storage couldn't be migrated since no migrate function was provided")}else return[!1,F.state];return[!1,void 0]}).then(F=>{var W;const[K,$]=F;if(P=d.merge($,(W=u())!=null?W:O),s(P,!0),K)return E()}).then(()=>{V==null||V(P,void 0),P=u(),p=!0,v.forEach(F=>F(P))}).catch(F=>{V==null||V(void 0,F)})};return c.persist={setOptions:R=>{d={...d,...R},R.storage&&(x=R.storage)},clearStorage:()=>{x==null||x.removeItem(d.name)},getOptions:()=>d,rehydrate:()=>I(),hasHydrated:()=>p,onHydrate:R=>(m.add(R),()=>{m.delete(R)}),onFinishHydration:R=>(v.add(R),()=>{v.delete(R)})},d.skipHydration||I(),P||O},Q0=W0,ut=bn(Q0(r=>({currentUserId:null,setCurrentUser:i=>r({currentUserId:i.id}),logout:()=>{const i=ut.getState().currentUserId;i&&Dt.getState().updateUserStatus(i),r({currentUserId:null})},updateUser:async(i,s)=>{try{const u=await he.patch(`${Xe.apiBaseUrl}/users/${i}`,s,{headers:{"Content-Type":"multipart/form-data"}});return await Dt.getState().fetchUsers(),u.data}catch(u){throw console.error("사용자 정보 수정 실패:",u),u}}}),{name:"user-storage",storage:Vp(()=>sessionStorage)})),Qt="data:image/png;base64,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";function Md({channel:r,isActive:i,onClick:s,hasUnread:u}){const c=ut(x=>x.currentUserId),d=Dt(x=>x.users),p=Wt(x=>x.profileImages);if(r.type==="PUBLIC")return g.jsxs(yp,{$isActive:i,onClick:s,$hasUnread:u,children:["# ",r.name]});const m=r.participantIds.map(x=>d.find(E=>E.id===x)).filter(Boolean);if(m.length>2){const x=m.filter(E=>E.id!==c).map(E=>E.username).join(", ");return g.jsxs(yd,{$isActive:i,onClick:s,children:[g.jsx(uy,{children:m.filter(E=>E.id!==c).slice(0,2).map((E,j)=>g.jsx(cy,{src:E.profileId?p[E.profileId]:Qt,style:{position:"absolute",left:j*16,zIndex:2-j}},E.id))}),g.jsxs(xd,{children:[g.jsx(vd,{$hasUnread:u,children:x}),g.jsxs(ay,{children:["멤버 ",m.length,"명"]})]})]})}const v=m.filter(x=>x.id!==c)[0];return g.jsxs(yd,{$isActive:i,onClick:s,children:[g.jsxs(ly,{children:[g.jsx("img",{src:v.profileId?p[v.profileId]:Qt,alt:"profile"}),g.jsx(vp,{$online:v.online})]}),g.jsx(xd,{children:g.jsx(vd,{$hasUnread:u,children:v.username})})]})}function q0({isOpen:r,onClose:i,user:s,onSubmit:u}){const[c,d]=ue.useState(s.username),[p,m]=ue.useState(s.email),[v,x]=ue.useState(""),[E,j]=ue.useState(null),[O,P]=ue.useState(""),[I,R]=ue.useState(null),L=Wt(T=>T.profileImages),V=Wt(T=>T.fetchProfileImage),F=ut(T=>T.logout);ue.useEffect(()=>{s.profileId&&!L[s.profileId]&&V(s.profileId)},[s.profileId,L,V]);const W=()=>{d(s.username),m(s.email),x(""),j(null),R(null),P(""),i()},K=T=>{const H=T.target.files[0];if(H){j(H);const se=new FileReader;se.onloadend=()=>{R(se.result)},se.readAsDataURL(H)}},$=async T=>{T.preventDefault(),P("");try{const H=new FormData,se={};c!==s.username&&(se.newUsername=c),p!==s.email&&(se.newEmail=p),v&&(se.newPassword=v),(Object.keys(se).length>0||E)&&(H.append("userUpdateRequest",new Blob([JSON.stringify(se)],{type:"application/json"})),E&&H.append("profile",E),await u(H)),i()}catch{P("사용자 정보 수정에 실패했습니다.")}};return r?g.jsx(b0,{children:g.jsxs(G0,{children:[g.jsx("h2",{children:"프로필 수정"}),g.jsxs("form",{onSubmit:$,children:[g.jsxs(Mi,{children:[g.jsx(Ui,{children:"프로필 이미지"}),g.jsxs(K0,{children:[g.jsx(X0,{src:I||L[s.profileId]||Qt,alt:"profile"}),g.jsx(J0,{type:"file",accept:"image/*",onChange:K,id:"profile-image"}),g.jsx(Z0,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),g.jsxs(Mi,{children:[g.jsxs(Ui,{children:["사용자명 ",g.jsx(Fd,{children:"*"})]}),g.jsx(xu,{type:"text",value:c,onChange:T=>d(T.target.value),required:!0})]}),g.jsxs(Mi,{children:[g.jsxs(Ui,{children:["이메일 ",g.jsx(Fd,{children:"*"})]}),g.jsx(xu,{type:"email",value:p,onChange:T=>m(T.target.value),required:!0})]}),g.jsxs(Mi,{children:[g.jsx(Ui,{children:"새 비밀번호"}),g.jsx(xu,{type:"password",placeholder:"변경하지 않으려면 비워두세요",value:v,onChange:T=>x(T.target.value)})]}),O&&g.jsx(Y0,{children:O}),g.jsxs(ev,{children:[g.jsx(Ud,{type:"button",onClick:W,$secondary:!0,children:"취소"}),g.jsx(Ud,{type:"submit",children:"저장"})]})]}),g.jsx(tv,{onClick:F,children:"로그아웃"})]})}):null}const b0=N.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,G0=N.div` + background: ${({theme:r})=>r.colors.background.secondary}; + padding: 32px; + border-radius: 5px; + width: 100%; + max-width: 480px; + + h2 { + color: ${({theme:r})=>r.colors.text.primary}; + margin-bottom: 24px; + text-align: center; + font-size: 24px; + } +`,xu=N.input` + width: 100%; + padding: 10px; + margin-bottom: 10px; + border: none; + border-radius: 4px; + background: ${({theme:r})=>r.colors.background.input}; + color: ${({theme:r})=>r.colors.text.primary}; + + &::placeholder { + color: ${({theme:r})=>r.colors.text.muted}; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${({theme:r})=>r.colors.brand.primary}; + } +`,Ud=N.button` + width: 100%; + padding: 10px; + border: none; + border-radius: 4px; + background: ${({$secondary:r,theme:i})=>r?"transparent":i.colors.brand.primary}; + color: ${({theme:r})=>r.colors.text.primary}; + cursor: pointer; + font-weight: 500; + + &:hover { + background: ${({$secondary:r,theme:i})=>r?i.colors.background.hover:i.colors.brand.hover}; + } +`,Y0=N.div` + color: ${({theme:r})=>r.colors.status.error}; + font-size: 14px; + margin-bottom: 10px; +`,K0=N.div` + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 20px; +`,X0=N.img` + width: 100px; + height: 100px; + border-radius: 50%; + margin-bottom: 10px; + object-fit: cover; +`,J0=N.input` + display: none; +`,Z0=N.label` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +`,ev=N.div` + display: flex; + gap: 10px; + margin-top: 20px; +`,tv=N.button` + width: 100%; + padding: 10px; + margin-top: 16px; + border: none; + border-radius: 4px; + background: transparent; + color: ${({theme:r})=>r.colors.status.error}; + cursor: pointer; + font-weight: 500; + + &:hover { + background: ${({theme:r})=>r.colors.status.error}20; + } +`,Mi=N.div` + margin-bottom: 20px; +`,Ui=N.label` + display: block; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +`,Fd=N.span` + color: ${({theme:r})=>r.colors.status.error}; +`,Wp=N.div` + position: absolute; + bottom: -3px; + right: -3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: ${r=>r.$online?J.colors.status.online:J.colors.status.offline}; + border: 4px solid ${r=>r.$background||J.colors.background.secondary}; +`,nv=N.div` + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background-color: ${({theme:r})=>r.colors.background.tertiary}; + width: 100%; + height: 52px; +`,rv=N.div` + position: relative; + width: 32px; + height: 32px; + flex-shrink: 0; +`,ov=N.img` + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +`,iv=N.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +`,sv=N.div` + font-weight: 500; + color: ${({theme:r})=>r.colors.text.primary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.875rem; + line-height: 1.2; +`,lv=N.div` + font-size: 0.75rem; + color: ${({theme:r})=>r.colors.text.secondary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +`,uv=N.div` + display: flex; + align-items: center; + flex-shrink: 0; +`,av=N.button` + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: ${({theme:r})=>r.colors.text.secondary}; + font-size: 18px; + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`;function cv({user:r}){const[i,s]=ue.useState(!1);ut(m=>m.logout);const u=ut(m=>m.updateUser),c=Wt(m=>m.profileImages),d=Wt(m=>m.fetchProfileImage);ue.useEffect(()=>{r.profileId&&!c[r.profileId]&&d(r.profileId)},[r.profileId,c,d]);const p=async m=>{await u(r.id,m)};return g.jsxs(g.Fragment,{children:[g.jsxs(nv,{children:[g.jsxs(rv,{children:[g.jsx(ov,{src:c[r.profileId]||Qt}),g.jsx(Wp,{$online:r.online,$background:J.colors.background.tertiary})]}),g.jsxs(iv,{children:[g.jsx(sv,{children:r.username}),g.jsx(lv,{children:r.email})]}),g.jsx(uv,{children:g.jsx(av,{onClick:()=>s(!0),children:"⚙️"})})]}),g.jsx(q0,{isOpen:i,onClose:()=>s(!1),user:r,onSubmit:p})]})}const fv=N.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,dv=N.div` + background: ${J.colors.background.primary}; + border-radius: 4px; + width: 440px; + max-width: 90%; +`,pv=N.div` + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; +`,hv=N.h2` + color: ${J.colors.text.primary}; + font-size: 20px; + font-weight: 600; + margin: 0; +`,mv=N.div` + padding: 0 16px 16px; +`,gv=N.form` + display: flex; + flex-direction: column; + gap: 16px; +`,Su=N.div` + display: flex; + flex-direction: column; + gap: 8px; +`,ku=N.label` + color: ${J.colors.text.primary}; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +`,yv=N.p` + color: ${J.colors.text.muted}; + font-size: 14px; + margin: -4px 0 0; +`,Fu=N.input` + padding: 10px; + background: ${J.colors.background.tertiary}; + border: none; + border-radius: 3px; + color: ${J.colors.text.primary}; + font-size: 16px; + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${J.colors.status.online}; + } + + &::placeholder { + color: ${J.colors.text.muted}; + } +`,vv=N.button` + margin-top: 8px; + padding: 12px; + background: ${J.colors.status.online}; + color: white; + border: none; + border-radius: 3px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #3ca374; + } +`,wv=N.button` + background: none; + border: none; + color: ${J.colors.text.muted}; + font-size: 24px; + cursor: pointer; + padding: 4px; + line-height: 1; + + &:hover { + color: ${J.colors.text.primary}; + } +`,xv=N(Fu)` + margin-bottom: 8px; +`,Sv=N.div` + max-height: 300px; + overflow-y: auto; + background: ${J.colors.background.tertiary}; + border-radius: 4px; +`,kv=N.div` + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: ${J.colors.background.hover}; + } + + & + & { + border-top: 1px solid ${J.colors.border.primary}; + } +`,Ev=N.input` + margin-right: 12px; + width: 16px; + height: 16px; + cursor: pointer; +`,Bd=N.img` + width: 32px; + height: 32px; + border-radius: 50%; + margin-right: 12px; +`,Cv=N.div` + flex: 1; + min-width: 0; +`,Av=N.div` + color: ${J.colors.text.primary}; + font-size: 14px; + font-weight: 500; +`,Rv=N.div` + color: ${J.colors.text.muted}; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,Pv=N.div` + padding: 16px; + text-align: center; + color: ${J.colors.text.muted}; +`,jv=N.div` + color: ${J.colors.status.error}; + font-size: 14px; + padding: 8px 0; + text-align: center; + background-color: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 4px; + margin-bottom: 8px; +`,Bn=bn((r,i)=>({channels:[],pollingInterval:null,fetchChannels:async s=>{try{const u=await he.get(`${Xe.apiBaseUrl}/channels`,{params:{userId:s}});return r({channels:u.data}),u.data}catch(u){console.error("채널 목록 조회 실패:",u)}},startPolling:s=>{i().pollingInterval&&clearInterval(i().pollingInterval);const u=setInterval(()=>{i().fetchChannels(s)},3e3);r({pollingInterval:u})},stopPolling:()=>{i().pollingInterval&&(clearInterval(i().pollingInterval),r({pollingInterval:null}))},createPublicChannel:async s=>{try{const u=await he.post(`${Xe.apiBaseUrl}/channels/public`,s),c={...u.data,participantIds:[],lastMessageAt:u.data.createdAt};return r(d=>({channels:[...d.channels,c]})),c}catch(u){throw console.error("공개 채널 생성 실패:",u),u}},createPrivateChannel:async s=>{try{const u=await he.post(`${Xe.apiBaseUrl}/channels/private`,{participantIds:s}),c={...u.data,participantIds:s,lastMessageAt:u.data.createdAt};return r(d=>({channels:[...d.channels,c]})),c}catch(u){throw console.error("비공개 채널 생성 실패:",u),u}}}));function Iv({isOpen:r,type:i,onClose:s,onCreateSuccess:u}){const[c,d]=ue.useState({name:"",description:""}),[p,m]=ue.useState(""),[v,x]=ue.useState([]),[E,j]=ue.useState(""),O=Dt($=>$.users),P=Wt($=>$.profileImages),I=ut($=>$.currentUserId),R=ue.useMemo(()=>O.filter($=>$.id!==I).filter($=>$.username.toLowerCase().includes(p.toLowerCase())||$.email.toLowerCase().includes(p.toLowerCase())),[p,O]),L=Bn($=>$.createPublicChannel),V=Bn($=>$.createPrivateChannel),F=$=>{const{name:T,value:H}=$.target;d(se=>({...se,[T]:H}))},W=$=>{x(T=>T.includes($)?T.filter(H=>H!==$):[...T,$])},K=async $=>{var T,H;$.preventDefault(),j("");try{if(i==="PUBLIC"){if(!c.name.trim()){j("채널 이름을 입력해주세요.");return}await L({name:c.name,description:c.description})}else{if(v.length===0){j("대화 상대를 선택해주세요.");return}const se=[...v,I];await V(se)}u()}catch(se){console.error("채널 생성 실패:",se),j(((H=(T=se.response)==null?void 0:T.data)==null?void 0:H.message)||"채널 생성에 실패했습니다. 다시 시도해주세요.")}};return r?g.jsx(fv,{onClick:s,children:g.jsxs(dv,{onClick:$=>$.stopPropagation(),children:[g.jsxs(pv,{children:[g.jsx(hv,{children:i==="PUBLIC"?"채널 만들기":"개인 메시지 시작하기"}),g.jsx(wv,{onClick:s,children:"×"})]}),g.jsx(mv,{children:g.jsxs(gv,{onSubmit:K,children:[E&&g.jsx(jv,{children:E}),i==="PUBLIC"?g.jsxs(g.Fragment,{children:[g.jsxs(Su,{children:[g.jsx(ku,{children:"채널 이름"}),g.jsx(Fu,{name:"name",value:c.name,onChange:F,placeholder:"새로운-채널",required:!0})]}),g.jsxs(Su,{children:[g.jsx(ku,{children:"채널 설명"}),g.jsx(yv,{children:"이 채널의 주제를 설명해주세요."}),g.jsx(Fu,{name:"description",value:c.description,onChange:F,placeholder:"채널 설명을 입력하세요"})]})]}):g.jsxs(Su,{children:[g.jsx(ku,{children:"사용자 검색"}),g.jsx(xv,{type:"text",value:p,onChange:$=>m($.target.value),placeholder:"사용자명 또는 이메일로 검색"}),g.jsx(Sv,{children:R.length>0?R.map($=>g.jsxs(kv,{children:[g.jsx(Ev,{type:"checkbox",checked:v.includes($.id),onChange:()=>W($.id)}),$.profileId?g.jsx(Bd,{src:P[$.profileId]}):g.jsx(Bd,{src:Qt}),g.jsxs(Cv,{children:[g.jsx(Av,{children:$.username}),g.jsx(Rv,{children:$.email})]})]},$.id)):g.jsx(Pv,{children:"검색 결과가 없습니다."})})]}),g.jsx(vv,{type:"submit",children:i==="PUBLIC"?"채널 만들기":"대화 시작하기"})]})})]})}):null}const yo=bn((r,i)=>({readStatuses:{},fetchReadStatuses:async()=>{try{const s=ut.getState().currentUserId;if(!s)return;const c=(await he.get(`${Xe.apiBaseUrl}/readStatuses`,{params:{userId:s}})).data.reduce((d,p)=>(d[p.channelId]={id:p.id,lastReadAt:p.lastReadAt},d),{});r({readStatuses:c})}catch(s){console.error("읽음 상태 조회 실패:",s)}},updateReadStatus:async s=>{try{const u=ut.getState().currentUserId;if(!u)return;const c=i().readStatuses[s];let d;c?d=await he.patch(`${Xe.apiBaseUrl}/readStatuses/${c.id}`,{newLastReadAt:new Date().toISOString()}):d=await he.post(`${Xe.apiBaseUrl}/readStatuses`,{userId:u,channelId:s,lastReadAt:new Date().toISOString()}),r(p=>({readStatuses:{...p.readStatuses,[s]:{id:d.data.id,lastReadAt:d.data.lastReadAt}}}))}catch(u){console.error("읽음 상태 업데이트 실패:",u)}},hasUnreadMessages:(s,u)=>{const c=i().readStatuses[s],d=c==null?void 0:c.lastReadAt;return!d||new Date(u)>new Date(d)}}));function _v({currentUser:r,activeChannel:i,onChannelSelect:s}){var K,$;const[u,c]=ue.useState({PUBLIC:!1,PRIVATE:!1}),[d,p]=ue.useState({isOpen:!1,type:null}),m=Bn(T=>T.channels),v=Bn(T=>T.fetchChannels),x=Bn(T=>T.startPolling),E=Bn(T=>T.stopPolling);yo(T=>T.readStatuses);const j=yo(T=>T.fetchReadStatuses),O=yo(T=>T.updateReadStatus),P=yo(T=>T.hasUnreadMessages);ue.useEffect(()=>{if(r)return v(r.id),j(),x(r.id),()=>{E()}},[r,v,j,x,E]);const I=T=>{c(H=>({...H,[T]:!H[T]}))},R=(T,H)=>{H.stopPropagation(),p({isOpen:!0,type:T})},L=()=>{p({isOpen:!1,type:null})},V=async T=>{try{await v(r.id),L()}catch(H){console.error("채널 생성 실패:",H)}},F=T=>{s(T),O(T.id)},W=m.reduce((T,H)=>(T[H.type]||(T[H.type]=[]),T[H.type].push(H),T),{});return g.jsxs(oy,{children:[g.jsx(fy,{}),g.jsxs(iy,{children:[g.jsxs(hd,{children:[g.jsxs(Nu,{onClick:()=>I("PUBLIC"),children:[g.jsx(md,{$folded:u.PUBLIC,children:"▼"}),g.jsx("span",{children:"일반 채널"}),g.jsx(wd,{onClick:T=>R("PUBLIC",T),children:"+"})]}),g.jsx(gd,{$folded:u.PUBLIC,children:(K=W.PUBLIC)==null?void 0:K.map(T=>g.jsx(Md,{channel:T,isActive:(i==null?void 0:i.id)===T.id,hasUnread:P(T.id,T.lastMessageAt),onClick:()=>F(T)},T.id))})]}),g.jsxs(hd,{children:[g.jsxs(Nu,{onClick:()=>I("PRIVATE"),children:[g.jsx(md,{$folded:u.PRIVATE,children:"▼"}),g.jsx("span",{children:"개인 메시지"}),g.jsx(wd,{onClick:T=>R("PRIVATE",T),children:"+"})]}),g.jsx(gd,{$folded:u.PRIVATE,children:($=W.PRIVATE)==null?void 0:$.map(T=>g.jsx(Md,{channel:T,isActive:(i==null?void 0:i.id)===T.id,hasUnread:P(T.id,T.lastMessageAt),onClick:()=>F(T)},T.id))})]})]}),g.jsx(Nv,{children:g.jsx(cv,{user:r})}),g.jsx(Iv,{isOpen:d.isOpen,type:d.type,onClose:L,onCreateSuccess:V})]})}const Nv=N.div` + margin-top: auto; + border-top: 1px solid ${({theme:r})=>r.colors.border.primary}; + background-color: ${({theme:r})=>r.colors.background.tertiary}; +`,Ov=N.div` + flex: 1; + display: flex; + flex-direction: column; + background: ${({theme:r})=>r.colors.background.primary}; +`,Tv=N.div` + display: flex; + flex-direction: column; + height: 100%; + background: ${({theme:r})=>r.colors.background.primary}; +`,Lv=N(Tv)` + justify-content: center; + align-items: center; + flex: 1; + padding: 0 20px; +`,Dv=N.div` + text-align: center; + max-width: 400px; + padding: 20px; + margin-bottom: 80px; +`,zv=N.div` + font-size: 48px; + margin-bottom: 16px; + animation: wave 2s infinite; + transform-origin: 70% 70%; + + @keyframes wave { + 0% { transform: rotate(0deg); } + 10% { transform: rotate(14deg); } + 20% { transform: rotate(-8deg); } + 30% { transform: rotate(14deg); } + 40% { transform: rotate(-4deg); } + 50% { transform: rotate(10deg); } + 60% { transform: rotate(0deg); } + 100% { transform: rotate(0deg); } + } +`,Mv=N.h2` + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 28px; + font-weight: 700; + margin-bottom: 16px; +`,Uv=N.p` + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + line-height: 1.6; + word-break: keep-all; +`,$d=N.div` + height: 48px; + padding: 0 16px; + background: ${J.colors.background.primary}; + border-bottom: 1px solid ${J.colors.border.primary}; + display: flex; + align-items: center; +`,Hd=N.div` + display: flex; + align-items: center; + gap: 8px; + height: 100%; +`,Fv=N.div` + display: flex; + align-items: center; + gap: 12px; + height: 100%; +`,Bv=N.div` + position: relative; + width: 24px; + height: 24px; + flex-shrink: 0; +`,Vd=N.img` + width: 24px; + height: 24px; + border-radius: 50%; +`,$v=N.div` + position: relative; + width: 40px; + height: 24px; + flex-shrink: 0; +`,Hv=N.div` + position: absolute; + bottom: -2px; + right: -2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: ${r=>r.online?J.colors.status.online:J.colors.status.offline}; + border: 3px solid ${J.colors.background.secondary}; +`,Vv=N(Hv)` + border-color: ${J.colors.background.primary}; + bottom: -3px; + right: -3px; +`,Wv=N.div` + font-size: 12px; + color: ${J.colors.text.muted}; + line-height: 13px; +`,Wd=N.div` + font-weight: bold; + color: ${J.colors.text.primary}; + line-height: 20px; + font-size: 16px; +`,Qv=N.div` + flex: 1; + display: flex; + flex-direction: column-reverse; + overflow-y: auto; +`,qv=N.div` + padding: 16px; + display: flex; + flex-direction: column; +`,bv=N.div` + margin-bottom: 16px; + display: flex; + align-items: flex-start; +`,Gv=N.div` + position: relative; + margin-right: 16px; + flex-shrink: 0; +`,Yv=N.img` + width: 40px; + height: 40px; + border-radius: 50%; +`,Kv=N.div` + display: flex; + align-items: center; + margin-bottom: 4px; +`,Xv=N.span` + font-weight: bold; + color: ${J.colors.text.primary}; + margin-right: 8px; +`,Jv=N.span` + font-size: 0.75rem; + color: ${J.colors.text.muted}; +`,Zv=N.div` + color: ${J.colors.text.secondary}; + margin-top: 4px; +`,e1=N.form` + display: flex; + align-items: center; + gap: 8px; + padding: 16px; + background: ${({theme:r})=>r.colors.background.secondary}; +`,t1=N.textarea` + flex: 1; + padding: 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border: none; + border-radius: 4px; + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 14px; + resize: none; + min-height: 44px; + max-height: 144px; + + &:focus { + outline: none; + } + + &::placeholder { + color: ${({theme:r})=>r.colors.text.muted}; + } +`,n1=N.button` + background: none; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`;N.div` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: ${J.colors.text.muted}; + font-size: 16px; + font-weight: 500; + padding: 20px; + text-align: center; +`;const Qd=N.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + width: 100%; +`,r1=N.a` + display: block; + border-radius: 4px; + overflow: hidden; + max-width: 300px; + + img { + width: 100%; + height: auto; + display: block; + } +`,o1=N.a` + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 8px; + text-decoration: none; + width: fit-content; + + &:hover { + background: ${({theme:r})=>r.colors.background.hover}; + } +`,i1=N.div` + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: #0B93F6; +`,s1=N.div` + display: flex; + flex-direction: column; + gap: 2px; +`,l1=N.span` + font-size: 14px; + color: #0B93F6; + font-weight: 500; +`,u1=N.span` + font-size: 13px; + color: ${({theme:r})=>r.colors.text.muted}; +`,a1=N.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 0; +`,Qp=N.div` + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 4px; + max-width: 300px; +`,c1=N(Qp)` + padding: 0; + overflow: hidden; + width: 200px; + height: 120px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`,f1=N.div` + color: #0B93F6; + font-size: 20px; +`,d1=N.div` + font-size: 13px; + color: ${({theme:r})=>r.colors.text.primary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,qd=N.button` + position: absolute; + top: -6px; + right: -6px; + width: 20px; + height: 20px; + border-radius: 50%; + background: ${({theme:r})=>r.colors.background.secondary}; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`,vo=bn((r,i)=>({messages:[],pollingIntervals:{},lastMessageId:null,fetchMessages:async s=>{try{const u=await he.get(`${Xe.apiBaseUrl}/messages`,{params:{channelId:s}}),c=u.data[u.data.length-1],d=(c==null?void 0:c.id)!==i().lastMessageId;return r({messages:u.data,lastMessageId:c==null?void 0:c.id}),d}catch(u){return console.error("메시지 목록 조회 실패:",u),!1}},startPolling:s=>{const u=i();u.pollingIntervals[s]&&clearTimeout(u.pollingIntervals[s]);let c=300;const d=3e3;r(m=>({pollingIntervals:{...m.pollingIntervals,[s]:!0}}));const p=async()=>{const m=i();if(!m.pollingIntervals[s])return;if(await m.fetchMessages(s)?c=300:c=Math.min(c*1.5,d),i().pollingIntervals[s]){const x=setTimeout(p,c);r(E=>({pollingIntervals:{...E.pollingIntervals,[s]:x}}))}};p()},stopPolling:s=>{const{pollingIntervals:u}=i();if(u[s]){const c=u[s];typeof c=="number"&&clearTimeout(c),r(d=>{const p={...d.pollingIntervals};return delete p[s],{pollingIntervals:p}})}},createMessage:async s=>{try{const u=new FormData;u.append("messageCreateRequest",new Blob([JSON.stringify({content:s.content,channelId:s.channelId,authorId:s.authorId})],{type:"application/json"})),s.attachments&&s.attachments.forEach(p=>{u.append("attachments",p)});const c=await he.post(`${Xe.apiBaseUrl}/messages`,u,{headers:{"Content-Type":"multipart/form-data"}}),d=yo.getState().updateReadStatus;return await d(s.channelId),r(p=>({messages:[...p.messages,c.data]})),c.data}catch(u){throw console.error("메시지 생성 실패:",u),u}}})),p1=bn((r,i)=>({attachments:{},fetchAttachment:async s=>{if(i().attachments[s])return i().attachments[s];try{const u=await he.get(`${Xe.apiBaseUrl}/binaryContents/${s}`),{bytes:c,contentType:d,fileName:p,size:m}=u.data,x={url:`data:${d};base64,${c}`,contentType:d,originalName:p,size:m};return r(E=>({attachments:{...E.attachments,[s]:x}})),x}catch(u){return console.error("첨부파일 정보 조회 실패:",u),null}}})),h1=r=>r<1024?r+" B":r<1024*1024?(r/1024).toFixed(2)+" KB":r<1024*1024*1024?(r/(1024*1024)).toFixed(2)+" MB":(r/(1024*1024*1024)).toFixed(2)+" GB";function m1({channel:r}){const i=vo(P=>P.messages),s=vo(P=>P.fetchMessages),u=vo(P=>P.startPolling),c=vo(P=>P.stopPolling),d=Wt(P=>P.profileImages),p=Dt(P=>P.users),{attachments:m,fetchAttachment:v}=p1();ue.useEffect(()=>{if(r!=null&&r.id)return s(r.id),u(r.id),()=>{c(r.id)}},[r==null?void 0:r.id,s,u,c]),ue.useEffect(()=>{i.forEach(P=>{var I;(I=P.attachmentIds)==null||I.forEach(R=>{m[R]||v(R)})})},[i,m,v]);const x=async(P,I)=>{try{const R=await he.get(`${Xe.apiBaseUrl}/binaryContents/${P}`,{responseType:"blob"}),L=new Blob([R.data],{type:R.headers["content-type"]}),V=window.URL.createObjectURL(L),F=document.createElement("a");F.href=V,F.download=I,F.style.display="none",document.body.appendChild(F);try{const K=await(await window.showSaveFilePicker({suggestedName:I,types:[{description:"Files",accept:{"*/*":[".txt",".pdf",".doc",".docx",".xls",".xlsx",".jpg",".jpeg",".png",".gif"]}}]})).createWritable();await K.write(L),await K.close()}catch(W){W.name!=="AbortError"&&F.click()}document.body.removeChild(F),window.URL.revokeObjectURL(V)}catch(R){console.error("파일 다운로드 실패:",R)}},E=P=>P!=null&&P.length?P.map(I=>{const R=m[I];return R?R.contentType.startsWith("image/")?g.jsx(Qd,{children:g.jsx(r1,{href:"#",onClick:V=>{V.preventDefault(),x(I,R.originalName)},children:g.jsx("img",{src:R.url,alt:R.originalName})})},I):g.jsx(Qd,{children:g.jsxs(o1,{href:"#",onClick:V=>{V.preventDefault(),x(I,R.originalName)},children:[g.jsx(i1,{children:g.jsxs("svg",{width:"40",height:"40",viewBox:"0 0 40 40",fill:"none",children:[g.jsx("path",{d:"M8 3C8 1.89543 8.89543 1 10 1H22L32 11V37C32 38.1046 31.1046 39 30 39H10C8.89543 39 8 38.1046 8 37V3Z",fill:"#0B93F6",fillOpacity:"0.1"}),g.jsx("path",{d:"M22 1L32 11H24C22.8954 11 22 10.1046 22 9V1Z",fill:"#0B93F6",fillOpacity:"0.3"}),g.jsx("path",{d:"M13 19H27M13 25H27M13 31H27",stroke:"#0B93F6",strokeWidth:"2",strokeLinecap:"round"})]})}),g.jsxs(s1,{children:[g.jsx(l1,{children:R.originalName}),g.jsx(u1,{children:h1(R.size)})]})]})},I):null}):null,j=P=>new Date(P).toLocaleTimeString(),O=[...i].sort((P,I)=>P.createdAt.localeCompare(I.createdAt));return g.jsx(Qv,{children:g.jsx(qv,{children:O.map(P=>{const I=p.find(R=>R.id===P.authorId);return g.jsxs(bv,{children:[g.jsx(Gv,{children:g.jsx(Yv,{src:I&&I.profileId?d[I.profileId]:Qt,alt:I&&I.username||"알 수 없음"})}),g.jsxs("div",{children:[g.jsxs(Kv,{children:[g.jsx(Xv,{children:I&&I.username||"알 수 없음"}),g.jsx(Jv,{children:j(P.createdAt)})]}),g.jsx(Zv,{children:P.content}),E(P.attachmentIds)]})]},P.id)})})})}function g1({channel:r}){const[i,s]=ue.useState(""),[u,c]=ue.useState([]),d=vo(O=>O.createMessage),p=ut(O=>O.currentUserId),m=async O=>{if(O.preventDefault(),!(!i.trim()&&u.length===0))try{await d({content:i.trim(),channelId:r.id,authorId:p,attachments:u}),s(""),c([])}catch(P){console.error("메시지 전송 실패:",P)}},v=O=>{const P=Array.from(O.target.files);c(I=>[...I,...P]),O.target.value=""},x=O=>{c(P=>P.filter((I,R)=>R!==O))},E=O=>{O.key==="Enter"&&!O.shiftKey&&(O.preventDefault(),m(O))},j=(O,P)=>O.type.startsWith("image/")?g.jsxs(c1,{children:[g.jsx("img",{src:URL.createObjectURL(O),alt:O.name}),g.jsx(qd,{onClick:()=>x(P),children:"×"})]},P):g.jsxs(Qp,{children:[g.jsx(f1,{children:"📎"}),g.jsx(d1,{children:O.name}),g.jsx(qd,{onClick:()=>x(P),children:"×"})]},P);return ue.useEffect(()=>()=>{u.forEach(O=>{O.type.startsWith("image/")&&URL.revokeObjectURL(O)})},[u]),r?g.jsxs(g.Fragment,{children:[u.length>0&&g.jsx(a1,{children:u.map((O,P)=>j(O,P))}),g.jsxs(e1,{onSubmit:m,children:[g.jsxs(n1,{as:"label",children:["+",g.jsx("input",{type:"file",multiple:!0,onChange:v,style:{display:"none"}})]}),g.jsx(t1,{value:i,onChange:O=>s(O.target.value),onKeyPress:E,placeholder:r.type==="PUBLIC"?`#${r.name}에 메시지 보내기`:"메시지 보내기"})]})]}):null}function y1({channel:r}){const i=ut(v=>v.currentUserId),s=Dt(v=>v.users),u=Wt(v=>v.profileImages);if(!r)return null;if(r.type==="PUBLIC")return g.jsx($d,{children:g.jsx(Hd,{children:g.jsxs(Wd,{children:["# ",r.name]})})});const c=r.participantIds.map(v=>s.find(x=>x.id===v)).filter(Boolean),d=c.filter(v=>v.id!==i),p=c.length>2,m=c.filter(v=>v.id!==i).map(v=>v.username).join(", ");return g.jsx($d,{children:g.jsx(Hd,{children:g.jsxs(Fv,{children:[p?g.jsx($v,{children:d.slice(0,2).map((v,x)=>g.jsx(Vd,{src:v.profileId?u[v.profileId]:Qt,style:{position:"absolute",left:x*16,zIndex:2-x}},v.id))}):g.jsxs(Bv,{children:[g.jsx(Vd,{src:d[0].profileId?u[d[0].profileId]:Qt}),g.jsx(Vv,{online:d[0].online})]}),g.jsxs("div",{children:[g.jsx(Wd,{children:m}),p&&g.jsxs(Wv,{children:["멤버 ",c.length,"명"]})]})]})})})}function v1({channel:r}){return r?g.jsxs(Ov,{children:[g.jsx(y1,{channel:r}),g.jsx(m1,{channel:r}),g.jsx(g1,{channel:r})]}):g.jsx(Lv,{children:g.jsxs(Dv,{children:[g.jsx(zv,{children:"👋"}),g.jsx(Mv,{children:"채널을 선택해주세요"}),g.jsxs(Uv,{children:["왼쪽의 채널 목록에서 채널을 선택하여",g.jsx("br",{}),"대화를 시작하세요."]})]})})}const w1=N.div` + width: 240px; + background: ${J.colors.background.secondary}; + border-left: 1px solid ${J.colors.border.primary}; +`,x1=N.div` + padding: 16px; + font-size: 14px; + font-weight: bold; + color: ${J.colors.text.muted}; + text-transform: uppercase; +`,S1=N.div` + padding: 8px 16px; + display: flex; + align-items: center; + color: ${J.colors.text.muted}; +`,k1=N.div` + position: relative; + width: 32px; + height: 32px; + margin-right: 12px; +`,bd=N.img` + width: 100%; + height: 100%; + border-radius: 50%; +`,E1=N.div` + display: flex; + align-items: center; +`;N.div` + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; + background: ${r=>J.colors.status[r.status]}; +`;function C1({member:r}){const i=Wt(u=>u.profileImages),s=Wt(u=>u.fetchProfileImage);return ue.useEffect(()=>{r.profileId&&!i[r.profileId]&&s(r.profileId)},[r.profileId,i,s]),g.jsxs(S1,{children:[g.jsxs(k1,{children:[i[r.profileId]?g.jsx(bd,{src:i[r.profileId]}):g.jsx(bd,{src:Qt}),g.jsx(Wp,{$online:r.online})]}),g.jsx(E1,{children:r.username})]})}function A1(){const r=Dt(c=>c.users),i=Dt(c=>c.fetchUsers),s=ut(c=>c.currentUserId);ue.useEffect(()=>{i()},[i]);const u=[...r].sort((c,d)=>c.id===s?-1:d.id===s?1:c.online&&!d.online?-1:!c.online&&d.online?1:c.username.localeCompare(d.username));return g.jsxs(w1,{children:[g.jsxs(x1,{children:["멤버 목록 - ",r.length]}),u.map(c=>g.jsx(C1,{member:c},c.id))]})}const qp=N.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,bp=N.div` + background: ${J.colors.background.primary}; + padding: 32px; + border-radius: 8px; + width: 440px; + + h2 { + color: ${J.colors.text.primary}; + margin-bottom: 24px; + font-size: 24px; + font-weight: bold; + } + + form { + display: flex; + flex-direction: column; + gap: 16px; + } +`,xo=N.input` + width: 100%; + padding: 10px; + border-radius: 4px; + background: ${J.colors.background.input}; + border: none; + color: ${J.colors.text.primary}; + font-size: 16px; + + &::placeholder { + color: ${J.colors.text.muted}; + } + + &:focus { + outline: none; + } +`,Gp=N.button` + width: 100%; + padding: 12px; + border-radius: 4px; + background: ${J.colors.brand.primary}; + color: white; + font-size: 16px; + font-weight: 500; + border: none; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: ${J.colors.brand.hover}; + } +`,Yp=N.div` + color: ${J.colors.status.error}; + font-size: 14px; + text-align: center; +`,R1=N.p` + text-align: center; + margin-top: 16px; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 14px; +`,P1=N.span` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`;function j1({isOpen:r,onClose:i}){const[s,u]=ue.useState(""),[c,d]=ue.useState(""),[p,m]=ue.useState(""),[v,x]=ue.useState(null),[E,j]=ue.useState(null),[O,P]=ue.useState(""),I=ut(V=>V.setCurrentUser),R=V=>{const F=V.target.files[0];if(F){x(F);const W=new FileReader;W.onloadend=()=>{j(W.result)},W.readAsDataURL(F)}},L=async V=>{V.preventDefault(),P("");try{const F=new FormData;F.append("userCreateRequest",new Blob([JSON.stringify({email:s,username:c,password:p})],{type:"application/json"})),v&&F.append("profile",v);const W=await he.post(`${Xe.apiBaseUrl}/users`,F);I(W.data),i()}catch{P("회원가입에 실패했습니다.")}};return r?g.jsx(qp,{children:g.jsxs(bp,{children:[g.jsx("h2",{children:"계정 만들기"}),g.jsxs("form",{onSubmit:L,children:[g.jsxs(Fi,{children:[g.jsxs(Bi,{children:["이메일 ",g.jsx(Eu,{children:"*"})]}),g.jsx(xo,{type:"email",value:s,onChange:V=>u(V.target.value),required:!0})]}),g.jsxs(Fi,{children:[g.jsxs(Bi,{children:["사용자명 ",g.jsx(Eu,{children:"*"})]}),g.jsx(xo,{type:"text",value:c,onChange:V=>d(V.target.value),required:!0})]}),g.jsxs(Fi,{children:[g.jsxs(Bi,{children:["비밀번호 ",g.jsx(Eu,{children:"*"})]}),g.jsx(xo,{type:"password",value:p,onChange:V=>m(V.target.value),required:!0})]}),g.jsxs(Fi,{children:[g.jsx(Bi,{children:"프로필 이미지"}),g.jsxs(I1,{children:[g.jsx(_1,{src:E||Qt,alt:"profile"}),g.jsx(N1,{type:"file",accept:"image/*",onChange:R,id:"profile-image"}),g.jsx(O1,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),O&&g.jsx(Yp,{children:O}),g.jsx(Gp,{type:"submit",children:"계속하기"}),g.jsx(L1,{onClick:i,children:"이미 계정이 있으신가요?"})]})]})}):null}const Fi=N.div` + margin-bottom: 20px; +`,Bi=N.label` + display: block; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +`,Eu=N.span` + color: ${({theme:r})=>r.colors.status.error}; +`,I1=N.div` + display: flex; + flex-direction: column; + align-items: center; + margin: 10px 0; +`,_1=N.img` + width: 80px; + height: 80px; + border-radius: 50%; + margin-bottom: 10px; + object-fit: cover; +`,N1=N.input` + display: none; +`,O1=N.label` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +`;N.p` + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 12px; + margin-top: 16px; + text-align: center; +`;const T1=N.span` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`,L1=N(T1)` + display: block; + text-align: center; + margin-top: 16px; +`,D1=({isOpen:r,onClose:i})=>{const[s,u]=ue.useState(""),[c,d]=ue.useState(""),[p,m]=ue.useState(""),[v,x]=ue.useState(!1),E=ut(P=>P.setCurrentUser),{fetchUsers:j}=Dt(),O=async()=>{var P;try{const I=await he.post(`${Xe.apiBaseUrl}/auth/login`,{username:s,password:c});I.status===200&&(await j(),E(I.data),m(""),i())}catch(I){console.error("로그인 에러:",I),((P=I.response)==null?void 0:P.status)===401?m("아이디 또는 비밀번호가 올바르지 않습니다."):m("로그인에 실패했습니다.")}};return r?g.jsxs(g.Fragment,{children:[g.jsx(qp,{children:g.jsxs(bp,{children:[g.jsx("h2",{children:"돌아오신 것을 환영해요!"}),g.jsxs("form",{onSubmit:P=>{P.preventDefault(),O()},children:[g.jsx(xo,{type:"text",placeholder:"사용자 이름",value:s,onChange:P=>u(P.target.value)}),g.jsx(xo,{type:"password",placeholder:"비밀번호",value:c,onChange:P=>d(P.target.value)}),p&&g.jsx(Yp,{children:p}),g.jsx(Gp,{type:"submit",children:"로그인"})]}),g.jsxs(R1,{children:["계정이 필요한가요? ",g.jsx(P1,{onClick:()=>x(!0),children:"가입하기"})]})]})}),g.jsx(j1,{isOpen:v,onClose:()=>x(!1)})]}):null};function z1(){const r=ut(v=>v.currentUserId),i=Dt(v=>v.users),{fetchUsers:s,updateUserStatus:u}=Dt(),[c,d]=ue.useState(null),p=Bn(v=>v.channels),m=r?i.find(v=>v.id===r):null;return ue.useEffect(()=>{let v;if(r){s(),u(r),v=setInterval(()=>{u(r)},3e4);const x=setInterval(()=>{s()},6e4);return()=>{clearInterval(v),clearInterval(x)}}},[r,s,u]),g.jsx(ty,{theme:J,children:m?g.jsxs(M1,{children:[g.jsx(_v,{channels:p,currentUser:m,activeChannel:c,onChannelSelect:d}),g.jsx(v1,{channel:c}),g.jsx(A1,{})]}):g.jsx(D1,{isOpen:!0,onClose:()=>{}})})}const M1=N.div` + display: flex; + height: 100vh; + width: 100vw; + position: relative; +`;eg.createRoot(document.getElementById("root")).render(g.jsx(ue.StrictMode,{children:g.jsx(z1,{})})); diff --git a/src/main/resources/static/assets/index-kQJbKSsj.css b/src/main/resources/static/assets/index-kQJbKSsj.css new file mode 100644 index 000000000..096eb4112 --- /dev/null +++ b/src/main/resources/static/assets/index-kQJbKSsj.css @@ -0,0 +1 @@ +:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}} diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..479bed6a3da0a8dbdd08a51d81b30e4d4fabae89 GIT binary patch literal 1588 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyacIC_6YK2V5m}KU}$JzVE6?TYIwoGP-?)y@G60U!Dv>Mu*Du8ycRt4Yw>0&$ytddU zdTHwA$vlU)7;*ZQn^d>r9eiw}SEV3v&DP3PpZVm?c2D=&D? zJg+7dT;x9cg;(mDqrovi2QemjySudY+_R1aaySb-B8!2p69!>MhFNnYfC{QST^vI! zPM@6=9?WDY()wLtM|S>=KoQ44K~Zk4us5=<8xs!eeY>~&=ly4!jD%AXj+wvro>aU~ zrMO$=?`j4U&ZyW$Je*!Zo0>H2RZVqmn^V&mZ(9Dkv!~|IuDF1RBN|EPJE zX3ok)rzF<3&vZKWEj4ag73&t}uJvVk^<~M;*V0n54#8@&v!WGjE_hAaeAZEF z$~V4aF>{^dUc7o%=f8f9m%*2vzjfI@vJ2Z97)VU5x-s2*r@e{H>FEn3A3Dr3G&8U| z)>wFiQO&|Yl6}UkXAQ>%q$jNWac-tTL*)AEyto|onkmnmcJLf?71w_<>4WODmBMxF zwGM7``txcQgT`x>(tH-DrT2Kg=4LzpNv>|+a@TgYDZ`5^$KJVb`K=%k^tRpoxP|4? zwXb!O5~dXYKYt*j(YSx+#_rP{TNcK=40T|)+k3s|?t||EQTgwGgs{E0Y+(QPL&Wx4 zMP23By&sn`zn7oCQQLp%-(Axm|M=5-u;TlFiTn5B^PWnb%fAPV8r2flh?11Vl2ohY zqEsNoU}Ruqple{LYiJr`U}|M-Vr62aZD3$!V6dZTmJ5o8-29Zxv`X9>PU+TH>UWRL)v7?M$%n`C9>lAm0fo0?Z*WfcHaTFhX${Qqu! zG&Nv5t*kOqGt)Cl7z{0q_!){?fojB&%z>&2&rB)F04ce=Mv()kL=s7fZ)R?4No7GQ z1K3si1$pWAo5K9i%<&BYs$wuSHMcY{Gc&O;(${(hEL0izk<1CstV(4taB`Zm$nFhL zDhx>~G{}=7Ei)$-=zaa%ypo*!bp5o%vdrZCykdPs#ORw@rkW)uCz=~4Cz={1nkQNs oC7PHSBpVtgnwc6|q*&+yb?5=zccWrGsMu%lboFyt=akR{0N~++#sB~S literal 0 HcmV?d00001 diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 000000000..66e849757 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,26 @@ + + + + + + Discodeit + + + + + +

+ + diff --git a/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java new file mode 100644 index 000000000..3a987a214 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DiscodeitApplicationTests { + + @Test + void contextLoads() { + } + +} From dc3f14ab8d3d955c0ec69c78e776f112d0f4ff76 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Mon, 28 Jul 2025 13:14:41 +0900 Subject: [PATCH 02/28] =?UTF-8?q?Sprint6=20=EA=B3=BC=EC=A0=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/basic/BasicUserRepository.java | 13 --- .../service/file/FileChannelService.java | 96 ----------------- .../service/file/FileMessageService.java | 101 ------------------ .../service/file/FileUserService.java | 90 ---------------- .../service/jcf/JCFChannelService.java | 70 ------------ .../service/jcf/JCFMessageService.java | 64 ----------- .../discodeit/service/jcf/JCFUserService.java | 64 ----------- src/main/resources/static/schema.sql | 75 +++++++++++++ 8 files changed, 75 insertions(+), 498 deletions(-) delete mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserRepository.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/service/file/FileChannelService.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/service/file/FileMessageService.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/service/file/FileUserService.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/service/jcf/JCFChannelService.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/service/jcf/JCFMessageService.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/service/jcf/JCFUserService.java create mode 100644 src/main/resources/static/schema.sql diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserRepository.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserRepository.java deleted file mode 100644 index 0514f0885..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -//package com.sprint.mission.discodeit.service.basic; -// -//import com.sprint.mission.discodeit.repository.UserRepository; -//import com.sprint.mission.discodeit.service.UserService; -// -//public class BasicUserRepository implements UserService { -// private final UserRepository userRepository; -// -// -// public BasicUserRepository(UserRepository userRepository) { -// this.userRepository = userRepository; -// } -//} diff --git a/src/main/java/com/sprint/mission/discodeit/service/file/FileChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/file/FileChannelService.java deleted file mode 100644 index b3b9bd8a0..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/file/FileChannelService.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.sprint.mission.discodeit.service.file; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.service.ChannelService; - -import java.io.*; -import java.util.*; - -public class FileChannelService implements ChannelService { - private final String filePath = "channels.ser"; - - @Override - public Channel createChannel(Channel channel) { - Map data = readChannelFile(); - - Optional optionalChannel = findByChannelName(channel.getChannel()); - if (optionalChannel.isPresent()) { - System.out.println("이미 존재하는 채널명입니다: " + channel.getChannel()); - return optionalChannel.get(); - } - - data.put(channel.getId(), channel); - writeChannelFile(data); - System.out.println("channels.ser 파일에 작성을 완료했습니다."); - return channel; - } - - @Override - public Channel getChannel(UUID id) { - return validationChannel(id).orElse(null); - } - - @Override - public List getChannels() { - return new ArrayList<>(readChannelFile().values()); - } - - @Override - public void updateChannel(UUID id, String updateTitle) { - Map data = readChannelFile(); - validationChannel(id).ifPresentOrElse( - channel -> { - channel.updateChannel(updateTitle); - writeChannelFile(data); - System.out.println("채널 업데이트 완료: " + updateTitle); - }, - () -> System.out.println("존재하지 않는 채널입니다.") - ); - } - - @Override - public void deleteChannel(UUID id) { - Map data = readChannelFile(); - validationChannel(id).ifPresentOrElse( - channel -> { - data.remove(id); - writeChannelFile(data); - System.out.println("채널 삭제 완료"); - }, - () -> System.out.println("삭제할 채널이 없습니다.") - ); - } - - @Override - public Optional findByChannelName(String title) { - Map data = readChannelFile(); - return data.values().stream() - .filter(channel -> channel.getChannel().equals(title)) - .findFirst(); - } - - public Optional validationChannel(UUID id) { - return Optional.ofNullable(readChannelFile().get(id)); - } - - //채널 파일 READ - private Map readChannelFile() { - try(ObjectInputStream in = new ObjectInputStream(new FileInputStream(filePath))){ - return (Map) in.readObject(); - } catch (IOException | ClassNotFoundException e) { - return new HashMap<>(); - } - } - - //채널 파일에 WRITE - private void writeChannelFile(Map data) { - try(ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("channels.ser"))){ - out.writeObject(data); - } catch (IOException e) { - e.printStackTrace(); - } - } - - -} diff --git a/src/main/java/com/sprint/mission/discodeit/service/file/FileMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/file/FileMessageService.java deleted file mode 100644 index c165f213e..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/file/FileMessageService.java +++ /dev/null @@ -1,101 +0,0 @@ -/* -package com.sprint.mission.discodeit.service.file; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.entity.Message; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.service.MessageService; - -import java.io.*; -import java.util.*; - -public class FileMessageService implements MessageService { - private String filePath = "message.ser"; - - public Message createMessage(Message message, User user, Channel channel) { - if(message == null || user == null || channel == null){ - System.out.println(("message, user, channel은 null일 수 없습니다.")); - } - Map data = readMessageFile(); - data.put(message.getId(), message); - writeMessageFile(data); - - user.addMessage(message); - channel.addMessage(message); - channel.addUser(user); - message.addUser(user); - return message; - } - - public Message getMessage(UUID id) { - Map data = readMessageFile(); - return validationMessage(id, data).orElse(null); - } - - public List getMessages() { - Map data = readMessageFile(); - return new ArrayList<>(data.values()); - } - - public void updateMessage(UUID id, String newMessage) { - Map data = readMessageFile(); - validationMessage(id, data).ifPresentOrElse( - msg -> { - msg.updateContent(newMessage); - writeMessageFile(data); - System.out.println("메시지 업데이트 완료"); - }, - () -> System.out.println("수정할 메시지가 존재하지 않습니다.") - ); - } - - public void deleteMessage(UUID id) { - Map data = readMessageFile(); - if(data.containsKey(id)){ - data.remove(id); - writeMessageFile(data); - System.out.println("메시지 삭제 완료"); - } else { - System.out.println("삭제할 메시지가 존재하지 않습니다."); - } - } - - public Optional findByMessage(String content) { - Map data = readMessageFile(); - return data.values() - .stream() - .filter(msg -> msg.getContent().equals(content)) - .findFirst(); - } - - public Optional validationMessage(UUID id, Map data) { - return Optional.ofNullable(data.get(id)); - } - - public Optional validationMessage(UUID id) { - Map data = readMessageFile(); - return validationMessage(id, data); - } - - - //메세지 파일 READ - @SuppressWarnings("unchecked") - private Map readMessageFile() { - try(ObjectInputStream in = new ObjectInputStream(new FileInputStream(filePath))){ - return (Map) in.readObject(); - } catch (IOException | ClassNotFoundException e) { - System.out.println("파일을 읽는 과정에서 에러 발생"); - return new HashMap<>(); - } - } - - //메세지 파일에 WRITE - private void writeMessageFile(Map data) { - try(ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("Message.ser"))){ - out.writeObject(data); - } catch (IOException e) { - e.printStackTrace(); - } - } -} -*/ \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/service/file/FileUserService.java b/src/main/java/com/sprint/mission/discodeit/service/file/FileUserService.java deleted file mode 100644 index 4755f97af..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/file/FileUserService.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.sprint.mission.discodeit.service.file; - -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.service.UserService; - -import java.io.*; -import java.nio.file.Path; -import java.util.*; - -public class FileUserService implements UserService { - private final String filePath = "users.ser"; - - public User createUser(User user) { - Map data = readUserFile(); - findByUserId(user.getId(), data).ifPresentOrElse( - existingUser -> System.out.println("이미 존재하는 유저 입니다."), - () -> { - data.put(user.getId(), user); - writeUserFile(data); - System.out.println("users.ser 파일에 작성을 완료했습니다"); - } - ); - return user; - } - - public User getUser(UUID id) { - Map data = readUserFile(); - return findByUserId(id, data).orElse(null); - - } - - public List getUsers() { - Map data = readUserFile(); - return new ArrayList<>(data.values()); - } - - public void updateUser(UUID id, String username) { - Map data = readUserFile(); - findByUserId(id, data).ifPresentOrElse( - user -> { - user.updateName(username); - writeUserFile(data); - System.out.println("유저 업데이트 완료"); - }, - () -> System.out.println("존재하지 않는 유저입니다.") - ); - } - - public void deleteUser(UUID id) { - Map data = readUserFile(); - findByUserId(id, data).ifPresentOrElse( - user -> { - data.remove(id); - writeUserFile(data); - System.out.println("유저 삭제 완료"); - }, - () -> System.out.println("삭제할 유저가 없습니다: " + id) - ); - } - - public Optional findByUserId(UUID id , Map data) { - return Optional.ofNullable(data.get(id)); - } - - - public Optional findByUserId(UUID id) { - Map data = readUserFile(); - return findByUserId(id, data); - } - - //유저 파일 READ - @SuppressWarnings("unchecked") - private Map readUserFile() { - try(ObjectInputStream in = new ObjectInputStream(new FileInputStream(filePath))){ - return (Map) in.readObject(); - } catch (IOException | ClassNotFoundException e) { - return new HashMap<>(); - } - } - - //유저 파일에 WRITE - private void writeUserFile(Map data) { - try(ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("users.ser"))){ - out.writeObject(data); - } catch (IOException e) { - e.printStackTrace(); - } - } -} - diff --git a/src/main/java/com/sprint/mission/discodeit/service/jcf/JCFChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/jcf/JCFChannelService.java deleted file mode 100644 index cb9c61014..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/jcf/JCFChannelService.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.sprint.mission.discodeit.service.jcf; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.service.ChannelService; - -import java.util.*; - -public class JCFChannelService implements ChannelService { - Map data = new HashMap<>(); - - @Override - public Channel createChannel(Channel channel) { - //내가 만든 어플에서는 중복이 안되지만, 다른사람이 만든 어플에서는 중복이 될 수 있음 -> 핵심 비즈니스 로직 중 1개가 될수있음 - //채널이름 중복체크 - //1. 받아온 파라미터로 채널 생성 시도 - //2. 중복되는 채널이 있는지 리스트에서 조회 (파라미터로 받은 채널의 이름이 있는지 조회) - //3. 중복되는 채널 있으면 저장 X - //4. 중복되는 채널 없으면 저장 O - Optional optionalChannel = findByChannelName(channel.getChannel()); - if (optionalChannel.isPresent()) { - System.out.println("이미 존재하는 채널입니다."); - } else { - data.put(channel.getId(), channel); - } - return channel; - } - - @Override - public Channel getChannel(UUID id) { - return validationChannel(id); - } - - @Override - public List getChannels() { - return new ArrayList<>(data.values()); - } - - @Override - public void updateChannel(UUID id, String updateTitle) { - //조회 -> 검증한 후 -> 반영한 후 -> 저장 - Channel channel = validationChannel(id); - channel.updateChannel(updateTitle); - } - - - @Override - public void deleteChannel(UUID id) { - Channel channel = validationChannel(id); - if(channel != null){ - data.remove(id); - System.out.println("채널 삭제 성공"); - } - } - - - public Optional findByChannelName(String title) { - return data.values() - .stream() - .filter(channel -> channel.getChannel().equals(title)) - .findFirst(); - } - - private Channel validationChannel(UUID id) { - Channel channel = data.get(id); - if (channel == null) { - System.out.println("존재하지 않는 채널입니다."); - } - return channel; - } -} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/service/jcf/JCFMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/jcf/JCFMessageService.java deleted file mode 100644 index cbc043d87..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/jcf/JCFMessageService.java +++ /dev/null @@ -1,64 +0,0 @@ -/*package com.sprint.mission.discodeit.service.jcf; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.entity.Message; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.service.MessageService; - -import java.util.*; - -public class JCFMessageService implements MessageService { - Map data = new HashMap<>(); - - @Override - public Message createMessage(Message message, User user, Channel channel) { - if(message == null || user == null || channel == null){ - System.out.println(("message, user, channel은 null일 수 없습니다.")); - } - data.put(message.getId(), message); - - user.addMessage(message); - channel.addMessage(message); - channel.addUser(user); - message.addUser(user); - return message; - } - - @Override - public Message getMessage(UUID id) { - return data.get(id); - } - - @Override - public List getMessages() { - return new ArrayList<>(data.values()); - } - - @Override - public void updateMessage(UUID id, String newMessage) { - Optional.ofNullable(data.get(id)) - .ifPresentOrElse(msg -> msg.updateContent(newMessage), - () -> System.out.println("수정할 메세지가 존재하지 않습니다.")); - } - - @Override - public void deleteMessage(UUID id) { - Message message = validationMessage(id); - data.remove(id); - - } - public Optional findByMessage(String content) { - return data.values() - .stream() - .filter(msg -> msg.getContent().equals(content)) - .findFirst(); - } - - public Message validationMessage(UUID id) { - Message message = data.get(id); - if(message == null){ - System.out.println("해당 ID의 메세지를 찾을 수 없습니다 "); - } - return message; - } -}*/ \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/service/jcf/JCFUserService.java b/src/main/java/com/sprint/mission/discodeit/service/jcf/JCFUserService.java deleted file mode 100644 index 3719b223c..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/jcf/JCFUserService.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.sprint.mission.discodeit.service.jcf; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.service.UserService; - -import java.io.IOException; -import java.util.*; - -public class JCFUserService implements UserService { - private final Map data = new HashMap<>(); - - @Override - public User createUser(User user){ - Optional optionalUser = findByUserId(user.getId()); - if(optionalUser.isPresent()){ - System.out.println("이미 존재하는 ID 입니다."); - }else { - data.put(user.getId(), user); - } - return user; - } - - @Override - public User getUser(UUID id){ - Optional optionalUser = findByUserId(id); - if (optionalUser.isPresent()) { - return optionalUser.get(); - } else { - System.out.println("존재하지 않는 아이디입니다."); - return null; - } - } - - @Override - public List getUsers(){ - return new ArrayList<>(data.values()); - } - - @Override - public void updateUser(UUID id, String username){ - findByUserId(id).ifPresentOrElse( - user -> user.updateName(username), - () -> System.out.println("존재하지 않는 아이디입니다.") - ); - } - - @Override - public void deleteUser(UUID id) { - Optional user = findByUserId(id); - if(user != null) { - data.remove(id); - System.out.println("유저 삭제 성공"); - }else{ - System.out.println("삭제 할 유저가 없습니다."); - } - } - - public Optional findByUserId(UUID id) { - return Optional.ofNullable(data.get(id)); - } - - -} \ No newline at end of file diff --git a/src/main/resources/static/schema.sql b/src/main/resources/static/schema.sql new file mode 100644 index 000000000..b7487d57f --- /dev/null +++ b/src/main/resources/static/schema.sql @@ -0,0 +1,75 @@ +CREATE TABLE users( + id uuid primary key , + created_at timestamptz NOT NULL, + updated_at timestamptz, + username varchar(50) UNIQUE NOT NULL, + email varchar(100) UNIQUE NOT NULL, + password varchar(60) NOT NULL +); + +ALTER TABLE users add profile_id uuid; + +ALTER TABLE users + ADD CONSTRAINT fk_user_profile_id + FOREIGN KEY (profile_id) + REFERENCES binary_contents(id) + ON DELETE SET NULL; + +CREATE TABLE user_statuses( + id uuid primary key , + created_at timestamptz not null , + updated_at timestamptz, + user_id uuid UNIQUE NOT NULL, + last_active_at timestamptz NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE binary_contents ( + id uuid PRIMARY KEY , + created_at timestamptz NOT NULL, + file_name varchar(255) NOT NULL, + size bigint NOT NULL, + content_type varchar(100) NOT NULL, + bytes bytea NOT NULL +); + +CREATE TYPE channel_type AS ENUM ('PUBLIC', 'PRIVATE'); + +CREATE TABLE channels( + id uuid PRIMARY KEY , + created_at timestamptz NOT NULL, + updated_at timestamptz, + name varchar(100), + description varchar(500), + type varchar(10) NOT NULL CHECK ( type IN ('PUBLIC','PRIVATE') ) +); + +CREATE TABLE read_statuses( + id uuid PRIMARY KEY, + created_at timestamptz NOT NULL, + updated_at timestamptz, + user_id uuid , + channel_id uuid , + last_read_at timestamptz NOT NULL, + CONSTRAINT uk_user_channel UNIQUE (user_id, channel_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE +); + +CREATE TABLE messages ( + id uuid PRIMARY KEY , + created_at timestamptz NOT NULL, + updated_at timestamptz, + content text, + channel_id uuid NOT NULL, + author_id uuid, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, + FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE message_attachments( + message_id uuid, + attachment_id uuid, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (attachment_id) REFERENCES binary_contents(id) ON DELETE CASCADE +); \ No newline at end of file From 4ad150b5b79736343735cb625ff2cac2a300c190 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Mon, 18 Aug 2025 11:19:38 +0900 Subject: [PATCH 03/28] =?UTF-8?q?Sprint7=20=EA=B3=BC=EC=A0=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 46 + HELP.md | 22 + README.md | 2 + api-docs_1.2.json | 1278 +++++++++++++++++ build.gradle | 46 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++ gradlew.bat | 94 ++ settings.gradle | 1 + .../discodeit/DiscodeitApplication.java | 12 + .../mission/discodeit/config/AppConfig.java | 10 + .../discodeit/config/SwaggerConfig.java | 25 + .../discodeit/controller/AuthController.java | 35 + .../controller/BinaryContentController.java | 54 + .../controller/ChannelController.java | 78 + .../controller/MessageController.java | 107 ++ .../controller/ReadStatusController.java | 55 + .../discodeit/controller/UserController.java | 121 ++ .../discodeit/controller/api/AuthApi.java | 36 + .../controller/api/BinaryContentApi.java | 57 + .../discodeit/controller/api/ChannelApi.java | 89 ++ .../discodeit/controller/api/MessageApi.java | 90 ++ .../controller/api/ReadStatusApi.java | 67 + .../discodeit/controller/api/UserApi.java | 109 ++ .../discodeit/dto/data/BinaryContentDto.java | 12 + .../discodeit/dto/data/ChannelDto.java | 17 + .../discodeit/dto/data/MessageDto.java | 17 + .../discodeit/dto/data/ReadStatusDto.java | 13 + .../mission/discodeit/dto/data/UserDto.java | 13 + .../discodeit/dto/data/UserStatusDto.java | 11 + .../request/BinaryContentCreateRequest.java | 9 + .../discodeit/dto/request/LoginRequest.java | 8 + .../dto/request/MessageCreateRequest.java | 16 + .../dto/request/MessageUpdateRequest.java | 7 + .../request/PrivateChannelCreateRequest.java | 13 + .../request/PublicChannelCreateRequest.java | 15 + .../request/PublicChannelUpdateRequest.java | 8 + .../dto/request/ReadStatusCreateRequest.java | 12 + .../dto/request/ReadStatusUpdateRequest.java | 9 + .../dto/request/UserCreateRequest.java | 23 + .../dto/request/UserStatusCreateRequest.java | 11 + .../dto/request/UserStatusUpdateRequest.java | 9 + .../dto/request/UserUpdateRequest.java | 16 + .../discodeit/dto/response/PageResponse.java | 13 + .../discodeit/entity/BinaryContent.java | 29 + .../mission/discodeit/entity/Channel.java | 41 + .../mission/discodeit/entity/ChannelType.java | 6 + .../mission/discodeit/entity/Message.java | 55 + .../mission/discodeit/entity/ReadStatus.java | 47 + .../sprint/mission/discodeit/entity/User.java | 60 + .../mission/discodeit/entity/UserStatus.java | 50 + .../discodeit/entity/base/BaseEntity.java | 33 + .../entity/base/BaseUpdatableEntity.java | 19 + .../exception/DiscodeitException.java | 19 + .../discodeit/exception/ErrorCode.java | 20 + .../discodeit/exception/ErrorResponse.java | 21 + .../exception/GlobalExceptionHandler.java | 57 + .../exception/channel/ChannelException.java | 12 + .../channel/ChannelNotFoundException.java | 11 + .../PrivateChannelUpdateException.java | 12 + .../exception/message/MessageException.java | 11 + .../message/MessageNotFoundException.java | 12 + .../user/UserDuplicateException.java | 11 + .../user/UserEmailDuplicateException.java | 11 + .../exception/user/UserException.java | 12 + .../exception/user/UserNotFoundException.java | 12 + .../discodeit/mapper/BinaryContentMapper.java | 11 + .../discodeit/mapper/ChannelMapper.java | 48 + .../discodeit/mapper/MessageMapper.java | 13 + .../discodeit/mapper/PageResponseMapper.java | 30 + .../discodeit/mapper/ReadStatusMapper.java | 14 + .../mission/discodeit/mapper/UserMapper.java | 13 + .../discodeit/mapper/UserStatusMapper.java | 13 + .../repository/BinaryContentRepository.java | 9 + .../repository/ChannelRepository.java | 12 + .../repository/MessageRepository.java | 32 + .../repository/ReadStatusRepository.java | 25 + .../discodeit/repository/UserRepository.java | 22 + .../repository/UserStatusRepository.java | 11 + .../discodeit/service/AuthService.java | 9 + .../service/BinaryContentService.java | 17 + .../discodeit/service/ChannelService.java | 23 + .../discodeit/service/MessageService.java | 25 + .../discodeit/service/ReadStatusService.java | 20 + .../discodeit/service/UserService.java | 24 + .../discodeit/service/UserStatusService.java | 22 + .../service/basic/BasicAuthService.java | 39 + .../basic/BasicBinaryContentService.java | 74 + .../service/basic/BasicChannelService.java | 133 ++ .../service/basic/BasicMessageService.java | 153 ++ .../service/basic/BasicReadStatusService.java | 93 ++ .../service/basic/BasicUserService.java | 149 ++ .../service/basic/BasicUserStatusService.java | 98 ++ .../storage/BinaryContentStorage.java | 15 + .../local/LocalBinaryContentStorage.java | 89 ++ src/main/resources/application-dev.yaml | 16 + src/main/resources/application-prod.yaml | 16 + src/main/resources/application.yaml | 68 + src/main/resources/fe_bundle_1.2.3.zip | Bin 0 -> 95493 bytes src/main/resources/logback-spring.xml | 50 + src/main/resources/schema.sql | 126 ++ .../resources/static/assets/index-DRjprt8D.js | 1015 +++++++++++++ .../static/assets/index-kQJbKSsj.css | 1 + src/main/resources/static/favicon.ico | Bin 0 -> 1588 bytes src/main/resources/static/index.html | 26 + .../discodeit/DiscodeitApplicationTests.java | 13 + .../mission/discodeit/UserServiceTest.java | 182 +++ 108 files changed, 6255 insertions(+) create mode 100644 .gitignore create mode 100644 HELP.md create mode 100644 README.md create mode 100644 api-docs_1.2.json create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/AppConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/AuthController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/MessageController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/UserController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/Channel.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/Message.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/User.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserDuplicateException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserEmailDuplicateException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/AuthService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/ChannelService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/MessageService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/UserService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java create mode 100644 src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java create mode 100644 src/main/resources/application-dev.yaml create mode 100644 src/main/resources/application-prod.yaml create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/fe_bundle_1.2.3.zip create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/main/resources/schema.sql create mode 100644 src/main/resources/static/assets/index-DRjprt8D.js create mode 100644 src/main/resources/static/assets/index-kQJbKSsj.css create mode 100644 src/main/resources/static/favicon.ico create mode 100644 src/main/resources/static/index.html create mode 100644 src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java create mode 100644 src/test/java/com/sprint/mission/discodeit/UserServiceTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b2b7bf3d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Discodeit ### +.discodeit + +### 숨김 파일 ### +.* +!.gitignore \ No newline at end of file diff --git a/HELP.md b/HELP.md new file mode 100644 index 000000000..42c5f0023 --- /dev/null +++ b/HELP.md @@ -0,0 +1,22 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.0/gradle-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.0/gradle-plugin/packaging-oci-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/3.4.0/reference/web/servlet.html) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/README.md b/README.md new file mode 100644 index 000000000..815bede54 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# 0-spring-mission +스프린트 미션 모범 답안 리포지토리입니다. diff --git a/api-docs_1.2.json b/api-docs_1.2.json new file mode 100644 index 000000000..7253644c9 --- /dev/null +++ b/api-docs_1.2.json @@ -0,0 +1,1278 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Discodeit API 문서", + "description": "Discodeit 프로젝트의 Swagger API 문서입니다.", + "version": "1.2" + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "로컬 서버" + } + ], + "tags": [ + { + "name": "Channel", + "description": "Channel API" + }, + { + "name": "ReadStatus", + "description": "Message 읽음 상태 API" + }, + { + "name": "Message", + "description": "Message API" + }, + { + "name": "User", + "description": "User API" + }, + { + "name": "BinaryContent", + "description": "첨부 파일 API" + }, + { + "name": "Auth", + "description": "인증 API" + } + ], + "paths": { + "/api/users": { + "get": { + "tags": [ + "User" + ], + "summary": "전체 User 목록 조회", + "operationId": "findAll", + "responses": { + "200": { + "description": "User 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "User" + ], + "summary": "User 등록", + "operationId": "create", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "userCreateRequest": { + "$ref": "#/components/schemas/UserCreateRequest" + }, + "profile": { + "type": "string", + "format": "binary", + "description": "User 프로필 이미지" + } + }, + "required": [ + "userCreateRequest" + ] + } + } + } + }, + "responses": { + "201": { + "description": "User가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "400": { + "description": "같은 email 또는 username를 사용하는 User가 이미 존재함", + "content": { + "*/*": { + "example": "User with email {email} already exists" + } + } + } + } + } + }, + "/api/readStatuses": { + "get": { + "tags": [ + "ReadStatus" + ], + "summary": "User의 Message 읽음 상태 목록 조회", + "operationId": "findAllByUserId", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "조회할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Message 읽음 상태 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "ReadStatus" + ], + "summary": "Message 읽음 상태 생성", + "operationId": "create_1", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadStatusCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "이미 읽음 상태가 존재함", + "content": { + "*/*": { + "example": "ReadStatus with userId {userId} and channelId {channelId} already exists" + } + } + }, + "201": { + "description": "Message 읽음 상태가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + }, + "404": { + "description": "Channel 또는 User를 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel | User with id {channelId | userId} not found" + } + } + } + } + } + }, + "/api/messages": { + "get": { + "tags": [ + "Message" + ], + "summary": "Channel의 Message 목록 조회", + "operationId": "findAllByChannelId", + "parameters": [ + { + "name": "channelId", + "in": "query", + "description": "조회할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "description": "페이징 커서 정보", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "pageable", + "in": "query", + "description": "페이징 정보", + "required": true, + "schema": { + "$ref": "#/components/schemas/Pageable" + }, + "example": { + "size": 50, + "sort": "createdAt,desc" + } + } + ], + "responses": { + "200": { + "description": "Message 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PageResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Message" + ], + "summary": "Message 생성", + "operationId": "create_2", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "messageCreateRequest": { + "$ref": "#/components/schemas/MessageCreateRequest" + }, + "attachments": { + "type": "array", + "description": "Message 첨부 파일들", + "items": { + "type": "string", + "format": "binary" + } + } + }, + "required": [ + "messageCreateRequest" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Message가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/MessageDto" + } + } + } + }, + "404": { + "description": "Channel 또는 User를 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel | Author with id {channelId | authorId} not found" + } + } + } + } + } + }, + "/api/channels/public": { + "post": { + "tags": [ + "Channel" + ], + "summary": "Public Channel 생성", + "operationId": "create_3", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicChannelCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Public Channel이 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/channels/private": { + "post": { + "tags": [ + "Channel" + ], + "summary": "Private Channel 생성", + "operationId": "create_4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivateChannelCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Private Channel이 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "summary": "로그인", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "로그인 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "404": { + "description": "사용자를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with username {username} not found" + } + } + }, + "400": { + "description": "비밀번호가 일치하지 않음", + "content": { + "*/*": { + "example": "Wrong password" + } + } + } + } + } + }, + "/api/users/{userId}": { + "delete": { + "tags": [ + "User" + ], + "summary": "User 삭제", + "operationId": "delete", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "삭제할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "User를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with id {id} not found" + } + } + }, + "204": { + "description": "User가 성공적으로 삭제됨" + } + } + }, + "patch": { + "tags": [ + "User" + ], + "summary": "User 정보 수정", + "operationId": "update", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "수정할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "userUpdateRequest": { + "$ref": "#/components/schemas/UserUpdateRequest" + }, + "profile": { + "type": "string", + "format": "binary", + "description": "수정할 User 프로필 이미지" + } + }, + "required": [ + "userUpdateRequest" + ] + } + } + } + }, + "responses": { + "400": { + "description": "같은 email 또는 username를 사용하는 User가 이미 존재함", + "content": { + "*/*": { + "example": "user with email {newEmail} already exists" + } + } + }, + "200": { + "description": "User 정보가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "404": { + "description": "User를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with id {userId} not found" + } + } + } + } + } + }, + "/api/users/{userId}/userStatus": { + "patch": { + "tags": [ + "User" + ], + "summary": "User 온라인 상태 업데이트", + "operationId": "updateUserStatusByUserId", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "상태를 변경할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatusUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "해당 User의 UserStatus를 찾을 수 없음", + "content": { + "*/*": { + "example": "UserStatus with userId {userId} not found" + } + } + }, + "200": { + "description": "User 온라인 상태가 성공적으로 업데이트됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserStatusDto" + } + } + } + } + } + } + }, + "/api/readStatuses/{readStatusId}": { + "patch": { + "tags": [ + "ReadStatus" + ], + "summary": "Message 읽음 상태 수정", + "operationId": "update_1", + "parameters": [ + { + "name": "readStatusId", + "in": "path", + "description": "수정할 읽음 상태 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadStatusUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Message 읽음 상태를 찾을 수 없음", + "content": { + "*/*": { + "example": "ReadStatus with id {readStatusId} not found" + } + } + }, + "200": { + "description": "Message 읽음 상태가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + } + } + } + }, + "/api/messages/{messageId}": { + "delete": { + "tags": [ + "Message" + ], + "summary": "Message 삭제", + "operationId": "delete_1", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "삭제할 Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Message가 성공적으로 삭제됨" + }, + "404": { + "description": "Message를 찾을 수 없음", + "content": { + "*/*": { + "example": "Message with id {messageId} not found" + } + } + } + } + }, + "patch": { + "tags": [ + "Message" + ], + "summary": "Message 내용 수정", + "operationId": "update_2", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "수정할 Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Message를 찾을 수 없음", + "content": { + "*/*": { + "example": "Message with id {messageId} not found" + } + } + }, + "200": { + "description": "Message가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/MessageDto" + } + } + } + } + } + } + }, + "/api/channels/{channelId}": { + "delete": { + "tags": [ + "Channel" + ], + "summary": "Channel 삭제", + "operationId": "delete_2", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "삭제할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "Channel을 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel with id {channelId} not found" + } + } + }, + "204": { + "description": "Channel이 성공적으로 삭제됨" + } + } + }, + "patch": { + "tags": [ + "Channel" + ], + "summary": "Channel 정보 수정", + "operationId": "update_3", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "수정할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicChannelUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Channel을 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel with id {channelId} not found" + } + } + }, + "400": { + "description": "Private Channel은 수정할 수 없음", + "content": { + "*/*": { + "example": "Private channel cannot be updated" + } + } + }, + "200": { + "description": "Channel 정보가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/channels": { + "get": { + "tags": [ + "Channel" + ], + "summary": "User가 참여 중인 Channel 목록 조회", + "operationId": "findAll_1", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "조회할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Channel 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + } + }, + "/api/binaryContents": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "여러 첨부 파일 조회", + "operationId": "findAllByIdIn", + "parameters": [ + { + "name": "binaryContentIds", + "in": "query", + "description": "조회할 첨부 파일 ID 목록", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "첨부 파일 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + } + } + } + } + }, + "/api/binaryContents/{binaryContentId}": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "첨부 파일 조회", + "operationId": "find", + "parameters": [ + { + "name": "binaryContentId", + "in": "path", + "description": "조회할 첨부 파일 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "첨부 파일 조회 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + }, + "404": { + "description": "첨부 파일을 찾을 수 없음", + "content": { + "*/*": { + "example": "BinaryContent with id {binaryContentId} not found" + } + } + } + } + } + }, + "/api/binaryContents/{binaryContentId}/download": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "파일 다운로드", + "operationId": "download", + "parameters": [ + { + "name": "binaryContentId", + "in": "path", + "description": "다운로드할 파일 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "파일 다운로드 성공", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UserCreateRequest": { + "type": "object", + "description": "User 생성 정보", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "BinaryContentDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "fileName": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64" + }, + "contentType": { + "type": "string" + } + } + }, + "UserDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "profile": { + "$ref": "#/components/schemas/BinaryContentDto" + }, + "online": { + "type": "boolean" + } + } + }, + "ReadStatusCreateRequest": { + "type": "object", + "description": "Message 읽음 상태 생성 정보", + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadStatusDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MessageCreateRequest": { + "type": "object", + "description": "Message 생성 정보", + "properties": { + "content": { + "type": "string" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "authorId": { + "type": "string", + "format": "uuid" + } + } + }, + "MessageDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "content": { + "type": "string" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "author": { + "$ref": "#/components/schemas/UserDto" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + }, + "PublicChannelCreateRequest": { + "type": "object", + "description": "Public Channel 생성 정보", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "ChannelDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "PUBLIC", + "PRIVATE" + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + }, + "lastMessageAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PrivateChannelCreateRequest": { + "type": "object", + "description": "Private Channel 생성 정보", + "properties": { + "participantIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "LoginRequest": { + "type": "object", + "description": "로그인 정보", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "UserUpdateRequest": { + "type": "object", + "description": "수정할 User 정보", + "properties": { + "newUsername": { + "type": "string" + }, + "newEmail": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "UserStatusUpdateRequest": { + "type": "object", + "description": "변경할 User 온라인 상태 정보", + "properties": { + "newLastActiveAt": { + "type": "string", + "format": "date-time" + } + } + }, + "UserStatusDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadStatusUpdateRequest": { + "type": "object", + "description": "수정할 읽음 상태 정보", + "properties": { + "newLastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MessageUpdateRequest": { + "type": "object", + "description": "수정할 Message 내용", + "properties": { + "newContent": { + "type": "string" + } + } + }, + "PublicChannelUpdateRequest": { + "type": "object", + "description": "수정할 Channel 정보", + "properties": { + "newName": { + "type": "string" + }, + "newDescription": { + "type": "string" + } + } + }, + "Pageable": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "size": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "sort": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PageResponse": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "nextCursor": { + "type": "object" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "hasNext": { + "type": "boolean" + }, + "totalElements": { + "type": "integer", + "format": "int64" + } + } + } + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..b1c2d3b3b --- /dev/null +++ b/build.gradle @@ -0,0 +1,46 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.6' +} + +group = 'com.sprint.mission' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'org.postgresql:postgresql' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.mapstruct:mapstruct:1.6.3' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..e2847c820 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 000000000..f5feea6d6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..9d21a2183 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..2437dfb29 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'discodeit' diff --git a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java new file mode 100644 index 000000000..8f61230d4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DiscodeitApplication { + + public static void main(String[] args) { + SpringApplication.run(DiscodeitApplication.class, args); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java new file mode 100644 index 000000000..96010621f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class AppConfig { + +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java new file mode 100644 index 000000000..15a777199 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Discodeit API 문서") + .description("Discodeit 프로젝트의 Swagger API 문서입니다.") + .version("1.2") + ) + .servers(List.of( + new Server().url("http://localhost:8080").description("로컬 서버") + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java new file mode 100644 index 000000000..a2b5dc64d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -0,0 +1,35 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.AuthApi; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.service.AuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.internal.log.SubSystemLogging; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/auth") +@Slf4j +public class AuthController implements AuthApi { + + private static final Logger logger = LoggerFactory.getLogger(AuthService.class); + private final AuthService authService; + + @PostMapping(path = "login") + public ResponseEntity login(@RequestBody LoginRequest loginRequest) { + UserDto user = authService.login(loginRequest); + return ResponseEntity + .status(HttpStatus.OK) + .body(user); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java new file mode 100644 index 000000000..9db76337e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java @@ -0,0 +1,54 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.BinaryContentApi; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/binaryContents") +@Slf4j +public class BinaryContentController implements BinaryContentApi { + + private final BinaryContentService binaryContentService; + private final BinaryContentStorage binaryContentStorage; + + @GetMapping(path = "{binaryContentId}") + public ResponseEntity find( + @PathVariable("binaryContentId") UUID binaryContentId) { + BinaryContentDto binaryContent = binaryContentService.find(binaryContentId); + return ResponseEntity + .status(HttpStatus.OK) + .body(binaryContent); + } + + @GetMapping + public ResponseEntity> findAllByIdIn( + @RequestParam("binaryContentIds") List binaryContentIds) { + List binaryContents = binaryContentService.findAllByIdIn(binaryContentIds); + return ResponseEntity + .status(HttpStatus.OK) + .body(binaryContents); + } + + @GetMapping(path = "{binaryContentId}/download") + public ResponseEntity download( + @PathVariable("binaryContentId") UUID binaryContentId) { + log.debug("BinaryContent 다운로드 요청: binaryContentId = {}", binaryContentId); + BinaryContentDto binaryContentDto = binaryContentService.find(binaryContentId); + log.info("BinaryContent 다운로드 성공: id = {}", binaryContentId); + return binaryContentStorage.download(binaryContentDto); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java new file mode 100644 index 000000000..2b8449dbb --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java @@ -0,0 +1,78 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.ChannelApi; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.service.ChannelService; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/channels") +@Slf4j +public class ChannelController implements ChannelApi { + + private final ChannelService channelService; + + @PostMapping(path = "public") + public ResponseEntity create(@Valid @RequestBody PublicChannelCreateRequest request) { + log.info("public Channel 생성 요청 : name = {}", request.name()); + ChannelDto createdChannel = channelService.create(request); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdChannel); + } + + @PostMapping(path = "private") + public ResponseEntity create(@Valid @RequestBody PrivateChannelCreateRequest request) { + log.info("private Channel 생성 요청 : participantIds = {}", request.participantIds()); + ChannelDto createdChannel = channelService.create(request); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdChannel); + } + + @PatchMapping(path = "{channelId}") + public ResponseEntity update(@PathVariable("channelId") UUID channelId, + @RequestBody PublicChannelUpdateRequest request) { + log.info("public Channel 생성 요청 : newName = {} newDescription = {}", request.newName(), request.newDescription()); + ChannelDto updatedChannel = channelService.update(channelId, request); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedChannel); + } + + @DeleteMapping(path = "{channelId}") + public ResponseEntity delete(@PathVariable("channelId") UUID channelId) { + log.info("public Channel 삭제 요청 : channelId = {}", channelId); + channelService.delete(channelId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + public ResponseEntity> findAll(@RequestParam("userId") UUID userId) { + List channels = channelService.findAllByUserId(userId); + return ResponseEntity + .status(HttpStatus.OK) + .body(channels); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java new file mode 100644 index 000000000..878183a5d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java @@ -0,0 +1,107 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.MessageApi; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.service.MessageService; +import jakarta.validation.Valid; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/messages") +@Slf4j +public class MessageController implements MessageApi { + + private final MessageService messageService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity create( + @Valid @RequestPart("messageCreateRequest") MessageCreateRequest messageCreateRequest, + @RequestPart(value = "attachments", required = false) List attachments + ) { + log.info("message 생성 요청 : content = {}", messageCreateRequest.content()); + List attachmentRequests = Optional.ofNullable(attachments) + .map(files -> files.stream() + .map(file -> { + try { + return new BinaryContentCreateRequest( + file.getOriginalFilename(), + file.getContentType(), + file.getBytes() + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .toList()) + .orElse(new ArrayList<>()); + MessageDto createdMessage = messageService.create(messageCreateRequest, attachmentRequests); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdMessage); + } + + @PatchMapping(path = "{messageId}") + public ResponseEntity update(@PathVariable("messageId") UUID messageId, + @RequestBody MessageUpdateRequest request) { + log.info("message 수정 요청 : id = {}, newContent = {}", messageId, request.newContent()); + MessageDto updatedMessage = messageService.update(messageId, request); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedMessage); + } + + @DeleteMapping(path = "{messageId}") + public ResponseEntity delete(@PathVariable("messageId") UUID messageId) { + log.info("message 삭제 요청 : messageId = {}", messageId); + messageService.delete(messageId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + public ResponseEntity> findAllByChannelId( + @RequestParam("channelId") UUID channelId, + @RequestParam(value = "cursor", required = false) Instant cursor, + @PageableDefault( + size = 50, + page = 0, + sort = "createdAt", + direction = Direction.DESC + ) Pageable pageable) { + PageResponse messages = messageService.findAllByChannelId(channelId, cursor, + pageable); + return ResponseEntity + .status(HttpStatus.OK) + .body(messages); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java new file mode 100644 index 000000000..fef7e88c3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java @@ -0,0 +1,55 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.ReadStatusApi; +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.service.ReadStatusService; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/readStatuses") +@Slf4j +public class ReadStatusController implements ReadStatusApi { + + private final ReadStatusService readStatusService; + + @PostMapping + public ResponseEntity create(@RequestBody ReadStatusCreateRequest request) { + ReadStatusDto createdReadStatus = readStatusService.create(request); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdReadStatus); + } + + @PatchMapping(path = "{readStatusId}") + public ResponseEntity update(@PathVariable("readStatusId") UUID readStatusId, + @RequestBody ReadStatusUpdateRequest request) { + ReadStatusDto updatedReadStatus = readStatusService.update(readStatusId, request); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedReadStatus); + } + + @GetMapping + public ResponseEntity> findAllByUserId(@RequestParam("userId") UUID userId) { + List readStatuses = readStatusService.findAllByUserId(userId); + return ResponseEntity + .status(HttpStatus.OK) + .body(readStatuses); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java new file mode 100644 index 000000000..5e8ecb5e1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java @@ -0,0 +1,121 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.UserApi; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.service.UserService; +import com.sprint.mission.discodeit.service.UserStatusService; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/users") +@Slf4j +public class UserController implements UserApi { + + private final UserService userService; + private final UserStatusService userStatusService; + + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) + @Override + public ResponseEntity create( + @Valid @RequestPart("userCreateRequest") UserCreateRequest userCreateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + log.info("User 생성 요청 : username = {}",userCreateRequest.username()); + Optional profileRequest = Optional.ofNullable(profile) + .flatMap(this::resolveProfileRequest); + UserDto createdUser = userService.create(userCreateRequest, profileRequest); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdUser); + } + + @PatchMapping( + path = "{userId}", + consumes = {MediaType.MULTIPART_FORM_DATA_VALUE} + ) + @Override + public ResponseEntity update( + @PathVariable("userId") UUID userId, + @Valid @RequestPart("userUpdateRequest") UserUpdateRequest userUpdateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + log.info("User 수정 요청 : newUsername = {}",userUpdateRequest.newUsername()); + Optional profileRequest = Optional.ofNullable(profile) + .flatMap(this::resolveProfileRequest); + UserDto updatedUser = userService.update(userId, userUpdateRequest, profileRequest); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedUser); + } + + @DeleteMapping(path = "{userId}") + @Override + public ResponseEntity delete(@PathVariable("userId") UUID userId) { + log.info("User 삭제 요청 : userId = {}",userId); + userService.delete(userId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + @Override + public ResponseEntity> findAll() { + List users = userService.findAll(); + return ResponseEntity + .status(HttpStatus.OK) + .body(users); + } + + @PatchMapping(path = "{userId}/userStatus") + @Override + public ResponseEntity updateUserStatusByUserId(@PathVariable("userId") UUID userId, + @RequestBody UserStatusUpdateRequest request) { + UserStatusDto updatedUserStatus = userStatusService.updateByUserId(userId, request); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedUserStatus); + } + + private Optional resolveProfileRequest(MultipartFile profileFile) { + if (profileFile.isEmpty()) { + return Optional.empty(); + } else { + try { + BinaryContentCreateRequest binaryContentCreateRequest = new BinaryContentCreateRequest( + profileFile.getOriginalFilename(), + profileFile.getContentType(), + profileFile.getBytes() + ); + return Optional.of(binaryContentCreateRequest); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java new file mode 100644 index 000000000..ee9ce79f9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java @@ -0,0 +1,36 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Auth", description = "인증 API") +public interface AuthApi { + + @Operation(summary = "로그인") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "로그인 성공", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "User with username {username} not found")) + ), + @ApiResponse( + responseCode = "400", description = "비밀번호가 일치하지 않음", + content = @Content(examples = @ExampleObject(value = "Wrong password")) + ) + }) + ResponseEntity login( + @Parameter(description = "로그인 정보") LoginRequest loginRequest + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java new file mode 100644 index 000000000..883ab8a88 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java @@ -0,0 +1,57 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; + +@Tag(name = "BinaryContent", description = "첨부 파일 API") +public interface BinaryContentApi { + + @Operation(summary = "첨부 파일 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "첨부 파일 조회 성공", + content = @Content(schema = @Schema(implementation = BinaryContentDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "첨부 파일을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "BinaryContent with id {binaryContentId} not found")) + ) + }) + ResponseEntity find( + @Parameter(description = "조회할 첨부 파일 ID") UUID binaryContentId + ); + + @Operation(summary = "여러 첨부 파일 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "첨부 파일 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = BinaryContentDto.class))) + ) + }) + ResponseEntity> findAllByIdIn( + @Parameter(description = "조회할 첨부 파일 ID 목록") List binaryContentIds + ); + + @Operation(summary = "파일 다운로드") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "파일 다운로드 성공", + content = @Content(schema = @Schema(implementation = Resource.class)) + ) + }) + ResponseEntity download( + @Parameter(description = "다운로드할 파일 ID") UUID binaryContentId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java new file mode 100644 index 000000000..af8c7afc7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java @@ -0,0 +1,89 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Channel", description = "Channel API") +public interface ChannelApi { + + @Operation(summary = "Public Channel 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Public Channel이 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ChannelDto.class)) + ) + }) + ResponseEntity create( + @Parameter(description = "Public Channel 생성 정보") PublicChannelCreateRequest request + ); + + @Operation(summary = "Private Channel 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Private Channel이 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ChannelDto.class)) + ) + }) + ResponseEntity create( + @Parameter(description = "Private Channel 생성 정보") PrivateChannelCreateRequest request + ); + + @Operation(summary = "Channel 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Channel 정보가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = ChannelDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel with id {channelId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "Private Channel은 수정할 수 없음", + content = @Content(examples = @ExampleObject(value = "Private channel cannot be updated")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 Channel ID") UUID channelId, + @Parameter(description = "수정할 Channel 정보") PublicChannelUpdateRequest request + ); + + @Operation(summary = "Channel 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", description = "Channel이 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", description = "Channel을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel with id {channelId} not found")) + ) + }) + ResponseEntity delete( + @Parameter(description = "삭제할 Channel ID") UUID channelId + ); + + @Operation(summary = "User가 참여 중인 Channel 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Channel 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class))) + ) + }) + ResponseEntity> findAll( + @Parameter(description = "조회할 User ID") UUID userId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java new file mode 100644 index 000000000..c9a7aebbd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java @@ -0,0 +1,90 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Message", description = "Message API") +public interface MessageApi { + + @Operation(summary = "Message 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Message가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = MessageDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel 또는 User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel | Author with id {channelId | authorId} not found")) + ), + }) + ResponseEntity create( + @Parameter( + description = "Message 생성 정보", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) MessageCreateRequest messageCreateRequest, + @Parameter( + description = "Message 첨부 파일들", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) List attachments + ); + + @Operation(summary = "Message 내용 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = MessageDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Message를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Message with id {messageId} not found")) + ), + }) + ResponseEntity update( + @Parameter(description = "수정할 Message ID") UUID messageId, + @Parameter(description = "수정할 Message 내용") MessageUpdateRequest request + ); + + @Operation(summary = "Message 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", description = "Message가 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", description = "Message를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Message with id {messageId} not found")) + ), + }) + ResponseEntity delete( + @Parameter(description = "삭제할 Message ID") UUID messageId + ); + + @Operation(summary = "Channel의 Message 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 목록 조회 성공", + content = @Content(schema = @Schema(implementation = PageResponse.class)) + ) + }) + ResponseEntity> findAllByChannelId( + @Parameter(description = "조회할 Channel ID") UUID channelId, + @Parameter(description = "페이징 커서 정보") Instant cursor, + @Parameter(description = "페이징 정보", example = "{\"size\": 50, \"sort\": \"createdAt,desc\"}") Pageable pageable + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java new file mode 100644 index 000000000..eb08b359f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java @@ -0,0 +1,67 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +@Tag(name = "ReadStatus", description = "Message 읽음 상태 API") +public interface ReadStatusApi { + + @Operation(summary = "Message 읽음 상태 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Message 읽음 상태가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ReadStatusDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel 또는 User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel | User with id {channelId | userId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "이미 읽음 상태가 존재함", + content = @Content(examples = @ExampleObject(value = "ReadStatus with userId {userId} and channelId {channelId} already exists")) + ) + }) + ResponseEntity create( + @Parameter(description = "Message 읽음 상태 생성 정보") ReadStatusCreateRequest request + ); + + @Operation(summary = "Message 읽음 상태 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 읽음 상태가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = ReadStatusDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Message 읽음 상태를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "ReadStatus with id {readStatusId} not found")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 읽음 상태 ID") UUID readStatusId, + @Parameter(description = "수정할 읽음 상태 정보") ReadStatusUpdateRequest request + ); + + @Operation(summary = "User의 Message 읽음 상태 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 읽음 상태 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ReadStatusDto.class))) + ) + }) + ResponseEntity> findAllByUserId( + @Parameter(description = "조회할 User ID") UUID userId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java new file mode 100644 index 000000000..9d40bc1ce --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java @@ -0,0 +1,109 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "User", description = "User API") +public interface UserApi { + + @Operation(summary = "User 등록") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "User가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ), + @ApiResponse( + responseCode = "400", description = "같은 email 또는 username를 사용하는 User가 이미 존재함", + content = @Content(examples = @ExampleObject(value = "User with email {email} already exists")) + ), + }) + ResponseEntity create( + @Parameter( + description = "User 생성 정보", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) UserCreateRequest userCreateRequest, + @Parameter( + description = "User 프로필 이미지", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) MultipartFile profile + ); + + @Operation(summary = "User 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 정보가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "User를 찾을 수 없음", + content = @Content(examples = @ExampleObject("User with id {userId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "같은 email 또는 username를 사용하는 User가 이미 존재함", + content = @Content(examples = @ExampleObject("user with email {newEmail} already exists")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 User ID") UUID userId, + @Parameter(description = "수정할 User 정보") UserUpdateRequest userUpdateRequest, + @Parameter(description = "수정할 User 프로필 이미지") MultipartFile profile + ); + + @Operation(summary = "User 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "User가 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", + description = "User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "User with id {id} not found")) + ) + }) + ResponseEntity delete( + @Parameter(description = "삭제할 User ID") UUID userId + ); + + @Operation(summary = "전체 User 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) + ) + }) + ResponseEntity> findAll(); + + @Operation(summary = "User 온라인 상태 업데이트") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 온라인 상태가 성공적으로 업데이트됨", + content = @Content(schema = @Schema(implementation = UserStatusDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "해당 User의 UserStatus를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "UserStatus with userId {userId} not found")) + ) + }) + ResponseEntity updateUserStatusByUserId( + @Parameter(description = "상태를 변경할 User ID") UUID userId, + @Parameter(description = "변경할 User 온라인 상태 정보") UserStatusUpdateRequest request + ); +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java new file mode 100644 index 000000000..d44aee484 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.util.UUID; + +public record BinaryContentDto( + UUID id, + String fileName, + Long size, + String contentType +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java new file mode 100644 index 000000000..cf9b99080 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.data; + +import com.sprint.mission.discodeit.entity.ChannelType; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record ChannelDto( + UUID id, + ChannelType type, + String name, + String description, + List participants, + Instant lastMessageAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java new file mode 100644 index 000000000..6bcaa0907 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record MessageDto( + UUID id, + Instant createdAt, + Instant updatedAt, + String content, + UUID channelId, + UserDto author, + List attachments +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java new file mode 100644 index 000000000..1d0bc2c12 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.time.Instant; +import java.util.UUID; + +public record ReadStatusDto( + UUID id, + UUID userId, + UUID channelId, + Instant lastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java new file mode 100644 index 000000000..aa696a69f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.util.UUID; + +public record UserDto( + UUID id, + String username, + String email, + BinaryContentDto profile, + Boolean online +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java new file mode 100644 index 000000000..87ee9d000 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.time.Instant; +import java.util.UUID; + +public record UserStatusDto( + UUID id, + UUID userId, + Instant lastActiveAt) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java new file mode 100644 index 000000000..d86eb9898 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.dto.request; + +public record BinaryContentCreateRequest( + String fileName, + String contentType, + byte[] bytes +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java new file mode 100644 index 000000000..51ca9e620 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java @@ -0,0 +1,8 @@ +package com.sprint.mission.discodeit.dto.request; + +public record LoginRequest( + String username, + String password +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java new file mode 100644 index 000000000..21d08449b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import java.util.UUID; + +public record MessageCreateRequest( + + @NotBlank(message = "메세지 내용은 필수입니다.") + String content, + @NotBlank(message = "채널 ID는 필수입니다.") + UUID channelId, + @NotBlank(message = "사용자 ID는 필수입니다.") + UUID authorId +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java new file mode 100644 index 000000000..d786b1e8c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java @@ -0,0 +1,7 @@ +package com.sprint.mission.discodeit.dto.request; + +public record MessageUpdateRequest( + String newContent +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java new file mode 100644 index 000000000..224effde8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import java.util.UUID; + +public record PrivateChannelCreateRequest( + + @NotBlank(message = "참여자는 필수입니다.") + List participantIds +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java new file mode 100644 index 000000000..a944bd951 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java @@ -0,0 +1,15 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PublicChannelCreateRequest( + + @NotBlank(message = "채널 이름은 필수입니다.") + String name, + + @NotBlank(message = "채널 설명은 필수입니다.") + String description +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java new file mode 100644 index 000000000..d6e515410 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java @@ -0,0 +1,8 @@ +package com.sprint.mission.discodeit.dto.request; + +public record PublicChannelUpdateRequest( + String newName, + String newDescription +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java new file mode 100644 index 000000000..046a48808 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.request; + +import java.time.Instant; +import java.util.UUID; + +public record ReadStatusCreateRequest( + UUID userId, + UUID channelId, + Instant lastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java new file mode 100644 index 000000000..16b0c27ce --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.dto.request; + +import java.time.Instant; + +public record ReadStatusUpdateRequest( + Instant newLastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java new file mode 100644 index 000000000..2e63dd982 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record UserCreateRequest( + + @NotBlank(message = "유저 이름은 필수입니다.") + @Size(min = 2, max = 20, message = "유저 이름은 2~20자 여야합니다.") + String username, + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 형식이여야 합니다.") + String email, + + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 8, message = "비밀번호는 8자리 이상이어야 합니다.") + String password +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java new file mode 100644 index 000000000..71c92abba --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.dto.request; + +import java.time.Instant; +import java.util.UUID; + +public record UserStatusCreateRequest( + UUID userId, + Instant lastActiveAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java new file mode 100644 index 000000000..c69b2610f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.dto.request; + +import java.time.Instant; + +public record UserStatusUpdateRequest( + Instant newLastActiveAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java new file mode 100644 index 000000000..ed91288cc --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +public record UserUpdateRequest( + String newUsername, + + @Email(message = "올바른 형식이여야 합니다.") + String newEmail, + + @Size(min = 8, message = "비밀번호는 최소 8자리여야 합니다.") + String newPassword +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java new file mode 100644 index 000000000..181d532d7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.response; + +import java.util.List; + +public record PageResponse( + List content, + Object nextCursor, + int size, + boolean hasNext, + Long totalElements +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java new file mode 100644 index 000000000..88a096848 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java @@ -0,0 +1,29 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "binary_contents") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BinaryContent extends BaseEntity { + + @Column(nullable = false) + private String fileName; + @Column(nullable = false) + private Long size; + @Column(length = 100, nullable = false) + private String contentType; + + public BinaryContent(String fileName, Long size, String contentType) { + this.fileName = fileName; + this.size = size; + this.contentType = contentType; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Channel.java b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java new file mode 100644 index 000000000..101b737bd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java @@ -0,0 +1,41 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "channels") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Channel extends BaseUpdatableEntity { + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ChannelType type; + @Column(length = 100) + private String name; + @Column(length = 500) + private String description; + + public Channel(ChannelType type, String name, String description) { + this.type = type; + this.name = name; + this.description = description; + } + + public void update(String newName, String newDescription) { + if (newName != null && !newName.equals(this.name)) { + this.name = newName; + } + if (newDescription != null && !newDescription.equals(this.description)) { + this.description = newDescription; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java new file mode 100644 index 000000000..4fca37721 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java @@ -0,0 +1,6 @@ +package com.sprint.mission.discodeit.entity; + +public enum ChannelType { + PUBLIC, + PRIVATE, +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Message.java b/src/main/java/com/sprint/mission/discodeit/entity/Message.java new file mode 100644 index 000000000..7fe8865ea --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Message.java @@ -0,0 +1,55 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; + +@Entity +@Table(name = "messages") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Message extends BaseUpdatableEntity { + + @Column(columnDefinition = "text", nullable = false) + private String content; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "channel_id", columnDefinition = "uuid") + private Channel channel; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", columnDefinition = "uuid") + private User author; + @BatchSize(size = 100) + @OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.ALL) + @JoinTable( + name = "message_attachments", + joinColumns = @JoinColumn(name = "message_id"), + inverseJoinColumns = @JoinColumn(name = "attachment_id") + ) + private List attachments = new ArrayList<>(); + + public Message(String content, Channel channel, User author, List attachments) { + this.channel = channel; + this.content = content; + this.author = author; + this.attachments = attachments; + } + + public void update(String newContent) { + if (newContent != null && !newContent.equals(this.content)) { + this.content = newContent; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java new file mode 100644 index 000000000..d51448b96 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java @@ -0,0 +1,47 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "read_statuses", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "channel_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReadStatus extends BaseUpdatableEntity { + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", columnDefinition = "uuid") + private User user; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "channel_id", columnDefinition = "uuid") + private Channel channel; + @Column(columnDefinition = "timestamp with time zone", nullable = false) + private Instant lastReadAt; + + public ReadStatus(User user, Channel channel, Instant lastReadAt) { + this.user = user; + this.channel = channel; + this.lastReadAt = lastReadAt; + } + + public void update(Instant newLastReadAt) { + if (newLastReadAt != null && !newLastReadAt.equals(this.lastReadAt)) { + this.lastReadAt = newLastReadAt; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/entity/User.java b/src/main/java/com/sprint/mission/discodeit/entity/User.java new file mode 100644 index 000000000..e26aea1b5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/User.java @@ -0,0 +1,60 @@ +package com.sprint.mission.discodeit.entity; + +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor // JPA를 위한 기본 생성자 +public class User extends BaseUpdatableEntity { + + @Column(length = 50, nullable = false, unique = true) + private String username; + @Column(length = 100, nullable = false, unique = true) + private String email; + @Column(length = 60, nullable = false) + private String password; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "profile_id", columnDefinition = "uuid") + private BinaryContent profile; + @JsonManagedReference + @Setter(AccessLevel.PROTECTED) + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private UserStatus status; + + public User(String username, String email, String password, BinaryContent profile) { + this.username = username; + this.email = email; + this.password = password; + this.profile = profile; + } + + public void update(String newUsername, String newEmail, String newPassword, + BinaryContent newProfile) { + if (newUsername != null && !newUsername.equals(this.username)) { + this.username = newUsername; + } + if (newEmail != null && !newEmail.equals(this.email)) { + this.email = newEmail; + } + if (newPassword != null && !newPassword.equals(this.password)) { + this.password = newPassword; + } + if (newProfile != null) { + this.profile = newProfile; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java new file mode 100644 index 000000000..9726f73c7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java @@ -0,0 +1,50 @@ +package com.sprint.mission.discodeit.entity; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.time.Duration; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "user_statuses") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserStatus extends BaseUpdatableEntity { + + @JsonBackReference + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + @Column(columnDefinition = "timestamp with time zone", nullable = false) + private Instant lastActiveAt; + + public UserStatus(User user, Instant lastActiveAt) { + setUser(user); + this.lastActiveAt = lastActiveAt; + } + + public void update(Instant lastActiveAt) { + if (lastActiveAt != null && !lastActiveAt.equals(this.lastActiveAt)) { + this.lastActiveAt = lastActiveAt; + } + } + + public Boolean isOnline() { + Instant instantFiveMinutesAgo = Instant.now().minus(Duration.ofMinutes(5)); + return lastActiveAt.isAfter(instantFiveMinutesAgo); + } + + protected void setUser(User user) { + this.user = user; + user.setStatus(this); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java new file mode 100644 index 000000000..1a140a6ba --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java @@ -0,0 +1,33 @@ +package com.sprint.mission.discodeit.entity.base; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "uuid", updatable = false, nullable = false) + private UUID id; + + @CreatedDate + @Column(columnDefinition = "timestamp with time zone", updatable = false, nullable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java new file mode 100644 index 000000000..57d1d3169 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.entity.base; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.LastModifiedDate; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@MappedSuperclass +public abstract class BaseUpdatableEntity extends BaseEntity { + + @LastModifiedDate + @Column(columnDefinition = "timestamp with time zone") + private Instant updatedAt; +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java new file mode 100644 index 000000000..4aab2b3dc --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.exception; + +import java.time.Instant; +import java.util.Map; +import lombok.Getter; + +@Getter +public class DiscodeitException extends RuntimeException { + private final Instant timestamp; + private final ErrorCode errorCode; + private final Map details; + + public DiscodeitException(ErrorCode errorCode, Map details) { + super(errorCode.getMessage()); + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = details; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java new file mode 100644 index 000000000..eb3dfe01b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "유저를 찾을 수 없습니다.", "해당 유저를 찾을 수 없습니다."), + DUPLICATE_USER(HttpStatus.CONFLICT.value(), "잘못된 요청 입니다.","유저이름이 이미 존재합니다." ), + DUPLICATE_EMAIL(HttpStatus.CONFLICT.value(), "잘못된 요청 입니다.", "이메일이 이미 존재합니다"), + CHANNEL_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "채널을 찾을 수 없습니다.", "해당 채널을 찾을 수 없습니다."), + PRIVATE_CHANNEL_NOT_UPDATE(HttpStatus.BAD_REQUEST.value(), "채널을 수정할 수 없습니다.", "비공개 채널은 수정할 수 없습니다."), + MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "메세지를 찾을 수 없습니다", "해당 메세지를 찾을 수 없습니다."); + + private final int status; + private final String message; + private final String detail; +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java new file mode 100644 index 000000000..325ee534f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.exception; + +import java.time.Instant; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ErrorResponse { + private Instant timestamp; + private String code; + private String message; + private Map details; + private String exceptionType; + private int status; +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..b8e4594f8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -0,0 +1,57 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.exception.user.UserException; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(DiscodeitException.class) + public ResponseEntity handleDiscodeitException(DiscodeitException e) { + ErrorCode errorCode = e.getErrorCode(); + + ErrorResponse errorResponse = ErrorResponse.builder() + .status(errorCode.getStatus()) + .message(errorCode.getMessage()) + .details(e.getDetails()) + .exceptionType(e.getClass().getName()) + .status(errorCode.getStatus()) + .build(); + + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(errorResponse); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions( + MethodArgumentNotValidException ex) { + + Map details = new HashMap<>(); + for (FieldError error : ex.getBindingResult().getFieldErrors()) { + details.put(error.getField(), error.getDefaultMessage()); + } + + ErrorResponse errorResponse = ErrorResponse.builder() + .timestamp(Instant.now()) + .status(HttpStatus.BAD_REQUEST.value()) + .exceptionType(ex.getClass().getSimpleName()) + .code("INVALID_REQUEST") + .message("유효성 검증 실패") + .details(details) + .build(); + + return ResponseEntity.badRequest().body(errorResponse); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java new file mode 100644 index 000000000..781ffe2e7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class ChannelException extends DiscodeitException { + + public ChannelException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java new file mode 100644 index 000000000..c8f693d73 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; +import java.util.UUID; + +public class ChannelNotFoundException extends ChannelException { + public ChannelNotFoundException(UUID channelId) { + super(ErrorCode.CHANNEL_NOT_FOUND, Map.of("channelId", channelId)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java new file mode 100644 index 000000000..9babb138d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; +import java.util.UUID; + +public class PrivateChannelUpdateException extends ChannelException { + + public PrivateChannelUpdateException(UUID channelId) { + super(ErrorCode.PRIVATE_CHANNEL_NOT_UPDATE, Map.of("channelId", channelId)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java new file mode 100644 index 000000000..d1fea2bc2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class MessageException extends DiscodeitException { + public MessageException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java new file mode 100644 index 000000000..9393f1f52 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; +import java.util.UUID; + +public class MessageNotFoundException extends MessageException{ + + public MessageNotFoundException(UUID messageId) { + super(ErrorCode.MESSAGE_NOT_FOUND, Map.of("messageId", messageId)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserDuplicateException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserDuplicateException.java new file mode 100644 index 000000000..9515e9997 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserDuplicateException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class UserDuplicateException extends UserException { + + public UserDuplicateException(String userName) { + super(ErrorCode.DUPLICATE_USER, Map.of("userName", userName)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserEmailDuplicateException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserEmailDuplicateException.java new file mode 100644 index 000000000..bd260e5c5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserEmailDuplicateException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class UserEmailDuplicateException extends UserException { + + public UserEmailDuplicateException(String email) { + super(ErrorCode.DUPLICATE_EMAIL, Map.of("email", email)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java new file mode 100644 index 000000000..58ed3b686 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; + +public class UserException extends DiscodeitException { + + public UserException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java new file mode 100644 index 000000000..3c391b9af --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; +import java.util.Map; +import java.util.UUID; + +public class UserNotFoundException extends UserException { + + public UserNotFoundException(UUID userId) { + super(ErrorCode.USER_NOT_FOUND, Map.of("userId", userId)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java new file mode 100644 index 000000000..d3ea1f137 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.entity.BinaryContent; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface BinaryContentMapper { + + BinaryContentDto toDto(BinaryContent binaryContent); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java new file mode 100644 index 000000000..f39a5809c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java @@ -0,0 +1,48 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.beans.factory.annotation.Autowired; + +@Mapper(componentModel = "spring", uses = {UserMapper.class}) +public abstract class ChannelMapper { + + @Autowired + private MessageRepository messageRepository; + @Autowired + private ReadStatusRepository readStatusRepository; + @Autowired + private UserMapper userMapper; + + @Mapping(target = "participants", expression = "java(resolveParticipants(channel))") + @Mapping(target = "lastMessageAt", expression = "java(resolveLastMessageAt(channel))") + abstract public ChannelDto toDto(Channel channel); + + protected Instant resolveLastMessageAt(Channel channel) { + return messageRepository.findLastMessageAtByChannelId( + channel.getId()) + .orElse(Instant.MIN); + } + + protected List resolveParticipants(Channel channel) { + List participants = new ArrayList<>(); + if (channel.getType().equals(ChannelType.PRIVATE)) { + readStatusRepository.findAllByChannelIdWithUser(channel.getId()) + .stream() + .map(ReadStatus::getUser) + .map(userMapper::toDto) + .forEach(participants::add); + } + return participants; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java new file mode 100644 index 000000000..e0301ac08 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.entity.Message; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", uses = {BinaryContentMapper.class, UserMapper.class}) +public interface MessageMapper { + + @Mapping(target = "channelId", source = "channel.id") + MessageDto toDto(Message message); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java new file mode 100644 index 000000000..108a9b59d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java @@ -0,0 +1,30 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.response.PageResponse; +import org.mapstruct.Mapper; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; + +@Mapper(componentModel = "spring") +public interface PageResponseMapper { + + default PageResponse fromSlice(Slice slice, Object nextCursor) { + return new PageResponse<>( + slice.getContent(), + nextCursor, + slice.getSize(), + slice.hasNext(), + null + ); + } + + default PageResponse fromPage(Page page, Object nextCursor) { + return new PageResponse<>( + page.getContent(), + nextCursor, + page.getSize(), + page.hasNext(), + page.getTotalElements() + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java new file mode 100644 index 000000000..af9b85279 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.entity.ReadStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ReadStatusMapper { + + @Mapping(target = "userId", source = "user.id") + @Mapping(target = "channelId", source = "channel.id") + ReadStatusDto toDto(ReadStatus readStatus); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java new file mode 100644 index 000000000..c040a2edb --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", uses = {BinaryContentMapper.class, UserStatusMapper.class}) +public interface UserMapper { + + @Mapping(target = "online", expression = "java(user.getStatus().isOnline())") + UserDto toDto(User user); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java new file mode 100644 index 000000000..202e56a18 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.entity.UserStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface UserStatusMapper { + + @Mapping(target = "userId", source = "user.id") + UserStatusDto toDto(UserStatus userStatus); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java new file mode 100644 index 000000000..cbd8c79cf --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BinaryContentRepository extends JpaRepository { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java new file mode 100644 index 000000000..e4b1fd235 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChannelRepository extends JpaRepository { + + List findAllByTypeOrIdIn(ChannelType type, List ids); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java new file mode 100644 index 000000000..ac649b75f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java @@ -0,0 +1,32 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Message; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MessageRepository extends JpaRepository { + + @Query("SELECT m FROM Message m " + + "LEFT JOIN FETCH m.author a " + + "JOIN FETCH a.status " + + "LEFT JOIN FETCH a.profile " + + "WHERE m.channel.id=:channelId AND m.createdAt < :createdAt") + Slice findAllByChannelIdWithAuthor(@Param("channelId") UUID channelId, + @Param("createdAt") Instant createdAt, + Pageable pageable); + + + @Query("SELECT m.createdAt " + + "FROM Message m " + + "WHERE m.channel.id = :channelId " + + "ORDER BY m.createdAt DESC LIMIT 1") + Optional findLastMessageAtByChannelId(@Param("channelId") UUID channelId); + + void deleteAllByChannelId(UUID channelId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java new file mode 100644 index 000000000..f1d469af1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.ReadStatus; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ReadStatusRepository extends JpaRepository { + + + List findAllByUserId(UUID userId); + + @Query("SELECT r FROM ReadStatus r " + + "JOIN FETCH r.user u " + + "JOIN FETCH u.status " + + "LEFT JOIN FETCH u.profile " + + "WHERE r.channel.id = :channelId") + List findAllByChannelIdWithUser(@Param("channelId") UUID channelId); + + Boolean existsByUserIdAndChannelId(UUID userId, UUID channelId); + + void deleteAllByChannelId(UUID channelId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java new file mode 100644 index 000000000..f7103705f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.User; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); + + @Query("SELECT u FROM User u " + + "LEFT JOIN FETCH u.profile " + + "JOIN FETCH u.status") + List findAllWithProfileAndStatus(); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java new file mode 100644 index 000000000..46102abf5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.UserStatus; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserStatusRepository extends JpaRepository { + + Optional findByUserId(UUID userId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java new file mode 100644 index 000000000..a1caf1d2d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; + +public interface AuthService { + + UserDto login(LoginRequest loginRequest); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java new file mode 100644 index 000000000..23836a446 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import java.util.List; +import java.util.UUID; + +public interface BinaryContentService { + + BinaryContentDto create(BinaryContentCreateRequest request); + + BinaryContentDto find(UUID binaryContentId); + + List findAllByIdIn(List binaryContentIds); + + void delete(UUID binaryContentId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java new file mode 100644 index 000000000..a082c9ff9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import java.util.List; +import java.util.UUID; + +public interface ChannelService { + + ChannelDto create(PublicChannelCreateRequest request); + + ChannelDto create(PrivateChannelCreateRequest request); + + ChannelDto find(UUID channelId); + + List findAllByUserId(UUID userId); + + ChannelDto update(UUID channelId, PublicChannelUpdateRequest request); + + void delete(UUID channelId); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java new file mode 100644 index 000000000..8ac5ee924 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Pageable; + +public interface MessageService { + + MessageDto create(MessageCreateRequest messageCreateRequest, + List binaryContentCreateRequests); + + MessageDto find(UUID messageId); + + PageResponse findAllByChannelId(UUID channelId, Instant createdAt, Pageable pageable); + + MessageDto update(UUID messageId, MessageUpdateRequest request); + + void delete(UUID messageId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java new file mode 100644 index 000000000..8b0c80a31 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import java.util.List; +import java.util.UUID; + +public interface ReadStatusService { + + ReadStatusDto create(ReadStatusCreateRequest request); + + ReadStatusDto find(UUID readStatusId); + + List findAllByUserId(UUID userId); + + ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request); + + void delete(UUID readStatusId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserService.java b/src/main/java/com/sprint/mission/discodeit/service/UserService.java new file mode 100644 index 000000000..444118780 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/UserService.java @@ -0,0 +1,24 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserService { + + UserDto create(UserCreateRequest userCreateRequest, + Optional profileCreateRequest); + + UserDto find(UUID userId); + + List findAll(); + + UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, + Optional profileCreateRequest); + + void delete(UUID userId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java new file mode 100644 index 000000000..3c5c55e6e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import java.util.List; +import java.util.UUID; + +public interface UserStatusService { + + UserStatusDto create(UserStatusCreateRequest request); + + UserStatusDto find(UUID userStatusId); + + List findAll(); + + UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request); + + UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request); + + void delete(UUID userStatusId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java new file mode 100644 index 000000000..80e9cf8bb --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -0,0 +1,39 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.AuthService; +import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Slf4j +public class BasicAuthService implements AuthService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + + @Transactional(readOnly = true) + @Override + public UserDto login(LoginRequest loginRequest) { + String username = loginRequest.username(); + String password = loginRequest.password(); + + User user = userRepository.findByUsername(username) + .orElseThrow( + () -> new NoSuchElementException("User with username " + username + " not found")); + + if (!user.getPassword().equals(password)) { + throw new IllegalArgumentException("Wrong password"); + } + + return userMapper.toDto(user); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java new file mode 100644 index 000000000..f2354cda9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java @@ -0,0 +1,74 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Slf4j +public class BasicBinaryContentService implements BinaryContentService { + + private final BinaryContentRepository binaryContentRepository; + private final BinaryContentMapper binaryContentMapper; + private final BinaryContentStorage binaryContentStorage; + + @Transactional + @Override + public BinaryContentDto create(BinaryContentCreateRequest request) { + log.debug("BinaryContent 생성 요청: fileName = {}, contentType = {}, size = {} bytes", + request.fileName(), request.contentType(), request.bytes().length); + String fileName = request.fileName(); + byte[] bytes = request.bytes(); + String contentType = request.contentType(); + BinaryContent binaryContent = new BinaryContent( + fileName, + (long) bytes.length, + contentType + ); + binaryContentRepository.save(binaryContent); + log.info("BinaryContent 생성 성공: id = {}, fileName = {}", + binaryContent.getId(), binaryContent.getFileName()); + binaryContentStorage.put(binaryContent.getId(), bytes); + + return binaryContentMapper.toDto(binaryContent); + } + + @Override + public BinaryContentDto find(UUID binaryContentId) { + return binaryContentRepository.findById(binaryContentId) + .map(binaryContentMapper::toDto) + .orElseThrow(() -> new NoSuchElementException( + "BinaryContent with id " + binaryContentId + " not found")); + } + + @Override + public List findAllByIdIn(List binaryContentIds) { + return binaryContentRepository.findAllById(binaryContentIds).stream() + .map(binaryContentMapper::toDto) + .toList(); + } + + @Transactional + @Override + public void delete(UUID binaryContentId) { + log.debug("BinaryContent 삭제 요청: binaryContentId = {}", binaryContentId); + if (!binaryContentRepository.existsById(binaryContentId)) { + log.error("BinaryContent 삭제 실패: binaryContentId = {} ", binaryContentId); + throw new NoSuchElementException("BinaryContent with id " + binaryContentId + " not found"); + } + binaryContentRepository.deleteById(binaryContentId); + log.info("BinaryContent 삭제 성공: id = {}", binaryContentId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java new file mode 100644 index 000000000..0598fb826 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java @@ -0,0 +1,133 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.ChannelService; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Slf4j +public class BasicChannelService implements ChannelService { + + private final ChannelRepository channelRepository; + // + private final ReadStatusRepository readStatusRepository; + private final MessageRepository messageRepository; + private final UserRepository userRepository; + private final ChannelMapper channelMapper; + + @Transactional + @Override + public ChannelDto create(PublicChannelCreateRequest request) { + log.debug("public Channel 생성 요청 : request = {}", request); + String name = request.name(); + String description = request.description(); + Channel channel = new Channel(ChannelType.PUBLIC, name, description); + + channelRepository.save(channel); + log.info("public Channel 생성 성공 : name = {}", request.name()); + return channelMapper.toDto(channel); + } + + @Transactional + @Override + public ChannelDto create(PrivateChannelCreateRequest request) { + log.debug("private Channel 생성 요청 : request = {}", request); + Channel channel = new Channel(ChannelType.PRIVATE, null, null); + channelRepository.save(channel); + log.info("private Channel 생성 성공 : channel = {}", channel); + + List readStatuses = userRepository.findAllById(request.participantIds()).stream() + .map(user -> new ReadStatus(user, channel, channel.getCreatedAt())) + .toList(); + readStatusRepository.saveAll(readStatuses); + + return channelMapper.toDto(channel); + } + + @Transactional(readOnly = true) + @Override + public ChannelDto find(UUID channelId) { + return channelRepository.findById(channelId) + .map(channelMapper::toDto) + .orElseThrow( + () -> new ChannelNotFoundException(channelId)); + } + + @Transactional(readOnly = true) + @Override + public List findAllByUserId(UUID userId) { + List mySubscribedChannelIds = readStatusRepository.findAllByUserId(userId).stream() + .map(ReadStatus::getChannel) + .map(Channel::getId) + .toList(); + + return channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, mySubscribedChannelIds) + .stream() + .map(channelMapper::toDto) + .toList(); + } + + @Transactional + @Override + public ChannelDto update(UUID channelId, PublicChannelUpdateRequest request) { + log.debug("Channel 수정 요청 : request = {} ", request); + String newName = request.newName(); + String newDescription = request.newDescription(); + + Channel channel = channelRepository.findById(channelId) + .orElseThrow(() -> { + log.error("Channel 을 찾지 못함 : {}", channelId); + return new ChannelNotFoundException(channelId); + } + ); + + if (channel.getType().equals(ChannelType.PRIVATE)) { + log.debug("private Channel 채널 수정 불가 : getType = {}", channel.getType()); + throw new PrivateChannelUpdateException(channelId); + } + channel.update(newName, newDescription); + log.info("public Channel 수정 성공 : newName = {}, newDescription = {}", request.newName(), request.newDescription()); + return channelMapper.toDto(channel); + } + + @Transactional + @Override + public void delete(UUID channelId) { + log.debug("Channel 삭제 요청 : channelId = {}", channelId); + if (!channelRepository.existsById(channelId)) { + log.error("Channel 삭제 실패 - 존재하지 않음 : channelId = {}", channelId); + throw new ChannelNotFoundException(channelId); + } + + log.debug("message 삭제 시작: = {}", channelId); + messageRepository.deleteAllByChannelId(channelId); + log.debug("message 삭제 완료: = {}", channelId); + + log.debug("읽음 상태 삭제 시작: = {}", channelId); + readStatusRepository.deleteAllByChannelId(channelId); + log.debug("읽음 상태 삭제 완료: = {}", channelId); + + channelRepository.deleteById(channelId); + log.info("Channel 삭제 성공 : channelId = {}", channelId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java new file mode 100644 index 000000000..92ac51ddb --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java @@ -0,0 +1,153 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.PageResponseMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.time.Instant; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Slf4j +public class BasicMessageService implements MessageService { + + private final MessageRepository messageRepository; + // + private final ChannelRepository channelRepository; + private final UserRepository userRepository; + private final MessageMapper messageMapper; + private final BinaryContentStorage binaryContentStorage; + private final BinaryContentRepository binaryContentRepository; + private final PageResponseMapper pageResponseMapper; + + @Transactional + @Override + public MessageDto create(MessageCreateRequest messageCreateRequest, + List binaryContentCreateRequests) { + log.debug("message 생성 요청 : messageCreateRequest = {}", messageCreateRequest); + UUID channelId = messageCreateRequest.channelId(); + UUID authorId = messageCreateRequest.authorId(); + + Channel channel = channelRepository.findById(channelId) + .orElseThrow(() -> { + log.error("Channel 을 찾을 수 없음 : channelId = {}", channelId); + return new ChannelNotFoundException(channelId); + }); + User author = userRepository.findById(authorId) + .orElseThrow( + () -> { + log.error("User 를 찾을 수 없음 : authorId = {}", authorId); + return new UserNotFoundException(authorId); + } + ); + + List attachments = binaryContentCreateRequests.stream() + .map(attachmentRequest -> { + String fileName = attachmentRequest.fileName(); + String contentType = attachmentRequest.contentType(); + byte[] bytes = attachmentRequest.bytes(); + + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + log.info("binaryContent 저장 성공 : binaryContent = {}", binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .toList(); + + String content = messageCreateRequest.content(); + Message message = new Message( + content, + channel, + author, + attachments + ); + + messageRepository.save(message); + log.info("message 생성 성공 : content = {}", messageCreateRequest.content()); + return messageMapper.toDto(message); + } + + @Transactional(readOnly = true) + @Override + public MessageDto find(UUID messageId) { + return messageRepository.findById(messageId) + .map(messageMapper::toDto) + .orElseThrow( + () -> new MessageNotFoundException(messageId)); + } + + @Transactional(readOnly = true) + @Override + public PageResponse findAllByChannelId(UUID channelId, Instant createAt, + Pageable pageable) { + Slice slice = messageRepository.findAllByChannelIdWithAuthor(channelId, + Optional.ofNullable(createAt).orElse(Instant.now()), + pageable) + .map(messageMapper::toDto); + + Instant nextCursor = null; + if (!slice.getContent().isEmpty()) { + nextCursor = slice.getContent().get(slice.getContent().size() - 1) + .createdAt(); + } + + return pageResponseMapper.fromSlice(slice, nextCursor); + } + + @Transactional + @Override + public MessageDto update(UUID messageId, MessageUpdateRequest request) { + log.debug("message 수정 요청 : newContent = {}", request.newContent()); + String newContent = request.newContent(); + Message message = messageRepository.findById(messageId) + .orElseThrow( + () -> { + log.error("message 수정 실패 : messageId = {}", messageId); + return new MessageNotFoundException(messageId); + } + ); + message.update(newContent); + log.info("message 수정 성공 : newContent = {}", request.newContent()); + return messageMapper.toDto(message); + } + + @Transactional + @Override + public void delete(UUID messageId) { + log.debug("message 삭제 요청 : messageId = {}", messageId); + if (!messageRepository.existsById(messageId)) { + log.error("message 삭제 요청 실패 : messageId = {}", messageId); + throw new MessageNotFoundException(messageId); + } + log.info("message 삭제 성공: messageId = {}", messageId); + messageRepository.deleteById(messageId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java new file mode 100644 index 000000000..6484415a9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java @@ -0,0 +1,93 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.mapper.ReadStatusMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.ReadStatusService; +import java.time.Instant; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Slf4j +public class BasicReadStatusService implements ReadStatusService { + + private final ReadStatusRepository readStatusRepository; + private final UserRepository userRepository; + private final ChannelRepository channelRepository; + private final ReadStatusMapper readStatusMapper; + + @Transactional + @Override + public ReadStatusDto create(ReadStatusCreateRequest request) { + UUID userId = request.userId(); + UUID channelId = request.channelId(); + + User user = userRepository.findById(userId) + .orElseThrow( + () -> new NoSuchElementException("User with id " + userId + " does not exist")); + Channel channel = channelRepository.findById(channelId) + .orElseThrow( + () -> new NoSuchElementException("Channel with id " + channelId + " does not exist") + ); + + if (readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId())) { + throw new IllegalArgumentException( + "ReadStatus with userId " + userId + " and channelId " + channelId + " already exists"); + } + + Instant lastReadAt = request.lastReadAt(); + ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt); + readStatusRepository.save(readStatus); + + return readStatusMapper.toDto(readStatus); + } + + @Override + public ReadStatusDto find(UUID readStatusId) { + return readStatusRepository.findById(readStatusId) + .map(readStatusMapper::toDto) + .orElseThrow( + () -> new NoSuchElementException("ReadStatus with id " + readStatusId + " not found")); + } + + @Override + public List findAllByUserId(UUID userId) { + return readStatusRepository.findAllByUserId(userId).stream() + .map(readStatusMapper::toDto) + .toList(); + } + + @Transactional + @Override + public ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request) { + Instant newLastReadAt = request.newLastReadAt(); + ReadStatus readStatus = readStatusRepository.findById(readStatusId) + .orElseThrow( + () -> new NoSuchElementException("ReadStatus with id " + readStatusId + " not found")); + readStatus.update(newLastReadAt); + return readStatusMapper.toDto(readStatus); + } + + @Transactional + @Override + public void delete(UUID readStatusId) { + if (!readStatusRepository.existsById(readStatusId)) { + throw new NoSuchElementException("ReadStatus with id " + readStatusId + " not found"); + } + readStatusRepository.deleteById(readStatusId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java new file mode 100644 index 000000000..3c785c021 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -0,0 +1,149 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.user.UserDuplicateException; +import com.sprint.mission.discodeit.exception.user.UserEmailDuplicateException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.service.UserService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.time.Instant; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Slf4j +public class BasicUserService implements UserService { + + private final UserRepository userRepository; + private final UserStatusRepository userStatusRepository; + private final UserMapper userMapper; + private final BinaryContentRepository binaryContentRepository; + private final BinaryContentStorage binaryContentStorage; + + @Transactional + @Override + public UserDto create(UserCreateRequest userCreateRequest, + Optional optionalProfileCreateRequest) { + log.debug("User 생성 요청 : userCreateRequest = {}", userCreateRequest); + String username = userCreateRequest.username(); + String email = userCreateRequest.email(); + + if (userRepository.existsByEmail(email)) { + log.error("User 생성 실패 : email = {}", userCreateRequest.email()); + throw new UserEmailDuplicateException(email); + } + if (userRepository.existsByUsername(username)) { + log.error("User 생성 실패 : username = {}", userCreateRequest.username()); + throw new UserDuplicateException(username); + } + + BinaryContent nullableProfile = optionalProfileCreateRequest + .map(profileRequest -> { + String fileName = profileRequest.fileName(); + String contentType = profileRequest.contentType(); + byte[] bytes = profileRequest.bytes(); + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + log.info("binaryContent 저장 성공 : binaryContent = {}", binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .orElse(null); + String password = userCreateRequest.password(); + + User user = new User(username, email, password, nullableProfile); + Instant now = Instant.now(); + UserStatus userStatus = new UserStatus(user, now); + + userRepository.save(user); + log.info("User 생성 성공: username = {}", userCreateRequest.username()); + return userMapper.toDto(user); + } + + @Override + public UserDto find(UUID userId) { + return userRepository.findById(userId) + .map(userMapper::toDto) + .orElseThrow(() -> new UserNotFoundException(userId)); + } + + @Override + public List findAll() { + return userRepository.findAllWithProfileAndStatus() + .stream() + .map(userMapper::toDto) + .toList(); + } + + @Transactional + @Override + public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, + Optional optionalProfileCreateRequest) { + log.debug("User 수정 요청 : {}", userUpdateRequest); + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException(userId)); + + String newUsername = userUpdateRequest.newUsername(); + String newEmail = userUpdateRequest.newEmail(); + if (userRepository.existsByEmail(newEmail)) { + log.error("User 수정 실패 : newEmail = {}", userUpdateRequest.newEmail()); + throw new UserEmailDuplicateException(newEmail); + } + if (userRepository.existsByUsername(newUsername)) { + log.error("User 수정 실패 : newUsername = {}", userUpdateRequest.newUsername()); + throw new UserDuplicateException(newUsername); + } + + BinaryContent nullableProfile = optionalProfileCreateRequest + .map(profileRequest -> { + + String fileName = profileRequest.fileName(); + String contentType = profileRequest.contentType(); + byte[] bytes = profileRequest.bytes(); + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + log.info("binaryContent 저장 성공 : binaryContent = {}", binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .orElse(null); + + String newPassword = userUpdateRequest.newPassword(); + user.update(newUsername, newEmail, newPassword, nullableProfile); + log.info("User 수정 성공 : newUsername = {}", userUpdateRequest.newUsername()); + + return userMapper.toDto(user); + } + + @Transactional + @Override + public void delete(UUID userId) { + log.debug("User 삭제 요청 : userId = {}", userId); + if (!userRepository.existsById(userId)) { + log.error("User 삭제 요청 : userId = {}", userId); + throw new UserNotFoundException(userId); + } + + userRepository.deleteById(userId); + log.info("User 삭제 성공 : userId = {}", userId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java new file mode 100644 index 000000000..9a3798a9d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java @@ -0,0 +1,98 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.mapper.UserStatusMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.service.UserStatusService; +import java.time.Instant; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Slf4j +public class BasicUserStatusService implements UserStatusService { + + private final UserStatusRepository userStatusRepository; + private final UserRepository userRepository; + private final UserStatusMapper userStatusMapper; + + @Transactional + @Override + public UserStatusDto create(UserStatusCreateRequest request) { + UUID userId = request.userId(); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User with id " + userId + " not found")); + Optional.ofNullable(user.getStatus()) + .ifPresent(status -> { + throw new IllegalArgumentException("UserStatus with id " + userId + " already exists"); + }); + + Instant lastActiveAt = request.lastActiveAt(); + UserStatus userStatus = new UserStatus(user, lastActiveAt); + userStatusRepository.save(userStatus); + return userStatusMapper.toDto(userStatus); + } + + @Override + public UserStatusDto find(UUID userStatusId) { + return userStatusRepository.findById(userStatusId) + .map(userStatusMapper::toDto) + .orElseThrow( + () -> new NoSuchElementException("UserStatus with id " + userStatusId + " not found")); + } + + @Override + public List findAll() { + return userStatusRepository.findAll().stream() + .map(userStatusMapper::toDto) + .toList(); + } + + @Transactional + @Override + public UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request) { + Instant newLastActiveAt = request.newLastActiveAt(); + + UserStatus userStatus = userStatusRepository.findById(userStatusId) + .orElseThrow( + () -> new NoSuchElementException("UserStatus with id " + userStatusId + " not found")); + userStatus.update(newLastActiveAt); + + return userStatusMapper.toDto(userStatus); + } + + @Transactional + @Override + public UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request) { + Instant newLastActiveAt = request.newLastActiveAt(); + + UserStatus userStatus = userStatusRepository.findByUserId(userId) + .orElseThrow( + () -> new NoSuchElementException("UserStatus with userId " + userId + " not found")); + userStatus.update(newLastActiveAt); + + return userStatusMapper.toDto(userStatus); + } + + @Transactional + @Override + public void delete(UUID userStatusId) { + if (!userStatusRepository.existsById(userStatusId)) { + throw new NoSuchElementException("UserStatus with id " + userStatusId + " not found"); + } + userStatusRepository.deleteById(userStatusId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java new file mode 100644 index 000000000..f00216c40 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java @@ -0,0 +1,15 @@ +package com.sprint.mission.discodeit.storage; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import java.io.InputStream; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +public interface BinaryContentStorage { + + UUID put(UUID binaryContentId, byte[] bytes); + + InputStream get(UUID binaryContentId); + + ResponseEntity download(BinaryContentDto metaData); +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java new file mode 100644 index 000000000..8922903c0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java @@ -0,0 +1,89 @@ +package com.sprint.mission.discodeit.storage.local; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.NoSuchElementException; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local") +@Component +public class LocalBinaryContentStorage implements BinaryContentStorage { + + private final Path root; + + public LocalBinaryContentStorage( + @Value("${discodeit.storage.local.root-path}") Path root + ) { + this.root = root; + } + + @PostConstruct + public void init() { + if (!Files.exists(root)) { + try { + Files.createDirectories(root); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + } + + public UUID put(UUID binaryContentId, byte[] bytes) { + Path filePath = resolvePath(binaryContentId); + if (Files.exists(filePath)) { + throw new IllegalArgumentException("File with key " + binaryContentId + " already exists"); + } + try (OutputStream outputStream = Files.newOutputStream(filePath)) { + outputStream.write(bytes); + } catch (IOException e) { + throw new RuntimeException(e); + } + return binaryContentId; + } + + public InputStream get(UUID binaryContentId) { + Path filePath = resolvePath(binaryContentId); + if (Files.notExists(filePath)) { + throw new NoSuchElementException("File with key " + binaryContentId + " does not exist"); + } + try { + return Files.newInputStream(filePath); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private Path resolvePath(UUID key) { + return root.resolve(key.toString()); + } + + @Override + public ResponseEntity download(BinaryContentDto metaData) { + InputStream inputStream = get(metaData.id()); + Resource resource = new InputStreamResource(inputStream); + + return ResponseEntity + .status(HttpStatus.OK) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + metaData.fileName() + "\"") + .header(HttpHeaders.CONTENT_TYPE, metaData.contentType()) + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(metaData.size())) + .body(resource); + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 000000000..37ea0700b --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,16 @@ +spring: + config: + activate: + on-profile: dev + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/discodeit + username: discodeit_user + password: discodeit1234 +logging: + level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + com.sprint.mission.discodeit : debug +server: + port: 8080 \ No newline at end of file diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 000000000..3f8edd040 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,16 @@ +spring: + config: + activate: + on-profile: prod + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/discodeit + username: discodeit_user + password: discodeit1234 +logging: + level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + com.sprint.mission.discodeit : info +server: + port: 8080 \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 000000000..635cf6f2f --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,68 @@ +spring: + profiles: + active: prod + application: + name: discodeit + servlet: + multipart: + maxFileSize: 10MB # 파일 하나의 최대 크기 + maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/discodeit + username: discodeit_user + password: discodeit1234 + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + open-in-view: false + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + root: info + +discodeit: + storage: + type: local + local: + root-path: .discodeit/storage + +management: + endpoints: + web: + exposure: + include: '*' # 모든 actuator 엔드포인트 노출 + metrics: + data: + repository: + autotime: + enabled: true + endpoint: + loggers: + access: read_only + info: + env: + enabled: true + +info: + app: + name: Discodeit + description: | + datasource: + url: jdbc:postgresql://localhost:5432/discodeit + driver-class-name: org.postgresql.Driver + jpa: ddl-auto + storage: + type: local + path: .discodeit/storage + multipart: + maxFileSize: 10MB + maxRequestSize: 30MB + "app version": 1.7.0 + "java version": 17 + "spring boot version": 3.4.0 \ No newline at end of file diff --git a/src/main/resources/fe_bundle_1.2.3.zip b/src/main/resources/fe_bundle_1.2.3.zip new file mode 100644 index 0000000000000000000000000000000000000000..852a0869c0058982bcd14d2879e20e95e78927c2 GIT binary patch literal 95493 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�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 |HJx!8ETT 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 BOw#}W literal 0 HcmV?d00001 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..46950a042 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,50 @@ + + + + + + + %d{yy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger{36} - %msg%n + + + + + + .logs/application.log + + %d{yy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger{36} - %msg%n + + + + .logs/application.%d{yyyy-MM-dd}.log + 30 + 10GB + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 000000000..c658649cd --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,126 @@ +-- 테이블 +-- User +CREATE TABLE users +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + username varchar(50) UNIQUE NOT NULL, + email varchar(100) UNIQUE NOT NULL, + password varchar(60) NOT NULL, + profile_id uuid +); + +-- BinaryContent +CREATE TABLE binary_contents +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + file_name varchar(255) NOT NULL, + size bigint NOT NULL, + content_type varchar(100) NOT NULL +-- ,bytes bytea NOT NULL +); + +-- UserStatus +CREATE TABLE user_statuses +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + user_id uuid UNIQUE NOT NULL, + last_active_at timestamp with time zone NOT NULL +); + +-- Channel +CREATE TABLE channels +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + name varchar(100), + description varchar(500), + type varchar(10) NOT NULL +); + +-- Message +CREATE TABLE messages +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + content text, + channel_id uuid NOT NULL, + author_id uuid +); + +-- Message.attachments +CREATE TABLE message_attachments +( + message_id uuid, + attachment_id uuid, + PRIMARY KEY (message_id, attachment_id) +); + +-- ReadStatus +CREATE TABLE read_statuses +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + user_id uuid NOT NULL, + channel_id uuid NOT NULL, + last_read_at timestamp with time zone NOT NULL, + UNIQUE (user_id, channel_id) +); + + +-- 제약 조건 +-- User (1) -> BinaryContent (1) +ALTER TABLE users + ADD CONSTRAINT fk_user_binary_content + FOREIGN KEY (profile_id) + REFERENCES binary_contents (id) + ON DELETE SET NULL; + +-- UserStatus (1) -> User (1) +ALTER TABLE user_statuses + ADD CONSTRAINT fk_user_status_user + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE; + +-- Message (N) -> Channel (1) +ALTER TABLE messages + ADD CONSTRAINT fk_message_channel + FOREIGN KEY (channel_id) + REFERENCES channels (id) + ON DELETE CASCADE; + +-- Message (N) -> Author (1) +ALTER TABLE messages + ADD CONSTRAINT fk_message_user + FOREIGN KEY (author_id) + REFERENCES users (id) + ON DELETE SET NULL; + +-- MessageAttachment (1) -> BinaryContent (1) +ALTER TABLE message_attachments + ADD CONSTRAINT fk_message_attachment_binary_content + FOREIGN KEY (attachment_id) + REFERENCES binary_contents (id) + ON DELETE CASCADE; + +-- ReadStatus (N) -> User (1) +ALTER TABLE read_statuses + ADD CONSTRAINT fk_read_status_user + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE; + +-- ReadStatus (N) -> User (1) +ALTER TABLE read_statuses + ADD CONSTRAINT fk_read_status_channel + FOREIGN KEY (channel_id) + REFERENCES channels (id) + ON DELETE CASCADE; \ No newline at end of file diff --git a/src/main/resources/static/assets/index-DRjprt8D.js b/src/main/resources/static/assets/index-DRjprt8D.js new file mode 100644 index 000000000..40695b51e --- /dev/null +++ b/src/main/resources/static/assets/index-DRjprt8D.js @@ -0,0 +1,1015 @@ +var rg=Object.defineProperty;var og=(r,i,s)=>i in r?rg(r,i,{enumerable:!0,configurable:!0,writable:!0,value:s}):r[i]=s;var ed=(r,i,s)=>og(r,typeof i!="symbol"?i+"":i,s);(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))l(c);new MutationObserver(c=>{for(const f of c)if(f.type==="childList")for(const p of f.addedNodes)p.tagName==="LINK"&&p.rel==="modulepreload"&&l(p)}).observe(document,{childList:!0,subtree:!0});function s(c){const f={};return c.integrity&&(f.integrity=c.integrity),c.referrerPolicy&&(f.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?f.credentials="include":c.crossOrigin==="anonymous"?f.credentials="omit":f.credentials="same-origin",f}function l(c){if(c.ep)return;c.ep=!0;const f=s(c);fetch(c.href,f)}})();function ig(r){return r&&r.__esModule&&Object.prototype.hasOwnProperty.call(r,"default")?r.default:r}var mu={exports:{}},yo={},gu={exports:{}},fe={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var td;function sg(){if(td)return fe;td=1;var r=Symbol.for("react.element"),i=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),l=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),f=Symbol.for("react.provider"),p=Symbol.for("react.context"),g=Symbol.for("react.forward_ref"),x=Symbol.for("react.suspense"),v=Symbol.for("react.memo"),S=Symbol.for("react.lazy"),A=Symbol.iterator;function T(E){return E===null||typeof E!="object"?null:(E=A&&E[A]||E["@@iterator"],typeof E=="function"?E:null)}var I={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},R=Object.assign,C={};function N(E,D,se){this.props=E,this.context=D,this.refs=C,this.updater=se||I}N.prototype.isReactComponent={},N.prototype.setState=function(E,D){if(typeof E!="object"&&typeof E!="function"&&E!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,E,D,"setState")},N.prototype.forceUpdate=function(E){this.updater.enqueueForceUpdate(this,E,"forceUpdate")};function H(){}H.prototype=N.prototype;function U(E,D,se){this.props=E,this.context=D,this.refs=C,this.updater=se||I}var V=U.prototype=new H;V.constructor=U,R(V,N.prototype),V.isPureReactComponent=!0;var Q=Array.isArray,$=Object.prototype.hasOwnProperty,L={current:null},b={key:!0,ref:!0,__self:!0,__source:!0};function re(E,D,se){var ue,de={},ce=null,ve=null;if(D!=null)for(ue in D.ref!==void 0&&(ve=D.ref),D.key!==void 0&&(ce=""+D.key),D)$.call(D,ue)&&!b.hasOwnProperty(ue)&&(de[ue]=D[ue]);var pe=arguments.length-2;if(pe===1)de.children=se;else if(1>>1,D=W[E];if(0>>1;Ec(de,Y))cec(ve,de)?(W[E]=ve,W[ce]=Y,E=ce):(W[E]=de,W[ue]=Y,E=ue);else if(cec(ve,Y))W[E]=ve,W[ce]=Y,E=ce;else break e}}return Z}function c(W,Z){var Y=W.sortIndex-Z.sortIndex;return Y!==0?Y:W.id-Z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var f=performance;r.unstable_now=function(){return f.now()}}else{var p=Date,g=p.now();r.unstable_now=function(){return p.now()-g}}var x=[],v=[],S=1,A=null,T=3,I=!1,R=!1,C=!1,N=typeof setTimeout=="function"?setTimeout:null,H=typeof clearTimeout=="function"?clearTimeout:null,U=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function V(W){for(var Z=s(v);Z!==null;){if(Z.callback===null)l(v);else if(Z.startTime<=W)l(v),Z.sortIndex=Z.expirationTime,i(x,Z);else break;Z=s(v)}}function Q(W){if(C=!1,V(W),!R)if(s(x)!==null)R=!0,We($);else{var Z=s(v);Z!==null&&Se(Q,Z.startTime-W)}}function $(W,Z){R=!1,C&&(C=!1,H(re),re=-1),I=!0;var Y=T;try{for(V(Z),A=s(x);A!==null&&(!(A.expirationTime>Z)||W&&!at());){var E=A.callback;if(typeof E=="function"){A.callback=null,T=A.priorityLevel;var D=E(A.expirationTime<=Z);Z=r.unstable_now(),typeof D=="function"?A.callback=D:A===s(x)&&l(x),V(Z)}else l(x);A=s(x)}if(A!==null)var se=!0;else{var ue=s(v);ue!==null&&Se(Q,ue.startTime-Z),se=!1}return se}finally{A=null,T=Y,I=!1}}var L=!1,b=null,re=-1,ye=5,Ne=-1;function at(){return!(r.unstable_now()-NeW||125E?(W.sortIndex=Y,i(v,W),s(x)===null&&W===s(v)&&(C?(H(re),re=-1):C=!0,Se(Q,Y-E))):(W.sortIndex=D,i(x,W),R||I||(R=!0,We($))),W},r.unstable_shouldYield=at,r.unstable_wrapCallback=function(W){var Z=T;return function(){var Y=T;T=Z;try{return W.apply(this,arguments)}finally{T=Y}}}}(wu)),wu}var sd;function cg(){return sd||(sd=1,vu.exports=ag()),vu.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var ld;function fg(){if(ld)return lt;ld=1;var r=Ku(),i=cg();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),x=Object.prototype.hasOwnProperty,v=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,S={},A={};function T(e){return x.call(A,e)?!0:x.call(S,e)?!1:v.test(e)?A[e]=!0:(S[e]=!0,!1)}function I(e,t,n,o){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return o?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function R(e,t,n,o){if(t===null||typeof t>"u"||I(e,t,n,o))return!0;if(o)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function C(e,t,n,o,u,a,d){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=o,this.attributeNamespace=u,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=d}var N={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){N[e]=new C(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];N[t]=new C(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){N[e]=new C(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){N[e]=new C(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){N[e]=new C(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){N[e]=new C(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){N[e]=new C(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){N[e]=new C(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){N[e]=new C(e,5,!1,e.toLowerCase(),null,!1,!1)});var H=/[\-:]([a-z])/g;function U(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(H,U);N[t]=new C(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(H,U);N[t]=new C(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(H,U);N[t]=new C(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){N[e]=new C(e,1,!1,e.toLowerCase(),null,!1,!1)}),N.xlinkHref=new C("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){N[e]=new C(e,1,!1,e.toLowerCase(),null,!0,!0)});function V(e,t,n,o){var u=N.hasOwnProperty(t)?N[t]:null;(u!==null?u.type!==0:o||!(2m||u[d]!==a[m]){var y=` +`+u[d].replace(" at new "," at ");return e.displayName&&y.includes("")&&(y=y.replace("",e.displayName)),y}while(1<=d&&0<=m);break}}}finally{se=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?D(e):""}function de(e){switch(e.tag){case 5:return D(e.type);case 16:return D("Lazy");case 13:return D("Suspense");case 19:return D("SuspenseList");case 0:case 2:case 15:return e=ue(e.type,!1),e;case 11:return e=ue(e.type.render,!1),e;case 1:return e=ue(e.type,!0),e;default:return""}}function ce(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case b:return"Fragment";case L:return"Portal";case ye:return"Profiler";case re:return"StrictMode";case Ze:return"Suspense";case ct:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case at:return(e.displayName||"Context")+".Consumer";case Ne:return(e._context.displayName||"Context")+".Provider";case wt:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case xt:return t=e.displayName||null,t!==null?t:ce(e.type)||"Memo";case We:t=e._payload,e=e._init;try{return ce(e(t))}catch{}}return null}function ve(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ce(t);case 8:return t===re?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function pe(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function me(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function He(e){var t=me(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),o=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var u=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return u.call(this)},set:function(d){o=""+d,a.call(this,d)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return o},setValue:function(d){o=""+d},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Yt(e){e._valueTracker||(e._valueTracker=He(e))}function Pt(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),o="";return e&&(o=me(e)?e.checked?"true":"false":e.value),e=o,e!==n?(t.setValue(e),!0):!1}function No(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Es(e,t){var n=t.checked;return Y({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function sa(e,t){var n=t.defaultValue==null?"":t.defaultValue,o=t.checked!=null?t.checked:t.defaultChecked;n=pe(t.value!=null?t.value:n),e._wrapperState={initialChecked:o,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function la(e,t){t=t.checked,t!=null&&V(e,"checked",t,!1)}function Cs(e,t){la(e,t);var n=pe(t.value),o=t.type;if(n!=null)o==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(o==="submit"||o==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?ks(e,t.type,n):t.hasOwnProperty("defaultValue")&&ks(e,t.type,pe(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ua(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var o=t.type;if(!(o!=="submit"&&o!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function ks(e,t,n){(t!=="number"||No(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Ir=Array.isArray;function qn(e,t,n,o){if(e=e.options,t){t={};for(var u=0;u"+t.valueOf().toString()+"",t=Oo.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Nr(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Or={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},uh=["Webkit","ms","Moz","O"];Object.keys(Or).forEach(function(e){uh.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Or[t]=Or[e]})});function ha(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Or.hasOwnProperty(e)&&Or[e]?(""+t).trim():t+"px"}function ma(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var o=n.indexOf("--")===0,u=ha(n,t[n],o);n==="float"&&(n="cssFloat"),o?e.setProperty(n,u):e[n]=u}}var ah=Y({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Rs(e,t){if(t){if(ah[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(s(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(s(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(s(61))}if(t.style!=null&&typeof t.style!="object")throw Error(s(62))}}function Ps(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var _s=null;function Ts(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Is=null,Qn=null,Gn=null;function ga(e){if(e=to(e)){if(typeof Is!="function")throw Error(s(280));var t=e.stateNode;t&&(t=ni(t),Is(e.stateNode,e.type,t))}}function ya(e){Qn?Gn?Gn.push(e):Gn=[e]:Qn=e}function va(){if(Qn){var e=Qn,t=Gn;if(Gn=Qn=null,ga(e),t)for(e=0;e>>=0,e===0?32:31-(xh(e)/Sh|0)|0}var Uo=64,Fo=4194304;function zr(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Bo(e,t){var n=e.pendingLanes;if(n===0)return 0;var o=0,u=e.suspendedLanes,a=e.pingedLanes,d=n&268435455;if(d!==0){var m=d&~u;m!==0?o=zr(m):(a&=d,a!==0&&(o=zr(a)))}else d=n&~u,d!==0?o=zr(d):a!==0&&(o=zr(a));if(o===0)return 0;if(t!==0&&t!==o&&!(t&u)&&(u=o&-o,a=t&-t,u>=a||u===16&&(a&4194240)!==0))return t;if(o&4&&(o|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=o;0n;n++)t.push(e);return t}function Ur(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-_t(t),e[t]=n}function jh(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var o=e.eventTimes;for(e=e.expirationTimes;0=Yr),Ya=" ",qa=!1;function Qa(e,t){switch(e){case"keyup":return Zh.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ga(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Jn=!1;function tm(e,t){switch(e){case"compositionend":return Ga(t);case"keypress":return t.which!==32?null:(qa=!0,Ya);case"textInput":return e=t.data,e===Ya&&qa?null:e;default:return null}}function nm(e,t){if(Jn)return e==="compositionend"||!Gs&&Qa(e,t)?(e=Ba(),Wo=bs=an=null,Jn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=o}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=nc(n)}}function oc(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?oc(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function ic(){for(var e=window,t=No();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=No(e.document)}return t}function Js(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function fm(e){var t=ic(),n=e.focusedElem,o=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&oc(n.ownerDocument.documentElement,n)){if(o!==null&&Js(n)){if(t=o.start,e=o.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var u=n.textContent.length,a=Math.min(o.start,u);o=o.end===void 0?a:Math.min(o.end,u),!e.extend&&a>o&&(u=o,o=a,a=u),u=rc(n,a);var d=rc(n,o);u&&d&&(e.rangeCount!==1||e.anchorNode!==u.node||e.anchorOffset!==u.offset||e.focusNode!==d.node||e.focusOffset!==d.offset)&&(t=t.createRange(),t.setStart(u.node,u.offset),e.removeAllRanges(),a>o?(e.addRange(t),e.extend(d.node,d.offset)):(t.setEnd(d.node,d.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Zn=null,Zs=null,Kr=null,el=!1;function sc(e,t,n){var o=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;el||Zn==null||Zn!==No(o)||(o=Zn,"selectionStart"in o&&Js(o)?o={start:o.selectionStart,end:o.selectionEnd}:(o=(o.ownerDocument&&o.ownerDocument.defaultView||window).getSelection(),o={anchorNode:o.anchorNode,anchorOffset:o.anchorOffset,focusNode:o.focusNode,focusOffset:o.focusOffset}),Kr&&Gr(Kr,o)||(Kr=o,o=Zo(Zs,"onSelect"),0or||(e.current=dl[or],dl[or]=null,or--)}function Ee(e,t){or++,dl[or]=e.current,e.current=t}var pn={},Ye=dn(pn),nt=dn(!1),Rn=pn;function ir(e,t){var n=e.type.contextTypes;if(!n)return pn;var o=e.stateNode;if(o&&o.__reactInternalMemoizedUnmaskedChildContext===t)return o.__reactInternalMemoizedMaskedChildContext;var u={},a;for(a in n)u[a]=t[a];return o&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=u),u}function rt(e){return e=e.childContextTypes,e!=null}function ri(){ke(nt),ke(Ye)}function Sc(e,t,n){if(Ye.current!==pn)throw Error(s(168));Ee(Ye,t),Ee(nt,n)}function Ec(e,t,n){var o=e.stateNode;if(t=t.childContextTypes,typeof o.getChildContext!="function")return n;o=o.getChildContext();for(var u in o)if(!(u in t))throw Error(s(108,ve(e)||"Unknown",u));return Y({},n,o)}function oi(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||pn,Rn=Ye.current,Ee(Ye,e),Ee(nt,nt.current),!0}function Cc(e,t,n){var o=e.stateNode;if(!o)throw Error(s(169));n?(e=Ec(e,t,Rn),o.__reactInternalMemoizedMergedChildContext=e,ke(nt),ke(Ye),Ee(Ye,e)):ke(nt),Ee(nt,n)}var Qt=null,ii=!1,pl=!1;function kc(e){Qt===null?Qt=[e]:Qt.push(e)}function Cm(e){ii=!0,kc(e)}function hn(){if(!pl&&Qt!==null){pl=!0;var e=0,t=xe;try{var n=Qt;for(xe=1;e>=d,u-=d,Gt=1<<32-_t(t)+u|n<oe?(Be=ne,ne=null):Be=ne.sibling;var ge=M(k,ne,j[oe],B);if(ge===null){ne===null&&(ne=Be);break}e&&ne&&ge.alternate===null&&t(k,ne),w=a(ge,w,oe),te===null?J=ge:te.sibling=ge,te=ge,ne=Be}if(oe===j.length)return n(k,ne),Ae&&_n(k,oe),J;if(ne===null){for(;oeoe?(Be=ne,ne=null):Be=ne.sibling;var Cn=M(k,ne,ge.value,B);if(Cn===null){ne===null&&(ne=Be);break}e&&ne&&Cn.alternate===null&&t(k,ne),w=a(Cn,w,oe),te===null?J=Cn:te.sibling=Cn,te=Cn,ne=Be}if(ge.done)return n(k,ne),Ae&&_n(k,oe),J;if(ne===null){for(;!ge.done;oe++,ge=j.next())ge=F(k,ge.value,B),ge!==null&&(w=a(ge,w,oe),te===null?J=ge:te.sibling=ge,te=ge);return Ae&&_n(k,oe),J}for(ne=o(k,ne);!ge.done;oe++,ge=j.next())ge=q(ne,k,oe,ge.value,B),ge!==null&&(e&&ge.alternate!==null&&ne.delete(ge.key===null?oe:ge.key),w=a(ge,w,oe),te===null?J=ge:te.sibling=ge,te=ge);return e&&ne.forEach(function(ng){return t(k,ng)}),Ae&&_n(k,oe),J}function Ie(k,w,j,B){if(typeof j=="object"&&j!==null&&j.type===b&&j.key===null&&(j=j.props.children),typeof j=="object"&&j!==null){switch(j.$$typeof){case $:e:{for(var J=j.key,te=w;te!==null;){if(te.key===J){if(J=j.type,J===b){if(te.tag===7){n(k,te.sibling),w=u(te,j.props.children),w.return=k,k=w;break e}}else if(te.elementType===J||typeof J=="object"&&J!==null&&J.$$typeof===We&&Tc(J)===te.type){n(k,te.sibling),w=u(te,j.props),w.ref=no(k,te,j),w.return=k,k=w;break e}n(k,te);break}else t(k,te);te=te.sibling}j.type===b?(w=zn(j.props.children,k.mode,B,j.key),w.return=k,k=w):(B=Oi(j.type,j.key,j.props,null,k.mode,B),B.ref=no(k,w,j),B.return=k,k=B)}return d(k);case L:e:{for(te=j.key;w!==null;){if(w.key===te)if(w.tag===4&&w.stateNode.containerInfo===j.containerInfo&&w.stateNode.implementation===j.implementation){n(k,w.sibling),w=u(w,j.children||[]),w.return=k,k=w;break e}else{n(k,w);break}else t(k,w);w=w.sibling}w=cu(j,k.mode,B),w.return=k,k=w}return d(k);case We:return te=j._init,Ie(k,w,te(j._payload),B)}if(Ir(j))return K(k,w,j,B);if(Z(j))return X(k,w,j,B);ai(k,j)}return typeof j=="string"&&j!==""||typeof j=="number"?(j=""+j,w!==null&&w.tag===6?(n(k,w.sibling),w=u(w,j),w.return=k,k=w):(n(k,w),w=au(j,k.mode,B),w.return=k,k=w),d(k)):n(k,w)}return Ie}var ar=Ic(!0),Nc=Ic(!1),ci=dn(null),fi=null,cr=null,wl=null;function xl(){wl=cr=fi=null}function Sl(e){var t=ci.current;ke(ci),e._currentValue=t}function El(e,t,n){for(;e!==null;){var o=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,o!==null&&(o.childLanes|=t)):o!==null&&(o.childLanes&t)!==t&&(o.childLanes|=t),e===n)break;e=e.return}}function fr(e,t){fi=e,wl=cr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(ot=!0),e.firstContext=null)}function Ct(e){var t=e._currentValue;if(wl!==e)if(e={context:e,memoizedValue:t,next:null},cr===null){if(fi===null)throw Error(s(308));cr=e,fi.dependencies={lanes:0,firstContext:e}}else cr=cr.next=e;return t}var Tn=null;function Cl(e){Tn===null?Tn=[e]:Tn.push(e)}function Oc(e,t,n,o){var u=t.interleaved;return u===null?(n.next=n,Cl(t)):(n.next=u.next,u.next=n),t.interleaved=n,Xt(e,o)}function Xt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var mn=!1;function kl(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Lc(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Jt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function gn(e,t,n){var o=e.updateQueue;if(o===null)return null;if(o=o.shared,he&2){var u=o.pending;return u===null?t.next=t:(t.next=u.next,u.next=t),o.pending=t,Xt(e,n)}return u=o.interleaved,u===null?(t.next=t,Cl(o)):(t.next=u.next,u.next=t),o.interleaved=t,Xt(e,n)}function di(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var o=t.lanes;o&=e.pendingLanes,n|=o,t.lanes=n,Us(e,n)}}function Dc(e,t){var n=e.updateQueue,o=e.alternate;if(o!==null&&(o=o.updateQueue,n===o)){var u=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var d={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?u=a=d:a=a.next=d,n=n.next}while(n!==null);a===null?u=a=t:a=a.next=t}else u=a=t;n={baseState:o.baseState,firstBaseUpdate:u,lastBaseUpdate:a,shared:o.shared,effects:o.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function pi(e,t,n,o){var u=e.updateQueue;mn=!1;var a=u.firstBaseUpdate,d=u.lastBaseUpdate,m=u.shared.pending;if(m!==null){u.shared.pending=null;var y=m,P=y.next;y.next=null,d===null?a=P:d.next=P,d=y;var z=e.alternate;z!==null&&(z=z.updateQueue,m=z.lastBaseUpdate,m!==d&&(m===null?z.firstBaseUpdate=P:m.next=P,z.lastBaseUpdate=y))}if(a!==null){var F=u.baseState;d=0,z=P=y=null,m=a;do{var M=m.lane,q=m.eventTime;if((o&M)===M){z!==null&&(z=z.next={eventTime:q,lane:0,tag:m.tag,payload:m.payload,callback:m.callback,next:null});e:{var K=e,X=m;switch(M=t,q=n,X.tag){case 1:if(K=X.payload,typeof K=="function"){F=K.call(q,F,M);break e}F=K;break e;case 3:K.flags=K.flags&-65537|128;case 0:if(K=X.payload,M=typeof K=="function"?K.call(q,F,M):K,M==null)break e;F=Y({},F,M);break e;case 2:mn=!0}}m.callback!==null&&m.lane!==0&&(e.flags|=64,M=u.effects,M===null?u.effects=[m]:M.push(m))}else q={eventTime:q,lane:M,tag:m.tag,payload:m.payload,callback:m.callback,next:null},z===null?(P=z=q,y=F):z=z.next=q,d|=M;if(m=m.next,m===null){if(m=u.shared.pending,m===null)break;M=m,m=M.next,M.next=null,u.lastBaseUpdate=M,u.shared.pending=null}}while(!0);if(z===null&&(y=F),u.baseState=y,u.firstBaseUpdate=P,u.lastBaseUpdate=z,t=u.shared.interleaved,t!==null){u=t;do d|=u.lane,u=u.next;while(u!==t)}else a===null&&(u.shared.lanes=0);On|=d,e.lanes=d,e.memoizedState=F}}function Mc(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var o=_l.transition;_l.transition={};try{e(!1),t()}finally{xe=n,_l.transition=o}}function tf(){return kt().memoizedState}function Rm(e,t,n){var o=xn(e);if(n={lane:o,action:n,hasEagerState:!1,eagerState:null,next:null},nf(e))rf(t,n);else if(n=Oc(e,t,n,o),n!==null){var u=tt();Dt(n,e,o,u),of(n,t,o)}}function Pm(e,t,n){var o=xn(e),u={lane:o,action:n,hasEagerState:!1,eagerState:null,next:null};if(nf(e))rf(t,u);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var d=t.lastRenderedState,m=a(d,n);if(u.hasEagerState=!0,u.eagerState=m,Tt(m,d)){var y=t.interleaved;y===null?(u.next=u,Cl(t)):(u.next=y.next,y.next=u),t.interleaved=u;return}}catch{}finally{}n=Oc(e,t,u,o),n!==null&&(u=tt(),Dt(n,e,o,u),of(n,t,o))}}function nf(e){var t=e.alternate;return e===Pe||t!==null&&t===Pe}function rf(e,t){so=gi=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function of(e,t,n){if(n&4194240){var o=t.lanes;o&=e.pendingLanes,n|=o,t.lanes=n,Us(e,n)}}var wi={readContext:Ct,useCallback:qe,useContext:qe,useEffect:qe,useImperativeHandle:qe,useInsertionEffect:qe,useLayoutEffect:qe,useMemo:qe,useReducer:qe,useRef:qe,useState:qe,useDebugValue:qe,useDeferredValue:qe,useTransition:qe,useMutableSource:qe,useSyncExternalStore:qe,useId:qe,unstable_isNewReconciler:!1},_m={readContext:Ct,useCallback:function(e,t){return Ht().memoizedState=[e,t===void 0?null:t],e},useContext:Ct,useEffect:qc,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,yi(4194308,4,Kc.bind(null,t,e),n)},useLayoutEffect:function(e,t){return yi(4194308,4,e,t)},useInsertionEffect:function(e,t){return yi(4,2,e,t)},useMemo:function(e,t){var n=Ht();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var o=Ht();return t=n!==void 0?n(t):t,o.memoizedState=o.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},o.queue=e,e=e.dispatch=Rm.bind(null,Pe,e),[o.memoizedState,e]},useRef:function(e){var t=Ht();return e={current:e},t.memoizedState=e},useState:Wc,useDebugValue:Ml,useDeferredValue:function(e){return Ht().memoizedState=e},useTransition:function(){var e=Wc(!1),t=e[0];return e=Am.bind(null,e[1]),Ht().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var o=Pe,u=Ht();if(Ae){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),Fe===null)throw Error(s(349));Nn&30||Bc(o,t,n)}u.memoizedState=n;var a={value:n,getSnapshot:t};return u.queue=a,qc(Hc.bind(null,o,a,e),[e]),o.flags|=2048,ao(9,$c.bind(null,o,a,n,t),void 0,null),n},useId:function(){var e=Ht(),t=Fe.identifierPrefix;if(Ae){var n=Kt,o=Gt;n=(o&~(1<<32-_t(o)-1)).toString(32)+n,t=":"+t+"R"+n,n=lo++,0<\/script>",e=e.removeChild(e.firstChild)):typeof o.is=="string"?e=d.createElement(n,{is:o.is}):(e=d.createElement(n),n==="select"&&(d=e,o.multiple?d.multiple=!0:o.size&&(d.size=o.size))):e=d.createElementNS(e,n),e[Bt]=t,e[eo]=o,jf(e,t,!1,!1),t.stateNode=e;e:{switch(d=Ps(n,o),n){case"dialog":Ce("cancel",e),Ce("close",e),u=o;break;case"iframe":case"object":case"embed":Ce("load",e),u=o;break;case"video":case"audio":for(u=0;ugr&&(t.flags|=128,o=!0,co(a,!1),t.lanes=4194304)}else{if(!o)if(e=hi(d),e!==null){if(t.flags|=128,o=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),co(a,!0),a.tail===null&&a.tailMode==="hidden"&&!d.alternate&&!Ae)return Qe(t),null}else 2*Te()-a.renderingStartTime>gr&&n!==1073741824&&(t.flags|=128,o=!0,co(a,!1),t.lanes=4194304);a.isBackwards?(d.sibling=t.child,t.child=d):(n=a.last,n!==null?n.sibling=d:t.child=d,a.last=d)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=Te(),t.sibling=null,n=Re.current,Ee(Re,o?n&1|2:n&1),t):(Qe(t),null);case 22:case 23:return su(),o=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==o&&(t.flags|=8192),o&&t.mode&1?ht&1073741824&&(Qe(t),t.subtreeFlags&6&&(t.flags|=8192)):Qe(t),null;case 24:return null;case 25:return null}throw Error(s(156,t.tag))}function zm(e,t){switch(ml(t),t.tag){case 1:return rt(t.type)&&ri(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return dr(),ke(nt),ke(Ye),Pl(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Al(t),null;case 13:if(ke(Re),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));ur()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return ke(Re),null;case 4:return dr(),null;case 10:return Sl(t.type._context),null;case 22:case 23:return su(),null;case 24:return null;default:return null}}var Ci=!1,Ge=!1,Um=typeof WeakSet=="function"?WeakSet:Set,G=null;function hr(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(o){_e(e,t,o)}else n.current=null}function Ql(e,t,n){try{n()}catch(o){_e(e,t,o)}}var Pf=!1;function Fm(e,t){if(sl=bo,e=ic(),Js(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var o=n.getSelection&&n.getSelection();if(o&&o.rangeCount!==0){n=o.anchorNode;var u=o.anchorOffset,a=o.focusNode;o=o.focusOffset;try{n.nodeType,a.nodeType}catch{n=null;break e}var d=0,m=-1,y=-1,P=0,z=0,F=e,M=null;t:for(;;){for(var q;F!==n||u!==0&&F.nodeType!==3||(m=d+u),F!==a||o!==0&&F.nodeType!==3||(y=d+o),F.nodeType===3&&(d+=F.nodeValue.length),(q=F.firstChild)!==null;)M=F,F=q;for(;;){if(F===e)break t;if(M===n&&++P===u&&(m=d),M===a&&++z===o&&(y=d),(q=F.nextSibling)!==null)break;F=M,M=F.parentNode}F=q}n=m===-1||y===-1?null:{start:m,end:y}}else n=null}n=n||{start:0,end:0}}else n=null;for(ll={focusedElem:e,selectionRange:n},bo=!1,G=t;G!==null;)if(t=G,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,G=e;else for(;G!==null;){t=G;try{var K=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(K!==null){var X=K.memoizedProps,Ie=K.memoizedState,k=t.stateNode,w=k.getSnapshotBeforeUpdate(t.elementType===t.type?X:Nt(t.type,X),Ie);k.__reactInternalSnapshotBeforeUpdate=w}break;case 3:var j=t.stateNode.containerInfo;j.nodeType===1?j.textContent="":j.nodeType===9&&j.documentElement&&j.removeChild(j.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(s(163))}}catch(B){_e(t,t.return,B)}if(e=t.sibling,e!==null){e.return=t.return,G=e;break}G=t.return}return K=Pf,Pf=!1,K}function fo(e,t,n){var o=t.updateQueue;if(o=o!==null?o.lastEffect:null,o!==null){var u=o=o.next;do{if((u.tag&e)===e){var a=u.destroy;u.destroy=void 0,a!==void 0&&Ql(t,n,a)}u=u.next}while(u!==o)}}function ki(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var o=n.create;n.destroy=o()}n=n.next}while(n!==t)}}function Gl(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function _f(e){var t=e.alternate;t!==null&&(e.alternate=null,_f(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Bt],delete t[eo],delete t[fl],delete t[Sm],delete t[Em])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Tf(e){return e.tag===5||e.tag===3||e.tag===4}function If(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Tf(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Kl(e,t,n){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=ti));else if(o!==4&&(e=e.child,e!==null))for(Kl(e,t,n),e=e.sibling;e!==null;)Kl(e,t,n),e=e.sibling}function Xl(e,t,n){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(o!==4&&(e=e.child,e!==null))for(Xl(e,t,n),e=e.sibling;e!==null;)Xl(e,t,n),e=e.sibling}var be=null,Ot=!1;function yn(e,t,n){for(n=n.child;n!==null;)Nf(e,t,n),n=n.sibling}function Nf(e,t,n){if(Ft&&typeof Ft.onCommitFiberUnmount=="function")try{Ft.onCommitFiberUnmount(zo,n)}catch{}switch(n.tag){case 5:Ge||hr(n,t);case 6:var o=be,u=Ot;be=null,yn(e,t,n),be=o,Ot=u,be!==null&&(Ot?(e=be,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):be.removeChild(n.stateNode));break;case 18:be!==null&&(Ot?(e=be,n=n.stateNode,e.nodeType===8?cl(e.parentNode,n):e.nodeType===1&&cl(e,n),br(e)):cl(be,n.stateNode));break;case 4:o=be,u=Ot,be=n.stateNode.containerInfo,Ot=!0,yn(e,t,n),be=o,Ot=u;break;case 0:case 11:case 14:case 15:if(!Ge&&(o=n.updateQueue,o!==null&&(o=o.lastEffect,o!==null))){u=o=o.next;do{var a=u,d=a.destroy;a=a.tag,d!==void 0&&(a&2||a&4)&&Ql(n,t,d),u=u.next}while(u!==o)}yn(e,t,n);break;case 1:if(!Ge&&(hr(n,t),o=n.stateNode,typeof o.componentWillUnmount=="function"))try{o.props=n.memoizedProps,o.state=n.memoizedState,o.componentWillUnmount()}catch(m){_e(n,t,m)}yn(e,t,n);break;case 21:yn(e,t,n);break;case 22:n.mode&1?(Ge=(o=Ge)||n.memoizedState!==null,yn(e,t,n),Ge=o):yn(e,t,n);break;default:yn(e,t,n)}}function Of(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Um),t.forEach(function(o){var u=Qm.bind(null,e,o);n.has(o)||(n.add(o),o.then(u,u))})}}function Lt(e,t){var n=t.deletions;if(n!==null)for(var o=0;ou&&(u=d),o&=~a}if(o=u,o=Te()-o,o=(120>o?120:480>o?480:1080>o?1080:1920>o?1920:3e3>o?3e3:4320>o?4320:1960*$m(o/1960))-o,10e?16:e,wn===null)var o=!1;else{if(e=wn,wn=null,_i=0,he&6)throw Error(s(331));var u=he;for(he|=4,G=e.current;G!==null;){var a=G,d=a.child;if(G.flags&16){var m=a.deletions;if(m!==null){for(var y=0;yTe()-eu?Dn(e,0):Zl|=n),st(e,t)}function Yf(e,t){t===0&&(e.mode&1?(t=Fo,Fo<<=1,!(Fo&130023424)&&(Fo=4194304)):t=1);var n=tt();e=Xt(e,t),e!==null&&(Ur(e,t,n),st(e,n))}function qm(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Yf(e,n)}function Qm(e,t){var n=0;switch(e.tag){case 13:var o=e.stateNode,u=e.memoizedState;u!==null&&(n=u.retryLane);break;case 19:o=e.stateNode;break;default:throw Error(s(314))}o!==null&&o.delete(t),Yf(e,n)}var qf;qf=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||nt.current)ot=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return ot=!1,Dm(e,t,n);ot=!!(e.flags&131072)}else ot=!1,Ae&&t.flags&1048576&&jc(t,li,t.index);switch(t.lanes=0,t.tag){case 2:var o=t.type;Ei(e,t),e=t.pendingProps;var u=ir(t,Ye.current);fr(t,n),u=Il(null,t,o,e,u,n);var a=Nl();return t.flags|=1,typeof u=="object"&&u!==null&&typeof u.render=="function"&&u.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,rt(o)?(a=!0,oi(t)):a=!1,t.memoizedState=u.state!==null&&u.state!==void 0?u.state:null,kl(t),u.updater=xi,t.stateNode=u,u._reactInternals=t,Ul(t,o,e,n),t=Hl(null,t,o,!0,a,n)):(t.tag=0,Ae&&a&&hl(t),et(null,t,u,n),t=t.child),t;case 16:o=t.elementType;e:{switch(Ei(e,t),e=t.pendingProps,u=o._init,o=u(o._payload),t.type=o,u=t.tag=Km(o),e=Nt(o,e),u){case 0:t=$l(null,t,o,e,n);break e;case 1:t=wf(null,t,o,e,n);break e;case 11:t=hf(null,t,o,e,n);break e;case 14:t=mf(null,t,o,Nt(o.type,e),n);break e}throw Error(s(306,o,""))}return t;case 0:return o=t.type,u=t.pendingProps,u=t.elementType===o?u:Nt(o,u),$l(e,t,o,u,n);case 1:return o=t.type,u=t.pendingProps,u=t.elementType===o?u:Nt(o,u),wf(e,t,o,u,n);case 3:e:{if(xf(t),e===null)throw Error(s(387));o=t.pendingProps,a=t.memoizedState,u=a.element,Lc(e,t),pi(t,o,null,n);var d=t.memoizedState;if(o=d.element,a.isDehydrated)if(a={element:o,isDehydrated:!1,cache:d.cache,pendingSuspenseBoundaries:d.pendingSuspenseBoundaries,transitions:d.transitions},t.updateQueue.baseState=a,t.memoizedState=a,t.flags&256){u=pr(Error(s(423)),t),t=Sf(e,t,o,n,u);break e}else if(o!==u){u=pr(Error(s(424)),t),t=Sf(e,t,o,n,u);break e}else for(pt=fn(t.stateNode.containerInfo.firstChild),dt=t,Ae=!0,It=null,n=Nc(t,null,o,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(ur(),o===u){t=Zt(e,t,n);break e}et(e,t,o,n)}t=t.child}return t;case 5:return zc(t),e===null&&yl(t),o=t.type,u=t.pendingProps,a=e!==null?e.memoizedProps:null,d=u.children,ul(o,u)?d=null:a!==null&&ul(o,a)&&(t.flags|=32),vf(e,t),et(e,t,d,n),t.child;case 6:return e===null&&yl(t),null;case 13:return Ef(e,t,n);case 4:return jl(t,t.stateNode.containerInfo),o=t.pendingProps,e===null?t.child=ar(t,null,o,n):et(e,t,o,n),t.child;case 11:return o=t.type,u=t.pendingProps,u=t.elementType===o?u:Nt(o,u),hf(e,t,o,u,n);case 7:return et(e,t,t.pendingProps,n),t.child;case 8:return et(e,t,t.pendingProps.children,n),t.child;case 12:return et(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(o=t.type._context,u=t.pendingProps,a=t.memoizedProps,d=u.value,Ee(ci,o._currentValue),o._currentValue=d,a!==null)if(Tt(a.value,d)){if(a.children===u.children&&!nt.current){t=Zt(e,t,n);break e}}else for(a=t.child,a!==null&&(a.return=t);a!==null;){var m=a.dependencies;if(m!==null){d=a.child;for(var y=m.firstContext;y!==null;){if(y.context===o){if(a.tag===1){y=Jt(-1,n&-n),y.tag=2;var P=a.updateQueue;if(P!==null){P=P.shared;var z=P.pending;z===null?y.next=y:(y.next=z.next,z.next=y),P.pending=y}}a.lanes|=n,y=a.alternate,y!==null&&(y.lanes|=n),El(a.return,n,t),m.lanes|=n;break}y=y.next}}else if(a.tag===10)d=a.type===t.type?null:a.child;else if(a.tag===18){if(d=a.return,d===null)throw Error(s(341));d.lanes|=n,m=d.alternate,m!==null&&(m.lanes|=n),El(d,n,t),d=a.sibling}else d=a.child;if(d!==null)d.return=a;else for(d=a;d!==null;){if(d===t){d=null;break}if(a=d.sibling,a!==null){a.return=d.return,d=a;break}d=d.return}a=d}et(e,t,u.children,n),t=t.child}return t;case 9:return u=t.type,o=t.pendingProps.children,fr(t,n),u=Ct(u),o=o(u),t.flags|=1,et(e,t,o,n),t.child;case 14:return o=t.type,u=Nt(o,t.pendingProps),u=Nt(o.type,u),mf(e,t,o,u,n);case 15:return gf(e,t,t.type,t.pendingProps,n);case 17:return o=t.type,u=t.pendingProps,u=t.elementType===o?u:Nt(o,u),Ei(e,t),t.tag=1,rt(o)?(e=!0,oi(t)):e=!1,fr(t,n),lf(t,o,u),Ul(t,o,u,n),Hl(null,t,o,!0,e,n);case 19:return kf(e,t,n);case 22:return yf(e,t,n)}throw Error(s(156,t.tag))};function Qf(e,t){return Aa(e,t)}function Gm(e,t,n,o){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=o,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function At(e,t,n,o){return new Gm(e,t,n,o)}function uu(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Km(e){if(typeof e=="function")return uu(e)?1:0;if(e!=null){if(e=e.$$typeof,e===wt)return 11;if(e===xt)return 14}return 2}function En(e,t){var n=e.alternate;return n===null?(n=At(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Oi(e,t,n,o,u,a){var d=2;if(o=e,typeof e=="function")uu(e)&&(d=1);else if(typeof e=="string")d=5;else e:switch(e){case b:return zn(n.children,u,a,t);case re:d=8,u|=8;break;case ye:return e=At(12,n,t,u|2),e.elementType=ye,e.lanes=a,e;case Ze:return e=At(13,n,t,u),e.elementType=Ze,e.lanes=a,e;case ct:return e=At(19,n,t,u),e.elementType=ct,e.lanes=a,e;case Se:return Li(n,u,a,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Ne:d=10;break e;case at:d=9;break e;case wt:d=11;break e;case xt:d=14;break e;case We:d=16,o=null;break e}throw Error(s(130,e==null?e:typeof e,""))}return t=At(d,n,t,u),t.elementType=e,t.type=o,t.lanes=a,t}function zn(e,t,n,o){return e=At(7,e,o,t),e.lanes=n,e}function Li(e,t,n,o){return e=At(22,e,o,t),e.elementType=Se,e.lanes=n,e.stateNode={isHidden:!1},e}function au(e,t,n){return e=At(6,e,null,t),e.lanes=n,e}function cu(e,t,n){return t=At(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Xm(e,t,n,o,u){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=zs(0),this.expirationTimes=zs(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=zs(0),this.identifierPrefix=o,this.onRecoverableError=u,this.mutableSourceEagerHydrationData=null}function fu(e,t,n,o,u,a,d,m,y){return e=new Xm(e,t,n,m,y),t===1?(t=1,a===!0&&(t|=8)):t=0,a=At(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:o,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},kl(a),e}function Jm(e,t,n){var o=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(i){console.error(i)}}return r(),yu.exports=fg(),yu.exports}var ad;function pg(){if(ad)return $i;ad=1;var r=dg();return $i.createRoot=r.createRoot,$i.hydrateRoot=r.hydrateRoot,$i}var hg=pg(),Xe=function(){return Xe=Object.assign||function(i){for(var s,l=1,c=arguments.length;l0?$e(Ar,--Rt):0,Cr--,Le===10&&(Cr=1,fs--),Le}function Mt(){return Le=Rt2||Lu(Le)>3?"":" "}function kg(r,i){for(;--i&&Mt()&&!(Le<48||Le>102||Le>57&&Le<65||Le>70&&Le<97););return ps(r,Gi()+(i<6&&Bn()==32&&Mt()==32))}function Du(r){for(;Mt();)switch(Le){case r:return Rt;case 34:case 39:r!==34&&r!==39&&Du(Le);break;case 40:r===41&&Du(r);break;case 92:Mt();break}return Rt}function jg(r,i){for(;Mt()&&r+Le!==57;)if(r+Le===84&&Bn()===47)break;return"/*"+ps(i,Rt-1)+"*"+Ju(r===47?r:Mt())}function Ag(r){for(;!Lu(Bn());)Mt();return ps(r,Rt)}function Rg(r){return Eg(Ki("",null,null,null,[""],r=Sg(r),0,[0],r))}function Ki(r,i,s,l,c,f,p,g,x){for(var v=0,S=0,A=p,T=0,I=0,R=0,C=1,N=1,H=1,U=0,V="",Q=c,$=f,L=l,b=V;N;)switch(R=U,U=Mt()){case 40:if(R!=108&&$e(b,A-1)==58){Qi(b+=ae(xu(U),"&","&\f"),"&\f",up(v?g[v-1]:0))!=-1&&(H=-1);break}case 34:case 39:case 91:b+=xu(U);break;case 9:case 10:case 13:case 32:b+=Cg(R);break;case 92:b+=kg(Gi()-1,7);continue;case 47:switch(Bn()){case 42:case 47:Eo(Pg(jg(Mt(),Gi()),i,s,x),x);break;default:b+="/"}break;case 123*C:g[v++]=Wt(b)*H;case 125*C:case 59:case 0:switch(U){case 0:case 125:N=0;case 59+S:H==-1&&(b=ae(b,/\f/g,"")),I>0&&Wt(b)-A&&Eo(I>32?dd(b+";",l,s,A-1,x):dd(ae(b," ","")+";",l,s,A-2,x),x);break;case 59:b+=";";default:if(Eo(L=fd(b,i,s,v,S,c,g,V,Q=[],$=[],A,f),f),U===123)if(S===0)Ki(b,i,L,L,Q,f,A,g,$);else switch(T===99&&$e(b,3)===110?100:T){case 100:case 108:case 109:case 115:Ki(r,L,L,l&&Eo(fd(r,L,L,0,0,c,g,V,c,Q=[],A,$),$),c,$,A,g,l?Q:$);break;default:Ki(b,L,L,L,[""],$,0,g,$)}}v=S=I=0,C=H=1,V=b="",A=p;break;case 58:A=1+Wt(b),I=R;default:if(C<1){if(U==123)--C;else if(U==125&&C++==0&&xg()==125)continue}switch(b+=Ju(U),U*C){case 38:H=S>0?1:(b+="\f",-1);break;case 44:g[v++]=(Wt(b)-1)*H,H=1;break;case 64:Bn()===45&&(b+=xu(Mt())),T=Bn(),S=A=Wt(V=b+=Ag(Gi())),U++;break;case 45:R===45&&Wt(b)==2&&(C=0)}}return f}function fd(r,i,s,l,c,f,p,g,x,v,S,A){for(var T=c-1,I=c===0?f:[""],R=cp(I),C=0,N=0,H=0;C0?I[U]+" "+V:ae(V,/&\f/g,I[U])))&&(x[H++]=Q);return ds(r,i,s,c===0?cs:g,x,v,S,A)}function Pg(r,i,s,l){return ds(r,i,s,sp,Ju(wg()),Er(r,2,-2),0,l)}function dd(r,i,s,l,c){return ds(r,i,s,Xu,Er(r,0,l),Er(r,l+1,-1),l,c)}function dp(r,i,s){switch(yg(r,i)){case 5103:return we+"print-"+r+r;case 5737:case 4201:case 3177:case 3433:case 1641:case 4457:case 2921:case 5572:case 6356:case 5844:case 3191:case 6645:case 3005:case 6391:case 5879:case 5623:case 6135:case 4599:case 4855:case 4215:case 6389:case 5109:case 5365:case 5621:case 3829:return we+r+r;case 4789:return Co+r+r;case 5349:case 4246:case 4810:case 6968:case 2756:return we+r+Co+r+je+r+r;case 5936:switch($e(r,i+11)){case 114:return we+r+je+ae(r,/[svh]\w+-[tblr]{2}/,"tb")+r;case 108:return we+r+je+ae(r,/[svh]\w+-[tblr]{2}/,"tb-rl")+r;case 45:return we+r+je+ae(r,/[svh]\w+-[tblr]{2}/,"lr")+r}case 6828:case 4268:case 2903:return we+r+je+r+r;case 6165:return we+r+je+"flex-"+r+r;case 5187:return we+r+ae(r,/(\w+).+(:[^]+)/,we+"box-$1$2"+je+"flex-$1$2")+r;case 5443:return we+r+je+"flex-item-"+ae(r,/flex-|-self/g,"")+(tn(r,/flex-|baseline/)?"":je+"grid-row-"+ae(r,/flex-|-self/g,""))+r;case 4675:return we+r+je+"flex-line-pack"+ae(r,/align-content|flex-|-self/g,"")+r;case 5548:return we+r+je+ae(r,"shrink","negative")+r;case 5292:return we+r+je+ae(r,"basis","preferred-size")+r;case 6060:return we+"box-"+ae(r,"-grow","")+we+r+je+ae(r,"grow","positive")+r;case 4554:return we+ae(r,/([^-])(transform)/g,"$1"+we+"$2")+r;case 6187:return ae(ae(ae(r,/(zoom-|grab)/,we+"$1"),/(image-set)/,we+"$1"),r,"")+r;case 5495:case 3959:return ae(r,/(image-set\([^]*)/,we+"$1$`$1");case 4968:return ae(ae(r,/(.+:)(flex-)?(.*)/,we+"box-pack:$3"+je+"flex-pack:$3"),/s.+-b[^;]+/,"justify")+we+r+r;case 4200:if(!tn(r,/flex-|baseline/))return je+"grid-column-align"+Er(r,i)+r;break;case 2592:case 3360:return je+ae(r,"template-","")+r;case 4384:case 3616:return s&&s.some(function(l,c){return i=c,tn(l.props,/grid-\w+-end/)})?~Qi(r+(s=s[i].value),"span",0)?r:je+ae(r,"-start","")+r+je+"grid-row-span:"+(~Qi(s,"span",0)?tn(s,/\d+/):+tn(s,/\d+/)-+tn(r,/\d+/))+";":je+ae(r,"-start","")+r;case 4896:case 4128:return s&&s.some(function(l){return tn(l.props,/grid-\w+-start/)})?r:je+ae(ae(r,"-end","-span"),"span ","")+r;case 4095:case 3583:case 4068:case 2532:return ae(r,/(.+)-inline(.+)/,we+"$1$2")+r;case 8116:case 7059:case 5753:case 5535:case 5445:case 5701:case 4933:case 4677:case 5533:case 5789:case 5021:case 4765:if(Wt(r)-1-i>6)switch($e(r,i+1)){case 109:if($e(r,i+4)!==45)break;case 102:return ae(r,/(.+:)(.+)-([^]+)/,"$1"+we+"$2-$3$1"+Co+($e(r,i+3)==108?"$3":"$2-$3"))+r;case 115:return~Qi(r,"stretch",0)?dp(ae(r,"stretch","fill-available"),i,s)+r:r}break;case 5152:case 5920:return ae(r,/(.+?):(\d+)(\s*\/\s*(span)?\s*(\d+))?(.*)/,function(l,c,f,p,g,x,v){return je+c+":"+f+v+(p?je+c+"-span:"+(g?x:+x-+f)+v:"")+r});case 4949:if($e(r,i+6)===121)return ae(r,":",":"+we)+r;break;case 6444:switch($e(r,$e(r,14)===45?18:11)){case 120:return ae(r,/(.+:)([^;\s!]+)(;|(\s+)?!.+)?/,"$1"+we+($e(r,14)===45?"inline-":"")+"box$3$1"+we+"$2$3$1"+je+"$2box$3")+r;case 100:return ae(r,":",":"+je)+r}break;case 5719:case 2647:case 2135:case 3927:case 2391:return ae(r,"scroll-","scroll-snap-")+r}return r}function rs(r,i){for(var s="",l=0;l-1&&!r.return)switch(r.type){case Xu:r.return=dp(r.value,r.length,s);return;case lp:return rs([kn(r,{value:ae(r.value,"@","@"+we)})],l);case cs:if(r.length)return vg(s=r.props,function(c){switch(tn(c,l=/(::plac\w+|:read-\w+)/)){case":read-only":case":read-write":vr(kn(r,{props:[ae(c,/:(read-\w+)/,":"+Co+"$1")]})),vr(kn(r,{props:[c]})),Ou(r,{props:cd(s,l)});break;case"::placeholder":vr(kn(r,{props:[ae(c,/:(plac\w+)/,":"+we+"input-$1")]})),vr(kn(r,{props:[ae(c,/:(plac\w+)/,":"+Co+"$1")]})),vr(kn(r,{props:[ae(c,/:(plac\w+)/,je+"input-$1")]})),vr(kn(r,{props:[c]})),Ou(r,{props:cd(s,l)});break}return""})}}var Og={animationIterationCount:1,aspectRatio:1,borderImageOutset:1,borderImageSlice:1,borderImageWidth:1,boxFlex:1,boxFlexGroup:1,boxOrdinalGroup:1,columnCount:1,columns:1,flex:1,flexGrow:1,flexPositive:1,flexShrink:1,flexNegative:1,flexOrder:1,gridRow:1,gridRowEnd:1,gridRowSpan:1,gridRowStart:1,gridColumn:1,gridColumnEnd:1,gridColumnSpan:1,gridColumnStart:1,msGridRow:1,msGridRowSpan:1,msGridColumn:1,msGridColumnSpan:1,fontWeight:1,lineHeight:1,opacity:1,order:1,orphans:1,tabSize:1,widows:1,zIndex:1,zoom:1,WebkitLineClamp:1,fillOpacity:1,floodOpacity:1,stopOpacity:1,strokeDasharray:1,strokeDashoffset:1,strokeMiterlimit:1,strokeOpacity:1,strokeWidth:1},mt={},kr=typeof process<"u"&&mt!==void 0&&(mt.REACT_APP_SC_ATTR||mt.SC_ATTR)||"data-styled",pp="active",hp="data-styled-version",hs="6.1.14",Zu=`/*!sc*/ +`,os=typeof window<"u"&&"HTMLElement"in window,Lg=!!(typeof SC_DISABLE_SPEEDY=="boolean"?SC_DISABLE_SPEEDY:typeof process<"u"&&mt!==void 0&&mt.REACT_APP_SC_DISABLE_SPEEDY!==void 0&&mt.REACT_APP_SC_DISABLE_SPEEDY!==""?mt.REACT_APP_SC_DISABLE_SPEEDY!=="false"&&mt.REACT_APP_SC_DISABLE_SPEEDY:typeof process<"u"&&mt!==void 0&&mt.SC_DISABLE_SPEEDY!==void 0&&mt.SC_DISABLE_SPEEDY!==""&&mt.SC_DISABLE_SPEEDY!=="false"&&mt.SC_DISABLE_SPEEDY),ms=Object.freeze([]),jr=Object.freeze({});function Dg(r,i,s){return s===void 0&&(s=jr),r.theme!==s.theme&&r.theme||i||s.theme}var mp=new Set(["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","big","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","keygen","label","legend","li","link","main","map","mark","menu","menuitem","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","section","select","small","source","span","strong","style","sub","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","track","u","ul","use","var","video","wbr","circle","clipPath","defs","ellipse","foreignObject","g","image","line","linearGradient","marker","mask","path","pattern","polygon","polyline","radialGradient","rect","stop","svg","text","tspan"]),Mg=/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~-]+/g,zg=/(^-|-$)/g;function pd(r){return r.replace(Mg,"-").replace(zg,"")}var Ug=/(a)(d)/gi,Hi=52,hd=function(r){return String.fromCharCode(r+(r>25?39:97))};function Mu(r){var i,s="";for(i=Math.abs(r);i>Hi;i=i/Hi|0)s=hd(i%Hi)+s;return(hd(i%Hi)+s).replace(Ug,"$1-$2")}var Su,gp=5381,wr=function(r,i){for(var s=i.length;s;)r=33*r^i.charCodeAt(--s);return r},yp=function(r){return wr(gp,r)};function Fg(r){return Mu(yp(r)>>>0)}function Bg(r){return r.displayName||r.name||"Component"}function Eu(r){return typeof r=="string"&&!0}var vp=typeof Symbol=="function"&&Symbol.for,wp=vp?Symbol.for("react.memo"):60115,$g=vp?Symbol.for("react.forward_ref"):60112,Hg={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},bg={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},xp={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},Vg=((Su={})[$g]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},Su[wp]=xp,Su);function md(r){return("type"in(i=r)&&i.type.$$typeof)===wp?xp:"$$typeof"in r?Vg[r.$$typeof]:Hg;var i}var Wg=Object.defineProperty,Yg=Object.getOwnPropertyNames,gd=Object.getOwnPropertySymbols,qg=Object.getOwnPropertyDescriptor,Qg=Object.getPrototypeOf,yd=Object.prototype;function Sp(r,i,s){if(typeof i!="string"){if(yd){var l=Qg(i);l&&l!==yd&&Sp(r,l,s)}var c=Yg(i);gd&&(c=c.concat(gd(i)));for(var f=md(r),p=md(i),g=0;g0?" Args: ".concat(i.join(", ")):""))}var Gg=function(){function r(i){this.groupSizes=new Uint32Array(512),this.length=512,this.tag=i}return r.prototype.indexOfGroup=function(i){for(var s=0,l=0;l=this.groupSizes.length){for(var l=this.groupSizes,c=l.length,f=c;i>=f;)if((f<<=1)<0)throw Vn(16,"".concat(i));this.groupSizes=new Uint32Array(f),this.groupSizes.set(l),this.length=f;for(var p=c;p=this.length||this.groupSizes[i]===0)return s;for(var l=this.groupSizes[i],c=this.indexOfGroup(i),f=c+l,p=c;p=0){var l=document.createTextNode(s);return this.element.insertBefore(l,this.nodes[i]||null),this.length++,!0}return!1},r.prototype.deleteRule=function(i){this.element.removeChild(this.nodes[i]),this.length--},r.prototype.getRule=function(i){return i0&&(N+="".concat(H,","))}),x+="".concat(R).concat(C,'{content:"').concat(N,'"}').concat(Zu)},S=0;S0?".".concat(i):T},S=x.slice();S.push(function(T){T.type===cs&&T.value.includes("&")&&(T.props[0]=T.props[0].replace(sy,s).replace(l,v))}),p.prefix&&S.push(Ng),S.push(_g);var A=function(T,I,R,C){I===void 0&&(I=""),R===void 0&&(R=""),C===void 0&&(C="&"),i=C,s=I,l=new RegExp("\\".concat(s,"\\b"),"g");var N=T.replace(ly,""),H=Rg(R||I?"".concat(R," ").concat(I," { ").concat(N," }"):N);p.namespace&&(H=kp(H,p.namespace));var U=[];return rs(H,Tg(S.concat(Ig(function(V){return U.push(V)})))),U};return A.hash=x.length?x.reduce(function(T,I){return I.name||Vn(15),wr(T,I.name)},gp).toString():"",A}var ay=new Cp,Uu=uy(),jp=gt.createContext({shouldForwardProp:void 0,styleSheet:ay,stylis:Uu});jp.Consumer;gt.createContext(void 0);function Sd(){return ie.useContext(jp)}var cy=function(){function r(i,s){var l=this;this.inject=function(c,f){f===void 0&&(f=Uu);var p=l.name+f.hash;c.hasNameForId(l.id,p)||c.insertRules(l.id,p,f(l.rules,p,"@keyframes"))},this.name=i,this.id="sc-keyframes-".concat(i),this.rules=s,ta(this,function(){throw Vn(12,String(l.name))})}return r.prototype.getName=function(i){return i===void 0&&(i=Uu),this.name+i.hash},r}(),fy=function(r){return r>="A"&&r<="Z"};function Ed(r){for(var i="",s=0;s>>0);if(!s.hasNameForId(this.componentId,p)){var g=l(f,".".concat(p),void 0,this.componentId);s.insertRules(this.componentId,p,g)}c=Un(c,p),this.staticRulesId=p}else{for(var x=wr(this.baseHash,l.hash),v="",S=0;S>>0);s.hasNameForId(this.componentId,I)||s.insertRules(this.componentId,I,l(v,".".concat(I),void 0,this.componentId)),c=Un(c,I)}}return c},r}(),ss=gt.createContext(void 0);ss.Consumer;function Cd(r){var i=gt.useContext(ss),s=ie.useMemo(function(){return function(l,c){if(!l)throw Vn(14);if(bn(l)){var f=l(c);return f}if(Array.isArray(l)||typeof l!="object")throw Vn(8);return c?Xe(Xe({},c),l):l}(r.theme,i)},[r.theme,i]);return r.children?gt.createElement(ss.Provider,{value:s},r.children):null}var Cu={};function my(r,i,s){var l=ea(r),c=r,f=!Eu(r),p=i.attrs,g=p===void 0?ms:p,x=i.componentId,v=x===void 0?function(Q,$){var L=typeof Q!="string"?"sc":pd(Q);Cu[L]=(Cu[L]||0)+1;var b="".concat(L,"-").concat(Fg(hs+L+Cu[L]));return $?"".concat($,"-").concat(b):b}(i.displayName,i.parentComponentId):x,S=i.displayName,A=S===void 0?function(Q){return Eu(Q)?"styled.".concat(Q):"Styled(".concat(Bg(Q),")")}(r):S,T=i.displayName&&i.componentId?"".concat(pd(i.displayName),"-").concat(i.componentId):i.componentId||v,I=l&&c.attrs?c.attrs.concat(g).filter(Boolean):g,R=i.shouldForwardProp;if(l&&c.shouldForwardProp){var C=c.shouldForwardProp;if(i.shouldForwardProp){var N=i.shouldForwardProp;R=function(Q,$){return C(Q,$)&&N(Q,$)}}else R=C}var H=new hy(s,T,l?c.componentStyle:void 0);function U(Q,$){return function(L,b,re){var ye=L.attrs,Ne=L.componentStyle,at=L.defaultProps,wt=L.foldedComponentIds,Ze=L.styledComponentId,ct=L.target,xt=gt.useContext(ss),We=Sd(),Se=L.shouldForwardProp||We.shouldForwardProp,W=Dg(b,xt,at)||jr,Z=function(de,ce,ve){for(var pe,me=Xe(Xe({},ce),{className:void 0,theme:ve}),He=0;Hei=>{const s=yy.call(i);return r[s]||(r[s]=s.slice(8,-1).toLowerCase())})(Object.create(null)),Ut=r=>(r=r.toLowerCase(),i=>gs(i)===r),ys=r=>i=>typeof i===r,{isArray:Rr}=Array,Po=ys("undefined");function vy(r){return r!==null&&!Po(r)&&r.constructor!==null&&!Po(r.constructor)&&yt(r.constructor.isBuffer)&&r.constructor.isBuffer(r)}const Tp=Ut("ArrayBuffer");function wy(r){let i;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?i=ArrayBuffer.isView(r):i=r&&r.buffer&&Tp(r.buffer),i}const xy=ys("string"),yt=ys("function"),Ip=ys("number"),vs=r=>r!==null&&typeof r=="object",Sy=r=>r===!0||r===!1,Zi=r=>{if(gs(r)!=="object")return!1;const i=na(r);return(i===null||i===Object.prototype||Object.getPrototypeOf(i)===null)&&!(Symbol.toStringTag in r)&&!(Symbol.iterator in r)},Ey=Ut("Date"),Cy=Ut("File"),ky=Ut("Blob"),jy=Ut("FileList"),Ay=r=>vs(r)&&yt(r.pipe),Ry=r=>{let i;return r&&(typeof FormData=="function"&&r instanceof FormData||yt(r.append)&&((i=gs(r))==="formdata"||i==="object"&&yt(r.toString)&&r.toString()==="[object FormData]"))},Py=Ut("URLSearchParams"),[_y,Ty,Iy,Ny]=["ReadableStream","Request","Response","Headers"].map(Ut),Oy=r=>r.trim?r.trim():r.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function _o(r,i,{allOwnKeys:s=!1}={}){if(r===null||typeof r>"u")return;let l,c;if(typeof r!="object"&&(r=[r]),Rr(r))for(l=0,c=r.length;l0;)if(c=s[l],i===c.toLowerCase())return c;return null}const Fn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,Op=r=>!Po(r)&&r!==Fn;function Bu(){const{caseless:r}=Op(this)&&this||{},i={},s=(l,c)=>{const f=r&&Np(i,c)||c;Zi(i[f])&&Zi(l)?i[f]=Bu(i[f],l):Zi(l)?i[f]=Bu({},l):Rr(l)?i[f]=l.slice():i[f]=l};for(let l=0,c=arguments.length;l(_o(i,(c,f)=>{s&&yt(c)?r[f]=_p(c,s):r[f]=c},{allOwnKeys:l}),r),Dy=r=>(r.charCodeAt(0)===65279&&(r=r.slice(1)),r),My=(r,i,s,l)=>{r.prototype=Object.create(i.prototype,l),r.prototype.constructor=r,Object.defineProperty(r,"super",{value:i.prototype}),s&&Object.assign(r.prototype,s)},zy=(r,i,s,l)=>{let c,f,p;const g={};if(i=i||{},r==null)return i;do{for(c=Object.getOwnPropertyNames(r),f=c.length;f-- >0;)p=c[f],(!l||l(p,r,i))&&!g[p]&&(i[p]=r[p],g[p]=!0);r=s!==!1&&na(r)}while(r&&(!s||s(r,i))&&r!==Object.prototype);return i},Uy=(r,i,s)=>{r=String(r),(s===void 0||s>r.length)&&(s=r.length),s-=i.length;const l=r.indexOf(i,s);return l!==-1&&l===s},Fy=r=>{if(!r)return null;if(Rr(r))return r;let i=r.length;if(!Ip(i))return null;const s=new Array(i);for(;i-- >0;)s[i]=r[i];return s},By=(r=>i=>r&&i instanceof r)(typeof Uint8Array<"u"&&na(Uint8Array)),$y=(r,i)=>{const l=(r&&r[Symbol.iterator]).call(r);let c;for(;(c=l.next())&&!c.done;){const f=c.value;i.call(r,f[0],f[1])}},Hy=(r,i)=>{let s;const l=[];for(;(s=r.exec(i))!==null;)l.push(s);return l},by=Ut("HTMLFormElement"),Vy=r=>r.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(s,l,c){return l.toUpperCase()+c}),Ad=(({hasOwnProperty:r})=>(i,s)=>r.call(i,s))(Object.prototype),Wy=Ut("RegExp"),Lp=(r,i)=>{const s=Object.getOwnPropertyDescriptors(r),l={};_o(s,(c,f)=>{let p;(p=i(c,f,r))!==!1&&(l[f]=p||c)}),Object.defineProperties(r,l)},Yy=r=>{Lp(r,(i,s)=>{if(yt(r)&&["arguments","caller","callee"].indexOf(s)!==-1)return!1;const l=r[s];if(yt(l)){if(i.enumerable=!1,"writable"in i){i.writable=!1;return}i.set||(i.set=()=>{throw Error("Can not rewrite read-only method '"+s+"'")})}})},qy=(r,i)=>{const s={},l=c=>{c.forEach(f=>{s[f]=!0})};return Rr(r)?l(r):l(String(r).split(i)),s},Qy=()=>{},Gy=(r,i)=>r!=null&&Number.isFinite(r=+r)?r:i,ku="abcdefghijklmnopqrstuvwxyz",Rd="0123456789",Dp={DIGIT:Rd,ALPHA:ku,ALPHA_DIGIT:ku+ku.toUpperCase()+Rd},Ky=(r=16,i=Dp.ALPHA_DIGIT)=>{let s="";const{length:l}=i;for(;r--;)s+=i[Math.random()*l|0];return s};function Xy(r){return!!(r&&yt(r.append)&&r[Symbol.toStringTag]==="FormData"&&r[Symbol.iterator])}const Jy=r=>{const i=new Array(10),s=(l,c)=>{if(vs(l)){if(i.indexOf(l)>=0)return;if(!("toJSON"in l)){i[c]=l;const f=Rr(l)?[]:{};return _o(l,(p,g)=>{const x=s(p,c+1);!Po(x)&&(f[g]=x)}),i[c]=void 0,f}}return l};return s(r,0)},Zy=Ut("AsyncFunction"),ev=r=>r&&(vs(r)||yt(r))&&yt(r.then)&&yt(r.catch),Mp=((r,i)=>r?setImmediate:i?((s,l)=>(Fn.addEventListener("message",({source:c,data:f})=>{c===Fn&&f===s&&l.length&&l.shift()()},!1),c=>{l.push(c),Fn.postMessage(s,"*")}))(`axios@${Math.random()}`,[]):s=>setTimeout(s))(typeof setImmediate=="function",yt(Fn.postMessage)),tv=typeof queueMicrotask<"u"?queueMicrotask.bind(Fn):typeof process<"u"&&process.nextTick||Mp,O={isArray:Rr,isArrayBuffer:Tp,isBuffer:vy,isFormData:Ry,isArrayBufferView:wy,isString:xy,isNumber:Ip,isBoolean:Sy,isObject:vs,isPlainObject:Zi,isReadableStream:_y,isRequest:Ty,isResponse:Iy,isHeaders:Ny,isUndefined:Po,isDate:Ey,isFile:Cy,isBlob:ky,isRegExp:Wy,isFunction:yt,isStream:Ay,isURLSearchParams:Py,isTypedArray:By,isFileList:jy,forEach:_o,merge:Bu,extend:Ly,trim:Oy,stripBOM:Dy,inherits:My,toFlatObject:zy,kindOf:gs,kindOfTest:Ut,endsWith:Uy,toArray:Fy,forEachEntry:$y,matchAll:Hy,isHTMLForm:by,hasOwnProperty:Ad,hasOwnProp:Ad,reduceDescriptors:Lp,freezeMethods:Yy,toObjectSet:qy,toCamelCase:Vy,noop:Qy,toFiniteNumber:Gy,findKey:Np,global:Fn,isContextDefined:Op,ALPHABET:Dp,generateString:Ky,isSpecCompliantForm:Xy,toJSONObject:Jy,isAsyncFn:Zy,isThenable:ev,setImmediate:Mp,asap:tv};function le(r,i,s,l,c){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack,this.message=r,this.name="AxiosError",i&&(this.code=i),s&&(this.config=s),l&&(this.request=l),c&&(this.response=c,this.status=c.status?c.status:null)}O.inherits(le,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:O.toJSONObject(this.config),code:this.code,status:this.status}}});const zp=le.prototype,Up={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach(r=>{Up[r]={value:r}});Object.defineProperties(le,Up);Object.defineProperty(zp,"isAxiosError",{value:!0});le.from=(r,i,s,l,c,f)=>{const p=Object.create(zp);return O.toFlatObject(r,p,function(x){return x!==Error.prototype},g=>g!=="isAxiosError"),le.call(p,r.message,i,s,l,c),p.cause=r,p.name=r.name,f&&Object.assign(p,f),p};const nv=null;function $u(r){return O.isPlainObject(r)||O.isArray(r)}function Fp(r){return O.endsWith(r,"[]")?r.slice(0,-2):r}function Pd(r,i,s){return r?r.concat(i).map(function(c,f){return c=Fp(c),!s&&f?"["+c+"]":c}).join(s?".":""):i}function rv(r){return O.isArray(r)&&!r.some($u)}const ov=O.toFlatObject(O,{},null,function(i){return/^is[A-Z]/.test(i)});function ws(r,i,s){if(!O.isObject(r))throw new TypeError("target must be an object");i=i||new FormData,s=O.toFlatObject(s,{metaTokens:!0,dots:!1,indexes:!1},!1,function(C,N){return!O.isUndefined(N[C])});const l=s.metaTokens,c=s.visitor||S,f=s.dots,p=s.indexes,x=(s.Blob||typeof Blob<"u"&&Blob)&&O.isSpecCompliantForm(i);if(!O.isFunction(c))throw new TypeError("visitor must be a function");function v(R){if(R===null)return"";if(O.isDate(R))return R.toISOString();if(!x&&O.isBlob(R))throw new le("Blob is not supported. Use a Buffer instead.");return O.isArrayBuffer(R)||O.isTypedArray(R)?x&&typeof Blob=="function"?new Blob([R]):Buffer.from(R):R}function S(R,C,N){let H=R;if(R&&!N&&typeof R=="object"){if(O.endsWith(C,"{}"))C=l?C:C.slice(0,-2),R=JSON.stringify(R);else if(O.isArray(R)&&rv(R)||(O.isFileList(R)||O.endsWith(C,"[]"))&&(H=O.toArray(R)))return C=Fp(C),H.forEach(function(V,Q){!(O.isUndefined(V)||V===null)&&i.append(p===!0?Pd([C],Q,f):p===null?C:C+"[]",v(V))}),!1}return $u(R)?!0:(i.append(Pd(N,C,f),v(R)),!1)}const A=[],T=Object.assign(ov,{defaultVisitor:S,convertValue:v,isVisitable:$u});function I(R,C){if(!O.isUndefined(R)){if(A.indexOf(R)!==-1)throw Error("Circular reference detected in "+C.join("."));A.push(R),O.forEach(R,function(H,U){(!(O.isUndefined(H)||H===null)&&c.call(i,H,O.isString(U)?U.trim():U,C,T))===!0&&I(H,C?C.concat(U):[U])}),A.pop()}}if(!O.isObject(r))throw new TypeError("data must be an object");return I(r),i}function _d(r){const i={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(r).replace(/[!'()~]|%20|%00/g,function(l){return i[l]})}function ra(r,i){this._pairs=[],r&&ws(r,this,i)}const Bp=ra.prototype;Bp.append=function(i,s){this._pairs.push([i,s])};Bp.toString=function(i){const s=i?function(l){return i.call(this,l,_d)}:_d;return this._pairs.map(function(c){return s(c[0])+"="+s(c[1])},"").join("&")};function iv(r){return encodeURIComponent(r).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}function $p(r,i,s){if(!i)return r;const l=s&&s.encode||iv;O.isFunction(s)&&(s={serialize:s});const c=s&&s.serialize;let f;if(c?f=c(i,s):f=O.isURLSearchParams(i)?i.toString():new ra(i,s).toString(l),f){const p=r.indexOf("#");p!==-1&&(r=r.slice(0,p)),r+=(r.indexOf("?")===-1?"?":"&")+f}return r}class Td{constructor(){this.handlers=[]}use(i,s,l){return this.handlers.push({fulfilled:i,rejected:s,synchronous:l?l.synchronous:!1,runWhen:l?l.runWhen:null}),this.handlers.length-1}eject(i){this.handlers[i]&&(this.handlers[i]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(i){O.forEach(this.handlers,function(l){l!==null&&i(l)})}}const Hp={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},sv=typeof URLSearchParams<"u"?URLSearchParams:ra,lv=typeof FormData<"u"?FormData:null,uv=typeof Blob<"u"?Blob:null,av={isBrowser:!0,classes:{URLSearchParams:sv,FormData:lv,Blob:uv},protocols:["http","https","file","blob","url","data"]},oa=typeof window<"u"&&typeof document<"u",Hu=typeof navigator=="object"&&navigator||void 0,cv=oa&&(!Hu||["ReactNative","NativeScript","NS"].indexOf(Hu.product)<0),fv=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",dv=oa&&window.location.href||"http://localhost",pv=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:oa,hasStandardBrowserEnv:cv,hasStandardBrowserWebWorkerEnv:fv,navigator:Hu,origin:dv},Symbol.toStringTag,{value:"Module"})),Ke={...pv,...av};function hv(r,i){return ws(r,new Ke.classes.URLSearchParams,Object.assign({visitor:function(s,l,c,f){return Ke.isNode&&O.isBuffer(s)?(this.append(l,s.toString("base64")),!1):f.defaultVisitor.apply(this,arguments)}},i))}function mv(r){return O.matchAll(/\w+|\[(\w*)]/g,r).map(i=>i[0]==="[]"?"":i[1]||i[0])}function gv(r){const i={},s=Object.keys(r);let l;const c=s.length;let f;for(l=0;l=s.length;return p=!p&&O.isArray(c)?c.length:p,x?(O.hasOwnProp(c,p)?c[p]=[c[p],l]:c[p]=l,!g):((!c[p]||!O.isObject(c[p]))&&(c[p]=[]),i(s,l,c[p],f)&&O.isArray(c[p])&&(c[p]=gv(c[p])),!g)}if(O.isFormData(r)&&O.isFunction(r.entries)){const s={};return O.forEachEntry(r,(l,c)=>{i(mv(l),c,s,0)}),s}return null}function yv(r,i,s){if(O.isString(r))try{return(i||JSON.parse)(r),O.trim(r)}catch(l){if(l.name!=="SyntaxError")throw l}return(0,JSON.stringify)(r)}const To={transitional:Hp,adapter:["xhr","http","fetch"],transformRequest:[function(i,s){const l=s.getContentType()||"",c=l.indexOf("application/json")>-1,f=O.isObject(i);if(f&&O.isHTMLForm(i)&&(i=new FormData(i)),O.isFormData(i))return c?JSON.stringify(bp(i)):i;if(O.isArrayBuffer(i)||O.isBuffer(i)||O.isStream(i)||O.isFile(i)||O.isBlob(i)||O.isReadableStream(i))return i;if(O.isArrayBufferView(i))return i.buffer;if(O.isURLSearchParams(i))return s.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),i.toString();let g;if(f){if(l.indexOf("application/x-www-form-urlencoded")>-1)return hv(i,this.formSerializer).toString();if((g=O.isFileList(i))||l.indexOf("multipart/form-data")>-1){const x=this.env&&this.env.FormData;return ws(g?{"files[]":i}:i,x&&new x,this.formSerializer)}}return f||c?(s.setContentType("application/json",!1),yv(i)):i}],transformResponse:[function(i){const s=this.transitional||To.transitional,l=s&&s.forcedJSONParsing,c=this.responseType==="json";if(O.isResponse(i)||O.isReadableStream(i))return i;if(i&&O.isString(i)&&(l&&!this.responseType||c)){const p=!(s&&s.silentJSONParsing)&&c;try{return JSON.parse(i)}catch(g){if(p)throw g.name==="SyntaxError"?le.from(g,le.ERR_BAD_RESPONSE,this,null,this.response):g}}return i}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Ke.classes.FormData,Blob:Ke.classes.Blob},validateStatus:function(i){return i>=200&&i<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};O.forEach(["delete","get","head","post","put","patch"],r=>{To.headers[r]={}});const vv=O.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),wv=r=>{const i={};let s,l,c;return r&&r.split(` +`).forEach(function(p){c=p.indexOf(":"),s=p.substring(0,c).trim().toLowerCase(),l=p.substring(c+1).trim(),!(!s||i[s]&&vv[s])&&(s==="set-cookie"?i[s]?i[s].push(l):i[s]=[l]:i[s]=i[s]?i[s]+", "+l:l)}),i},Id=Symbol("internals");function vo(r){return r&&String(r).trim().toLowerCase()}function es(r){return r===!1||r==null?r:O.isArray(r)?r.map(es):String(r)}function xv(r){const i=Object.create(null),s=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let l;for(;l=s.exec(r);)i[l[1]]=l[2];return i}const Sv=r=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(r.trim());function ju(r,i,s,l,c){if(O.isFunction(l))return l.call(this,i,s);if(c&&(i=s),!!O.isString(i)){if(O.isString(l))return i.indexOf(l)!==-1;if(O.isRegExp(l))return l.test(i)}}function Ev(r){return r.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(i,s,l)=>s.toUpperCase()+l)}function Cv(r,i){const s=O.toCamelCase(" "+i);["get","set","has"].forEach(l=>{Object.defineProperty(r,l+s,{value:function(c,f,p){return this[l].call(this,i,c,f,p)},configurable:!0})})}class ut{constructor(i){i&&this.set(i)}set(i,s,l){const c=this;function f(g,x,v){const S=vo(x);if(!S)throw new Error("header name must be a non-empty string");const A=O.findKey(c,S);(!A||c[A]===void 0||v===!0||v===void 0&&c[A]!==!1)&&(c[A||x]=es(g))}const p=(g,x)=>O.forEach(g,(v,S)=>f(v,S,x));if(O.isPlainObject(i)||i instanceof this.constructor)p(i,s);else if(O.isString(i)&&(i=i.trim())&&!Sv(i))p(wv(i),s);else if(O.isHeaders(i))for(const[g,x]of i.entries())f(x,g,l);else i!=null&&f(s,i,l);return this}get(i,s){if(i=vo(i),i){const l=O.findKey(this,i);if(l){const c=this[l];if(!s)return c;if(s===!0)return xv(c);if(O.isFunction(s))return s.call(this,c,l);if(O.isRegExp(s))return s.exec(c);throw new TypeError("parser must be boolean|regexp|function")}}}has(i,s){if(i=vo(i),i){const l=O.findKey(this,i);return!!(l&&this[l]!==void 0&&(!s||ju(this,this[l],l,s)))}return!1}delete(i,s){const l=this;let c=!1;function f(p){if(p=vo(p),p){const g=O.findKey(l,p);g&&(!s||ju(l,l[g],g,s))&&(delete l[g],c=!0)}}return O.isArray(i)?i.forEach(f):f(i),c}clear(i){const s=Object.keys(this);let l=s.length,c=!1;for(;l--;){const f=s[l];(!i||ju(this,this[f],f,i,!0))&&(delete this[f],c=!0)}return c}normalize(i){const s=this,l={};return O.forEach(this,(c,f)=>{const p=O.findKey(l,f);if(p){s[p]=es(c),delete s[f];return}const g=i?Ev(f):String(f).trim();g!==f&&delete s[f],s[g]=es(c),l[g]=!0}),this}concat(...i){return this.constructor.concat(this,...i)}toJSON(i){const s=Object.create(null);return O.forEach(this,(l,c)=>{l!=null&&l!==!1&&(s[c]=i&&O.isArray(l)?l.join(", "):l)}),s}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([i,s])=>i+": "+s).join(` +`)}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(i){return i instanceof this?i:new this(i)}static concat(i,...s){const l=new this(i);return s.forEach(c=>l.set(c)),l}static accessor(i){const l=(this[Id]=this[Id]={accessors:{}}).accessors,c=this.prototype;function f(p){const g=vo(p);l[g]||(Cv(c,p),l[g]=!0)}return O.isArray(i)?i.forEach(f):f(i),this}}ut.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);O.reduceDescriptors(ut.prototype,({value:r},i)=>{let s=i[0].toUpperCase()+i.slice(1);return{get:()=>r,set(l){this[s]=l}}});O.freezeMethods(ut);function Au(r,i){const s=this||To,l=i||s,c=ut.from(l.headers);let f=l.data;return O.forEach(r,function(g){f=g.call(s,f,c.normalize(),i?i.status:void 0)}),c.normalize(),f}function Vp(r){return!!(r&&r.__CANCEL__)}function Pr(r,i,s){le.call(this,r??"canceled",le.ERR_CANCELED,i,s),this.name="CanceledError"}O.inherits(Pr,le,{__CANCEL__:!0});function Wp(r,i,s){const l=s.config.validateStatus;!s.status||!l||l(s.status)?r(s):i(new le("Request failed with status code "+s.status,[le.ERR_BAD_REQUEST,le.ERR_BAD_RESPONSE][Math.floor(s.status/100)-4],s.config,s.request,s))}function kv(r){const i=/^([-+\w]{1,25})(:?\/\/|:)/.exec(r);return i&&i[1]||""}function jv(r,i){r=r||10;const s=new Array(r),l=new Array(r);let c=0,f=0,p;return i=i!==void 0?i:1e3,function(x){const v=Date.now(),S=l[f];p||(p=v),s[c]=x,l[c]=v;let A=f,T=0;for(;A!==c;)T+=s[A++],A=A%r;if(c=(c+1)%r,c===f&&(f=(f+1)%r),v-p{s=S,c=null,f&&(clearTimeout(f),f=null),r.apply(null,v)};return[(...v)=>{const S=Date.now(),A=S-s;A>=l?p(v,S):(c=v,f||(f=setTimeout(()=>{f=null,p(c)},l-A)))},()=>c&&p(c)]}const ls=(r,i,s=3)=>{let l=0;const c=jv(50,250);return Av(f=>{const p=f.loaded,g=f.lengthComputable?f.total:void 0,x=p-l,v=c(x),S=p<=g;l=p;const A={loaded:p,total:g,progress:g?p/g:void 0,bytes:x,rate:v||void 0,estimated:v&&g&&S?(g-p)/v:void 0,event:f,lengthComputable:g!=null,[i?"download":"upload"]:!0};r(A)},s)},Nd=(r,i)=>{const s=r!=null;return[l=>i[0]({lengthComputable:s,total:r,loaded:l}),i[1]]},Od=r=>(...i)=>O.asap(()=>r(...i)),Rv=Ke.hasStandardBrowserEnv?((r,i)=>s=>(s=new URL(s,Ke.origin),r.protocol===s.protocol&&r.host===s.host&&(i||r.port===s.port)))(new URL(Ke.origin),Ke.navigator&&/(msie|trident)/i.test(Ke.navigator.userAgent)):()=>!0,Pv=Ke.hasStandardBrowserEnv?{write(r,i,s,l,c,f){const p=[r+"="+encodeURIComponent(i)];O.isNumber(s)&&p.push("expires="+new Date(s).toGMTString()),O.isString(l)&&p.push("path="+l),O.isString(c)&&p.push("domain="+c),f===!0&&p.push("secure"),document.cookie=p.join("; ")},read(r){const i=document.cookie.match(new RegExp("(^|;\\s*)("+r+")=([^;]*)"));return i?decodeURIComponent(i[3]):null},remove(r){this.write(r,"",Date.now()-864e5)}}:{write(){},read(){return null},remove(){}};function _v(r){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(r)}function Tv(r,i){return i?r.replace(/\/?\/$/,"")+"/"+i.replace(/^\/+/,""):r}function Yp(r,i){return r&&!_v(i)?Tv(r,i):i}const Ld=r=>r instanceof ut?{...r}:r;function Wn(r,i){i=i||{};const s={};function l(v,S,A,T){return O.isPlainObject(v)&&O.isPlainObject(S)?O.merge.call({caseless:T},v,S):O.isPlainObject(S)?O.merge({},S):O.isArray(S)?S.slice():S}function c(v,S,A,T){if(O.isUndefined(S)){if(!O.isUndefined(v))return l(void 0,v,A,T)}else return l(v,S,A,T)}function f(v,S){if(!O.isUndefined(S))return l(void 0,S)}function p(v,S){if(O.isUndefined(S)){if(!O.isUndefined(v))return l(void 0,v)}else return l(void 0,S)}function g(v,S,A){if(A in i)return l(v,S);if(A in r)return l(void 0,v)}const x={url:f,method:f,data:f,baseURL:p,transformRequest:p,transformResponse:p,paramsSerializer:p,timeout:p,timeoutMessage:p,withCredentials:p,withXSRFToken:p,adapter:p,responseType:p,xsrfCookieName:p,xsrfHeaderName:p,onUploadProgress:p,onDownloadProgress:p,decompress:p,maxContentLength:p,maxBodyLength:p,beforeRedirect:p,transport:p,httpAgent:p,httpsAgent:p,cancelToken:p,socketPath:p,responseEncoding:p,validateStatus:g,headers:(v,S,A)=>c(Ld(v),Ld(S),A,!0)};return O.forEach(Object.keys(Object.assign({},r,i)),function(S){const A=x[S]||c,T=A(r[S],i[S],S);O.isUndefined(T)&&A!==g||(s[S]=T)}),s}const qp=r=>{const i=Wn({},r);let{data:s,withXSRFToken:l,xsrfHeaderName:c,xsrfCookieName:f,headers:p,auth:g}=i;i.headers=p=ut.from(p),i.url=$p(Yp(i.baseURL,i.url),r.params,r.paramsSerializer),g&&p.set("Authorization","Basic "+btoa((g.username||"")+":"+(g.password?unescape(encodeURIComponent(g.password)):"")));let x;if(O.isFormData(s)){if(Ke.hasStandardBrowserEnv||Ke.hasStandardBrowserWebWorkerEnv)p.setContentType(void 0);else if((x=p.getContentType())!==!1){const[v,...S]=x?x.split(";").map(A=>A.trim()).filter(Boolean):[];p.setContentType([v||"multipart/form-data",...S].join("; "))}}if(Ke.hasStandardBrowserEnv&&(l&&O.isFunction(l)&&(l=l(i)),l||l!==!1&&Rv(i.url))){const v=c&&f&&Pv.read(f);v&&p.set(c,v)}return i},Iv=typeof XMLHttpRequest<"u",Nv=Iv&&function(r){return new Promise(function(s,l){const c=qp(r);let f=c.data;const p=ut.from(c.headers).normalize();let{responseType:g,onUploadProgress:x,onDownloadProgress:v}=c,S,A,T,I,R;function C(){I&&I(),R&&R(),c.cancelToken&&c.cancelToken.unsubscribe(S),c.signal&&c.signal.removeEventListener("abort",S)}let N=new XMLHttpRequest;N.open(c.method.toUpperCase(),c.url,!0),N.timeout=c.timeout;function H(){if(!N)return;const V=ut.from("getAllResponseHeaders"in N&&N.getAllResponseHeaders()),$={data:!g||g==="text"||g==="json"?N.responseText:N.response,status:N.status,statusText:N.statusText,headers:V,config:r,request:N};Wp(function(b){s(b),C()},function(b){l(b),C()},$),N=null}"onloadend"in N?N.onloadend=H:N.onreadystatechange=function(){!N||N.readyState!==4||N.status===0&&!(N.responseURL&&N.responseURL.indexOf("file:")===0)||setTimeout(H)},N.onabort=function(){N&&(l(new le("Request aborted",le.ECONNABORTED,r,N)),N=null)},N.onerror=function(){l(new le("Network Error",le.ERR_NETWORK,r,N)),N=null},N.ontimeout=function(){let Q=c.timeout?"timeout of "+c.timeout+"ms exceeded":"timeout exceeded";const $=c.transitional||Hp;c.timeoutErrorMessage&&(Q=c.timeoutErrorMessage),l(new le(Q,$.clarifyTimeoutError?le.ETIMEDOUT:le.ECONNABORTED,r,N)),N=null},f===void 0&&p.setContentType(null),"setRequestHeader"in N&&O.forEach(p.toJSON(),function(Q,$){N.setRequestHeader($,Q)}),O.isUndefined(c.withCredentials)||(N.withCredentials=!!c.withCredentials),g&&g!=="json"&&(N.responseType=c.responseType),v&&([T,R]=ls(v,!0),N.addEventListener("progress",T)),x&&N.upload&&([A,I]=ls(x),N.upload.addEventListener("progress",A),N.upload.addEventListener("loadend",I)),(c.cancelToken||c.signal)&&(S=V=>{N&&(l(!V||V.type?new Pr(null,r,N):V),N.abort(),N=null)},c.cancelToken&&c.cancelToken.subscribe(S),c.signal&&(c.signal.aborted?S():c.signal.addEventListener("abort",S)));const U=kv(c.url);if(U&&Ke.protocols.indexOf(U)===-1){l(new le("Unsupported protocol "+U+":",le.ERR_BAD_REQUEST,r));return}N.send(f||null)})},Ov=(r,i)=>{const{length:s}=r=r?r.filter(Boolean):[];if(i||s){let l=new AbortController,c;const f=function(v){if(!c){c=!0,g();const S=v instanceof Error?v:this.reason;l.abort(S instanceof le?S:new Pr(S instanceof Error?S.message:S))}};let p=i&&setTimeout(()=>{p=null,f(new le(`timeout ${i} of ms exceeded`,le.ETIMEDOUT))},i);const g=()=>{r&&(p&&clearTimeout(p),p=null,r.forEach(v=>{v.unsubscribe?v.unsubscribe(f):v.removeEventListener("abort",f)}),r=null)};r.forEach(v=>v.addEventListener("abort",f));const{signal:x}=l;return x.unsubscribe=()=>O.asap(g),x}},Lv=function*(r,i){let s=r.byteLength;if(s{const c=Dv(r,i);let f=0,p,g=x=>{p||(p=!0,l&&l(x))};return new ReadableStream({async pull(x){try{const{done:v,value:S}=await c.next();if(v){g(),x.close();return}let A=S.byteLength;if(s){let T=f+=A;s(T)}x.enqueue(new Uint8Array(S))}catch(v){throw g(v),v}},cancel(x){return g(x),c.return()}},{highWaterMark:2})},xs=typeof fetch=="function"&&typeof Request=="function"&&typeof Response=="function",Qp=xs&&typeof ReadableStream=="function",zv=xs&&(typeof TextEncoder=="function"?(r=>i=>r.encode(i))(new TextEncoder):async r=>new Uint8Array(await new Response(r).arrayBuffer())),Gp=(r,...i)=>{try{return!!r(...i)}catch{return!1}},Uv=Qp&&Gp(()=>{let r=!1;const i=new Request(Ke.origin,{body:new ReadableStream,method:"POST",get duplex(){return r=!0,"half"}}).headers.has("Content-Type");return r&&!i}),Md=64*1024,bu=Qp&&Gp(()=>O.isReadableStream(new Response("").body)),us={stream:bu&&(r=>r.body)};xs&&(r=>{["text","arrayBuffer","blob","formData","stream"].forEach(i=>{!us[i]&&(us[i]=O.isFunction(r[i])?s=>s[i]():(s,l)=>{throw new le(`Response type '${i}' is not supported`,le.ERR_NOT_SUPPORT,l)})})})(new Response);const Fv=async r=>{if(r==null)return 0;if(O.isBlob(r))return r.size;if(O.isSpecCompliantForm(r))return(await new Request(Ke.origin,{method:"POST",body:r}).arrayBuffer()).byteLength;if(O.isArrayBufferView(r)||O.isArrayBuffer(r))return r.byteLength;if(O.isURLSearchParams(r)&&(r=r+""),O.isString(r))return(await zv(r)).byteLength},Bv=async(r,i)=>{const s=O.toFiniteNumber(r.getContentLength());return s??Fv(i)},$v=xs&&(async r=>{let{url:i,method:s,data:l,signal:c,cancelToken:f,timeout:p,onDownloadProgress:g,onUploadProgress:x,responseType:v,headers:S,withCredentials:A="same-origin",fetchOptions:T}=qp(r);v=v?(v+"").toLowerCase():"text";let I=Ov([c,f&&f.toAbortSignal()],p),R;const C=I&&I.unsubscribe&&(()=>{I.unsubscribe()});let N;try{if(x&&Uv&&s!=="get"&&s!=="head"&&(N=await Bv(S,l))!==0){let $=new Request(i,{method:"POST",body:l,duplex:"half"}),L;if(O.isFormData(l)&&(L=$.headers.get("content-type"))&&S.setContentType(L),$.body){const[b,re]=Nd(N,ls(Od(x)));l=Dd($.body,Md,b,re)}}O.isString(A)||(A=A?"include":"omit");const H="credentials"in Request.prototype;R=new Request(i,{...T,signal:I,method:s.toUpperCase(),headers:S.normalize().toJSON(),body:l,duplex:"half",credentials:H?A:void 0});let U=await fetch(R);const V=bu&&(v==="stream"||v==="response");if(bu&&(g||V&&C)){const $={};["status","statusText","headers"].forEach(ye=>{$[ye]=U[ye]});const L=O.toFiniteNumber(U.headers.get("content-length")),[b,re]=g&&Nd(L,ls(Od(g),!0))||[];U=new Response(Dd(U.body,Md,b,()=>{re&&re(),C&&C()}),$)}v=v||"text";let Q=await us[O.findKey(us,v)||"text"](U,r);return!V&&C&&C(),await new Promise(($,L)=>{Wp($,L,{data:Q,headers:ut.from(U.headers),status:U.status,statusText:U.statusText,config:r,request:R})})}catch(H){throw C&&C(),H&&H.name==="TypeError"&&/fetch/i.test(H.message)?Object.assign(new le("Network Error",le.ERR_NETWORK,r,R),{cause:H.cause||H}):le.from(H,H&&H.code,r,R)}}),Vu={http:nv,xhr:Nv,fetch:$v};O.forEach(Vu,(r,i)=>{if(r){try{Object.defineProperty(r,"name",{value:i})}catch{}Object.defineProperty(r,"adapterName",{value:i})}});const zd=r=>`- ${r}`,Hv=r=>O.isFunction(r)||r===null||r===!1,Kp={getAdapter:r=>{r=O.isArray(r)?r:[r];const{length:i}=r;let s,l;const c={};for(let f=0;f`adapter ${g} `+(x===!1?"is not supported by the environment":"is not available in the build"));let p=i?f.length>1?`since : +`+f.map(zd).join(` +`):" "+zd(f[0]):"as no adapter specified";throw new le("There is no suitable adapter to dispatch the request "+p,"ERR_NOT_SUPPORT")}return l},adapters:Vu};function Ru(r){if(r.cancelToken&&r.cancelToken.throwIfRequested(),r.signal&&r.signal.aborted)throw new Pr(null,r)}function Ud(r){return Ru(r),r.headers=ut.from(r.headers),r.data=Au.call(r,r.transformRequest),["post","put","patch"].indexOf(r.method)!==-1&&r.headers.setContentType("application/x-www-form-urlencoded",!1),Kp.getAdapter(r.adapter||To.adapter)(r).then(function(l){return Ru(r),l.data=Au.call(r,r.transformResponse,l),l.headers=ut.from(l.headers),l},function(l){return Vp(l)||(Ru(r),l&&l.response&&(l.response.data=Au.call(r,r.transformResponse,l.response),l.response.headers=ut.from(l.response.headers))),Promise.reject(l)})}const Xp="1.7.9",Ss={};["object","boolean","number","function","string","symbol"].forEach((r,i)=>{Ss[r]=function(l){return typeof l===r||"a"+(i<1?"n ":" ")+r}});const Fd={};Ss.transitional=function(i,s,l){function c(f,p){return"[Axios v"+Xp+"] Transitional option '"+f+"'"+p+(l?". "+l:"")}return(f,p,g)=>{if(i===!1)throw new le(c(p," has been removed"+(s?" in "+s:"")),le.ERR_DEPRECATED);return s&&!Fd[p]&&(Fd[p]=!0,console.warn(c(p," has been deprecated since v"+s+" and will be removed in the near future"))),i?i(f,p,g):!0}};Ss.spelling=function(i){return(s,l)=>(console.warn(`${l} is likely a misspelling of ${i}`),!0)};function bv(r,i,s){if(typeof r!="object")throw new le("options must be an object",le.ERR_BAD_OPTION_VALUE);const l=Object.keys(r);let c=l.length;for(;c-- >0;){const f=l[c],p=i[f];if(p){const g=r[f],x=g===void 0||p(g,f,r);if(x!==!0)throw new le("option "+f+" must be "+x,le.ERR_BAD_OPTION_VALUE);continue}if(s!==!0)throw new le("Unknown option "+f,le.ERR_BAD_OPTION)}}const ts={assertOptions:bv,validators:Ss},Vt=ts.validators;class Hn{constructor(i){this.defaults=i,this.interceptors={request:new Td,response:new Td}}async request(i,s){try{return await this._request(i,s)}catch(l){if(l instanceof Error){let c={};Error.captureStackTrace?Error.captureStackTrace(c):c=new Error;const f=c.stack?c.stack.replace(/^.+\n/,""):"";try{l.stack?f&&!String(l.stack).endsWith(f.replace(/^.+\n.+\n/,""))&&(l.stack+=` +`+f):l.stack=f}catch{}}throw l}}_request(i,s){typeof i=="string"?(s=s||{},s.url=i):s=i||{},s=Wn(this.defaults,s);const{transitional:l,paramsSerializer:c,headers:f}=s;l!==void 0&&ts.assertOptions(l,{silentJSONParsing:Vt.transitional(Vt.boolean),forcedJSONParsing:Vt.transitional(Vt.boolean),clarifyTimeoutError:Vt.transitional(Vt.boolean)},!1),c!=null&&(O.isFunction(c)?s.paramsSerializer={serialize:c}:ts.assertOptions(c,{encode:Vt.function,serialize:Vt.function},!0)),ts.assertOptions(s,{baseUrl:Vt.spelling("baseURL"),withXsrfToken:Vt.spelling("withXSRFToken")},!0),s.method=(s.method||this.defaults.method||"get").toLowerCase();let p=f&&O.merge(f.common,f[s.method]);f&&O.forEach(["delete","get","head","post","put","patch","common"],R=>{delete f[R]}),s.headers=ut.concat(p,f);const g=[];let x=!0;this.interceptors.request.forEach(function(C){typeof C.runWhen=="function"&&C.runWhen(s)===!1||(x=x&&C.synchronous,g.unshift(C.fulfilled,C.rejected))});const v=[];this.interceptors.response.forEach(function(C){v.push(C.fulfilled,C.rejected)});let S,A=0,T;if(!x){const R=[Ud.bind(this),void 0];for(R.unshift.apply(R,g),R.push.apply(R,v),T=R.length,S=Promise.resolve(s);A{if(!l._listeners)return;let f=l._listeners.length;for(;f-- >0;)l._listeners[f](c);l._listeners=null}),this.promise.then=c=>{let f;const p=new Promise(g=>{l.subscribe(g),f=g}).then(c);return p.cancel=function(){l.unsubscribe(f)},p},i(function(f,p,g){l.reason||(l.reason=new Pr(f,p,g),s(l.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(i){if(this.reason){i(this.reason);return}this._listeners?this._listeners.push(i):this._listeners=[i]}unsubscribe(i){if(!this._listeners)return;const s=this._listeners.indexOf(i);s!==-1&&this._listeners.splice(s,1)}toAbortSignal(){const i=new AbortController,s=l=>{i.abort(l)};return this.subscribe(s),i.signal.unsubscribe=()=>this.unsubscribe(s),i.signal}static source(){let i;return{token:new ia(function(c){i=c}),cancel:i}}}function Vv(r){return function(s){return r.apply(null,s)}}function Wv(r){return O.isObject(r)&&r.isAxiosError===!0}const Wu={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511};Object.entries(Wu).forEach(([r,i])=>{Wu[i]=r});function Jp(r){const i=new Hn(r),s=_p(Hn.prototype.request,i);return O.extend(s,Hn.prototype,i,{allOwnKeys:!0}),O.extend(s,i,null,{allOwnKeys:!0}),s.create=function(c){return Jp(Wn(r,c))},s}const De=Jp(To);De.Axios=Hn;De.CanceledError=Pr;De.CancelToken=ia;De.isCancel=Vp;De.VERSION=Xp;De.toFormData=ws;De.AxiosError=le;De.Cancel=De.CanceledError;De.all=function(i){return Promise.all(i)};De.spread=Vv;De.isAxiosError=Wv;De.mergeConfig=Wn;De.AxiosHeaders=ut;De.formToJSON=r=>bp(O.isHTMLForm(r)?new FormData(r):r);De.getAdapter=Kp.getAdapter;De.HttpStatusCode=Wu;De.default=De;const Yv={apiBaseUrl:"/api"};class qv{constructor(){ed(this,"events",{})}on(i,s){return this.events[i]||(this.events[i]=[]),this.events[i].push(s),()=>this.off(i,s)}off(i,s){this.events[i]&&(this.events[i]=this.events[i].filter(l=>l!==s))}emit(i,...s){this.events[i]&&this.events[i].forEach(l=>{l(...s)})}}const as=new qv,Je=De.create({baseURL:Yv.apiBaseUrl,headers:{"Content-Type":"application/json"}});Je.interceptors.response.use(r=>r,r=>{var s,l,c;const i=(s=r.response)==null?void 0:s.data;if(i){const f=(c=(l=r.response)==null?void 0:l.headers)==null?void 0:c["discodeit-request-id"];f&&(i.requestId=f),r.response.data=i}return as.emit("api-error",r),r.response&&r.response.status===401&&as.emit("auth-error"),Promise.reject(r)});const Qv=()=>Je.defaults.baseURL,Gv=async(r,i)=>{const s={username:r,password:i};return(await Je.post("/auth/login",s)).data},Kv=async r=>(await Je.post("/users",r,{headers:{"Content-Type":"multipart/form-data"}})).data,Bd=r=>{let i;const s=new Set,l=(v,S)=>{const A=typeof v=="function"?v(i):v;if(!Object.is(A,i)){const T=i;i=S??(typeof A!="object"||A===null)?A:Object.assign({},i,A),s.forEach(I=>I(i,T))}},c=()=>i,g={setState:l,getState:c,getInitialState:()=>x,subscribe:v=>(s.add(v),()=>s.delete(v))},x=i=r(l,c,g);return g},Xv=r=>r?Bd(r):Bd,Jv=r=>r;function Zv(r,i=Jv){const s=gt.useSyncExternalStore(r.subscribe,()=>i(r.getState()),()=>i(r.getInitialState()));return gt.useDebugValue(s),s}const $d=r=>{const i=Xv(r),s=l=>Zv(i,l);return Object.assign(s,i),s},_r=r=>r?$d(r):$d,e0=async(r,i)=>(await Je.patch(`/users/${r}`,i,{headers:{"Content-Type":"multipart/form-data"}})).data,t0=async()=>(await Je.get("/users")).data,n0=async r=>(await Je.patch(`/users/${r}/userStatus`,{newLastActiveAt:new Date().toISOString()})).data,nn=_r(r=>({users:[],fetchUsers:async()=>{try{const i=await t0();r({users:i})}catch(i){console.error("사용자 목록 조회 실패:",i)}},updateUserStatus:async i=>{try{await n0(i)}catch(s){console.error("사용자 상태 업데이트 실패:",s)}}}));function Zp(r,i){let s;try{s=r()}catch{return}return{getItem:c=>{var f;const p=x=>x===null?null:JSON.parse(x,void 0),g=(f=s.getItem(c))!=null?f:null;return g instanceof Promise?g.then(p):p(g)},setItem:(c,f)=>s.setItem(c,JSON.stringify(f,void 0)),removeItem:c=>s.removeItem(c)}}const Yu=r=>i=>{try{const s=r(i);return s instanceof Promise?s:{then(l){return Yu(l)(s)},catch(l){return this}}}catch(s){return{then(l){return this},catch(l){return Yu(l)(s)}}}},r0=(r,i)=>(s,l,c)=>{let f={storage:Zp(()=>localStorage),partialize:C=>C,version:0,merge:(C,N)=>({...N,...C}),...i},p=!1;const g=new Set,x=new Set;let v=f.storage;if(!v)return r((...C)=>{console.warn(`[zustand persist middleware] Unable to update item '${f.name}', the given storage is currently unavailable.`),s(...C)},l,c);const S=()=>{const C=f.partialize({...l()});return v.setItem(f.name,{state:C,version:f.version})},A=c.setState;c.setState=(C,N)=>{A(C,N),S()};const T=r((...C)=>{s(...C),S()},l,c);c.getInitialState=()=>T;let I;const R=()=>{var C,N;if(!v)return;p=!1,g.forEach(U=>{var V;return U((V=l())!=null?V:T)});const H=((N=f.onRehydrateStorage)==null?void 0:N.call(f,(C=l())!=null?C:T))||void 0;return Yu(v.getItem.bind(v))(f.name).then(U=>{if(U)if(typeof U.version=="number"&&U.version!==f.version){if(f.migrate){const V=f.migrate(U.state,U.version);return V instanceof Promise?V.then(Q=>[!0,Q]):[!0,V]}console.error("State loaded from storage couldn't be migrated since no migrate function was provided")}else return[!1,U.state];return[!1,void 0]}).then(U=>{var V;const[Q,$]=U;if(I=f.merge($,(V=l())!=null?V:T),s(I,!0),Q)return S()}).then(()=>{H==null||H(I,void 0),I=l(),p=!0,x.forEach(U=>U(I))}).catch(U=>{H==null||H(void 0,U)})};return c.persist={setOptions:C=>{f={...f,...C},C.storage&&(v=C.storage)},clearStorage:()=>{v==null||v.removeItem(f.name)},getOptions:()=>f,rehydrate:()=>R(),hasHydrated:()=>p,onHydrate:C=>(g.add(C),()=>{g.delete(C)}),onFinishHydration:C=>(x.add(C),()=>{x.delete(C)})},f.skipHydration||R(),I||T},o0=r0,vt=_r()(o0(r=>({currentUserId:null,setCurrentUser:i=>r({currentUserId:i.id}),logout:()=>{const i=vt.getState().currentUserId;i&&nn.getState().updateUserStatus(i),r({currentUserId:null})},updateUser:async(i,s)=>{try{const l=await e0(i,s);return await nn.getState().fetchUsers(),l}catch(l){throw console.error("사용자 정보 수정 실패:",l),l}}}),{name:"user-storage",storage:Zp(()=>sessionStorage)})),ee={colors:{brand:{primary:"#5865F2",hover:"#4752C4"},background:{primary:"#1a1a1a",secondary:"#2a2a2a",tertiary:"#333333",input:"#40444B",hover:"rgba(255, 255, 255, 0.1)"},text:{primary:"#ffffff",secondary:"#cccccc",muted:"#999999"},status:{online:"#43b581",idle:"#faa61a",dnd:"#f04747",offline:"#747f8d",error:"#ED4245"},border:{primary:"#404040"}}},eh=_.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,th=_.div` + background: ${ee.colors.background.primary}; + padding: 32px; + border-radius: 8px; + width: 440px; + + h2 { + color: ${ee.colors.text.primary}; + margin-bottom: 24px; + font-size: 24px; + font-weight: bold; + } + + form { + display: flex; + flex-direction: column; + gap: 16px; + } +`,ko=_.input` + width: 100%; + padding: 10px; + border-radius: 4px; + background: ${ee.colors.background.input}; + border: none; + color: ${ee.colors.text.primary}; + font-size: 16px; + + &::placeholder { + color: ${ee.colors.text.muted}; + } + + &:focus { + outline: none; + } +`,nh=_.button` + width: 100%; + padding: 12px; + border-radius: 4px; + background: ${ee.colors.brand.primary}; + color: white; + font-size: 16px; + font-weight: 500; + border: none; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: ${ee.colors.brand.hover}; + } +`,rh=_.div` + color: ${ee.colors.status.error}; + font-size: 14px; + text-align: center; +`,i0=_.p` + text-align: center; + margin-top: 16px; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 14px; +`,s0=_.span` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`,Vi=_.div` + margin-bottom: 20px; +`,Wi=_.label` + display: block; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +`,Pu=_.span` + color: ${({theme:r})=>r.colors.status.error}; +`,l0=_.div` + display: flex; + flex-direction: column; + align-items: center; + margin: 10px 0; +`,u0=_.img` + width: 80px; + height: 80px; + border-radius: 50%; + margin-bottom: 10px; + object-fit: cover; +`,a0=_.input` + display: none; +`,c0=_.label` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +`,f0=_.span` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`,d0=_(f0)` + display: block; + text-align: center; + margin-top: 16px; +`,zt="data:image/png;base64,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",p0=({isOpen:r,onClose:i})=>{const[s,l]=ie.useState(""),[c,f]=ie.useState(""),[p,g]=ie.useState(""),[x,v]=ie.useState(null),[S,A]=ie.useState(null),[T,I]=ie.useState(""),R=vt(H=>H.setCurrentUser),C=H=>{var V;const U=(V=H.target.files)==null?void 0:V[0];if(U){v(U);const Q=new FileReader;Q.onloadend=()=>{A(Q.result)},Q.readAsDataURL(U)}},N=async H=>{H.preventDefault(),I("");try{const U=new FormData;U.append("userCreateRequest",new Blob([JSON.stringify({email:s,username:c,password:p})],{type:"application/json"})),x&&U.append("profile",x);const V=await Kv(U);R(V),i()}catch{I("회원가입에 실패했습니다.")}};return r?h.jsx(eh,{children:h.jsxs(th,{children:[h.jsx("h2",{children:"계정 만들기"}),h.jsxs("form",{onSubmit:N,children:[h.jsxs(Vi,{children:[h.jsxs(Wi,{children:["이메일 ",h.jsx(Pu,{children:"*"})]}),h.jsx(ko,{type:"email",value:s,onChange:H=>l(H.target.value),required:!0})]}),h.jsxs(Vi,{children:[h.jsxs(Wi,{children:["사용자명 ",h.jsx(Pu,{children:"*"})]}),h.jsx(ko,{type:"text",value:c,onChange:H=>f(H.target.value),required:!0})]}),h.jsxs(Vi,{children:[h.jsxs(Wi,{children:["비밀번호 ",h.jsx(Pu,{children:"*"})]}),h.jsx(ko,{type:"password",value:p,onChange:H=>g(H.target.value),required:!0})]}),h.jsxs(Vi,{children:[h.jsx(Wi,{children:"프로필 이미지"}),h.jsxs(l0,{children:[h.jsx(u0,{src:S||zt,alt:"profile"}),h.jsx(a0,{type:"file",accept:"image/*",onChange:C,id:"profile-image"}),h.jsx(c0,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),T&&h.jsx(rh,{children:T}),h.jsx(nh,{type:"submit",children:"계속하기"}),h.jsx(d0,{onClick:i,children:"이미 계정이 있으신가요?"})]})]})}):null},h0=({isOpen:r,onClose:i})=>{const[s,l]=ie.useState(""),[c,f]=ie.useState(""),[p,g]=ie.useState(""),[x,v]=ie.useState(!1),S=vt(I=>I.setCurrentUser),{fetchUsers:A}=nn(),T=async()=>{var I;try{const R=await Gv(s,c);await A(),S(R),g(""),i()}catch(R){console.error("로그인 에러:",R),((I=R.response)==null?void 0:I.status)===401?g("아이디 또는 비밀번호가 올바르지 않습니다."):g("로그인에 실패했습니다.")}};return r?h.jsxs(h.Fragment,{children:[h.jsx(eh,{children:h.jsxs(th,{children:[h.jsx("h2",{children:"돌아오신 것을 환영해요!"}),h.jsxs("form",{onSubmit:I=>{I.preventDefault(),T()},children:[h.jsx(ko,{type:"text",placeholder:"사용자 이름",value:s,onChange:I=>l(I.target.value)}),h.jsx(ko,{type:"password",placeholder:"비밀번호",value:c,onChange:I=>f(I.target.value)}),p&&h.jsx(rh,{children:p}),h.jsx(nh,{type:"submit",children:"로그인"})]}),h.jsxs(i0,{children:["계정이 필요한가요? ",h.jsx(s0,{onClick:()=>v(!0),children:"가입하기"})]})]})}),h.jsx(p0,{isOpen:x,onClose:()=>v(!1)})]}):null},m0=async r=>(await Je.get(`/channels?userId=${r}`)).data,g0=async r=>(await Je.post("/channels/public",r)).data,y0=async r=>{const i={participantIds:r};return(await Je.post("/channels/private",i)).data},v0=async r=>(await Je.get("/readStatuses",{params:{userId:r}})).data,w0=async(r,i)=>{const s={newLastReadAt:i};return(await Je.patch(`/readStatuses/${r}`,s)).data},x0=async(r,i,s)=>{const l={userId:r,channelId:i,lastReadAt:s};return(await Je.post("/readStatuses",l)).data},jo=_r((r,i)=>({readStatuses:{},fetchReadStatuses:async()=>{try{const s=vt.getState().currentUserId;if(!s)return;const c=(await v0(s)).reduce((f,p)=>(f[p.channelId]={id:p.id,lastReadAt:p.lastReadAt},f),{});r({readStatuses:c})}catch(s){console.error("읽음 상태 조회 실패:",s)}},updateReadStatus:async s=>{try{const l=vt.getState().currentUserId;if(!l)return;const c=i().readStatuses[s];let f;c?f=await w0(c.id,new Date().toISOString()):f=await x0(l,s,new Date().toISOString()),r(p=>({readStatuses:{...p.readStatuses,[s]:{id:f.id,lastReadAt:f.lastReadAt}}}))}catch(l){console.error("읽음 상태 업데이트 실패:",l)}},hasUnreadMessages:(s,l)=>{const c=i().readStatuses[s],f=c==null?void 0:c.lastReadAt;return!f||new Date(l)>new Date(f)}})),xr=_r((r,i)=>({channels:[],pollingInterval:null,loading:!1,error:null,fetchChannels:async s=>{r({loading:!0,error:null});try{const l=await m0(s);r(f=>{const p=new Set(f.channels.map(S=>S.id)),g=l.filter(S=>!p.has(S.id));return{channels:[...f.channels.filter(S=>l.some(A=>A.id===S.id)),...g],loading:!1}});const{fetchReadStatuses:c}=jo.getState();return c(),l}catch(l){return r({error:l,loading:!1}),[]}},startPolling:s=>{const l=i().pollingInterval;l&&clearInterval(l);const c=setInterval(()=>{i().fetchChannels(s)},3e3);r({pollingInterval:c})},stopPolling:()=>{const s=i().pollingInterval;s&&(clearInterval(s),r({pollingInterval:null}))},createPublicChannel:async s=>{try{const l=await g0(s);return r(c=>c.channels.some(p=>p.id===l.id)?c:{channels:[...c.channels,{...l,participantIds:[],lastMessageAt:new Date().toISOString()}]}),l}catch(l){throw console.error("공개 채널 생성 실패:",l),l}},createPrivateChannel:async s=>{try{const l=await y0(s);return r(c=>c.channels.some(p=>p.id===l.id)?c:{channels:[...c.channels,{...l,participantIds:s,lastMessageAt:new Date().toISOString()}]}),l}catch(l){throw console.error("비공개 채널 생성 실패:",l),l}}})),S0=async r=>(await Je.get(`/binaryContents/${r}`)).data,E0=r=>`${Qv()}/binaryContents/${r}/download`,Yn=_r((r,i)=>({binaryContents:{},fetchBinaryContent:async s=>{if(i().binaryContents[s])return i().binaryContents[s];try{const l=await S0(s),{contentType:c,fileName:f,size:p}=l,x={url:E0(s),contentType:c,fileName:f,size:p};return r(v=>({binaryContents:{...v.binaryContents,[s]:x}})),x}catch(l){return console.error("첨부파일 정보 조회 실패:",l),null}}})),Io=_.div` + position: absolute; + bottom: -3px; + right: -3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: ${r=>r.$online?ee.colors.status.online:ee.colors.status.offline}; + border: 4px solid ${r=>r.$background||ee.colors.background.secondary}; +`;_.div` + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; + background: ${r=>ee.colors.status[r.status||"offline"]||ee.colors.status.offline}; +`;const Tr=_.div` + position: relative; + width: ${r=>r.$size||"32px"}; + height: ${r=>r.$size||"32px"}; + flex-shrink: 0; + margin: ${r=>r.$margin||"0"}; +`,rn=_.img` + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + border: ${r=>r.$border||"none"}; +`;function C0({isOpen:r,onClose:i,user:s}){var L,b;const[l,c]=ie.useState(s.username),[f,p]=ie.useState(s.email),[g,x]=ie.useState(""),[v,S]=ie.useState(null),[A,T]=ie.useState(""),[I,R]=ie.useState(null),{binaryContents:C,fetchBinaryContent:N}=Yn(),{logout:H,updateUser:U}=vt();ie.useEffect(()=>{var re;(re=s.profile)!=null&&re.id&&!C[s.profile.id]&&N(s.profile.id)},[s.profile,C,N]);const V=()=>{c(s.username),p(s.email),x(""),S(null),R(null),T(""),i()},Q=re=>{var Ne;const ye=(Ne=re.target.files)==null?void 0:Ne[0];if(ye){S(ye);const at=new FileReader;at.onloadend=()=>{R(at.result)},at.readAsDataURL(ye)}},$=async re=>{re.preventDefault(),T("");try{const ye=new FormData,Ne={};l!==s.username&&(Ne.newUsername=l),f!==s.email&&(Ne.newEmail=f),g&&(Ne.newPassword=g),(Object.keys(Ne).length>0||v)&&(ye.append("userUpdateRequest",new Blob([JSON.stringify(Ne)],{type:"application/json"})),v&&ye.append("profile",v),await U(s.id,ye)),i()}catch{T("사용자 정보 수정에 실패했습니다.")}};return r?h.jsx(k0,{children:h.jsxs(j0,{children:[h.jsx("h2",{children:"프로필 수정"}),h.jsxs("form",{onSubmit:$,children:[h.jsxs(Yi,{children:[h.jsx(qi,{children:"프로필 이미지"}),h.jsxs(R0,{children:[h.jsx(P0,{src:I||((L=s.profile)!=null&&L.id?(b=C[s.profile.id])==null?void 0:b.url:void 0)||zt,alt:"profile"}),h.jsx(_0,{type:"file",accept:"image/*",onChange:Q,id:"profile-image"}),h.jsx(T0,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),h.jsxs(Yi,{children:[h.jsxs(qi,{children:["사용자명 ",h.jsx(bd,{children:"*"})]}),h.jsx(_u,{type:"text",value:l,onChange:re=>c(re.target.value),required:!0})]}),h.jsxs(Yi,{children:[h.jsxs(qi,{children:["이메일 ",h.jsx(bd,{children:"*"})]}),h.jsx(_u,{type:"email",value:f,onChange:re=>p(re.target.value),required:!0})]}),h.jsxs(Yi,{children:[h.jsx(qi,{children:"새 비밀번호"}),h.jsx(_u,{type:"password",placeholder:"변경하지 않으려면 비워두세요",value:g,onChange:re=>x(re.target.value)})]}),A&&h.jsx(A0,{children:A}),h.jsxs(I0,{children:[h.jsx(Hd,{type:"button",onClick:V,$secondary:!0,children:"취소"}),h.jsx(Hd,{type:"submit",children:"저장"})]})]}),h.jsx(N0,{onClick:H,children:"로그아웃"})]})}):null}const k0=_.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,j0=_.div` + background: ${({theme:r})=>r.colors.background.secondary}; + padding: 32px; + border-radius: 5px; + width: 100%; + max-width: 480px; + + h2 { + color: ${({theme:r})=>r.colors.text.primary}; + margin-bottom: 24px; + text-align: center; + font-size: 24px; + } +`,_u=_.input` + width: 100%; + padding: 10px; + margin-bottom: 10px; + border: none; + border-radius: 4px; + background: ${({theme:r})=>r.colors.background.input}; + color: ${({theme:r})=>r.colors.text.primary}; + + &::placeholder { + color: ${({theme:r})=>r.colors.text.muted}; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${({theme:r})=>r.colors.brand.primary}; + } +`,Hd=_.button` + width: 100%; + padding: 10px; + border: none; + border-radius: 4px; + background: ${({$secondary:r,theme:i})=>r?"transparent":i.colors.brand.primary}; + color: ${({theme:r})=>r.colors.text.primary}; + cursor: pointer; + font-weight: 500; + + &:hover { + background: ${({$secondary:r,theme:i})=>r?i.colors.background.hover:i.colors.brand.hover}; + } +`,A0=_.div` + color: ${({theme:r})=>r.colors.status.error}; + font-size: 14px; + margin-bottom: 10px; +`,R0=_.div` + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 20px; +`,P0=_.img` + width: 100px; + height: 100px; + border-radius: 50%; + margin-bottom: 10px; + object-fit: cover; +`,_0=_.input` + display: none; +`,T0=_.label` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +`,I0=_.div` + display: flex; + gap: 10px; + margin-top: 20px; +`,N0=_.button` + width: 100%; + padding: 10px; + margin-top: 16px; + border: none; + border-radius: 4px; + background: transparent; + color: ${({theme:r})=>r.colors.status.error}; + cursor: pointer; + font-weight: 500; + + &:hover { + background: ${({theme:r})=>r.colors.status.error}20; + } +`,Yi=_.div` + margin-bottom: 20px; +`,qi=_.label` + display: block; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +`,bd=_.span` + color: ${({theme:r})=>r.colors.status.error}; +`,O0=_.div` + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background-color: ${({theme:r})=>r.colors.background.tertiary}; + width: 100%; + height: 52px; +`,L0=_(Tr)``;_(rn)``;const D0=_.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +`,M0=_.div` + font-weight: 500; + color: ${({theme:r})=>r.colors.text.primary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.875rem; + line-height: 1.2; +`,z0=_.div` + font-size: 0.75rem; + color: ${({theme:r})=>r.colors.text.secondary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +`,U0=_.div` + display: flex; + align-items: center; + flex-shrink: 0; +`,F0=_.button` + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: ${({theme:r})=>r.colors.text.secondary}; + font-size: 18px; + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`;function B0({user:r}){var f,p;const[i,s]=ie.useState(!1),{binaryContents:l,fetchBinaryContent:c}=Yn();return ie.useEffect(()=>{var g;(g=r.profile)!=null&&g.id&&!l[r.profile.id]&&c(r.profile.id)},[r.profile,l,c]),h.jsxs(h.Fragment,{children:[h.jsxs(O0,{children:[h.jsxs(L0,{children:[h.jsx(rn,{src:(f=r.profile)!=null&&f.id?(p=l[r.profile.id])==null?void 0:p.url:zt,alt:r.username}),h.jsx(Io,{$online:!0})]}),h.jsxs(D0,{children:[h.jsx(M0,{children:r.username}),h.jsx(z0,{children:"온라인"})]}),h.jsx(U0,{children:h.jsx(F0,{onClick:()=>s(!0),children:"⚙️"})})]}),h.jsx(C0,{isOpen:i,onClose:()=>s(!1),user:r})]})}const $0=_.div` + width: 240px; + background: ${ee.colors.background.secondary}; + border-right: 1px solid ${ee.colors.border.primary}; + display: flex; + flex-direction: column; +`,H0=_.div` + flex: 1; + overflow-y: auto; +`,b0=_.div` + padding: 16px; + font-size: 16px; + font-weight: bold; + color: ${ee.colors.text.primary}; +`,oh=_.div` + height: 34px; + padding: 0 8px; + margin: 1px 8px; + display: flex; + align-items: center; + gap: 6px; + color: ${r=>r.$hasUnread?r.theme.colors.text.primary:r.theme.colors.text.muted}; + font-weight: ${r=>r.$hasUnread?"600":"normal"}; + cursor: pointer; + background: ${r=>r.$isActive?r.theme.colors.background.hover:"transparent"}; + border-radius: 4px; + + &:hover { + background: ${r=>r.theme.colors.background.hover}; + color: ${r=>r.theme.colors.text.primary}; + } +`,Vd=_.div` + margin-bottom: 8px; +`,qu=_.div` + padding: 8px 16px; + display: flex; + align-items: center; + color: ${ee.colors.text.muted}; + text-transform: uppercase; + font-size: 12px; + font-weight: 600; + cursor: pointer; + user-select: none; + + & > span:nth-child(2) { + flex: 1; + margin-right: auto; + } + + &:hover { + color: ${ee.colors.text.primary}; + } +`,Wd=_.span` + margin-right: 4px; + font-size: 10px; + transition: transform 0.2s; + transform: rotate(${r=>r.$folded?"-90deg":"0deg"}); +`,Yd=_.div` + display: ${r=>r.$folded?"none":"block"}; +`,qd=_(oh)` + height: ${r=>r.hasSubtext?"42px":"34px"}; +`,V0=_(Tr)` + width: 32px; + height: 32px; + margin: 0 8px; +`,Qd=_.div` + font-size: 16px; + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${r=>r.$isActive||r.$hasUnread?r.theme.colors.text.primary:r.theme.colors.text.muted}; + font-weight: ${r=>r.$hasUnread?"600":"normal"}; +`;_(Io)` + border-color: ${ee.colors.background.primary}; +`;const Gd=_.button` + background: none; + border: none; + color: ${ee.colors.text.muted}; + font-size: 18px; + padding: 0; + cursor: pointer; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s, color 0.2s; + + ${qu}:hover & { + opacity: 1; + } + + &:hover { + color: ${ee.colors.text.primary}; + } +`,W0=_(Tr)` + width: 40px; + height: 24px; + margin: 0 8px; +`,Y0=_.div` + font-size: 12px; + line-height: 13px; + color: ${ee.colors.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,Kd=_.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; +`,q0=_.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,Q0=_.div` + background: ${ee.colors.background.primary}; + border-radius: 4px; + width: 440px; + max-width: 90%; +`,G0=_.div` + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; +`,K0=_.h2` + color: ${ee.colors.text.primary}; + font-size: 20px; + font-weight: 600; + margin: 0; +`,X0=_.div` + padding: 0 16px 16px; +`,J0=_.form` + display: flex; + flex-direction: column; + gap: 16px; +`,Tu=_.div` + display: flex; + flex-direction: column; + gap: 8px; +`,Iu=_.label` + color: ${ee.colors.text.primary}; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +`,Z0=_.p` + color: ${ee.colors.text.muted}; + font-size: 14px; + margin: -4px 0 0; +`,Qu=_.input` + padding: 10px; + background: ${ee.colors.background.tertiary}; + border: none; + border-radius: 3px; + color: ${ee.colors.text.primary}; + font-size: 16px; + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${ee.colors.status.online}; + } + + &::placeholder { + color: ${ee.colors.text.muted}; + } +`,e1=_.button` + margin-top: 8px; + padding: 12px; + background: ${ee.colors.status.online}; + color: white; + border: none; + border-radius: 3px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #3ca374; + } +`,t1=_.button` + background: none; + border: none; + color: ${ee.colors.text.muted}; + font-size: 24px; + cursor: pointer; + padding: 4px; + line-height: 1; + + &:hover { + color: ${ee.colors.text.primary}; + } +`,n1=_(Qu)` + margin-bottom: 8px; +`,r1=_.div` + max-height: 300px; + overflow-y: auto; + background: ${ee.colors.background.tertiary}; + border-radius: 4px; +`,o1=_.div` + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: ${ee.colors.background.hover}; + } + + & + & { + border-top: 1px solid ${ee.colors.border.primary}; + } +`,i1=_.input` + margin-right: 12px; + width: 16px; + height: 16px; + cursor: pointer; +`,Xd=_.img` + width: 32px; + height: 32px; + border-radius: 50%; + margin-right: 12px; +`,s1=_.div` + flex: 1; + min-width: 0; +`,l1=_.div` + color: ${ee.colors.text.primary}; + font-size: 14px; + font-weight: 500; +`,u1=_.div` + color: ${ee.colors.text.muted}; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,a1=_.div` + padding: 16px; + text-align: center; + color: ${ee.colors.text.muted}; +`,c1=_.div` + color: ${ee.colors.status.error}; + font-size: 14px; + padding: 8px 0; + text-align: center; + background-color: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 4px; + margin-bottom: 8px; +`;function f1(){return h.jsx(b0,{children:"채널 목록"})}function Jd({channel:r,isActive:i,onClick:s,hasUnread:l}){var x;const c=vt(v=>v.currentUserId),{binaryContents:f}=Yn();if(r.type==="PUBLIC")return h.jsxs(oh,{$isActive:i,onClick:s,$hasUnread:l,children:["# ",r.name]});const p=r.participants;if(p.length>2){const v=p.filter(S=>S.id!==c).map(S=>S.username).join(", ");return h.jsxs(qd,{$isActive:i,onClick:s,children:[h.jsx(W0,{children:p.filter(S=>S.id!==c).slice(0,2).map((S,A)=>{var T;return h.jsx(rn,{src:S.profile?(T=f[S.profile.id])==null?void 0:T.url:zt,style:{position:"absolute",left:A*16,zIndex:2-A,width:"24px",height:"24px",border:"2px solid #2a2a2a"}},S.id)})}),h.jsxs(Kd,{children:[h.jsx(Qd,{$hasUnread:l,children:v}),h.jsxs(Y0,{children:["멤버 ",p.length,"명"]})]})]})}const g=p.filter(v=>v.id!==c)[0];return g&&h.jsxs(qd,{$isActive:i,onClick:s,children:[h.jsxs(V0,{children:[h.jsx(rn,{src:g.profile?(x=f[g.profile.id])==null?void 0:x.url:zt,alt:"profile"}),h.jsx(Io,{$online:g.online})]}),h.jsx(Kd,{children:h.jsx(Qd,{$hasUnread:l,children:g.username})})]})}function d1({isOpen:r,type:i,onClose:s,onCreateSuccess:l}){const[c,f]=ie.useState({name:"",description:""}),[p,g]=ie.useState(""),[x,v]=ie.useState([]),[S,A]=ie.useState(""),T=nn($=>$.users),I=Yn($=>$.binaryContents),R=vt($=>$.currentUserId),C=ie.useMemo(()=>T.filter($=>$.id!==R).filter($=>$.username.toLowerCase().includes(p.toLowerCase())||$.email.toLowerCase().includes(p.toLowerCase())),[p,T,R]),N=xr($=>$.createPublicChannel),H=xr($=>$.createPrivateChannel),U=$=>{const{name:L,value:b}=$.target;f(re=>({...re,[L]:b}))},V=$=>{v(L=>L.includes($)?L.filter(b=>b!==$):[...L,$])},Q=async $=>{var L,b;$.preventDefault(),A("");try{let re;if(i==="PUBLIC"){if(!c.name.trim()){A("채널 이름을 입력해주세요.");return}const ye={name:c.name,description:c.description};re=await N(ye)}else{if(x.length===0){A("대화 상대를 선택해주세요.");return}const ye=R&&[...x,R]||x;re=await H(ye)}l(re)}catch(re){console.error("채널 생성 실패:",re),A(((b=(L=re.response)==null?void 0:L.data)==null?void 0:b.message)||"채널 생성에 실패했습니다. 다시 시도해주세요.")}};return r?h.jsx(q0,{onClick:s,children:h.jsxs(Q0,{onClick:$=>$.stopPropagation(),children:[h.jsxs(G0,{children:[h.jsx(K0,{children:i==="PUBLIC"?"채널 만들기":"개인 메시지 시작하기"}),h.jsx(t1,{onClick:s,children:"×"})]}),h.jsx(X0,{children:h.jsxs(J0,{onSubmit:Q,children:[S&&h.jsx(c1,{children:S}),i==="PUBLIC"?h.jsxs(h.Fragment,{children:[h.jsxs(Tu,{children:[h.jsx(Iu,{children:"채널 이름"}),h.jsx(Qu,{name:"name",value:c.name,onChange:U,placeholder:"새로운-채널",required:!0})]}),h.jsxs(Tu,{children:[h.jsx(Iu,{children:"채널 설명"}),h.jsx(Z0,{children:"이 채널의 주제를 설명해주세요."}),h.jsx(Qu,{name:"description",value:c.description,onChange:U,placeholder:"채널 설명을 입력하세요"})]})]}):h.jsxs(Tu,{children:[h.jsx(Iu,{children:"사용자 검색"}),h.jsx(n1,{type:"text",value:p,onChange:$=>g($.target.value),placeholder:"사용자명 또는 이메일로 검색"}),h.jsx(r1,{children:C.length>0?C.map($=>h.jsxs(o1,{children:[h.jsx(i1,{type:"checkbox",checked:x.includes($.id),onChange:()=>V($.id)}),$.profile?h.jsx(Xd,{src:I[$.profile.id].url}):h.jsx(Xd,{src:zt}),h.jsxs(s1,{children:[h.jsx(l1,{children:$.username}),h.jsx(u1,{children:$.email})]})]},$.id)):h.jsx(a1,{children:"검색 결과가 없습니다."})})]}),h.jsx(e1,{type:"submit",children:i==="PUBLIC"?"채널 만들기":"대화 시작하기"})]})})]})}):null}function p1({currentUser:r,activeChannel:i,onChannelSelect:s}){var Q,$;const[l,c]=ie.useState({PUBLIC:!1,PRIVATE:!1}),[f,p]=ie.useState({isOpen:!1,type:null}),g=xr(L=>L.channels),x=xr(L=>L.fetchChannels),v=xr(L=>L.startPolling),S=xr(L=>L.stopPolling),A=jo(L=>L.fetchReadStatuses),T=jo(L=>L.updateReadStatus),I=jo(L=>L.hasUnreadMessages);ie.useEffect(()=>{if(r)return x(r.id),A(),v(r.id),()=>{S()}},[r,x,A,v,S]);const R=L=>{c(b=>({...b,[L]:!b[L]}))},C=(L,b)=>{b.stopPropagation(),p({isOpen:!0,type:L})},N=()=>{p({isOpen:!1,type:null})},H=async L=>{try{const re=(await x(r.id)).find(ye=>ye.id===L.id);re&&s(re),N()}catch(b){console.error("채널 생성 실패:",b)}},U=L=>{s(L),T(L.id)},V=g.reduce((L,b)=>(L[b.type]||(L[b.type]=[]),L[b.type].push(b),L),{});return h.jsxs($0,{children:[h.jsx(f1,{}),h.jsxs(H0,{children:[h.jsxs(Vd,{children:[h.jsxs(qu,{onClick:()=>R("PUBLIC"),children:[h.jsx(Wd,{$folded:l.PUBLIC,children:"▼"}),h.jsx("span",{children:"일반 채널"}),h.jsx(Gd,{onClick:L=>C("PUBLIC",L),children:"+"})]}),h.jsx(Yd,{$folded:l.PUBLIC,children:(Q=V.PUBLIC)==null?void 0:Q.map(L=>h.jsx(Jd,{channel:L,isActive:(i==null?void 0:i.id)===L.id,hasUnread:I(L.id,L.lastMessageAt),onClick:()=>U(L)},L.id))})]}),h.jsxs(Vd,{children:[h.jsxs(qu,{onClick:()=>R("PRIVATE"),children:[h.jsx(Wd,{$folded:l.PRIVATE,children:"▼"}),h.jsx("span",{children:"개인 메시지"}),h.jsx(Gd,{onClick:L=>C("PRIVATE",L),children:"+"})]}),h.jsx(Yd,{$folded:l.PRIVATE,children:($=V.PRIVATE)==null?void 0:$.map(L=>h.jsx(Jd,{channel:L,isActive:(i==null?void 0:i.id)===L.id,hasUnread:I(L.id,L.lastMessageAt),onClick:()=>U(L)},L.id))})]})]}),h.jsx(h1,{children:h.jsx(B0,{user:r})}),h.jsx(d1,{isOpen:f.isOpen,type:f.type,onClose:N,onCreateSuccess:H})]})}const h1=_.div` + margin-top: auto; + border-top: 1px solid ${({theme:r})=>r.colors.border.primary}; + background-color: ${({theme:r})=>r.colors.background.tertiary}; +`,m1=_.div` + flex: 1; + display: flex; + flex-direction: column; + background: ${({theme:r})=>r.colors.background.primary}; +`,g1=_.div` + display: flex; + flex-direction: column; + height: 100%; + background: ${({theme:r})=>r.colors.background.primary}; +`,y1=_(g1)` + justify-content: center; + align-items: center; + flex: 1; + padding: 0 20px; +`,v1=_.div` + text-align: center; + max-width: 400px; + padding: 20px; + margin-bottom: 80px; +`,w1=_.div` + font-size: 48px; + margin-bottom: 16px; + animation: wave 2s infinite; + transform-origin: 70% 70%; + + @keyframes wave { + 0% { transform: rotate(0deg); } + 10% { transform: rotate(14deg); } + 20% { transform: rotate(-8deg); } + 30% { transform: rotate(14deg); } + 40% { transform: rotate(-4deg); } + 50% { transform: rotate(10deg); } + 60% { transform: rotate(0deg); } + 100% { transform: rotate(0deg); } + } +`,x1=_.h2` + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 28px; + font-weight: 700; + margin-bottom: 16px; +`,S1=_.p` + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + line-height: 1.6; + word-break: keep-all; +`,Zd=_.div` + height: 48px; + padding: 0 16px; + background: ${ee.colors.background.primary}; + border-bottom: 1px solid ${ee.colors.border.primary}; + display: flex; + align-items: center; +`,ep=_.div` + display: flex; + align-items: center; + gap: 8px; + height: 100%; +`,E1=_.div` + display: flex; + align-items: center; + gap: 12px; + height: 100%; +`,C1=_(Tr)` + width: 24px; + height: 24px; +`;_.img` + width: 24px; + height: 24px; + border-radius: 50%; +`;const k1=_.div` + position: relative; + width: 40px; + height: 24px; + flex-shrink: 0; +`,j1=_(Io)` + border-color: ${ee.colors.background.primary}; + bottom: -3px; + right: -3px; +`,A1=_.div` + font-size: 12px; + color: ${ee.colors.text.muted}; + line-height: 13px; +`,tp=_.div` + font-weight: bold; + color: ${ee.colors.text.primary}; + line-height: 20px; + font-size: 16px; +`,R1=_.div` + flex: 1; + display: flex; + flex-direction: column-reverse; + overflow-y: auto; +`,P1=_.div` + padding: 16px; + display: flex; + flex-direction: column; +`,_1=_.div` + margin-bottom: 16px; + display: flex; + align-items: flex-start; +`,T1=_(Tr)` + margin-right: 16px; + width: 40px; + height: 40px; +`;_.img` + width: 40px; + height: 40px; + border-radius: 50%; +`;const I1=_.div` + display: flex; + align-items: center; + margin-bottom: 4px; +`,N1=_.span` + font-weight: bold; + color: ${ee.colors.text.primary}; + margin-right: 8px; +`,O1=_.span` + font-size: 0.75rem; + color: ${ee.colors.text.muted}; +`,L1=_.div` + color: ${ee.colors.text.secondary}; + margin-top: 4px; +`,D1=_.form` + display: flex; + align-items: center; + gap: 8px; + padding: 16px; + background: ${({theme:r})=>r.colors.background.secondary}; +`,M1=_.textarea` + flex: 1; + padding: 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border: none; + border-radius: 4px; + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 14px; + resize: none; + min-height: 44px; + max-height: 144px; + + &:focus { + outline: none; + } + + &::placeholder { + color: ${({theme:r})=>r.colors.text.muted}; + } +`,z1=_.button` + background: none; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`;_.div` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: ${ee.colors.text.muted}; + font-size: 16px; + font-weight: 500; + padding: 20px; + text-align: center; +`;const np=_.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + width: 100%; +`,U1=_.a` + display: block; + border-radius: 4px; + overflow: hidden; + max-width: 300px; + + img { + width: 100%; + height: auto; + display: block; + } +`,F1=_.a` + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 8px; + text-decoration: none; + width: fit-content; + + &:hover { + background: ${({theme:r})=>r.colors.background.hover}; + } +`,B1=_.div` + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: #0B93F6; +`,$1=_.div` + display: flex; + flex-direction: column; + gap: 2px; +`,H1=_.span` + font-size: 14px; + color: #0B93F6; + font-weight: 500; +`,b1=_.span` + font-size: 13px; + color: ${({theme:r})=>r.colors.text.muted}; +`,V1=_.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 0; +`,ih=_.div` + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 4px; + max-width: 300px; +`,W1=_(ih)` + padding: 0; + overflow: hidden; + width: 200px; + height: 120px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`,Y1=_.div` + color: #0B93F6; + font-size: 20px; +`,q1=_.div` + font-size: 13px; + color: ${({theme:r})=>r.colors.text.primary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,rp=_.button` + position: absolute; + top: -6px; + right: -6px; + width: 20px; + height: 20px; + border-radius: 50%; + background: ${({theme:r})=>r.colors.background.secondary}; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`;function Q1({channel:r}){var x;const i=vt(v=>v.currentUserId),s=nn(v=>v.users),l=Yn(v=>v.binaryContents);if(!r)return null;if(r.type==="PUBLIC")return h.jsx(Zd,{children:h.jsx(ep,{children:h.jsxs(tp,{children:["# ",r.name]})})});const c=r.participants.map(v=>s.find(S=>S.id===v.id)).filter(Boolean),f=c.filter(v=>v.id!==i),p=c.length>2,g=c.filter(v=>v.id!==i).map(v=>v.username).join(", ");return h.jsx(Zd,{children:h.jsx(ep,{children:h.jsxs(E1,{children:[p?h.jsx(k1,{children:f.slice(0,2).map((v,S)=>{var A;return h.jsx(rn,{src:v.profile?(A=l[v.profile.id])==null?void 0:A.url:zt,style:{position:"absolute",left:S*16,zIndex:2-S,width:"24px",height:"24px"}},v.id)})}):h.jsxs(C1,{children:[h.jsx(rn,{src:f[0].profile?(x=l[f[0].profile.id])==null?void 0:x.url:zt}),h.jsx(j1,{$online:f[0].online})]}),h.jsxs("div",{children:[h.jsx(tp,{children:g}),p&&h.jsxs(A1,{children:["멤버 ",c.length,"명"]})]})]})})})}const G1=async(r,i,s)=>{var c;return(await Je.get("/messages",{params:{channelId:r,cursor:i,size:s.size,sort:(c=s.sort)==null?void 0:c.join(",")}})).data},K1=async(r,i)=>{const s=new FormData,l={content:r.content,channelId:r.channelId,authorId:r.authorId};return s.append("messageCreateRequest",new Blob([JSON.stringify(l)],{type:"application/json"})),i&&i.length>0&&i.forEach(f=>{s.append("attachments",f)}),(await Je.post("/messages",s,{headers:{"Content-Type":"multipart/form-data"}})).data},Nu={size:50,sort:["createdAt,desc"]},sh=_r((r,i)=>({messages:[],pollingIntervals:{},lastMessageId:null,pagination:{nextCursor:null,pageSize:50,hasNext:!1},fetchMessages:async(s,l,c=Nu)=>{try{const f=await G1(s,l,c),p=f.content,g=p.length>0?p[0]:null,x=(g==null?void 0:g.id)!==i().lastMessageId;return r(v=>{var C;const S=!l,A=s!==((C=v.messages[0])==null?void 0:C.channelId),T=S&&(v.messages.length===0||A);let I=[],R={...v.pagination};if(T)I=p,R={nextCursor:f.nextCursor,pageSize:f.size,hasNext:f.hasNext};else if(S){const N=new Set(v.messages.map(U=>U.id));I=[...p.filter(U=>!N.has(U.id)&&(v.messages.length===0||U.createdAt>v.messages[0].createdAt)),...v.messages]}else{const N=new Set(v.messages.map(U=>U.id)),H=p.filter(U=>!N.has(U.id));I=[...v.messages,...H],R={nextCursor:f.nextCursor,pageSize:f.size,hasNext:f.hasNext}}return{messages:I,lastMessageId:(g==null?void 0:g.id)||null,pagination:R}}),x}catch(f){return console.error("메시지 목록 조회 실패:",f),!1}},loadMoreMessages:async s=>{const{pagination:l}=i();l.hasNext&&await i().fetchMessages(s,l.nextCursor,{...Nu})},startPolling:s=>{const l=i();if(l.pollingIntervals[s]){const g=l.pollingIntervals[s];typeof g=="number"&&clearTimeout(g)}let c=300;const f=3e3;r(g=>({pollingIntervals:{...g.pollingIntervals,[s]:!0}}));const p=async()=>{const g=i();if(!g.pollingIntervals[s])return;if(await g.fetchMessages(s,null,Nu)?c=300:c=Math.min(c*1.5,f),i().pollingIntervals[s]){const v=setTimeout(p,c);r(S=>({pollingIntervals:{...S.pollingIntervals,[s]:v}}))}};p()},stopPolling:s=>{const{pollingIntervals:l}=i();if(l[s]){const c=l[s];typeof c=="number"&&clearTimeout(c),r(f=>{const p={...f.pollingIntervals};return delete p[s],{pollingIntervals:p}})}},createMessage:async(s,l)=>{try{const c=await K1(s,l),f=jo.getState().updateReadStatus;return await f(s.channelId),r(p=>p.messages.some(x=>x.id===c.id)?p:{messages:[c,...p.messages],lastMessageId:c.id}),c}catch(c){throw console.error("메시지 생성 실패:",c),c}}}));function X1({channel:r}){const[i,s]=ie.useState(""),[l,c]=ie.useState([]),f=sh(T=>T.createMessage),p=vt(T=>T.currentUserId),g=async T=>{if(T.preventDefault(),!(!i.trim()&&l.length===0))try{await f({content:i.trim(),channelId:r.id,authorId:p??""},l),s(""),c([])}catch(I){console.error("메시지 전송 실패:",I)}},x=T=>{const I=Array.from(T.target.files||[]);c(R=>[...R,...I]),T.target.value=""},v=T=>{c(I=>I.filter((R,C)=>C!==T))},S=T=>{if(T.key==="Enter"&&!T.shiftKey){if(console.log("Enter key pressed"),T.preventDefault(),T.nativeEvent.isComposing)return;g(T)}},A=(T,I)=>T.type.startsWith("image/")?h.jsxs(W1,{children:[h.jsx("img",{src:URL.createObjectURL(T),alt:T.name}),h.jsx(rp,{onClick:()=>v(I),children:"×"})]},I):h.jsxs(ih,{children:[h.jsx(Y1,{children:"📎"}),h.jsx(q1,{children:T.name}),h.jsx(rp,{onClick:()=>v(I),children:"×"})]},I);return ie.useEffect(()=>()=>{l.forEach(T=>{T.type.startsWith("image/")&&URL.revokeObjectURL(URL.createObjectURL(T))})},[l]),r?h.jsxs(h.Fragment,{children:[l.length>0&&h.jsx(V1,{children:l.map((T,I)=>A(T,I))}),h.jsxs(D1,{onSubmit:g,children:[h.jsxs(z1,{as:"label",children:["+",h.jsx("input",{type:"file",multiple:!0,onChange:x,style:{display:"none"}})]}),h.jsx(M1,{value:i,onChange:T=>s(T.target.value),onKeyDown:S,placeholder:r.type==="PUBLIC"?`#${r.name}에 메시지 보내기`:"메시지 보내기"})]})]}):null}/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 + +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. + +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */var Gu=function(r,i){return Gu=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(s,l){s.__proto__=l}||function(s,l){for(var c in l)l.hasOwnProperty(c)&&(s[c]=l[c])},Gu(r,i)};function J1(r,i){Gu(r,i);function s(){this.constructor=r}r.prototype=i===null?Object.create(i):(s.prototype=i.prototype,new s)}var Ao=function(){return Ao=Object.assign||function(i){for(var s,l=1,c=arguments.length;lr?I():i!==!0&&(c=setTimeout(l?R:I,l===void 0?r-A:r))}return v.cancel=x,v}var Sr={Pixel:"Pixel",Percent:"Percent"},op={unit:Sr.Percent,value:.8};function ip(r){return typeof r=="number"?{unit:Sr.Percent,value:r*100}:typeof r=="string"?r.match(/^(\d*(\.\d+)?)px$/)?{unit:Sr.Pixel,value:parseFloat(r)}:r.match(/^(\d*(\.\d+)?)%$/)?{unit:Sr.Percent,value:parseFloat(r)}:(console.warn('scrollThreshold format is invalid. Valid formats: "120px", "50%"...'),op):(console.warn("scrollThreshold should be string or number"),op)}var ew=function(r){J1(i,r);function i(s){var l=r.call(this,s)||this;return l.lastScrollTop=0,l.actionTriggered=!1,l.startY=0,l.currentY=0,l.dragging=!1,l.maxPullDownDistance=0,l.getScrollableTarget=function(){return l.props.scrollableTarget instanceof HTMLElement?l.props.scrollableTarget:typeof l.props.scrollableTarget=="string"?document.getElementById(l.props.scrollableTarget):(l.props.scrollableTarget===null&&console.warn(`You are trying to pass scrollableTarget but it is null. This might + happen because the element may not have been added to DOM yet. + See https://github.com/ankeetmaini/react-infinite-scroll-component/issues/59 for more info. + `),null)},l.onStart=function(c){l.lastScrollTop||(l.dragging=!0,c instanceof MouseEvent?l.startY=c.pageY:c instanceof TouchEvent&&(l.startY=c.touches[0].pageY),l.currentY=l.startY,l._infScroll&&(l._infScroll.style.willChange="transform",l._infScroll.style.transition="transform 0.2s cubic-bezier(0,0,0.31,1)"))},l.onMove=function(c){l.dragging&&(c instanceof MouseEvent?l.currentY=c.pageY:c instanceof TouchEvent&&(l.currentY=c.touches[0].pageY),!(l.currentY=Number(l.props.pullDownToRefreshThreshold)&&l.setState({pullToRefreshThresholdBreached:!0}),!(l.currentY-l.startY>l.maxPullDownDistance*1.5)&&l._infScroll&&(l._infScroll.style.overflow="visible",l._infScroll.style.transform="translate3d(0px, "+(l.currentY-l.startY)+"px, 0px)")))},l.onEnd=function(){l.startY=0,l.currentY=0,l.dragging=!1,l.state.pullToRefreshThresholdBreached&&(l.props.refreshFunction&&l.props.refreshFunction(),l.setState({pullToRefreshThresholdBreached:!1})),requestAnimationFrame(function(){l._infScroll&&(l._infScroll.style.overflow="auto",l._infScroll.style.transform="none",l._infScroll.style.willChange="unset")})},l.onScrollListener=function(c){typeof l.props.onScroll=="function"&&setTimeout(function(){return l.props.onScroll&&l.props.onScroll(c)},0);var f=l.props.height||l._scrollableNode?c.target:document.documentElement.scrollTop?document.documentElement:document.body;if(!l.actionTriggered){var p=l.props.inverse?l.isElementAtTop(f,l.props.scrollThreshold):l.isElementAtBottom(f,l.props.scrollThreshold);p&&l.props.hasMore&&(l.actionTriggered=!0,l.setState({showLoader:!0}),l.props.next&&l.props.next()),l.lastScrollTop=f.scrollTop}},l.state={showLoader:!1,pullToRefreshThresholdBreached:!1,prevDataLength:s.dataLength},l.throttledOnScrollListener=Z1(150,l.onScrollListener).bind(l),l.onStart=l.onStart.bind(l),l.onMove=l.onMove.bind(l),l.onEnd=l.onEnd.bind(l),l}return i.prototype.componentDidMount=function(){if(typeof this.props.dataLength>"u")throw new Error('mandatory prop "dataLength" is missing. The prop is needed when loading more content. Check README.md for usage');if(this._scrollableNode=this.getScrollableTarget(),this.el=this.props.height?this._infScroll:this._scrollableNode||window,this.el&&this.el.addEventListener("scroll",this.throttledOnScrollListener),typeof this.props.initialScrollY=="number"&&this.el&&this.el instanceof HTMLElement&&this.el.scrollHeight>this.props.initialScrollY&&this.el.scrollTo(0,this.props.initialScrollY),this.props.pullDownToRefresh&&this.el&&(this.el.addEventListener("touchstart",this.onStart),this.el.addEventListener("touchmove",this.onMove),this.el.addEventListener("touchend",this.onEnd),this.el.addEventListener("mousedown",this.onStart),this.el.addEventListener("mousemove",this.onMove),this.el.addEventListener("mouseup",this.onEnd),this.maxPullDownDistance=this._pullDown&&this._pullDown.firstChild&&this._pullDown.firstChild.getBoundingClientRect().height||0,this.forceUpdate(),typeof this.props.refreshFunction!="function"))throw new Error(`Mandatory prop "refreshFunction" missing. + Pull Down To Refresh functionality will not work + as expected. Check README.md for usage'`)},i.prototype.componentWillUnmount=function(){this.el&&(this.el.removeEventListener("scroll",this.throttledOnScrollListener),this.props.pullDownToRefresh&&(this.el.removeEventListener("touchstart",this.onStart),this.el.removeEventListener("touchmove",this.onMove),this.el.removeEventListener("touchend",this.onEnd),this.el.removeEventListener("mousedown",this.onStart),this.el.removeEventListener("mousemove",this.onMove),this.el.removeEventListener("mouseup",this.onEnd)))},i.prototype.componentDidUpdate=function(s){this.props.dataLength!==s.dataLength&&(this.actionTriggered=!1,this.setState({showLoader:!1}))},i.getDerivedStateFromProps=function(s,l){var c=s.dataLength!==l.prevDataLength;return c?Ao(Ao({},l),{prevDataLength:s.dataLength}):null},i.prototype.isElementAtTop=function(s,l){l===void 0&&(l=.8);var c=s===document.body||s===document.documentElement?window.screen.availHeight:s.clientHeight,f=ip(l);return f.unit===Sr.Pixel?s.scrollTop<=f.value+c-s.scrollHeight+1:s.scrollTop<=f.value/100+c-s.scrollHeight+1},i.prototype.isElementAtBottom=function(s,l){l===void 0&&(l=.8);var c=s===document.body||s===document.documentElement?window.screen.availHeight:s.clientHeight,f=ip(l);return f.unit===Sr.Pixel?s.scrollTop+c>=s.scrollHeight-f.value:s.scrollTop+c>=f.value/100*s.scrollHeight},i.prototype.render=function(){var s=this,l=Ao({height:this.props.height||"auto",overflow:"auto",WebkitOverflowScrolling:"touch"},this.props.style),c=this.props.hasChildren||!!(this.props.children&&this.props.children instanceof Array&&this.props.children.length),f=this.props.pullDownToRefresh&&this.props.height?{overflow:"auto"}:{};return gt.createElement("div",{style:f,className:"infinite-scroll-component__outerdiv"},gt.createElement("div",{className:"infinite-scroll-component "+(this.props.className||""),ref:function(p){return s._infScroll=p},style:l},this.props.pullDownToRefresh&>.createElement("div",{style:{position:"relative"},ref:function(p){return s._pullDown=p}},gt.createElement("div",{style:{position:"absolute",left:0,right:0,top:-1*this.maxPullDownDistance}},this.state.pullToRefreshThresholdBreached?this.props.releaseToRefreshContent:this.props.pullDownToRefreshContent)),this.props.children,!this.state.showLoader&&!c&&this.props.hasMore&&this.props.loader,this.state.showLoader&&this.props.hasMore&&this.props.loader,!this.props.hasMore&&this.props.endMessage))},i}(ie.Component);const tw=r=>r<1024?r+" B":r<1024*1024?(r/1024).toFixed(2)+" KB":r<1024*1024*1024?(r/(1024*1024)).toFixed(2)+" MB":(r/(1024*1024*1024)).toFixed(2)+" GB";function nw({channel:r}){const{messages:i,fetchMessages:s,loadMoreMessages:l,pagination:c,startPolling:f,stopPolling:p}=sh(),{binaryContents:g,fetchBinaryContent:x}=Yn();ie.useEffect(()=>{if(r!=null&&r.id)return s(r.id,null),f(r.id),()=>{p(r.id)}},[r==null?void 0:r.id,s,f,p]),ie.useEffect(()=>{i.forEach(I=>{var R;(R=I.attachments)==null||R.forEach(C=>{g[C.id]||x(C.id)})})},[i,g,x]);const v=async I=>{try{const{url:R,fileName:C}=I,N=document.createElement("a");N.href=R,N.download=C,N.style.display="none",document.body.appendChild(N);try{const U=await(await window.showSaveFilePicker({suggestedName:I.fileName,types:[{description:"Files",accept:{"*/*":[".txt",".pdf",".doc",".docx",".xls",".xlsx",".jpg",".jpeg",".png",".gif"]}}]})).createWritable(),Q=await(await fetch(R)).blob();await U.write(Q),await U.close()}catch(H){H.name!=="AbortError"&&N.click()}document.body.removeChild(N),window.URL.revokeObjectURL(R)}catch(R){console.error("파일 다운로드 실패:",R)}},S=I=>I!=null&&I.length?I.map(R=>{const C=g[R.id];return C?C.contentType.startsWith("image/")?h.jsx(np,{children:h.jsx(U1,{href:"#",onClick:H=>{H.preventDefault(),v(C)},children:h.jsx("img",{src:C.url,alt:C.fileName})})},C.url):h.jsx(np,{children:h.jsxs(F1,{href:"#",onClick:H=>{H.preventDefault(),v(C)},children:[h.jsx(B1,{children:h.jsxs("svg",{width:"40",height:"40",viewBox:"0 0 40 40",fill:"none",children:[h.jsx("path",{d:"M8 3C8 1.89543 8.89543 1 10 1H22L32 11V37C32 38.1046 31.1046 39 30 39H10C8.89543 39 8 38.1046 8 37V3Z",fill:"#0B93F6",fillOpacity:"0.1"}),h.jsx("path",{d:"M22 1L32 11H24C22.8954 11 22 10.1046 22 9V1Z",fill:"#0B93F6",fillOpacity:"0.3"}),h.jsx("path",{d:"M13 19H27M13 25H27M13 31H27",stroke:"#0B93F6",strokeWidth:"2",strokeLinecap:"round"})]})}),h.jsxs($1,{children:[h.jsx(H1,{children:C.fileName}),h.jsx(b1,{children:tw(C.size)})]})]})},C.url):null}):null,A=I=>new Date(I).toLocaleTimeString(),T=()=>{r!=null&&r.id&&l(r.id)};return h.jsx(R1,{children:h.jsx("div",{id:"scrollableDiv",style:{height:"100%",overflow:"auto",display:"flex",flexDirection:"column-reverse"},children:h.jsx(ew,{dataLength:i.length,next:T,hasMore:c.hasNext,loader:h.jsx("h4",{style:{textAlign:"center"},children:"메시지를 불러오는 중..."}),scrollableTarget:"scrollableDiv",style:{display:"flex",flexDirection:"column-reverse"},inverse:!0,endMessage:h.jsx("p",{style:{textAlign:"center"},children:h.jsx("b",{children:c.nextCursor!==null?"모든 메시지를 불러왔습니다":""})}),children:h.jsx(P1,{children:[...i].reverse().map(I=>{var C;const R=I.author;return h.jsxs(_1,{children:[h.jsx(T1,{children:h.jsx(rn,{src:R&&R.profile?(C=g[R.profile.id])==null?void 0:C.url:zt,alt:R&&R.username||"알 수 없음"})}),h.jsxs("div",{children:[h.jsxs(I1,{children:[h.jsx(N1,{children:R&&R.username||"알 수 없음"}),h.jsx(O1,{children:A(I.createdAt)})]}),h.jsx(L1,{children:I.content}),S(I.attachments)]})]},I.id)})})})})})}function rw({channel:r}){return r?h.jsxs(m1,{children:[h.jsx(Q1,{channel:r}),h.jsx(nw,{channel:r}),h.jsx(X1,{channel:r})]}):h.jsx(y1,{children:h.jsxs(v1,{children:[h.jsx(w1,{children:"👋"}),h.jsx(x1,{children:"채널을 선택해주세요"}),h.jsxs(S1,{children:["왼쪽의 채널 목록에서 채널을 선택하여",h.jsx("br",{}),"대화를 시작하세요."]})]})})}function ow(r,i="yyyy-MM-dd HH:mm:ss"){if(!r||!(r instanceof Date)||isNaN(r.getTime()))return"";const s=r.getFullYear(),l=String(r.getMonth()+1).padStart(2,"0"),c=String(r.getDate()).padStart(2,"0"),f=String(r.getHours()).padStart(2,"0"),p=String(r.getMinutes()).padStart(2,"0"),g=String(r.getSeconds()).padStart(2,"0");return i.replace("yyyy",s.toString()).replace("MM",l).replace("dd",c).replace("HH",f).replace("mm",p).replace("ss",g)}const iw=_.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,sw=_.div` + background: ${({theme:r})=>r.colors.background.primary}; + border-radius: 8px; + width: 500px; + max-width: 90%; + padding: 24px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +`,lw=_.div` + display: flex; + align-items: center; + margin-bottom: 16px; +`,uw=_.div` + color: ${({theme:r})=>r.colors.status.error}; + font-size: 24px; + margin-right: 12px; +`,aw=_.h3` + color: ${({theme:r})=>r.colors.text.primary}; + margin: 0; + font-size: 18px; +`,cw=_.div` + background: ${({theme:r})=>r.colors.background.tertiary}; + color: ${({theme:r})=>r.colors.text.muted}; + padding: 2px 8px; + border-radius: 4px; + font-size: 14px; + margin-left: auto; +`,fw=_.p` + color: ${({theme:r})=>r.colors.text.secondary}; + margin-bottom: 20px; + line-height: 1.5; + font-weight: 500; +`,dw=_.div` + margin-bottom: 20px; + background: ${({theme:r})=>r.colors.background.secondary}; + border-radius: 6px; + padding: 12px; +`,wo=_.div` + display: flex; + margin-bottom: 8px; + font-size: 14px; +`,xo=_.span` + color: ${({theme:r})=>r.colors.text.muted}; + min-width: 100px; +`,So=_.span` + color: ${({theme:r})=>r.colors.text.secondary}; + word-break: break-word; +`,pw=_.button` + background: ${({theme:r})=>r.colors.brand.primary}; + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + width: 100%; + + &:hover { + background: ${({theme:r})=>r.colors.brand.hover}; + } +`;function hw({isOpen:r,onClose:i,error:s}){var T,I;if(!r)return null;const l=(T=s==null?void 0:s.response)==null?void 0:T.data,c=(l==null?void 0:l.status)||((I=s==null?void 0:s.response)==null?void 0:I.status)||"오류",f=(l==null?void 0:l.code)||"",p=(l==null?void 0:l.message)||(s==null?void 0:s.message)||"알 수 없는 오류가 발생했습니다.",g=l!=null&&l.timestamp?new Date(l.timestamp):new Date,x=ow(g),v=(l==null?void 0:l.exceptionType)||"",S=(l==null?void 0:l.details)||{},A=(l==null?void 0:l.requestId)||"";return h.jsx(iw,{onClick:i,children:h.jsxs(sw,{onClick:R=>R.stopPropagation(),children:[h.jsxs(lw,{children:[h.jsx(uw,{children:"⚠️"}),h.jsx(aw,{children:"오류가 발생했습니다"}),h.jsxs(cw,{children:[c,f?` (${f})`:""]})]}),h.jsx(fw,{children:p}),h.jsxs(dw,{children:[h.jsxs(wo,{children:[h.jsx(xo,{children:"시간:"}),h.jsx(So,{children:x})]}),A&&h.jsxs(wo,{children:[h.jsx(xo,{children:"요청 ID:"}),h.jsx(So,{children:A})]}),f&&h.jsxs(wo,{children:[h.jsx(xo,{children:"에러 코드:"}),h.jsx(So,{children:f})]}),v&&h.jsxs(wo,{children:[h.jsx(xo,{children:"예외 유형:"}),h.jsx(So,{children:v})]}),Object.keys(S).length>0&&h.jsxs(wo,{children:[h.jsx(xo,{children:"상세 정보:"}),h.jsx(So,{children:Object.entries(S).map(([R,C])=>h.jsxs("div",{children:[R,": ",String(C)]},R))})]})]}),h.jsx(pw,{onClick:i,children:"확인"})]})})}const mw=_.div` + width: 240px; + background: ${ee.colors.background.secondary}; + border-left: 1px solid ${ee.colors.border.primary}; +`,gw=_.div` + padding: 16px; + font-size: 14px; + font-weight: bold; + color: ${ee.colors.text.muted}; + text-transform: uppercase; +`,yw=_.div` + padding: 8px 16px; + display: flex; + align-items: center; + color: ${ee.colors.text.muted}; +`,vw=_(Tr)` + margin-right: 12px; +`;_(rn)``;const ww=_.div` + display: flex; + align-items: center; +`;function xw({member:r}){var l,c,f;const{binaryContents:i,fetchBinaryContent:s}=Yn();return ie.useEffect(()=>{var p;(p=r.profile)!=null&&p.id&&!i[r.profile.id]&&s(r.profile.id)},[(l=r.profile)==null?void 0:l.id,i,s]),h.jsxs(yw,{children:[h.jsxs(vw,{children:[h.jsx(rn,{src:(c=r.profile)!=null&&c.id&&((f=i[r.profile.id])==null?void 0:f.url)||zt,alt:r.username}),h.jsx(Io,{$online:r.online})]}),h.jsx(ww,{children:r.username})]})}function Sw(){const r=nn(c=>c.users),i=nn(c=>c.fetchUsers),s=vt(c=>c.currentUserId);ie.useEffect(()=>{i()},[i]);const l=[...r].sort((c,f)=>c.id===s?-1:f.id===s?1:c.online&&!f.online?-1:!c.online&&f.online?1:c.username.localeCompare(f.username));return h.jsxs(mw,{children:[h.jsxs(gw,{children:["멤버 목록 - ",r.length]}),l.map(c=>h.jsx(xw,{member:c},c.id))]})}function Ew(){const r=vt(C=>C.currentUserId),i=vt(C=>C.logout),s=nn(C=>C.users),{fetchUsers:l,updateUserStatus:c}=nn(),[f,p]=ie.useState(null),[g,x]=ie.useState(null),[v,S]=ie.useState(!1),[A,T]=ie.useState(!0),I=r?s.find(C=>C.id===r):null;ie.useEffect(()=>{(async()=>{try{if(r)try{await c(r),await l()}catch(N){console.warn("사용자 상태 업데이트 실패. 로그아웃합니다.",N),i()}}catch(N){console.error("초기화 오류:",N)}finally{T(!1)}})()},[r,c,l,i]),ie.useEffect(()=>{const C=V=>{x(V),S(!0)},N=()=>{i()},H=as.on("api-error",C),U=as.on("auth-error",N);return()=>{H("api-error",C),U("auth-error",N)}},[i]),ie.useEffect(()=>{let C;if(r){c(r),C=setInterval(()=>{c(r)},3e4);const N=setInterval(()=>{l()},6e4);return()=>{clearInterval(C),clearInterval(N)}}},[r,l,c]);const R=()=>{S(!1),x(null)};return A?h.jsx(Cd,{theme:ee,children:h.jsx(kw,{children:h.jsx(jw,{})})}):h.jsxs(Cd,{theme:ee,children:[I?h.jsxs(Cw,{children:[h.jsx(p1,{currentUser:I,activeChannel:f,onChannelSelect:p}),h.jsx(rw,{channel:f}),h.jsx(Sw,{})]}):h.jsx(h0,{isOpen:!0,onClose:()=>{}}),h.jsx(hw,{isOpen:v,onClose:R,error:g})]})}const Cw=_.div` + display: flex; + height: 100vh; + width: 100vw; + position: relative; +`,kw=_.div` + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + background-color: ${({theme:r})=>r.colors.background.primary}; +`,jw=_.div` + width: 40px; + height: 40px; + border: 4px solid ${({theme:r})=>r.colors.background.tertiary}; + border-top: 4px solid ${({theme:r})=>r.colors.brand.primary}; + border-radius: 50%; + animation: spin 1s linear infinite; + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } +`,lh=document.getElementById("root");if(!lh)throw new Error("Root element not found");hg.createRoot(lh).render(h.jsx(ie.StrictMode,{children:h.jsx(Ew,{})})); diff --git a/src/main/resources/static/assets/index-kQJbKSsj.css b/src/main/resources/static/assets/index-kQJbKSsj.css new file mode 100644 index 000000000..096eb4112 --- /dev/null +++ b/src/main/resources/static/assets/index-kQJbKSsj.css @@ -0,0 +1 @@ +:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}} diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..479bed6a3da0a8dbdd08a51d81b30e4d4fabae89 GIT binary patch literal 1588 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyacIC_6YK2V5m}KU}$JzVE6?TYIwoGP-?)y@G60U!Dv>Mu*Du8ycRt4Yw>0&$ytddU zdTHwA$vlU)7;*ZQn^d>r9eiw}SEV3v&DP3PpZVm?c2D=&D? zJg+7dT;x9cg;(mDqrovi2QemjySudY+_R1aaySb-B8!2p69!>MhFNnYfC{QST^vI! zPM@6=9?WDY()wLtM|S>=KoQ44K~Zk4us5=<8xs!eeY>~&=ly4!jD%AXj+wvro>aU~ zrMO$=?`j4U&ZyW$Je*!Zo0>H2RZVqmn^V&mZ(9Dkv!~|IuDF1RBN|EPJE zX3ok)rzF<3&vZKWEj4ag73&t}uJvVk^<~M;*V0n54#8@&v!WGjE_hAaeAZEF z$~V4aF>{^dUc7o%=f8f9m%*2vzjfI@vJ2Z97)VU5x-s2*r@e{H>FEn3A3Dr3G&8U| z)>wFiQO&|Yl6}UkXAQ>%q$jNWac-tTL*)AEyto|onkmnmcJLf?71w_<>4WODmBMxF zwGM7``txcQgT`x>(tH-DrT2Kg=4LzpNv>|+a@TgYDZ`5^$KJVb`K=%k^tRpoxP|4? zwXb!O5~dXYKYt*j(YSx+#_rP{TNcK=40T|)+k3s|?t||EQTgwGgs{E0Y+(QPL&Wx4 zMP23By&sn`zn7oCQQLp%-(Axm|M=5-u;TlFiTn5B^PWnb%fAPV8r2flh?11Vl2ohY zqEsNoU}Ruqple{LYiJr`U}|M-Vr62aZD3$!V6dZTmJ5o8-29Zxv`X9>PU+TH>UWRL)v7?M$%n`C9>lAm0fo0?Z*WfcHaTFhX${Qqu! zG&Nv5t*kOqGt)Cl7z{0q_!){?fojB&%z>&2&rB)F04ce=Mv()kL=s7fZ)R?4No7GQ z1K3si1$pWAo5K9i%<&BYs$wuSHMcY{Gc&O;(${(hEL0izk<1CstV(4taB`Zm$nFhL zDhx>~G{}=7Ei)$-=zaa%ypo*!bp5o%vdrZCykdPs#ORw@rkW)uCz=~4Cz={1nkQNs oC7PHSBpVtgnwc6|q*&+yb?5=zccWrGsMu%lboFyt=akR{0N~++#sB~S literal 0 HcmV?d00001 diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 000000000..64f0bd6e4 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,26 @@ + + + + + + Discodeit + + + + + +
+ + diff --git a/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java new file mode 100644 index 000000000..3a987a214 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DiscodeitApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/sprint/mission/discodeit/UserServiceTest.java b/src/test/java/com/sprint/mission/discodeit/UserServiceTest.java new file mode 100644 index 000000000..1e8f35138 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/UserServiceTest.java @@ -0,0 +1,182 @@ +package com.sprint.mission.discodeit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.user.UserEmailDuplicateException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.basic.BasicUserService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + + @InjectMocks + private BasicUserService userService; // UserService 구현체 + + @DisplayName("UserService.create() - 유저 생성 단위 테스트") + @Test + void user_create() { + // given + UserCreateRequest request = new UserCreateRequest( + "eunhyeok", + "eunhyeok@gmail.com", + "1234" + ); + + //중복 체크 + when(userRepository.existsByUsername(anyString())).thenReturn(false); + when(userRepository.existsByEmail(anyString())).thenReturn(false); + + UserDto userDto = new UserDto( + UUID.randomUUID(), + "eunhyeok", + "eunhyeok@gmail.com", + null, + true + ); + when(userMapper.toDto(any(User.class))).thenReturn(userDto); + + // when + UserDto result = userService.create(request, Optional.empty()); + + // then + assertAll( + "회원 가입", + () -> assertEquals(userDto.id(), result.id()), + () -> assertEquals(userDto.username(), result.username()), + () -> assertEquals(userDto.email(), result.email()), + () -> assertEquals(userDto.profile(), result.profile()), + () -> assertEquals(userDto.online(), result.online()) + ); + verify(userRepository, times(1)).save(any(User.class)); + } + + @Test + @DisplayName("유저 생성 실패") + void user_create_fail(){ + + //given + UserCreateRequest request = new UserCreateRequest( + "eunhyeok", + "eunhyeok@gmail.com", + "1234" + ); + + //이메일 중복 + when(userRepository.existsByEmail(anyString())).thenReturn(true); + + assertThrows(UserEmailDuplicateException.class, () -> userService.create(request, Optional.empty())); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("UserService.Update() - 유저 정보 수정") + void user_update() { + + //given + UserUpdateRequest request = new UserUpdateRequest( + "eunhyeok1", + "eunhyeok@naver.com", + "33221" + ); + + //기존 유저 준비 + User user = mock(User.class); + UUID userId = UUID.randomUUID(); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + //중복 체크 통과로 침 + when(userRepository.existsByUsername(anyString())).thenReturn(false); + when(userRepository.existsByEmail(anyString())).thenReturn(false); + + UserDto userDto = new UserDto( + userId, + request.newUsername(), + request.newEmail(), + null, + true + ); + //DTO 반환 + when(userMapper.toDto(any(User.class))).thenReturn(userDto); + + //when + UserDto result = userService.update(userId, request, Optional.empty()); + + //then + assertAll( + () -> assertEquals(userDto.id(), result.id()), + () -> assertEquals(userDto.username(), result.username()), + () -> assertEquals(userDto.email(), result.email()), + () -> assertEquals(userDto.profile(), result.profile()), + () -> assertEquals(userDto.online(), result.online()) + ); + } + + @Test + @DisplayName("유저를 찾을 수 없어서 업데이트 실패") + void user_update_fail() { + // given + UserUpdateRequest request = new UserUpdateRequest( + "eunhyeok", + "eunhyeok@gmail.com", + "1234" + ); + UUID userId = UUID.randomUUID(); + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + assertThrows(UserNotFoundException.class, + () -> userService.update(userId, request, Optional.empty())); + } + + @Test + @DisplayName("UserService.delete() - 유저 삭제") + void user_delete() { + //given + UUID userId = UUID.randomUUID(); + when(userRepository.existsById(userId)).thenReturn(true); + + //when + userService.delete(userId); + + //then + verify(userRepository, times(1)).deleteById(userId); + } + + @Test + @DisplayName("유저를 찾을 수 없어서 삭제 실패") + void delete_fail(){ + //given + UUID userId = UUID.randomUUID(); + when(userRepository.existsById(userId)).thenReturn(false); + + assertThrows(UserNotFoundException.class, () -> userService.delete(userId)); + verify(userRepository, never()).deleteById(userId); + } + +} \ No newline at end of file From 1ad19d81b4d54dfaeebc523c4b217fbac359ac66 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Fri, 22 Aug 2025 11:38:05 +0900 Subject: [PATCH 04/28] =?UTF-8?q?sprint8=20base=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # build.gradle # src/main/java/com/sprint/mission/discodeit/controller/AuthController.java # src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java # src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java # src/main/java/com/sprint/mission/discodeit/controller/MessageController.java # src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java # src/main/java/com/sprint/mission/discodeit/controller/UserController.java # src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java # src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java # src/main/java/com/sprint/mission/discodeit/entity/User.java # src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java # src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java # src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java # src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java # src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java # src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java # src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java # src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java # src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java # src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java # src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java # src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java # src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java # src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java # src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java # src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java # src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java # src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java # src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java # src/main/resources/application-dev.yaml # src/main/resources/application-prod.yaml # src/main/resources/application.yaml # src/main/resources/logback-spring.xml --- build.gradle | 17 +- .../config/MDCLoggingInterceptor.java | 49 +++ .../discodeit/config/WebMvcConfig.java | 24 ++ .../discodeit/controller/AuthController.java | 13 +- .../controller/BinaryContentController.java | 16 +- .../controller/ChannelController.java | 27 +- .../controller/MessageController.java | 25 +- .../controller/ReadStatusController.java | 15 +- .../discodeit/controller/UserController.java | 20 +- .../request/BinaryContentCreateRequest.java | 10 + .../discodeit/dto/request/LoginRequest.java | 5 + .../dto/request/MessageCreateRequest.java | 12 +- .../dto/request/MessageUpdateRequest.java | 5 + .../request/PrivateChannelCreateRequest.java | 9 +- .../request/PublicChannelCreateRequest.java | 8 +- .../request/PublicChannelUpdateRequest.java | 5 + .../dto/request/ReadStatusCreateRequest.java | 8 + .../dto/request/ReadStatusUpdateRequest.java | 4 + .../dto/request/UserCreateRequest.java | 22 +- .../dto/request/UserStatusCreateRequest.java | 6 + .../dto/request/UserStatusUpdateRequest.java | 4 + .../dto/request/UserUpdateRequest.java | 13 +- .../sprint/mission/discodeit/entity/User.java | 3 +- .../discodeit/entity/base/BaseEntity.java | 2 - .../exception/DiscodeitException.java | 33 +- .../discodeit/exception/ErrorCode.java | 45 ++- .../discodeit/exception/ErrorResponse.java | 32 +- .../exception/GlobalExceptionHandler.java | 84 ++-- .../binarycontent/BinaryContentException.java | 14 + .../BinaryContentNotFoundException.java | 17 + .../exception/channel/ChannelException.java | 12 +- .../channel/ChannelNotFoundException.java | 18 +- .../PrivateChannelUpdateException.java | 17 +- .../exception/message/MessageException.java | 13 +- .../message/MessageNotFoundException.java | 19 +- .../DuplicateReadStatusException.java | 18 + .../readstatus/ReadStatusException.java | 14 + .../ReadStatusNotFoundException.java | 17 + .../user/InvalidCredentialsException.java | 14 + .../user/UserAlreadyExistsException.java | 21 + .../exception/user/UserException.java | 12 +- .../exception/user/UserNotFoundException.java | 25 +- .../DuplicateUserStatusException.java | 17 + .../userstatus/UserStatusException.java | 14 + .../UserStatusNotFoundException.java | 23 ++ .../service/basic/BasicAuthService.java | 15 +- .../basic/BasicBinaryContentService.java | 34 +- .../service/basic/BasicChannelService.java | 45 +-- .../service/basic/BasicMessageService.java | 50 +-- .../service/basic/BasicReadStatusService.java | 50 ++- .../service/basic/BasicUserService.java | 59 +-- .../service/basic/BasicUserStatusService.java | 55 ++- src/main/resources/application-dev.yaml | 24 +- src/main/resources/application-prod.yaml | 33 +- src/main/resources/application.yaml | 87 ++--- src/main/resources/logback-spring.xml | 81 ++-- .../controller/AuthControllerTest.java | 121 ++++++ .../BinaryContentControllerTest.java | 149 +++++++ .../controller/ChannelControllerTest.java | 274 +++++++++++++ .../controller/MessageControllerTest.java | 304 +++++++++++++++ .../controller/ReadStatusControllerTest.java | 172 +++++++++ .../controller/UserControllerTest.java | 343 ++++++++++++++++ .../integration/AuthApiIntegrationTest.java | 133 +++++++ .../BinaryContentApiIntegrationTest.java | 209 ++++++++++ .../ChannelApiIntegrationTest.java | 269 +++++++++++++ .../MessageApiIntegrationTest.java | 307 +++++++++++++++ .../ReadStatusApiIntegrationTest.java | 266 +++++++++++++ .../integration/UserApiIntegrationTest.java | 299 ++++++++++++++ .../repository/ChannelRepositoryTest.java | 96 +++++ .../repository/MessageRepositoryTest.java | 221 +++++++++++ .../repository/ReadStatusRepositoryTest.java | 199 ++++++++++ .../repository/UserRepositoryTest.java | 138 +++++++ .../repository/UserStatusRepositoryTest.java | 118 ++++++ .../basic/BasicBinaryContentServiceTest.java | 172 +++++++++ .../basic/BasicChannelServiceTest.java | 228 +++++++++++ .../basic/BasicMessageServiceTest.java | 365 ++++++++++++++++++ .../service/basic/BasicUserServiceTest.java | 184 +++++++++ .../basic/BasicUserStatusServiceTest.java | 243 ++++++++++++ src/test/resources/application-test.yaml | 19 + 79 files changed, 5713 insertions(+), 450 deletions(-) create mode 100644 src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java create mode 100644 src/test/resources/application-test.yaml diff --git a/build.gradle b/build.gradle index b1c2d3b3b..fb91d08b8 100644 --- a/build.gradle +++ b/build.gradle @@ -2,10 +2,11 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.0' id 'io.spring.dependency-management' version '1.1.6' + id 'jacoco' } group = 'com.sprint.mission' -version = '0.0.1-SNAPSHOT' +version = '1.2-M8' java { toolchain { @@ -37,10 +38,22 @@ dependencies { annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - } tasks.named('test') { useJUnitPlatform() } + +test { + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java new file mode 100644 index 000000000..569309f8a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java @@ -0,0 +1,49 @@ +package com.sprint.mission.discodeit.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +/** + * 요청마다 MDC에 컨텍스트 정보를 추가하는 인터셉터 + */ +@Slf4j +public class MDCLoggingInterceptor implements HandlerInterceptor { + + /** + * MDC 로깅에 사용되는 상수 정의 + */ + public static final String REQUEST_ID = "requestId"; + public static final String REQUEST_METHOD = "requestMethod"; + public static final String REQUEST_URI = "requestUri"; + + public static final String REQUEST_ID_HEADER = "Discodeit-Request-ID"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 요청 ID 생성 (UUID) + String requestId = UUID.randomUUID().toString().replaceAll("-", ""); + + // MDC에 컨텍스트 정보 추가 + MDC.put(REQUEST_ID, requestId); + MDC.put(REQUEST_METHOD, request.getMethod()); + MDC.put(REQUEST_URI, request.getRequestURI()); + + // 응답 헤더에 요청 ID 추가 + response.setHeader(REQUEST_ID_HEADER, requestId); + + log.debug("Request started"); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + // 요청 처리 후 MDC 데이터 정리 + log.debug("Request completed"); + MDC.clear(); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java new file mode 100644 index 000000000..21790c7a0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 웹 MVC 설정 클래스 + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Bean + public MDCLoggingInterceptor mdcLoggingInterceptor() { + return new MDCLoggingInterceptor(); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(mdcLoggingInterceptor()) + .addPathPatterns("/**"); // 모든 경로에 적용 + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java index a2b5dc64d..8d3d2a9f9 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -4,30 +4,29 @@ import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.LoginRequest; import com.sprint.mission.discodeit.service.AuthService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.hibernate.internal.log.SubSystemLogging; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/auth") -@Slf4j public class AuthController implements AuthApi { - private static final Logger logger = LoggerFactory.getLogger(AuthService.class); private final AuthService authService; @PostMapping(path = "login") - public ResponseEntity login(@RequestBody LoginRequest loginRequest) { + public ResponseEntity login(@RequestBody @Valid LoginRequest loginRequest) { + log.info("로그인 요청: username={}", loginRequest.username()); UserDto user = authService.login(loginRequest); + log.debug("로그인 응답: {}", user); return ResponseEntity .status(HttpStatus.OK) .body(user); diff --git a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java index 9db76337e..a0b93ffde 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -15,11 +14,12 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/binaryContents") -@Slf4j public class BinaryContentController implements BinaryContentApi { private final BinaryContentService binaryContentService; @@ -28,7 +28,9 @@ public class BinaryContentController implements BinaryContentApi { @GetMapping(path = "{binaryContentId}") public ResponseEntity find( @PathVariable("binaryContentId") UUID binaryContentId) { + log.info("바이너리 컨텐츠 조회 요청: id={}", binaryContentId); BinaryContentDto binaryContent = binaryContentService.find(binaryContentId); + log.debug("바이너리 컨텐츠 조회 응답: {}", binaryContent); return ResponseEntity .status(HttpStatus.OK) .body(binaryContent); @@ -37,7 +39,9 @@ public ResponseEntity find( @GetMapping public ResponseEntity> findAllByIdIn( @RequestParam("binaryContentIds") List binaryContentIds) { + log.info("바이너리 컨텐츠 목록 조회 요청: ids={}", binaryContentIds); List binaryContents = binaryContentService.findAllByIdIn(binaryContentIds); + log.debug("바이너리 컨텐츠 목록 조회 응답: count={}", binaryContents.size()); return ResponseEntity .status(HttpStatus.OK) .body(binaryContents); @@ -46,9 +50,11 @@ public ResponseEntity> findAllByIdIn( @GetMapping(path = "{binaryContentId}/download") public ResponseEntity download( @PathVariable("binaryContentId") UUID binaryContentId) { - log.debug("BinaryContent 다운로드 요청: binaryContentId = {}", binaryContentId); + log.info("바이너리 컨텐츠 다운로드 요청: id={}", binaryContentId); BinaryContentDto binaryContentDto = binaryContentService.find(binaryContentId); - log.info("BinaryContent 다운로드 성공: id = {}", binaryContentId); - return binaryContentStorage.download(binaryContentDto); + ResponseEntity response = binaryContentStorage.download(binaryContentDto); + log.debug("바이너리 컨텐츠 다운로드 응답: contentType={}, contentLength={}", + response.getHeaders().getContentType(), response.getHeaders().getContentLength()); + return response; } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java index 2b8449dbb..3c8424236 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java @@ -10,7 +10,6 @@ import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -22,38 +21,43 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/channels") -@Slf4j public class ChannelController implements ChannelApi { private final ChannelService channelService; @PostMapping(path = "public") - public ResponseEntity create(@Valid @RequestBody PublicChannelCreateRequest request) { - log.info("public Channel 생성 요청 : name = {}", request.name()); + public ResponseEntity create(@RequestBody @Valid PublicChannelCreateRequest request) { + log.info("공개 채널 생성 요청: {}", request); ChannelDto createdChannel = channelService.create(request); + log.debug("공개 채널 생성 응답: {}", createdChannel); return ResponseEntity .status(HttpStatus.CREATED) .body(createdChannel); } @PostMapping(path = "private") - public ResponseEntity create(@Valid @RequestBody PrivateChannelCreateRequest request) { - log.info("private Channel 생성 요청 : participantIds = {}", request.participantIds()); + public ResponseEntity create(@RequestBody @Valid PrivateChannelCreateRequest request) { + log.info("비공개 채널 생성 요청: {}", request); ChannelDto createdChannel = channelService.create(request); + log.debug("비공개 채널 생성 응답: {}", createdChannel); return ResponseEntity .status(HttpStatus.CREATED) .body(createdChannel); } @PatchMapping(path = "{channelId}") - public ResponseEntity update(@PathVariable("channelId") UUID channelId, - @RequestBody PublicChannelUpdateRequest request) { - log.info("public Channel 생성 요청 : newName = {} newDescription = {}", request.newName(), request.newDescription()); + public ResponseEntity update( + @PathVariable("channelId") UUID channelId, + @RequestBody @Valid PublicChannelUpdateRequest request) { + log.info("채널 수정 요청: id={}, request={}", channelId, request); ChannelDto updatedChannel = channelService.update(channelId, request); + log.debug("채널 수정 응답: {}", updatedChannel); return ResponseEntity .status(HttpStatus.OK) .body(updatedChannel); @@ -61,8 +65,9 @@ public ResponseEntity update(@PathVariable("channelId") UUID channel @DeleteMapping(path = "{channelId}") public ResponseEntity delete(@PathVariable("channelId") UUID channelId) { - log.info("public Channel 삭제 요청 : channelId = {}", channelId); + log.info("채널 삭제 요청: id={}", channelId); channelService.delete(channelId); + log.debug("채널 삭제 완료"); return ResponseEntity .status(HttpStatus.NO_CONTENT) .build(); @@ -70,7 +75,9 @@ public ResponseEntity delete(@PathVariable("channelId") UUID channelId) { @GetMapping public ResponseEntity> findAll(@RequestParam("userId") UUID userId) { + log.info("사용자별 채널 목록 조회 요청: userId={}", userId); List channels = channelService.findAllByUserId(userId); + log.debug("사용자별 채널 목록 조회 응답: count={}", channels.size()); return ResponseEntity .status(HttpStatus.OK) .body(channels); diff --git a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java index 878183a5d..5f7777d02 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java @@ -15,7 +15,6 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort.Direction; import org.springframework.data.web.PageableDefault; @@ -33,21 +32,24 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/messages") -@Slf4j public class MessageController implements MessageApi { private final MessageService messageService; @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity create( - @Valid @RequestPart("messageCreateRequest") MessageCreateRequest messageCreateRequest, + @RequestPart("messageCreateRequest") @Valid MessageCreateRequest messageCreateRequest, @RequestPart(value = "attachments", required = false) List attachments ) { - log.info("message 생성 요청 : content = {}", messageCreateRequest.content()); + log.info("메시지 생성 요청: request={}, attachmentCount={}", + messageCreateRequest, attachments != null ? attachments.size() : 0); + List attachmentRequests = Optional.ofNullable(attachments) .map(files -> files.stream() .map(file -> { @@ -64,16 +66,19 @@ public ResponseEntity create( .toList()) .orElse(new ArrayList<>()); MessageDto createdMessage = messageService.create(messageCreateRequest, attachmentRequests); + log.debug("메시지 생성 응답: {}", createdMessage); return ResponseEntity .status(HttpStatus.CREATED) .body(createdMessage); } @PatchMapping(path = "{messageId}") - public ResponseEntity update(@PathVariable("messageId") UUID messageId, - @RequestBody MessageUpdateRequest request) { - log.info("message 수정 요청 : id = {}, newContent = {}", messageId, request.newContent()); + public ResponseEntity update( + @PathVariable("messageId") UUID messageId, + @RequestBody @Valid MessageUpdateRequest request) { + log.info("메시지 수정 요청: id={}, request={}", messageId, request); MessageDto updatedMessage = messageService.update(messageId, request); + log.debug("메시지 수정 응답: {}", updatedMessage); return ResponseEntity .status(HttpStatus.OK) .body(updatedMessage); @@ -81,8 +86,9 @@ public ResponseEntity update(@PathVariable("messageId") UUID message @DeleteMapping(path = "{messageId}") public ResponseEntity delete(@PathVariable("messageId") UUID messageId) { - log.info("message 삭제 요청 : messageId = {}", messageId); + log.info("메시지 삭제 요청: id={}", messageId); messageService.delete(messageId); + log.debug("메시지 삭제 완료"); return ResponseEntity .status(HttpStatus.NO_CONTENT) .build(); @@ -98,8 +104,11 @@ public ResponseEntity> findAllByChannelId( sort = "createdAt", direction = Direction.DESC ) Pageable pageable) { + log.info("채널별 메시지 목록 조회 요청: channelId={}, cursor={}, pageable={}", + channelId, cursor, pageable); PageResponse messages = messageService.findAllByChannelId(channelId, cursor, pageable); + log.debug("채널별 메시지 목록 조회 응답: totalElements={}", messages.totalElements()); return ResponseEntity .status(HttpStatus.OK) .body(messages); diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java index fef7e88c3..ac980c066 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java @@ -5,10 +5,10 @@ import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; import com.sprint.mission.discodeit.service.ReadStatusService; +import jakarta.validation.Valid; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -19,18 +19,21 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/readStatuses") -@Slf4j public class ReadStatusController implements ReadStatusApi { private final ReadStatusService readStatusService; @PostMapping - public ResponseEntity create(@RequestBody ReadStatusCreateRequest request) { + public ResponseEntity create(@RequestBody @Valid ReadStatusCreateRequest request) { + log.info("읽음 상태 생성 요청: {}", request); ReadStatusDto createdReadStatus = readStatusService.create(request); + log.debug("읽음 상태 생성 응답: {}", createdReadStatus); return ResponseEntity .status(HttpStatus.CREATED) .body(createdReadStatus); @@ -38,8 +41,10 @@ public ResponseEntity create(@RequestBody ReadStatusCreateRequest @PatchMapping(path = "{readStatusId}") public ResponseEntity update(@PathVariable("readStatusId") UUID readStatusId, - @RequestBody ReadStatusUpdateRequest request) { + @RequestBody @Valid ReadStatusUpdateRequest request) { + log.info("읽음 상태 수정 요청: id={}, request={}", readStatusId, request); ReadStatusDto updatedReadStatus = readStatusService.update(readStatusId, request); + log.debug("읽음 상태 수정 응답: {}", updatedReadStatus); return ResponseEntity .status(HttpStatus.OK) .body(updatedReadStatus); @@ -47,7 +52,9 @@ public ResponseEntity update(@PathVariable("readStatusId") UUID r @GetMapping public ResponseEntity> findAllByUserId(@RequestParam("userId") UUID userId) { + log.info("사용자별 읽음 상태 목록 조회 요청: userId={}", userId); List readStatuses = readStatusService.findAllByUserId(userId); + log.debug("사용자별 읽음 상태 목록 조회 응답: count={}", readStatuses.size()); return ResponseEntity .status(HttpStatus.OK) .body(readStatuses); diff --git a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java index 5e8ecb5e1..46bb8a445 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java @@ -15,7 +15,6 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -29,11 +28,12 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/users") -@Slf4j public class UserController implements UserApi { private final UserService userService; @@ -42,13 +42,14 @@ public class UserController implements UserApi { @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) @Override public ResponseEntity create( - @Valid @RequestPart("userCreateRequest") UserCreateRequest userCreateRequest, + @RequestPart("userCreateRequest") @Valid UserCreateRequest userCreateRequest, @RequestPart(value = "profile", required = false) MultipartFile profile ) { - log.info("User 생성 요청 : username = {}",userCreateRequest.username()); + log.info("사용자 생성 요청: {}", userCreateRequest); Optional profileRequest = Optional.ofNullable(profile) .flatMap(this::resolveProfileRequest); UserDto createdUser = userService.create(userCreateRequest, profileRequest); + log.debug("사용자 생성 응답: {}", createdUser); return ResponseEntity .status(HttpStatus.CREATED) .body(createdUser); @@ -61,13 +62,14 @@ public ResponseEntity create( @Override public ResponseEntity update( @PathVariable("userId") UUID userId, - @Valid @RequestPart("userUpdateRequest") UserUpdateRequest userUpdateRequest, + @RequestPart("userUpdateRequest") @Valid UserUpdateRequest userUpdateRequest, @RequestPart(value = "profile", required = false) MultipartFile profile ) { - log.info("User 수정 요청 : newUsername = {}",userUpdateRequest.newUsername()); + log.info("사용자 수정 요청: id={}, request={}", userId, userUpdateRequest); Optional profileRequest = Optional.ofNullable(profile) .flatMap(this::resolveProfileRequest); UserDto updatedUser = userService.update(userId, userUpdateRequest, profileRequest); + log.debug("사용자 수정 응답: {}", updatedUser); return ResponseEntity .status(HttpStatus.OK) .body(updatedUser); @@ -76,7 +78,6 @@ public ResponseEntity update( @DeleteMapping(path = "{userId}") @Override public ResponseEntity delete(@PathVariable("userId") UUID userId) { - log.info("User 삭제 요청 : userId = {}",userId); userService.delete(userId); return ResponseEntity .status(HttpStatus.NO_CONTENT) @@ -94,8 +95,9 @@ public ResponseEntity> findAll() { @PatchMapping(path = "{userId}/userStatus") @Override - public ResponseEntity updateUserStatusByUserId(@PathVariable("userId") UUID userId, - @RequestBody UserStatusUpdateRequest request) { + public ResponseEntity updateUserStatusByUserId( + @PathVariable("userId") UUID userId, + @RequestBody @Valid UserStatusUpdateRequest request) { UserStatusDto updatedUserStatus = userStatusService.updateByUserId(userId, request); return ResponseEntity .status(HttpStatus.OK) diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java index d86eb9898..402239697 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java @@ -1,8 +1,18 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + public record BinaryContentCreateRequest( + @NotBlank(message = "파일 이름은 필수입니다") + @Size(max = 255, message = "파일 이름은 255자 이하여야 합니다") String fileName, + + @NotBlank(message = "콘텐츠 타입은 필수입니다") String contentType, + + @NotNull(message = "파일 데이터는 필수입니다") byte[] bytes ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java index 51ca9e620..40452eea2 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java @@ -1,7 +1,12 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotBlank; + public record LoginRequest( + @NotBlank(message = "사용자 이름은 필수입니다") String username, + + @NotBlank(message = "비밀번호는 필수입니다") String password ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java index 21d08449b..366539aee 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java @@ -1,15 +1,19 @@ package com.sprint.mission.discodeit.dto.request; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.util.UUID; public record MessageCreateRequest( - - @NotBlank(message = "메세지 내용은 필수입니다.") + @NotBlank(message = "메시지 내용은 필수입니다") + @Size(max = 2000, message = "메시지 내용은 2000자 이하여야 합니다") String content, - @NotBlank(message = "채널 ID는 필수입니다.") + + @NotNull(message = "채널 ID는 필수입니다") UUID channelId, - @NotBlank(message = "사용자 ID는 필수입니다.") + + @NotNull(message = "작성자 ID는 필수입니다") UUID authorId ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java index d786b1e8c..792ef27c2 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java @@ -1,6 +1,11 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + public record MessageUpdateRequest( + @NotBlank(message = "메시지 내용은 필수입니다") + @Size(max = 2000, message = "메시지 내용은 2000자 이하여야 합니다") String newContent ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java index 224effde8..478cf4e32 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java @@ -1,12 +1,15 @@ package com.sprint.mission.discodeit.dto.request; -import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.util.List; import java.util.UUID; public record PrivateChannelCreateRequest( - - @NotBlank(message = "참여자는 필수입니다.") + @NotNull(message = "참여자 목록은 필수입니다") + @NotEmpty(message = "참여자 목록은 비어있을 수 없습니다") + @Size(min = 2, message = "비공개 채널에는 최소 2명의 참여자가 필요합니다") List participantIds ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java index a944bd951..e2e284a02 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java @@ -4,11 +4,11 @@ import jakarta.validation.constraints.Size; public record PublicChannelCreateRequest( - - @NotBlank(message = "채널 이름은 필수입니다.") + @NotBlank(message = "채널명은 필수입니다") + @Size(min = 2, max = 50, message = "채널명은 2자 이상 50자 이하여야 합니다") String name, - - @NotBlank(message = "채널 설명은 필수입니다.") + + @Size(max = 255, message = "채널 설명은 255자 이하여야 합니다") String description ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java index d6e515410..e438f761c 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java @@ -1,7 +1,12 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.Size; + public record PublicChannelUpdateRequest( + @Size(min = 2, max = 50, message = "채널명은 2자 이상 50자 이하여야 합니다") String newName, + + @Size(max = 255, message = "채널 설명은 255자 이하여야 합니다") String newDescription ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java index 046a48808..f7f485199 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java @@ -1,11 +1,19 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; import java.time.Instant; import java.util.UUID; public record ReadStatusCreateRequest( + @NotNull(message = "사용자 ID는 필수입니다") UUID userId, + + @NotNull(message = "채널 ID는 필수입니다") UUID channelId, + + @NotNull(message = "마지막 읽은 시간은 필수입니다") + @PastOrPresent(message = "마지막 읽은 시간은 현재 또는 과거 시간이어야 합니다") Instant lastReadAt ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java index 16b0c27ce..de197a07f 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java @@ -1,8 +1,12 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; import java.time.Instant; public record ReadStatusUpdateRequest( + @NotNull(message = "새로운 마지막 읽은 시간은 필수입니다") + @PastOrPresent(message = "마지막 읽은 시간은 현재 또는 과거 시간이어야 합니다") Instant newLastReadAt ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java index 2e63dd982..a8c888423 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java @@ -2,21 +2,23 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public record UserCreateRequest( - - @NotBlank(message = "유저 이름은 필수입니다.") - @Size(min = 2, max = 20, message = "유저 이름은 2~20자 여야합니다.") + @NotBlank(message = "사용자 이름은 필수입니다") + @Size(min = 3, max = 50, message = "사용자 이름은 3자 이상 50자 이하여야 합니다") String username, - - @NotBlank(message = "이메일은 필수입니다.") - @Email(message = "올바른 형식이여야 합니다.") + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "유효한 이메일 형식이어야 합니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") String email, - - @NotBlank(message = "비밀번호는 필수입니다.") - @Size(min = 8, message = "비밀번호는 8자리 이상이어야 합니다.") + + @NotBlank(message = "비밀번호는 필수입니다") + @Size(min = 8, max = 60, message = "비밀번호는 8자 이상 60자 이하여야 합니다") + @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{8,}$", + message = "비밀번호는 최소 8자 이상, 숫자, 문자, 특수문자를 포함해야 합니다") String password ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java index 71c92abba..2d3970adb 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java @@ -1,10 +1,16 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; import java.time.Instant; import java.util.UUID; public record UserStatusCreateRequest( + @NotNull(message = "사용자 ID는 필수입니다") UUID userId, + + @NotNull(message = "마지막 활동 시간은 필수입니다") + @PastOrPresent(message = "마지막 활동 시간은 현재 또는 과거 시간이어야 합니다") Instant lastActiveAt ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java index c69b2610f..6556ae56c 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java @@ -1,8 +1,12 @@ package com.sprint.mission.discodeit.dto.request; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; import java.time.Instant; public record UserStatusUpdateRequest( + @NotNull(message = "마지막 활동 시간은 필수입니다") + @PastOrPresent(message = "마지막 활동 시간은 현재 또는 과거 시간이어야 합니다") Instant newLastActiveAt ) { diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java index ed91288cc..19e271309 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java @@ -1,15 +1,20 @@ package com.sprint.mission.discodeit.dto.request; import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public record UserUpdateRequest( + @Size(min = 3, max = 50, message = "사용자 이름은 3자 이상 50자 이하여야 합니다") String newUsername, - - @Email(message = "올바른 형식이여야 합니다.") + + @Email(message = "유효한 이메일 형식이어야 합니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") String newEmail, - - @Size(min = 8, message = "비밀번호는 최소 8자리여야 합니다.") + + @Size(min = 8, max = 60, message = "비밀번호는 8자 이상 60자 이하여야 합니다") + @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{8,}$", + message = "비밀번호는 최소 8자 이상, 숫자, 문자, 특수문자를 포함해야 합니다") String newPassword ) { diff --git a/src/main/java/com/sprint/mission/discodeit/entity/User.java b/src/main/java/com/sprint/mission/discodeit/entity/User.java index e26aea1b5..7961aaecc 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/User.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/User.java @@ -17,8 +17,7 @@ @Entity @Table(name = "users") @Getter -@Setter -@NoArgsConstructor // JPA를 위한 기본 생성자 +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA를 위한 기본 생성자 public class User extends BaseUpdatableEntity { @Column(length = 50, nullable = false, unique = true) diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java index 1a140a6ba..f28210164 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java @@ -11,12 +11,10 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Getter -@Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @MappedSuperclass @EntityListeners(AuditingEntityListener.class) diff --git a/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java index 4aab2b3dc..d929a51f8 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java @@ -1,19 +1,32 @@ package com.sprint.mission.discodeit.exception; import java.time.Instant; +import java.util.HashMap; import java.util.Map; + import lombok.Getter; @Getter public class DiscodeitException extends RuntimeException { - private final Instant timestamp; - private final ErrorCode errorCode; - private final Map details; + private final Instant timestamp; + private final ErrorCode errorCode; + private final Map details; + + public DiscodeitException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + public DiscodeitException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } - public DiscodeitException(ErrorCode errorCode, Map details) { - super(errorCode.getMessage()); - this.timestamp = Instant.now(); - this.errorCode = errorCode; - this.details = details; - } -} + public void addDetail(String key, Object value) { + this.details.put(key, value); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java index eb3dfe01b..e8dc58033 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java @@ -1,20 +1,39 @@ package com.sprint.mission.discodeit.exception; import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; @Getter -@RequiredArgsConstructor public enum ErrorCode { - USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "유저를 찾을 수 없습니다.", "해당 유저를 찾을 수 없습니다."), - DUPLICATE_USER(HttpStatus.CONFLICT.value(), "잘못된 요청 입니다.","유저이름이 이미 존재합니다." ), - DUPLICATE_EMAIL(HttpStatus.CONFLICT.value(), "잘못된 요청 입니다.", "이메일이 이미 존재합니다"), - CHANNEL_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "채널을 찾을 수 없습니다.", "해당 채널을 찾을 수 없습니다."), - PRIVATE_CHANNEL_NOT_UPDATE(HttpStatus.BAD_REQUEST.value(), "채널을 수정할 수 없습니다.", "비공개 채널은 수정할 수 없습니다."), - MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "메세지를 찾을 수 없습니다", "해당 메세지를 찾을 수 없습니다."); + // User 관련 에러 코드 + USER_NOT_FOUND("사용자를 찾을 수 없습니다."), + DUPLICATE_USER("이미 존재하는 사용자입니다."), + INVALID_USER_CREDENTIALS("잘못된 사용자 인증 정보입니다."), + + // Channel 관련 에러 코드 + CHANNEL_NOT_FOUND("채널을 찾을 수 없습니다."), + PRIVATE_CHANNEL_UPDATE("비공개 채널은 수정할 수 없습니다."), + + // Message 관련 에러 코드 + MESSAGE_NOT_FOUND("메시지를 찾을 수 없습니다."), + + // BinaryContent 관련 에러 코드 + BINARY_CONTENT_NOT_FOUND("바이너리 컨텐츠를 찾을 수 없습니다."), + + // ReadStatus 관련 에러 코드 + READ_STATUS_NOT_FOUND("읽음 상태를 찾을 수 없습니다."), + DUPLICATE_READ_STATUS("이미 존재하는 읽음 상태입니다."), + + // UserStatus 관련 에러 코드 + USER_STATUS_NOT_FOUND("사용자 상태를 찾을 수 없습니다."), + DUPLICATE_USER_STATUS("이미 존재하는 사용자 상태입니다."), + + // Server 에러 코드 + INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다."), + INVALID_REQUEST("잘못된 요청입니다."); - private final int status; - private final String message; - private final String detail; -} + private final String message; + + ErrorCode(String message) { + this.message = message; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java index 325ee534f..6a9ae50ef 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java @@ -1,21 +1,27 @@ package com.sprint.mission.discodeit.exception; import java.time.Instant; +import java.util.HashMap; import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Builder; + import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; @Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder +@RequiredArgsConstructor public class ErrorResponse { - private Instant timestamp; - private String code; - private String message; - private Map details; - private String exceptionType; - private int status; -} + private final Instant timestamp; + private final String code; + private final String message; + private final Map details; + private final String exceptionType; + private final int status; + + public ErrorResponse(DiscodeitException exception, int status) { + this(Instant.now(), exception.getErrorCode().name(), exception.getMessage(), exception.getDetails(), exception.getClass().getSimpleName(), status); + } + + public ErrorResponse(Exception exception, int status) { + this(Instant.now(), exception.getClass().getSimpleName(), exception.getMessage(), new HashMap<>(), exception.getClass().getSimpleName(), status); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java index b8e4594f8..f5ecc566a 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -1,10 +1,9 @@ package com.sprint.mission.discodeit.exception; -import com.sprint.mission.discodeit.exception.user.UserException; import java.time.Instant; import java.util.HashMap; import java.util.Map; -import java.util.NoSuchElementException; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; @@ -12,46 +11,65 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(DiscodeitException.class) - public ResponseEntity handleDiscodeitException(DiscodeitException e) { - ErrorCode errorCode = e.getErrorCode(); - - ErrorResponse errorResponse = ErrorResponse.builder() - .status(errorCode.getStatus()) - .message(errorCode.getMessage()) - .details(e.getDetails()) - .exceptionType(e.getClass().getName()) - .status(errorCode.getStatus()) - .build(); - + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error("예상치 못한 오류 발생: {}", e.getMessage(), e); + ErrorResponse errorResponse = new ErrorResponse(e, HttpStatus.INTERNAL_SERVER_ERROR.value()); return ResponseEntity - .status(e.getErrorCode().getStatus()) + .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(errorResponse); } + @ExceptionHandler(DiscodeitException.class) + public ResponseEntity handleDiscodeitException(DiscodeitException exception) { + log.error("커스텀 예외 발생: code={}, message={}", exception.getErrorCode(), exception.getMessage(), exception); + HttpStatus status = determineHttpStatus(exception); + ErrorResponse response = new ErrorResponse(exception, status.value()); + return ResponseEntity + .status(status) + .body(response); + } + @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidationExceptions( - MethodArgumentNotValidException ex) { - - Map details = new HashMap<>(); - for (FieldError error : ex.getBindingResult().getFieldErrors()) { - details.put(error.getField(), error.getDefaultMessage()); - } - - ErrorResponse errorResponse = ErrorResponse.builder() - .timestamp(Instant.now()) - .status(HttpStatus.BAD_REQUEST.value()) - .exceptionType(ex.getClass().getSimpleName()) - .code("INVALID_REQUEST") - .message("유효성 검증 실패") - .details(details) - .build(); - - return ResponseEntity.badRequest().body(errorResponse); + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { + log.error("요청 유효성 검사 실패: {}", ex.getMessage()); + + Map validationErrors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + validationErrors.put(fieldName, errorMessage); + }); + + ErrorResponse response = new ErrorResponse( + Instant.now(), + "VALIDATION_ERROR", + "요청 데이터 유효성 검사에 실패했습니다", + validationErrors, + ex.getClass().getSimpleName(), + HttpStatus.BAD_REQUEST.value() + ); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); } + private HttpStatus determineHttpStatus(DiscodeitException exception) { + ErrorCode errorCode = exception.getErrorCode(); + return switch (errorCode) { + case USER_NOT_FOUND, CHANNEL_NOT_FOUND, MESSAGE_NOT_FOUND, BINARY_CONTENT_NOT_FOUND, + READ_STATUS_NOT_FOUND, USER_STATUS_NOT_FOUND -> HttpStatus.NOT_FOUND; + case DUPLICATE_USER, DUPLICATE_READ_STATUS, DUPLICATE_USER_STATUS -> HttpStatus.CONFLICT; + case INVALID_USER_CREDENTIALS -> HttpStatus.UNAUTHORIZED; + case PRIVATE_CHANNEL_UPDATE, INVALID_REQUEST -> HttpStatus.BAD_REQUEST; + case INTERNAL_SERVER_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } } diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java new file mode 100644 index 000000000..368025bf2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.binarycontent; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class BinaryContentException extends DiscodeitException { + public BinaryContentException(ErrorCode errorCode) { + super(errorCode); + } + + public BinaryContentException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java new file mode 100644 index 000000000..65ad82363 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.binarycontent; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class BinaryContentNotFoundException extends BinaryContentException { + public BinaryContentNotFoundException() { + super(ErrorCode.BINARY_CONTENT_NOT_FOUND); + } + + public static BinaryContentNotFoundException withId(UUID binaryContentId) { + BinaryContentNotFoundException exception = new BinaryContentNotFoundException(); + exception.addDetail("binaryContentId", binaryContentId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java index 781ffe2e7..1ba3364ba 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java @@ -2,11 +2,13 @@ import com.sprint.mission.discodeit.exception.DiscodeitException; import com.sprint.mission.discodeit.exception.ErrorCode; -import java.util.Map; public class ChannelException extends DiscodeitException { + public ChannelException(ErrorCode errorCode) { + super(errorCode); + } - public ChannelException(ErrorCode errorCode, Map details) { - super(errorCode, details); - } -} + public ChannelException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java index c8f693d73..ec7b1f335 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java @@ -1,11 +1,17 @@ package com.sprint.mission.discodeit.exception.channel; -import com.sprint.mission.discodeit.exception.ErrorCode; -import java.util.Map; import java.util.UUID; +import com.sprint.mission.discodeit.exception.ErrorCode; + public class ChannelNotFoundException extends ChannelException { - public ChannelNotFoundException(UUID channelId) { - super(ErrorCode.CHANNEL_NOT_FOUND, Map.of("channelId", channelId)); - } -} + public ChannelNotFoundException() { + super(ErrorCode.CHANNEL_NOT_FOUND); + } + + public static ChannelNotFoundException withId(UUID channelId) { + ChannelNotFoundException exception = new ChannelNotFoundException(); + exception.addDetail("channelId", channelId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java index 9babb138d..2b8b1597c 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java @@ -1,12 +1,17 @@ package com.sprint.mission.discodeit.exception.channel; import com.sprint.mission.discodeit.exception.ErrorCode; -import java.util.Map; + import java.util.UUID; public class PrivateChannelUpdateException extends ChannelException { - - public PrivateChannelUpdateException(UUID channelId) { - super(ErrorCode.PRIVATE_CHANNEL_NOT_UPDATE, Map.of("channelId", channelId)); - } -} + public PrivateChannelUpdateException() { + super(ErrorCode.PRIVATE_CHANNEL_UPDATE); + } + + public static PrivateChannelUpdateException forChannel(UUID channelId) { + PrivateChannelUpdateException exception = new PrivateChannelUpdateException(); + exception.addDetail("channelId", channelId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java index d1fea2bc2..289922ed3 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java @@ -2,10 +2,13 @@ import com.sprint.mission.discodeit.exception.DiscodeitException; import com.sprint.mission.discodeit.exception.ErrorCode; -import java.util.Map; public class MessageException extends DiscodeitException { - public MessageException(ErrorCode errorCode, Map details) { - super(errorCode, details); - } -} + public MessageException(ErrorCode errorCode) { + super(errorCode); + } + + public MessageException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java index 9393f1f52..423aafbb3 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java @@ -1,12 +1,17 @@ package com.sprint.mission.discodeit.exception.message; import com.sprint.mission.discodeit.exception.ErrorCode; -import java.util.Map; -import java.util.UUID; -public class MessageNotFoundException extends MessageException{ +import java.util.UUID; - public MessageNotFoundException(UUID messageId) { - super(ErrorCode.MESSAGE_NOT_FOUND, Map.of("messageId", messageId)); - } -} +public class MessageNotFoundException extends MessageException { + public MessageNotFoundException() { + super(ErrorCode.MESSAGE_NOT_FOUND); + } + + public static MessageNotFoundException withId(UUID messageId) { + MessageNotFoundException exception = new MessageNotFoundException(); + exception.addDetail("messageId", messageId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java new file mode 100644 index 000000000..5a30692d8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class DuplicateReadStatusException extends ReadStatusException { + public DuplicateReadStatusException() { + super(ErrorCode.DUPLICATE_READ_STATUS); + } + + public static DuplicateReadStatusException withUserIdAndChannelId(UUID userId, UUID channelId) { + DuplicateReadStatusException exception = new DuplicateReadStatusException(); + exception.addDetail("userId", userId); + exception.addDetail("channelId", channelId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java new file mode 100644 index 000000000..3860caf2e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class ReadStatusException extends DiscodeitException { + public ReadStatusException(ErrorCode errorCode) { + super(errorCode); + } + + public ReadStatusException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java new file mode 100644 index 000000000..86b9fde75 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class ReadStatusNotFoundException extends ReadStatusException { + public ReadStatusNotFoundException() { + super(ErrorCode.READ_STATUS_NOT_FOUND); + } + + public static ReadStatusNotFoundException withId(UUID readStatusId) { + ReadStatusNotFoundException exception = new ReadStatusNotFoundException(); + exception.addDetail("readStatusId", readStatusId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java new file mode 100644 index 000000000..d75576fdf --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class InvalidCredentialsException extends UserException { + public InvalidCredentialsException() { + super(ErrorCode.INVALID_USER_CREDENTIALS); + } + + public static InvalidCredentialsException wrongPassword() { + InvalidCredentialsException exception = new InvalidCredentialsException(); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java new file mode 100644 index 000000000..9d0b3b3d1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class UserAlreadyExistsException extends UserException { + public UserAlreadyExistsException() { + super(ErrorCode.DUPLICATE_USER); + } + + public static UserAlreadyExistsException withEmail(String email) { + UserAlreadyExistsException exception = new UserAlreadyExistsException(); + exception.addDetail("email", email); + return exception; + } + + public static UserAlreadyExistsException withUsername(String username) { + UserAlreadyExistsException exception = new UserAlreadyExistsException(); + exception.addDetail("username", username); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java index 58ed3b686..f48629706 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java @@ -2,11 +2,13 @@ import com.sprint.mission.discodeit.exception.DiscodeitException; import com.sprint.mission.discodeit.exception.ErrorCode; -import java.util.Map; public class UserException extends DiscodeitException { + public UserException(ErrorCode errorCode) { + super(errorCode); + } - public UserException(ErrorCode errorCode, Map details) { - super(errorCode, details); - } -} + public UserException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java index 3c391b9af..bd76dfa9e 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java @@ -1,12 +1,23 @@ package com.sprint.mission.discodeit.exception.user; -import com.sprint.mission.discodeit.exception.ErrorCode; -import java.util.Map; import java.util.UUID; -public class UserNotFoundException extends UserException { +import com.sprint.mission.discodeit.exception.ErrorCode; - public UserNotFoundException(UUID userId) { - super(ErrorCode.USER_NOT_FOUND, Map.of("userId", userId)); - } -} +public class UserNotFoundException extends UserException { + public UserNotFoundException() { + super(ErrorCode.USER_NOT_FOUND); + } + + public static UserNotFoundException withId(UUID userId) { + UserNotFoundException exception = new UserNotFoundException(); + exception.addDetail("userId", userId); + return exception; + } + + public static UserNotFoundException withUsername(String username) { + UserNotFoundException exception = new UserNotFoundException(); + exception.addDetail("username", username); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java new file mode 100644 index 000000000..04978a2e2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class DuplicateUserStatusException extends UserStatusException { + public DuplicateUserStatusException() { + super(ErrorCode.DUPLICATE_USER_STATUS); + } + + public static DuplicateUserStatusException withUserId(UUID userId) { + DuplicateUserStatusException exception = new DuplicateUserStatusException(); + exception.addDetail("userId", userId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java new file mode 100644 index 000000000..1a45a3d08 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class UserStatusException extends DiscodeitException { + public UserStatusException(ErrorCode errorCode) { + super(errorCode); + } + + public UserStatusException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java new file mode 100644 index 000000000..199fca795 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class UserStatusNotFoundException extends UserStatusException { + public UserStatusNotFoundException() { + super(ErrorCode.USER_STATUS_NOT_FOUND); + } + + public static UserStatusNotFoundException withId(UUID userStatusId) { + UserStatusNotFoundException exception = new UserStatusNotFoundException(); + exception.addDetail("userStatusId", userStatusId); + return exception; + } + + public static UserStatusNotFoundException withUserId(UUID userId) { + UserStatusNotFoundException exception = new UserStatusNotFoundException(); + exception.addDetail("userId", userId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java index 80e9cf8bb..6785cff2f 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -3,18 +3,19 @@ import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.LoginRequest; import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.user.InvalidCredentialsException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.service.AuthService; -import java.util.NoSuchElementException; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor @Service -@Slf4j public class BasicAuthService implements AuthService { private final UserRepository userRepository; @@ -23,17 +24,19 @@ public class BasicAuthService implements AuthService { @Transactional(readOnly = true) @Override public UserDto login(LoginRequest loginRequest) { + log.debug("로그인 시도: username={}", loginRequest.username()); + String username = loginRequest.username(); String password = loginRequest.password(); User user = userRepository.findByUsername(username) - .orElseThrow( - () -> new NoSuchElementException("User with username " + username + " not found")); + .orElseThrow(() -> UserNotFoundException.withUsername(username)); if (!user.getPassword().equals(password)) { - throw new IllegalArgumentException("Wrong password"); + throw InvalidCredentialsException.wrongPassword(); } + log.info("로그인 성공: userId={}, username={}", user.getId(), username); return userMapper.toDto(user); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java index f2354cda9..bd50ce57d 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java @@ -3,21 +3,21 @@ import com.sprint.mission.discodeit.dto.data.BinaryContentDto; import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; import com.sprint.mission.discodeit.mapper.BinaryContentMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; import com.sprint.mission.discodeit.service.BinaryContentService; import com.sprint.mission.discodeit.storage.BinaryContentStorage; import java.util.List; -import java.util.NoSuchElementException; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @RequiredArgsConstructor @Service -@Slf4j public class BasicBinaryContentService implements BinaryContentService { private final BinaryContentRepository binaryContentRepository; @@ -27,8 +27,9 @@ public class BasicBinaryContentService implements BinaryContentService { @Transactional @Override public BinaryContentDto create(BinaryContentCreateRequest request) { - log.debug("BinaryContent 생성 요청: fileName = {}, contentType = {}, size = {} bytes", - request.fileName(), request.contentType(), request.bytes().length); + log.debug("바이너리 컨텐츠 생성 시작: fileName={}, size={}, contentType={}", + request.fileName(), request.bytes().length, request.contentType()); + String fileName = request.fileName(); byte[] bytes = request.bytes(); String contentType = request.contentType(); @@ -38,37 +39,42 @@ public BinaryContentDto create(BinaryContentCreateRequest request) { contentType ); binaryContentRepository.save(binaryContent); - log.info("BinaryContent 생성 성공: id = {}, fileName = {}", - binaryContent.getId(), binaryContent.getFileName()); binaryContentStorage.put(binaryContent.getId(), bytes); + log.info("바이너리 컨텐츠 생성 완료: id={}, fileName={}, size={}", + binaryContent.getId(), fileName, bytes.length); return binaryContentMapper.toDto(binaryContent); } @Override public BinaryContentDto find(UUID binaryContentId) { - return binaryContentRepository.findById(binaryContentId) + log.debug("바이너리 컨텐츠 조회 시작: id={}", binaryContentId); + BinaryContentDto dto = binaryContentRepository.findById(binaryContentId) .map(binaryContentMapper::toDto) - .orElseThrow(() -> new NoSuchElementException( - "BinaryContent with id " + binaryContentId + " not found")); + .orElseThrow(() -> BinaryContentNotFoundException.withId(binaryContentId)); + log.info("바이너리 컨텐츠 조회 완료: id={}, fileName={}", + dto.id(), dto.fileName()); + return dto; } @Override public List findAllByIdIn(List binaryContentIds) { - return binaryContentRepository.findAllById(binaryContentIds).stream() + log.debug("바이너리 컨텐츠 목록 조회 시작: ids={}", binaryContentIds); + List dtos = binaryContentRepository.findAllById(binaryContentIds).stream() .map(binaryContentMapper::toDto) .toList(); + log.info("바이너리 컨텐츠 목록 조회 완료: 조회된 항목 수={}", dtos.size()); + return dtos; } @Transactional @Override public void delete(UUID binaryContentId) { - log.debug("BinaryContent 삭제 요청: binaryContentId = {}", binaryContentId); + log.debug("바이너리 컨텐츠 삭제 시작: id={}", binaryContentId); if (!binaryContentRepository.existsById(binaryContentId)) { - log.error("BinaryContent 삭제 실패: binaryContentId = {} ", binaryContentId); - throw new NoSuchElementException("BinaryContent with id " + binaryContentId + " not found"); + throw BinaryContentNotFoundException.withId(binaryContentId); } binaryContentRepository.deleteById(binaryContentId); - log.info("BinaryContent 삭제 성공: id = {}", binaryContentId); + log.info("바이너리 컨텐츠 삭제 완료: id={}", binaryContentId); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java index 0598fb826..00ab04087 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java @@ -16,16 +16,15 @@ import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.service.ChannelService; import java.util.List; -import java.util.NoSuchElementException; import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; -@RequiredArgsConstructor -@Service @Slf4j +@Service +@RequiredArgsConstructor public class BasicChannelService implements ChannelService { private final ChannelRepository channelRepository; @@ -38,29 +37,29 @@ public class BasicChannelService implements ChannelService { @Transactional @Override public ChannelDto create(PublicChannelCreateRequest request) { - log.debug("public Channel 생성 요청 : request = {}", request); + log.debug("채널 생성 시작: {}", request); String name = request.name(); String description = request.description(); Channel channel = new Channel(ChannelType.PUBLIC, name, description); channelRepository.save(channel); - log.info("public Channel 생성 성공 : name = {}", request.name()); + log.info("채널 생성 완료: id={}, name={}", channel.getId(), channel.getName()); return channelMapper.toDto(channel); } @Transactional @Override public ChannelDto create(PrivateChannelCreateRequest request) { - log.debug("private Channel 생성 요청 : request = {}", request); + log.debug("채널 생성 시작: {}", request); Channel channel = new Channel(ChannelType.PRIVATE, null, null); channelRepository.save(channel); - log.info("private Channel 생성 성공 : channel = {}", channel); List readStatuses = userRepository.findAllById(request.participantIds()).stream() .map(user -> new ReadStatus(user, channel, channel.getCreatedAt())) .toList(); readStatusRepository.saveAll(readStatuses); + log.info("채널 생성 완료: id={}, name={}", channel.getId(), channel.getName()); return channelMapper.toDto(channel); } @@ -69,8 +68,7 @@ public ChannelDto create(PrivateChannelCreateRequest request) { public ChannelDto find(UUID channelId) { return channelRepository.findById(channelId) .map(channelMapper::toDto) - .orElseThrow( - () -> new ChannelNotFoundException(channelId)); + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); } @Transactional(readOnly = true) @@ -90,44 +88,31 @@ public List findAllByUserId(UUID userId) { @Transactional @Override public ChannelDto update(UUID channelId, PublicChannelUpdateRequest request) { - log.debug("Channel 수정 요청 : request = {} ", request); + log.debug("채널 수정 시작: id={}, request={}", channelId, request); String newName = request.newName(); String newDescription = request.newDescription(); - Channel channel = channelRepository.findById(channelId) - .orElseThrow(() -> { - log.error("Channel 을 찾지 못함 : {}", channelId); - return new ChannelNotFoundException(channelId); - } - ); - + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); if (channel.getType().equals(ChannelType.PRIVATE)) { - log.debug("private Channel 채널 수정 불가 : getType = {}", channel.getType()); - throw new PrivateChannelUpdateException(channelId); + throw PrivateChannelUpdateException.forChannel(channelId); } channel.update(newName, newDescription); - log.info("public Channel 수정 성공 : newName = {}, newDescription = {}", request.newName(), request.newDescription()); + log.info("채널 수정 완료: id={}, name={}", channelId, channel.getName()); return channelMapper.toDto(channel); } @Transactional @Override public void delete(UUID channelId) { - log.debug("Channel 삭제 요청 : channelId = {}", channelId); + log.debug("채널 삭제 시작: id={}", channelId); if (!channelRepository.existsById(channelId)) { - log.error("Channel 삭제 실패 - 존재하지 않음 : channelId = {}", channelId); - throw new ChannelNotFoundException(channelId); + throw ChannelNotFoundException.withId(channelId); } - log.debug("message 삭제 시작: = {}", channelId); messageRepository.deleteAllByChannelId(channelId); - log.debug("message 삭제 완료: = {}", channelId); - - log.debug("읽음 상태 삭제 시작: = {}", channelId); readStatusRepository.deleteAllByChannelId(channelId); - log.debug("읽음 상태 삭제 완료: = {}", channelId); channelRepository.deleteById(channelId); - log.info("Channel 삭제 성공 : channelId = {}", channelId); + log.info("채널 삭제 완료: id={}", channelId); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java index 92ac51ddb..5516ac518 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java @@ -22,23 +22,21 @@ import com.sprint.mission.discodeit.storage.BinaryContentStorage; import java.time.Instant; import java.util.List; -import java.util.NoSuchElementException; import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; -@RequiredArgsConstructor -@Service @Slf4j +@Service +@RequiredArgsConstructor public class BasicMessageService implements MessageService { private final MessageRepository messageRepository; - // private final ChannelRepository channelRepository; private final UserRepository userRepository; private final MessageMapper messageMapper; @@ -50,22 +48,14 @@ public class BasicMessageService implements MessageService { @Override public MessageDto create(MessageCreateRequest messageCreateRequest, List binaryContentCreateRequests) { - log.debug("message 생성 요청 : messageCreateRequest = {}", messageCreateRequest); + log.debug("메시지 생성 시작: request={}", messageCreateRequest); UUID channelId = messageCreateRequest.channelId(); UUID authorId = messageCreateRequest.authorId(); Channel channel = channelRepository.findById(channelId) - .orElseThrow(() -> { - log.error("Channel 을 찾을 수 없음 : channelId = {}", channelId); - return new ChannelNotFoundException(channelId); - }); + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); User author = userRepository.findById(authorId) - .orElseThrow( - () -> { - log.error("User 를 찾을 수 없음 : authorId = {}", authorId); - return new UserNotFoundException(authorId); - } - ); + .orElseThrow(() -> UserNotFoundException.withId(authorId)); List attachments = binaryContentCreateRequests.stream() .map(attachmentRequest -> { @@ -76,7 +66,6 @@ public MessageDto create(MessageCreateRequest messageCreateRequest, BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, contentType); binaryContentRepository.save(binaryContent); - log.info("binaryContent 저장 성공 : binaryContent = {}", binaryContent); binaryContentStorage.put(binaryContent.getId(), bytes); return binaryContent; }) @@ -91,7 +80,7 @@ public MessageDto create(MessageCreateRequest messageCreateRequest, ); messageRepository.save(message); - log.info("message 생성 성공 : content = {}", messageCreateRequest.content()); + log.info("메시지 생성 완료: id={}, channelId={}", message.getId(), channelId); return messageMapper.toDto(message); } @@ -100,8 +89,7 @@ public MessageDto create(MessageCreateRequest messageCreateRequest, public MessageDto find(UUID messageId) { return messageRepository.findById(messageId) .map(messageMapper::toDto) - .orElseThrow( - () -> new MessageNotFoundException(messageId)); + .orElseThrow(() -> MessageNotFoundException.withId(messageId)); } @Transactional(readOnly = true) @@ -125,29 +113,23 @@ public PageResponse findAllByChannelId(UUID channelId, Instant creat @Transactional @Override public MessageDto update(UUID messageId, MessageUpdateRequest request) { - log.debug("message 수정 요청 : newContent = {}", request.newContent()); - String newContent = request.newContent(); + log.debug("메시지 수정 시작: id={}, request={}", messageId, request); Message message = messageRepository.findById(messageId) - .orElseThrow( - () -> { - log.error("message 수정 실패 : messageId = {}", messageId); - return new MessageNotFoundException(messageId); - } - ); - message.update(newContent); - log.info("message 수정 성공 : newContent = {}", request.newContent()); + .orElseThrow(() -> MessageNotFoundException.withId(messageId)); + + message.update(request.newContent()); + log.info("메시지 수정 완료: id={}, channelId={}", messageId, message.getChannel().getId()); return messageMapper.toDto(message); } @Transactional @Override public void delete(UUID messageId) { - log.debug("message 삭제 요청 : messageId = {}", messageId); + log.debug("메시지 삭제 시작: id={}", messageId); if (!messageRepository.existsById(messageId)) { - log.error("message 삭제 요청 실패 : messageId = {}", messageId); - throw new MessageNotFoundException(messageId); + throw MessageNotFoundException.withId(messageId); } - log.info("message 삭제 성공: messageId = {}", messageId); messageRepository.deleteById(messageId); + log.info("메시지 삭제 완료: id={}", messageId); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java index 6484415a9..41d998fbe 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java @@ -6,6 +6,10 @@ import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.ReadStatus; import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.readstatus.DuplicateReadStatusException; +import com.sprint.mission.discodeit.exception.readstatus.ReadStatusNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.mapper.ReadStatusMapper; import com.sprint.mission.discodeit.repository.ChannelRepository; import com.sprint.mission.discodeit.repository.ReadStatusRepository; @@ -13,16 +17,15 @@ import com.sprint.mission.discodeit.service.ReadStatusService; import java.time.Instant; import java.util.List; -import java.util.NoSuchElementException; import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor @Service -@Slf4j public class BasicReadStatusService implements ReadStatusService { private final ReadStatusRepository readStatusRepository; @@ -33,61 +36,70 @@ public class BasicReadStatusService implements ReadStatusService { @Transactional @Override public ReadStatusDto create(ReadStatusCreateRequest request) { + log.debug("읽음 상태 생성 시작: userId={}, channelId={}", request.userId(), request.channelId()); + UUID userId = request.userId(); UUID channelId = request.channelId(); User user = userRepository.findById(userId) - .orElseThrow( - () -> new NoSuchElementException("User with id " + userId + " does not exist")); + .orElseThrow(() -> UserNotFoundException.withId(userId)); Channel channel = channelRepository.findById(channelId) - .orElseThrow( - () -> new NoSuchElementException("Channel with id " + channelId + " does not exist") - ); + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); if (readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId())) { - throw new IllegalArgumentException( - "ReadStatus with userId " + userId + " and channelId " + channelId + " already exists"); + throw DuplicateReadStatusException.withUserIdAndChannelId(userId, channelId); } Instant lastReadAt = request.lastReadAt(); ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt); readStatusRepository.save(readStatus); + log.info("읽음 상태 생성 완료: id={}, userId={}, channelId={}", + readStatus.getId(), userId, channelId); return readStatusMapper.toDto(readStatus); } @Override public ReadStatusDto find(UUID readStatusId) { - return readStatusRepository.findById(readStatusId) + log.debug("읽음 상태 조회 시작: id={}", readStatusId); + ReadStatusDto dto = readStatusRepository.findById(readStatusId) .map(readStatusMapper::toDto) - .orElseThrow( - () -> new NoSuchElementException("ReadStatus with id " + readStatusId + " not found")); + .orElseThrow(() -> ReadStatusNotFoundException.withId(readStatusId)); + log.info("읽음 상태 조회 완료: id={}", readStatusId); + return dto; } @Override public List findAllByUserId(UUID userId) { - return readStatusRepository.findAllByUserId(userId).stream() + log.debug("사용자별 읽음 상태 목록 조회 시작: userId={}", userId); + List dtos = readStatusRepository.findAllByUserId(userId).stream() .map(readStatusMapper::toDto) .toList(); + log.info("사용자별 읽음 상태 목록 조회 완료: userId={}, 조회된 항목 수={}", userId, dtos.size()); + return dtos; } @Transactional @Override public ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request) { - Instant newLastReadAt = request.newLastReadAt(); + log.debug("읽음 상태 수정 시작: id={}, newLastReadAt={}", readStatusId, request.newLastReadAt()); + ReadStatus readStatus = readStatusRepository.findById(readStatusId) - .orElseThrow( - () -> new NoSuchElementException("ReadStatus with id " + readStatusId + " not found")); - readStatus.update(newLastReadAt); + .orElseThrow(() -> ReadStatusNotFoundException.withId(readStatusId)); + readStatus.update(request.newLastReadAt()); + + log.info("읽음 상태 수정 완료: id={}", readStatusId); return readStatusMapper.toDto(readStatus); } @Transactional @Override public void delete(UUID readStatusId) { + log.debug("읽음 상태 삭제 시작: id={}", readStatusId); if (!readStatusRepository.existsById(readStatusId)) { - throw new NoSuchElementException("ReadStatus with id " + readStatusId + " not found"); + throw ReadStatusNotFoundException.withId(readStatusId); } readStatusRepository.deleteById(readStatusId); + log.info("읽음 상태 삭제 완료: id={}", readStatusId); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java index 3c785c021..5883f2107 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -7,8 +7,7 @@ import com.sprint.mission.discodeit.entity.BinaryContent; import com.sprint.mission.discodeit.entity.User; import com.sprint.mission.discodeit.entity.UserStatus; -import com.sprint.mission.discodeit.exception.user.UserDuplicateException; -import com.sprint.mission.discodeit.exception.user.UserEmailDuplicateException; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException; import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; @@ -18,17 +17,16 @@ import com.sprint.mission.discodeit.storage.BinaryContentStorage; import java.time.Instant; import java.util.List; -import java.util.NoSuchElementException; import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor @Service -@Slf4j public class BasicUserService implements UserService { private final UserRepository userRepository; @@ -41,17 +39,16 @@ public class BasicUserService implements UserService { @Override public UserDto create(UserCreateRequest userCreateRequest, Optional optionalProfileCreateRequest) { - log.debug("User 생성 요청 : userCreateRequest = {}", userCreateRequest); + log.debug("사용자 생성 시작: {}", userCreateRequest); + String username = userCreateRequest.username(); String email = userCreateRequest.email(); if (userRepository.existsByEmail(email)) { - log.error("User 생성 실패 : email = {}", userCreateRequest.email()); - throw new UserEmailDuplicateException(email); + throw UserAlreadyExistsException.withEmail(email); } if (userRepository.existsByUsername(username)) { - log.error("User 생성 실패 : username = {}", userCreateRequest.username()); - throw new UserDuplicateException(username); + throw UserAlreadyExistsException.withUsername(username); } BinaryContent nullableProfile = optionalProfileCreateRequest @@ -62,7 +59,6 @@ public UserDto create(UserCreateRequest userCreateRequest, BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, contentType); binaryContentRepository.save(binaryContent); - log.info("binaryContent 저장 성공 : binaryContent = {}", binaryContent); binaryContentStorage.put(binaryContent.getId(), bytes); return binaryContent; }) @@ -74,42 +70,52 @@ public UserDto create(UserCreateRequest userCreateRequest, UserStatus userStatus = new UserStatus(user, now); userRepository.save(user); - log.info("User 생성 성공: username = {}", userCreateRequest.username()); + log.info("사용자 생성 완료: id={}, username={}", user.getId(), username); return userMapper.toDto(user); } @Override public UserDto find(UUID userId) { - return userRepository.findById(userId) + log.debug("사용자 조회 시작: id={}", userId); + UserDto userDto = userRepository.findById(userId) .map(userMapper::toDto) - .orElseThrow(() -> new UserNotFoundException(userId)); + .orElseThrow(() -> UserNotFoundException.withId(userId)); + log.info("사용자 조회 완료: id={}", userId); + return userDto; } @Override public List findAll() { - return userRepository.findAllWithProfileAndStatus() + log.debug("모든 사용자 조회 시작"); + List userDtos = userRepository.findAllWithProfileAndStatus() .stream() .map(userMapper::toDto) .toList(); + log.info("모든 사용자 조회 완료: 총 {}명", userDtos.size()); + return userDtos; } @Transactional @Override public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, Optional optionalProfileCreateRequest) { - log.debug("User 수정 요청 : {}", userUpdateRequest); + log.debug("사용자 수정 시작: id={}, request={}", userId, userUpdateRequest); + User user = userRepository.findById(userId) - .orElseThrow(() -> new UserNotFoundException(userId)); + .orElseThrow(() -> { + UserNotFoundException exception = UserNotFoundException.withId(userId); + return exception; + }); String newUsername = userUpdateRequest.newUsername(); String newEmail = userUpdateRequest.newEmail(); + if (userRepository.existsByEmail(newEmail)) { - log.error("User 수정 실패 : newEmail = {}", userUpdateRequest.newEmail()); - throw new UserEmailDuplicateException(newEmail); + throw UserAlreadyExistsException.withEmail(newEmail); } + if (userRepository.existsByUsername(newUsername)) { - log.error("User 수정 실패 : newUsername = {}", userUpdateRequest.newUsername()); - throw new UserDuplicateException(newUsername); + throw UserAlreadyExistsException.withUsername(newUsername); } BinaryContent nullableProfile = optionalProfileCreateRequest @@ -121,7 +127,6 @@ public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, contentType); binaryContentRepository.save(binaryContent); - log.info("binaryContent 저장 성공 : binaryContent = {}", binaryContent); binaryContentStorage.put(binaryContent.getId(), bytes); return binaryContent; }) @@ -129,21 +134,21 @@ public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, String newPassword = userUpdateRequest.newPassword(); user.update(newUsername, newEmail, newPassword, nullableProfile); - log.info("User 수정 성공 : newUsername = {}", userUpdateRequest.newUsername()); + log.info("사용자 수정 완료: id={}", userId); return userMapper.toDto(user); } @Transactional @Override public void delete(UUID userId) { - log.debug("User 삭제 요청 : userId = {}", userId); + log.debug("사용자 삭제 시작: id={}", userId); + if (!userRepository.existsById(userId)) { - log.error("User 삭제 요청 : userId = {}", userId); - throw new UserNotFoundException(userId); + throw UserNotFoundException.withId(userId); } userRepository.deleteById(userId); - log.info("User 삭제 성공 : userId = {}", userId); + log.info("사용자 삭제 완료: id={}", userId); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java index 9a3798a9d..0ae0e4ac7 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java @@ -5,23 +5,25 @@ import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; import com.sprint.mission.discodeit.entity.User; import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.exception.userstatus.DuplicateUserStatusException; +import com.sprint.mission.discodeit.exception.userstatus.UserStatusNotFoundException; import com.sprint.mission.discodeit.mapper.UserStatusMapper; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.repository.UserStatusRepository; import com.sprint.mission.discodeit.service.UserStatusService; import java.time.Instant; import java.util.List; -import java.util.NoSuchElementException; import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor @Service -@Slf4j public class BasicUserStatusService implements UserStatusService { private final UserStatusRepository userStatusRepository; @@ -31,46 +33,57 @@ public class BasicUserStatusService implements UserStatusService { @Transactional @Override public UserStatusDto create(UserStatusCreateRequest request) { + log.debug("사용자 상태 생성 시작: userId={}", request.userId()); + UUID userId = request.userId(); - User user = userRepository.findById(userId) - .orElseThrow(() -> new NoSuchElementException("User with id " + userId + " not found")); + .orElseThrow(() -> UserNotFoundException.withId(userId)); + Optional.ofNullable(user.getStatus()) .ifPresent(status -> { - throw new IllegalArgumentException("UserStatus with id " + userId + " already exists"); + throw DuplicateUserStatusException.withUserId(userId); }); Instant lastActiveAt = request.lastActiveAt(); UserStatus userStatus = new UserStatus(user, lastActiveAt); userStatusRepository.save(userStatus); + + log.info("사용자 상태 생성 완료: id={}, userId={}", userStatus.getId(), userId); return userStatusMapper.toDto(userStatus); } @Override public UserStatusDto find(UUID userStatusId) { - return userStatusRepository.findById(userStatusId) + log.debug("사용자 상태 조회 시작: id={}", userStatusId); + UserStatusDto dto = userStatusRepository.findById(userStatusId) .map(userStatusMapper::toDto) - .orElseThrow( - () -> new NoSuchElementException("UserStatus with id " + userStatusId + " not found")); + .orElseThrow(() -> UserStatusNotFoundException.withId(userStatusId)); + log.info("사용자 상태 조회 완료: id={}", userStatusId); + return dto; } @Override public List findAll() { - return userStatusRepository.findAll().stream() + log.debug("전체 사용자 상태 목록 조회 시작"); + List dtos = userStatusRepository.findAll().stream() .map(userStatusMapper::toDto) .toList(); + log.info("전체 사용자 상태 목록 조회 완료: 조회된 항목 수={}", dtos.size()); + return dtos; } @Transactional @Override public UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request) { Instant newLastActiveAt = request.newLastActiveAt(); - + log.debug("사용자 상태 수정 시작: id={}, newLastActiveAt={}", + userStatusId, newLastActiveAt); + UserStatus userStatus = userStatusRepository.findById(userStatusId) - .orElseThrow( - () -> new NoSuchElementException("UserStatus with id " + userStatusId + " not found")); + .orElseThrow(() -> UserStatusNotFoundException.withId(userStatusId)); userStatus.update(newLastActiveAt); - + + log.info("사용자 상태 수정 완료: id={}", userStatusId); return userStatusMapper.toDto(userStatus); } @@ -78,21 +91,25 @@ public UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request) @Override public UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request) { Instant newLastActiveAt = request.newLastActiveAt(); - + log.debug("사용자 ID로 상태 수정 시작: userId={}, newLastActiveAt={}", + userId, newLastActiveAt); + UserStatus userStatus = userStatusRepository.findByUserId(userId) - .orElseThrow( - () -> new NoSuchElementException("UserStatus with userId " + userId + " not found")); + .orElseThrow(() -> UserStatusNotFoundException.withUserId(userId)); userStatus.update(newLastActiveAt); - + + log.info("사용자 ID로 상태 수정 완료: userId={}", userId); return userStatusMapper.toDto(userStatus); } @Transactional @Override public void delete(UUID userStatusId) { + log.debug("사용자 상태 삭제 시작: id={}", userStatusId); if (!userStatusRepository.existsById(userStatusId)) { - throw new NoSuchElementException("UserStatus with id " + userStatusId + " not found"); + throw UserStatusNotFoundException.withId(userStatusId); } userStatusRepository.deleteById(userStatusId); + log.info("사용자 상태 삭제 완료: id={}", userStatusId); } } diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 37ea0700b..22ae092e6 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -1,16 +1,26 @@ +server: + port: 8080 + spring: - config: - activate: - on-profile: dev datasource: - driver-class-name: org.postgresql.Driver url: jdbc:postgresql://localhost:5432/discodeit username: discodeit_user password: discodeit1234 + jpa: + properties: + hibernate: + format_sql: true + logging: level: + com.sprint.mission.discodeit: debug org.hibernate.SQL: debug org.hibernate.orm.jdbc.bind: trace - com.sprint.mission.discodeit : debug -server: - port: 8080 \ No newline at end of file + +management: + endpoint: + health: + show-details: always + info: + env: + enabled: true \ No newline at end of file diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 3f8edd040..3074885d3 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -1,16 +1,25 @@ +server: + port: 80 + spring: - config: - activate: - on-profile: prod datasource: - driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://localhost:5432/discodeit - username: discodeit_user - password: discodeit1234 + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + jpa: + properties: + hibernate: + format_sql: false + logging: level: - org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace - com.sprint.mission.discodeit : info -server: - port: 8080 \ No newline at end of file + com.sprint.mission.discodeit: info + org.hibernate.SQL: info + +management: + endpoint: + health: + show-details: never + info: + env: + enabled: false \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 635cf6f2f..5ed2fa4ac 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,68 +1,55 @@ spring: - profiles: - active: prod application: name: discodeit servlet: multipart: - maxFileSize: 10MB # 파일 하나의 최대 크기 - maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 + maxFileSize: 10MB # 파일 하나의 최대 크기 + maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 datasource: driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://localhost:5432/discodeit - username: discodeit_user - password: discodeit1234 jpa: hibernate: - ddl-auto: create - properties: - hibernate: - format_sql: true + ddl-auto: validate open-in-view: false - -logging: - level: - org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace - root: info - -discodeit: - storage: - type: local - local: - root-path: .discodeit/storage + profiles: + active: + - dev management: endpoints: web: exposure: - include: '*' # 모든 actuator 엔드포인트 노출 - metrics: - data: - repository: - autotime: - enabled: true + include: health,info,metrics,loggers endpoint: - loggers: - access: read_only - info: - env: - enabled: true + health: + show-details: always info: - app: - name: Discodeit - description: | - datasource: - url: jdbc:postgresql://localhost:5432/discodeit - driver-class-name: org.postgresql.Driver - jpa: ddl-auto - storage: - type: local - path: .discodeit/storage - multipart: - maxFileSize: 10MB - maxRequestSize: 30MB - "app version": 1.7.0 - "java version": 17 - "spring boot version": 3.4.0 \ No newline at end of file + name: Discodeit + version: 1.7.0 + java: + version: 17 + spring-boot: + version: 3.4.0 + config: + datasource: + url: ${spring.datasource.url} + driver-class-name: ${spring.datasource.driver-class-name} + jpa: + ddl-auto: ${spring.jpa.hibernate.ddl-auto} + storage: + type: ${discodeit.storage.type} + path: ${discodeit.storage.local.root-path} + multipart: + max-file-size: ${spring.servlet.multipart.maxFileSize} + max-request-size: ${spring.servlet.multipart.maxRequestSize} + +discodeit: + storage: + type: local + local: + root-path: .discodeit/storage + +logging: + level: + root: info diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 46950a042..0f338e0b1 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,50 +1,35 @@ - - - - - - %d{yy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger{36} - %msg%n - - - - - - .logs/application.log - - %d{yy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger{36} - %msg%n - - - - .logs/application.%d{yyyy-MM-dd}.log - 30 - 10GB - true - - - - - - - - - - - - + + + + + + + + + + + + + ${LOG_PATTERN} + + + + + + ${LOG_FILE_PATH}/${LOG_FILE_NAME}.log + + ${LOG_PATTERN} + + + ${LOG_FILE_PATH}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}.log + 30 + + + + + + + - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java new file mode 100644 index 000000000..d23a59e59 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java @@ -0,0 +1,121 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.exception.user.InvalidCredentialsException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.service.AuthService; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(AuthController.class) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private AuthService authService; + + @Test + @DisplayName("로그인 성공 테스트") + void login_Success() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "testuser", + "Password1!" + ); + + UUID userId = UUID.randomUUID(); + UserDto loggedInUser = new UserDto( + userId, + "testuser", + "test@example.com", + null, + true + ); + + given(authService.login(any(LoginRequest.class))).willReturn(loggedInUser); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.online").value(true)); + } + + @Test + @DisplayName("로그인 실패 테스트 - 존재하지 않는 사용자") + void login_Failure_UserNotFound() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "nonexistentuser", + "Password1!" + ); + + given(authService.login(any(LoginRequest.class))) + .willThrow(UserNotFoundException.withUsername("nonexistentuser")); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("로그인 실패 테스트 - 잘못된 비밀번호") + void login_Failure_InvalidCredentials() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "testuser", + "WrongPassword1!" + ); + + given(authService.login(any(LoginRequest.class))) + .willThrow(InvalidCredentialsException.wrongPassword()); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("로그인 실패 테스트 - 유효하지 않은 요청") + void login_Failure_InvalidRequest() throws Exception { + // Given + LoginRequest invalidRequest = new LoginRequest( + "", // 사용자 이름 비어있음 (NotBlank 위반) + "" // 비밀번호 비어있음 (NotBlank 위반) + ); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java new file mode 100644 index 000000000..a8451d370 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java @@ -0,0 +1,149 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(BinaryContentController.class) +class BinaryContentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private BinaryContentService binaryContentService; + + @MockitoBean + private BinaryContentStorage binaryContentStorage; + + @Test + @DisplayName("바이너리 컨텐츠 조회 성공 테스트") + void find_Success() throws Exception { + // Given + UUID binaryContentId = UUID.randomUUID(); + BinaryContentDto binaryContent = new BinaryContentDto( + binaryContentId, + "test.jpg", + 10240L, + MediaType.IMAGE_JPEG_VALUE + ); + + given(binaryContentService.find(binaryContentId)).willReturn(binaryContent); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", binaryContentId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(binaryContentId.toString())) + .andExpect(jsonPath("$.fileName").value("test.jpg")) + .andExpect(jsonPath("$.size").value(10240)) + .andExpect(jsonPath("$.contentType").value(MediaType.IMAGE_JPEG_VALUE)); + } + + @Test + @DisplayName("바이너리 컨텐츠 조회 실패 테스트 - 존재하지 않는 컨텐츠") + void find_Failure_BinaryContentNotFound() throws Exception { + // Given + UUID nonExistentId = UUID.randomUUID(); + + given(binaryContentService.find(nonExistentId)) + .willThrow(BinaryContentNotFoundException.withId(nonExistentId)); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", nonExistentId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("ID 목록으로 바이너리 컨텐츠 조회 성공 테스트") + void findAllByIdIn_Success() throws Exception { + // Given + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + + List binaryContentIds = List.of(id1, id2); + + List binaryContents = List.of( + new BinaryContentDto(id1, "test1.jpg", 10240L, MediaType.IMAGE_JPEG_VALUE), + new BinaryContentDto(id2, "test2.pdf", 20480L, MediaType.APPLICATION_PDF_VALUE) + ); + + given(binaryContentService.findAllByIdIn(binaryContentIds)).willReturn(binaryContents); + + // When & Then + mockMvc.perform(get("/api/binaryContents") + .param("binaryContentIds", id1.toString(), id2.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(id1.toString())) + .andExpect(jsonPath("$[0].fileName").value("test1.jpg")) + .andExpect(jsonPath("$[1].id").value(id2.toString())) + .andExpect(jsonPath("$[1].fileName").value("test2.pdf")); + } + + @Test + @DisplayName("바이너리 컨텐츠 다운로드 성공 테스트") + void download_Success() throws Exception { + // Given + UUID binaryContentId = UUID.randomUUID(); + BinaryContentDto binaryContent = new BinaryContentDto( + binaryContentId, + "test.jpg", + 10240L, + MediaType.IMAGE_JPEG_VALUE + ); + + given(binaryContentService.find(binaryContentId)).willReturn(binaryContent); + + // doReturn 사용하여 타입 문제 우회 + ResponseEntity mockResponse = ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"test.jpg\"") + .header(HttpHeaders.CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE) + .body(new ByteArrayResource("test data".getBytes())); + + doReturn(mockResponse).when(binaryContentStorage).download(any(BinaryContentDto.class)); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", binaryContentId)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("바이너리 컨텐츠 다운로드 실패 테스트 - 존재하지 않는 컨텐츠") + void download_Failure_BinaryContentNotFound() throws Exception { + // Given + UUID nonExistentId = UUID.randomUUID(); + + given(binaryContentService.find(nonExistentId)) + .willThrow(BinaryContentNotFoundException.withId(nonExistentId)); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", nonExistentId)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java new file mode 100644 index 000000000..facad5130 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java @@ -0,0 +1,274 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.service.ChannelService; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ChannelController.class) +class ChannelControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ChannelService channelService; + + @Test + @DisplayName("공개 채널 생성 성공 테스트") + void createPublicChannel_Success() throws Exception { + // Given + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "test-channel", + "채널 설명입니다." + ); + + UUID channelId = UUID.randomUUID(); + ChannelDto createdChannel = new ChannelDto( + channelId, + ChannelType.PUBLIC, + "test-channel", + "채널 설명입니다.", + new ArrayList<>(), + Instant.now() + ); + + given(channelService.create(any(PublicChannelCreateRequest.class))) + .willReturn(createdChannel); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.type").value("PUBLIC")) + .andExpect(jsonPath("$.name").value("test-channel")) + .andExpect(jsonPath("$.description").value("채널 설명입니다.")); + } + + @Test + @DisplayName("공개 채널 생성 실패 테스트 - 유효하지 않은 요청") + void createPublicChannel_Failure_InvalidRequest() throws Exception { + // Given + PublicChannelCreateRequest invalidRequest = new PublicChannelCreateRequest( + "a", // 최소 길이 위반 (2자 이상이어야 함) + "채널 설명은 최대 255자까지 가능합니다.".repeat(10) // 최대 길이 위반 + ); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비공개 채널 생성 성공 테스트") + void createPrivateChannel_Success() throws Exception { + // Given + List participantIds = List.of(UUID.randomUUID(), UUID.randomUUID()); + PrivateChannelCreateRequest createRequest = new PrivateChannelCreateRequest(participantIds); + + UUID channelId = UUID.randomUUID(); + List participants = new ArrayList<>(); + for (UUID userId : participantIds) { + participants.add(new UserDto(userId, "user-" + userId.toString().substring(0, 5), + "user" + userId.toString().substring(0, 5) + "@example.com", null, false)); + } + + ChannelDto createdChannel = new ChannelDto( + channelId, + ChannelType.PRIVATE, + null, + null, + participants, + Instant.now() + ); + + given(channelService.create(any(PrivateChannelCreateRequest.class))) + .willReturn(createdChannel); + + // When & Then + mockMvc.perform(post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.type").value("PRIVATE")) + .andExpect(jsonPath("$.participants").isArray()) + .andExpect(jsonPath("$.participants.length()").value(2)); + } + + @Test + @DisplayName("공개 채널 업데이트 성공 테스트") + void updateChannel_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "updated-channel", + "업데이트된 채널 설명입니다." + ); + + ChannelDto updatedChannel = new ChannelDto( + channelId, + ChannelType.PUBLIC, + "updated-channel", + "업데이트된 채널 설명입니다.", + new ArrayList<>(), + Instant.now() + ); + + given(channelService.update(eq(channelId), any(PublicChannelUpdateRequest.class))) + .willReturn(updatedChannel); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.name").value("updated-channel")) + .andExpect(jsonPath("$.description").value("업데이트된 채널 설명입니다.")); + } + + @Test + @DisplayName("채널 업데이트 실패 테스트 - 존재하지 않는 채널") + void updateChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "updated-channel", + "업데이트된 채널 설명입니다." + ); + + given(channelService.update(eq(nonExistentChannelId), any(PublicChannelUpdateRequest.class))) + .willThrow(ChannelNotFoundException.withId(nonExistentChannelId)); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", nonExistentChannelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널 업데이트 실패 테스트 - 비공개 채널 업데이트 시도") + void updateChannel_Failure_PrivateChannelUpdate() throws Exception { + // Given + UUID privateChannelId = UUID.randomUUID(); + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "updated-channel", + "업데이트된 채널 설명입니다." + ); + + given(channelService.update(eq(privateChannelId), any(PublicChannelUpdateRequest.class))) + .willThrow(PrivateChannelUpdateException.forChannel(privateChannelId)); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", privateChannelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("채널 삭제 성공 테스트") + void deleteChannel_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + willDoNothing().given(channelService).delete(channelId); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("채널 삭제 실패 테스트 - 존재하지 않는 채널") + void deleteChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + willThrow(ChannelNotFoundException.withId(nonExistentChannelId)) + .given(channelService).delete(nonExistentChannelId); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", nonExistentChannelId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 성공 테스트") + void findAllByUserId_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId1 = UUID.randomUUID(); + UUID channelId2 = UUID.randomUUID(); + + List channels = List.of( + new ChannelDto( + channelId1, + ChannelType.PUBLIC, + "public-channel", + "공개 채널 설명", + new ArrayList<>(), + Instant.now() + ), + new ChannelDto( + channelId2, + ChannelType.PRIVATE, + null, + null, + List.of(new UserDto(userId, "user1", "user1@example.com", null, true)), + Instant.now().minusSeconds(3600) + ) + ); + + given(channelService.findAllByUserId(userId)).willReturn(channels); + + // When & Then + mockMvc.perform(get("/api/channels") + .param("userId", userId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(channelId1.toString())) + .andExpect(jsonPath("$[0].type").value("PUBLIC")) + .andExpect(jsonPath("$[0].name").value("public-channel")) + .andExpect(jsonPath("$[1].id").value(channelId2.toString())) + .andExpect(jsonPath("$[1].type").value("PRIVATE")); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java new file mode 100644 index 000000000..3330e6b08 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java @@ -0,0 +1,304 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.service.MessageService; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(MessageController.class) +class MessageControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private MessageService messageService; + + @Test + @DisplayName("메시지 생성 성공 테스트") + void createMessage_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + MessageCreateRequest createRequest = new MessageCreateRequest( + "안녕하세요, 테스트 메시지입니다.", + channelId, + authorId + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile attachment = new MockMultipartFile( + "attachments", + "test.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test-image".getBytes() + ); + + UUID messageId = UUID.randomUUID(); + Instant now = Instant.now(); + + UserDto author = new UserDto( + authorId, + "testuser", + "test@example.com", + null, + true + ); + + BinaryContentDto attachmentDto = new BinaryContentDto( + UUID.randomUUID(), + "test.jpg", + 10L, + MediaType.IMAGE_JPEG_VALUE + ); + + MessageDto createdMessage = new MessageDto( + messageId, + now, + now, + "안녕하세요, 테스트 메시지입니다.", + channelId, + author, + List.of(attachmentDto) + ); + + given(messageService.create(any(MessageCreateRequest.class), any(List.class))) + .willReturn(createdMessage); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart) + .file(attachment) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(messageId.toString())) + .andExpect(jsonPath("$.content").value("안녕하세요, 테스트 메시지입니다.")) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.author.id").value(authorId.toString())) + .andExpect(jsonPath("$.attachments[0].fileName").value("test.jpg")); + } + + @Test + @DisplayName("메시지 생성 실패 테스트 - 유효하지 않은 요청") + void createMessage_Failure_InvalidRequest() throws Exception { + // Given + MessageCreateRequest invalidRequest = new MessageCreateRequest( + "", // 내용이 비어있음 (NotBlank 위반) + null, // 채널 ID가 비어있음 (NotNull 위반) + null // 작성자 ID가 비어있음 (NotNull 위반) + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("메시지 업데이트 성공 테스트") + void updateMessage_Success() throws Exception { + // Given + UUID messageId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + Instant now = Instant.now(); + + UserDto author = new UserDto( + authorId, + "testuser", + "test@example.com", + null, + true + ); + + MessageDto updatedMessage = new MessageDto( + messageId, + now.minusSeconds(60), + now, + "수정된 메시지 내용입니다.", + channelId, + author, + new ArrayList<>() + ); + + given(messageService.update(eq(messageId), any(MessageUpdateRequest.class))) + .willReturn(updatedMessage); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", messageId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(messageId.toString())) + .andExpect(jsonPath("$.content").value("수정된 메시지 내용입니다.")) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.author.id").value(authorId.toString())); + } + + @Test + @DisplayName("메시지 업데이트 실패 테스트 - 존재하지 않는 메시지") + void updateMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + given(messageService.update(eq(nonExistentMessageId), any(MessageUpdateRequest.class))) + .willThrow(MessageNotFoundException.withId(nonExistentMessageId)); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", nonExistentMessageId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("메시지 삭제 성공 테스트") + void deleteMessage_Success() throws Exception { + // Given + UUID messageId = UUID.randomUUID(); + willDoNothing().given(messageService).delete(messageId); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", messageId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("메시지 삭제 실패 테스트 - 존재하지 않는 메시지") + void deleteMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + willThrow(MessageNotFoundException.withId(nonExistentMessageId)) + .given(messageService).delete(nonExistentMessageId); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", nonExistentMessageId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널별 메시지 목록 조회 성공 테스트") + void findAllByChannelId_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + Instant cursor = Instant.now(); + Pageable pageable = PageRequest.of(0, 50, Sort.Direction.DESC, "createdAt"); + + UserDto author = new UserDto( + authorId, + "testuser", + "test@example.com", + null, + true + ); + + List messages = List.of( + new MessageDto( + UUID.randomUUID(), + cursor.minusSeconds(10), + cursor.minusSeconds(10), + "첫 번째 메시지", + channelId, + author, + new ArrayList<>() + ), + new MessageDto( + UUID.randomUUID(), + cursor.minusSeconds(20), + cursor.minusSeconds(20), + "두 번째 메시지", + channelId, + author, + new ArrayList<>() + ) + ); + + PageResponse pageResponse = new PageResponse<>( + messages, + cursor.minusSeconds(30), // nextCursor 값 + pageable.getPageSize(), + true, // hasNext + (long) messages.size() // totalElements + ); + + given(messageService.findAllByChannelId(eq(channelId), eq(cursor), any(Pageable.class))) + .willReturn(pageResponse); + + // When & Then + mockMvc.perform(get("/api/messages") + .param("channelId", channelId.toString()) + .param("cursor", cursor.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.content[0].content").value("첫 번째 메시지")) + .andExpect(jsonPath("$.content[1].content").value("두 번째 메시지")) + .andExpect(jsonPath("$.nextCursor").exists()) + .andExpect(jsonPath("$.size").value(50)) + .andExpect(jsonPath("$.hasNext").value(true)) + .andExpect(jsonPath("$.totalElements").value(2)); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java new file mode 100644 index 000000000..91772c33c --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java @@ -0,0 +1,172 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.exception.readstatus.ReadStatusNotFoundException; +import com.sprint.mission.discodeit.service.ReadStatusService; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ReadStatusController.class) +class ReadStatusControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ReadStatusService readStatusService; + + @Test + @DisplayName("읽음 상태 생성 성공 테스트") + void create_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + Instant lastReadAt = Instant.now(); + + ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest( + userId, + channelId, + lastReadAt + ); + + UUID readStatusId = UUID.randomUUID(); + ReadStatusDto createdReadStatus = new ReadStatusDto( + readStatusId, + userId, + channelId, + lastReadAt + ); + + given(readStatusService.create(any(ReadStatusCreateRequest.class))) + .willReturn(createdReadStatus); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(readStatusId.toString())) + .andExpect(jsonPath("$.userId").value(userId.toString())) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.lastReadAt").exists()); + } + + @Test + @DisplayName("읽음 상태 생성 실패 테스트 - 유효하지 않은 요청") + void create_Failure_InvalidRequest() throws Exception { + // Given + ReadStatusCreateRequest invalidRequest = new ReadStatusCreateRequest( + null, // userId가 null (NotNull 위반) + null, // channelId가 null (NotNull 위반) + null // lastReadAt이 null (NotNull 위반) + ); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("읽음 상태 업데이트 성공 테스트") + void update_Success() throws Exception { + // Given + UUID readStatusId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + Instant newLastReadAt = Instant.now(); + + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest(newLastReadAt); + + ReadStatusDto updatedReadStatus = new ReadStatusDto( + readStatusId, + userId, + channelId, + newLastReadAt + ); + + given(readStatusService.update(eq(readStatusId), any(ReadStatusUpdateRequest.class))) + .willReturn(updatedReadStatus); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", readStatusId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(readStatusId.toString())) + .andExpect(jsonPath("$.userId").value(userId.toString())) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.lastReadAt").exists()); + } + + @Test + @DisplayName("읽음 상태 업데이트 실패 테스트 - 존재하지 않는 읽음 상태") + void update_Failure_ReadStatusNotFound() throws Exception { + // Given + UUID nonExistentId = UUID.randomUUID(); + Instant newLastReadAt = Instant.now(); + + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest(newLastReadAt); + + given(readStatusService.update(eq(nonExistentId), any(ReadStatusUpdateRequest.class))) + .willThrow(ReadStatusNotFoundException.withId(nonExistentId)); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", nonExistentId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자별 읽음 상태 목록 조회 성공 테스트") + void findAllByUserId_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId1 = UUID.randomUUID(); + UUID channelId2 = UUID.randomUUID(); + Instant now = Instant.now(); + + List readStatuses = List.of( + new ReadStatusDto(UUID.randomUUID(), userId, channelId1, now.minusSeconds(60)), + new ReadStatusDto(UUID.randomUUID(), userId, channelId2, now) + ); + + given(readStatusService.findAllByUserId(userId)).willReturn(readStatuses); + + // When & Then + mockMvc.perform(get("/api/readStatuses") + .param("userId", userId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].userId").value(userId.toString())) + .andExpect(jsonPath("$[0].channelId").value(channelId1.toString())) + .andExpect(jsonPath("$[1].userId").value(userId.toString())) + .andExpect(jsonPath("$[1].channelId").value(channelId2.toString())); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java new file mode 100644 index 000000000..d376362d8 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java @@ -0,0 +1,343 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.service.UserService; +import com.sprint.mission.discodeit.service.UserStatusService; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(UserController.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserService userService; + + @MockitoBean + private UserStatusService userStatusService; + + @Test + @DisplayName("사용자 생성 성공 테스트") + void createUser_Success() throws Exception { + // Given + UserCreateRequest createRequest = new UserCreateRequest( + "testuser", + "test@example.com", + "Password1!" + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test-image".getBytes() + ); + + UUID userId = UUID.randomUUID(); + BinaryContentDto profileDto = new BinaryContentDto( + UUID.randomUUID(), + "profile.jpg", + 12L, + MediaType.IMAGE_JPEG_VALUE + ); + + UserDto createdUser = new UserDto( + userId, + "testuser", + "test@example.com", + profileDto, + false + ); + + given(userService.create(any(UserCreateRequest.class), any(Optional.class))) + .willReturn(createdUser); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.profile.fileName").value("profile.jpg")) + .andExpect(jsonPath("$.online").value(false)); + } + + @Test + @DisplayName("사용자 생성 실패 테스트 - 유효하지 않은 요청") + void createUser_Failure_InvalidRequest() throws Exception { + // Given + UserCreateRequest invalidRequest = new UserCreateRequest( + "t", // 최소 길이 위반 + "invalid-email", // 이메일 형식 위반 + "short" // 비밀번호 정책 위반 + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("사용자 조회 성공 테스트") + void findAllUsers_Success() throws Exception { + // Given + UUID userId1 = UUID.randomUUID(); + UUID userId2 = UUID.randomUUID(); + + UserDto user1 = new UserDto( + userId1, + "user1", + "user1@example.com", + null, + true + ); + + UserDto user2 = new UserDto( + userId2, + "user2", + "user2@example.com", + null, + false + ); + + List users = List.of(user1, user2); + + given(userService.findAll()).willReturn(users); + + // When & Then + mockMvc.perform(get("/api/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(userId1.toString())) + .andExpect(jsonPath("$[0].username").value("user1")) + .andExpect(jsonPath("$[0].online").value(true)) + .andExpect(jsonPath("$[1].id").value(userId2.toString())) + .andExpect(jsonPath("$[1].username").value("user2")) + .andExpect(jsonPath("$[1].online").value(false)); + } + + @Test + @DisplayName("사용자 업데이트 성공 테스트") + void updateUser_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "updated-profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "updated-image".getBytes() + ); + + BinaryContentDto profileDto = new BinaryContentDto( + UUID.randomUUID(), + "updated-profile.jpg", + 14L, + MediaType.IMAGE_JPEG_VALUE + ); + + UserDto updatedUser = new UserDto( + userId, + "updateduser", + "updated@example.com", + profileDto, + true + ); + + given(userService.update(eq(userId), any(UserUpdateRequest.class), any(Optional.class))) + .willReturn(updatedUser); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", userId) + .file(userUpdateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("updateduser")) + .andExpect(jsonPath("$.email").value("updated@example.com")) + .andExpect(jsonPath("$.profile.fileName").value("updated-profile.jpg")) + .andExpect(jsonPath("$.online").value(true)); + } + + @Test + @DisplayName("사용자 업데이트 실패 테스트 - 존재하지 않는 사용자") + void updateUser_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "updated-profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "updated-image".getBytes() + ); + + given(userService.update(eq(nonExistentUserId), any(UserUpdateRequest.class), + any(Optional.class))) + .willThrow(UserNotFoundException.withId(nonExistentUserId)); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", nonExistentUserId) + .file(userUpdateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자 삭제 성공 테스트") + void deleteUser_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + willDoNothing().given(userService).delete(userId); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", userId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("사용자 삭제 실패 테스트 - 존재하지 않는 사용자") + void deleteUser_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + willThrow(UserNotFoundException.withId(nonExistentUserId)) + .given(userService).delete(nonExistentUserId); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", nonExistentUserId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자 상태 업데이트 성공 테스트") + void updateUserStatus_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID statusId = UUID.randomUUID(); + Instant lastActiveAt = Instant.now(); + + UserStatusUpdateRequest updateRequest = new UserStatusUpdateRequest(lastActiveAt); + UserStatusDto updatedStatus = new UserStatusDto(statusId, userId, lastActiveAt); + + given(userStatusService.updateByUserId(eq(userId), any(UserStatusUpdateRequest.class))) + .willReturn(updatedStatus); + + // When & Then + mockMvc.perform(patch("/api/users/{userId}/userStatus", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(statusId.toString())) + .andExpect(jsonPath("$.userId").value(userId.toString())) + .andExpect(content().json(objectMapper.writeValueAsString(updatedStatus))); + } + + @Test + @DisplayName("사용자 상태 업데이트 실패 테스트 - 존재하지 않는 사용자 상태") + void updateUserStatus_Failure_UserStatusNotFound() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + Instant lastActiveAt = Instant.now(); + + UserStatusUpdateRequest updateRequest = new UserStatusUpdateRequest(lastActiveAt); + + given(userStatusService.updateByUserId(eq(userId), any(UserStatusUpdateRequest.class))) + .willThrow(UserNotFoundException.withId(userId)); + + // When & Then + mockMvc.perform(patch("/api/users/{userId}/userStatus", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java new file mode 100644 index 000000000..2dfed05bc --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java @@ -0,0 +1,133 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.UserService; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class AuthApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserService userService; + + @Test + @DisplayName("로그인 API 통합 테스트 - 성공") + void login_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "loginuser", + "login@example.com", + "Password1!" + ); + + userService.create(userRequest, Optional.empty()); + + // 로그인 요청 + LoginRequest loginRequest = new LoginRequest( + "loginuser", + "Password1!" + ); + + String requestBody = objectMapper.writeValueAsString(loginRequest); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.username", is("loginuser"))) + .andExpect(jsonPath("$.email", is("login@example.com"))); + } + + @Test + @DisplayName("로그인 API 통합 테스트 - 실패 (존재하지 않는 사용자)") + void login_Failure_UserNotFound() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "nonexistentuser", + "Password1!" + ); + + String requestBody = objectMapper.writeValueAsString(loginRequest); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("로그인 API 통합 테스트 - 실패 (잘못된 비밀번호)") + void login_Failure_InvalidCredentials() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "loginuser2", + "login2@example.com", + "Password1!" + ); + + userService.create(userRequest, Optional.empty()); + + // 잘못된 비밀번호로 로그인 시도 + LoginRequest loginRequest = new LoginRequest( + "loginuser2", + "WrongPassword1!" + ); + + String requestBody = objectMapper.writeValueAsString(loginRequest); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("로그인 API 통합 테스트 - 실패 (유효하지 않은 요청)") + void login_Failure_InvalidRequest() throws Exception { + // Given + LoginRequest invalidRequest = new LoginRequest( + "", // 사용자 이름 비어있음 (NotBlank 위반) + "" // 비밀번호 비어있음 (NotBlank 위반) + ); + + String requestBody = objectMapper.writeValueAsString(invalidRequest); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java new file mode 100644 index 000000000..871296eaa --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java @@ -0,0 +1,209 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class BinaryContentApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private BinaryContentService binaryContentService; + + @Autowired + private UserService userService; + + @Autowired + private ChannelService channelService; + + @Autowired + private MessageService messageService; + + @Test + @DisplayName("바이너리 컨텐츠 조회 API 통합 테스트") + void findBinaryContent_Success() throws Exception { + // Given + // 테스트 바이너리 컨텐츠 생성 (메시지 첨부파일을 통해 생성) + // 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "contentuser", + "content@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + var channel = channelService.create(channelRequest); + + // 첨부파일이 있는 메시지 생성 + MessageCreateRequest messageRequest = new MessageCreateRequest( + "첨부파일이 있는 메시지입니다.", + channel.id(), + user.id() + ); + + byte[] fileContent = "테스트 파일 내용입니다.".getBytes(); + BinaryContentCreateRequest attachmentRequest = new BinaryContentCreateRequest( + "test.txt", + MediaType.TEXT_PLAIN_VALUE, + fileContent + ); + + MessageDto message = messageService.create(messageRequest, List.of(attachmentRequest)); + UUID binaryContentId = message.attachments().get(0).id(); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", binaryContentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(binaryContentId.toString()))) + .andExpect(jsonPath("$.fileName", is("test.txt"))) + .andExpect(jsonPath("$.contentType", is(MediaType.TEXT_PLAIN_VALUE))) + .andExpect(jsonPath("$.size", is(fileContent.length))); + } + + @Test + @DisplayName("존재하지 않는 바이너리 컨텐츠 조회 API 통합 테스트") + void findBinaryContent_Failure_NotFound() throws Exception { + // Given + UUID nonExistentBinaryContentId = UUID.randomUUID(); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", nonExistentBinaryContentId)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("여러 바이너리 컨텐츠 조회 API 통합 테스트") + void findAllBinaryContentsByIds_Success() throws Exception { + // Given + // 테스트 바이너리 컨텐츠 생성 (메시지 첨부파일을 통해 생성) + UserCreateRequest userRequest = new UserCreateRequest( + "contentuser2", + "content2@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널2", + "테스트 채널 설명입니다." + ); + var channel = channelService.create(channelRequest); + + MessageCreateRequest messageRequest = new MessageCreateRequest( + "첨부파일이 있는 메시지입니다.", + channel.id(), + user.id() + ); + + // 첫 번째 첨부파일 + BinaryContentCreateRequest attachmentRequest1 = new BinaryContentCreateRequest( + "test1.txt", + MediaType.TEXT_PLAIN_VALUE, + "첫 번째 테스트 파일 내용입니다.".getBytes() + ); + + // 두 번째 첨부파일 + BinaryContentCreateRequest attachmentRequest2 = new BinaryContentCreateRequest( + "test2.txt", + MediaType.TEXT_PLAIN_VALUE, + "두 번째 테스트 파일 내용입니다.".getBytes() + ); + + // 첨부파일 두 개를 가진 메시지 생성 + MessageDto message = messageService.create( + messageRequest, + List.of(attachmentRequest1, attachmentRequest2) + ); + + List binaryContentIds = message.attachments().stream() + .map(BinaryContentDto::id) + .toList(); + + // When & Then + mockMvc.perform(get("/api/binaryContents") + .param("binaryContentIds", binaryContentIds.get(0).toString()) + .param("binaryContentIds", binaryContentIds.get(1).toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[*].fileName", hasItems("test1.txt", "test2.txt"))); + } + + @Test + @DisplayName("바이너리 컨텐츠 다운로드 API 통합 테스트") + void downloadBinaryContent_Success() throws Exception { + // Given + String fileContent = "다운로드 테스트 파일 내용입니다."; + BinaryContentCreateRequest createRequest = new BinaryContentCreateRequest( + "download-test.txt", + MediaType.TEXT_PLAIN_VALUE, + fileContent.getBytes() + ); + + BinaryContentDto binaryContent = binaryContentService.create(createRequest); + UUID binaryContentId = binaryContent.id(); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", binaryContentId)) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", + "attachment; filename=\"download-test.txt\"")) + .andExpect(content().contentType(MediaType.TEXT_PLAIN_VALUE)) + .andExpect(content().bytes(fileContent.getBytes())); + } + + @Test + @DisplayName("존재하지 않는 바이너리 컨텐츠 다운로드 API 통합 테스트") + void downloadBinaryContent_Failure_NotFound() throws Exception { + // Given + UUID nonExistentBinaryContentId = UUID.randomUUID(); + + // When & Then + mockMvc.perform( + get("/api/binaryContents/{binaryContentId}/download", nonExistentBinaryContentId)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java new file mode 100644 index 000000000..5917b4d02 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java @@ -0,0 +1,269 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class ChannelApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ChannelService channelService; + + @Autowired + private UserService userService; + + @Test + @DisplayName("공개 채널 생성 API 통합 테스트") + void createPublicChannel_Success() throws Exception { + // Given + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(createRequest); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.type", is(ChannelType.PUBLIC.name()))) + .andExpect(jsonPath("$.name", is("테스트 채널"))) + .andExpect(jsonPath("$.description", is("테스트 채널 설명입니다."))); + } + + @Test + @DisplayName("공개 채널 생성 실패 API 통합 테스트 - 유효하지 않은 요청") + void createPublicChannel_Failure_InvalidRequest() throws Exception { + // Given + PublicChannelCreateRequest invalidRequest = new PublicChannelCreateRequest( + "a", // 최소 길이 위반 + "테스트 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(invalidRequest); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비공개 채널 생성 API 통합 테스트") + void createPrivateChannel_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest1 = new UserCreateRequest( + "user1", + "user1@example.com", + "Password1!" + ); + + UserCreateRequest userRequest2 = new UserCreateRequest( + "user2", + "user2@example.com", + "Password1!" + ); + + UserDto user1 = userService.create(userRequest1, Optional.empty()); + UserDto user2 = userService.create(userRequest2, Optional.empty()); + + List participantIds = List.of(user1.id(), user2.id()); + PrivateChannelCreateRequest createRequest = new PrivateChannelCreateRequest(participantIds); + + String requestBody = objectMapper.writeValueAsString(createRequest); + + // When & Then + mockMvc.perform(post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.type", is(ChannelType.PRIVATE.name()))) + .andExpect(jsonPath("$.participants", hasSize(2))); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 API 통합 테스트") + void findAllChannelsByUserId_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "channeluser", + "channeluser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + UUID userId = user.id(); + + // 공개 채널 생성 + PublicChannelCreateRequest publicChannelRequest = new PublicChannelCreateRequest( + "공개 채널 1", + "공개 채널 설명입니다." + ); + + channelService.create(publicChannelRequest); + + // 비공개 채널 생성 + UserCreateRequest otherUserRequest = new UserCreateRequest( + "otheruser", + "otheruser@example.com", + "Password1!" + ); + + UserDto otherUser = userService.create(otherUserRequest, Optional.empty()); + + PrivateChannelCreateRequest privateChannelRequest = new PrivateChannelCreateRequest( + List.of(userId, otherUser.id()) + ); + + channelService.create(privateChannelRequest); + + // When & Then + mockMvc.perform(get("/api/channels") + .param("userId", userId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].type", is(ChannelType.PUBLIC.name()))) + .andExpect(jsonPath("$[1].type", is(ChannelType.PRIVATE.name()))); + } + + @Test + @DisplayName("채널 업데이트 API 통합 테스트") + void updateChannel_Success() throws Exception { + // Given + // 공개 채널 생성 + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "원본 채널", + "원본 채널 설명입니다." + ); + + ChannelDto createdChannel = channelService.create(createRequest); + UUID channelId = createdChannel.id(); + + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "수정된 채널", + "수정된 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(channelId.toString()))) + .andExpect(jsonPath("$.name", is("수정된 채널"))) + .andExpect(jsonPath("$.description", is("수정된 채널 설명입니다."))); + } + + @Test + @DisplayName("채널 업데이트 실패 API 통합 테스트 - 존재하지 않는 채널") + void updateChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "수정된 채널", + "수정된 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", nonExistentChannelId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널 삭제 API 통합 테스트") + void deleteChannel_Success() throws Exception { + // Given + // 공개 채널 생성 + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "삭제할 채널", + "삭제할 채널 설명입니다." + ); + + ChannelDto createdChannel = channelService.create(createRequest); + UUID channelId = createdChannel.id(); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", channelId)) + .andExpect(status().isNoContent()); + + // 삭제 확인 - 사용자로 채널 조회 시 삭제된 채널은 조회되지 않아야 함 + UserCreateRequest userRequest = new UserCreateRequest( + "testuser", + "testuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + mockMvc.perform(get("/api/channels") + .param("userId", user.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == '" + channelId + "')]").doesNotExist()); + } + + @Test + @DisplayName("채널 삭제 실패 API 통합 테스트 - 존재하지 않는 채널") + void deleteChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", nonExistentChannelId)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java new file mode 100644 index 000000000..092575de3 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java @@ -0,0 +1,307 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.ArrayList; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class MessageApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MessageService messageService; + + @Autowired + private ChannelService channelService; + + @Autowired + private UserService userService; + + @Test + @DisplayName("메시지 생성 API 통합 테스트") + void createMessage_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + // 메시지 생성 요청 + MessageCreateRequest createRequest = new MessageCreateRequest( + "테스트 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile attachmentPart = new MockMultipartFile( + "attachments", + "test.txt", + MediaType.TEXT_PLAIN_VALUE, + "테스트 첨부 파일 내용".getBytes() + ); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart) + .file(attachmentPart)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.content", is("테스트 메시지 내용입니다."))) + .andExpect(jsonPath("$.channelId", is(channel.id().toString()))) + .andExpect(jsonPath("$.author.id", is(user.id().toString()))) + .andExpect(jsonPath("$.attachments", hasSize(1))) + .andExpect(jsonPath("$.attachments[0].fileName", is("test.txt"))); + } + + @Test + @DisplayName("메시지 생성 실패 API 통합 테스트 - 유효하지 않은 요청") + void createMessage_Failure_InvalidRequest() throws Exception { + // Given + MessageCreateRequest invalidRequest = new MessageCreateRequest( + "", // 내용이 비어있음 + UUID.randomUUID(), + UUID.randomUUID() + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("채널별 메시지 목록 조회 API 통합 테스트") + void findAllMessagesByChannelId_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + // 메시지 생성 + MessageCreateRequest messageRequest1 = new MessageCreateRequest( + "첫 번째 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MessageCreateRequest messageRequest2 = new MessageCreateRequest( + "두 번째 메시지 내용입니다.", + channel.id(), + user.id() + ); + + messageService.create(messageRequest1, new ArrayList<>()); + messageService.create(messageRequest2, new ArrayList<>()); + + // When & Then + mockMvc.perform(get("/api/messages") + .param("channelId", channel.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(2))) + .andExpect(jsonPath("$.content[0].content", is("두 번째 메시지 내용입니다."))) + .andExpect(jsonPath("$.content[1].content", is("첫 번째 메시지 내용입니다."))) + .andExpect(jsonPath("$.size").exists()) + .andExpect(jsonPath("$.hasNext").exists()) + .andExpect(jsonPath("$.totalElements").isEmpty()); + } + + @Test + @DisplayName("메시지 업데이트 API 통합 테스트") + void updateMessage_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + // 메시지 생성 + MessageCreateRequest createRequest = new MessageCreateRequest( + "원본 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MessageDto createdMessage = messageService.create(createRequest, new ArrayList<>()); + UUID messageId = createdMessage.id(); + + // 메시지 업데이트 요청 + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", messageId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(messageId.toString()))) + .andExpect(jsonPath("$.content", is("수정된 메시지 내용입니다."))) + .andExpect(jsonPath("$.updatedAt").exists()); + } + + @Test + @DisplayName("메시지 업데이트 실패 API 통합 테스트 - 존재하지 않는 메시지") + void updateMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", nonExistentMessageId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("메시지 삭제 API 통합 테스트") + void deleteMessage_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + // 메시지 생성 + MessageCreateRequest createRequest = new MessageCreateRequest( + "삭제할 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MessageDto createdMessage = messageService.create(createRequest, new ArrayList<>()); + UUID messageId = createdMessage.id(); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", messageId)) + .andExpect(status().isNoContent()); + + // 삭제 확인 - 채널의 메시지 목록 조회 시 삭제된 메시지는 조회되지 않아야 함 + mockMvc.perform(get("/api/messages") + .param("channelId", channel.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(0))); + } + + @Test + @DisplayName("메시지 삭제 실패 API 통합 테스트 - 존재하지 않는 메시지") + void deleteMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", nonExistentMessageId)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java new file mode 100644 index 000000000..8a93c8831 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java @@ -0,0 +1,266 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.ReadStatusService; +import com.sprint.mission.discodeit.service.UserService; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class ReadStatusApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ReadStatusService readStatusService; + + @Autowired + private UserService userService; + + @Autowired + private ChannelService channelService; + + @Test + @DisplayName("읽음 상태 생성 API 통합 테스트") + void createReadStatus_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "readstatususer", + "readstatus@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 공개 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "읽음 상태 테스트 채널", + "읽음 상태 테스트 채널 설명입니다." + ); + ChannelDto channel = channelService.create(channelRequest); + + // 읽음 상태 생성 요청 + Instant lastReadAt = Instant.now(); + ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + lastReadAt + ); + + String requestBody = objectMapper.writeValueAsString(createRequest); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.userId", is(user.id().toString()))) + .andExpect(jsonPath("$.channelId", is(channel.id().toString()))) + .andExpect(jsonPath("$.lastReadAt", is(lastReadAt.toString()))); + } + + @Test + @DisplayName("읽음 상태 생성 실패 API 통합 테스트 - 중복 생성") + void createReadStatus_Failure_Duplicate() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "duplicateuser", + "duplicate@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 공개 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "중복 테스트 채널", + "중복 테스트 채널 설명입니다." + ); + ChannelDto channel = channelService.create(channelRequest); + + // 첫 번째 읽음 상태 생성 요청 (성공) + Instant lastReadAt = Instant.now(); + ReadStatusCreateRequest firstCreateRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + lastReadAt + ); + + String firstRequestBody = objectMapper.writeValueAsString(firstCreateRequest); + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(firstRequestBody)) + .andExpect(status().isCreated()); + + // 두 번째 읽음 상태 생성 요청 (동일 사용자, 동일 채널) - 실패해야 함 + ReadStatusCreateRequest duplicateCreateRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + Instant.now() + ); + + String duplicateRequestBody = objectMapper.writeValueAsString(duplicateCreateRequest); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(duplicateRequestBody)) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("읽음 상태 업데이트 API 통합 테스트") + void updateReadStatus_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "updateuser", + "update@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 공개 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "업데이트 테스트 채널", + "업데이트 테스트 채널 설명입니다." + ); + ChannelDto channel = channelService.create(channelRequest); + + // 읽음 상태 생성 + Instant initialLastReadAt = Instant.now().minusSeconds(3600); // 1시간 전 + ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + initialLastReadAt + ); + + ReadStatusDto createdReadStatus = readStatusService.create(createRequest); + UUID readStatusId = createdReadStatus.id(); + + // 읽음 상태 업데이트 요청 + Instant newLastReadAt = Instant.now(); + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest( + newLastReadAt + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", readStatusId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(readStatusId.toString()))) + .andExpect(jsonPath("$.userId", is(user.id().toString()))) + .andExpect(jsonPath("$.channelId", is(channel.id().toString()))) + .andExpect(jsonPath("$.lastReadAt", is(newLastReadAt.toString()))); + } + + @Test + @DisplayName("읽음 상태 업데이트 실패 API 통합 테스트 - 존재하지 않는 읽음 상태") + void updateReadStatus_Failure_NotFound() throws Exception { + // Given + UUID nonExistentReadStatusId = UUID.randomUUID(); + + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest( + Instant.now() + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", nonExistentReadStatusId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자별 읽음 상태 목록 조회 API 통합 테스트") + void findAllReadStatusesByUserId_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "listuser", + "list@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 여러 채널 생성 + PublicChannelCreateRequest channelRequest1 = new PublicChannelCreateRequest( + "목록 테스트 채널 1", + "목록 테스트 채널 설명입니다." + ); + + PublicChannelCreateRequest channelRequest2 = new PublicChannelCreateRequest( + "목록 테스트 채널 2", + "목록 테스트 채널 설명입니다." + ); + + ChannelDto channel1 = channelService.create(channelRequest1); + ChannelDto channel2 = channelService.create(channelRequest2); + + // 각 채널에 대한 읽음 상태 생성 + ReadStatusCreateRequest createRequest1 = new ReadStatusCreateRequest( + user.id(), + channel1.id(), + Instant.now().minusSeconds(3600) + ); + + ReadStatusCreateRequest createRequest2 = new ReadStatusCreateRequest( + user.id(), + channel2.id(), + Instant.now() + ); + + readStatusService.create(createRequest1); + readStatusService.create(createRequest2); + + // When & Then + mockMvc.perform(get("/api/readStatuses") + .param("userId", user.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[*].channelId", + hasItems(channel1.id().toString(), channel2.id().toString()))); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java new file mode 100644 index 000000000..61d7895bf --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java @@ -0,0 +1,299 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.service.UserService; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class UserApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserService userService; + + + @Test + @DisplayName("사용자 생성 API 통합 테스트") + void createUser_Success() throws Exception { + // Given + UserCreateRequest createRequest = new UserCreateRequest( + "testuser", + "test@example.com", + "Password1!" + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test-image".getBytes() + ); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.username", is("testuser"))) + .andExpect(jsonPath("$.email", is("test@example.com"))) + .andExpect(jsonPath("$.profile.fileName", is("profile.jpg"))) + .andExpect(jsonPath("$.online", is(true))); + } + + @Test + @DisplayName("사용자 생성 실패 API 통합 테스트 - 유효하지 않은 요청") + void createUser_Failure_InvalidRequest() throws Exception { + // Given + UserCreateRequest invalidRequest = new UserCreateRequest( + "t", // 최소 길이 위반 + "invalid-email", // 이메일 형식 위반 + "short" // 비밀번호 정책 위반 + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("모든 사용자 조회 API 통합 테스트") + void findAllUsers_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest userRequest1 = new UserCreateRequest( + "user1", + "user1@example.com", + "Password1!" + ); + + UserCreateRequest userRequest2 = new UserCreateRequest( + "user2", + "user2@example.com", + "Password1!" + ); + + userService.create(userRequest1, Optional.empty()); + userService.create(userRequest2, Optional.empty()); + + // When & Then + mockMvc.perform(get("/api/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].username", is("user1"))) + .andExpect(jsonPath("$[0].email", is("user1@example.com"))) + .andExpect(jsonPath("$[1].username", is("user2"))) + .andExpect(jsonPath("$[1].email", is("user2@example.com"))); + } + + @Test + @DisplayName("사용자 업데이트 API 통합 테스트") + void updateUser_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest createRequest = new UserCreateRequest( + "originaluser", + "original@example.com", + "Password1!" + ); + + UserDto createdUser = userService.create(createRequest, Optional.empty()); + UUID userId = createdUser.id(); + + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "updated-profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "updated-image".getBytes() + ); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", userId) + .file(userUpdateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(userId.toString()))) + .andExpect(jsonPath("$.username", is("updateduser"))) + .andExpect(jsonPath("$.email", is("updated@example.com"))) + .andExpect(jsonPath("$.profile.fileName", is("updated-profile.jpg"))); + } + + @Test + @DisplayName("사용자 업데이트 실패 API 통합 테스트 - 존재하지 않는 사용자") + void updateUser_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", nonExistentUserId) + .file(userUpdateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자 삭제 API 통합 테스트") + void deleteUser_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest createRequest = new UserCreateRequest( + "deleteuser", + "delete@example.com", + "Password1!" + ); + + UserDto createdUser = userService.create(createRequest, Optional.empty()); + UUID userId = createdUser.id(); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", userId)) + .andExpect(status().isNoContent()); + + // 삭제 확인 + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == '" + userId + "')]").doesNotExist()); + } + + @Test + @DisplayName("사용자 삭제 실패 API 통합 테스트 - 존재하지 않는 사용자") + void deleteUser_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", nonExistentUserId)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자 상태 업데이트 API 통합 테스트") + void updateUserStatus_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest createRequest = new UserCreateRequest( + "statususer", + "status@example.com", + "Password1!" + ); + + UserDto createdUser = userService.create(createRequest, Optional.empty()); + UUID userId = createdUser.id(); + + Instant newLastActiveAt = Instant.now(); + UserStatusUpdateRequest statusUpdateRequest = new UserStatusUpdateRequest( + newLastActiveAt + ); + String requestBody = objectMapper.writeValueAsString(statusUpdateRequest); + + // When & Then + mockMvc.perform(patch("/api/users/{userId}/userStatus", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lastActiveAt", is(newLastActiveAt.toString()))); + } + + @Test + @DisplayName("사용자 상태 업데이트 실패 API 통합 테스트 - 존재하지 않는 사용자") + void updateUserStatus_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + UserStatusUpdateRequest statusUpdateRequest = new UserStatusUpdateRequest( + Instant.now() + ); + String requestBody = objectMapper.writeValueAsString(statusUpdateRequest); + + // When & Then + mockMvc.perform(patch("/api/users/{userId}/userStatus", nonExistentUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java new file mode 100644 index 000000000..6d4563153 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java @@ -0,0 +1,96 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * ChannelRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class ChannelRepositoryTest { + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 채널 생성용 테스트 픽스처 + */ + private Channel createTestChannel(ChannelType type, String name) { + Channel channel = new Channel(type, name, "설명: " + name); + return channelRepository.save(channel); + } + + @Test + @DisplayName("타입이 PUBLIC이거나 ID 목록에 포함된 채널을 모두 조회할 수 있다") + void findAllByTypeOrIdIn_ReturnsChannels() { + // given + Channel publicChannel1 = createTestChannel(ChannelType.PUBLIC, "공개채널1"); + Channel publicChannel2 = createTestChannel(ChannelType.PUBLIC, "공개채널2"); + Channel privateChannel1 = createTestChannel(ChannelType.PRIVATE, "비공개채널1"); + Channel privateChannel2 = createTestChannel(ChannelType.PRIVATE, "비공개채널2"); + + channelRepository.saveAll( + Arrays.asList(publicChannel1, publicChannel2, privateChannel1, privateChannel2)); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List selectedPrivateIds = List.of(privateChannel1.getId()); + List foundChannels = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, + selectedPrivateIds); + + // then + assertThat(foundChannels).hasSize(3); // 공개채널 2개 + 선택된 비공개채널 1개 + + // 공개 채널 2개가 모두 포함되어 있는지 확인 + assertThat( + foundChannels.stream().filter(c -> c.getType() == ChannelType.PUBLIC).count()).isEqualTo(2); + + // 선택된 비공개 채널만 포함되어 있는지 확인 + List privateChannels = foundChannels.stream() + .filter(c -> c.getType() == ChannelType.PRIVATE) + .toList(); + assertThat(privateChannels).hasSize(1); + assertThat(privateChannels.get(0).getId()).isEqualTo(privateChannel1.getId()); + } + + @Test + @DisplayName("타입이 PUBLIC이 아니고 ID 목록이 비어있으면 비어있는 리스트를 반환한다") + void findAllByTypeOrIdIn_EmptyList_ReturnsEmptyList() { + // given + Channel privateChannel1 = createTestChannel(ChannelType.PRIVATE, "비공개채널1"); + Channel privateChannel2 = createTestChannel(ChannelType.PRIVATE, "비공개채널2"); + + channelRepository.saveAll(Arrays.asList(privateChannel1, privateChannel2)); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List foundChannels = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, + List.of()); + + // then + assertThat(foundChannels).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java new file mode 100644 index 000000000..3207ebb06 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java @@ -0,0 +1,221 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * MessageRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class MessageRepositoryTest { + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트용 사용자 생성 + */ + private User createTestUser(String username, String email) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + // UserStatus 생성 및 연결 + UserStatus status = new UserStatus(user, Instant.now()); + return userRepository.save(user); + } + + /** + * TestFixture: 테스트용 채널 생성 + */ + private Channel createTestChannel(ChannelType type, String name) { + Channel channel = new Channel(type, name, "설명: " + name); + return channelRepository.save(channel); + } + + /** + * TestFixture: 테스트용 메시지 생성 ReflectionTestUtils를 사용하여 createdAt 필드를 직접 설정 + */ + private Message createTestMessage(String content, Channel channel, User author, + Instant createdAt) { + Message message = new Message(content, channel, author, new ArrayList<>()); + + // 생성 시간이 지정된 경우, ReflectionTestUtils로 설정 + if (createdAt != null) { + ReflectionTestUtils.setField(message, "createdAt", createdAt); + } + + Message savedMessage = messageRepository.save(message); + entityManager.flush(); + + return savedMessage; + } + + @Test + @DisplayName("채널 ID와 생성 시간으로 메시지를 페이징하여 조회할 수 있다") + void findAllByChannelIdWithAuthor_ReturnsMessagesWithAuthor() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널"); + + Instant now = Instant.now(); + Instant fiveMinutesAgo = now.minus(5, ChronoUnit.MINUTES); + Instant tenMinutesAgo = now.minus(10, ChronoUnit.MINUTES); + + // 채널에 세 개의 메시지 생성 (시간 순서대로) + Message message1 = createTestMessage("첫 번째 메시지", channel, user, tenMinutesAgo); + Message message2 = createTestMessage("두 번째 메시지", channel, user, fiveMinutesAgo); + Message message3 = createTestMessage("세 번째 메시지", channel, user, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when - 최신 메시지보다 이전 시간으로 조회 + Slice messages = messageRepository.findAllByChannelIdWithAuthor( + channel.getId(), + now.plus(1, ChronoUnit.MINUTES), // 현재 시간보다 더 미래 + PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + + // then + assertThat(messages).isNotNull(); + assertThat(messages.hasContent()).isTrue(); + assertThat(messages.getNumberOfElements()).isEqualTo(2); // 페이지 크기 만큼만 반환 + assertThat(messages.hasNext()).isTrue(); + + // 시간 역순(최신순)으로 정렬되어 있는지 확인 + List content = messages.getContent(); + assertThat(content.get(0).getCreatedAt()).isAfterOrEqualTo(content.get(1).getCreatedAt()); + + // 저자 정보가 함께 로드되었는지 확인 (FETCH JOIN) + Message firstMessage = content.get(0); + assertThat(Hibernate.isInitialized(firstMessage.getAuthor())).isTrue(); + assertThat(Hibernate.isInitialized(firstMessage.getAuthor().getStatus())).isTrue(); + assertThat(Hibernate.isInitialized(firstMessage.getAuthor().getProfile())).isTrue(); + } + + @Test + @DisplayName("채널의 마지막 메시지 시간을 조회할 수 있다") + void findLastMessageAtByChannelId_ReturnsLastMessageTime() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널"); + + Instant now = Instant.now(); + Instant fiveMinutesAgo = now.minus(5, ChronoUnit.MINUTES); + Instant tenMinutesAgo = now.minus(10, ChronoUnit.MINUTES); + + // 채널에 세 개의 메시지 생성 (시간 순서대로) + createTestMessage("첫 번째 메시지", channel, user, tenMinutesAgo); + createTestMessage("두 번째 메시지", channel, user, fiveMinutesAgo); + Message lastMessage = createTestMessage("세 번째 메시지", channel, user, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional lastMessageAt = messageRepository.findLastMessageAtByChannelId( + channel.getId()); + + // then + assertThat(lastMessageAt).isPresent(); + // 마지막 메시지 시간과 일치하는지 확인 (밀리초 단위 이하의 차이는 무시) + assertThat(lastMessageAt.get().truncatedTo(ChronoUnit.MILLIS)) + .isEqualTo(lastMessage.getCreatedAt().truncatedTo(ChronoUnit.MILLIS)); + } + + @Test + @DisplayName("메시지가 없는 채널에서는 마지막 메시지 시간이 없다") + void findLastMessageAtByChannelId_NoMessages_ReturnsEmpty() { + // given + Channel emptyChannel = createTestChannel(ChannelType.PUBLIC, "빈채널"); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional lastMessageAt = messageRepository.findLastMessageAtByChannelId( + emptyChannel.getId()); + + // then + assertThat(lastMessageAt).isEmpty(); + } + + @Test + @DisplayName("채널의 모든 메시지를 삭제할 수 있다") + void deleteAllByChannelId_DeletesAllMessages() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널"); + Channel otherChannel = createTestChannel(ChannelType.PUBLIC, "다른채널"); + + // 테스트 채널에 메시지 3개 생성 + createTestMessage("첫 번째 메시지", channel, user, null); + createTestMessage("두 번째 메시지", channel, user, null); + createTestMessage("세 번째 메시지", channel, user, null); + + // 다른 채널에 메시지 1개 생성 + createTestMessage("다른 채널 메시지", otherChannel, user, null); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + messageRepository.deleteAllByChannelId(channel.getId()); + entityManager.flush(); + entityManager.clear(); + + // then + // 해당 채널의 메시지는 삭제되었는지 확인 + List channelMessages = messageRepository.findAllByChannelIdWithAuthor( + channel.getId(), + Instant.now().plus(1, ChronoUnit.DAYS), + PageRequest.of(0, 100) + ).getContent(); + assertThat(channelMessages).isEmpty(); + + // 다른 채널의 메시지는 그대로인지 확인 + List otherChannelMessages = messageRepository.findAllByChannelIdWithAuthor( + otherChannel.getId(), + Instant.now().plus(1, ChronoUnit.DAYS), + PageRequest.of(0, 100) + ).getContent(); + assertThat(otherChannelMessages).hasSize(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java new file mode 100644 index 000000000..3dc797ca4 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java @@ -0,0 +1,199 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * ReadStatusRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class ReadStatusRepositoryTest { + + @Autowired + private ReadStatusRepository readStatusRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트용 사용자 생성 + */ + private User createTestUser(String username, String email) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + // UserStatus 생성 및 연결 + UserStatus status = new UserStatus(user, Instant.now()); + return userRepository.save(user); + } + + /** + * TestFixture: 테스트용 채널 생성 + */ + private Channel createTestChannel(ChannelType type, String name) { + Channel channel = new Channel(type, name, "설명: " + name); + return channelRepository.save(channel); + } + + /** + * TestFixture: 테스트용 읽음 상태 생성 + */ + private ReadStatus createTestReadStatus(User user, Channel channel, Instant lastReadAt) { + ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt); + return readStatusRepository.save(readStatus); + } + + @Test + @DisplayName("사용자 ID로 모든 읽음 상태를 조회할 수 있다") + void findAllByUserId_ReturnsReadStatuses() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel1 = createTestChannel(ChannelType.PUBLIC, "채널1"); + Channel channel2 = createTestChannel(ChannelType.PRIVATE, "채널2"); + + Instant now = Instant.now(); + ReadStatus readStatus1 = createTestReadStatus(user, channel1, now.minus(1, ChronoUnit.DAYS)); + ReadStatus readStatus2 = createTestReadStatus(user, channel2, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List readStatuses = readStatusRepository.findAllByUserId(user.getId()); + + // then + assertThat(readStatuses).hasSize(2); + } + + @Test + @DisplayName("채널 ID로 모든 읽음 상태를 사용자 정보와 함께 조회할 수 있다") + void findAllByChannelIdWithUser_ReturnsReadStatusesWithUser() { + // given + User user1 = createTestUser("user1", "user1@example.com"); + User user2 = createTestUser("user2", "user2@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널"); + + Instant now = Instant.now(); + ReadStatus readStatus1 = createTestReadStatus(user1, channel, now.minus(1, ChronoUnit.DAYS)); + ReadStatus readStatus2 = createTestReadStatus(user2, channel, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List readStatuses = readStatusRepository.findAllByChannelIdWithUser( + channel.getId()); + + // then + assertThat(readStatuses).hasSize(2); + + // 사용자 정보가 함께 로드되었는지 확인 (FETCH JOIN) + for (ReadStatus status : readStatuses) { + assertThat(Hibernate.isInitialized(status.getUser())).isTrue(); + assertThat(Hibernate.isInitialized(status.getUser().getStatus())).isTrue(); + assertThat(Hibernate.isInitialized(status.getUser().getProfile())).isTrue(); + } + } + + @Test + @DisplayName("사용자 ID와 채널 ID로 읽음 상태 존재 여부를 확인할 수 있다") + void existsByUserIdAndChannelId_ExistingStatus_ReturnsTrue() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널"); + + ReadStatus readStatus = createTestReadStatus(user, channel, Instant.now()); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Boolean exists = readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId()); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 읽음 상태에 대해 false를 반환한다") + void existsByUserIdAndChannelId_NonExistingStatus_ReturnsFalse() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널"); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // 읽음 상태를 생성하지 않음 + + // when + Boolean exists = readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId()); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("채널의 모든 읽음 상태를 삭제할 수 있다") + void deleteAllByChannelId_DeletesAllReadStatuses() { + // given + User user1 = createTestUser("user1", "user1@example.com"); + User user2 = createTestUser("user2", "user2@example.com"); + + Channel channel = createTestChannel(ChannelType.PUBLIC, "삭제할채널"); + Channel otherChannel = createTestChannel(ChannelType.PUBLIC, "유지할채널"); + + // 삭제할 채널에 읽음 상태 2개 생성 + createTestReadStatus(user1, channel, Instant.now()); + createTestReadStatus(user2, channel, Instant.now()); + + // 유지할 채널에 읽음 상태 1개 생성 + createTestReadStatus(user1, otherChannel, Instant.now()); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + readStatusRepository.deleteAllByChannelId(channel.getId()); + entityManager.flush(); + entityManager.clear(); + + // then + // 해당 채널의 읽음 상태는 삭제되었는지 확인 + List channelReadStatuses = readStatusRepository.findAllByChannelIdWithUser(channel.getId()); + assertThat(channelReadStatuses).isEmpty(); + + // 다른 채널의 읽음 상태는 그대로인지 확인 + List otherChannelReadStatuses = readStatusRepository.findAllByChannelIdWithUser(otherChannel.getId()); + assertThat(otherChannelReadStatuses).hasSize(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java new file mode 100644 index 000000000..84f360a2d --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java @@ -0,0 +1,138 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * UserRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트에서 일관된 상태를 제공하기 위한 고정된 객체 세트 여러 테스트에서 재사용할 수 있는 테스트 데이터를 생성하는 메서드 + */ + private User createTestUser(String username, String email) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + // UserStatus 생성 및 연결 + UserStatus status = new UserStatus(user, Instant.now()); + return user; + } + + @Test + @DisplayName("사용자 이름으로 사용자를 찾을 수 있다") + void findByUsername_ExistingUsername_ReturnsUser() { + // given + String username = "testUser"; + User user = createTestUser(username, "test@example.com"); + userRepository.save(user); + + // 영속성 컨텍스트 초기화 - 1차 캐시 비우기 + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundUser = userRepository.findByUsername(username); + + // then + assertThat(foundUser).isPresent(); + assertThat(foundUser.get().getUsername()).isEqualTo(username); + } + + @Test + @DisplayName("존재하지 않는 사용자 이름으로 검색하면 빈 Optional을 반환한다") + void findByUsername_NonExistingUsername_ReturnsEmptyOptional() { + // given + String nonExistingUsername = "nonExistingUser"; + + // when + Optional foundUser = userRepository.findByUsername(nonExistingUsername); + + // then + assertThat(foundUser).isEmpty(); + } + + @Test + @DisplayName("이메일로 사용자 존재 여부를 확인할 수 있다") + void existsByEmail_ExistingEmail_ReturnsTrue() { + // given + String email = "test@example.com"; + User user = createTestUser("testUser", email); + userRepository.save(user); + + // when + boolean exists = userRepository.existsByEmail(email); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 이메일로 확인하면 false를 반환한다") + void existsByEmail_NonExistingEmail_ReturnsFalse() { + // given + String nonExistingEmail = "nonexisting@example.com"; + + // when + boolean exists = userRepository.existsByEmail(nonExistingEmail); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("모든 사용자를 프로필과 상태 정보와 함께 조회할 수 있다") + void findAllWithProfileAndStatus_ReturnsUsersWithProfileAndStatus() { + // given + User user1 = createTestUser("user1", "user1@example.com"); + User user2 = createTestUser("user2", "user2@example.com"); + + userRepository.saveAll(List.of(user1, user2)); + + // 영속성 컨텍스트 초기화 - 1차 캐시 비우기 + entityManager.flush(); + entityManager.clear(); + + // when + List users = userRepository.findAllWithProfileAndStatus(); + + // then + assertThat(users).hasSize(2); + assertThat(users).extracting("username").containsExactlyInAnyOrder("user1", "user2"); + + // 프로필과 상태 정보가 함께 조회되었는지 확인 - 프록시 초기화 없이도 접근 가능한지 테스트 + User foundUser1 = users.stream().filter(u -> u.getUsername().equals("user1")).findFirst() + .orElseThrow(); + User foundUser2 = users.stream().filter(u -> u.getUsername().equals("user2")).findFirst() + .orElseThrow(); + + // 프록시 초기화 여부 확인 + assertThat(Hibernate.isInitialized(foundUser1.getProfile())).isTrue(); + assertThat(Hibernate.isInitialized(foundUser1.getStatus())).isTrue(); + assertThat(Hibernate.isInitialized(foundUser2.getProfile())).isTrue(); + assertThat(Hibernate.isInitialized(foundUser2.getStatus())).isTrue(); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java new file mode 100644 index 000000000..9b4ca2ab7 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java @@ -0,0 +1,118 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * UserStatusRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class UserStatusRepositoryTest { + + @Autowired + private UserStatusRepository userStatusRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트용 사용자와 상태 생성 + */ + private User createTestUserWithStatus(String username, String email, Instant lastActiveAt) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + UserStatus status = new UserStatus(user, lastActiveAt); + return userRepository.save(user); + } + + @Test + @DisplayName("사용자 ID로 상태 정보를 찾을 수 있다") + void findByUserId_ExistingUserId_ReturnsUserStatus() { + // given + Instant now = Instant.now(); + User user = createTestUserWithStatus("testUser", "test@example.com", now); + UUID userId = user.getId(); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundStatus = userStatusRepository.findByUserId(userId); + + // then + assertThat(foundStatus).isPresent(); + assertThat(foundStatus.get().getUser().getId()).isEqualTo(userId); + assertThat(foundStatus.get().getLastActiveAt()).isEqualTo(now); + } + + @Test + @DisplayName("존재하지 않는 사용자 ID로 검색하면 빈 Optional을 반환한다") + void findByUserId_NonExistingUserId_ReturnsEmptyOptional() { + // given + UUID nonExistingUserId = UUID.randomUUID(); + + // when + Optional foundStatus = userStatusRepository.findByUserId(nonExistingUserId); + + // then + assertThat(foundStatus).isEmpty(); + } + + @Test + @DisplayName("UserStatus의 isOnline 메서드는 최근 활동 시간이 5분 이내일 때 true를 반환한다") + void isOnline_LastActiveWithinFiveMinutes_ReturnsTrue() { + // given + Instant now = Instant.now(); + User user = createTestUserWithStatus("testUser", "test@example.com", now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundStatus = userStatusRepository.findByUserId(user.getId()); + + // then + assertThat(foundStatus).isPresent(); + assertThat(foundStatus.get().isOnline()).isTrue(); + } + + @Test + @DisplayName("UserStatus의 isOnline 메서드는 최근 활동 시간이 5분보다 이전일 때 false를 반환한다") + void isOnline_LastActiveBeforeFiveMinutes_ReturnsFalse() { + // given + Instant sixMinutesAgo = Instant.now().minus(6, ChronoUnit.MINUTES); + User user = createTestUserWithStatus("testUser", "test@example.com", sixMinutesAgo); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundStatus = userStatusRepository.findByUserId(user.getId()); + + // then + assertThat(foundStatus).isPresent(); + assertThat(foundStatus.get().isOnline()).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java new file mode 100644 index 000000000..fba3f3d65 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java @@ -0,0 +1,172 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicBinaryContentServiceTest { + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private BinaryContentMapper binaryContentMapper; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @InjectMocks + private BasicBinaryContentService binaryContentService; + + private UUID binaryContentId; + private String fileName; + private String contentType; + private byte[] bytes; + private BinaryContent binaryContent; + private BinaryContentDto binaryContentDto; + + @BeforeEach + void setUp() { + binaryContentId = UUID.randomUUID(); + fileName = "test.jpg"; + contentType = "image/jpeg"; + bytes = "test data".getBytes(); + + binaryContent = new BinaryContent(fileName, (long) bytes.length, contentType); + ReflectionTestUtils.setField(binaryContent, "id", binaryContentId); + + binaryContentDto = new BinaryContentDto( + binaryContentId, + fileName, + (long) bytes.length, + contentType + ); + } + + @Test + @DisplayName("바이너리 콘텐츠 생성 성공") + void createBinaryContent_Success() { + // given + BinaryContentCreateRequest request = new BinaryContentCreateRequest(fileName, contentType, + bytes); + + given(binaryContentRepository.save(any(BinaryContent.class))).will(invocation -> { + BinaryContent binaryContent = invocation.getArgument(0); + ReflectionTestUtils.setField(binaryContent, "id", binaryContentId); + return binaryContent; + }); + given(binaryContentMapper.toDto(any(BinaryContent.class))).willReturn(binaryContentDto); + + // when + BinaryContentDto result = binaryContentService.create(request); + + // then + assertThat(result).isEqualTo(binaryContentDto); + verify(binaryContentRepository).save(any(BinaryContent.class)); + verify(binaryContentStorage).put(binaryContentId, bytes); + } + + @Test + @DisplayName("바이너리 콘텐츠 조회 성공") + void findBinaryContent_Success() { + // given + given(binaryContentRepository.findById(eq(binaryContentId))).willReturn( + Optional.of(binaryContent)); + given(binaryContentMapper.toDto(eq(binaryContent))).willReturn(binaryContentDto); + + // when + BinaryContentDto result = binaryContentService.find(binaryContentId); + + // then + assertThat(result).isEqualTo(binaryContentDto); + } + + @Test + @DisplayName("존재하지 않는 바이너리 콘텐츠 조회 시 예외 발생") + void findBinaryContent_WithNonExistentId_ThrowsException() { + // given + given(binaryContentRepository.findById(eq(binaryContentId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> binaryContentService.find(binaryContentId)) + .isInstanceOf(BinaryContentNotFoundException.class); + } + + @Test + @DisplayName("여러 ID로 바이너리 콘텐츠 목록 조회 성공") + void findAllByIdIn_Success() { + // given + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + List ids = Arrays.asList(id1, id2); + + BinaryContent content1 = new BinaryContent("file1.jpg", 100L, "image/jpeg"); + ReflectionTestUtils.setField(content1, "id", id1); + + BinaryContent content2 = new BinaryContent("file2.jpg", 200L, "image/png"); + ReflectionTestUtils.setField(content2, "id", id2); + + List contents = Arrays.asList(content1, content2); + + BinaryContentDto dto1 = new BinaryContentDto(id1, "file1.jpg", 100L, "image/jpeg"); + BinaryContentDto dto2 = new BinaryContentDto(id2, "file2.jpg", 200L, "image/png"); + + given(binaryContentRepository.findAllById(eq(ids))).willReturn(contents); + given(binaryContentMapper.toDto(eq(content1))).willReturn(dto1); + given(binaryContentMapper.toDto(eq(content2))).willReturn(dto2); + + // when + List result = binaryContentService.findAllByIdIn(ids); + + // then + assertThat(result).containsExactly(dto1, dto2); + } + + @Test + @DisplayName("바이너리 콘텐츠 삭제 성공") + void deleteBinaryContent_Success() { + // given + given(binaryContentRepository.existsById(binaryContentId)).willReturn(true); + + // when + binaryContentService.delete(binaryContentId); + + // then + verify(binaryContentRepository).deleteById(binaryContentId); + } + + @Test + @DisplayName("존재하지 않는 바이너리 콘텐츠 삭제 시 예외 발생") + void deleteBinaryContent_WithNonExistentId_ThrowsException() { + // given + given(binaryContentRepository.existsById(eq(binaryContentId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> binaryContentService.delete(binaryContentId)) + .isInstanceOf(BinaryContentNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java new file mode 100644 index 000000000..da1dd0ca0 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java @@ -0,0 +1,228 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicChannelServiceTest { + + @Mock + private ChannelRepository channelRepository; + + @Mock + private ReadStatusRepository readStatusRepository; + + @Mock + private MessageRepository messageRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChannelMapper channelMapper; + + @InjectMocks + private BasicChannelService channelService; + + private UUID channelId; + private UUID userId; + private String channelName; + private String channelDescription; + private Channel channel; + private ChannelDto channelDto; + private User user; + + @BeforeEach + void setUp() { + channelId = UUID.randomUUID(); + userId = UUID.randomUUID(); + channelName = "testChannel"; + channelDescription = "testDescription"; + + channel = new Channel(ChannelType.PUBLIC, channelName, channelDescription); + ReflectionTestUtils.setField(channel, "id", channelId); + channelDto = new ChannelDto(channelId, ChannelType.PUBLIC, channelName, channelDescription, + List.of(), Instant.now()); + user = new User("testUser", "test@example.com", "password", null); + } + + @Test + @DisplayName("공개 채널 생성 성공") + void createPublicChannel_Success() { + // given + PublicChannelCreateRequest request = new PublicChannelCreateRequest(channelName, + channelDescription); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.create(request); + + // then + assertThat(result).isEqualTo(channelDto); + verify(channelRepository).save(any(Channel.class)); + } + + @Test + @DisplayName("비공개 채널 생성 성공") + void createPrivateChannel_Success() { + // given + List participantIds = List.of(userId); + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(participantIds); + given(userRepository.findAllById(eq(participantIds))).willReturn(List.of(user)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.create(request); + + // then + assertThat(result).isEqualTo(channelDto); + verify(channelRepository).save(any(Channel.class)); + verify(readStatusRepository).saveAll(anyList()); + } + + @Test + @DisplayName("채널 조회 성공") + void findChannel_Success() { + // given + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.find(channelId); + + // then + assertThat(result).isEqualTo(channelDto); + } + + @Test + @DisplayName("존재하지 않는 채널 조회 시 실패") + void findChannel_WithNonExistentId_ThrowsException() { + // given + given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> channelService.find(channelId)) + .isInstanceOf(ChannelNotFoundException.class); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 성공") + void findAllByUserId_Success() { + // given + List readStatuses = List.of(new ReadStatus(user, channel, Instant.now())); + given(readStatusRepository.findAllByUserId(eq(userId))).willReturn(readStatuses); + given(channelRepository.findAllByTypeOrIdIn(eq(ChannelType.PUBLIC), eq(List.of(channel.getId())))) + .willReturn(List.of(channel)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + List result = channelService.findAllByUserId(userId); + + // then + assertThat(result).containsExactly(channelDto); + } + + @Test + @DisplayName("공개 채널 수정 성공") + void updatePublicChannel_Success() { + // given + String newName = "newChannelName"; + String newDescription = "newDescription"; + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest(newName, newDescription); + + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.update(channelId, request); + + // then + assertThat(result).isEqualTo(channelDto); + } + + @Test + @DisplayName("비공개 채널 수정 시도 시 실패") + void updatePrivateChannel_ThrowsException() { + // given + Channel privateChannel = new Channel(ChannelType.PRIVATE, null, null); + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName", + "newDescription"); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(privateChannel)); + + // when & then + assertThatThrownBy(() -> channelService.update(channelId, request)) + .isInstanceOf(PrivateChannelUpdateException.class); + } + + @Test + @DisplayName("존재하지 않는 채널 수정 시도 시 실패") + void updateChannel_WithNonExistentId_ThrowsException() { + // given + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName", + "newDescription"); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> channelService.update(channelId, request)) + .isInstanceOf(ChannelNotFoundException.class); + } + + @Test + @DisplayName("채널 삭제 성공") + void deleteChannel_Success() { + // given + given(channelRepository.existsById(eq(channelId))).willReturn(true); + + // when + channelService.delete(channelId); + + // then + verify(messageRepository).deleteAllByChannelId(eq(channelId)); + verify(readStatusRepository).deleteAllByChannelId(eq(channelId)); + verify(channelRepository).deleteById(eq(channelId)); + } + + @Test + @DisplayName("존재하지 않는 채널 삭제 시도 시 실패") + void deleteChannel_WithNonExistentId_ThrowsException() { + // given + given(channelRepository.existsById(eq(channelId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> channelService.delete(channelId)) + .isInstanceOf(ChannelNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java new file mode 100644 index 000000000..c00150bab --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java @@ -0,0 +1,365 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.PageResponseMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.SliceImpl; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicMessageServiceTest { + + @Mock + private MessageRepository messageRepository; + + @Mock + private ChannelRepository channelRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private MessageMapper messageMapper; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private PageResponseMapper pageResponseMapper; + + @InjectMocks + private BasicMessageService messageService; + + private UUID messageId; + private UUID channelId; + private UUID authorId; + private String content; + private Message message; + private MessageDto messageDto; + private Channel channel; + private User author; + private BinaryContent attachment; + private BinaryContentDto attachmentDto; + + @BeforeEach + void setUp() { + messageId = UUID.randomUUID(); + channelId = UUID.randomUUID(); + authorId = UUID.randomUUID(); + content = "test message"; + + channel = new Channel(ChannelType.PUBLIC, "testChannel", "testDescription"); + ReflectionTestUtils.setField(channel, "id", channelId); + + author = new User("testUser", "test@example.com", "password", null); + ReflectionTestUtils.setField(author, "id", authorId); + + attachment = new BinaryContent("test.txt", 100L, "text/plain"); + ReflectionTestUtils.setField(attachment, "id", UUID.randomUUID()); + attachmentDto = new BinaryContentDto(attachment.getId(), "test.txt", 100L, "text/plain"); + + message = new Message(content, channel, author, List.of(attachment)); + ReflectionTestUtils.setField(message, "id", messageId); + + messageDto = new MessageDto( + messageId, + Instant.now(), + Instant.now(), + content, + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true), + List.of(attachmentDto) + ); + } + + @Test + @DisplayName("메시지 생성 성공") + void createMessage_Success() { + // given + MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId); + BinaryContentCreateRequest attachmentRequest = new BinaryContentCreateRequest("test.txt", "text/plain", new byte[100]); + List attachmentRequests = List.of(attachmentRequest); + + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(userRepository.findById(eq(authorId))).willReturn(Optional.of(author)); + given(binaryContentRepository.save(any(BinaryContent.class))).will(invocation -> { + BinaryContent binaryContent = invocation.getArgument(0); + ReflectionTestUtils.setField(binaryContent, "id", attachment.getId()); + return attachment; + }); + given(messageRepository.save(any(Message.class))).willReturn(message); + given(messageMapper.toDto(any(Message.class))).willReturn(messageDto); + + // when + MessageDto result = messageService.create(request, attachmentRequests); + + // then + assertThat(result).isEqualTo(messageDto); + verify(messageRepository).save(any(Message.class)); + verify(binaryContentStorage).put(eq(attachment.getId()), any(byte[].class)); + } + + @Test + @DisplayName("존재하지 않는 채널에 메시지 생성 시도 시 실패") + void createMessage_WithNonExistentChannel_ThrowsException() { + // given + MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.create(request, List.of())) + .isInstanceOf(ChannelNotFoundException.class); + } + + @Test + @DisplayName("존재하지 않는 작성자로 메시지 생성 시도 시 실패") + void createMessage_WithNonExistentAuthor_ThrowsException() { + // given + MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(userRepository.findById(eq(authorId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.create(request, List.of())) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("메시지 조회 성공") + void findMessage_Success() { + // given + given(messageRepository.findById(eq(messageId))).willReturn(Optional.of(message)); + given(messageMapper.toDto(eq(message))).willReturn(messageDto); + + // when + MessageDto result = messageService.find(messageId); + + // then + assertThat(result).isEqualTo(messageDto); + } + + @Test + @DisplayName("존재하지 않는 메시지 조회 시 실패") + void findMessage_WithNonExistentId_ThrowsException() { + // given + given(messageRepository.findById(eq(messageId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.find(messageId)) + .isInstanceOf(MessageNotFoundException.class); + } + + @Test + @DisplayName("채널별 메시지 목록 조회 성공") + void findAllByChannelId_Success() { + // given + int pageSize = 2; // 페이지 크기를 2로 설정 + Instant createdAt = Instant.now(); + Pageable pageable = PageRequest.of(0, pageSize); + + // 여러 메시지 생성 (페이지 사이즈보다 많게) + Message message1 = new Message(content + "1", channel, author, List.of(attachment)); + Message message2 = new Message(content + "2", channel, author, List.of(attachment)); + Message message3 = new Message(content + "3", channel, author, List.of(attachment)); + + ReflectionTestUtils.setField(message1, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(message2, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(message3, "id", UUID.randomUUID()); + + // 각 메시지에 해당하는 DTO 생성 + Instant message1CreatedAt = Instant.now().minusSeconds(30); + Instant message2CreatedAt = Instant.now().minusSeconds(20); + Instant message3CreatedAt = Instant.now().minusSeconds(10); + + ReflectionTestUtils.setField(message1, "createdAt", message1CreatedAt); + ReflectionTestUtils.setField(message2, "createdAt", message2CreatedAt); + ReflectionTestUtils.setField(message3, "createdAt", message3CreatedAt); + + MessageDto messageDto1 = new MessageDto( + message1.getId(), + message1CreatedAt, + message1CreatedAt, + content + "1", + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true), + List.of(attachmentDto) + ); + + MessageDto messageDto2 = new MessageDto( + message2.getId(), + message2CreatedAt, + message2CreatedAt, + content + "2", + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true), + List.of(attachmentDto) + ); + + // 첫 페이지 결과 세팅 (2개 메시지) + List firstPageMessages = List.of(message1, message2); + List firstPageDtos = List.of(messageDto1, messageDto2); + + // 첫 페이지는 다음 페이지가 있고, 커서는 message2의 생성 시간이어야 함 + SliceImpl firstPageSlice = new SliceImpl<>(firstPageMessages, pageable, true); + PageResponse firstPageResponse = new PageResponse<>( + firstPageDtos, + message2CreatedAt, + pageSize, + true, + null + ); + + // 모의 객체 설정 + given(messageRepository.findAllByChannelIdWithAuthor(eq(channelId), eq(createdAt), eq(pageable))) + .willReturn(firstPageSlice); + given(messageMapper.toDto(eq(message1))).willReturn(messageDto1); + given(messageMapper.toDto(eq(message2))).willReturn(messageDto2); + given(pageResponseMapper.fromSlice(any(), eq(message2CreatedAt))) + .willReturn(firstPageResponse); + + // when + PageResponse result = messageService.findAllByChannelId(channelId, createdAt, + pageable); + + // then + assertThat(result).isEqualTo(firstPageResponse); + assertThat(result.content()).hasSize(pageSize); + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isEqualTo(message2CreatedAt); + + // 두 번째 페이지 테스트 + // given + List secondPageMessages = List.of(message3); + MessageDto messageDto3 = new MessageDto( + message3.getId(), + message3CreatedAt, + message3CreatedAt, + content + "3", + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true), + List.of(attachmentDto) + ); + List secondPageDtos = List.of(messageDto3); + + // 두 번째 페이지는 다음 페이지가 없음 + SliceImpl secondPageSlice = new SliceImpl<>(secondPageMessages, pageable, false); + PageResponse secondPageResponse = new PageResponse<>( + secondPageDtos, + message3CreatedAt, + pageSize, + false, + null + ); + + // 두 번째 페이지 모의 객체 설정 + given(messageRepository.findAllByChannelIdWithAuthor(eq(channelId), eq(message2CreatedAt), eq(pageable))) + .willReturn(secondPageSlice); + given(messageMapper.toDto(eq(message3))).willReturn(messageDto3); + given(pageResponseMapper.fromSlice(any(), eq(message3CreatedAt))) + .willReturn(secondPageResponse); + + // when - 두 번째 페이지 요청 (첫 페이지의 커서 사용) + PageResponse secondResult = messageService.findAllByChannelId(channelId, message2CreatedAt, + pageable); + + // then - 두 번째 페이지 검증 + assertThat(secondResult).isEqualTo(secondPageResponse); + assertThat(secondResult.content()).hasSize(1); // 마지막 페이지는 항목 1개만 있음 + assertThat(secondResult.hasNext()).isFalse(); // 더 이상 다음 페이지 없음 + } + + @Test + @DisplayName("메시지 수정 성공") + void updateMessage_Success() { + // given + String newContent = "updated content"; + MessageUpdateRequest request = new MessageUpdateRequest(newContent); + + given(messageRepository.findById(eq(messageId))).willReturn(Optional.of(message)); + given(messageMapper.toDto(eq(message))).willReturn(messageDto); + + // when + MessageDto result = messageService.update(messageId, request); + + // then + assertThat(result).isEqualTo(messageDto); + } + + @Test + @DisplayName("존재하지 않는 메시지 수정 시도 시 실패") + void updateMessage_WithNonExistentId_ThrowsException() { + // given + MessageUpdateRequest request = new MessageUpdateRequest("new content"); + given(messageRepository.findById(eq(messageId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.update(messageId, request)) + .isInstanceOf(MessageNotFoundException.class); + } + + @Test + @DisplayName("메시지 삭제 성공") + void deleteMessage_Success() { + // given + given(messageRepository.existsById(eq(messageId))).willReturn(true); + + // when + messageService.delete(messageId); + + // then + verify(messageRepository).deleteById(eq(messageId)); + } + + @Test + @DisplayName("존재하지 않는 메시지 삭제 시도 시 실패") + void deleteMessage_WithNonExistentId_ThrowsException() { + // given + given(messageRepository.existsById(eq(messageId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> messageService.delete(messageId)) + .isInstanceOf(MessageNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java new file mode 100644 index 000000000..d165fb710 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java @@ -0,0 +1,184 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicUserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + + @InjectMocks + private BasicUserService userService; + + private UUID userId; + private String username; + private String email; + private String password; + private User user; + private UserDto userDto; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + username = "testUser"; + email = "test@example.com"; + password = "password123"; + + user = new User(username, email, password, null); + ReflectionTestUtils.setField(user, "id", userId); + userDto = new UserDto(userId, username, email, null, true); + } + + @Test + @DisplayName("사용자 생성 성공") + void createUser_Success() { + // given + UserCreateRequest request = new UserCreateRequest(username, email, password); + given(userRepository.existsByEmail(eq(email))).willReturn(false); + given(userRepository.existsByUsername(eq(username))).willReturn(false); + given(userMapper.toDto(any(User.class))).willReturn(userDto); + + // when + UserDto result = userService.create(request, Optional.empty()); + + // then + assertThat(result).isEqualTo(userDto); + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("이미 존재하는 이메일로 사용자 생성 시도 시 실패") + void createUser_WithExistingEmail_ThrowsException() { + // given + UserCreateRequest request = new UserCreateRequest(username, email, password); + given(userRepository.existsByEmail(eq(email))).willReturn(true); + + // when & then + assertThatThrownBy(() -> userService.create(request, Optional.empty())) + .isInstanceOf(UserAlreadyExistsException.class); + } + + @Test + @DisplayName("이미 존재하는 사용자명으로 사용자 생성 시도 시 실패") + void createUser_WithExistingUsername_ThrowsException() { + // given + UserCreateRequest request = new UserCreateRequest(username, email, password); + given(userRepository.existsByEmail(eq(email))).willReturn(false); + given(userRepository.existsByUsername(eq(username))).willReturn(true); + + // when & then + assertThatThrownBy(() -> userService.create(request, Optional.empty())) + .isInstanceOf(UserAlreadyExistsException.class); + } + + @Test + @DisplayName("사용자 조회 성공") + void findUser_Success() { + // given + given(userRepository.findById(eq(userId))).willReturn(Optional.of(user)); + given(userMapper.toDto(any(User.class))).willReturn(userDto); + + // when + UserDto result = userService.find(userId); + + // then + assertThat(result).isEqualTo(userDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 조회 시 실패") + void findUser_WithNonExistentId_ThrowsException() { + // given + given(userRepository.findById(eq(userId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userService.find(userId)) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("사용자 수정 성공") + void updateUser_Success() { + // given + String newUsername = "newUsername"; + String newEmail = "new@example.com"; + String newPassword = "newPassword"; + UserUpdateRequest request = new UserUpdateRequest(newUsername, newEmail, newPassword); + + given(userRepository.findById(eq(userId))).willReturn(Optional.of(user)); + given(userRepository.existsByEmail(eq(newEmail))).willReturn(false); + given(userRepository.existsByUsername(eq(newUsername))).willReturn(false); + given(userMapper.toDto(any(User.class))).willReturn(userDto); + + // when + UserDto result = userService.update(userId, request, Optional.empty()); + + // then + assertThat(result).isEqualTo(userDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 수정 시도 시 실패") + void updateUser_WithNonExistentId_ThrowsException() { + // given + UserUpdateRequest request = new UserUpdateRequest("newUsername", "new@example.com", + "newPassword"); + given(userRepository.findById(eq(userId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userService.update(userId, request, Optional.empty())) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("사용자 삭제 성공") + void deleteUser_Success() { + // given + given(userRepository.existsById(eq(userId))).willReturn(true); + + // when + userService.delete(userId); + + // then + verify(userRepository).deleteById(eq(userId)); + } + + @Test + @DisplayName("존재하지 않는 사용자 삭제 시도 시 실패") + void deleteUser_WithNonExistentId_ThrowsException() { + // given + given(userRepository.existsById(eq(userId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> userService.delete(userId)) + .isInstanceOf(UserNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java new file mode 100644 index 000000000..1fd57a0e9 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java @@ -0,0 +1,243 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.exception.userstatus.DuplicateUserStatusException; +import com.sprint.mission.discodeit.exception.userstatus.UserStatusNotFoundException; +import com.sprint.mission.discodeit.mapper.UserStatusMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicUserStatusServiceTest { + + @Mock + private UserStatusRepository userStatusRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserStatusMapper userStatusMapper; + + @InjectMocks + private BasicUserStatusService userStatusService; + + private UUID userStatusId; + private UUID userId; + private Instant lastActiveAt; + private User user; + private UserStatus userStatus; + private UserStatusDto userStatusDto; + + @BeforeEach + void setUp() { + userStatusId = UUID.randomUUID(); + userId = UUID.randomUUID(); + lastActiveAt = Instant.now(); + + user = new User("testUser", "test@example.com", "password", null); + ReflectionTestUtils.setField(user, "id", userId); + + userStatus = new UserStatus(user, lastActiveAt); + ReflectionTestUtils.setField(userStatus, "id", userStatusId); + + userStatusDto = new UserStatusDto(userStatusId, userId, lastActiveAt); + } + + @Test + @DisplayName("사용자 상태 생성 성공") + void createUserStatus_Success() { + // given + UserStatusCreateRequest request = new UserStatusCreateRequest(userId, lastActiveAt); + given(userRepository.findById(eq(userId))).willReturn(Optional.of(user)); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto); + + // 사용자에게 기존 상태가 없어야 함 + ReflectionTestUtils.setField(user, "status", null); + + // when + UserStatusDto result = userStatusService.create(request); + + // then + assertThat(result).isEqualTo(userStatusDto); + verify(userStatusRepository).save(any(UserStatus.class)); + } + + @Test + @DisplayName("이미 상태가 있는 사용자에 대한 상태 생성 시도 시 실패") + void createUserStatus_WithExistingStatus_ThrowsException() { + // given + UserStatusCreateRequest request = new UserStatusCreateRequest(userId, lastActiveAt); + given(userRepository.findById(eq(userId))).willReturn(Optional.of(user)); + + // 사용자에게 이미 상태가 있음 + ReflectionTestUtils.setField(user, "status", userStatus); + + // when & then + assertThatThrownBy(() -> userStatusService.create(request)) + .isInstanceOf(DuplicateUserStatusException.class); + } + + @Test + @DisplayName("존재하지 않는 사용자에 대한 상태 생성 시도 시 실패") + void createUserStatus_WithNonExistentUser_ThrowsException() { + // given + UserStatusCreateRequest request = new UserStatusCreateRequest(userId, lastActiveAt); + given(userRepository.findById(eq(userId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userStatusService.create(request)) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("사용자 상태 조회 성공") + void findUserStatus_Success() { + // given + given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.of(userStatus)); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto); + + // when + UserStatusDto result = userStatusService.find(userStatusId); + + // then + assertThat(result).isEqualTo(userStatusDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 상태 조회 시 실패") + void findUserStatus_WithNonExistentId_ThrowsException() { + // given + given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userStatusService.find(userStatusId)) + .isInstanceOf(UserStatusNotFoundException.class); + } + + @Test + @DisplayName("전체 사용자 상태 목록 조회 성공") + void findAllUserStatuses_Success() { + // given + given(userStatusRepository.findAll()).willReturn(List.of(userStatus)); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto); + + // when + List result = userStatusService.findAll(); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0)).isEqualTo(userStatusDto); + } + + @Test + @DisplayName("사용자 상태 수정 성공") + void updateUserStatus_Success() { + // given + Instant newLastActiveAt = Instant.now().plusSeconds(60); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt); + + given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.of(userStatus)); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto); + + // when + UserStatusDto result = userStatusService.update(userStatusId, request); + + // then + assertThat(result).isEqualTo(userStatusDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 상태 수정 시도 시 실패") + void updateUserStatus_WithNonExistentId_ThrowsException() { + // given + Instant newLastActiveAt = Instant.now().plusSeconds(60); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt); + + given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userStatusService.update(userStatusId, request)) + .isInstanceOf(UserStatusNotFoundException.class); + } + + @Test + @DisplayName("사용자 ID로 상태 수정 성공") + void updateUserStatusByUserId_Success() { + // given + Instant newLastActiveAt = Instant.now().plusSeconds(60); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt); + + given(userStatusRepository.findByUserId(eq(userId))).willReturn(Optional.of(userStatus)); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto); + + // when + UserStatusDto result = userStatusService.updateByUserId(userId, request); + + // then + assertThat(result).isEqualTo(userStatusDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 ID로 상태 수정 시도 시 실패") + void updateUserStatusByUserId_WithNonExistentUserId_ThrowsException() { + // given + Instant newLastActiveAt = Instant.now().plusSeconds(60); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt); + + given(userStatusRepository.findByUserId(eq(userId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userStatusService.updateByUserId(userId, request)) + .isInstanceOf(UserStatusNotFoundException.class); + } + + @Test + @DisplayName("사용자 상태 삭제 성공") + void deleteUserStatus_Success() { + // given + given(userStatusRepository.existsById(eq(userStatusId))).willReturn(true); + + // when + userStatusService.delete(userStatusId); + + // then + verify(userStatusRepository).deleteById(eq(userStatusId)); + } + + @Test + @DisplayName("존재하지 않는 사용자 상태 삭제 시도 시 실패") + void deleteUserStatus_WithNonExistentId_ThrowsException() { + // given + given(userStatusRepository.existsById(eq(userStatusId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> userStatusService.delete(userStatusId)) + .isInstanceOf(UserStatusNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 000000000..7df254a4c --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,19 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + +logging: + level: + com.sprint.mission.discodeit: debug + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace \ No newline at end of file From c938df0f8dca4fd8e969358a2cd0babda019245a Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Fri, 22 Aug 2025 15:12:53 +0900 Subject: [PATCH 05/28] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20Dockerfile=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 17 +++++ .../discodeit/exception/ErrorCode.java | 3 +- .../exception/GlobalExceptionHandler.java | 1 + .../user/UserDuplicateException.java | 2 +- .../user/UserEmailDuplicateException.java | 2 +- .../file/FileChannelRepository.java | 63 ----------------- .../file/FileMessageRepository.java | 67 ------------------- .../repository/file/FileUserRepository.java | 65 ------------------ .../repository/jcf/JCFChannelRepository.java | 31 --------- .../repository/jcf/JCFMessageRepository.java | 33 --------- .../repository/jcf/JCFUserRepository.java | 36 ---------- 11 files changed, 22 insertions(+), 298 deletions(-) create mode 100644 Dockerfile delete mode 100644 src/main/java/com/sprint/mission/discodeit/repository/file/FileChannelRepository.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/repository/file/FileMessageRepository.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/repository/file/FileUserRepository.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFChannelRepository.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFMessageRepository.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserRepository.java diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..e93f2c586 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM amazoncorretto:17 + +WORKDIR /app + +COPY . . + +RUN chmod +x ./gradlew +RUN ./gradlew clean build -x test + + +ENV PROJECT_NAME=discodeit \ + PROJECT_VERSION=1.2-M8 \ + JVM_OPTS="" + +CMD ["sh", "-c", "java $JVM_OPTS -jar build/libs/${PROJECT_NAME}-${PROJECT_VERSION}.jar"] + +EXPOSE 80 \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java index e8dc58033..16a5c7682 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java @@ -29,7 +29,8 @@ public enum ErrorCode { // Server 에러 코드 INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다."), - INVALID_REQUEST("잘못된 요청입니다."); + INVALID_REQUEST("잘못된 요청입니다."), + DUPLICATE_EMAIL("이미 존재하는 이메일 입니다."); private final String message; diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java index f5ecc566a..df8de34e8 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -70,6 +70,7 @@ private HttpStatus determineHttpStatus(DiscodeitException exception) { case INVALID_USER_CREDENTIALS -> HttpStatus.UNAUTHORIZED; case PRIVATE_CHANNEL_UPDATE, INVALID_REQUEST -> HttpStatus.BAD_REQUEST; case INTERNAL_SERVER_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; + case DUPLICATE_EMAIL -> HttpStatus.BAD_REQUEST; }; } } diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserDuplicateException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserDuplicateException.java index 9515e9997..3b45a0045 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/user/UserDuplicateException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserDuplicateException.java @@ -6,6 +6,6 @@ public class UserDuplicateException extends UserException { public UserDuplicateException(String userName) { - super(ErrorCode.DUPLICATE_USER, Map.of("userName", userName)); + super(ErrorCode.DUPLICATE_USER, (Throwable) Map.of("userName", userName)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserEmailDuplicateException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserEmailDuplicateException.java index bd260e5c5..6d147d294 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/user/UserEmailDuplicateException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserEmailDuplicateException.java @@ -6,6 +6,6 @@ public class UserEmailDuplicateException extends UserException { public UserEmailDuplicateException(String email) { - super(ErrorCode.DUPLICATE_EMAIL, Map.of("email", email)); + super(ErrorCode.DUPLICATE_EMAIL, (Throwable) Map.of("email", email)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/file/FileChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/file/FileChannelRepository.java deleted file mode 100644 index 4f6e739da..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/file/FileChannelRepository.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.sprint.mission.discodeit.repository.file; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.repository.ChannelRepository; -import com.sprint.mission.discodeit.repository.UserRepository; - -import java.io.*; -import java.util.*; - -public class FileChannelRepository implements ChannelRepository { - private final String filePath = "channels.ser"; - - - public Channel getChannel(UUID id) { - Map data = readChannelFile(); - return data.get(id); - } - - public List getChannels() { - return new ArrayList<>(readChannelFile().values()); - } - - @Override - public void save(Channel channel) { - Map data = readChannelFile(); - data.put(channel.getId(), channel); - writeChannelFile(data); - } - - @Override - public void delete(UUID id) { - Map data = readChannelFile(); - data.remove(id); - writeChannelFile(data); - } - - - public Optional findByChannelName(String title) { - Map data = readChannelFile(); - return data.values() - .stream() - .filter(channel -> channel.getChannel().equals(title)) - .findFirst(); - } - - //채널 파일 READ - private Map readChannelFile() { - try(ObjectInputStream in = new ObjectInputStream(new FileInputStream(filePath))){ - return (Map) in.readObject(); - } catch (IOException | ClassNotFoundException e) { - return new HashMap<>(); - } - } - - //채널 파일에 WRITE - private void writeChannelFile(Map data) { - try(ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("channels.ser"))){ - out.writeObject(data); - } catch (IOException e) { - e.printStackTrace(); - } - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/file/FileMessageRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/file/FileMessageRepository.java deleted file mode 100644 index 93d038dda..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/file/FileMessageRepository.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.sprint.mission.discodeit.repository.file; - -import com.sprint.mission.discodeit.entity.Message; -import com.sprint.mission.discodeit.repository.MessageRepository; - -import java.io.*; -import java.util.*; - -public class FileMessageRepository implements MessageRepository { - - private final String filePath = "message.ser"; - - @Override - public Message getMessage(UUID id) { - return readMessageFile().get(id); - } - - @Override - public List getMessages() { - return new ArrayList<>(readMessageFile().values()); - } - - @Override - public Optional validationMessage(UUID id) { - Message message = readMessageFile().get(id); - if (message == null) { - System.out.println("해당 ID의 메세지를 찾을 수 없습니다."); - } - return Optional.ofNullable(message); - } - - - public void save(Message message) { - Map data = readMessageFile(); - data.put(message.getId(), message); - writeMessageFile(data); - } - - public void delete(UUID id) { - Map data = readMessageFile(); - if (data.containsKey(id)) { - data.remove(id); - writeMessageFile(data); - } else { - System.out.println("삭제할 메세지가 존재하지 않습니다."); - } - } - - - @SuppressWarnings("unchecked") - private Map readMessageFile() { - try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(filePath))) { - return (Map) in.readObject(); - } catch (IOException | ClassNotFoundException e) { - return new HashMap<>(); - } - } - - - private void writeMessageFile(Map data) { - try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(filePath))) { - out.writeObject(data); - } catch (IOException e) { - e.printStackTrace(); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/repository/file/FileUserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/file/FileUserRepository.java deleted file mode 100644 index 142c66b8b..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/file/FileUserRepository.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.sprint.mission.discodeit.repository.file; - -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.repository.UserRepository; - -import java.io.*; -import java.util.*; - -public class FileUserRepository implements UserRepository { - private final String filePath = "users.ser"; - - public User getUser(UUID id) { - Map data = readUserFile(); - return findByUserId(id, data).orElse(null); - } - - public List getUsers() { - Map data = readUserFile(); - return new ArrayList<>(data.values()); - } - - public Optional findByUserId(UUID id , Map data) { - return Optional.ofNullable(data.get(id)); - } - - public Optional findByUserId(UUID id) { - Map data = readUserFile(); - return findByUserId(id, data); - } - - public void save(User user){ - Map data = readUserFile(); - data.put(user.getId(), user); - writeUserFile(data); - } - - public void delete(UUID id){ - Map data = readUserFile(); - if(data.containsKey(id)) { - data.remove(id); - writeUserFile(data); - }else{ - System.out.println("삭제할 유저가 존재하지 않습니다."); - } - } - - //유저 파일 READ - @SuppressWarnings("unchecked") - private Map readUserFile() { - try(ObjectInputStream in = new ObjectInputStream(new FileInputStream(filePath))){ - return (Map) in.readObject(); - } catch (IOException | ClassNotFoundException e) { - return new HashMap<>(); - } - } - - //유저 파일에 WRITE - private void writeUserFile(Map data) { - try(ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("users.ser"))){ - out.writeObject(data); - } catch (IOException e) { - e.printStackTrace(); - } - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFChannelRepository.java deleted file mode 100644 index 8256d2f26..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFChannelRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -/*package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.repository.ChannelRepository; - -import java.util.*; - -public class JCFChannelRepository implements ChannelRepository { - Map data = new HashMap<>(); - - @Override - public Channel getChannel(UUID id) { - return data.get(id); - } - - @Override - public List getChannels() { - return new ArrayList<>(data.values()); - } - - @Override - public void updateChannel(UUID name, String Channel) { - Channel channel = data.get(name); - if(name != null){ - channel.updateChannel(Channel); - } - } - - -} -*/ \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFMessageRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFMessageRepository.java deleted file mode 100644 index 18bc48261..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFMessageRepository.java +++ /dev/null @@ -1,33 +0,0 @@ -/*package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.Message; -import com.sprint.mission.discodeit.repository.MessageRepository; - -import java.util.*; - -public class JCFMessageRepository implements MessageRepository { - Map data = new HashMap<>(); - - @Override - public Message getMessage(UUID id) { - return data.get(id); - } - - @Override - public List getMessages() { - return new ArrayList<>(data.values()); - } - - @Override - public Optional validationMessage(UUID id) { - Message message = data.get(id); - if(message == null){ - System.out.println("해당 ID의 메세지를 찾을 수 없습니다 "); - } - return Optional.ofNullable(message); - } - - -} - -*/ \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserRepository.java deleted file mode 100644 index d02c32276..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserRepository.java +++ /dev/null @@ -1,36 +0,0 @@ -/*package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.repository.UserRepository; - -import java.util.*; - -public class JCFUserRepository implements UserRepository { - private final Map data = new HashMap<>(); - - @Override - public User getUser(UUID id){ - Optional optionalUser = validationId(id); - if (optionalUser.isPresent()) { - return optionalUser.get(); - } else { - System.out.println("존재하지 않는 아이디입니다."); - return null; - } - } - - @Override - public List getUsers(){ - return new ArrayList<>(data.values()); - } - - public Optional findByUserId(UUID id) { - return Optional.ofNullable(data.get(id)); - } - - public Optional validationId(UUID id){ - return Optional.ofNullable(data.get(id)); - } - -} -*/ \ No newline at end of file From f552d655369194016a52a111bae627b39391a7b6 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Mon, 25 Aug 2025 14:06:31 +0900 Subject: [PATCH 06/28] =?UTF-8?q?application.yaml,=20docker-compose=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docker-compose.yml | 47 +++++++++++++++++++++++++++++ src/main/resources/application.yaml | 7 +++-- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index b2b7bf3d7..b2263f2ba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +.env ### IntelliJ IDEA ### .idea diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..aeef94acf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +version: "3.9" + +services: + app: + build: . + container_name: discodeit-app + ports: + - "8081:80" + env_file: + - .env + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + depends_on: + db: + condition: service_healthy + volumes: + - app-data:/app/data + networks: + - discodeit-net + + db: + image: postgres:15 + container_name: discodeit-db + ports: + - "5432:5432" + env_file: + - .env + volumes: + - postgres-data:/var/lib/postgresql/data + - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql.ro + networks: + - discodeit-net + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + app-data: + postgres-data: + +networks: + discodeit-net: + driver: bridge diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 5ed2fa4ac..08c659f57 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -6,14 +6,17 @@ spring: maxFileSize: 10MB # 파일 하나의 최대 크기 maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 datasource: + url: jdbc:postgresql://db:5432/${POSTGRES_DB} + username: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: validate + ddl-auto: update open-in-view: false profiles: active: - - dev + - ${SPRING_PROFILE:dev} management: endpoints: From a36b46e2262b1155776bee7719fe2ee0b1e9661b Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Mon, 25 Aug 2025 16:47:14 +0900 Subject: [PATCH 07/28] =?UTF-8?q?AWSS3Test=20=EC=84=B1=EA=B3=B5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9E=91=EC=84=B1=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../discodeit/stoarge/s3/AWSS3Test.java | 96 +++++++++++++++++++ src/test/resources/download-sample.txt | 0 src/test/resources/upload-sample.txt | 0 4 files changed, 97 insertions(+) create mode 100644 src/test/java/com/sprint/mission/discodeit/stoarge/s3/AWSS3Test.java create mode 100644 src/test/resources/download-sample.txt create mode 100644 src/test/resources/upload-sample.txt diff --git a/build.gradle b/build.gradle index fb91d08b8..e1b82fdb4 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'software.amazon.awssdk:s3:2.31.7' runtimeOnly 'org.postgresql:postgresql' compileOnly 'org.projectlombok:lombok' diff --git a/src/test/java/com/sprint/mission/discodeit/stoarge/s3/AWSS3Test.java b/src/test/java/com/sprint/mission/discodeit/stoarge/s3/AWSS3Test.java new file mode 100644 index 000000000..ccd2acb15 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/stoarge/s3/AWSS3Test.java @@ -0,0 +1,96 @@ +package com.sprint.mission.discodeit.stoarge.s3; + +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.Properties; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +public class AWSS3Test { + + private static String bucket; + private static S3Client s3; + private static S3Presigner s3Presigner; + + @BeforeAll + static void setUp() throws IOException { + + Properties prop = new Properties(); + prop.load(new FileInputStream(".env")); + + String accessKeyId = prop.getProperty("AWS_S3_ACCESS_KEY"); + String secretAccessKey = prop.getProperty("AWS_S3_SECRET_KEY"); + String region = prop.getProperty("AWS_S3_REGION"); + bucket = prop.getProperty("AWS_S3_BUCKET"); + + //S3Client 생성 + AwsBasicCredentials creds = AwsBasicCredentials.create(accessKeyId, secretAccessKey); + + s3 = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(creds)) + .build(); + + s3Presigner = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(creds)) + .build(); + } + + @Test + void testUploadFile() { + String key = "test/upload-sample.txt"; + String filePath = "src/test/resources/upload-sample.txt"; + + s3.putObject(PutObjectRequest + .builder() + .bucket(bucket) + .key(key) + .build(), Paths.get(filePath)); + + System.out.println("업로드 완료 : " + key); + } + + @Test + void testDownloadFile() { + String key = "test/upload-sample.txt"; + String filePath = "src/test/resources/download-sample.txt"; + + s3.getObject(GetObjectRequest + .builder() + .bucket(bucket) + .key(key) + .build(), Paths.get(filePath)); + + System.out.println("다운로드 완료 : " + key); + } + + @Test + void testPresignedUrl(){ + String key = "test/upload-sample.txt"; + + GetObjectRequest getObjectRequest = GetObjectRequest + .builder() + .bucket(bucket) + .key(key) + .build(); + + GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(getObjectRequest) + .build(); + + String url = s3Presigner.presignGetObject(getObjectPresignRequest).url().toString(); + System.out.println("Presigned URL : " + url); + } +} diff --git a/src/test/resources/download-sample.txt b/src/test/resources/download-sample.txt new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/upload-sample.txt b/src/test/resources/upload-sample.txt new file mode 100644 index 000000000..e69de29bb From b2039b8c1f657ad9ea89ffa519ed4a96e9333c43 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Mon, 25 Aug 2025 18:54:47 +0900 Subject: [PATCH 08/28] =?UTF-8?q?S3BinaryContentStorage=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../storage/s3/S3BinaryContentStorage.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java new file mode 100644 index 000000000..9c4028049 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java @@ -0,0 +1,67 @@ +package com.sprint.mission.discodeit.storage.s3; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.io.InputStream; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "s3") +public class S3BinaryContentStorage implements BinaryContentStorage { + + String accessKey; + String secretKey; + String region; + String bucket; + + public S3BinaryContentStorage(@Value("${aws.s3.access-key}") String accessKey, + @Value("${aws.s3.secret-key}") String secretKey, + @Value("${aws.s3.region}") String region, + @Value("${aws.s3.bucket}") String bucket) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.bucket = bucket; + } + + @Override + public UUID put(UUID binaryContentId, byte[] bytes) { + try(S3Client s3 = getS3Client()) { + String key = binaryContentId.toString(); + s3.putObject(PutObjectRequest + .builder() + .bucket(bucket) + .key(key) + .build(), + RequestBody.fromBytes(bytes)); + return binaryContentId; + } + } + + @Override + public InputStream get(UUID binaryContentId) { + return null; + } + + @Override + public ResponseEntity download(BinaryContentDto metaData) { + return null; + } + + public S3Client getS3Client() { + return null; + } + + public String generatePresignedUrl(String key, String contentType) { + return null; + } +} From fd1b619c72969ac8398d4b0b0e625b6601d4ddf6 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Tue, 26 Aug 2025 11:06:44 +0900 Subject: [PATCH 09/28] =?UTF-8?q?S3BinaryContentStorage=20=EB=82=98?= =?UTF-8?q?=EB=A8=B8=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84,?= =?UTF-8?q?=20application.yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../storage/s3/S3BinaryContentStorage.java | 43 +++++++++++++++++-- src/main/resources/application.yaml | 11 ++++- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java index 9c4028049..8343d9c08 100644 --- a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java @@ -2,16 +2,25 @@ import com.sprint.mission.discodeit.dto.data.BinaryContentDto; import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.time.Duration; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; @Component @RequiredArgsConstructor @@ -49,19 +58,45 @@ public UUID put(UUID binaryContentId, byte[] bytes) { @Override public InputStream get(UUID binaryContentId) { - return null; + S3Client s3 = getS3Client(); + GetObjectResponse response = s3.getObject(GetObjectRequest.builder() + .bucket(bucket) + .key(binaryContentId.toString()) + .build()).response(); + return new ByteArrayInputStream(response.toString().getBytes()); } @Override public ResponseEntity download(BinaryContentDto metaData) { - return null; + String url = generatePresignedUrl(metaData.id().toString(), metaData.contentType()); + return ResponseEntity.status(302).header("Location", url).build(); } public S3Client getS3Client() { - return null; + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .build(); } public String generatePresignedUrl(String key, String contentType) { - return null; + try(S3Presigner presigner = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .build()) { + + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .responseContentType(contentType) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(10)) + .getObjectRequest(getObjectRequest) + .build(); + + return presigner.presignGetObject(presignRequest).url().toString(); + } } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 08c659f57..f154b4b97 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -49,9 +49,16 @@ info: discodeit: storage: - type: local + type: ${STORAGE_TYPE:local} # local | s3 (기본값: local) local: - root-path: .discodeit/storage + root-path: ${STORAGE_LOCAL_ROOT_PATH:.discodeit/storage} + s3: + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} + presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION:600} # (기본값: 10분) + logging: level: From 29e455961f8253e63865b575ec03813dac30413f Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Wed, 27 Aug 2025 17:21:29 +0900 Subject: [PATCH 10/28] =?UTF-8?q?test=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../storage/s3/S3BinaryContentStorage.java | 17 +- .../s3/S3BinaryContentStorageTest.java | 151 ++++++++++++++++++ 2 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 src/test/java/com/sprint/mission/discodeit/stoarge/s3/S3BinaryContentStorageTest.java diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java index 8343d9c08..3f437eb0b 100644 --- a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java @@ -2,11 +2,9 @@ import com.sprint.mission.discodeit.dto.data.BinaryContentDto; import com.sprint.mission.discodeit.storage.BinaryContentStorage; -import java.io.ByteArrayInputStream; import java.io.InputStream; import java.time.Duration; import java.util.UUID; -import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.ResponseEntity; @@ -17,13 +15,11 @@ import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; @Component -@RequiredArgsConstructor @ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "s3") public class S3BinaryContentStorage implements BinaryContentStorage { @@ -32,10 +28,10 @@ public class S3BinaryContentStorage implements BinaryContentStorage { String region; String bucket; - public S3BinaryContentStorage(@Value("${aws.s3.access-key}") String accessKey, - @Value("${aws.s3.secret-key}") String secretKey, - @Value("${aws.s3.region}") String region, - @Value("${aws.s3.bucket}") String bucket) { + public S3BinaryContentStorage(@Value("${discodeit.storage.access-key}") String accessKey, + @Value("${discodeit.storage.secret-key}") String secretKey, + @Value("${discodeit.storage.region}") String region, + @Value("${discodeit.storage.bucket}") String bucket) { this.accessKey = accessKey; this.secretKey = secretKey; this.region = region; @@ -59,11 +55,10 @@ public UUID put(UUID binaryContentId, byte[] bytes) { @Override public InputStream get(UUID binaryContentId) { S3Client s3 = getS3Client(); - GetObjectResponse response = s3.getObject(GetObjectRequest.builder() + return s3.getObject(GetObjectRequest.builder() .bucket(bucket) .key(binaryContentId.toString()) - .build()).response(); - return new ByteArrayInputStream(response.toString().getBytes()); + .build()); } @Override diff --git a/src/test/java/com/sprint/mission/discodeit/stoarge/s3/S3BinaryContentStorageTest.java b/src/test/java/com/sprint/mission/discodeit/stoarge/s3/S3BinaryContentStorageTest.java new file mode 100644 index 000000000..6c2c1685d --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/stoarge/s3/S3BinaryContentStorageTest.java @@ -0,0 +1,151 @@ +package com.sprint.mission.discodeit.stoarge.s3; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.storage.s3.S3BinaryContentStorage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@ExtendWith(MockitoExtension.class) +public class S3BinaryContentStorageTest { + + @Mock + S3Client s3Client; + + private S3BinaryContentStorage storage; + + @BeforeEach + void setUp() { + storage = spy(new S3BinaryContentStorage( + "access", "secret", "ap-northeast-2", "test-bucket" + )); + + } + + @Test + @DisplayName("put 성공") + public void put_success(){ + + //getS3Client()만 mock S3Client 반환하도록 오버라이드 + doReturn(s3Client).when(storage).getS3Client(); + + UUID id = UUID.randomUUID(); + byte[] data = "hello".getBytes(); + + storage.put(id, data); + + // ArgumentCaptor로 PutObjectRequest 검증 + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + + verify(s3Client).putObject(requestCaptor.capture(), + any(RequestBody.class)); + + assertThat(requestCaptor.getValue().bucket()).isEqualTo("test-bucket"); + assertThat(requestCaptor.getValue().key()).isEqualTo(id.toString()); + } + + @Test + @DisplayName("get 성공") + public void get_success() throws IOException { + //getS3Client()만 mock S3Client 반환하도록 오버라이드 + doReturn(s3Client).when(storage).getS3Client(); + + //given + UUID id = UUID.randomUUID(); + byte[] data = "hello".getBytes(); + + // Mock ResponseInputStream 생성 + ResponseInputStream mockResponse = + new ResponseInputStream<>( + GetObjectResponse.builder().contentLength((long) data.length).build(), + AbortableInputStream.create(new ByteArrayInputStream(data)) + ); + + when(s3Client.getObject(any(GetObjectRequest.class))) + .thenReturn(mockResponse); + + InputStream result = storage.get(id); + + assertThat(result.readAllBytes()).isEqualTo(data); + } + + @Test + @DisplayName("다운로드 성공") + void download_success() throws IOException { + // given + UUID id = UUID.randomUUID(); + BinaryContentDto dto = new BinaryContentDto(id, "hello.png", 123L, "image/png"); + + S3BinaryContentStorage realStorage = new S3BinaryContentStorage( + "accessKey", + "secretKey", + "region", + "test-bucket" + ); + + // when + ResponseEntity response = realStorage.download(dto); + + // then + assertThat(response.getStatusCodeValue()).isEqualTo(302); + assertThat(response.getHeaders().getFirst("Location")) + .startsWith("https://"); // 실제 Presigned URL 형식만 확인 + } + + @Test + @DisplayName("S3Client 성공") + void getS3Client_success(){ + //given + String region = "ap-northeast-2"; + + //when + S3Client s3Client = storage.getS3Client(); + + //then + assertThat(s3Client).isNotNull(); + assertThat(s3Client.serviceClientConfiguration().region()).isEqualTo(Region.of(region)); + } + + @Test + @DisplayName("generatePresignedUrl 성공") + void generatePresignedUrl_success(){ + //given + String bucket = "test-bucket"; + + String key = UUID.randomUUID().toString(); + String contentType = "image/png"; + + //when + String url = storage.generatePresignedUrl(key, contentType); + + //then + assertThat(url).isNotNull(); + assertThat(url).contains(bucket); + assertThat(url).contains(key); + assertThat(url).startsWith("https://"); + } +} \ No newline at end of file From 1289b949353c728dcecd3a5d5de4dde8bf5f002f Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Thu, 28 Aug 2025 16:27:25 +0900 Subject: [PATCH 11/28] =?UTF-8?q?gitignore=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Message.ser | Bin 82 -> 0 bytes channels.ser | Bin 82 -> 0 bytes users.ser | Bin 907 -> 0 bytes 4 files changed, 1 insertion(+) delete mode 100644 Message.ser delete mode 100644 channels.ser delete mode 100644 users.ser diff --git a/.gitignore b/.gitignore index b2263f2ba..8bd6ffb75 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ .env +discodeit.env ### IntelliJ IDEA ### .idea diff --git a/Message.ser b/Message.ser deleted file mode 100644 index 496eab0e4aaf72fb4a7a365fdfb2959c0977536c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 82 zcmZ4UmVvdnh(Rzbu`E%qv?Mb}&m*xo!#A;jmHpPi!(s^+nHiYe7`Srs6I0w0lS}f8 fJQ+AkGKx}*GxBp%Dhli!7(jrhoC8P-fS45kZuA$C diff --git a/channels.ser b/channels.ser deleted file mode 100644 index 496eab0e4aaf72fb4a7a365fdfb2959c0977536c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 82 zcmZ4UmVvdnh(Rzbu`E%qv?Mb}&m*xo!#A;jmHpPi!(s^+nHiYe7`Srs6I0w0lS}f8 fJQ+AkGKx}*GxBp%Dhli!7(jrhoC8P-fS45kZuA$C diff --git a/users.ser b/users.ser deleted file mode 100644 index 33ed7cba74560726c975d6cb3e5d784ac19f2c6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 907 zcma)(O=uHA7>2*>CYYF_sYMS$4*nbjVPg(G^-z=kn1m=u3G|@ibazNb_b)q>cH==5 zs+S)05YehVcoL-`=Iq6a3L-)yND=%w6j4NxUIb6hOl-2c7YBA=o}KS~-|w5{ZxEv% z44y%=NNX@+Y6V1VN0A%b^7iqQU8h%7P$prk=^$eg>CEwpkYF_r)0$%%zB_st#2T23 z^F77CC>Pt-7pqi?EHbts=!e|MSmeV`=?%jiXqhC&pzuuqx ze*Au6Y=dhZ&>c&ou19R9S%gyJ*qTA8?iiRb4cm;crdFZY6EXOTD4>=huGf%lW0NvS zl|`D2Xf0DFl#TGe7N!(cF|EUSi1BlS*W1X#40d#Ho5-$arWkjvj`$vE{}UpQD4vjc zx=H>-N$4I%3>!Jt%Ocgw4%aS&LW~}l8O6jnPc&SkTP8xreYZDPQLV}9h4rg1uk8Dv zK%@k*S!6aa&zh3MmzRz;tQqWGUcQ!2wmvQ>0KUrybp2#|=co`>W&g=d#0`e}50~>i z)D-jbFLu(4&(V?!ks^Oxat;d>fVrsHeW|fM02ruwXGUV>_MHpqR(<8{>QndAaA%?; zX*6K_@M}{f8TuQ^Q&2Mm7{qcW(Yr6G+_YQkEAJoZ(`(ta Date: Fri, 26 Sep 2025 14:46:58 +0900 Subject: [PATCH 12/28] =?UTF-8?q?sprint=209=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 87 ++ .github/workflows/test.yml | 30 + .gitignore | 50 + Dockerfile | 40 + HELP.md | 22 + README.md | 5 + api-docs_1.2.json | 1278 ++++++++++++++++ build.gradle | 65 + docker-compose.yml | 52 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 +++ gradlew.bat | 94 ++ settings.gradle | 1 + .../discodeit/DiscodeitApplication.java | 12 + .../mission/discodeit/config/AppConfig.java | 10 + .../config/MDCLoggingInterceptor.java | 49 + .../discodeit/config/SwaggerConfig.java | 25 + .../discodeit/config/WebMvcConfig.java | 24 + .../discodeit/controller/AuthController.java | 34 + .../controller/BinaryContentController.java | 60 + .../controller/ChannelController.java | 85 ++ .../controller/MessageController.java | 116 ++ .../controller/ReadStatusController.java | 62 + .../discodeit/controller/UserController.java | 123 ++ .../discodeit/controller/api/AuthApi.java | 36 + .../controller/api/BinaryContentApi.java | 57 + .../discodeit/controller/api/ChannelApi.java | 89 ++ .../discodeit/controller/api/MessageApi.java | 90 ++ .../controller/api/ReadStatusApi.java | 67 + .../discodeit/controller/api/UserApi.java | 109 ++ .../discodeit/dto/data/BinaryContentDto.java | 12 + .../discodeit/dto/data/ChannelDto.java | 17 + .../discodeit/dto/data/MessageDto.java | 17 + .../discodeit/dto/data/ReadStatusDto.java | 13 + .../mission/discodeit/dto/data/UserDto.java | 13 + .../discodeit/dto/data/UserStatusDto.java | 11 + .../request/BinaryContentCreateRequest.java | 19 + .../discodeit/dto/request/LoginRequest.java | 13 + .../dto/request/MessageCreateRequest.java | 20 + .../dto/request/MessageUpdateRequest.java | 12 + .../request/PrivateChannelCreateRequest.java | 16 + .../request/PublicChannelCreateRequest.java | 15 + .../request/PublicChannelUpdateRequest.java | 13 + .../dto/request/ReadStatusCreateRequest.java | 20 + .../dto/request/ReadStatusUpdateRequest.java | 13 + .../dto/request/UserCreateRequest.java | 25 + .../dto/request/UserStatusCreateRequest.java | 17 + .../dto/request/UserStatusUpdateRequest.java | 13 + .../dto/request/UserUpdateRequest.java | 21 + .../discodeit/dto/response/PageResponse.java | 13 + .../discodeit/entity/BinaryContent.java | 29 + .../mission/discodeit/entity/Channel.java | 41 + .../mission/discodeit/entity/ChannelType.java | 6 + .../mission/discodeit/entity/Message.java | 55 + .../mission/discodeit/entity/ReadStatus.java | 47 + .../sprint/mission/discodeit/entity/User.java | 59 + .../mission/discodeit/entity/UserStatus.java | 50 + .../discodeit/entity/base/BaseEntity.java | 31 + .../entity/base/BaseUpdatableEntity.java | 19 + .../exception/DiscodeitException.java | 32 + .../discodeit/exception/ErrorCode.java | 39 + .../discodeit/exception/ErrorResponse.java | 27 + .../exception/GlobalExceptionHandler.java | 75 + .../binarycontent/BinaryContentException.java | 14 + .../BinaryContentNotFoundException.java | 17 + .../exception/channel/ChannelException.java | 14 + .../channel/ChannelNotFoundException.java | 17 + .../PrivateChannelUpdateException.java | 17 + .../exception/message/MessageException.java | 14 + .../message/MessageNotFoundException.java | 17 + .../DuplicateReadStatusException.java | 18 + .../readstatus/ReadStatusException.java | 14 + .../ReadStatusNotFoundException.java | 17 + .../user/InvalidCredentialsException.java | 14 + .../user/UserAlreadyExistsException.java | 21 + .../exception/user/UserException.java | 14 + .../exception/user/UserNotFoundException.java | 23 + .../DuplicateUserStatusException.java | 17 + .../userstatus/UserStatusException.java | 14 + .../UserStatusNotFoundException.java | 23 + .../discodeit/mapper/BinaryContentMapper.java | 11 + .../discodeit/mapper/ChannelMapper.java | 48 + .../discodeit/mapper/MessageMapper.java | 13 + .../discodeit/mapper/PageResponseMapper.java | 30 + .../discodeit/mapper/ReadStatusMapper.java | 14 + .../mission/discodeit/mapper/UserMapper.java | 13 + .../discodeit/mapper/UserStatusMapper.java | 13 + .../repository/BinaryContentRepository.java | 9 + .../repository/ChannelRepository.java | 12 + .../repository/MessageRepository.java | 32 + .../repository/ReadStatusRepository.java | 25 + .../discodeit/repository/UserRepository.java | 22 + .../repository/UserStatusRepository.java | 11 + .../discodeit/service/AuthService.java | 9 + .../service/BinaryContentService.java | 17 + .../discodeit/service/ChannelService.java | 23 + .../discodeit/service/MessageService.java | 25 + .../discodeit/service/ReadStatusService.java | 20 + .../discodeit/service/UserService.java | 24 + .../discodeit/service/UserStatusService.java | 22 + .../service/basic/BasicAuthService.java | 42 + .../basic/BasicBinaryContentService.java | 80 + .../service/basic/BasicChannelService.java | 118 ++ .../service/basic/BasicMessageService.java | 135 ++ .../service/basic/BasicReadStatusService.java | 107 ++ .../service/basic/BasicUserService.java | 156 ++ .../service/basic/BasicUserStatusService.java | 117 ++ .../storage/BinaryContentStorage.java | 15 + .../local/LocalBinaryContentStorage.java | 89 ++ .../storage/s3/S3BinaryContentStorage.java | 151 ++ src/main/resources/application-dev.yaml | 26 + src/main/resources/application-prod.yaml | 25 + src/main/resources/application.yaml | 63 + src/main/resources/fe_bundle_1.2.3.zip | Bin 0 -> 95493 bytes src/main/resources/logback-spring.xml | 35 + src/main/resources/schema.sql | 126 ++ .../static/assets/index-kQJbKSsj.css | 1 + .../resources/static/assets/index-pvm8va9e.js | 1349 +++++++++++++++++ src/main/resources/static/favicon.ico | Bin 0 -> 1588 bytes src/main/resources/static/index.html | 26 + .../controller/AuthControllerTest.java | 121 ++ .../BinaryContentControllerTest.java | 149 ++ .../controller/ChannelControllerTest.java | 274 ++++ .../controller/MessageControllerTest.java | 304 ++++ .../controller/ReadStatusControllerTest.java | 172 +++ .../controller/UserControllerTest.java | 343 +++++ .../integration/AuthApiIntegrationTest.java | 133 ++ .../BinaryContentApiIntegrationTest.java | 209 +++ .../ChannelApiIntegrationTest.java | 269 ++++ .../MessageApiIntegrationTest.java | 307 ++++ .../ReadStatusApiIntegrationTest.java | 266 ++++ .../integration/UserApiIntegrationTest.java | 299 ++++ .../repository/ChannelRepositoryTest.java | 96 ++ .../repository/MessageRepositoryTest.java | 221 +++ .../repository/ReadStatusRepositoryTest.java | 199 +++ .../repository/UserRepositoryTest.java | 138 ++ .../repository/UserStatusRepositoryTest.java | 117 ++ .../basic/BasicBinaryContentServiceTest.java | 172 +++ .../basic/BasicChannelServiceTest.java | 228 +++ .../basic/BasicMessageServiceTest.java | 365 +++++ .../service/basic/BasicUserServiceTest.java | 184 +++ .../basic/BasicUserStatusServiceTest.java | 243 +++ .../discodeit/storage/s3/AWSS3Test.java | 174 +++ .../s3/S3BinaryContentStorageTest.java | 147 ++ src/test/resources/application-test.yaml | 19 + 146 files changed, 12342 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 HELP.md create mode 100644 README.md create mode 100644 api-docs_1.2.json create mode 100644 build.gradle create mode 100644 docker-compose.yml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/AppConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/AuthController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/MessageController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/UserController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/Channel.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/Message.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/User.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/AuthService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/ChannelService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/MessageService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/UserService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java create mode 100644 src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java create mode 100644 src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java create mode 100644 src/main/resources/application-dev.yaml create mode 100644 src/main/resources/application-prod.yaml create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/fe_bundle_1.2.3.zip create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/main/resources/schema.sql create mode 100644 src/main/resources/static/assets/index-kQJbKSsj.css create mode 100644 src/main/resources/static/assets/index-pvm8va9e.js create mode 100644 src/main/resources/static/favicon.ico create mode 100644 src/main/resources/static/index.html create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java create mode 100644 src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java create mode 100644 src/test/resources/application-test.yaml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..068c6dfe6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,87 @@ +name: 배포 + +on: + push: + branches: + - release + +jobs: + build-and-push: + runs-on: ubuntu-latest + outputs: + image_tag: ${{ github.sha }} + + steps: + - uses: actions/checkout@v4 + + - name: AWS CLI 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: us-east-1 + + - name: ECR 로그인 + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + with: + registry-type: public + + - name: Docker 이미지 빌드 및 푸시 + run: | + docker buildx build \ + -t ${{ vars.ECR_REPOSITORY_URI }}:${{ github.sha }} \ + -t ${{ vars.ECR_REPOSITORY_URI }}:latest \ + --push \ + . + deploy: + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: AWS CLI 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ${{ vars.AWS_REGION }} + + - name: ECS 태스크 정의 업데이트 + run: | + TASK_DEFINITION=$( + aws ecs describe-task-definition \ + --task-definition ${{ vars.ECS_TASK_DEFINITION }} + ) + + NEW_TASK_DEFINITION=$( + echo $TASK_DEFINITION | jq \ + --arg IMAGE "${{ vars.ECR_REPOSITORY_URI }}:latest" \ + '.taskDefinition | .containerDefinitions[0].image = $IMAGE | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)' + ) + + # 새로운 태스크 정의 등록 + NEW_TASK_DEF_ARN=$( + aws ecs register-task-definition \ + --cli-input-json "$NEW_TASK_DEFINITION" | \ + jq -r '.taskDefinition.taskDefinitionArn' + ) + + # 환경 파일에 변수 저장 (다음 단계에서 사용 가능) + echo "NEW_TASK_DEF_ARN=$NEW_TASK_DEF_ARN" >> $GITHUB_ENV + + - name: ECS 서비스 중지(프리티어 환경 고려) + run: | + aws ecs update-service \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --service ${{ vars.ECS_SERVICE }} \ + --desired-count 0 + + - name: ECS 서비스 업데이트 + run: | + aws ecs update-service \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --service ${{ vars.ECS_SERVICE }} \ + --task-definition $NEW_TASK_DEF_ARN \ + --desired-count 1 \ + --force-new-deployment + \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..97e594116 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: 테스트 + +on: + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: 체크아웃 + uses: actions/checkout@v4 + + - name: JDK 17 설정 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + cache: gradle + + - name: 테스트 실행 + run: ./gradlew test + + - name: Codecov 테스트 커버리지 업로드 + uses: codecov/codecov-action@v3 + with: + files: build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.CODECOV_TOKEN }} # 퍼블릭 저장소라면 생략 가능 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..f14dfb825 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Discodeit ### +.discodeit + +### 숨김 파일 ### +.* +!.gitignore + + +### Github Actions ### +!.github/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..229bd68aa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# 빌드 스테이지 +FROM amazoncorretto:17 AS builder + +# 작업 디렉토리 설정 +WORKDIR /app + +# Gradle Wrapper 파일 먼저 복사 +COPY gradle ./gradle +COPY gradlew ./gradlew + +# Gradle 캐시를 위한 의존성 파일 복사 +COPY build.gradle settings.gradle ./ + +# 의존성 다운로드 +RUN ./gradlew dependencies + +# 소스 코드 복사 및 빌드 +COPY src ./src +RUN ./gradlew build -x test + + +# 런타임 스테이지 +FROM amazoncorretto:17-alpine3.21 + +# 작업 디렉토리 설정 +WORKDIR /app + +# 프로젝트 정보를 ENV로 설정 +ENV PROJECT_NAME=discodeit \ + PROJECT_VERSION=1.2-M8 \ + JVM_OPTS="" + +# 빌드 스테이지에서 jar 파일만 복사 +COPY --from=builder /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}.jar ./ + +# 80 포트 노출 +EXPOSE 80 + +# jar 파일 실행 +ENTRYPOINT ["sh", "-c", "java ${JVM_OPTS} -jar ${PROJECT_NAME}-${PROJECT_VERSION}.jar"] \ No newline at end of file diff --git a/HELP.md b/HELP.md new file mode 100644 index 000000000..42c5f0023 --- /dev/null +++ b/HELP.md @@ -0,0 +1,22 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.0/gradle-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.0/gradle-plugin/packaging-oci-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/3.4.0/reference/web/servlet.html) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/README.md b/README.md new file mode 100644 index 000000000..a9e03e160 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# 0-spring-mission + +스프린트 미션 모범 답안 리포지토리입니다. + +[![codecov](https://codecov.io/gh/codeit-bootcamp-spring/0-sprint-mission/branch/s8%2Fadvanced/graph/badge.svg?token=XRIA1GENAM)](https://codecov.io/gh/codeit-bootcamp-spring/0-sprint-mission) \ No newline at end of file diff --git a/api-docs_1.2.json b/api-docs_1.2.json new file mode 100644 index 000000000..7253644c9 --- /dev/null +++ b/api-docs_1.2.json @@ -0,0 +1,1278 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Discodeit API 문서", + "description": "Discodeit 프로젝트의 Swagger API 문서입니다.", + "version": "1.2" + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "로컬 서버" + } + ], + "tags": [ + { + "name": "Channel", + "description": "Channel API" + }, + { + "name": "ReadStatus", + "description": "Message 읽음 상태 API" + }, + { + "name": "Message", + "description": "Message API" + }, + { + "name": "User", + "description": "User API" + }, + { + "name": "BinaryContent", + "description": "첨부 파일 API" + }, + { + "name": "Auth", + "description": "인증 API" + } + ], + "paths": { + "/api/users": { + "get": { + "tags": [ + "User" + ], + "summary": "전체 User 목록 조회", + "operationId": "findAll", + "responses": { + "200": { + "description": "User 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "User" + ], + "summary": "User 등록", + "operationId": "create", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "userCreateRequest": { + "$ref": "#/components/schemas/UserCreateRequest" + }, + "profile": { + "type": "string", + "format": "binary", + "description": "User 프로필 이미지" + } + }, + "required": [ + "userCreateRequest" + ] + } + } + } + }, + "responses": { + "201": { + "description": "User가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "400": { + "description": "같은 email 또는 username를 사용하는 User가 이미 존재함", + "content": { + "*/*": { + "example": "User with email {email} already exists" + } + } + } + } + } + }, + "/api/readStatuses": { + "get": { + "tags": [ + "ReadStatus" + ], + "summary": "User의 Message 읽음 상태 목록 조회", + "operationId": "findAllByUserId", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "조회할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Message 읽음 상태 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "ReadStatus" + ], + "summary": "Message 읽음 상태 생성", + "operationId": "create_1", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadStatusCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "이미 읽음 상태가 존재함", + "content": { + "*/*": { + "example": "ReadStatus with userId {userId} and channelId {channelId} already exists" + } + } + }, + "201": { + "description": "Message 읽음 상태가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + }, + "404": { + "description": "Channel 또는 User를 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel | User with id {channelId | userId} not found" + } + } + } + } + } + }, + "/api/messages": { + "get": { + "tags": [ + "Message" + ], + "summary": "Channel의 Message 목록 조회", + "operationId": "findAllByChannelId", + "parameters": [ + { + "name": "channelId", + "in": "query", + "description": "조회할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "description": "페이징 커서 정보", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "pageable", + "in": "query", + "description": "페이징 정보", + "required": true, + "schema": { + "$ref": "#/components/schemas/Pageable" + }, + "example": { + "size": 50, + "sort": "createdAt,desc" + } + } + ], + "responses": { + "200": { + "description": "Message 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PageResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Message" + ], + "summary": "Message 생성", + "operationId": "create_2", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "messageCreateRequest": { + "$ref": "#/components/schemas/MessageCreateRequest" + }, + "attachments": { + "type": "array", + "description": "Message 첨부 파일들", + "items": { + "type": "string", + "format": "binary" + } + } + }, + "required": [ + "messageCreateRequest" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Message가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/MessageDto" + } + } + } + }, + "404": { + "description": "Channel 또는 User를 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel | Author with id {channelId | authorId} not found" + } + } + } + } + } + }, + "/api/channels/public": { + "post": { + "tags": [ + "Channel" + ], + "summary": "Public Channel 생성", + "operationId": "create_3", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicChannelCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Public Channel이 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/channels/private": { + "post": { + "tags": [ + "Channel" + ], + "summary": "Private Channel 생성", + "operationId": "create_4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivateChannelCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Private Channel이 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "summary": "로그인", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "로그인 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "404": { + "description": "사용자를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with username {username} not found" + } + } + }, + "400": { + "description": "비밀번호가 일치하지 않음", + "content": { + "*/*": { + "example": "Wrong password" + } + } + } + } + } + }, + "/api/users/{userId}": { + "delete": { + "tags": [ + "User" + ], + "summary": "User 삭제", + "operationId": "delete", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "삭제할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "User를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with id {id} not found" + } + } + }, + "204": { + "description": "User가 성공적으로 삭제됨" + } + } + }, + "patch": { + "tags": [ + "User" + ], + "summary": "User 정보 수정", + "operationId": "update", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "수정할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "userUpdateRequest": { + "$ref": "#/components/schemas/UserUpdateRequest" + }, + "profile": { + "type": "string", + "format": "binary", + "description": "수정할 User 프로필 이미지" + } + }, + "required": [ + "userUpdateRequest" + ] + } + } + } + }, + "responses": { + "400": { + "description": "같은 email 또는 username를 사용하는 User가 이미 존재함", + "content": { + "*/*": { + "example": "user with email {newEmail} already exists" + } + } + }, + "200": { + "description": "User 정보가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "404": { + "description": "User를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with id {userId} not found" + } + } + } + } + } + }, + "/api/users/{userId}/userStatus": { + "patch": { + "tags": [ + "User" + ], + "summary": "User 온라인 상태 업데이트", + "operationId": "updateUserStatusByUserId", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "상태를 변경할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatusUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "해당 User의 UserStatus를 찾을 수 없음", + "content": { + "*/*": { + "example": "UserStatus with userId {userId} not found" + } + } + }, + "200": { + "description": "User 온라인 상태가 성공적으로 업데이트됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserStatusDto" + } + } + } + } + } + } + }, + "/api/readStatuses/{readStatusId}": { + "patch": { + "tags": [ + "ReadStatus" + ], + "summary": "Message 읽음 상태 수정", + "operationId": "update_1", + "parameters": [ + { + "name": "readStatusId", + "in": "path", + "description": "수정할 읽음 상태 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadStatusUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Message 읽음 상태를 찾을 수 없음", + "content": { + "*/*": { + "example": "ReadStatus with id {readStatusId} not found" + } + } + }, + "200": { + "description": "Message 읽음 상태가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + } + } + } + }, + "/api/messages/{messageId}": { + "delete": { + "tags": [ + "Message" + ], + "summary": "Message 삭제", + "operationId": "delete_1", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "삭제할 Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Message가 성공적으로 삭제됨" + }, + "404": { + "description": "Message를 찾을 수 없음", + "content": { + "*/*": { + "example": "Message with id {messageId} not found" + } + } + } + } + }, + "patch": { + "tags": [ + "Message" + ], + "summary": "Message 내용 수정", + "operationId": "update_2", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "수정할 Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Message를 찾을 수 없음", + "content": { + "*/*": { + "example": "Message with id {messageId} not found" + } + } + }, + "200": { + "description": "Message가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/MessageDto" + } + } + } + } + } + } + }, + "/api/channels/{channelId}": { + "delete": { + "tags": [ + "Channel" + ], + "summary": "Channel 삭제", + "operationId": "delete_2", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "삭제할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "Channel을 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel with id {channelId} not found" + } + } + }, + "204": { + "description": "Channel이 성공적으로 삭제됨" + } + } + }, + "patch": { + "tags": [ + "Channel" + ], + "summary": "Channel 정보 수정", + "operationId": "update_3", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "수정할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicChannelUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Channel을 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel with id {channelId} not found" + } + } + }, + "400": { + "description": "Private Channel은 수정할 수 없음", + "content": { + "*/*": { + "example": "Private channel cannot be updated" + } + } + }, + "200": { + "description": "Channel 정보가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/channels": { + "get": { + "tags": [ + "Channel" + ], + "summary": "User가 참여 중인 Channel 목록 조회", + "operationId": "findAll_1", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "조회할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Channel 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + } + }, + "/api/binaryContents": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "여러 첨부 파일 조회", + "operationId": "findAllByIdIn", + "parameters": [ + { + "name": "binaryContentIds", + "in": "query", + "description": "조회할 첨부 파일 ID 목록", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "첨부 파일 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + } + } + } + } + }, + "/api/binaryContents/{binaryContentId}": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "첨부 파일 조회", + "operationId": "find", + "parameters": [ + { + "name": "binaryContentId", + "in": "path", + "description": "조회할 첨부 파일 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "첨부 파일 조회 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + }, + "404": { + "description": "첨부 파일을 찾을 수 없음", + "content": { + "*/*": { + "example": "BinaryContent with id {binaryContentId} not found" + } + } + } + } + } + }, + "/api/binaryContents/{binaryContentId}/download": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "파일 다운로드", + "operationId": "download", + "parameters": [ + { + "name": "binaryContentId", + "in": "path", + "description": "다운로드할 파일 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "파일 다운로드 성공", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UserCreateRequest": { + "type": "object", + "description": "User 생성 정보", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "BinaryContentDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "fileName": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64" + }, + "contentType": { + "type": "string" + } + } + }, + "UserDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "profile": { + "$ref": "#/components/schemas/BinaryContentDto" + }, + "online": { + "type": "boolean" + } + } + }, + "ReadStatusCreateRequest": { + "type": "object", + "description": "Message 읽음 상태 생성 정보", + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadStatusDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MessageCreateRequest": { + "type": "object", + "description": "Message 생성 정보", + "properties": { + "content": { + "type": "string" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "authorId": { + "type": "string", + "format": "uuid" + } + } + }, + "MessageDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "content": { + "type": "string" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "author": { + "$ref": "#/components/schemas/UserDto" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + }, + "PublicChannelCreateRequest": { + "type": "object", + "description": "Public Channel 생성 정보", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "ChannelDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "PUBLIC", + "PRIVATE" + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + }, + "lastMessageAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PrivateChannelCreateRequest": { + "type": "object", + "description": "Private Channel 생성 정보", + "properties": { + "participantIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "LoginRequest": { + "type": "object", + "description": "로그인 정보", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "UserUpdateRequest": { + "type": "object", + "description": "수정할 User 정보", + "properties": { + "newUsername": { + "type": "string" + }, + "newEmail": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "UserStatusUpdateRequest": { + "type": "object", + "description": "변경할 User 온라인 상태 정보", + "properties": { + "newLastActiveAt": { + "type": "string", + "format": "date-time" + } + } + }, + "UserStatusDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadStatusUpdateRequest": { + "type": "object", + "description": "수정할 읽음 상태 정보", + "properties": { + "newLastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MessageUpdateRequest": { + "type": "object", + "description": "수정할 Message 내용", + "properties": { + "newContent": { + "type": "string" + } + } + }, + "PublicChannelUpdateRequest": { + "type": "object", + "description": "수정할 Channel 정보", + "properties": { + "newName": { + "type": "string" + }, + "newDescription": { + "type": "string" + } + } + }, + "Pageable": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "size": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "sort": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PageResponse": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "nextCursor": { + "type": "object" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "hasNext": { + "type": "boolean" + }, + "totalElements": { + "type": "integer", + "format": "int64" + } + } + } + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..41fcbe7cc --- /dev/null +++ b/build.gradle @@ -0,0 +1,65 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.6' + id 'jacoco' +} + +group = 'com.sprint.mission' +version = '2.0-M9' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + testCompileOnly { + extendsFrom testAnnotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'software.amazon.awssdk:s3:2.31.7' + runtimeOnly 'org.postgresql:postgresql' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.mapstruct:mapstruct:1.6.3' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.h2database:h2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' +} + +tasks.named('test') { + useJUnitPlatform() +} + +test { + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..3e9c24f85 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.8' + +services: + app: + image: discodeit:local + build: + context: . + dockerfile: Dockerfile + container_name: discodeit + ports: + - "8081:80" + environment: + - SPRING_PROFILES_ACTIVE=prod + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/discodeit + - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} + - STORAGE_TYPE=s3 + - STORAGE_LOCAL_ROOT_PATH=.discodeit/storage + - AWS_S3_ACCESS_KEY=${AWS_S3_ACCESS_KEY} + - AWS_S3_SECRET_KEY=${AWS_S3_SECRET_KEY} + - AWS_S3_REGION=${AWS_S3_REGION} + - AWS_S3_BUCKET=${AWS_S3_BUCKET} + - AWS_S3_PRESIGNED_URL_EXPIRATION=600 + depends_on: + - db + volumes: + - binary-content-storage:/app/.discodeit/storage + networks: + - discodeit-network + + db: + image: postgres:16-alpine + container_name: discodeit-db + environment: + - POSTGRES_DB=discodeit + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + - ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/schema.sql + networks: + - discodeit-network + +volumes: + postgres-data: + binary-content-storage: + +networks: + discodeit-network: + driver: bridge \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..e2847c820 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 000000000..f5feea6d6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..9d21a2183 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..2437dfb29 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'discodeit' diff --git a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java new file mode 100644 index 000000000..8f61230d4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DiscodeitApplication { + + public static void main(String[] args) { + SpringApplication.run(DiscodeitApplication.class, args); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java new file mode 100644 index 000000000..96010621f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class AppConfig { + +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java new file mode 100644 index 000000000..569309f8a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java @@ -0,0 +1,49 @@ +package com.sprint.mission.discodeit.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +/** + * 요청마다 MDC에 컨텍스트 정보를 추가하는 인터셉터 + */ +@Slf4j +public class MDCLoggingInterceptor implements HandlerInterceptor { + + /** + * MDC 로깅에 사용되는 상수 정의 + */ + public static final String REQUEST_ID = "requestId"; + public static final String REQUEST_METHOD = "requestMethod"; + public static final String REQUEST_URI = "requestUri"; + + public static final String REQUEST_ID_HEADER = "Discodeit-Request-ID"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 요청 ID 생성 (UUID) + String requestId = UUID.randomUUID().toString().replaceAll("-", ""); + + // MDC에 컨텍스트 정보 추가 + MDC.put(REQUEST_ID, requestId); + MDC.put(REQUEST_METHOD, request.getMethod()); + MDC.put(REQUEST_URI, request.getRequestURI()); + + // 응답 헤더에 요청 ID 추가 + response.setHeader(REQUEST_ID_HEADER, requestId); + + log.debug("Request started"); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + // 요청 처리 후 MDC 데이터 정리 + log.debug("Request completed"); + MDC.clear(); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java new file mode 100644 index 000000000..f8142c0dc --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Discodeit API 문서") + .description("Discodeit 프로젝트의 Swagger API 문서입니다.") + .version("2.0") + ) + .servers(List.of( + new Server().url("http://localhost:8080").description("로컬 서버") + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java new file mode 100644 index 000000000..21790c7a0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 웹 MVC 설정 클래스 + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Bean + public MDCLoggingInterceptor mdcLoggingInterceptor() { + return new MDCLoggingInterceptor(); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(mdcLoggingInterceptor()) + .addPathPatterns("/**"); // 모든 경로에 적용 + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java new file mode 100644 index 000000000..8d3d2a9f9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -0,0 +1,34 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.AuthApi; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.service.AuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/auth") +public class AuthController implements AuthApi { + + private final AuthService authService; + + @PostMapping(path = "login") + public ResponseEntity login(@RequestBody @Valid LoginRequest loginRequest) { + log.info("로그인 요청: username={}", loginRequest.username()); + UserDto user = authService.login(loginRequest); + log.debug("로그인 응답: {}", user); + return ResponseEntity + .status(HttpStatus.OK) + .body(user); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java new file mode 100644 index 000000000..a0b93ffde --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java @@ -0,0 +1,60 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.BinaryContentApi; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/binaryContents") +public class BinaryContentController implements BinaryContentApi { + + private final BinaryContentService binaryContentService; + private final BinaryContentStorage binaryContentStorage; + + @GetMapping(path = "{binaryContentId}") + public ResponseEntity find( + @PathVariable("binaryContentId") UUID binaryContentId) { + log.info("바이너리 컨텐츠 조회 요청: id={}", binaryContentId); + BinaryContentDto binaryContent = binaryContentService.find(binaryContentId); + log.debug("바이너리 컨텐츠 조회 응답: {}", binaryContent); + return ResponseEntity + .status(HttpStatus.OK) + .body(binaryContent); + } + + @GetMapping + public ResponseEntity> findAllByIdIn( + @RequestParam("binaryContentIds") List binaryContentIds) { + log.info("바이너리 컨텐츠 목록 조회 요청: ids={}", binaryContentIds); + List binaryContents = binaryContentService.findAllByIdIn(binaryContentIds); + log.debug("바이너리 컨텐츠 목록 조회 응답: count={}", binaryContents.size()); + return ResponseEntity + .status(HttpStatus.OK) + .body(binaryContents); + } + + @GetMapping(path = "{binaryContentId}/download") + public ResponseEntity download( + @PathVariable("binaryContentId") UUID binaryContentId) { + log.info("바이너리 컨텐츠 다운로드 요청: id={}", binaryContentId); + BinaryContentDto binaryContentDto = binaryContentService.find(binaryContentId); + ResponseEntity response = binaryContentStorage.download(binaryContentDto); + log.debug("바이너리 컨텐츠 다운로드 응답: contentType={}, contentLength={}", + response.getHeaders().getContentType(), response.getHeaders().getContentLength()); + return response; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java new file mode 100644 index 000000000..3c8424236 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java @@ -0,0 +1,85 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.ChannelApi; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.service.ChannelService; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/channels") +public class ChannelController implements ChannelApi { + + private final ChannelService channelService; + + @PostMapping(path = "public") + public ResponseEntity create(@RequestBody @Valid PublicChannelCreateRequest request) { + log.info("공개 채널 생성 요청: {}", request); + ChannelDto createdChannel = channelService.create(request); + log.debug("공개 채널 생성 응답: {}", createdChannel); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdChannel); + } + + @PostMapping(path = "private") + public ResponseEntity create(@RequestBody @Valid PrivateChannelCreateRequest request) { + log.info("비공개 채널 생성 요청: {}", request); + ChannelDto createdChannel = channelService.create(request); + log.debug("비공개 채널 생성 응답: {}", createdChannel); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdChannel); + } + + @PatchMapping(path = "{channelId}") + public ResponseEntity update( + @PathVariable("channelId") UUID channelId, + @RequestBody @Valid PublicChannelUpdateRequest request) { + log.info("채널 수정 요청: id={}, request={}", channelId, request); + ChannelDto updatedChannel = channelService.update(channelId, request); + log.debug("채널 수정 응답: {}", updatedChannel); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedChannel); + } + + @DeleteMapping(path = "{channelId}") + public ResponseEntity delete(@PathVariable("channelId") UUID channelId) { + log.info("채널 삭제 요청: id={}", channelId); + channelService.delete(channelId); + log.debug("채널 삭제 완료"); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + public ResponseEntity> findAll(@RequestParam("userId") UUID userId) { + log.info("사용자별 채널 목록 조회 요청: userId={}", userId); + List channels = channelService.findAllByUserId(userId); + log.debug("사용자별 채널 목록 조회 응답: count={}", channels.size()); + return ResponseEntity + .status(HttpStatus.OK) + .body(channels); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java new file mode 100644 index 000000000..5f7777d02 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java @@ -0,0 +1,116 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.MessageApi; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.service.MessageService; +import jakarta.validation.Valid; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/messages") +public class MessageController implements MessageApi { + + private final MessageService messageService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity create( + @RequestPart("messageCreateRequest") @Valid MessageCreateRequest messageCreateRequest, + @RequestPart(value = "attachments", required = false) List attachments + ) { + log.info("메시지 생성 요청: request={}, attachmentCount={}", + messageCreateRequest, attachments != null ? attachments.size() : 0); + + List attachmentRequests = Optional.ofNullable(attachments) + .map(files -> files.stream() + .map(file -> { + try { + return new BinaryContentCreateRequest( + file.getOriginalFilename(), + file.getContentType(), + file.getBytes() + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .toList()) + .orElse(new ArrayList<>()); + MessageDto createdMessage = messageService.create(messageCreateRequest, attachmentRequests); + log.debug("메시지 생성 응답: {}", createdMessage); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdMessage); + } + + @PatchMapping(path = "{messageId}") + public ResponseEntity update( + @PathVariable("messageId") UUID messageId, + @RequestBody @Valid MessageUpdateRequest request) { + log.info("메시지 수정 요청: id={}, request={}", messageId, request); + MessageDto updatedMessage = messageService.update(messageId, request); + log.debug("메시지 수정 응답: {}", updatedMessage); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedMessage); + } + + @DeleteMapping(path = "{messageId}") + public ResponseEntity delete(@PathVariable("messageId") UUID messageId) { + log.info("메시지 삭제 요청: id={}", messageId); + messageService.delete(messageId); + log.debug("메시지 삭제 완료"); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + public ResponseEntity> findAllByChannelId( + @RequestParam("channelId") UUID channelId, + @RequestParam(value = "cursor", required = false) Instant cursor, + @PageableDefault( + size = 50, + page = 0, + sort = "createdAt", + direction = Direction.DESC + ) Pageable pageable) { + log.info("채널별 메시지 목록 조회 요청: channelId={}, cursor={}, pageable={}", + channelId, cursor, pageable); + PageResponse messages = messageService.findAllByChannelId(channelId, cursor, + pageable); + log.debug("채널별 메시지 목록 조회 응답: totalElements={}", messages.totalElements()); + return ResponseEntity + .status(HttpStatus.OK) + .body(messages); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java new file mode 100644 index 000000000..ac980c066 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java @@ -0,0 +1,62 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.ReadStatusApi; +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.service.ReadStatusService; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/readStatuses") +public class ReadStatusController implements ReadStatusApi { + + private final ReadStatusService readStatusService; + + @PostMapping + public ResponseEntity create(@RequestBody @Valid ReadStatusCreateRequest request) { + log.info("읽음 상태 생성 요청: {}", request); + ReadStatusDto createdReadStatus = readStatusService.create(request); + log.debug("읽음 상태 생성 응답: {}", createdReadStatus); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdReadStatus); + } + + @PatchMapping(path = "{readStatusId}") + public ResponseEntity update(@PathVariable("readStatusId") UUID readStatusId, + @RequestBody @Valid ReadStatusUpdateRequest request) { + log.info("읽음 상태 수정 요청: id={}, request={}", readStatusId, request); + ReadStatusDto updatedReadStatus = readStatusService.update(readStatusId, request); + log.debug("읽음 상태 수정 응답: {}", updatedReadStatus); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedReadStatus); + } + + @GetMapping + public ResponseEntity> findAllByUserId(@RequestParam("userId") UUID userId) { + log.info("사용자별 읽음 상태 목록 조회 요청: userId={}", userId); + List readStatuses = readStatusService.findAllByUserId(userId); + log.debug("사용자별 읽음 상태 목록 조회 응답: count={}", readStatuses.size()); + return ResponseEntity + .status(HttpStatus.OK) + .body(readStatuses); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java new file mode 100644 index 000000000..46bb8a445 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java @@ -0,0 +1,123 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.UserApi; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.service.UserService; +import com.sprint.mission.discodeit.service.UserStatusService; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/users") +public class UserController implements UserApi { + + private final UserService userService; + private final UserStatusService userStatusService; + + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) + @Override + public ResponseEntity create( + @RequestPart("userCreateRequest") @Valid UserCreateRequest userCreateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + log.info("사용자 생성 요청: {}", userCreateRequest); + Optional profileRequest = Optional.ofNullable(profile) + .flatMap(this::resolveProfileRequest); + UserDto createdUser = userService.create(userCreateRequest, profileRequest); + log.debug("사용자 생성 응답: {}", createdUser); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdUser); + } + + @PatchMapping( + path = "{userId}", + consumes = {MediaType.MULTIPART_FORM_DATA_VALUE} + ) + @Override + public ResponseEntity update( + @PathVariable("userId") UUID userId, + @RequestPart("userUpdateRequest") @Valid UserUpdateRequest userUpdateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + log.info("사용자 수정 요청: id={}, request={}", userId, userUpdateRequest); + Optional profileRequest = Optional.ofNullable(profile) + .flatMap(this::resolveProfileRequest); + UserDto updatedUser = userService.update(userId, userUpdateRequest, profileRequest); + log.debug("사용자 수정 응답: {}", updatedUser); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedUser); + } + + @DeleteMapping(path = "{userId}") + @Override + public ResponseEntity delete(@PathVariable("userId") UUID userId) { + userService.delete(userId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + @Override + public ResponseEntity> findAll() { + List users = userService.findAll(); + return ResponseEntity + .status(HttpStatus.OK) + .body(users); + } + + @PatchMapping(path = "{userId}/userStatus") + @Override + public ResponseEntity updateUserStatusByUserId( + @PathVariable("userId") UUID userId, + @RequestBody @Valid UserStatusUpdateRequest request) { + UserStatusDto updatedUserStatus = userStatusService.updateByUserId(userId, request); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedUserStatus); + } + + private Optional resolveProfileRequest(MultipartFile profileFile) { + if (profileFile.isEmpty()) { + return Optional.empty(); + } else { + try { + BinaryContentCreateRequest binaryContentCreateRequest = new BinaryContentCreateRequest( + profileFile.getOriginalFilename(), + profileFile.getContentType(), + profileFile.getBytes() + ); + return Optional.of(binaryContentCreateRequest); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java new file mode 100644 index 000000000..ee9ce79f9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java @@ -0,0 +1,36 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Auth", description = "인증 API") +public interface AuthApi { + + @Operation(summary = "로그인") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "로그인 성공", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "User with username {username} not found")) + ), + @ApiResponse( + responseCode = "400", description = "비밀번호가 일치하지 않음", + content = @Content(examples = @ExampleObject(value = "Wrong password")) + ) + }) + ResponseEntity login( + @Parameter(description = "로그인 정보") LoginRequest loginRequest + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java new file mode 100644 index 000000000..883ab8a88 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java @@ -0,0 +1,57 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; + +@Tag(name = "BinaryContent", description = "첨부 파일 API") +public interface BinaryContentApi { + + @Operation(summary = "첨부 파일 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "첨부 파일 조회 성공", + content = @Content(schema = @Schema(implementation = BinaryContentDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "첨부 파일을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "BinaryContent with id {binaryContentId} not found")) + ) + }) + ResponseEntity find( + @Parameter(description = "조회할 첨부 파일 ID") UUID binaryContentId + ); + + @Operation(summary = "여러 첨부 파일 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "첨부 파일 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = BinaryContentDto.class))) + ) + }) + ResponseEntity> findAllByIdIn( + @Parameter(description = "조회할 첨부 파일 ID 목록") List binaryContentIds + ); + + @Operation(summary = "파일 다운로드") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "파일 다운로드 성공", + content = @Content(schema = @Schema(implementation = Resource.class)) + ) + }) + ResponseEntity download( + @Parameter(description = "다운로드할 파일 ID") UUID binaryContentId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java new file mode 100644 index 000000000..af8c7afc7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java @@ -0,0 +1,89 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Channel", description = "Channel API") +public interface ChannelApi { + + @Operation(summary = "Public Channel 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Public Channel이 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ChannelDto.class)) + ) + }) + ResponseEntity create( + @Parameter(description = "Public Channel 생성 정보") PublicChannelCreateRequest request + ); + + @Operation(summary = "Private Channel 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Private Channel이 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ChannelDto.class)) + ) + }) + ResponseEntity create( + @Parameter(description = "Private Channel 생성 정보") PrivateChannelCreateRequest request + ); + + @Operation(summary = "Channel 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Channel 정보가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = ChannelDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel with id {channelId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "Private Channel은 수정할 수 없음", + content = @Content(examples = @ExampleObject(value = "Private channel cannot be updated")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 Channel ID") UUID channelId, + @Parameter(description = "수정할 Channel 정보") PublicChannelUpdateRequest request + ); + + @Operation(summary = "Channel 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", description = "Channel이 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", description = "Channel을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel with id {channelId} not found")) + ) + }) + ResponseEntity delete( + @Parameter(description = "삭제할 Channel ID") UUID channelId + ); + + @Operation(summary = "User가 참여 중인 Channel 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Channel 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class))) + ) + }) + ResponseEntity> findAll( + @Parameter(description = "조회할 User ID") UUID userId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java new file mode 100644 index 000000000..c9a7aebbd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java @@ -0,0 +1,90 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Message", description = "Message API") +public interface MessageApi { + + @Operation(summary = "Message 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Message가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = MessageDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel 또는 User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel | Author with id {channelId | authorId} not found")) + ), + }) + ResponseEntity create( + @Parameter( + description = "Message 생성 정보", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) MessageCreateRequest messageCreateRequest, + @Parameter( + description = "Message 첨부 파일들", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) List attachments + ); + + @Operation(summary = "Message 내용 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = MessageDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Message를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Message with id {messageId} not found")) + ), + }) + ResponseEntity update( + @Parameter(description = "수정할 Message ID") UUID messageId, + @Parameter(description = "수정할 Message 내용") MessageUpdateRequest request + ); + + @Operation(summary = "Message 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", description = "Message가 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", description = "Message를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Message with id {messageId} not found")) + ), + }) + ResponseEntity delete( + @Parameter(description = "삭제할 Message ID") UUID messageId + ); + + @Operation(summary = "Channel의 Message 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 목록 조회 성공", + content = @Content(schema = @Schema(implementation = PageResponse.class)) + ) + }) + ResponseEntity> findAllByChannelId( + @Parameter(description = "조회할 Channel ID") UUID channelId, + @Parameter(description = "페이징 커서 정보") Instant cursor, + @Parameter(description = "페이징 정보", example = "{\"size\": 50, \"sort\": \"createdAt,desc\"}") Pageable pageable + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java new file mode 100644 index 000000000..eb08b359f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java @@ -0,0 +1,67 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +@Tag(name = "ReadStatus", description = "Message 읽음 상태 API") +public interface ReadStatusApi { + + @Operation(summary = "Message 읽음 상태 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Message 읽음 상태가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ReadStatusDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel 또는 User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel | User with id {channelId | userId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "이미 읽음 상태가 존재함", + content = @Content(examples = @ExampleObject(value = "ReadStatus with userId {userId} and channelId {channelId} already exists")) + ) + }) + ResponseEntity create( + @Parameter(description = "Message 읽음 상태 생성 정보") ReadStatusCreateRequest request + ); + + @Operation(summary = "Message 읽음 상태 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 읽음 상태가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = ReadStatusDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Message 읽음 상태를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "ReadStatus with id {readStatusId} not found")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 읽음 상태 ID") UUID readStatusId, + @Parameter(description = "수정할 읽음 상태 정보") ReadStatusUpdateRequest request + ); + + @Operation(summary = "User의 Message 읽음 상태 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 읽음 상태 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ReadStatusDto.class))) + ) + }) + ResponseEntity> findAllByUserId( + @Parameter(description = "조회할 User ID") UUID userId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java new file mode 100644 index 000000000..9d40bc1ce --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java @@ -0,0 +1,109 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "User", description = "User API") +public interface UserApi { + + @Operation(summary = "User 등록") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "User가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ), + @ApiResponse( + responseCode = "400", description = "같은 email 또는 username를 사용하는 User가 이미 존재함", + content = @Content(examples = @ExampleObject(value = "User with email {email} already exists")) + ), + }) + ResponseEntity create( + @Parameter( + description = "User 생성 정보", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) UserCreateRequest userCreateRequest, + @Parameter( + description = "User 프로필 이미지", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) MultipartFile profile + ); + + @Operation(summary = "User 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 정보가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "User를 찾을 수 없음", + content = @Content(examples = @ExampleObject("User with id {userId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "같은 email 또는 username를 사용하는 User가 이미 존재함", + content = @Content(examples = @ExampleObject("user with email {newEmail} already exists")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 User ID") UUID userId, + @Parameter(description = "수정할 User 정보") UserUpdateRequest userUpdateRequest, + @Parameter(description = "수정할 User 프로필 이미지") MultipartFile profile + ); + + @Operation(summary = "User 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "User가 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", + description = "User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "User with id {id} not found")) + ) + }) + ResponseEntity delete( + @Parameter(description = "삭제할 User ID") UUID userId + ); + + @Operation(summary = "전체 User 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) + ) + }) + ResponseEntity> findAll(); + + @Operation(summary = "User 온라인 상태 업데이트") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 온라인 상태가 성공적으로 업데이트됨", + content = @Content(schema = @Schema(implementation = UserStatusDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "해당 User의 UserStatus를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "UserStatus with userId {userId} not found")) + ) + }) + ResponseEntity updateUserStatusByUserId( + @Parameter(description = "상태를 변경할 User ID") UUID userId, + @Parameter(description = "변경할 User 온라인 상태 정보") UserStatusUpdateRequest request + ); +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java new file mode 100644 index 000000000..d44aee484 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.util.UUID; + +public record BinaryContentDto( + UUID id, + String fileName, + Long size, + String contentType +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java new file mode 100644 index 000000000..cf9b99080 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.data; + +import com.sprint.mission.discodeit.entity.ChannelType; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record ChannelDto( + UUID id, + ChannelType type, + String name, + String description, + List participants, + Instant lastMessageAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java new file mode 100644 index 000000000..6bcaa0907 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record MessageDto( + UUID id, + Instant createdAt, + Instant updatedAt, + String content, + UUID channelId, + UserDto author, + List attachments +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java new file mode 100644 index 000000000..1d0bc2c12 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.time.Instant; +import java.util.UUID; + +public record ReadStatusDto( + UUID id, + UUID userId, + UUID channelId, + Instant lastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java new file mode 100644 index 000000000..aa696a69f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.util.UUID; + +public record UserDto( + UUID id, + String username, + String email, + BinaryContentDto profile, + Boolean online +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java new file mode 100644 index 000000000..87ee9d000 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserStatusDto.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.time.Instant; +import java.util.UUID; + +public record UserStatusDto( + UUID id, + UUID userId, + Instant lastActiveAt) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java new file mode 100644 index 000000000..402239697 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record BinaryContentCreateRequest( + @NotBlank(message = "파일 이름은 필수입니다") + @Size(max = 255, message = "파일 이름은 255자 이하여야 합니다") + String fileName, + + @NotBlank(message = "콘텐츠 타입은 필수입니다") + String contentType, + + @NotNull(message = "파일 데이터는 필수입니다") + byte[] bytes +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java new file mode 100644 index 000000000..40452eea2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "사용자 이름은 필수입니다") + String username, + + @NotBlank(message = "비밀번호는 필수입니다") + String password +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java new file mode 100644 index 000000000..366539aee --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.UUID; + +public record MessageCreateRequest( + @NotBlank(message = "메시지 내용은 필수입니다") + @Size(max = 2000, message = "메시지 내용은 2000자 이하여야 합니다") + String content, + + @NotNull(message = "채널 ID는 필수입니다") + UUID channelId, + + @NotNull(message = "작성자 ID는 필수입니다") + UUID authorId +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java new file mode 100644 index 000000000..792ef27c2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MessageUpdateRequest( + @NotBlank(message = "메시지 내용은 필수입니다") + @Size(max = 2000, message = "메시지 내용은 2000자 이하여야 합니다") + String newContent +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java new file mode 100644 index 000000000..478cf4e32 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.UUID; + +public record PrivateChannelCreateRequest( + @NotNull(message = "참여자 목록은 필수입니다") + @NotEmpty(message = "참여자 목록은 비어있을 수 없습니다") + @Size(min = 2, message = "비공개 채널에는 최소 2명의 참여자가 필요합니다") + List participantIds +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java new file mode 100644 index 000000000..e2e284a02 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java @@ -0,0 +1,15 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PublicChannelCreateRequest( + @NotBlank(message = "채널명은 필수입니다") + @Size(min = 2, max = 50, message = "채널명은 2자 이상 50자 이하여야 합니다") + String name, + + @Size(max = 255, message = "채널 설명은 255자 이하여야 합니다") + String description +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java new file mode 100644 index 000000000..e438f761c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.Size; + +public record PublicChannelUpdateRequest( + @Size(min = 2, max = 50, message = "채널명은 2자 이상 50자 이하여야 합니다") + String newName, + + @Size(max = 255, message = "채널 설명은 255자 이하여야 합니다") + String newDescription +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java new file mode 100644 index 000000000..f7f485199 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import java.time.Instant; +import java.util.UUID; + +public record ReadStatusCreateRequest( + @NotNull(message = "사용자 ID는 필수입니다") + UUID userId, + + @NotNull(message = "채널 ID는 필수입니다") + UUID channelId, + + @NotNull(message = "마지막 읽은 시간은 필수입니다") + @PastOrPresent(message = "마지막 읽은 시간은 현재 또는 과거 시간이어야 합니다") + Instant lastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java new file mode 100644 index 000000000..de197a07f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import java.time.Instant; + +public record ReadStatusUpdateRequest( + @NotNull(message = "새로운 마지막 읽은 시간은 필수입니다") + @PastOrPresent(message = "마지막 읽은 시간은 현재 또는 과거 시간이어야 합니다") + Instant newLastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java new file mode 100644 index 000000000..a8c888423 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record UserCreateRequest( + @NotBlank(message = "사용자 이름은 필수입니다") + @Size(min = 3, max = 50, message = "사용자 이름은 3자 이상 50자 이하여야 합니다") + String username, + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "유효한 이메일 형식이어야 합니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") + String email, + + @NotBlank(message = "비밀번호는 필수입니다") + @Size(min = 8, max = 60, message = "비밀번호는 8자 이상 60자 이하여야 합니다") + @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{8,}$", + message = "비밀번호는 최소 8자 이상, 숫자, 문자, 특수문자를 포함해야 합니다") + String password +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java new file mode 100644 index 000000000..2d3970adb --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusCreateRequest.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import java.time.Instant; +import java.util.UUID; + +public record UserStatusCreateRequest( + @NotNull(message = "사용자 ID는 필수입니다") + UUID userId, + + @NotNull(message = "마지막 활동 시간은 필수입니다") + @PastOrPresent(message = "마지막 활동 시간은 현재 또는 과거 시간이어야 합니다") + Instant lastActiveAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java new file mode 100644 index 000000000..6556ae56c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import java.time.Instant; + +public record UserStatusUpdateRequest( + @NotNull(message = "마지막 활동 시간은 필수입니다") + @PastOrPresent(message = "마지막 활동 시간은 현재 또는 과거 시간이어야 합니다") + Instant newLastActiveAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java new file mode 100644 index 000000000..19e271309 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record UserUpdateRequest( + @Size(min = 3, max = 50, message = "사용자 이름은 3자 이상 50자 이하여야 합니다") + String newUsername, + + @Email(message = "유효한 이메일 형식이어야 합니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") + String newEmail, + + @Size(min = 8, max = 60, message = "비밀번호는 8자 이상 60자 이하여야 합니다") + @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{8,}$", + message = "비밀번호는 최소 8자 이상, 숫자, 문자, 특수문자를 포함해야 합니다") + String newPassword +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java new file mode 100644 index 000000000..181d532d7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.response; + +import java.util.List; + +public record PageResponse( + List content, + Object nextCursor, + int size, + boolean hasNext, + Long totalElements +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java new file mode 100644 index 000000000..88a096848 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java @@ -0,0 +1,29 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "binary_contents") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BinaryContent extends BaseEntity { + + @Column(nullable = false) + private String fileName; + @Column(nullable = false) + private Long size; + @Column(length = 100, nullable = false) + private String contentType; + + public BinaryContent(String fileName, Long size, String contentType) { + this.fileName = fileName; + this.size = size; + this.contentType = contentType; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Channel.java b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java new file mode 100644 index 000000000..101b737bd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java @@ -0,0 +1,41 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "channels") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Channel extends BaseUpdatableEntity { + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ChannelType type; + @Column(length = 100) + private String name; + @Column(length = 500) + private String description; + + public Channel(ChannelType type, String name, String description) { + this.type = type; + this.name = name; + this.description = description; + } + + public void update(String newName, String newDescription) { + if (newName != null && !newName.equals(this.name)) { + this.name = newName; + } + if (newDescription != null && !newDescription.equals(this.description)) { + this.description = newDescription; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java new file mode 100644 index 000000000..4fca37721 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java @@ -0,0 +1,6 @@ +package com.sprint.mission.discodeit.entity; + +public enum ChannelType { + PUBLIC, + PRIVATE, +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Message.java b/src/main/java/com/sprint/mission/discodeit/entity/Message.java new file mode 100644 index 000000000..7fe8865ea --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Message.java @@ -0,0 +1,55 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; + +@Entity +@Table(name = "messages") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Message extends BaseUpdatableEntity { + + @Column(columnDefinition = "text", nullable = false) + private String content; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "channel_id", columnDefinition = "uuid") + private Channel channel; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", columnDefinition = "uuid") + private User author; + @BatchSize(size = 100) + @OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.ALL) + @JoinTable( + name = "message_attachments", + joinColumns = @JoinColumn(name = "message_id"), + inverseJoinColumns = @JoinColumn(name = "attachment_id") + ) + private List attachments = new ArrayList<>(); + + public Message(String content, Channel channel, User author, List attachments) { + this.channel = channel; + this.content = content; + this.author = author; + this.attachments = attachments; + } + + public void update(String newContent) { + if (newContent != null && !newContent.equals(this.content)) { + this.content = newContent; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java new file mode 100644 index 000000000..d51448b96 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java @@ -0,0 +1,47 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "read_statuses", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "channel_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReadStatus extends BaseUpdatableEntity { + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", columnDefinition = "uuid") + private User user; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "channel_id", columnDefinition = "uuid") + private Channel channel; + @Column(columnDefinition = "timestamp with time zone", nullable = false) + private Instant lastReadAt; + + public ReadStatus(User user, Channel channel, Instant lastReadAt) { + this.user = user; + this.channel = channel; + this.lastReadAt = lastReadAt; + } + + public void update(Instant newLastReadAt) { + if (newLastReadAt != null && !newLastReadAt.equals(this.lastReadAt)) { + this.lastReadAt = newLastReadAt; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/entity/User.java b/src/main/java/com/sprint/mission/discodeit/entity/User.java new file mode 100644 index 000000000..7961aaecc --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/User.java @@ -0,0 +1,59 @@ +package com.sprint.mission.discodeit.entity; + +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA를 위한 기본 생성자 +public class User extends BaseUpdatableEntity { + + @Column(length = 50, nullable = false, unique = true) + private String username; + @Column(length = 100, nullable = false, unique = true) + private String email; + @Column(length = 60, nullable = false) + private String password; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "profile_id", columnDefinition = "uuid") + private BinaryContent profile; + @JsonManagedReference + @Setter(AccessLevel.PROTECTED) + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private UserStatus status; + + public User(String username, String email, String password, BinaryContent profile) { + this.username = username; + this.email = email; + this.password = password; + this.profile = profile; + } + + public void update(String newUsername, String newEmail, String newPassword, + BinaryContent newProfile) { + if (newUsername != null && !newUsername.equals(this.username)) { + this.username = newUsername; + } + if (newEmail != null && !newEmail.equals(this.email)) { + this.email = newEmail; + } + if (newPassword != null && !newPassword.equals(this.password)) { + this.password = newPassword; + } + if (newProfile != null) { + this.profile = newProfile; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java new file mode 100644 index 000000000..9726f73c7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java @@ -0,0 +1,50 @@ +package com.sprint.mission.discodeit.entity; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.time.Duration; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "user_statuses") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserStatus extends BaseUpdatableEntity { + + @JsonBackReference + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + @Column(columnDefinition = "timestamp with time zone", nullable = false) + private Instant lastActiveAt; + + public UserStatus(User user, Instant lastActiveAt) { + setUser(user); + this.lastActiveAt = lastActiveAt; + } + + public void update(Instant lastActiveAt) { + if (lastActiveAt != null && !lastActiveAt.equals(this.lastActiveAt)) { + this.lastActiveAt = lastActiveAt; + } + } + + public Boolean isOnline() { + Instant instantFiveMinutesAgo = Instant.now().minus(Duration.ofMinutes(5)); + return lastActiveAt.isAfter(instantFiveMinutesAgo); + } + + protected void setUser(User user) { + this.user = user; + user.setStatus(this); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java new file mode 100644 index 000000000..f28210164 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java @@ -0,0 +1,31 @@ +package com.sprint.mission.discodeit.entity.base; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "uuid", updatable = false, nullable = false) + private UUID id; + + @CreatedDate + @Column(columnDefinition = "timestamp with time zone", updatable = false, nullable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java new file mode 100644 index 000000000..57d1d3169 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.entity.base; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.LastModifiedDate; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@MappedSuperclass +public abstract class BaseUpdatableEntity extends BaseEntity { + + @LastModifiedDate + @Column(columnDefinition = "timestamp with time zone") + private Instant updatedAt; +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java new file mode 100644 index 000000000..d929a51f8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java @@ -0,0 +1,32 @@ +package com.sprint.mission.discodeit.exception; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; + +@Getter +public class DiscodeitException extends RuntimeException { + private final Instant timestamp; + private final ErrorCode errorCode; + private final Map details; + + public DiscodeitException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + public DiscodeitException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + public void addDetail(String key, Object value) { + this.details.put(key, value); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java new file mode 100644 index 000000000..e8dc58033 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java @@ -0,0 +1,39 @@ +package com.sprint.mission.discodeit.exception; + +import lombok.Getter; + +@Getter +public enum ErrorCode { + // User 관련 에러 코드 + USER_NOT_FOUND("사용자를 찾을 수 없습니다."), + DUPLICATE_USER("이미 존재하는 사용자입니다."), + INVALID_USER_CREDENTIALS("잘못된 사용자 인증 정보입니다."), + + // Channel 관련 에러 코드 + CHANNEL_NOT_FOUND("채널을 찾을 수 없습니다."), + PRIVATE_CHANNEL_UPDATE("비공개 채널은 수정할 수 없습니다."), + + // Message 관련 에러 코드 + MESSAGE_NOT_FOUND("메시지를 찾을 수 없습니다."), + + // BinaryContent 관련 에러 코드 + BINARY_CONTENT_NOT_FOUND("바이너리 컨텐츠를 찾을 수 없습니다."), + + // ReadStatus 관련 에러 코드 + READ_STATUS_NOT_FOUND("읽음 상태를 찾을 수 없습니다."), + DUPLICATE_READ_STATUS("이미 존재하는 읽음 상태입니다."), + + // UserStatus 관련 에러 코드 + USER_STATUS_NOT_FOUND("사용자 상태를 찾을 수 없습니다."), + DUPLICATE_USER_STATUS("이미 존재하는 사용자 상태입니다."), + + // Server 에러 코드 + INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다."), + INVALID_REQUEST("잘못된 요청입니다."); + + private final String message; + + ErrorCode(String message) { + this.message = message; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java new file mode 100644 index 000000000..6a9ae50ef --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java @@ -0,0 +1,27 @@ +package com.sprint.mission.discodeit.exception; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ErrorResponse { + private final Instant timestamp; + private final String code; + private final String message; + private final Map details; + private final String exceptionType; + private final int status; + + public ErrorResponse(DiscodeitException exception, int status) { + this(Instant.now(), exception.getErrorCode().name(), exception.getMessage(), exception.getDetails(), exception.getClass().getSimpleName(), status); + } + + public ErrorResponse(Exception exception, int status) { + this(Instant.now(), exception.getClass().getSimpleName(), exception.getMessage(), new HashMap<>(), exception.getClass().getSimpleName(), status); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..f5ecc566a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -0,0 +1,75 @@ +package com.sprint.mission.discodeit.exception; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error("예상치 못한 오류 발생: {}", e.getMessage(), e); + ErrorResponse errorResponse = new ErrorResponse(e, HttpStatus.INTERNAL_SERVER_ERROR.value()); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(errorResponse); + } + + @ExceptionHandler(DiscodeitException.class) + public ResponseEntity handleDiscodeitException(DiscodeitException exception) { + log.error("커스텀 예외 발생: code={}, message={}", exception.getErrorCode(), exception.getMessage(), exception); + HttpStatus status = determineHttpStatus(exception); + ErrorResponse response = new ErrorResponse(exception, status.value()); + return ResponseEntity + .status(status) + .body(response); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { + log.error("요청 유효성 검사 실패: {}", ex.getMessage()); + + Map validationErrors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + validationErrors.put(fieldName, errorMessage); + }); + + ErrorResponse response = new ErrorResponse( + Instant.now(), + "VALIDATION_ERROR", + "요청 데이터 유효성 검사에 실패했습니다", + validationErrors, + ex.getClass().getSimpleName(), + HttpStatus.BAD_REQUEST.value() + ); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + private HttpStatus determineHttpStatus(DiscodeitException exception) { + ErrorCode errorCode = exception.getErrorCode(); + return switch (errorCode) { + case USER_NOT_FOUND, CHANNEL_NOT_FOUND, MESSAGE_NOT_FOUND, BINARY_CONTENT_NOT_FOUND, + READ_STATUS_NOT_FOUND, USER_STATUS_NOT_FOUND -> HttpStatus.NOT_FOUND; + case DUPLICATE_USER, DUPLICATE_READ_STATUS, DUPLICATE_USER_STATUS -> HttpStatus.CONFLICT; + case INVALID_USER_CREDENTIALS -> HttpStatus.UNAUTHORIZED; + case PRIVATE_CHANNEL_UPDATE, INVALID_REQUEST -> HttpStatus.BAD_REQUEST; + case INTERNAL_SERVER_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java new file mode 100644 index 000000000..368025bf2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.binarycontent; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class BinaryContentException extends DiscodeitException { + public BinaryContentException(ErrorCode errorCode) { + super(errorCode); + } + + public BinaryContentException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java new file mode 100644 index 000000000..65ad82363 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.binarycontent; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class BinaryContentNotFoundException extends BinaryContentException { + public BinaryContentNotFoundException() { + super(ErrorCode.BINARY_CONTENT_NOT_FOUND); + } + + public static BinaryContentNotFoundException withId(UUID binaryContentId) { + BinaryContentNotFoundException exception = new BinaryContentNotFoundException(); + exception.addDetail("binaryContentId", binaryContentId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java new file mode 100644 index 000000000..1ba3364ba --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class ChannelException extends DiscodeitException { + public ChannelException(ErrorCode errorCode) { + super(errorCode); + } + + public ChannelException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java new file mode 100644 index 000000000..ec7b1f335 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.channel; + +import java.util.UUID; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class ChannelNotFoundException extends ChannelException { + public ChannelNotFoundException() { + super(ErrorCode.CHANNEL_NOT_FOUND); + } + + public static ChannelNotFoundException withId(UUID channelId) { + ChannelNotFoundException exception = new ChannelNotFoundException(); + exception.addDetail("channelId", channelId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java new file mode 100644 index 000000000..2b8b1597c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class PrivateChannelUpdateException extends ChannelException { + public PrivateChannelUpdateException() { + super(ErrorCode.PRIVATE_CHANNEL_UPDATE); + } + + public static PrivateChannelUpdateException forChannel(UUID channelId) { + PrivateChannelUpdateException exception = new PrivateChannelUpdateException(); + exception.addDetail("channelId", channelId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java new file mode 100644 index 000000000..289922ed3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class MessageException extends DiscodeitException { + public MessageException(ErrorCode errorCode) { + super(errorCode); + } + + public MessageException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java new file mode 100644 index 000000000..423aafbb3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class MessageNotFoundException extends MessageException { + public MessageNotFoundException() { + super(ErrorCode.MESSAGE_NOT_FOUND); + } + + public static MessageNotFoundException withId(UUID messageId) { + MessageNotFoundException exception = new MessageNotFoundException(); + exception.addDetail("messageId", messageId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java new file mode 100644 index 000000000..5a30692d8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class DuplicateReadStatusException extends ReadStatusException { + public DuplicateReadStatusException() { + super(ErrorCode.DUPLICATE_READ_STATUS); + } + + public static DuplicateReadStatusException withUserIdAndChannelId(UUID userId, UUID channelId) { + DuplicateReadStatusException exception = new DuplicateReadStatusException(); + exception.addDetail("userId", userId); + exception.addDetail("channelId", channelId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java new file mode 100644 index 000000000..3860caf2e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class ReadStatusException extends DiscodeitException { + public ReadStatusException(ErrorCode errorCode) { + super(errorCode); + } + + public ReadStatusException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java new file mode 100644 index 000000000..86b9fde75 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class ReadStatusNotFoundException extends ReadStatusException { + public ReadStatusNotFoundException() { + super(ErrorCode.READ_STATUS_NOT_FOUND); + } + + public static ReadStatusNotFoundException withId(UUID readStatusId) { + ReadStatusNotFoundException exception = new ReadStatusNotFoundException(); + exception.addDetail("readStatusId", readStatusId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java new file mode 100644 index 000000000..d75576fdf --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class InvalidCredentialsException extends UserException { + public InvalidCredentialsException() { + super(ErrorCode.INVALID_USER_CREDENTIALS); + } + + public static InvalidCredentialsException wrongPassword() { + InvalidCredentialsException exception = new InvalidCredentialsException(); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java new file mode 100644 index 000000000..9d0b3b3d1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class UserAlreadyExistsException extends UserException { + public UserAlreadyExistsException() { + super(ErrorCode.DUPLICATE_USER); + } + + public static UserAlreadyExistsException withEmail(String email) { + UserAlreadyExistsException exception = new UserAlreadyExistsException(); + exception.addDetail("email", email); + return exception; + } + + public static UserAlreadyExistsException withUsername(String username) { + UserAlreadyExistsException exception = new UserAlreadyExistsException(); + exception.addDetail("username", username); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java new file mode 100644 index 000000000..f48629706 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class UserException extends DiscodeitException { + public UserException(ErrorCode errorCode) { + super(errorCode); + } + + public UserException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java new file mode 100644 index 000000000..bd76dfa9e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.exception.user; + +import java.util.UUID; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class UserNotFoundException extends UserException { + public UserNotFoundException() { + super(ErrorCode.USER_NOT_FOUND); + } + + public static UserNotFoundException withId(UUID userId) { + UserNotFoundException exception = new UserNotFoundException(); + exception.addDetail("userId", userId); + return exception; + } + + public static UserNotFoundException withUsername(String username) { + UserNotFoundException exception = new UserNotFoundException(); + exception.addDetail("username", username); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java new file mode 100644 index 000000000..04978a2e2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/DuplicateUserStatusException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class DuplicateUserStatusException extends UserStatusException { + public DuplicateUserStatusException() { + super(ErrorCode.DUPLICATE_USER_STATUS); + } + + public static DuplicateUserStatusException withUserId(UUID userId) { + DuplicateUserStatusException exception = new DuplicateUserStatusException(); + exception.addDetail("userId", userId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java new file mode 100644 index 000000000..1a45a3d08 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class UserStatusException extends DiscodeitException { + public UserStatusException(ErrorCode errorCode) { + super(errorCode); + } + + public UserStatusException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java new file mode 100644 index 000000000..199fca795 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusNotFoundException.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.exception.userstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class UserStatusNotFoundException extends UserStatusException { + public UserStatusNotFoundException() { + super(ErrorCode.USER_STATUS_NOT_FOUND); + } + + public static UserStatusNotFoundException withId(UUID userStatusId) { + UserStatusNotFoundException exception = new UserStatusNotFoundException(); + exception.addDetail("userStatusId", userStatusId); + return exception; + } + + public static UserStatusNotFoundException withUserId(UUID userId) { + UserStatusNotFoundException exception = new UserStatusNotFoundException(); + exception.addDetail("userId", userId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java new file mode 100644 index 000000000..d3ea1f137 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.entity.BinaryContent; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface BinaryContentMapper { + + BinaryContentDto toDto(BinaryContent binaryContent); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java new file mode 100644 index 000000000..f39a5809c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java @@ -0,0 +1,48 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.beans.factory.annotation.Autowired; + +@Mapper(componentModel = "spring", uses = {UserMapper.class}) +public abstract class ChannelMapper { + + @Autowired + private MessageRepository messageRepository; + @Autowired + private ReadStatusRepository readStatusRepository; + @Autowired + private UserMapper userMapper; + + @Mapping(target = "participants", expression = "java(resolveParticipants(channel))") + @Mapping(target = "lastMessageAt", expression = "java(resolveLastMessageAt(channel))") + abstract public ChannelDto toDto(Channel channel); + + protected Instant resolveLastMessageAt(Channel channel) { + return messageRepository.findLastMessageAtByChannelId( + channel.getId()) + .orElse(Instant.MIN); + } + + protected List resolveParticipants(Channel channel) { + List participants = new ArrayList<>(); + if (channel.getType().equals(ChannelType.PRIVATE)) { + readStatusRepository.findAllByChannelIdWithUser(channel.getId()) + .stream() + .map(ReadStatus::getUser) + .map(userMapper::toDto) + .forEach(participants::add); + } + return participants; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java new file mode 100644 index 000000000..e0301ac08 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.entity.Message; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", uses = {BinaryContentMapper.class, UserMapper.class}) +public interface MessageMapper { + + @Mapping(target = "channelId", source = "channel.id") + MessageDto toDto(Message message); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java new file mode 100644 index 000000000..108a9b59d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java @@ -0,0 +1,30 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.response.PageResponse; +import org.mapstruct.Mapper; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; + +@Mapper(componentModel = "spring") +public interface PageResponseMapper { + + default PageResponse fromSlice(Slice slice, Object nextCursor) { + return new PageResponse<>( + slice.getContent(), + nextCursor, + slice.getSize(), + slice.hasNext(), + null + ); + } + + default PageResponse fromPage(Page page, Object nextCursor) { + return new PageResponse<>( + page.getContent(), + nextCursor, + page.getSize(), + page.hasNext(), + page.getTotalElements() + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java new file mode 100644 index 000000000..af9b85279 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.entity.ReadStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ReadStatusMapper { + + @Mapping(target = "userId", source = "user.id") + @Mapping(target = "channelId", source = "channel.id") + ReadStatusDto toDto(ReadStatus readStatus); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java new file mode 100644 index 000000000..c040a2edb --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", uses = {BinaryContentMapper.class, UserStatusMapper.class}) +public interface UserMapper { + + @Mapping(target = "online", expression = "java(user.getStatus().isOnline())") + UserDto toDto(User user); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java new file mode 100644 index 000000000..202e56a18 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.entity.UserStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface UserStatusMapper { + + @Mapping(target = "userId", source = "user.id") + UserStatusDto toDto(UserStatus userStatus); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java new file mode 100644 index 000000000..cbd8c79cf --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BinaryContentRepository extends JpaRepository { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java new file mode 100644 index 000000000..e4b1fd235 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChannelRepository extends JpaRepository { + + List findAllByTypeOrIdIn(ChannelType type, List ids); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java new file mode 100644 index 000000000..ac649b75f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java @@ -0,0 +1,32 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Message; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MessageRepository extends JpaRepository { + + @Query("SELECT m FROM Message m " + + "LEFT JOIN FETCH m.author a " + + "JOIN FETCH a.status " + + "LEFT JOIN FETCH a.profile " + + "WHERE m.channel.id=:channelId AND m.createdAt < :createdAt") + Slice findAllByChannelIdWithAuthor(@Param("channelId") UUID channelId, + @Param("createdAt") Instant createdAt, + Pageable pageable); + + + @Query("SELECT m.createdAt " + + "FROM Message m " + + "WHERE m.channel.id = :channelId " + + "ORDER BY m.createdAt DESC LIMIT 1") + Optional findLastMessageAtByChannelId(@Param("channelId") UUID channelId); + + void deleteAllByChannelId(UUID channelId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java new file mode 100644 index 000000000..f1d469af1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.ReadStatus; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ReadStatusRepository extends JpaRepository { + + + List findAllByUserId(UUID userId); + + @Query("SELECT r FROM ReadStatus r " + + "JOIN FETCH r.user u " + + "JOIN FETCH u.status " + + "LEFT JOIN FETCH u.profile " + + "WHERE r.channel.id = :channelId") + List findAllByChannelIdWithUser(@Param("channelId") UUID channelId); + + Boolean existsByUserIdAndChannelId(UUID userId, UUID channelId); + + void deleteAllByChannelId(UUID channelId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java new file mode 100644 index 000000000..f7103705f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.User; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); + + @Query("SELECT u FROM User u " + + "LEFT JOIN FETCH u.profile " + + "JOIN FETCH u.status") + List findAllWithProfileAndStatus(); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java new file mode 100644 index 000000000..46102abf5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.UserStatus; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserStatusRepository extends JpaRepository { + + Optional findByUserId(UUID userId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java new file mode 100644 index 000000000..a1caf1d2d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; + +public interface AuthService { + + UserDto login(LoginRequest loginRequest); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java new file mode 100644 index 000000000..23836a446 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import java.util.List; +import java.util.UUID; + +public interface BinaryContentService { + + BinaryContentDto create(BinaryContentCreateRequest request); + + BinaryContentDto find(UUID binaryContentId); + + List findAllByIdIn(List binaryContentIds); + + void delete(UUID binaryContentId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java new file mode 100644 index 000000000..a082c9ff9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import java.util.List; +import java.util.UUID; + +public interface ChannelService { + + ChannelDto create(PublicChannelCreateRequest request); + + ChannelDto create(PrivateChannelCreateRequest request); + + ChannelDto find(UUID channelId); + + List findAllByUserId(UUID userId); + + ChannelDto update(UUID channelId, PublicChannelUpdateRequest request); + + void delete(UUID channelId); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java new file mode 100644 index 000000000..8ac5ee924 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Pageable; + +public interface MessageService { + + MessageDto create(MessageCreateRequest messageCreateRequest, + List binaryContentCreateRequests); + + MessageDto find(UUID messageId); + + PageResponse findAllByChannelId(UUID channelId, Instant createdAt, Pageable pageable); + + MessageDto update(UUID messageId, MessageUpdateRequest request); + + void delete(UUID messageId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java new file mode 100644 index 000000000..8b0c80a31 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import java.util.List; +import java.util.UUID; + +public interface ReadStatusService { + + ReadStatusDto create(ReadStatusCreateRequest request); + + ReadStatusDto find(UUID readStatusId); + + List findAllByUserId(UUID userId); + + ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request); + + void delete(UUID readStatusId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserService.java b/src/main/java/com/sprint/mission/discodeit/service/UserService.java new file mode 100644 index 000000000..444118780 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/UserService.java @@ -0,0 +1,24 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserService { + + UserDto create(UserCreateRequest userCreateRequest, + Optional profileCreateRequest); + + UserDto find(UUID userId); + + List findAll(); + + UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, + Optional profileCreateRequest); + + void delete(UUID userId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java new file mode 100644 index 000000000..3c5c55e6e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import java.util.List; +import java.util.UUID; + +public interface UserStatusService { + + UserStatusDto create(UserStatusCreateRequest request); + + UserStatusDto find(UUID userStatusId); + + List findAll(); + + UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request); + + UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request); + + void delete(UUID userStatusId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java new file mode 100644 index 000000000..6785cff2f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -0,0 +1,42 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.user.InvalidCredentialsException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Service +public class BasicAuthService implements AuthService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + + @Transactional(readOnly = true) + @Override + public UserDto login(LoginRequest loginRequest) { + log.debug("로그인 시도: username={}", loginRequest.username()); + + String username = loginRequest.username(); + String password = loginRequest.password(); + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> UserNotFoundException.withUsername(username)); + + if (!user.getPassword().equals(password)) { + throw InvalidCredentialsException.wrongPassword(); + } + + log.info("로그인 성공: userId={}, username={}", user.getId(), username); + return userMapper.toDto(user); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java new file mode 100644 index 000000000..bd50ce57d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java @@ -0,0 +1,80 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class BasicBinaryContentService implements BinaryContentService { + + private final BinaryContentRepository binaryContentRepository; + private final BinaryContentMapper binaryContentMapper; + private final BinaryContentStorage binaryContentStorage; + + @Transactional + @Override + public BinaryContentDto create(BinaryContentCreateRequest request) { + log.debug("바이너리 컨텐츠 생성 시작: fileName={}, size={}, contentType={}", + request.fileName(), request.bytes().length, request.contentType()); + + String fileName = request.fileName(); + byte[] bytes = request.bytes(); + String contentType = request.contentType(); + BinaryContent binaryContent = new BinaryContent( + fileName, + (long) bytes.length, + contentType + ); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + + log.info("바이너리 컨텐츠 생성 완료: id={}, fileName={}, size={}", + binaryContent.getId(), fileName, bytes.length); + return binaryContentMapper.toDto(binaryContent); + } + + @Override + public BinaryContentDto find(UUID binaryContentId) { + log.debug("바이너리 컨텐츠 조회 시작: id={}", binaryContentId); + BinaryContentDto dto = binaryContentRepository.findById(binaryContentId) + .map(binaryContentMapper::toDto) + .orElseThrow(() -> BinaryContentNotFoundException.withId(binaryContentId)); + log.info("바이너리 컨텐츠 조회 완료: id={}, fileName={}", + dto.id(), dto.fileName()); + return dto; + } + + @Override + public List findAllByIdIn(List binaryContentIds) { + log.debug("바이너리 컨텐츠 목록 조회 시작: ids={}", binaryContentIds); + List dtos = binaryContentRepository.findAllById(binaryContentIds).stream() + .map(binaryContentMapper::toDto) + .toList(); + log.info("바이너리 컨텐츠 목록 조회 완료: 조회된 항목 수={}", dtos.size()); + return dtos; + } + + @Transactional + @Override + public void delete(UUID binaryContentId) { + log.debug("바이너리 컨텐츠 삭제 시작: id={}", binaryContentId); + if (!binaryContentRepository.existsById(binaryContentId)) { + throw BinaryContentNotFoundException.withId(binaryContentId); + } + binaryContentRepository.deleteById(binaryContentId); + log.info("바이너리 컨텐츠 삭제 완료: id={}", binaryContentId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java new file mode 100644 index 000000000..00ab04087 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java @@ -0,0 +1,118 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.ChannelService; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BasicChannelService implements ChannelService { + + private final ChannelRepository channelRepository; + // + private final ReadStatusRepository readStatusRepository; + private final MessageRepository messageRepository; + private final UserRepository userRepository; + private final ChannelMapper channelMapper; + + @Transactional + @Override + public ChannelDto create(PublicChannelCreateRequest request) { + log.debug("채널 생성 시작: {}", request); + String name = request.name(); + String description = request.description(); + Channel channel = new Channel(ChannelType.PUBLIC, name, description); + + channelRepository.save(channel); + log.info("채널 생성 완료: id={}, name={}", channel.getId(), channel.getName()); + return channelMapper.toDto(channel); + } + + @Transactional + @Override + public ChannelDto create(PrivateChannelCreateRequest request) { + log.debug("채널 생성 시작: {}", request); + Channel channel = new Channel(ChannelType.PRIVATE, null, null); + channelRepository.save(channel); + + List readStatuses = userRepository.findAllById(request.participantIds()).stream() + .map(user -> new ReadStatus(user, channel, channel.getCreatedAt())) + .toList(); + readStatusRepository.saveAll(readStatuses); + + log.info("채널 생성 완료: id={}, name={}", channel.getId(), channel.getName()); + return channelMapper.toDto(channel); + } + + @Transactional(readOnly = true) + @Override + public ChannelDto find(UUID channelId) { + return channelRepository.findById(channelId) + .map(channelMapper::toDto) + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); + } + + @Transactional(readOnly = true) + @Override + public List findAllByUserId(UUID userId) { + List mySubscribedChannelIds = readStatusRepository.findAllByUserId(userId).stream() + .map(ReadStatus::getChannel) + .map(Channel::getId) + .toList(); + + return channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, mySubscribedChannelIds) + .stream() + .map(channelMapper::toDto) + .toList(); + } + + @Transactional + @Override + public ChannelDto update(UUID channelId, PublicChannelUpdateRequest request) { + log.debug("채널 수정 시작: id={}, request={}", channelId, request); + String newName = request.newName(); + String newDescription = request.newDescription(); + Channel channel = channelRepository.findById(channelId) + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); + if (channel.getType().equals(ChannelType.PRIVATE)) { + throw PrivateChannelUpdateException.forChannel(channelId); + } + channel.update(newName, newDescription); + log.info("채널 수정 완료: id={}, name={}", channelId, channel.getName()); + return channelMapper.toDto(channel); + } + + @Transactional + @Override + public void delete(UUID channelId) { + log.debug("채널 삭제 시작: id={}", channelId); + if (!channelRepository.existsById(channelId)) { + throw ChannelNotFoundException.withId(channelId); + } + + messageRepository.deleteAllByChannelId(channelId); + readStatusRepository.deleteAllByChannelId(channelId); + + channelRepository.deleteById(channelId); + log.info("채널 삭제 완료: id={}", channelId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java new file mode 100644 index 000000000..5516ac518 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java @@ -0,0 +1,135 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.PageResponseMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BasicMessageService implements MessageService { + + private final MessageRepository messageRepository; + private final ChannelRepository channelRepository; + private final UserRepository userRepository; + private final MessageMapper messageMapper; + private final BinaryContentStorage binaryContentStorage; + private final BinaryContentRepository binaryContentRepository; + private final PageResponseMapper pageResponseMapper; + + @Transactional + @Override + public MessageDto create(MessageCreateRequest messageCreateRequest, + List binaryContentCreateRequests) { + log.debug("메시지 생성 시작: request={}", messageCreateRequest); + UUID channelId = messageCreateRequest.channelId(); + UUID authorId = messageCreateRequest.authorId(); + + Channel channel = channelRepository.findById(channelId) + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); + User author = userRepository.findById(authorId) + .orElseThrow(() -> UserNotFoundException.withId(authorId)); + + List attachments = binaryContentCreateRequests.stream() + .map(attachmentRequest -> { + String fileName = attachmentRequest.fileName(); + String contentType = attachmentRequest.contentType(); + byte[] bytes = attachmentRequest.bytes(); + + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .toList(); + + String content = messageCreateRequest.content(); + Message message = new Message( + content, + channel, + author, + attachments + ); + + messageRepository.save(message); + log.info("메시지 생성 완료: id={}, channelId={}", message.getId(), channelId); + return messageMapper.toDto(message); + } + + @Transactional(readOnly = true) + @Override + public MessageDto find(UUID messageId) { + return messageRepository.findById(messageId) + .map(messageMapper::toDto) + .orElseThrow(() -> MessageNotFoundException.withId(messageId)); + } + + @Transactional(readOnly = true) + @Override + public PageResponse findAllByChannelId(UUID channelId, Instant createAt, + Pageable pageable) { + Slice slice = messageRepository.findAllByChannelIdWithAuthor(channelId, + Optional.ofNullable(createAt).orElse(Instant.now()), + pageable) + .map(messageMapper::toDto); + + Instant nextCursor = null; + if (!slice.getContent().isEmpty()) { + nextCursor = slice.getContent().get(slice.getContent().size() - 1) + .createdAt(); + } + + return pageResponseMapper.fromSlice(slice, nextCursor); + } + + @Transactional + @Override + public MessageDto update(UUID messageId, MessageUpdateRequest request) { + log.debug("메시지 수정 시작: id={}, request={}", messageId, request); + Message message = messageRepository.findById(messageId) + .orElseThrow(() -> MessageNotFoundException.withId(messageId)); + + message.update(request.newContent()); + log.info("메시지 수정 완료: id={}, channelId={}", messageId, message.getChannel().getId()); + return messageMapper.toDto(message); + } + + @Transactional + @Override + public void delete(UUID messageId) { + log.debug("메시지 삭제 시작: id={}", messageId); + if (!messageRepository.existsById(messageId)) { + throw MessageNotFoundException.withId(messageId); + } + messageRepository.deleteById(messageId); + log.info("메시지 삭제 완료: id={}", messageId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java new file mode 100644 index 000000000..d5787246c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java @@ -0,0 +1,107 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.readstatus.DuplicateReadStatusException; +import com.sprint.mission.discodeit.exception.readstatus.ReadStatusNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.ReadStatusMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.ReadStatusService; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Service +public class BasicReadStatusService implements ReadStatusService { + + private final ReadStatusRepository readStatusRepository; + private final UserRepository userRepository; + private final ChannelRepository channelRepository; + private final ReadStatusMapper readStatusMapper; + + @Transactional + @Override + public ReadStatusDto create(ReadStatusCreateRequest request) { + log.debug("읽음 상태 생성 시작: userId={}, channelId={}", request.userId(), request.channelId()); + + UUID userId = request.userId(); + UUID channelId = request.channelId(); + + User user = userRepository.findById(userId) + .orElseThrow(() -> UserNotFoundException.withId(userId)); + Channel channel = channelRepository.findById(channelId) + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); + + if (readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId())) { + throw DuplicateReadStatusException.withUserIdAndChannelId(userId, channelId); + } + + Instant lastReadAt = request.lastReadAt(); + ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt); + readStatusRepository.save(readStatus); + + log.info("읽음 상태 생성 완료: id={}, userId={}, channelId={}", + readStatus.getId(), userId, channelId); + return readStatusMapper.toDto(readStatus); + } + + @Transactional(readOnly = true) + @Override + public ReadStatusDto find(UUID readStatusId) { + log.debug("읽음 상태 조회 시작: id={}", readStatusId); + ReadStatusDto dto = readStatusRepository.findById(readStatusId) + .map(readStatusMapper::toDto) + .orElseThrow(() -> ReadStatusNotFoundException.withId(readStatusId)); + log.info("읽음 상태 조회 완료: id={}", readStatusId); + return dto; + } + + @Transactional(readOnly = true) + @Override + public List findAllByUserId(UUID userId) { + log.debug("사용자별 읽음 상태 목록 조회 시작: userId={}", userId); + List dtos = readStatusRepository.findAllByUserId(userId).stream() + .map(readStatusMapper::toDto) + .toList(); + log.info("사용자별 읽음 상태 목록 조회 완료: userId={}, 조회된 항목 수={}", userId, dtos.size()); + return dtos; + } + + @Transactional + @Override + public ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request) { + log.debug("읽음 상태 수정 시작: id={}, newLastReadAt={}", readStatusId, request.newLastReadAt()); + + ReadStatus readStatus = readStatusRepository.findById(readStatusId) + .orElseThrow(() -> ReadStatusNotFoundException.withId(readStatusId)); + readStatus.update(request.newLastReadAt()); + + log.info("읽음 상태 수정 완료: id={}", readStatusId); + return readStatusMapper.toDto(readStatus); + } + + @Transactional + @Override + public void delete(UUID readStatusId) { + log.debug("읽음 상태 삭제 시작: id={}", readStatusId); + if (!readStatusRepository.existsById(readStatusId)) { + throw ReadStatusNotFoundException.withId(readStatusId); + } + readStatusRepository.deleteById(readStatusId); + log.info("읽음 상태 삭제 완료: id={}", readStatusId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java new file mode 100644 index 000000000..12a0a3222 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -0,0 +1,156 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.service.UserService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Service +public class BasicUserService implements UserService { + + private final UserRepository userRepository; + private final UserStatusRepository userStatusRepository; + private final UserMapper userMapper; + private final BinaryContentRepository binaryContentRepository; + private final BinaryContentStorage binaryContentStorage; + + @Transactional + @Override + public UserDto create(UserCreateRequest userCreateRequest, + Optional optionalProfileCreateRequest) { + log.debug("사용자 생성 시작: {}", userCreateRequest); + + String username = userCreateRequest.username(); + String email = userCreateRequest.email(); + + if (userRepository.existsByEmail(email)) { + throw UserAlreadyExistsException.withEmail(email); + } + if (userRepository.existsByUsername(username)) { + throw UserAlreadyExistsException.withUsername(username); + } + + BinaryContent nullableProfile = optionalProfileCreateRequest + .map(profileRequest -> { + String fileName = profileRequest.fileName(); + String contentType = profileRequest.contentType(); + byte[] bytes = profileRequest.bytes(); + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .orElse(null); + String password = userCreateRequest.password(); + + User user = new User(username, email, password, nullableProfile); + Instant now = Instant.now(); + UserStatus userStatus = new UserStatus(user, now); + + userRepository.save(user); + log.info("사용자 생성 완료: id={}, username={}", user.getId(), username); + return userMapper.toDto(user); + } + + @Transactional(readOnly = true) + @Override + public UserDto find(UUID userId) { + log.debug("사용자 조회 시작: id={}", userId); + UserDto userDto = userRepository.findById(userId) + .map(userMapper::toDto) + .orElseThrow(() -> UserNotFoundException.withId(userId)); + log.info("사용자 조회 완료: id={}", userId); + return userDto; + } + + @Transactional(readOnly = true) + @Override + public List findAll() { + log.debug("모든 사용자 조회 시작"); + List userDtos = userRepository.findAllWithProfileAndStatus() + .stream() + .map(userMapper::toDto) + .toList(); + log.info("모든 사용자 조회 완료: 총 {}명", userDtos.size()); + return userDtos; + } + + @Transactional + @Override + public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, + Optional optionalProfileCreateRequest) { + log.debug("사용자 수정 시작: id={}, request={}", userId, userUpdateRequest); + + User user = userRepository.findById(userId) + .orElseThrow(() -> { + UserNotFoundException exception = UserNotFoundException.withId(userId); + return exception; + }); + + String newUsername = userUpdateRequest.newUsername(); + String newEmail = userUpdateRequest.newEmail(); + + if (userRepository.existsByEmail(newEmail)) { + throw UserAlreadyExistsException.withEmail(newEmail); + } + + if (userRepository.existsByUsername(newUsername)) { + throw UserAlreadyExistsException.withUsername(newUsername); + } + + BinaryContent nullableProfile = optionalProfileCreateRequest + .map(profileRequest -> { + + String fileName = profileRequest.fileName(); + String contentType = profileRequest.contentType(); + byte[] bytes = profileRequest.bytes(); + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .orElse(null); + + String newPassword = userUpdateRequest.newPassword(); + user.update(newUsername, newEmail, newPassword, nullableProfile); + + log.info("사용자 수정 완료: id={}", userId); + return userMapper.toDto(user); + } + + @Transactional + @Override + public void delete(UUID userId) { + log.debug("사용자 삭제 시작: id={}", userId); + + if (!userRepository.existsById(userId)) { + throw UserNotFoundException.withId(userId); + } + + userRepository.deleteById(userId); + log.info("사용자 삭제 완료: id={}", userId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java new file mode 100644 index 000000000..eb0a8d4b6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java @@ -0,0 +1,117 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.exception.userstatus.DuplicateUserStatusException; +import com.sprint.mission.discodeit.exception.userstatus.UserStatusNotFoundException; +import com.sprint.mission.discodeit.mapper.UserStatusMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.service.UserStatusService; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Service +public class BasicUserStatusService implements UserStatusService { + + private final UserStatusRepository userStatusRepository; + private final UserRepository userRepository; + private final UserStatusMapper userStatusMapper; + + @Transactional + @Override + public UserStatusDto create(UserStatusCreateRequest request) { + log.debug("사용자 상태 생성 시작: userId={}", request.userId()); + + UUID userId = request.userId(); + User user = userRepository.findById(userId) + .orElseThrow(() -> UserNotFoundException.withId(userId)); + + Optional.ofNullable(user.getStatus()) + .ifPresent(status -> { + throw DuplicateUserStatusException.withUserId(userId); + }); + + Instant lastActiveAt = request.lastActiveAt(); + UserStatus userStatus = new UserStatus(user, lastActiveAt); + userStatusRepository.save(userStatus); + + log.info("사용자 상태 생성 완료: id={}, userId={}", userStatus.getId(), userId); + return userStatusMapper.toDto(userStatus); + } + + @Transactional(readOnly = true) + @Override + public UserStatusDto find(UUID userStatusId) { + log.debug("사용자 상태 조회 시작: id={}", userStatusId); + UserStatusDto dto = userStatusRepository.findById(userStatusId) + .map(userStatusMapper::toDto) + .orElseThrow(() -> UserStatusNotFoundException.withId(userStatusId)); + log.info("사용자 상태 조회 완료: id={}", userStatusId); + return dto; + } + + @Transactional(readOnly = true) + @Override + public List findAll() { + log.debug("전체 사용자 상태 목록 조회 시작"); + List dtos = userStatusRepository.findAll().stream() + .map(userStatusMapper::toDto) + .toList(); + log.info("전체 사용자 상태 목록 조회 완료: 조회된 항목 수={}", dtos.size()); + return dtos; + } + + @Transactional + @Override + public UserStatusDto update(UUID userStatusId, UserStatusUpdateRequest request) { + Instant newLastActiveAt = request.newLastActiveAt(); + log.debug("사용자 상태 수정 시작: id={}, newLastActiveAt={}", + userStatusId, newLastActiveAt); + + UserStatus userStatus = userStatusRepository.findById(userStatusId) + .orElseThrow(() -> UserStatusNotFoundException.withId(userStatusId)); + userStatus.update(newLastActiveAt); + + log.info("사용자 상태 수정 완료: id={}", userStatusId); + return userStatusMapper.toDto(userStatus); + } + + @Transactional + @Override + public UserStatusDto updateByUserId(UUID userId, UserStatusUpdateRequest request) { + Instant newLastActiveAt = request.newLastActiveAt(); + log.debug("사용자 ID로 상태 수정 시작: userId={}, newLastActiveAt={}", + userId, newLastActiveAt); + + UserStatus userStatus = userStatusRepository.findByUserId(userId) + .orElseThrow(() -> UserStatusNotFoundException.withUserId(userId)); + userStatus.update(newLastActiveAt); + + log.info("사용자 ID로 상태 수정 완료: userId={}", userId); + return userStatusMapper.toDto(userStatus); + } + + @Transactional + @Override + public void delete(UUID userStatusId) { + log.debug("사용자 상태 삭제 시작: id={}", userStatusId); + if (!userStatusRepository.existsById(userStatusId)) { + throw UserStatusNotFoundException.withId(userStatusId); + } + userStatusRepository.deleteById(userStatusId); + log.info("사용자 상태 삭제 완료: id={}", userStatusId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java new file mode 100644 index 000000000..f00216c40 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java @@ -0,0 +1,15 @@ +package com.sprint.mission.discodeit.storage; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import java.io.InputStream; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +public interface BinaryContentStorage { + + UUID put(UUID binaryContentId, byte[] bytes); + + InputStream get(UUID binaryContentId); + + ResponseEntity download(BinaryContentDto metaData); +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java new file mode 100644 index 000000000..8922903c0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java @@ -0,0 +1,89 @@ +package com.sprint.mission.discodeit.storage.local; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.NoSuchElementException; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local") +@Component +public class LocalBinaryContentStorage implements BinaryContentStorage { + + private final Path root; + + public LocalBinaryContentStorage( + @Value("${discodeit.storage.local.root-path}") Path root + ) { + this.root = root; + } + + @PostConstruct + public void init() { + if (!Files.exists(root)) { + try { + Files.createDirectories(root); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + } + + public UUID put(UUID binaryContentId, byte[] bytes) { + Path filePath = resolvePath(binaryContentId); + if (Files.exists(filePath)) { + throw new IllegalArgumentException("File with key " + binaryContentId + " already exists"); + } + try (OutputStream outputStream = Files.newOutputStream(filePath)) { + outputStream.write(bytes); + } catch (IOException e) { + throw new RuntimeException(e); + } + return binaryContentId; + } + + public InputStream get(UUID binaryContentId) { + Path filePath = resolvePath(binaryContentId); + if (Files.notExists(filePath)) { + throw new NoSuchElementException("File with key " + binaryContentId + " does not exist"); + } + try { + return Files.newInputStream(filePath); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private Path resolvePath(UUID key) { + return root.resolve(key.toString()); + } + + @Override + public ResponseEntity download(BinaryContentDto metaData) { + InputStream inputStream = get(metaData.id()); + Resource resource = new InputStreamResource(inputStream); + + return ResponseEntity + .status(HttpStatus.OK) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + metaData.fileName() + "\"") + .header(HttpHeaders.CONTENT_TYPE, metaData.contentType()) + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(metaData.size())) + .body(resource); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java new file mode 100644 index 000000000..31b4dc0f3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java @@ -0,0 +1,151 @@ +package com.sprint.mission.discodeit.storage.s3; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.time.Duration; +import java.util.NoSuchElementException; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +@Slf4j +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "s3") +@Component +public class S3BinaryContentStorage implements BinaryContentStorage { + + private final String accessKey; + private final String secretKey; + private final String region; + private final String bucket; + + @Value("${discodeit.storage.s3.presigned-url-expiration:600}") // 기본값 10분 + private long presignedUrlExpirationSeconds; + + public S3BinaryContentStorage( + @Value("${discodeit.storage.s3.access-key}") String accessKey, + @Value("${discodeit.storage.s3.secret-key}") String secretKey, + @Value("${discodeit.storage.s3.region}") String region, + @Value("${discodeit.storage.s3.bucket}") String bucket + ) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.bucket = bucket; + } + + @Override + public UUID put(UUID binaryContentId, byte[] bytes) { + String key = binaryContentId.toString(); + try { + S3Client s3Client = getS3Client(); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + s3Client.putObject(request, RequestBody.fromBytes(bytes)); + log.info("S3에 파일 업로드 성공: {}", key); + + return binaryContentId; + } catch (S3Exception e) { + log.error("S3에 파일 업로드 실패: {}", e.getMessage()); + throw new RuntimeException("S3에 파일 업로드 실패: " + key, e); + } + } + + @Override + public InputStream get(UUID binaryContentId) { + String key = binaryContentId.toString(); + try { + S3Client s3Client = getS3Client(); + + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + byte[] bytes = s3Client.getObjectAsBytes(request).asByteArray(); + return new ByteArrayInputStream(bytes); + } catch (S3Exception e) { + log.error("S3에서 파일 다운로드 실패: {}", e.getMessage()); + throw new NoSuchElementException("File with key " + key + " does not exist"); + } + } + + private S3Client getS3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + } + + @Override + public ResponseEntity download(BinaryContentDto metaData) { + try { + String key = metaData.id().toString(); + String presignedUrl = generatePresignedUrl(key, metaData.contentType()); + + log.info("생성된 Presigned URL: {}", presignedUrl); + + return ResponseEntity + .status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, presignedUrl) + .build(); + } catch (Exception e) { + log.error("Presigned URL 생성 실패: {}", e.getMessage()); + throw new RuntimeException("Presigned URL 생성 실패", e); + } + } + + private String generatePresignedUrl(String key, String contentType) { + try (S3Presigner presigner = getS3Presigner()) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .responseContentType(contentType) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(presignedUrlExpirationSeconds)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + } + + private S3Presigner getS3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 000000000..22ae092e6 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,26 @@ +server: + port: 8080 + +spring: + datasource: + url: jdbc:postgresql://localhost:5432/discodeit + username: discodeit_user + password: discodeit1234 + jpa: + properties: + hibernate: + format_sql: true + +logging: + level: + com.sprint.mission.discodeit: debug + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + +management: + endpoint: + health: + show-details: always + info: + env: + enabled: true \ No newline at end of file diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 000000000..3074885d3 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,25 @@ +server: + port: 80 + +spring: + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + jpa: + properties: + hibernate: + format_sql: false + +logging: + level: + com.sprint.mission.discodeit: info + org.hibernate.SQL: info + +management: + endpoint: + health: + show-details: never + info: + env: + enabled: false \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 000000000..cedbf7c94 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,63 @@ +spring: + application: + name: discodeit + servlet: + multipart: + maxFileSize: 10MB # 파일 하나의 최대 크기 + maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 + datasource: + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + profiles: + active: + - dev + config: + import: optional:file:.env[.properties] + +management: + endpoints: + web: + exposure: + include: health,info,metrics,loggers + endpoint: + health: + show-details: always + +info: + name: Discodeit + version: 1.7.0 + java: + version: 17 + spring-boot: + version: 3.4.0 + config: + datasource: + url: ${spring.datasource.url} + driver-class-name: ${spring.datasource.driver-class-name} + jpa: + ddl-auto: ${spring.jpa.hibernate.ddl-auto} + storage: + type: ${discodeit.storage.type} + path: ${discodeit.storage.local.root-path} + multipart: + max-file-size: ${spring.servlet.multipart.maxFileSize} + max-request-size: ${spring.servlet.multipart.maxRequestSize} + +discodeit: + storage: + type: ${STORAGE_TYPE:local} # local | s3 (기본값: local) + local: + root-path: ${STORAGE_LOCAL_ROOT_PATH:.discodeit/storage} + s3: + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} + presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION:600} # (기본값: 10분) + +logging: + level: + root: info \ No newline at end of file diff --git a/src/main/resources/fe_bundle_1.2.3.zip b/src/main/resources/fe_bundle_1.2.3.zip new file mode 100644 index 0000000000000000000000000000000000000000..852a0869c0058982bcd14d2879e20e95e78927c2 GIT binary patch literal 95493 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�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 |HJx!8ETT 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 BOw#}W literal 0 HcmV?d00001 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..0f338e0b1 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + ${LOG_PATTERN} + + + + + + ${LOG_FILE_PATH}/${LOG_FILE_NAME}.log + + ${LOG_PATTERN} + + + ${LOG_FILE_PATH}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}.log + 30 + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 000000000..c658649cd --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,126 @@ +-- 테이블 +-- User +CREATE TABLE users +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + username varchar(50) UNIQUE NOT NULL, + email varchar(100) UNIQUE NOT NULL, + password varchar(60) NOT NULL, + profile_id uuid +); + +-- BinaryContent +CREATE TABLE binary_contents +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + file_name varchar(255) NOT NULL, + size bigint NOT NULL, + content_type varchar(100) NOT NULL +-- ,bytes bytea NOT NULL +); + +-- UserStatus +CREATE TABLE user_statuses +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + user_id uuid UNIQUE NOT NULL, + last_active_at timestamp with time zone NOT NULL +); + +-- Channel +CREATE TABLE channels +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + name varchar(100), + description varchar(500), + type varchar(10) NOT NULL +); + +-- Message +CREATE TABLE messages +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + content text, + channel_id uuid NOT NULL, + author_id uuid +); + +-- Message.attachments +CREATE TABLE message_attachments +( + message_id uuid, + attachment_id uuid, + PRIMARY KEY (message_id, attachment_id) +); + +-- ReadStatus +CREATE TABLE read_statuses +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + user_id uuid NOT NULL, + channel_id uuid NOT NULL, + last_read_at timestamp with time zone NOT NULL, + UNIQUE (user_id, channel_id) +); + + +-- 제약 조건 +-- User (1) -> BinaryContent (1) +ALTER TABLE users + ADD CONSTRAINT fk_user_binary_content + FOREIGN KEY (profile_id) + REFERENCES binary_contents (id) + ON DELETE SET NULL; + +-- UserStatus (1) -> User (1) +ALTER TABLE user_statuses + ADD CONSTRAINT fk_user_status_user + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE; + +-- Message (N) -> Channel (1) +ALTER TABLE messages + ADD CONSTRAINT fk_message_channel + FOREIGN KEY (channel_id) + REFERENCES channels (id) + ON DELETE CASCADE; + +-- Message (N) -> Author (1) +ALTER TABLE messages + ADD CONSTRAINT fk_message_user + FOREIGN KEY (author_id) + REFERENCES users (id) + ON DELETE SET NULL; + +-- MessageAttachment (1) -> BinaryContent (1) +ALTER TABLE message_attachments + ADD CONSTRAINT fk_message_attachment_binary_content + FOREIGN KEY (attachment_id) + REFERENCES binary_contents (id) + ON DELETE CASCADE; + +-- ReadStatus (N) -> User (1) +ALTER TABLE read_statuses + ADD CONSTRAINT fk_read_status_user + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE; + +-- ReadStatus (N) -> User (1) +ALTER TABLE read_statuses + ADD CONSTRAINT fk_read_status_channel + FOREIGN KEY (channel_id) + REFERENCES channels (id) + ON DELETE CASCADE; \ No newline at end of file diff --git a/src/main/resources/static/assets/index-kQJbKSsj.css b/src/main/resources/static/assets/index-kQJbKSsj.css new file mode 100644 index 000000000..096eb4112 --- /dev/null +++ b/src/main/resources/static/assets/index-kQJbKSsj.css @@ -0,0 +1 @@ +:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}} diff --git a/src/main/resources/static/assets/index-pvm8va9e.js b/src/main/resources/static/assets/index-pvm8va9e.js new file mode 100644 index 000000000..b43a85536 --- /dev/null +++ b/src/main/resources/static/assets/index-pvm8va9e.js @@ -0,0 +1,1349 @@ +var xg=Object.defineProperty;var wg=(r,i,s)=>i in r?xg(r,i,{enumerable:!0,configurable:!0,writable:!0,value:s}):r[i]=s;var sf=(r,i,s)=>wg(r,typeof i!="symbol"?i+"":i,s);(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))l(c);new MutationObserver(c=>{for(const d of c)if(d.type==="childList")for(const p of d.addedNodes)p.tagName==="LINK"&&p.rel==="modulepreload"&&l(p)}).observe(document,{childList:!0,subtree:!0});function s(c){const d={};return c.integrity&&(d.integrity=c.integrity),c.referrerPolicy&&(d.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?d.credentials="include":c.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function l(c){if(c.ep)return;c.ep=!0;const d=s(c);fetch(c.href,d)}})();function Sg(r){return r&&r.__esModule&&Object.prototype.hasOwnProperty.call(r,"default")?r.default:r}var wa={exports:{}},vo={},Sa={exports:{}},pe={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var lf;function Cg(){if(lf)return pe;lf=1;var r=Symbol.for("react.element"),i=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),l=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),d=Symbol.for("react.provider"),p=Symbol.for("react.context"),g=Symbol.for("react.forward_ref"),w=Symbol.for("react.suspense"),v=Symbol.for("react.memo"),S=Symbol.for("react.lazy"),j=Symbol.iterator;function R(C){return C===null||typeof C!="object"?null:(C=j&&C[j]||C["@@iterator"],typeof C=="function"?C:null)}var L={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},T=Object.assign,O={};function _(C,D,le){this.props=C,this.context=D,this.refs=O,this.updater=le||L}_.prototype.isReactComponent={},_.prototype.setState=function(C,D){if(typeof C!="object"&&typeof C!="function"&&C!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,C,D,"setState")},_.prototype.forceUpdate=function(C){this.updater.enqueueForceUpdate(this,C,"forceUpdate")};function b(){}b.prototype=_.prototype;function U(C,D,le){this.props=C,this.context=D,this.refs=O,this.updater=le||L}var B=U.prototype=new b;B.constructor=U,T(B,_.prototype),B.isPureReactComponent=!0;var W=Array.isArray,I=Object.prototype.hasOwnProperty,M={current:null},V={key:!0,ref:!0,__self:!0,__source:!0};function ne(C,D,le){var ue,he={},fe=null,Ce=null;if(D!=null)for(ue in D.ref!==void 0&&(Ce=D.ref),D.key!==void 0&&(fe=""+D.key),D)I.call(D,ue)&&!V.hasOwnProperty(ue)&&(he[ue]=D[ue]);var ge=arguments.length-2;if(ge===1)he.children=le;else if(1>>1,D=Y[C];if(0>>1;Cc(he,Q))fec(Ce,he)?(Y[C]=Ce,Y[fe]=Q,C=fe):(Y[C]=he,Y[ue]=Q,C=ue);else if(fec(Ce,Q))Y[C]=Ce,Y[fe]=Q,C=fe;else break e}}return te}function c(Y,te){var Q=Y.sortIndex-te.sortIndex;return Q!==0?Q:Y.id-te.id}if(typeof performance=="object"&&typeof performance.now=="function"){var d=performance;r.unstable_now=function(){return d.now()}}else{var p=Date,g=p.now();r.unstable_now=function(){return p.now()-g}}var w=[],v=[],S=1,j=null,R=3,L=!1,T=!1,O=!1,_=typeof setTimeout=="function"?setTimeout:null,b=typeof clearTimeout=="function"?clearTimeout:null,U=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function B(Y){for(var te=s(v);te!==null;){if(te.callback===null)l(v);else if(te.startTime<=Y)l(v),te.sortIndex=te.expirationTime,i(w,te);else break;te=s(v)}}function W(Y){if(O=!1,B(Y),!T)if(s(w)!==null)T=!0,Ue(I);else{var te=s(v);te!==null&&je(W,te.startTime-Y)}}function I(Y,te){T=!1,O&&(O=!1,b(ne),ne=-1),L=!0;var Q=R;try{for(B(te),j=s(w);j!==null&&(!(j.expirationTime>te)||Y&&!ie());){var C=j.callback;if(typeof C=="function"){j.callback=null,R=j.priorityLevel;var D=C(j.expirationTime<=te);te=r.unstable_now(),typeof D=="function"?j.callback=D:j===s(w)&&l(w),B(te)}else l(w);j=s(w)}if(j!==null)var le=!0;else{var ue=s(v);ue!==null&&je(W,ue.startTime-te),le=!1}return le}finally{j=null,R=Q,L=!1}}var M=!1,V=null,ne=-1,ye=5,Ie=-1;function ie(){return!(r.unstable_now()-IeY||125C?(Y.sortIndex=Q,i(v,Y),s(w)===null&&Y===s(v)&&(O?(b(ne),ne=-1):O=!0,je(W,Q-C))):(Y.sortIndex=D,i(w,Y),T||L||(T=!0,Ue(I))),Y},r.unstable_shouldYield=ie,r.unstable_wrapCallback=function(Y){var te=R;return function(){var Q=R;R=te;try{return Y.apply(this,arguments)}finally{R=Q}}}}(Ea)),Ea}var ff;function Ag(){return ff||(ff=1,ka.exports=jg()),ka.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var pf;function Rg(){if(pf)return dt;pf=1;var r=tu(),i=Ag();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),w=Object.prototype.hasOwnProperty,v=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,S={},j={};function R(e){return w.call(j,e)?!0:w.call(S,e)?!1:v.test(e)?j[e]=!0:(S[e]=!0,!1)}function L(e,t,n,o){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return o?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function T(e,t,n,o){if(t===null||typeof t>"u"||L(e,t,n,o))return!0;if(o)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function O(e,t,n,o,a,u,f){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=o,this.attributeNamespace=a,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=f}var _={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){_[e]=new O(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];_[t]=new O(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){_[e]=new O(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){_[e]=new O(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){_[e]=new O(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){_[e]=new O(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){_[e]=new O(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){_[e]=new O(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){_[e]=new O(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function U(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(b,U);_[t]=new O(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(b,U);_[t]=new O(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(b,U);_[t]=new O(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){_[e]=new O(e,1,!1,e.toLowerCase(),null,!1,!1)}),_.xlinkHref=new O("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){_[e]=new O(e,1,!1,e.toLowerCase(),null,!0,!0)});function B(e,t,n,o){var a=_.hasOwnProperty(t)?_[t]:null;(a!==null?a.type!==0:o||!(2m||a[f]!==u[m]){var y=` +`+a[f].replace(" at new "," at ");return e.displayName&&y.includes("")&&(y=y.replace("",e.displayName)),y}while(1<=f&&0<=m);break}}}finally{le=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?D(e):""}function he(e){switch(e.tag){case 5:return D(e.type);case 16:return D("Lazy");case 13:return D("Suspense");case 19:return D("SuspenseList");case 0:case 2:case 15:return e=ue(e.type,!1),e;case 11:return e=ue(e.type.render,!1),e;case 1:return e=ue(e.type,!0),e;default:return""}}function fe(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case V:return"Fragment";case M:return"Portal";case ye:return"Profiler";case ne:return"StrictMode";case me:return"Suspense";case _e:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case ie:return(e.displayName||"Context")+".Consumer";case Ie:return(e._context.displayName||"Context")+".Provider";case de:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Se:return t=e.displayName||null,t!==null?t:fe(e.type)||"Memo";case Ue:t=e._payload,e=e._init;try{return fe(e(t))}catch{}}return null}function Ce(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return fe(t);case 8:return t===ne?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ge(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function xe(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ge(e){var t=xe(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),o=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var a=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return a.call(this)},set:function(f){o=""+f,u.call(this,f)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return o},setValue:function(f){o=""+f},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Yt(e){e._valueTracker||(e._valueTracker=Ge(e))}function Tt(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),o="";return e&&(o=xe(e)?e.checked?"true":"false":e.value),e=o,e!==n?(t.setValue(e),!0):!1}function zo(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Rs(e,t){var n=t.checked;return Q({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function fu(e,t){var n=t.defaultValue==null?"":t.defaultValue,o=t.checked!=null?t.checked:t.defaultChecked;n=ge(t.value!=null?t.value:n),e._wrapperState={initialChecked:o,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function pu(e,t){t=t.checked,t!=null&&B(e,"checked",t,!1)}function Ps(e,t){pu(e,t);var n=ge(t.value),o=t.type;if(n!=null)o==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(o==="submit"||o==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Ts(e,t.type,n):t.hasOwnProperty("defaultValue")&&Ts(e,t.type,ge(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function hu(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var o=t.type;if(!(o!=="submit"&&o!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Ts(e,t,n){(t!=="number"||zo(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Or=Array.isArray;function Qn(e,t,n,o){if(e=e.options,t){t={};for(var a=0;a"+t.valueOf().toString()+"",t=$o.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Mr(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Lr={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Eh=["Webkit","ms","Moz","O"];Object.keys(Lr).forEach(function(e){Eh.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Lr[t]=Lr[e]})});function wu(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Lr.hasOwnProperty(e)&&Lr[e]?(""+t).trim():t+"px"}function Su(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var o=n.indexOf("--")===0,a=wu(n,t[n],o);n==="float"&&(n="cssFloat"),o?e.setProperty(n,a):e[n]=a}}var jh=Q({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Os(e,t){if(t){if(jh[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(s(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(s(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(s(61))}if(t.style!=null&&typeof t.style!="object")throw Error(s(62))}}function Ms(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Ls=null;function Is(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Ds=null,Gn=null,Kn=null;function Cu(e){if(e=no(e)){if(typeof Ds!="function")throw Error(s(280));var t=e.stateNode;t&&(t=li(t),Ds(e.stateNode,e.type,t))}}function ku(e){Gn?Kn?Kn.push(e):Kn=[e]:Gn=e}function Eu(){if(Gn){var e=Gn,t=Kn;if(Kn=Gn=null,Cu(e),t)for(e=0;e>>=0,e===0?32:31-(Dh(e)/zh|0)|0}var Ho=64,Vo=4194304;function $r(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Wo(e,t){var n=e.pendingLanes;if(n===0)return 0;var o=0,a=e.suspendedLanes,u=e.pingedLanes,f=n&268435455;if(f!==0){var m=f&~a;m!==0?o=$r(m):(u&=f,u!==0&&(o=$r(u)))}else f=n&~a,f!==0?o=$r(f):u!==0&&(o=$r(u));if(o===0)return 0;if(t!==0&&t!==o&&!(t&a)&&(a=o&-o,u=t&-t,a>=u||a===16&&(u&4194240)!==0))return t;if(o&4&&(o|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=o;0n;n++)t.push(e);return t}function Fr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-_t(t),e[t]=n}function bh(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var o=e.eventTimes;for(e=e.expirationTimes;0=qr),Ju=" ",Zu=!1;function ec(e,t){switch(e){case"keyup":return mm.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function tc(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Zn=!1;function ym(e,t){switch(e){case"compositionend":return tc(t);case"keypress":return t.which!==32?null:(Zu=!0,Ju);case"textInput":return e=t.data,e===Ju&&Zu?null:e;default:return null}}function vm(e,t){if(Zn)return e==="compositionend"||!el&&ec(e,t)?(e=Yu(),Ko=Qs=an=null,Zn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=o}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=ac(n)}}function cc(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?cc(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function dc(){for(var e=window,t=zo();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=zo(e.document)}return t}function rl(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Rm(e){var t=dc(),n=e.focusedElem,o=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&cc(n.ownerDocument.documentElement,n)){if(o!==null&&rl(n)){if(t=o.start,e=o.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var a=n.textContent.length,u=Math.min(o.start,a);o=o.end===void 0?u:Math.min(o.end,a),!e.extend&&u>o&&(a=o,o=u,u=a),a=uc(n,u);var f=uc(n,o);a&&f&&(e.rangeCount!==1||e.anchorNode!==a.node||e.anchorOffset!==a.offset||e.focusNode!==f.node||e.focusOffset!==f.offset)&&(t=t.createRange(),t.setStart(a.node,a.offset),e.removeAllRanges(),u>o?(e.addRange(t),e.extend(f.node,f.offset)):(t.setEnd(f.node,f.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,er=null,ol=null,Xr=null,il=!1;function fc(e,t,n){var o=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;il||er==null||er!==zo(o)||(o=er,"selectionStart"in o&&rl(o)?o={start:o.selectionStart,end:o.selectionEnd}:(o=(o.ownerDocument&&o.ownerDocument.defaultView||window).getSelection(),o={anchorNode:o.anchorNode,anchorOffset:o.anchorOffset,focusNode:o.focusNode,focusOffset:o.focusOffset}),Xr&&Kr(Xr,o)||(Xr=o,o=oi(ol,"onSelect"),0ir||(e.current=yl[ir],yl[ir]=null,ir--)}function Ae(e,t){ir++,yl[ir]=e.current,e.current=t}var fn={},Je=dn(fn),st=dn(!1),Tn=fn;function sr(e,t){var n=e.type.contextTypes;if(!n)return fn;var o=e.stateNode;if(o&&o.__reactInternalMemoizedUnmaskedChildContext===t)return o.__reactInternalMemoizedMaskedChildContext;var a={},u;for(u in n)a[u]=t[u];return o&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=a),a}function lt(e){return e=e.childContextTypes,e!=null}function ai(){Pe(st),Pe(Je)}function Rc(e,t,n){if(Je.current!==fn)throw Error(s(168));Ae(Je,t),Ae(st,n)}function Pc(e,t,n){var o=e.stateNode;if(t=t.childContextTypes,typeof o.getChildContext!="function")return n;o=o.getChildContext();for(var a in o)if(!(a in t))throw Error(s(108,Ce(e)||"Unknown",a));return Q({},n,o)}function ui(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||fn,Tn=Je.current,Ae(Je,e),Ae(st,st.current),!0}function Tc(e,t,n){var o=e.stateNode;if(!o)throw Error(s(169));n?(e=Pc(e,t,Tn),o.__reactInternalMemoizedMergedChildContext=e,Pe(st),Pe(Je),Ae(Je,e)):Pe(st),Ae(st,n)}var Qt=null,ci=!1,vl=!1;function _c(e){Qt===null?Qt=[e]:Qt.push(e)}function Fm(e){ci=!0,_c(e)}function pn(){if(!vl&&Qt!==null){vl=!0;var e=0,t=Ee;try{var n=Qt;for(Ee=1;e>=f,a-=f,Gt=1<<32-_t(t)+a|n<se?(qe=oe,oe=null):qe=oe.sibling;var we=z(E,oe,A[se],H);if(we===null){oe===null&&(oe=qe);break}e&&oe&&we.alternate===null&&t(E,oe),x=u(we,x,se),re===null?ee=we:re.sibling=we,re=we,oe=qe}if(se===A.length)return n(E,oe),Ne&&Nn(E,se),ee;if(oe===null){for(;sese?(qe=oe,oe=null):qe=oe.sibling;var Cn=z(E,oe,we.value,H);if(Cn===null){oe===null&&(oe=qe);break}e&&oe&&Cn.alternate===null&&t(E,oe),x=u(Cn,x,se),re===null?ee=Cn:re.sibling=Cn,re=Cn,oe=qe}if(we.done)return n(E,oe),Ne&&Nn(E,se),ee;if(oe===null){for(;!we.done;se++,we=A.next())we=F(E,we.value,H),we!==null&&(x=u(we,x,se),re===null?ee=we:re.sibling=we,re=we);return Ne&&Nn(E,se),ee}for(oe=o(E,oe);!we.done;se++,we=A.next())we=G(oe,E,se,we.value,H),we!==null&&(e&&we.alternate!==null&&oe.delete(we.key===null?se:we.key),x=u(we,x,se),re===null?ee=we:re.sibling=we,re=we);return e&&oe.forEach(function(vg){return t(E,vg)}),Ne&&Nn(E,se),ee}function $e(E,x,A,H){if(typeof A=="object"&&A!==null&&A.type===V&&A.key===null&&(A=A.props.children),typeof A=="object"&&A!==null){switch(A.$$typeof){case I:e:{for(var ee=A.key,re=x;re!==null;){if(re.key===ee){if(ee=A.type,ee===V){if(re.tag===7){n(E,re.sibling),x=a(re,A.props.children),x.return=E,E=x;break e}}else if(re.elementType===ee||typeof ee=="object"&&ee!==null&&ee.$$typeof===Ue&&Dc(ee)===re.type){n(E,re.sibling),x=a(re,A.props),x.ref=ro(E,re,A),x.return=E,E=x;break e}n(E,re);break}else t(E,re);re=re.sibling}A.type===V?(x=Fn(A.props.children,E.mode,H,A.key),x.return=E,E=x):(H=$i(A.type,A.key,A.props,null,E.mode,H),H.ref=ro(E,x,A),H.return=E,E=H)}return f(E);case M:e:{for(re=A.key;x!==null;){if(x.key===re)if(x.tag===4&&x.stateNode.containerInfo===A.containerInfo&&x.stateNode.implementation===A.implementation){n(E,x.sibling),x=a(x,A.children||[]),x.return=E,E=x;break e}else{n(E,x);break}else t(E,x);x=x.sibling}x=ma(A,E.mode,H),x.return=E,E=x}return f(E);case Ue:return re=A._init,$e(E,x,re(A._payload),H)}if(Or(A))return J(E,x,A,H);if(te(A))return Z(E,x,A,H);hi(E,A)}return typeof A=="string"&&A!==""||typeof A=="number"?(A=""+A,x!==null&&x.tag===6?(n(E,x.sibling),x=a(x,A),x.return=E,E=x):(n(E,x),x=ha(A,E.mode,H),x.return=E,E=x),f(E)):n(E,x)}return $e}var cr=zc(!0),$c=zc(!1),mi=dn(null),gi=null,dr=null,El=null;function jl(){El=dr=gi=null}function Al(e){var t=mi.current;Pe(mi),e._currentValue=t}function Rl(e,t,n){for(;e!==null;){var o=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,o!==null&&(o.childLanes|=t)):o!==null&&(o.childLanes&t)!==t&&(o.childLanes|=t),e===n)break;e=e.return}}function fr(e,t){gi=e,El=dr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(at=!0),e.firstContext=null)}function Et(e){var t=e._currentValue;if(El!==e)if(e={context:e,memoizedValue:t,next:null},dr===null){if(gi===null)throw Error(s(308));dr=e,gi.dependencies={lanes:0,firstContext:e}}else dr=dr.next=e;return t}var On=null;function Pl(e){On===null?On=[e]:On.push(e)}function Fc(e,t,n,o){var a=t.interleaved;return a===null?(n.next=n,Pl(t)):(n.next=a.next,a.next=n),t.interleaved=n,Xt(e,o)}function Xt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var hn=!1;function Tl(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Bc(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Jt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function mn(e,t,n){var o=e.updateQueue;if(o===null)return null;if(o=o.shared,ve&2){var a=o.pending;return a===null?t.next=t:(t.next=a.next,a.next=t),o.pending=t,Xt(e,n)}return a=o.interleaved,a===null?(t.next=t,Pl(o)):(t.next=a.next,a.next=t),o.interleaved=t,Xt(e,n)}function yi(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var o=t.lanes;o&=e.pendingLanes,n|=o,t.lanes=n,Hs(e,n)}}function bc(e,t){var n=e.updateQueue,o=e.alternate;if(o!==null&&(o=o.updateQueue,n===o)){var a=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var f={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?a=u=f:u=u.next=f,n=n.next}while(n!==null);u===null?a=u=t:u=u.next=t}else a=u=t;n={baseState:o.baseState,firstBaseUpdate:a,lastBaseUpdate:u,shared:o.shared,effects:o.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function vi(e,t,n,o){var a=e.updateQueue;hn=!1;var u=a.firstBaseUpdate,f=a.lastBaseUpdate,m=a.shared.pending;if(m!==null){a.shared.pending=null;var y=m,P=y.next;y.next=null,f===null?u=P:f.next=P,f=y;var $=e.alternate;$!==null&&($=$.updateQueue,m=$.lastBaseUpdate,m!==f&&(m===null?$.firstBaseUpdate=P:m.next=P,$.lastBaseUpdate=y))}if(u!==null){var F=a.baseState;f=0,$=P=y=null,m=u;do{var z=m.lane,G=m.eventTime;if((o&z)===z){$!==null&&($=$.next={eventTime:G,lane:0,tag:m.tag,payload:m.payload,callback:m.callback,next:null});e:{var J=e,Z=m;switch(z=t,G=n,Z.tag){case 1:if(J=Z.payload,typeof J=="function"){F=J.call(G,F,z);break e}F=J;break e;case 3:J.flags=J.flags&-65537|128;case 0:if(J=Z.payload,z=typeof J=="function"?J.call(G,F,z):J,z==null)break e;F=Q({},F,z);break e;case 2:hn=!0}}m.callback!==null&&m.lane!==0&&(e.flags|=64,z=a.effects,z===null?a.effects=[m]:z.push(m))}else G={eventTime:G,lane:z,tag:m.tag,payload:m.payload,callback:m.callback,next:null},$===null?(P=$=G,y=F):$=$.next=G,f|=z;if(m=m.next,m===null){if(m=a.shared.pending,m===null)break;z=m,m=z.next,z.next=null,a.lastBaseUpdate=z,a.shared.pending=null}}while(!0);if($===null&&(y=F),a.baseState=y,a.firstBaseUpdate=P,a.lastBaseUpdate=$,t=a.shared.interleaved,t!==null){a=t;do f|=a.lane,a=a.next;while(a!==t)}else u===null&&(a.shared.lanes=0);In|=f,e.lanes=f,e.memoizedState=F}}function Uc(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var o=Ll.transition;Ll.transition={};try{e(!1),t()}finally{Ee=n,Ll.transition=o}}function ld(){return jt().memoizedState}function Hm(e,t,n){var o=xn(e);if(n={lane:o,action:n,hasEagerState:!1,eagerState:null,next:null},ad(e))ud(t,n);else if(n=Fc(e,t,n,o),n!==null){var a=it();Dt(n,e,o,a),cd(n,t,o)}}function Vm(e,t,n){var o=xn(e),a={lane:o,action:n,hasEagerState:!1,eagerState:null,next:null};if(ad(e))ud(t,a);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var f=t.lastRenderedState,m=u(f,n);if(a.hasEagerState=!0,a.eagerState=m,Nt(m,f)){var y=t.interleaved;y===null?(a.next=a,Pl(t)):(a.next=y.next,y.next=a),t.interleaved=a;return}}catch{}finally{}n=Fc(e,t,a,o),n!==null&&(a=it(),Dt(n,e,o,a),cd(n,t,o))}}function ad(e){var t=e.alternate;return e===Le||t!==null&&t===Le}function ud(e,t){lo=Si=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function cd(e,t,n){if(n&4194240){var o=t.lanes;o&=e.pendingLanes,n|=o,t.lanes=n,Hs(e,n)}}var Ei={readContext:Et,useCallback:Ze,useContext:Ze,useEffect:Ze,useImperativeHandle:Ze,useInsertionEffect:Ze,useLayoutEffect:Ze,useMemo:Ze,useReducer:Ze,useRef:Ze,useState:Ze,useDebugValue:Ze,useDeferredValue:Ze,useTransition:Ze,useMutableSource:Ze,useSyncExternalStore:Ze,useId:Ze,unstable_isNewReconciler:!1},Wm={readContext:Et,useCallback:function(e,t){return Ut().memoizedState=[e,t===void 0?null:t],e},useContext:Et,useEffect:Zc,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Ci(4194308,4,nd.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ci(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ci(4,2,e,t)},useMemo:function(e,t){var n=Ut();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var o=Ut();return t=n!==void 0?n(t):t,o.memoizedState=o.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},o.queue=e,e=e.dispatch=Hm.bind(null,Le,e),[o.memoizedState,e]},useRef:function(e){var t=Ut();return e={current:e},t.memoizedState=e},useState:Xc,useDebugValue:bl,useDeferredValue:function(e){return Ut().memoizedState=e},useTransition:function(){var e=Xc(!1),t=e[0];return e=Um.bind(null,e[1]),Ut().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var o=Le,a=Ut();if(Ne){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),Ye===null)throw Error(s(349));Ln&30||Yc(o,t,n)}a.memoizedState=n;var u={value:n,getSnapshot:t};return a.queue=u,Zc(Qc.bind(null,o,u,e),[e]),o.flags|=2048,co(9,qc.bind(null,o,u,n,t),void 0,null),n},useId:function(){var e=Ut(),t=Ye.identifierPrefix;if(Ne){var n=Kt,o=Gt;n=(o&~(1<<32-_t(o)-1)).toString(32)+n,t=":"+t+"R"+n,n=ao++,0<\/script>",e=e.removeChild(e.firstChild)):typeof o.is=="string"?e=f.createElement(n,{is:o.is}):(e=f.createElement(n),n==="select"&&(f=e,o.multiple?f.multiple=!0:o.size&&(f.size=o.size))):e=f.createElementNS(e,n),e[Bt]=t,e[to]=o,_d(e,t,!1,!1),t.stateNode=e;e:{switch(f=Ms(n,o),n){case"dialog":Re("cancel",e),Re("close",e),a=o;break;case"iframe":case"object":case"embed":Re("load",e),a=o;break;case"video":case"audio":for(a=0;ayr&&(t.flags|=128,o=!0,fo(u,!1),t.lanes=4194304)}else{if(!o)if(e=xi(f),e!==null){if(t.flags|=128,o=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),fo(u,!0),u.tail===null&&u.tailMode==="hidden"&&!f.alternate&&!Ne)return et(t),null}else 2*ze()-u.renderingStartTime>yr&&n!==1073741824&&(t.flags|=128,o=!0,fo(u,!1),t.lanes=4194304);u.isBackwards?(f.sibling=t.child,t.child=f):(n=u.last,n!==null?n.sibling=f:t.child=f,u.last=f)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=ze(),t.sibling=null,n=Me.current,Ae(Me,o?n&1|2:n&1),t):(et(t),null);case 22:case 23:return da(),o=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==o&&(t.flags|=8192),o&&t.mode&1?yt&1073741824&&(et(t),t.subtreeFlags&6&&(t.flags|=8192)):et(t),null;case 24:return null;case 25:return null}throw Error(s(156,t.tag))}function Zm(e,t){switch(wl(t),t.tag){case 1:return lt(t.type)&&ai(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return pr(),Pe(st),Pe(Je),Ml(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Nl(t),null;case 13:if(Pe(Me),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));ur()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Pe(Me),null;case 4:return pr(),null;case 10:return Al(t.type._context),null;case 22:case 23:return da(),null;case 24:return null;default:return null}}var Pi=!1,tt=!1,eg=typeof WeakSet=="function"?WeakSet:Set,X=null;function mr(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(o){De(e,t,o)}else n.current=null}function Zl(e,t,n){try{n()}catch(o){De(e,t,o)}}var Md=!1;function tg(e,t){if(dl=Qo,e=dc(),rl(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var o=n.getSelection&&n.getSelection();if(o&&o.rangeCount!==0){n=o.anchorNode;var a=o.anchorOffset,u=o.focusNode;o=o.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var f=0,m=-1,y=-1,P=0,$=0,F=e,z=null;t:for(;;){for(var G;F!==n||a!==0&&F.nodeType!==3||(m=f+a),F!==u||o!==0&&F.nodeType!==3||(y=f+o),F.nodeType===3&&(f+=F.nodeValue.length),(G=F.firstChild)!==null;)z=F,F=G;for(;;){if(F===e)break t;if(z===n&&++P===a&&(m=f),z===u&&++$===o&&(y=f),(G=F.nextSibling)!==null)break;F=z,z=F.parentNode}F=G}n=m===-1||y===-1?null:{start:m,end:y}}else n=null}n=n||{start:0,end:0}}else n=null;for(fl={focusedElem:e,selectionRange:n},Qo=!1,X=t;X!==null;)if(t=X,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,X=e;else for(;X!==null;){t=X;try{var J=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(J!==null){var Z=J.memoizedProps,$e=J.memoizedState,E=t.stateNode,x=E.getSnapshotBeforeUpdate(t.elementType===t.type?Z:Mt(t.type,Z),$e);E.__reactInternalSnapshotBeforeUpdate=x}break;case 3:var A=t.stateNode.containerInfo;A.nodeType===1?A.textContent="":A.nodeType===9&&A.documentElement&&A.removeChild(A.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(s(163))}}catch(H){De(t,t.return,H)}if(e=t.sibling,e!==null){e.return=t.return,X=e;break}X=t.return}return J=Md,Md=!1,J}function po(e,t,n){var o=t.updateQueue;if(o=o!==null?o.lastEffect:null,o!==null){var a=o=o.next;do{if((a.tag&e)===e){var u=a.destroy;a.destroy=void 0,u!==void 0&&Zl(t,n,u)}a=a.next}while(a!==o)}}function Ti(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var o=n.create;n.destroy=o()}n=n.next}while(n!==t)}}function ea(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ld(e){var t=e.alternate;t!==null&&(e.alternate=null,Ld(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Bt],delete t[to],delete t[gl],delete t[zm],delete t[$m])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Id(e){return e.tag===5||e.tag===3||e.tag===4}function Dd(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Id(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function ta(e,t,n){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=si));else if(o!==4&&(e=e.child,e!==null))for(ta(e,t,n),e=e.sibling;e!==null;)ta(e,t,n),e=e.sibling}function na(e,t,n){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(o!==4&&(e=e.child,e!==null))for(na(e,t,n),e=e.sibling;e!==null;)na(e,t,n),e=e.sibling}var Ke=null,Lt=!1;function gn(e,t,n){for(n=n.child;n!==null;)zd(e,t,n),n=n.sibling}function zd(e,t,n){if(Ft&&typeof Ft.onCommitFiberUnmount=="function")try{Ft.onCommitFiberUnmount(Uo,n)}catch{}switch(n.tag){case 5:tt||mr(n,t);case 6:var o=Ke,a=Lt;Ke=null,gn(e,t,n),Ke=o,Lt=a,Ke!==null&&(Lt?(e=Ke,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Ke.removeChild(n.stateNode));break;case 18:Ke!==null&&(Lt?(e=Ke,n=n.stateNode,e.nodeType===8?ml(e.parentNode,n):e.nodeType===1&&ml(e,n),Vr(e)):ml(Ke,n.stateNode));break;case 4:o=Ke,a=Lt,Ke=n.stateNode.containerInfo,Lt=!0,gn(e,t,n),Ke=o,Lt=a;break;case 0:case 11:case 14:case 15:if(!tt&&(o=n.updateQueue,o!==null&&(o=o.lastEffect,o!==null))){a=o=o.next;do{var u=a,f=u.destroy;u=u.tag,f!==void 0&&(u&2||u&4)&&Zl(n,t,f),a=a.next}while(a!==o)}gn(e,t,n);break;case 1:if(!tt&&(mr(n,t),o=n.stateNode,typeof o.componentWillUnmount=="function"))try{o.props=n.memoizedProps,o.state=n.memoizedState,o.componentWillUnmount()}catch(m){De(n,t,m)}gn(e,t,n);break;case 21:gn(e,t,n);break;case 22:n.mode&1?(tt=(o=tt)||n.memoizedState!==null,gn(e,t,n),tt=o):gn(e,t,n);break;default:gn(e,t,n)}}function $d(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new eg),t.forEach(function(o){var a=cg.bind(null,e,o);n.has(o)||(n.add(o),o.then(a,a))})}}function It(e,t){var n=t.deletions;if(n!==null)for(var o=0;oa&&(a=f),o&=~u}if(o=a,o=ze()-o,o=(120>o?120:480>o?480:1080>o?1080:1920>o?1920:3e3>o?3e3:4320>o?4320:1960*rg(o/1960))-o,10e?16:e,vn===null)var o=!1;else{if(e=vn,vn=null,Li=0,ve&6)throw Error(s(331));var a=ve;for(ve|=4,X=e.current;X!==null;){var u=X,f=u.child;if(X.flags&16){var m=u.deletions;if(m!==null){for(var y=0;yze()-ia?zn(e,0):oa|=n),ct(e,t)}function Xd(e,t){t===0&&(e.mode&1?(t=Vo,Vo<<=1,!(Vo&130023424)&&(Vo=4194304)):t=1);var n=it();e=Xt(e,t),e!==null&&(Fr(e,t,n),ct(e,n))}function ug(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Xd(e,n)}function cg(e,t){var n=0;switch(e.tag){case 13:var o=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:o=e.stateNode;break;default:throw Error(s(314))}o!==null&&o.delete(t),Xd(e,n)}var Jd;Jd=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||st.current)at=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return at=!1,Xm(e,t,n);at=!!(e.flags&131072)}else at=!1,Ne&&t.flags&1048576&&Nc(t,fi,t.index);switch(t.lanes=0,t.tag){case 2:var o=t.type;Ri(e,t),e=t.pendingProps;var a=sr(t,Je.current);fr(t,n),a=Dl(null,t,o,e,a,n);var u=zl();return t.flags|=1,typeof a=="object"&&a!==null&&typeof a.render=="function"&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,lt(o)?(u=!0,ui(t)):u=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,Tl(t),a.updater=ji,t.stateNode=a,a._reactInternals=t,Hl(t,o,e,n),t=ql(null,t,o,!0,u,n)):(t.tag=0,Ne&&u&&xl(t),ot(null,t,a,n),t=t.child),t;case 16:o=t.elementType;e:{switch(Ri(e,t),e=t.pendingProps,a=o._init,o=a(o._payload),t.type=o,a=t.tag=fg(o),e=Mt(o,e),a){case 0:t=Yl(null,t,o,e,n);break e;case 1:t=Ed(null,t,o,e,n);break e;case 11:t=xd(null,t,o,e,n);break e;case 14:t=wd(null,t,o,Mt(o.type,e),n);break e}throw Error(s(306,o,""))}return t;case 0:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Mt(o,a),Yl(e,t,o,a,n);case 1:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Mt(o,a),Ed(e,t,o,a,n);case 3:e:{if(jd(t),e===null)throw Error(s(387));o=t.pendingProps,u=t.memoizedState,a=u.element,Bc(e,t),vi(t,o,null,n);var f=t.memoizedState;if(o=f.element,u.isDehydrated)if(u={element:o,isDehydrated:!1,cache:f.cache,pendingSuspenseBoundaries:f.pendingSuspenseBoundaries,transitions:f.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){a=hr(Error(s(423)),t),t=Ad(e,t,o,n,a);break e}else if(o!==a){a=hr(Error(s(424)),t),t=Ad(e,t,o,n,a);break e}else for(gt=cn(t.stateNode.containerInfo.firstChild),mt=t,Ne=!0,Ot=null,n=$c(t,null,o,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(ur(),o===a){t=Zt(e,t,n);break e}ot(e,t,o,n)}t=t.child}return t;case 5:return Hc(t),e===null&&Cl(t),o=t.type,a=t.pendingProps,u=e!==null?e.memoizedProps:null,f=a.children,pl(o,a)?f=null:u!==null&&pl(o,u)&&(t.flags|=32),kd(e,t),ot(e,t,f,n),t.child;case 6:return e===null&&Cl(t),null;case 13:return Rd(e,t,n);case 4:return _l(t,t.stateNode.containerInfo),o=t.pendingProps,e===null?t.child=cr(t,null,o,n):ot(e,t,o,n),t.child;case 11:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Mt(o,a),xd(e,t,o,a,n);case 7:return ot(e,t,t.pendingProps,n),t.child;case 8:return ot(e,t,t.pendingProps.children,n),t.child;case 12:return ot(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(o=t.type._context,a=t.pendingProps,u=t.memoizedProps,f=a.value,Ae(mi,o._currentValue),o._currentValue=f,u!==null)if(Nt(u.value,f)){if(u.children===a.children&&!st.current){t=Zt(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var m=u.dependencies;if(m!==null){f=u.child;for(var y=m.firstContext;y!==null;){if(y.context===o){if(u.tag===1){y=Jt(-1,n&-n),y.tag=2;var P=u.updateQueue;if(P!==null){P=P.shared;var $=P.pending;$===null?y.next=y:(y.next=$.next,$.next=y),P.pending=y}}u.lanes|=n,y=u.alternate,y!==null&&(y.lanes|=n),Rl(u.return,n,t),m.lanes|=n;break}y=y.next}}else if(u.tag===10)f=u.type===t.type?null:u.child;else if(u.tag===18){if(f=u.return,f===null)throw Error(s(341));f.lanes|=n,m=f.alternate,m!==null&&(m.lanes|=n),Rl(f,n,t),f=u.sibling}else f=u.child;if(f!==null)f.return=u;else for(f=u;f!==null;){if(f===t){f=null;break}if(u=f.sibling,u!==null){u.return=f.return,f=u;break}f=f.return}u=f}ot(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,o=t.pendingProps.children,fr(t,n),a=Et(a),o=o(a),t.flags|=1,ot(e,t,o,n),t.child;case 14:return o=t.type,a=Mt(o,t.pendingProps),a=Mt(o.type,a),wd(e,t,o,a,n);case 15:return Sd(e,t,t.type,t.pendingProps,n);case 17:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Mt(o,a),Ri(e,t),t.tag=1,lt(o)?(e=!0,ui(t)):e=!1,fr(t,n),fd(t,o,a),Hl(t,o,a,n),ql(null,t,o,!0,e,n);case 19:return Td(e,t,n);case 22:return Cd(e,t,n)}throw Error(s(156,t.tag))};function Zd(e,t){return Ou(e,t)}function dg(e,t,n,o){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=o,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Rt(e,t,n,o){return new dg(e,t,n,o)}function pa(e){return e=e.prototype,!(!e||!e.isReactComponent)}function fg(e){if(typeof e=="function")return pa(e)?1:0;if(e!=null){if(e=e.$$typeof,e===de)return 11;if(e===Se)return 14}return 2}function Sn(e,t){var n=e.alternate;return n===null?(n=Rt(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function $i(e,t,n,o,a,u){var f=2;if(o=e,typeof e=="function")pa(e)&&(f=1);else if(typeof e=="string")f=5;else e:switch(e){case V:return Fn(n.children,a,u,t);case ne:f=8,a|=8;break;case ye:return e=Rt(12,n,t,a|2),e.elementType=ye,e.lanes=u,e;case me:return e=Rt(13,n,t,a),e.elementType=me,e.lanes=u,e;case _e:return e=Rt(19,n,t,a),e.elementType=_e,e.lanes=u,e;case je:return Fi(n,a,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Ie:f=10;break e;case ie:f=9;break e;case de:f=11;break e;case Se:f=14;break e;case Ue:f=16,o=null;break e}throw Error(s(130,e==null?e:typeof e,""))}return t=Rt(f,n,t,a),t.elementType=e,t.type=o,t.lanes=u,t}function Fn(e,t,n,o){return e=Rt(7,e,o,t),e.lanes=n,e}function Fi(e,t,n,o){return e=Rt(22,e,o,t),e.elementType=je,e.lanes=n,e.stateNode={isHidden:!1},e}function ha(e,t,n){return e=Rt(6,e,null,t),e.lanes=n,e}function ma(e,t,n){return t=Rt(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function pg(e,t,n,o,a){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Us(0),this.expirationTimes=Us(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Us(0),this.identifierPrefix=o,this.onRecoverableError=a,this.mutableSourceEagerHydrationData=null}function ga(e,t,n,o,a,u,f,m,y){return e=new pg(e,t,n,m,y),t===1?(t=1,u===!0&&(t|=8)):t=0,u=Rt(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:o,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Tl(u),e}function hg(e,t,n){var o=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(i){console.error(i)}}return r(),Ca.exports=Rg(),Ca.exports}var mf;function Tg(){if(mf)return Yi;mf=1;var r=Pg();return Yi.createRoot=r.createRoot,Yi.hydrateRoot=r.hydrateRoot,Yi}var _g=Tg(),rt=function(){return rt=Object.assign||function(i){for(var s,l=1,c=arguments.length;l0?Qe(Rr,--Pt):0,kr--,Be===10&&(kr=1,gs--),Be}function zt(){return Be=Pt2||Ba(Be)>3?"":" "}function Bg(r,i){for(;--i&&zt()&&!(Be<48||Be>102||Be>57&&Be<65||Be>70&&Be<97););return vs(r,ts()+(i<6&&Un()==32&&zt()==32))}function ba(r){for(;zt();)switch(Be){case r:return Pt;case 34:case 39:r!==34&&r!==39&&ba(Be);break;case 40:r===41&&ba(r);break;case 92:zt();break}return Pt}function bg(r,i){for(;zt()&&r+Be!==57;)if(r+Be===84&&Un()===47)break;return"/*"+vs(i,Pt-1)+"*"+ru(r===47?r:zt())}function Ug(r){for(;!Ba(Un());)zt();return vs(r,Pt)}function Hg(r){return $g(ns("",null,null,null,[""],r=zg(r),0,[0],r))}function ns(r,i,s,l,c,d,p,g,w){for(var v=0,S=0,j=p,R=0,L=0,T=0,O=1,_=1,b=1,U=0,B="",W=c,I=d,M=l,V=B;_;)switch(T=U,U=zt()){case 40:if(T!=108&&Qe(V,j-1)==58){es(V+=ce(ja(U),"&","&\f"),"&\f",mp(v?g[v-1]:0))!=-1&&(b=-1);break}case 34:case 39:case 91:V+=ja(U);break;case 9:case 10:case 13:case 32:V+=Fg(T);break;case 92:V+=Bg(ts()-1,7);continue;case 47:switch(Un()){case 42:case 47:ko(Vg(bg(zt(),ts()),i,s,w),w);break;default:V+="/"}break;case 123*O:g[v++]=Wt(V)*b;case 125*O:case 59:case 0:switch(U){case 0:case 125:_=0;case 59+S:b==-1&&(V=ce(V,/\f/g,"")),L>0&&Wt(V)-j&&ko(L>32?vf(V+";",l,s,j-1,w):vf(ce(V," ","")+";",l,s,j-2,w),w);break;case 59:V+=";";default:if(ko(M=yf(V,i,s,v,S,c,g,B,W=[],I=[],j,d),d),U===123)if(S===0)ns(V,i,M,M,W,d,j,g,I);else switch(R===99&&Qe(V,3)===110?100:R){case 100:case 108:case 109:case 115:ns(r,M,M,l&&ko(yf(r,M,M,0,0,c,g,B,c,W=[],j,I),I),c,I,j,g,l?W:I);break;default:ns(V,M,M,M,[""],I,0,g,I)}}v=S=L=0,O=b=1,B=V="",j=p;break;case 58:j=1+Wt(V),L=T;default:if(O<1){if(U==123)--O;else if(U==125&&O++==0&&Dg()==125)continue}switch(V+=ru(U),U*O){case 38:b=S>0?1:(V+="\f",-1);break;case 44:g[v++]=(Wt(V)-1)*b,b=1;break;case 64:Un()===45&&(V+=ja(zt())),R=Un(),S=j=Wt(B=V+=Ug(ts())),U++;break;case 45:T===45&&Wt(V)==2&&(O=0)}}return d}function yf(r,i,s,l,c,d,p,g,w,v,S,j){for(var R=c-1,L=c===0?d:[""],T=yp(L),O=0,_=0,b=0;O0?L[U]+" "+B:ce(B,/&\f/g,L[U])))&&(w[b++]=W);return ys(r,i,s,c===0?ms:g,w,v,S,j)}function Vg(r,i,s,l){return ys(r,i,s,pp,ru(Ig()),Cr(r,2,-2),0,l)}function vf(r,i,s,l,c){return ys(r,i,s,nu,Cr(r,0,l),Cr(r,l+1,-1),l,c)}function xp(r,i,s){switch(Mg(r,i)){case 5103:return ke+"print-"+r+r;case 5737:case 4201:case 3177:case 3433:case 1641:case 4457:case 2921:case 5572:case 6356:case 5844:case 3191:case 6645:case 3005:case 6391:case 5879:case 5623:case 6135:case 4599:case 4855:case 4215:case 6389:case 5109:case 5365:case 5621:case 3829:return ke+r+r;case 4789:return Eo+r+r;case 5349:case 4246:case 4810:case 6968:case 2756:return ke+r+Eo+r+Te+r+r;case 5936:switch(Qe(r,i+11)){case 114:return ke+r+Te+ce(r,/[svh]\w+-[tblr]{2}/,"tb")+r;case 108:return ke+r+Te+ce(r,/[svh]\w+-[tblr]{2}/,"tb-rl")+r;case 45:return ke+r+Te+ce(r,/[svh]\w+-[tblr]{2}/,"lr")+r}case 6828:case 4268:case 2903:return ke+r+Te+r+r;case 6165:return ke+r+Te+"flex-"+r+r;case 5187:return ke+r+ce(r,/(\w+).+(:[^]+)/,ke+"box-$1$2"+Te+"flex-$1$2")+r;case 5443:return ke+r+Te+"flex-item-"+ce(r,/flex-|-self/g,"")+(tn(r,/flex-|baseline/)?"":Te+"grid-row-"+ce(r,/flex-|-self/g,""))+r;case 4675:return ke+r+Te+"flex-line-pack"+ce(r,/align-content|flex-|-self/g,"")+r;case 5548:return ke+r+Te+ce(r,"shrink","negative")+r;case 5292:return ke+r+Te+ce(r,"basis","preferred-size")+r;case 6060:return ke+"box-"+ce(r,"-grow","")+ke+r+Te+ce(r,"grow","positive")+r;case 4554:return ke+ce(r,/([^-])(transform)/g,"$1"+ke+"$2")+r;case 6187:return ce(ce(ce(r,/(zoom-|grab)/,ke+"$1"),/(image-set)/,ke+"$1"),r,"")+r;case 5495:case 3959:return ce(r,/(image-set\([^]*)/,ke+"$1$`$1");case 4968:return ce(ce(r,/(.+:)(flex-)?(.*)/,ke+"box-pack:$3"+Te+"flex-pack:$3"),/s.+-b[^;]+/,"justify")+ke+r+r;case 4200:if(!tn(r,/flex-|baseline/))return Te+"grid-column-align"+Cr(r,i)+r;break;case 2592:case 3360:return Te+ce(r,"template-","")+r;case 4384:case 3616:return s&&s.some(function(l,c){return i=c,tn(l.props,/grid-\w+-end/)})?~es(r+(s=s[i].value),"span",0)?r:Te+ce(r,"-start","")+r+Te+"grid-row-span:"+(~es(s,"span",0)?tn(s,/\d+/):+tn(s,/\d+/)-+tn(r,/\d+/))+";":Te+ce(r,"-start","")+r;case 4896:case 4128:return s&&s.some(function(l){return tn(l.props,/grid-\w+-start/)})?r:Te+ce(ce(r,"-end","-span"),"span ","")+r;case 4095:case 3583:case 4068:case 2532:return ce(r,/(.+)-inline(.+)/,ke+"$1$2")+r;case 8116:case 7059:case 5753:case 5535:case 5445:case 5701:case 4933:case 4677:case 5533:case 5789:case 5021:case 4765:if(Wt(r)-1-i>6)switch(Qe(r,i+1)){case 109:if(Qe(r,i+4)!==45)break;case 102:return ce(r,/(.+:)(.+)-([^]+)/,"$1"+ke+"$2-$3$1"+Eo+(Qe(r,i+3)==108?"$3":"$2-$3"))+r;case 115:return~es(r,"stretch",0)?xp(ce(r,"stretch","fill-available"),i,s)+r:r}break;case 5152:case 5920:return ce(r,/(.+?):(\d+)(\s*\/\s*(span)?\s*(\d+))?(.*)/,function(l,c,d,p,g,w,v){return Te+c+":"+d+v+(p?Te+c+"-span:"+(g?w:+w-+d)+v:"")+r});case 4949:if(Qe(r,i+6)===121)return ce(r,":",":"+ke)+r;break;case 6444:switch(Qe(r,Qe(r,14)===45?18:11)){case 120:return ce(r,/(.+:)([^;\s!]+)(;|(\s+)?!.+)?/,"$1"+ke+(Qe(r,14)===45?"inline-":"")+"box$3$1"+ke+"$2$3$1"+Te+"$2box$3")+r;case 100:return ce(r,":",":"+Te)+r}break;case 5719:case 2647:case 2135:case 3927:case 2391:return ce(r,"scroll-","scroll-snap-")+r}return r}function us(r,i){for(var s="",l=0;l-1&&!r.return)switch(r.type){case nu:r.return=xp(r.value,r.length,s);return;case hp:return us([kn(r,{value:ce(r.value,"@","@"+ke)})],l);case ms:if(r.length)return Lg(s=r.props,function(c){switch(tn(c,l=/(::plac\w+|:read-\w+)/)){case":read-only":case":read-write":xr(kn(r,{props:[ce(c,/:(read-\w+)/,":"+Eo+"$1")]})),xr(kn(r,{props:[c]})),Fa(r,{props:gf(s,l)});break;case"::placeholder":xr(kn(r,{props:[ce(c,/:(plac\w+)/,":"+ke+"input-$1")]})),xr(kn(r,{props:[ce(c,/:(plac\w+)/,":"+Eo+"$1")]})),xr(kn(r,{props:[ce(c,/:(plac\w+)/,Te+"input-$1")]})),xr(kn(r,{props:[c]})),Fa(r,{props:gf(s,l)});break}return""})}}var Gg={animationIterationCount:1,aspectRatio:1,borderImageOutset:1,borderImageSlice:1,borderImageWidth:1,boxFlex:1,boxFlexGroup:1,boxOrdinalGroup:1,columnCount:1,columns:1,flex:1,flexGrow:1,flexPositive:1,flexShrink:1,flexNegative:1,flexOrder:1,gridRow:1,gridRowEnd:1,gridRowSpan:1,gridRowStart:1,gridColumn:1,gridColumnEnd:1,gridColumnSpan:1,gridColumnStart:1,msGridRow:1,msGridRowSpan:1,msGridColumn:1,msGridColumnSpan:1,fontWeight:1,lineHeight:1,opacity:1,order:1,orphans:1,tabSize:1,widows:1,zIndex:1,zoom:1,WebkitLineClamp:1,fillOpacity:1,floodOpacity:1,stopOpacity:1,strokeDasharray:1,strokeDashoffset:1,strokeMiterlimit:1,strokeOpacity:1,strokeWidth:1},vt={},Er=typeof process<"u"&&vt!==void 0&&(vt.REACT_APP_SC_ATTR||vt.SC_ATTR)||"data-styled",wp="active",Sp="data-styled-version",xs="6.1.14",ou=`/*!sc*/ +`,cs=typeof window<"u"&&"HTMLElement"in window,Kg=!!(typeof SC_DISABLE_SPEEDY=="boolean"?SC_DISABLE_SPEEDY:typeof process<"u"&&vt!==void 0&&vt.REACT_APP_SC_DISABLE_SPEEDY!==void 0&&vt.REACT_APP_SC_DISABLE_SPEEDY!==""?vt.REACT_APP_SC_DISABLE_SPEEDY!=="false"&&vt.REACT_APP_SC_DISABLE_SPEEDY:typeof process<"u"&&vt!==void 0&&vt.SC_DISABLE_SPEEDY!==void 0&&vt.SC_DISABLE_SPEEDY!==""&&vt.SC_DISABLE_SPEEDY!=="false"&&vt.SC_DISABLE_SPEEDY),ws=Object.freeze([]),jr=Object.freeze({});function Xg(r,i,s){return s===void 0&&(s=jr),r.theme!==s.theme&&r.theme||i||s.theme}var Cp=new Set(["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","big","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","keygen","label","legend","li","link","main","map","mark","menu","menuitem","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","section","select","small","source","span","strong","style","sub","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","track","u","ul","use","var","video","wbr","circle","clipPath","defs","ellipse","foreignObject","g","image","line","linearGradient","marker","mask","path","pattern","polygon","polyline","radialGradient","rect","stop","svg","text","tspan"]),Jg=/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~-]+/g,Zg=/(^-|-$)/g;function xf(r){return r.replace(Jg,"-").replace(Zg,"")}var ey=/(a)(d)/gi,qi=52,wf=function(r){return String.fromCharCode(r+(r>25?39:97))};function Ua(r){var i,s="";for(i=Math.abs(r);i>qi;i=i/qi|0)s=wf(i%qi)+s;return(wf(i%qi)+s).replace(ey,"$1-$2")}var Aa,kp=5381,wr=function(r,i){for(var s=i.length;s;)r=33*r^i.charCodeAt(--s);return r},Ep=function(r){return wr(kp,r)};function ty(r){return Ua(Ep(r)>>>0)}function ny(r){return r.displayName||r.name||"Component"}function Ra(r){return typeof r=="string"&&!0}var jp=typeof Symbol=="function"&&Symbol.for,Ap=jp?Symbol.for("react.memo"):60115,ry=jp?Symbol.for("react.forward_ref"):60112,oy={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},iy={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},Rp={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},sy=((Aa={})[ry]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},Aa[Ap]=Rp,Aa);function Sf(r){return("type"in(i=r)&&i.type.$$typeof)===Ap?Rp:"$$typeof"in r?sy[r.$$typeof]:oy;var i}var ly=Object.defineProperty,ay=Object.getOwnPropertyNames,Cf=Object.getOwnPropertySymbols,uy=Object.getOwnPropertyDescriptor,cy=Object.getPrototypeOf,kf=Object.prototype;function Pp(r,i,s){if(typeof i!="string"){if(kf){var l=cy(i);l&&l!==kf&&Pp(r,l,s)}var c=ay(i);Cf&&(c=c.concat(Cf(i)));for(var d=Sf(r),p=Sf(i),g=0;g0?" Args: ".concat(i.join(", ")):""))}var dy=function(){function r(i){this.groupSizes=new Uint32Array(512),this.length=512,this.tag=i}return r.prototype.indexOfGroup=function(i){for(var s=0,l=0;l=this.groupSizes.length){for(var l=this.groupSizes,c=l.length,d=c;i>=d;)if((d<<=1)<0)throw Yn(16,"".concat(i));this.groupSizes=new Uint32Array(d),this.groupSizes.set(l),this.length=d;for(var p=c;p=this.length||this.groupSizes[i]===0)return s;for(var l=this.groupSizes[i],c=this.indexOfGroup(i),d=c+l,p=c;p=0){var l=document.createTextNode(s);return this.element.insertBefore(l,this.nodes[i]||null),this.length++,!0}return!1},r.prototype.deleteRule=function(i){this.element.removeChild(this.nodes[i]),this.length--},r.prototype.getRule=function(i){return i0&&(_+="".concat(b,","))}),w+="".concat(T).concat(O,'{content:"').concat(_,'"}').concat(ou)},S=0;S0?".".concat(i):R},S=w.slice();S.push(function(R){R.type===ms&&R.value.includes("&")&&(R.props[0]=R.props[0].replace(Cy,s).replace(l,v))}),p.prefix&&S.push(Qg),S.push(Wg);var j=function(R,L,T,O){L===void 0&&(L=""),T===void 0&&(T=""),O===void 0&&(O="&"),i=O,s=L,l=new RegExp("\\".concat(s,"\\b"),"g");var _=R.replace(ky,""),b=Hg(T||L?"".concat(T," ").concat(L," { ").concat(_," }"):_);p.namespace&&(b=Np(b,p.namespace));var U=[];return us(b,Yg(S.concat(qg(function(B){return U.push(B)})))),U};return j.hash=w.length?w.reduce(function(R,L){return L.name||Yn(15),wr(R,L.name)},kp).toString():"",j}var jy=new _p,Va=Ey(),Op=xt.createContext({shouldForwardProp:void 0,styleSheet:jy,stylis:Va});Op.Consumer;xt.createContext(void 0);function Rf(){return K.useContext(Op)}var Ay=function(){function r(i,s){var l=this;this.inject=function(c,d){d===void 0&&(d=Va);var p=l.name+d.hash;c.hasNameForId(l.id,p)||c.insertRules(l.id,p,d(l.rules,p,"@keyframes"))},this.name=i,this.id="sc-keyframes-".concat(i),this.rules=s,su(this,function(){throw Yn(12,String(l.name))})}return r.prototype.getName=function(i){return i===void 0&&(i=Va),this.name+i.hash},r}(),Ry=function(r){return r>="A"&&r<="Z"};function Pf(r){for(var i="",s=0;s>>0);if(!s.hasNameForId(this.componentId,p)){var g=l(d,".".concat(p),void 0,this.componentId);s.insertRules(this.componentId,p,g)}c=Bn(c,p),this.staticRulesId=p}else{for(var w=wr(this.baseHash,l.hash),v="",S=0;S>>0);s.hasNameForId(this.componentId,L)||s.insertRules(this.componentId,L,l(v,".".concat(L),void 0,this.componentId)),c=Bn(c,L)}}return c},r}(),fs=xt.createContext(void 0);fs.Consumer;function Tf(r){var i=xt.useContext(fs),s=K.useMemo(function(){return function(l,c){if(!l)throw Yn(14);if(Wn(l)){var d=l(c);return d}if(Array.isArray(l)||typeof l!="object")throw Yn(8);return c?rt(rt({},c),l):l}(r.theme,i)},[r.theme,i]);return r.children?xt.createElement(fs.Provider,{value:s},r.children):null}var Pa={};function Ny(r,i,s){var l=iu(r),c=r,d=!Ra(r),p=i.attrs,g=p===void 0?ws:p,w=i.componentId,v=w===void 0?function(W,I){var M=typeof W!="string"?"sc":xf(W);Pa[M]=(Pa[M]||0)+1;var V="".concat(M,"-").concat(ty(xs+M+Pa[M]));return I?"".concat(I,"-").concat(V):V}(i.displayName,i.parentComponentId):w,S=i.displayName,j=S===void 0?function(W){return Ra(W)?"styled.".concat(W):"Styled(".concat(ny(W),")")}(r):S,R=i.displayName&&i.componentId?"".concat(xf(i.displayName),"-").concat(i.componentId):i.componentId||v,L=l&&c.attrs?c.attrs.concat(g).filter(Boolean):g,T=i.shouldForwardProp;if(l&&c.shouldForwardProp){var O=c.shouldForwardProp;if(i.shouldForwardProp){var _=i.shouldForwardProp;T=function(W,I){return O(W,I)&&_(W,I)}}else T=O}var b=new _y(s,R,l?c.componentStyle:void 0);function U(W,I){return function(M,V,ne){var ye=M.attrs,Ie=M.componentStyle,ie=M.defaultProps,de=M.foldedComponentIds,me=M.styledComponentId,_e=M.target,Se=xt.useContext(fs),Ue=Rf(),je=M.shouldForwardProp||Ue.shouldForwardProp,Y=Xg(V,Se,ie)||jr,te=function(he,fe,Ce){for(var ge,xe=rt(rt({},fe),{className:void 0,theme:Ce}),Ge=0;Ge{let i;const s=new Set,l=(v,S)=>{const j=typeof v=="function"?v(i):v;if(!Object.is(j,i)){const R=i;i=S??(typeof j!="object"||j===null)?j:Object.assign({},i,j),s.forEach(L=>L(i,R))}},c=()=>i,g={setState:l,getState:c,getInitialState:()=>w,subscribe:v=>(s.add(v),()=>s.delete(v))},w=i=r(l,c,g);return g},My=r=>r?Of(r):Of,Ly=r=>r;function Iy(r,i=Ly){const s=xt.useSyncExternalStore(r.subscribe,()=>i(r.getState()),()=>i(r.getInitialState()));return xt.useDebugValue(s),s}const Mf=r=>{const i=My(r),s=l=>Iy(i,l);return Object.assign(s,i),s},Pr=r=>r?Mf(r):Mf;function Dp(r,i){return function(){return r.apply(i,arguments)}}const{toString:Dy}=Object.prototype,{getPrototypeOf:lu}=Object,Ss=(r=>i=>{const s=Dy.call(i);return r[s]||(r[s]=s.slice(8,-1).toLowerCase())})(Object.create(null)),$t=r=>(r=r.toLowerCase(),i=>Ss(i)===r),Cs=r=>i=>typeof i===r,{isArray:Tr}=Array,No=Cs("undefined");function zy(r){return r!==null&&!No(r)&&r.constructor!==null&&!No(r.constructor)&&wt(r.constructor.isBuffer)&&r.constructor.isBuffer(r)}const zp=$t("ArrayBuffer");function $y(r){let i;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?i=ArrayBuffer.isView(r):i=r&&r.buffer&&zp(r.buffer),i}const Fy=Cs("string"),wt=Cs("function"),$p=Cs("number"),ks=r=>r!==null&&typeof r=="object",By=r=>r===!0||r===!1,is=r=>{if(Ss(r)!=="object")return!1;const i=lu(r);return(i===null||i===Object.prototype||Object.getPrototypeOf(i)===null)&&!(Symbol.toStringTag in r)&&!(Symbol.iterator in r)},by=$t("Date"),Uy=$t("File"),Hy=$t("Blob"),Vy=$t("FileList"),Wy=r=>ks(r)&&wt(r.pipe),Yy=r=>{let i;return r&&(typeof FormData=="function"&&r instanceof FormData||wt(r.append)&&((i=Ss(r))==="formdata"||i==="object"&&wt(r.toString)&&r.toString()==="[object FormData]"))},qy=$t("URLSearchParams"),[Qy,Gy,Ky,Xy]=["ReadableStream","Request","Response","Headers"].map($t),Jy=r=>r.trim?r.trim():r.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function Lo(r,i,{allOwnKeys:s=!1}={}){if(r===null||typeof r>"u")return;let l,c;if(typeof r!="object"&&(r=[r]),Tr(r))for(l=0,c=r.length;l0;)if(c=s[l],i===c.toLowerCase())return c;return null}const bn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,Bp=r=>!No(r)&&r!==bn;function Ya(){const{caseless:r}=Bp(this)&&this||{},i={},s=(l,c)=>{const d=r&&Fp(i,c)||c;is(i[d])&&is(l)?i[d]=Ya(i[d],l):is(l)?i[d]=Ya({},l):Tr(l)?i[d]=l.slice():i[d]=l};for(let l=0,c=arguments.length;l(Lo(i,(c,d)=>{s&&wt(c)?r[d]=Dp(c,s):r[d]=c},{allOwnKeys:l}),r),e0=r=>(r.charCodeAt(0)===65279&&(r=r.slice(1)),r),t0=(r,i,s,l)=>{r.prototype=Object.create(i.prototype,l),r.prototype.constructor=r,Object.defineProperty(r,"super",{value:i.prototype}),s&&Object.assign(r.prototype,s)},n0=(r,i,s,l)=>{let c,d,p;const g={};if(i=i||{},r==null)return i;do{for(c=Object.getOwnPropertyNames(r),d=c.length;d-- >0;)p=c[d],(!l||l(p,r,i))&&!g[p]&&(i[p]=r[p],g[p]=!0);r=s!==!1&&lu(r)}while(r&&(!s||s(r,i))&&r!==Object.prototype);return i},r0=(r,i,s)=>{r=String(r),(s===void 0||s>r.length)&&(s=r.length),s-=i.length;const l=r.indexOf(i,s);return l!==-1&&l===s},o0=r=>{if(!r)return null;if(Tr(r))return r;let i=r.length;if(!$p(i))return null;const s=new Array(i);for(;i-- >0;)s[i]=r[i];return s},i0=(r=>i=>r&&i instanceof r)(typeof Uint8Array<"u"&&lu(Uint8Array)),s0=(r,i)=>{const l=(r&&r[Symbol.iterator]).call(r);let c;for(;(c=l.next())&&!c.done;){const d=c.value;i.call(r,d[0],d[1])}},l0=(r,i)=>{let s;const l=[];for(;(s=r.exec(i))!==null;)l.push(s);return l},a0=$t("HTMLFormElement"),u0=r=>r.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(s,l,c){return l.toUpperCase()+c}),Lf=(({hasOwnProperty:r})=>(i,s)=>r.call(i,s))(Object.prototype),c0=$t("RegExp"),bp=(r,i)=>{const s=Object.getOwnPropertyDescriptors(r),l={};Lo(s,(c,d)=>{let p;(p=i(c,d,r))!==!1&&(l[d]=p||c)}),Object.defineProperties(r,l)},d0=r=>{bp(r,(i,s)=>{if(wt(r)&&["arguments","caller","callee"].indexOf(s)!==-1)return!1;const l=r[s];if(wt(l)){if(i.enumerable=!1,"writable"in i){i.writable=!1;return}i.set||(i.set=()=>{throw Error("Can not rewrite read-only method '"+s+"'")})}})},f0=(r,i)=>{const s={},l=c=>{c.forEach(d=>{s[d]=!0})};return Tr(r)?l(r):l(String(r).split(i)),s},p0=()=>{},h0=(r,i)=>r!=null&&Number.isFinite(r=+r)?r:i,Ta="abcdefghijklmnopqrstuvwxyz",If="0123456789",Up={DIGIT:If,ALPHA:Ta,ALPHA_DIGIT:Ta+Ta.toUpperCase()+If},m0=(r=16,i=Up.ALPHA_DIGIT)=>{let s="";const{length:l}=i;for(;r--;)s+=i[Math.random()*l|0];return s};function g0(r){return!!(r&&wt(r.append)&&r[Symbol.toStringTag]==="FormData"&&r[Symbol.iterator])}const y0=r=>{const i=new Array(10),s=(l,c)=>{if(ks(l)){if(i.indexOf(l)>=0)return;if(!("toJSON"in l)){i[c]=l;const d=Tr(l)?[]:{};return Lo(l,(p,g)=>{const w=s(p,c+1);!No(w)&&(d[g]=w)}),i[c]=void 0,d}}return l};return s(r,0)},v0=$t("AsyncFunction"),x0=r=>r&&(ks(r)||wt(r))&&wt(r.then)&&wt(r.catch),Hp=((r,i)=>r?setImmediate:i?((s,l)=>(bn.addEventListener("message",({source:c,data:d})=>{c===bn&&d===s&&l.length&&l.shift()()},!1),c=>{l.push(c),bn.postMessage(s,"*")}))(`axios@${Math.random()}`,[]):s=>setTimeout(s))(typeof setImmediate=="function",wt(bn.postMessage)),w0=typeof queueMicrotask<"u"?queueMicrotask.bind(bn):typeof process<"u"&&process.nextTick||Hp,N={isArray:Tr,isArrayBuffer:zp,isBuffer:zy,isFormData:Yy,isArrayBufferView:$y,isString:Fy,isNumber:$p,isBoolean:By,isObject:ks,isPlainObject:is,isReadableStream:Qy,isRequest:Gy,isResponse:Ky,isHeaders:Xy,isUndefined:No,isDate:by,isFile:Uy,isBlob:Hy,isRegExp:c0,isFunction:wt,isStream:Wy,isURLSearchParams:qy,isTypedArray:i0,isFileList:Vy,forEach:Lo,merge:Ya,extend:Zy,trim:Jy,stripBOM:e0,inherits:t0,toFlatObject:n0,kindOf:Ss,kindOfTest:$t,endsWith:r0,toArray:o0,forEachEntry:s0,matchAll:l0,isHTMLForm:a0,hasOwnProperty:Lf,hasOwnProp:Lf,reduceDescriptors:bp,freezeMethods:d0,toObjectSet:f0,toCamelCase:u0,noop:p0,toFiniteNumber:h0,findKey:Fp,global:bn,isContextDefined:Bp,ALPHABET:Up,generateString:m0,isSpecCompliantForm:g0,toJSONObject:y0,isAsyncFn:v0,isThenable:x0,setImmediate:Hp,asap:w0};function ae(r,i,s,l,c){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack,this.message=r,this.name="AxiosError",i&&(this.code=i),s&&(this.config=s),l&&(this.request=l),c&&(this.response=c,this.status=c.status?c.status:null)}N.inherits(ae,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:N.toJSONObject(this.config),code:this.code,status:this.status}}});const Vp=ae.prototype,Wp={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach(r=>{Wp[r]={value:r}});Object.defineProperties(ae,Wp);Object.defineProperty(Vp,"isAxiosError",{value:!0});ae.from=(r,i,s,l,c,d)=>{const p=Object.create(Vp);return N.toFlatObject(r,p,function(w){return w!==Error.prototype},g=>g!=="isAxiosError"),ae.call(p,r.message,i,s,l,c),p.cause=r,p.name=r.name,d&&Object.assign(p,d),p};const S0=null;function qa(r){return N.isPlainObject(r)||N.isArray(r)}function Yp(r){return N.endsWith(r,"[]")?r.slice(0,-2):r}function Df(r,i,s){return r?r.concat(i).map(function(c,d){return c=Yp(c),!s&&d?"["+c+"]":c}).join(s?".":""):i}function C0(r){return N.isArray(r)&&!r.some(qa)}const k0=N.toFlatObject(N,{},null,function(i){return/^is[A-Z]/.test(i)});function Es(r,i,s){if(!N.isObject(r))throw new TypeError("target must be an object");i=i||new FormData,s=N.toFlatObject(s,{metaTokens:!0,dots:!1,indexes:!1},!1,function(O,_){return!N.isUndefined(_[O])});const l=s.metaTokens,c=s.visitor||S,d=s.dots,p=s.indexes,w=(s.Blob||typeof Blob<"u"&&Blob)&&N.isSpecCompliantForm(i);if(!N.isFunction(c))throw new TypeError("visitor must be a function");function v(T){if(T===null)return"";if(N.isDate(T))return T.toISOString();if(!w&&N.isBlob(T))throw new ae("Blob is not supported. Use a Buffer instead.");return N.isArrayBuffer(T)||N.isTypedArray(T)?w&&typeof Blob=="function"?new Blob([T]):Buffer.from(T):T}function S(T,O,_){let b=T;if(T&&!_&&typeof T=="object"){if(N.endsWith(O,"{}"))O=l?O:O.slice(0,-2),T=JSON.stringify(T);else if(N.isArray(T)&&C0(T)||(N.isFileList(T)||N.endsWith(O,"[]"))&&(b=N.toArray(T)))return O=Yp(O),b.forEach(function(B,W){!(N.isUndefined(B)||B===null)&&i.append(p===!0?Df([O],W,d):p===null?O:O+"[]",v(B))}),!1}return qa(T)?!0:(i.append(Df(_,O,d),v(T)),!1)}const j=[],R=Object.assign(k0,{defaultVisitor:S,convertValue:v,isVisitable:qa});function L(T,O){if(!N.isUndefined(T)){if(j.indexOf(T)!==-1)throw Error("Circular reference detected in "+O.join("."));j.push(T),N.forEach(T,function(b,U){(!(N.isUndefined(b)||b===null)&&c.call(i,b,N.isString(U)?U.trim():U,O,R))===!0&&L(b,O?O.concat(U):[U])}),j.pop()}}if(!N.isObject(r))throw new TypeError("data must be an object");return L(r),i}function zf(r){const i={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(r).replace(/[!'()~]|%20|%00/g,function(l){return i[l]})}function au(r,i){this._pairs=[],r&&Es(r,this,i)}const qp=au.prototype;qp.append=function(i,s){this._pairs.push([i,s])};qp.toString=function(i){const s=i?function(l){return i.call(this,l,zf)}:zf;return this._pairs.map(function(c){return s(c[0])+"="+s(c[1])},"").join("&")};function E0(r){return encodeURIComponent(r).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}function Qp(r,i,s){if(!i)return r;const l=s&&s.encode||E0;N.isFunction(s)&&(s={serialize:s});const c=s&&s.serialize;let d;if(c?d=c(i,s):d=N.isURLSearchParams(i)?i.toString():new au(i,s).toString(l),d){const p=r.indexOf("#");p!==-1&&(r=r.slice(0,p)),r+=(r.indexOf("?")===-1?"?":"&")+d}return r}class $f{constructor(){this.handlers=[]}use(i,s,l){return this.handlers.push({fulfilled:i,rejected:s,synchronous:l?l.synchronous:!1,runWhen:l?l.runWhen:null}),this.handlers.length-1}eject(i){this.handlers[i]&&(this.handlers[i]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(i){N.forEach(this.handlers,function(l){l!==null&&i(l)})}}const Gp={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},j0=typeof URLSearchParams<"u"?URLSearchParams:au,A0=typeof FormData<"u"?FormData:null,R0=typeof Blob<"u"?Blob:null,P0={isBrowser:!0,classes:{URLSearchParams:j0,FormData:A0,Blob:R0},protocols:["http","https","file","blob","url","data"]},uu=typeof window<"u"&&typeof document<"u",Qa=typeof navigator=="object"&&navigator||void 0,T0=uu&&(!Qa||["ReactNative","NativeScript","NS"].indexOf(Qa.product)<0),_0=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",N0=uu&&window.location.href||"http://localhost",O0=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:uu,hasStandardBrowserEnv:T0,hasStandardBrowserWebWorkerEnv:_0,navigator:Qa,origin:N0},Symbol.toStringTag,{value:"Module"})),nt={...O0,...P0};function M0(r,i){return Es(r,new nt.classes.URLSearchParams,Object.assign({visitor:function(s,l,c,d){return nt.isNode&&N.isBuffer(s)?(this.append(l,s.toString("base64")),!1):d.defaultVisitor.apply(this,arguments)}},i))}function L0(r){return N.matchAll(/\w+|\[(\w*)]/g,r).map(i=>i[0]==="[]"?"":i[1]||i[0])}function I0(r){const i={},s=Object.keys(r);let l;const c=s.length;let d;for(l=0;l=s.length;return p=!p&&N.isArray(c)?c.length:p,w?(N.hasOwnProp(c,p)?c[p]=[c[p],l]:c[p]=l,!g):((!c[p]||!N.isObject(c[p]))&&(c[p]=[]),i(s,l,c[p],d)&&N.isArray(c[p])&&(c[p]=I0(c[p])),!g)}if(N.isFormData(r)&&N.isFunction(r.entries)){const s={};return N.forEachEntry(r,(l,c)=>{i(L0(l),c,s,0)}),s}return null}function D0(r,i,s){if(N.isString(r))try{return(i||JSON.parse)(r),N.trim(r)}catch(l){if(l.name!=="SyntaxError")throw l}return(0,JSON.stringify)(r)}const Io={transitional:Gp,adapter:["xhr","http","fetch"],transformRequest:[function(i,s){const l=s.getContentType()||"",c=l.indexOf("application/json")>-1,d=N.isObject(i);if(d&&N.isHTMLForm(i)&&(i=new FormData(i)),N.isFormData(i))return c?JSON.stringify(Kp(i)):i;if(N.isArrayBuffer(i)||N.isBuffer(i)||N.isStream(i)||N.isFile(i)||N.isBlob(i)||N.isReadableStream(i))return i;if(N.isArrayBufferView(i))return i.buffer;if(N.isURLSearchParams(i))return s.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),i.toString();let g;if(d){if(l.indexOf("application/x-www-form-urlencoded")>-1)return M0(i,this.formSerializer).toString();if((g=N.isFileList(i))||l.indexOf("multipart/form-data")>-1){const w=this.env&&this.env.FormData;return Es(g?{"files[]":i}:i,w&&new w,this.formSerializer)}}return d||c?(s.setContentType("application/json",!1),D0(i)):i}],transformResponse:[function(i){const s=this.transitional||Io.transitional,l=s&&s.forcedJSONParsing,c=this.responseType==="json";if(N.isResponse(i)||N.isReadableStream(i))return i;if(i&&N.isString(i)&&(l&&!this.responseType||c)){const p=!(s&&s.silentJSONParsing)&&c;try{return JSON.parse(i)}catch(g){if(p)throw g.name==="SyntaxError"?ae.from(g,ae.ERR_BAD_RESPONSE,this,null,this.response):g}}return i}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:nt.classes.FormData,Blob:nt.classes.Blob},validateStatus:function(i){return i>=200&&i<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};N.forEach(["delete","get","head","post","put","patch"],r=>{Io.headers[r]={}});const z0=N.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),$0=r=>{const i={};let s,l,c;return r&&r.split(` +`).forEach(function(p){c=p.indexOf(":"),s=p.substring(0,c).trim().toLowerCase(),l=p.substring(c+1).trim(),!(!s||i[s]&&z0[s])&&(s==="set-cookie"?i[s]?i[s].push(l):i[s]=[l]:i[s]=i[s]?i[s]+", "+l:l)}),i},Ff=Symbol("internals");function xo(r){return r&&String(r).trim().toLowerCase()}function ss(r){return r===!1||r==null?r:N.isArray(r)?r.map(ss):String(r)}function F0(r){const i=Object.create(null),s=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let l;for(;l=s.exec(r);)i[l[1]]=l[2];return i}const B0=r=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(r.trim());function _a(r,i,s,l,c){if(N.isFunction(l))return l.call(this,i,s);if(c&&(i=s),!!N.isString(i)){if(N.isString(l))return i.indexOf(l)!==-1;if(N.isRegExp(l))return l.test(i)}}function b0(r){return r.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(i,s,l)=>s.toUpperCase()+l)}function U0(r,i){const s=N.toCamelCase(" "+i);["get","set","has"].forEach(l=>{Object.defineProperty(r,l+s,{value:function(c,d,p){return this[l].call(this,i,c,d,p)},configurable:!0})})}class ft{constructor(i){i&&this.set(i)}set(i,s,l){const c=this;function d(g,w,v){const S=xo(w);if(!S)throw new Error("header name must be a non-empty string");const j=N.findKey(c,S);(!j||c[j]===void 0||v===!0||v===void 0&&c[j]!==!1)&&(c[j||w]=ss(g))}const p=(g,w)=>N.forEach(g,(v,S)=>d(v,S,w));if(N.isPlainObject(i)||i instanceof this.constructor)p(i,s);else if(N.isString(i)&&(i=i.trim())&&!B0(i))p($0(i),s);else if(N.isHeaders(i))for(const[g,w]of i.entries())d(w,g,l);else i!=null&&d(s,i,l);return this}get(i,s){if(i=xo(i),i){const l=N.findKey(this,i);if(l){const c=this[l];if(!s)return c;if(s===!0)return F0(c);if(N.isFunction(s))return s.call(this,c,l);if(N.isRegExp(s))return s.exec(c);throw new TypeError("parser must be boolean|regexp|function")}}}has(i,s){if(i=xo(i),i){const l=N.findKey(this,i);return!!(l&&this[l]!==void 0&&(!s||_a(this,this[l],l,s)))}return!1}delete(i,s){const l=this;let c=!1;function d(p){if(p=xo(p),p){const g=N.findKey(l,p);g&&(!s||_a(l,l[g],g,s))&&(delete l[g],c=!0)}}return N.isArray(i)?i.forEach(d):d(i),c}clear(i){const s=Object.keys(this);let l=s.length,c=!1;for(;l--;){const d=s[l];(!i||_a(this,this[d],d,i,!0))&&(delete this[d],c=!0)}return c}normalize(i){const s=this,l={};return N.forEach(this,(c,d)=>{const p=N.findKey(l,d);if(p){s[p]=ss(c),delete s[d];return}const g=i?b0(d):String(d).trim();g!==d&&delete s[d],s[g]=ss(c),l[g]=!0}),this}concat(...i){return this.constructor.concat(this,...i)}toJSON(i){const s=Object.create(null);return N.forEach(this,(l,c)=>{l!=null&&l!==!1&&(s[c]=i&&N.isArray(l)?l.join(", "):l)}),s}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([i,s])=>i+": "+s).join(` +`)}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(i){return i instanceof this?i:new this(i)}static concat(i,...s){const l=new this(i);return s.forEach(c=>l.set(c)),l}static accessor(i){const l=(this[Ff]=this[Ff]={accessors:{}}).accessors,c=this.prototype;function d(p){const g=xo(p);l[g]||(U0(c,p),l[g]=!0)}return N.isArray(i)?i.forEach(d):d(i),this}}ft.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);N.reduceDescriptors(ft.prototype,({value:r},i)=>{let s=i[0].toUpperCase()+i.slice(1);return{get:()=>r,set(l){this[s]=l}}});N.freezeMethods(ft);function Na(r,i){const s=this||Io,l=i||s,c=ft.from(l.headers);let d=l.data;return N.forEach(r,function(g){d=g.call(s,d,c.normalize(),i?i.status:void 0)}),c.normalize(),d}function Xp(r){return!!(r&&r.__CANCEL__)}function _r(r,i,s){ae.call(this,r??"canceled",ae.ERR_CANCELED,i,s),this.name="CanceledError"}N.inherits(_r,ae,{__CANCEL__:!0});function Jp(r,i,s){const l=s.config.validateStatus;!s.status||!l||l(s.status)?r(s):i(new ae("Request failed with status code "+s.status,[ae.ERR_BAD_REQUEST,ae.ERR_BAD_RESPONSE][Math.floor(s.status/100)-4],s.config,s.request,s))}function H0(r){const i=/^([-+\w]{1,25})(:?\/\/|:)/.exec(r);return i&&i[1]||""}function V0(r,i){r=r||10;const s=new Array(r),l=new Array(r);let c=0,d=0,p;return i=i!==void 0?i:1e3,function(w){const v=Date.now(),S=l[d];p||(p=v),s[c]=w,l[c]=v;let j=d,R=0;for(;j!==c;)R+=s[j++],j=j%r;if(c=(c+1)%r,c===d&&(d=(d+1)%r),v-p{s=S,c=null,d&&(clearTimeout(d),d=null),r.apply(null,v)};return[(...v)=>{const S=Date.now(),j=S-s;j>=l?p(v,S):(c=v,d||(d=setTimeout(()=>{d=null,p(c)},l-j)))},()=>c&&p(c)]}const ps=(r,i,s=3)=>{let l=0;const c=V0(50,250);return W0(d=>{const p=d.loaded,g=d.lengthComputable?d.total:void 0,w=p-l,v=c(w),S=p<=g;l=p;const j={loaded:p,total:g,progress:g?p/g:void 0,bytes:w,rate:v||void 0,estimated:v&&g&&S?(g-p)/v:void 0,event:d,lengthComputable:g!=null,[i?"download":"upload"]:!0};r(j)},s)},Bf=(r,i)=>{const s=r!=null;return[l=>i[0]({lengthComputable:s,total:r,loaded:l}),i[1]]},bf=r=>(...i)=>N.asap(()=>r(...i)),Y0=nt.hasStandardBrowserEnv?((r,i)=>s=>(s=new URL(s,nt.origin),r.protocol===s.protocol&&r.host===s.host&&(i||r.port===s.port)))(new URL(nt.origin),nt.navigator&&/(msie|trident)/i.test(nt.navigator.userAgent)):()=>!0,q0=nt.hasStandardBrowserEnv?{write(r,i,s,l,c,d){const p=[r+"="+encodeURIComponent(i)];N.isNumber(s)&&p.push("expires="+new Date(s).toGMTString()),N.isString(l)&&p.push("path="+l),N.isString(c)&&p.push("domain="+c),d===!0&&p.push("secure"),document.cookie=p.join("; ")},read(r){const i=document.cookie.match(new RegExp("(^|;\\s*)("+r+")=([^;]*)"));return i?decodeURIComponent(i[3]):null},remove(r){this.write(r,"",Date.now()-864e5)}}:{write(){},read(){return null},remove(){}};function Q0(r){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(r)}function G0(r,i){return i?r.replace(/\/?\/$/,"")+"/"+i.replace(/^\/+/,""):r}function Zp(r,i){return r&&!Q0(i)?G0(r,i):i}const Uf=r=>r instanceof ft?{...r}:r;function qn(r,i){i=i||{};const s={};function l(v,S,j,R){return N.isPlainObject(v)&&N.isPlainObject(S)?N.merge.call({caseless:R},v,S):N.isPlainObject(S)?N.merge({},S):N.isArray(S)?S.slice():S}function c(v,S,j,R){if(N.isUndefined(S)){if(!N.isUndefined(v))return l(void 0,v,j,R)}else return l(v,S,j,R)}function d(v,S){if(!N.isUndefined(S))return l(void 0,S)}function p(v,S){if(N.isUndefined(S)){if(!N.isUndefined(v))return l(void 0,v)}else return l(void 0,S)}function g(v,S,j){if(j in i)return l(v,S);if(j in r)return l(void 0,v)}const w={url:d,method:d,data:d,baseURL:p,transformRequest:p,transformResponse:p,paramsSerializer:p,timeout:p,timeoutMessage:p,withCredentials:p,withXSRFToken:p,adapter:p,responseType:p,xsrfCookieName:p,xsrfHeaderName:p,onUploadProgress:p,onDownloadProgress:p,decompress:p,maxContentLength:p,maxBodyLength:p,beforeRedirect:p,transport:p,httpAgent:p,httpsAgent:p,cancelToken:p,socketPath:p,responseEncoding:p,validateStatus:g,headers:(v,S,j)=>c(Uf(v),Uf(S),j,!0)};return N.forEach(Object.keys(Object.assign({},r,i)),function(S){const j=w[S]||c,R=j(r[S],i[S],S);N.isUndefined(R)&&j!==g||(s[S]=R)}),s}const eh=r=>{const i=qn({},r);let{data:s,withXSRFToken:l,xsrfHeaderName:c,xsrfCookieName:d,headers:p,auth:g}=i;i.headers=p=ft.from(p),i.url=Qp(Zp(i.baseURL,i.url),r.params,r.paramsSerializer),g&&p.set("Authorization","Basic "+btoa((g.username||"")+":"+(g.password?unescape(encodeURIComponent(g.password)):"")));let w;if(N.isFormData(s)){if(nt.hasStandardBrowserEnv||nt.hasStandardBrowserWebWorkerEnv)p.setContentType(void 0);else if((w=p.getContentType())!==!1){const[v,...S]=w?w.split(";").map(j=>j.trim()).filter(Boolean):[];p.setContentType([v||"multipart/form-data",...S].join("; "))}}if(nt.hasStandardBrowserEnv&&(l&&N.isFunction(l)&&(l=l(i)),l||l!==!1&&Y0(i.url))){const v=c&&d&&q0.read(d);v&&p.set(c,v)}return i},K0=typeof XMLHttpRequest<"u",X0=K0&&function(r){return new Promise(function(s,l){const c=eh(r);let d=c.data;const p=ft.from(c.headers).normalize();let{responseType:g,onUploadProgress:w,onDownloadProgress:v}=c,S,j,R,L,T;function O(){L&&L(),T&&T(),c.cancelToken&&c.cancelToken.unsubscribe(S),c.signal&&c.signal.removeEventListener("abort",S)}let _=new XMLHttpRequest;_.open(c.method.toUpperCase(),c.url,!0),_.timeout=c.timeout;function b(){if(!_)return;const B=ft.from("getAllResponseHeaders"in _&&_.getAllResponseHeaders()),I={data:!g||g==="text"||g==="json"?_.responseText:_.response,status:_.status,statusText:_.statusText,headers:B,config:r,request:_};Jp(function(V){s(V),O()},function(V){l(V),O()},I),_=null}"onloadend"in _?_.onloadend=b:_.onreadystatechange=function(){!_||_.readyState!==4||_.status===0&&!(_.responseURL&&_.responseURL.indexOf("file:")===0)||setTimeout(b)},_.onabort=function(){_&&(l(new ae("Request aborted",ae.ECONNABORTED,r,_)),_=null)},_.onerror=function(){l(new ae("Network Error",ae.ERR_NETWORK,r,_)),_=null},_.ontimeout=function(){let W=c.timeout?"timeout of "+c.timeout+"ms exceeded":"timeout exceeded";const I=c.transitional||Gp;c.timeoutErrorMessage&&(W=c.timeoutErrorMessage),l(new ae(W,I.clarifyTimeoutError?ae.ETIMEDOUT:ae.ECONNABORTED,r,_)),_=null},d===void 0&&p.setContentType(null),"setRequestHeader"in _&&N.forEach(p.toJSON(),function(W,I){_.setRequestHeader(I,W)}),N.isUndefined(c.withCredentials)||(_.withCredentials=!!c.withCredentials),g&&g!=="json"&&(_.responseType=c.responseType),v&&([R,T]=ps(v,!0),_.addEventListener("progress",R)),w&&_.upload&&([j,L]=ps(w),_.upload.addEventListener("progress",j),_.upload.addEventListener("loadend",L)),(c.cancelToken||c.signal)&&(S=B=>{_&&(l(!B||B.type?new _r(null,r,_):B),_.abort(),_=null)},c.cancelToken&&c.cancelToken.subscribe(S),c.signal&&(c.signal.aborted?S():c.signal.addEventListener("abort",S)));const U=H0(c.url);if(U&&nt.protocols.indexOf(U)===-1){l(new ae("Unsupported protocol "+U+":",ae.ERR_BAD_REQUEST,r));return}_.send(d||null)})},J0=(r,i)=>{const{length:s}=r=r?r.filter(Boolean):[];if(i||s){let l=new AbortController,c;const d=function(v){if(!c){c=!0,g();const S=v instanceof Error?v:this.reason;l.abort(S instanceof ae?S:new _r(S instanceof Error?S.message:S))}};let p=i&&setTimeout(()=>{p=null,d(new ae(`timeout ${i} of ms exceeded`,ae.ETIMEDOUT))},i);const g=()=>{r&&(p&&clearTimeout(p),p=null,r.forEach(v=>{v.unsubscribe?v.unsubscribe(d):v.removeEventListener("abort",d)}),r=null)};r.forEach(v=>v.addEventListener("abort",d));const{signal:w}=l;return w.unsubscribe=()=>N.asap(g),w}},Z0=function*(r,i){let s=r.byteLength;if(s{const c=ev(r,i);let d=0,p,g=w=>{p||(p=!0,l&&l(w))};return new ReadableStream({async pull(w){try{const{done:v,value:S}=await c.next();if(v){g(),w.close();return}let j=S.byteLength;if(s){let R=d+=j;s(R)}w.enqueue(new Uint8Array(S))}catch(v){throw g(v),v}},cancel(w){return g(w),c.return()}},{highWaterMark:2})},js=typeof fetch=="function"&&typeof Request=="function"&&typeof Response=="function",th=js&&typeof ReadableStream=="function",nv=js&&(typeof TextEncoder=="function"?(r=>i=>r.encode(i))(new TextEncoder):async r=>new Uint8Array(await new Response(r).arrayBuffer())),nh=(r,...i)=>{try{return!!r(...i)}catch{return!1}},rv=th&&nh(()=>{let r=!1;const i=new Request(nt.origin,{body:new ReadableStream,method:"POST",get duplex(){return r=!0,"half"}}).headers.has("Content-Type");return r&&!i}),Vf=64*1024,Ga=th&&nh(()=>N.isReadableStream(new Response("").body)),hs={stream:Ga&&(r=>r.body)};js&&(r=>{["text","arrayBuffer","blob","formData","stream"].forEach(i=>{!hs[i]&&(hs[i]=N.isFunction(r[i])?s=>s[i]():(s,l)=>{throw new ae(`Response type '${i}' is not supported`,ae.ERR_NOT_SUPPORT,l)})})})(new Response);const ov=async r=>{if(r==null)return 0;if(N.isBlob(r))return r.size;if(N.isSpecCompliantForm(r))return(await new Request(nt.origin,{method:"POST",body:r}).arrayBuffer()).byteLength;if(N.isArrayBufferView(r)||N.isArrayBuffer(r))return r.byteLength;if(N.isURLSearchParams(r)&&(r=r+""),N.isString(r))return(await nv(r)).byteLength},iv=async(r,i)=>{const s=N.toFiniteNumber(r.getContentLength());return s??ov(i)},sv=js&&(async r=>{let{url:i,method:s,data:l,signal:c,cancelToken:d,timeout:p,onDownloadProgress:g,onUploadProgress:w,responseType:v,headers:S,withCredentials:j="same-origin",fetchOptions:R}=eh(r);v=v?(v+"").toLowerCase():"text";let L=J0([c,d&&d.toAbortSignal()],p),T;const O=L&&L.unsubscribe&&(()=>{L.unsubscribe()});let _;try{if(w&&rv&&s!=="get"&&s!=="head"&&(_=await iv(S,l))!==0){let I=new Request(i,{method:"POST",body:l,duplex:"half"}),M;if(N.isFormData(l)&&(M=I.headers.get("content-type"))&&S.setContentType(M),I.body){const[V,ne]=Bf(_,ps(bf(w)));l=Hf(I.body,Vf,V,ne)}}N.isString(j)||(j=j?"include":"omit");const b="credentials"in Request.prototype;T=new Request(i,{...R,signal:L,method:s.toUpperCase(),headers:S.normalize().toJSON(),body:l,duplex:"half",credentials:b?j:void 0});let U=await fetch(T);const B=Ga&&(v==="stream"||v==="response");if(Ga&&(g||B&&O)){const I={};["status","statusText","headers"].forEach(ye=>{I[ye]=U[ye]});const M=N.toFiniteNumber(U.headers.get("content-length")),[V,ne]=g&&Bf(M,ps(bf(g),!0))||[];U=new Response(Hf(U.body,Vf,V,()=>{ne&&ne(),O&&O()}),I)}v=v||"text";let W=await hs[N.findKey(hs,v)||"text"](U,r);return!B&&O&&O(),await new Promise((I,M)=>{Jp(I,M,{data:W,headers:ft.from(U.headers),status:U.status,statusText:U.statusText,config:r,request:T})})}catch(b){throw O&&O(),b&&b.name==="TypeError"&&/fetch/i.test(b.message)?Object.assign(new ae("Network Error",ae.ERR_NETWORK,r,T),{cause:b.cause||b}):ae.from(b,b&&b.code,r,T)}}),Ka={http:S0,xhr:X0,fetch:sv};N.forEach(Ka,(r,i)=>{if(r){try{Object.defineProperty(r,"name",{value:i})}catch{}Object.defineProperty(r,"adapterName",{value:i})}});const Wf=r=>`- ${r}`,lv=r=>N.isFunction(r)||r===null||r===!1,rh={getAdapter:r=>{r=N.isArray(r)?r:[r];const{length:i}=r;let s,l;const c={};for(let d=0;d`adapter ${g} `+(w===!1?"is not supported by the environment":"is not available in the build"));let p=i?d.length>1?`since : +`+d.map(Wf).join(` +`):" "+Wf(d[0]):"as no adapter specified";throw new ae("There is no suitable adapter to dispatch the request "+p,"ERR_NOT_SUPPORT")}return l},adapters:Ka};function Oa(r){if(r.cancelToken&&r.cancelToken.throwIfRequested(),r.signal&&r.signal.aborted)throw new _r(null,r)}function Yf(r){return Oa(r),r.headers=ft.from(r.headers),r.data=Na.call(r,r.transformRequest),["post","put","patch"].indexOf(r.method)!==-1&&r.headers.setContentType("application/x-www-form-urlencoded",!1),rh.getAdapter(r.adapter||Io.adapter)(r).then(function(l){return Oa(r),l.data=Na.call(r,r.transformResponse,l),l.headers=ft.from(l.headers),l},function(l){return Xp(l)||(Oa(r),l&&l.response&&(l.response.data=Na.call(r,r.transformResponse,l.response),l.response.headers=ft.from(l.response.headers))),Promise.reject(l)})}const oh="1.7.9",As={};["object","boolean","number","function","string","symbol"].forEach((r,i)=>{As[r]=function(l){return typeof l===r||"a"+(i<1?"n ":" ")+r}});const qf={};As.transitional=function(i,s,l){function c(d,p){return"[Axios v"+oh+"] Transitional option '"+d+"'"+p+(l?". "+l:"")}return(d,p,g)=>{if(i===!1)throw new ae(c(p," has been removed"+(s?" in "+s:"")),ae.ERR_DEPRECATED);return s&&!qf[p]&&(qf[p]=!0,console.warn(c(p," has been deprecated since v"+s+" and will be removed in the near future"))),i?i(d,p,g):!0}};As.spelling=function(i){return(s,l)=>(console.warn(`${l} is likely a misspelling of ${i}`),!0)};function av(r,i,s){if(typeof r!="object")throw new ae("options must be an object",ae.ERR_BAD_OPTION_VALUE);const l=Object.keys(r);let c=l.length;for(;c-- >0;){const d=l[c],p=i[d];if(p){const g=r[d],w=g===void 0||p(g,d,r);if(w!==!0)throw new ae("option "+d+" must be "+w,ae.ERR_BAD_OPTION_VALUE);continue}if(s!==!0)throw new ae("Unknown option "+d,ae.ERR_BAD_OPTION)}}const ls={assertOptions:av,validators:As},Vt=ls.validators;class Vn{constructor(i){this.defaults=i,this.interceptors={request:new $f,response:new $f}}async request(i,s){try{return await this._request(i,s)}catch(l){if(l instanceof Error){let c={};Error.captureStackTrace?Error.captureStackTrace(c):c=new Error;const d=c.stack?c.stack.replace(/^.+\n/,""):"";try{l.stack?d&&!String(l.stack).endsWith(d.replace(/^.+\n.+\n/,""))&&(l.stack+=` +`+d):l.stack=d}catch{}}throw l}}_request(i,s){typeof i=="string"?(s=s||{},s.url=i):s=i||{},s=qn(this.defaults,s);const{transitional:l,paramsSerializer:c,headers:d}=s;l!==void 0&&ls.assertOptions(l,{silentJSONParsing:Vt.transitional(Vt.boolean),forcedJSONParsing:Vt.transitional(Vt.boolean),clarifyTimeoutError:Vt.transitional(Vt.boolean)},!1),c!=null&&(N.isFunction(c)?s.paramsSerializer={serialize:c}:ls.assertOptions(c,{encode:Vt.function,serialize:Vt.function},!0)),ls.assertOptions(s,{baseUrl:Vt.spelling("baseURL"),withXsrfToken:Vt.spelling("withXSRFToken")},!0),s.method=(s.method||this.defaults.method||"get").toLowerCase();let p=d&&N.merge(d.common,d[s.method]);d&&N.forEach(["delete","get","head","post","put","patch","common"],T=>{delete d[T]}),s.headers=ft.concat(p,d);const g=[];let w=!0;this.interceptors.request.forEach(function(O){typeof O.runWhen=="function"&&O.runWhen(s)===!1||(w=w&&O.synchronous,g.unshift(O.fulfilled,O.rejected))});const v=[];this.interceptors.response.forEach(function(O){v.push(O.fulfilled,O.rejected)});let S,j=0,R;if(!w){const T=[Yf.bind(this),void 0];for(T.unshift.apply(T,g),T.push.apply(T,v),R=T.length,S=Promise.resolve(s);j{if(!l._listeners)return;let d=l._listeners.length;for(;d-- >0;)l._listeners[d](c);l._listeners=null}),this.promise.then=c=>{let d;const p=new Promise(g=>{l.subscribe(g),d=g}).then(c);return p.cancel=function(){l.unsubscribe(d)},p},i(function(d,p,g){l.reason||(l.reason=new _r(d,p,g),s(l.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(i){if(this.reason){i(this.reason);return}this._listeners?this._listeners.push(i):this._listeners=[i]}unsubscribe(i){if(!this._listeners)return;const s=this._listeners.indexOf(i);s!==-1&&this._listeners.splice(s,1)}toAbortSignal(){const i=new AbortController,s=l=>{i.abort(l)};return this.subscribe(s),i.signal.unsubscribe=()=>this.unsubscribe(s),i.signal}static source(){let i;return{token:new cu(function(c){i=c}),cancel:i}}}function uv(r){return function(s){return r.apply(null,s)}}function cv(r){return N.isObject(r)&&r.isAxiosError===!0}const Xa={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511};Object.entries(Xa).forEach(([r,i])=>{Xa[i]=r});function ih(r){const i=new Vn(r),s=Dp(Vn.prototype.request,i);return N.extend(s,Vn.prototype,i,{allOwnKeys:!0}),N.extend(s,i,null,{allOwnKeys:!0}),s.create=function(c){return ih(qn(r,c))},s}const be=ih(Io);be.Axios=Vn;be.CanceledError=_r;be.CancelToken=cu;be.isCancel=Xp;be.VERSION=oh;be.toFormData=Es;be.AxiosError=ae;be.Cancel=be.CanceledError;be.all=function(i){return Promise.all(i)};be.spread=uv;be.isAxiosError=cv;be.mergeConfig=qn;be.AxiosHeaders=ft;be.formToJSON=r=>Kp(N.isHTMLForm(r)?new FormData(r):r);be.getAdapter=rh.getAdapter;be.HttpStatusCode=Xa;be.default=be;const dv={apiBaseUrl:"/api"};class fv{constructor(){sf(this,"events",{})}on(i,s){return this.events[i]||(this.events[i]=[]),this.events[i].push(s),()=>this.off(i,s)}off(i,s){this.events[i]&&(this.events[i]=this.events[i].filter(l=>l!==s))}emit(i,s){this.events[i]&&this.events[i].forEach(l=>{l(s)})}}const Oo=new fv,Oe=be.create({baseURL:dv.apiBaseUrl,headers:{"Content-Type":"application/json"},withCredentials:!0});Oe.interceptors.request.use(r=>r,r=>Promise.reject(r));Oe.interceptors.response.use(r=>r,r=>{var s,l,c,d;const i=(s=r.response)==null?void 0:s.data;if(i){const p=(c=(l=r.response)==null?void 0:l.headers)==null?void 0:c["discodeit-request-id"];p&&(i.requestId=p),r.response.data=i}return console.log({error:r,errorResponse:i}),Oo.emit("api-error",{error:r,alert:((d=r.response)==null?void 0:d.status)===403}),r.response&&r.response.status===401&&Oo.emit("auth-error"),Promise.reject(r)});const pv=()=>Oe.defaults.baseURL,hv=async(r,i)=>(await Oe.patch(`/users/${r}`,i,{headers:{"Content-Type":"multipart/form-data"}})).data,mv=async()=>(await Oe.get("/users")).data,Ar=Pr(r=>({users:[],fetchUsers:async()=>{try{const i=await mv();r({users:i})}catch(i){console.error("사용자 목록 조회 실패:",i)}}})),gv=async(r,i,s)=>{const l=new FormData;return l.append("username",r),l.append("password",i),(await Oe.post("/auth/login",l,{params:{"remember-me":s?"true":"false"},headers:{"Content-Type":"multipart/form-data"}})).data},yv=async r=>(await Oe.post("/users",r,{headers:{"Content-Type":"multipart/form-data"}})).data,vv=async()=>{await Oe.get("/auth/csrf-token")},xv=async()=>(await Oe.get("/auth/me")).data,wv=async()=>{await Oe.post("/auth/logout")},Sv=async(r,i)=>{const s={userId:r,newRole:i};return(await Oe.put("/auth/role",s)).data},pt=Pr((r,i)=>({currentUser:null,login:async(s,l,c=!1)=>{const d=await gv(s,l,c);await i().fetchCsrfToken(),r({currentUser:d})},logout:async()=>{await wv(),i().clear(),i().fetchCsrfToken()},fetchCsrfToken:async()=>{await vv()},fetchMe:async()=>{const s=await xv();r({currentUser:s})},clear:()=>{r({currentUser:null})},updateUserRole:async(s,l)=>{await Sv(s,l)}})),q={colors:{brand:{primary:"#5865F2",hover:"#4752C4"},background:{primary:"#1a1a1a",secondary:"#2a2a2a",tertiary:"#333333",input:"#40444B",hover:"rgba(255, 255, 255, 0.1)"},text:{primary:"#ffffff",secondary:"#cccccc",muted:"#999999"},status:{online:"#43b581",idle:"#faa61a",dnd:"#f04747",offline:"#747f8d",error:"#ED4245"},border:{primary:"#404040"}}},sh=k.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,lh=k.div` + background: ${q.colors.background.primary}; + padding: 32px; + border-radius: 8px; + width: 440px; + + h2 { + color: ${q.colors.text.primary}; + margin-bottom: 24px; + font-size: 24px; + font-weight: bold; + } + + form { + display: flex; + flex-direction: column; + gap: 16px; + } +`,jo=k.input` + width: 100%; + padding: 10px; + border-radius: 4px; + background: ${q.colors.background.input}; + border: none; + color: ${q.colors.text.primary}; + font-size: 16px; + + &::placeholder { + color: ${q.colors.text.muted}; + } + + &:focus { + outline: none; + } +`,Cv=k.input.attrs({type:"checkbox"})` + width: 16px; + height: 16px; + padding: 0; + border-radius: 4px; + background: ${q.colors.background.input}; + border: none; + color: ${q.colors.text.primary}; + cursor: pointer; + + &:focus { + outline: none; + } + + &:checked { + background: ${q.colors.brand.primary}; + } +`,ah=k.button` + width: 100%; + padding: 12px; + border-radius: 4px; + background: ${q.colors.brand.primary}; + color: white; + font-size: 16px; + font-weight: 500; + border: none; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: ${q.colors.brand.hover}; + } +`,uh=k.div` + color: ${q.colors.status.error}; + font-size: 14px; + text-align: center; +`,kv=k.p` + text-align: center; + margin-top: 16px; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 14px; +`,Ev=k.span` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`,Gi=k.div` + margin-bottom: 20px; +`,Ki=k.label` + display: block; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +`,Ma=k.span` + color: ${({theme:r})=>r.colors.status.error}; +`,jv=k.div` + display: flex; + flex-direction: column; + align-items: center; + margin: 10px 0; +`,Av=k.img` + width: 80px; + height: 80px; + border-radius: 50%; + margin-bottom: 10px; + object-fit: cover; +`,Rv=k.input` + display: none; +`,Pv=k.label` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +`,Tv=k.span` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`,_v=k(Tv)` + display: block; + text-align: center; + margin-top: 16px; +`,St="data:image/png;base64,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",Nv=({isOpen:r,onClose:i})=>{const[s,l]=K.useState(""),[c,d]=K.useState(""),[p,g]=K.useState(""),[w,v]=K.useState(null),[S,j]=K.useState(null),[R,L]=K.useState(""),{fetchCsrfToken:T}=pt(),O=K.useCallback(()=>{S&&URL.revokeObjectURL(S),j(null),v(null),l(""),d(""),g(""),L("")},[S]),_=K.useCallback(()=>{O(),i()},[]),b=B=>{var I;const W=(I=B.target.files)==null?void 0:I[0];if(W){v(W);const M=new FileReader;M.onloadend=()=>{j(M.result)},M.readAsDataURL(W)}},U=async B=>{B.preventDefault(),L("");try{const W=new FormData;W.append("userCreateRequest",new Blob([JSON.stringify({email:s,username:c,password:p})],{type:"application/json"})),w&&W.append("profile",w),await yv(W),await T(),i()}catch{L("회원가입에 실패했습니다.")}};return r?h.jsx(sh,{children:h.jsxs(lh,{children:[h.jsx("h2",{children:"계정 만들기"}),h.jsxs("form",{onSubmit:U,children:[h.jsxs(Gi,{children:[h.jsxs(Ki,{children:["이메일 ",h.jsx(Ma,{children:"*"})]}),h.jsx(jo,{type:"email",value:s,onChange:B=>l(B.target.value),required:!0})]}),h.jsxs(Gi,{children:[h.jsxs(Ki,{children:["사용자명 ",h.jsx(Ma,{children:"*"})]}),h.jsx(jo,{type:"text",value:c,onChange:B=>d(B.target.value),required:!0})]}),h.jsxs(Gi,{children:[h.jsxs(Ki,{children:["비밀번호 ",h.jsx(Ma,{children:"*"})]}),h.jsx(jo,{type:"password",value:p,onChange:B=>g(B.target.value),required:!0})]}),h.jsxs(Gi,{children:[h.jsx(Ki,{children:"프로필 이미지"}),h.jsxs(jv,{children:[h.jsx(Av,{src:S||St,alt:"profile"}),h.jsx(Rv,{type:"file",accept:"image/*",onChange:b,id:"profile-image"}),h.jsx(Pv,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),R&&h.jsx(uh,{children:R}),h.jsx(ah,{type:"submit",children:"계속하기"}),h.jsx(_v,{onClick:_,children:"이미 계정이 있으신가요?"})]})]})}):null},Ov=({isOpen:r,onClose:i})=>{const[s,l]=K.useState(""),[c,d]=K.useState(""),[p,g]=K.useState(""),[w,v]=K.useState(!1),[S,j]=K.useState(!1),{login:R}=pt(),{fetchUsers:L}=Ar(),T=K.useCallback(()=>{l(""),d(""),g(""),j(!1),v(!1)},[]),O=K.useCallback(()=>{T(),v(!0)},[T,i]),_=async()=>{var b;try{await R(s,c,S),await L(),T(),i()}catch(U){console.error("로그인 에러:",U),((b=U.response)==null?void 0:b.status)===401?g("아이디 또는 비밀번호가 올바르지 않습니다."):g("로그인에 실패했습니다.")}};return r?h.jsxs(h.Fragment,{children:[h.jsx(sh,{children:h.jsxs(lh,{children:[h.jsx("h2",{children:"돌아오신 것을 환영해요!"}),h.jsxs("form",{onSubmit:b=>{b.preventDefault(),_()},children:[h.jsx(jo,{type:"text",placeholder:"사용자 이름",value:s,onChange:b=>l(b.target.value)}),h.jsx(jo,{type:"password",placeholder:"비밀번호",value:c,onChange:b=>d(b.target.value)}),h.jsxs(Mv,{children:[h.jsx(Cv,{id:"rememberMe",checked:S,onChange:b=>j(b.target.checked)}),h.jsx(Lv,{htmlFor:"rememberMe",children:"로그인 유지"})]}),p&&h.jsx(uh,{children:p}),h.jsx(ah,{type:"submit",children:"로그인"})]}),h.jsxs(kv,{children:["계정이 필요한가요? ",h.jsx(Ev,{onClick:O,children:"가입하기"})]})]})}),h.jsx(Nv,{isOpen:w,onClose:()=>v(!1)})]}):null},Mv=k.div` + display: flex; + align-items: center; + margin: 10px 0; + justify-content: flex-start; +`,Lv=k.label` + margin-left: 8px; + font-size: 14px; + color: #666; + cursor: pointer; + text-align: left; +`,Iv=async r=>(await Oe.get(`/channels?userId=${r}`)).data,Dv=async r=>(await Oe.post("/channels/public",r)).data,zv=async r=>{const i={participantIds:r};return(await Oe.post("/channels/private",i)).data},$v=async(r,i)=>(await Oe.patch(`/channels/${r}`,i)).data,Fv=async r=>{await Oe.delete(`/channels/${r}`)},Bv=async r=>(await Oe.get("/readStatuses",{params:{userId:r}})).data,bv=async(r,i)=>{const s={newLastReadAt:i};return(await Oe.patch(`/readStatuses/${r}`,s)).data},Uv=async(r,i,s)=>{const l={userId:r,channelId:i,lastReadAt:s};return(await Oe.post("/readStatuses",l)).data},Ao=Pr((r,i)=>({readStatuses:{},fetchReadStatuses:async()=>{try{const{currentUser:s}=pt.getState();if(!s)return;const c=(await Bv(s.id)).reduce((d,p)=>(d[p.channelId]={id:p.id,lastReadAt:p.lastReadAt},d),{});r({readStatuses:c})}catch(s){console.error("읽음 상태 조회 실패:",s)}},updateReadStatus:async s=>{try{const{currentUser:l}=pt.getState();if(!l)return;const c=i().readStatuses[s];let d;c?d=await bv(c.id,new Date().toISOString()):d=await Uv(l.id,s,new Date().toISOString()),r(p=>({readStatuses:{...p.readStatuses,[s]:{id:d.id,lastReadAt:d.lastReadAt}}}))}catch(l){console.error("읽음 상태 업데이트 실패:",l)}},hasUnreadMessages:(s,l)=>{const c=i().readStatuses[s],d=c==null?void 0:c.lastReadAt;return!d||new Date(l)>new Date(d)}})),jn=Pr((r,i)=>({channels:[],pollingInterval:null,loading:!1,error:null,fetchChannels:async s=>{r({loading:!0,error:null});try{const l=await Iv(s);r(d=>{const p=new Set(d.channels.map(S=>S.id)),g=l.filter(S=>!p.has(S.id));return{channels:[...d.channels.filter(S=>l.some(j=>j.id===S.id)),...g],loading:!1}});const{fetchReadStatuses:c}=Ao.getState();return c(),l}catch(l){return r({error:l,loading:!1}),[]}},startPolling:s=>{const l=i().pollingInterval;l&&clearInterval(l);const c=setInterval(()=>{i().fetchChannels(s)},3e3);r({pollingInterval:c})},stopPolling:()=>{const s=i().pollingInterval;s&&(clearInterval(s),r({pollingInterval:null}))},createPublicChannel:async s=>{try{const l=await Dv(s);return r(c=>c.channels.some(p=>p.id===l.id)?c:{channels:[...c.channels,{...l,participantIds:[],lastMessageAt:new Date().toISOString()}]}),l}catch(l){throw console.error("공개 채널 생성 실패:",l),l}},createPrivateChannel:async s=>{try{const l=await zv(s);return r(c=>c.channels.some(p=>p.id===l.id)?c:{channels:[...c.channels,{...l,participantIds:s,lastMessageAt:new Date().toISOString()}]}),l}catch(l){throw console.error("비공개 채널 생성 실패:",l),l}},updatePublicChannel:async(s,l)=>{try{const c=await $v(s,l);return r(d=>({channels:d.channels.map(p=>p.id===s?{...p,...c}:p)})),c}catch(c){throw console.error("채널 수정 실패:",c),c}},deleteChannel:async s=>{try{await Fv(s),r(l=>({channels:l.channels.filter(c=>c.id!==s)}))}catch(l){throw console.error("채널 삭제 실패:",l),l}}})),Hv=async r=>(await Oe.get(`/binaryContents/${r}`)).data,Vv=r=>`${pv()}/binaryContents/${r}/download`,An=Pr((r,i)=>({binaryContents:{},fetchBinaryContent:async s=>{if(i().binaryContents[s])return i().binaryContents[s];try{const l=await Hv(s),{contentType:c,fileName:d,size:p}=l,w={url:Vv(s),contentType:c,fileName:d,size:p};return r(v=>({binaryContents:{...v.binaryContents,[s]:w}})),w}catch(l){return console.error("첨부파일 정보 조회 실패:",l),null}}})),Do=k.div` + position: absolute; + bottom: -3px; + right: -3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: ${r=>r.$online?q.colors.status.online:q.colors.status.offline}; + border: 4px solid ${r=>r.$background||q.colors.background.secondary}; +`;k.div` + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; + background: ${r=>q.colors.status[r.status||"offline"]||q.colors.status.offline}; +`;const Nr=k.div` + position: relative; + width: ${r=>r.$size||"32px"}; + height: ${r=>r.$size||"32px"}; + flex-shrink: 0; + margin: ${r=>r.$margin||"0"}; +`,nn=k.img` + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + border: ${r=>r.$border||"none"}; +`;function Wv({isOpen:r,onClose:i,user:s}){var M,V;const[l,c]=K.useState(s.username),[d,p]=K.useState(s.email),[g,w]=K.useState(""),[v,S]=K.useState(null),[j,R]=K.useState(""),[L,T]=K.useState(null),{binaryContents:O,fetchBinaryContent:_}=An(),{logout:b,fetchMe:U}=pt();K.useEffect(()=>{var ne;(ne=s.profile)!=null&&ne.id&&!O[s.profile.id]&&_(s.profile.id)},[s.profile,O,_]);const B=()=>{c(s.username),p(s.email),w(""),S(null),T(null),R(""),i()},W=ne=>{var Ie;const ye=(Ie=ne.target.files)==null?void 0:Ie[0];if(ye){S(ye);const ie=new FileReader;ie.onloadend=()=>{T(ie.result)},ie.readAsDataURL(ye)}},I=async ne=>{ne.preventDefault(),R("");try{const ye=new FormData,Ie={};l!==s.username&&(Ie.newUsername=l),d!==s.email&&(Ie.newEmail=d),g&&(Ie.newPassword=g),(Object.keys(Ie).length>0||v)&&(ye.append("userUpdateRequest",new Blob([JSON.stringify(Ie)],{type:"application/json"})),v&&ye.append("profile",v),await hv(s.id,ye),await U()),i()}catch{R("사용자 정보 수정에 실패했습니다.")}};return r?h.jsx(Yv,{children:h.jsxs(qv,{children:[h.jsx("h2",{children:"프로필 수정"}),h.jsxs("form",{onSubmit:I,children:[h.jsxs(Xi,{children:[h.jsx(Ji,{children:"프로필 이미지"}),h.jsxs(Gv,{children:[h.jsx(Kv,{src:L||((M=s.profile)!=null&&M.id?(V=O[s.profile.id])==null?void 0:V.url:void 0)||St,alt:"profile"}),h.jsx(Xv,{type:"file",accept:"image/*",onChange:W,id:"profile-image"}),h.jsx(Jv,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),h.jsxs(Xi,{children:[h.jsxs(Ji,{children:["사용자명 ",h.jsx(Gf,{children:"*"})]}),h.jsx(La,{type:"text",value:l,onChange:ne=>c(ne.target.value),required:!0})]}),h.jsxs(Xi,{children:[h.jsxs(Ji,{children:["이메일 ",h.jsx(Gf,{children:"*"})]}),h.jsx(La,{type:"email",value:d,onChange:ne=>p(ne.target.value),required:!0})]}),h.jsxs(Xi,{children:[h.jsx(Ji,{children:"새 비밀번호"}),h.jsx(La,{type:"password",placeholder:"변경하지 않으려면 비워두세요",value:g,onChange:ne=>w(ne.target.value)})]}),j&&h.jsx(Qv,{children:j}),h.jsxs(Zv,{children:[h.jsx(Qf,{type:"button",onClick:B,$secondary:!0,children:"취소"}),h.jsx(Qf,{type:"submit",children:"저장"})]})]}),h.jsx(ex,{onClick:b,children:"로그아웃"})]})}):null}const Yv=k.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,qv=k.div` + background: ${({theme:r})=>r.colors.background.secondary}; + padding: 32px; + border-radius: 5px; + width: 100%; + max-width: 480px; + + h2 { + color: ${({theme:r})=>r.colors.text.primary}; + margin-bottom: 24px; + text-align: center; + font-size: 24px; + } +`,La=k.input` + width: 100%; + padding: 10px; + margin-bottom: 10px; + border: none; + border-radius: 4px; + background: ${({theme:r})=>r.colors.background.input}; + color: ${({theme:r})=>r.colors.text.primary}; + + &::placeholder { + color: ${({theme:r})=>r.colors.text.muted}; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${({theme:r})=>r.colors.brand.primary}; + } +`,Qf=k.button` + width: 100%; + padding: 10px; + border: none; + border-radius: 4px; + background: ${({$secondary:r,theme:i})=>r?"transparent":i.colors.brand.primary}; + color: ${({theme:r})=>r.colors.text.primary}; + cursor: pointer; + font-weight: 500; + + &:hover { + background: ${({$secondary:r,theme:i})=>r?i.colors.background.hover:i.colors.brand.hover}; + } +`,Qv=k.div` + color: ${({theme:r})=>r.colors.status.error}; + font-size: 14px; + margin-bottom: 10px; +`,Gv=k.div` + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 20px; +`,Kv=k.img` + width: 100px; + height: 100px; + border-radius: 50%; + margin-bottom: 10px; + object-fit: cover; +`,Xv=k.input` + display: none; +`,Jv=k.label` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +`,Zv=k.div` + display: flex; + gap: 10px; + margin-top: 20px; +`,ex=k.button` + width: 100%; + padding: 10px; + margin-top: 16px; + border: none; + border-radius: 4px; + background: transparent; + color: ${({theme:r})=>r.colors.status.error}; + cursor: pointer; + font-weight: 500; + + &:hover { + background: ${({theme:r})=>r.colors.status.error}20; + } +`,Xi=k.div` + margin-bottom: 20px; +`,Ji=k.label` + display: block; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +`,Gf=k.span` + color: ${({theme:r})=>r.colors.status.error}; +`,tx=k.div` + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background-color: ${({theme:r})=>r.colors.background.tertiary}; + width: 100%; + height: 52px; +`,nx=k(Nr)``;k(nn)``;const rx=k.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +`,ox=k.div` + font-weight: 500; + color: ${({theme:r})=>r.colors.text.primary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.875rem; + line-height: 1.2; +`,ix=k.div` + font-size: 0.75rem; + color: ${({theme:r})=>r.colors.text.secondary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +`,sx=k.div` + display: flex; + align-items: center; + flex-shrink: 0; +`,lx=k.button` + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: ${({theme:r})=>r.colors.text.secondary}; + font-size: 18px; + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`;function ax({user:r}){var d,p;const[i,s]=K.useState(!1),{binaryContents:l,fetchBinaryContent:c}=An();return K.useEffect(()=>{var g;(g=r.profile)!=null&&g.id&&!l[r.profile.id]&&c(r.profile.id)},[r.profile,l,c]),h.jsxs(h.Fragment,{children:[h.jsxs(tx,{children:[h.jsxs(nx,{children:[h.jsx(nn,{src:(d=r.profile)!=null&&d.id?(p=l[r.profile.id])==null?void 0:p.url:St,alt:r.username}),h.jsx(Do,{$online:!0})]}),h.jsxs(rx,{children:[h.jsx(ox,{children:r.username}),h.jsx(ix,{children:"온라인"})]}),h.jsx(sx,{children:h.jsx(lx,{onClick:()=>s(!0),children:"⚙️"})})]}),h.jsx(Wv,{isOpen:i,onClose:()=>s(!1),user:r})]})}const ux=k.div` + width: 240px; + background: ${q.colors.background.secondary}; + border-right: 1px solid ${q.colors.border.primary}; + display: flex; + flex-direction: column; +`,cx=k.div` + flex: 1; + overflow-y: auto; +`,dx=k.div` + padding: 16px; + font-size: 16px; + font-weight: bold; + color: ${q.colors.text.primary}; +`,du=k.div` + height: 34px; + padding: 0 8px; + margin: 1px 8px; + display: flex; + align-items: center; + gap: 6px; + color: ${r=>r.$hasUnread?r.theme.colors.text.primary:r.theme.colors.text.muted}; + font-weight: ${r=>r.$hasUnread?"600":"normal"}; + cursor: pointer; + background: ${r=>r.$isActive?r.theme.colors.background.hover:"transparent"}; + border-radius: 4px; + + &:hover { + background: ${r=>r.theme.colors.background.hover}; + color: ${r=>r.theme.colors.text.primary}; + } +`,Kf=k.div` + margin-bottom: 8px; +`,Ja=k.div` + padding: 8px 16px; + display: flex; + align-items: center; + color: ${q.colors.text.muted}; + text-transform: uppercase; + font-size: 12px; + font-weight: 600; + cursor: pointer; + user-select: none; + + & > span:nth-child(2) { + flex: 1; + margin-right: auto; + } + + &:hover { + color: ${q.colors.text.primary}; + } +`,Xf=k.span` + margin-right: 4px; + font-size: 10px; + transition: transform 0.2s; + transform: rotate(${r=>r.$folded?"-90deg":"0deg"}); +`,Jf=k.div` + display: ${r=>r.$folded?"none":"block"}; +`,Za=k(du)` + height: ${r=>r.hasSubtext?"42px":"34px"}; +`,fx=k(Nr)` + width: 32px; + height: 32px; + margin: 0 8px; +`,Zf=k.div` + font-size: 16px; + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${r=>r.$isActive||r.$hasUnread?r.theme.colors.text.primary:r.theme.colors.text.muted}; + font-weight: ${r=>r.$hasUnread?"600":"normal"}; +`;k(Do)` + border-color: ${q.colors.background.primary}; +`;const ep=k.button` + background: none; + border: none; + color: ${q.colors.text.muted}; + font-size: 18px; + padding: 0; + cursor: pointer; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s, color 0.2s; + + ${Ja}:hover & { + opacity: 1; + } + + &:hover { + color: ${q.colors.text.primary}; + } +`,px=k(Nr)` + width: 40px; + height: 24px; + margin: 0 8px; +`,hx=k.div` + font-size: 12px; + line-height: 13px; + color: ${q.colors.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,tp=k.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; +`,ch=k.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,dh=k.div` + background: ${q.colors.background.primary}; + border-radius: 4px; + width: 440px; + max-width: 90%; +`,fh=k.div` + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; +`,ph=k.h2` + color: ${q.colors.text.primary}; + font-size: 20px; + font-weight: 600; + margin: 0; +`,hh=k.div` + padding: 0 16px 16px; +`,mh=k.form` + display: flex; + flex-direction: column; + gap: 16px; +`,Ro=k.div` + display: flex; + flex-direction: column; + gap: 8px; +`,Po=k.label` + color: ${q.colors.text.primary}; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +`,gh=k.p` + color: ${q.colors.text.muted}; + font-size: 14px; + margin: -4px 0 0; +`,Mo=k.input` + padding: 10px; + background: ${q.colors.background.tertiary}; + border: none; + border-radius: 3px; + color: ${q.colors.text.primary}; + font-size: 16px; + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${q.colors.status.online}; + } + + &::placeholder { + color: ${q.colors.text.muted}; + } +`,yh=k.button` + margin-top: 8px; + padding: 12px; + background: ${q.colors.status.online}; + color: white; + border: none; + border-radius: 3px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #3ca374; + } +`,vh=k.button` + background: none; + border: none; + color: ${q.colors.text.muted}; + font-size: 24px; + cursor: pointer; + padding: 4px; + line-height: 1; + + &:hover { + color: ${q.colors.text.primary}; + } +`,mx=k(Mo)` + margin-bottom: 8px; +`,gx=k.div` + max-height: 300px; + overflow-y: auto; + background: ${q.colors.background.tertiary}; + border-radius: 4px; +`,yx=k.div` + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: ${q.colors.background.hover}; + } + + & + & { + border-top: 1px solid ${q.colors.border.primary}; + } +`,vx=k.input` + margin-right: 12px; + width: 16px; + height: 16px; + cursor: pointer; +`,np=k.img` + width: 32px; + height: 32px; + border-radius: 50%; + margin-right: 12px; +`,xx=k.div` + flex: 1; + min-width: 0; +`,wx=k.div` + color: ${q.colors.text.primary}; + font-size: 14px; + font-weight: 500; +`,Sx=k.div` + color: ${q.colors.text.muted}; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,Cx=k.div` + padding: 16px; + text-align: center; + color: ${q.colors.text.muted}; +`,xh=k.div` + color: ${q.colors.status.error}; + font-size: 14px; + padding: 8px 0; + text-align: center; + background-color: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 4px; + margin-bottom: 8px; +`,Ia=k.div` + position: relative; + margin-left: auto; + z-index: 99999; +`,Da=k.button` + background: none; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + cursor: pointer; + padding: 4px; + border-radius: 3px; + opacity: 0; + transition: opacity 0.2s, background 0.2s; + + &:hover { + background: ${({theme:r})=>r.colors.background.hover}; + color: ${({theme:r})=>r.colors.text.primary}; + } + + ${du}:hover &, + ${Za}:hover & { + opacity: 1; + } +`,za=k.div` + position: absolute; + top: 100%; + right: 0; + background: ${({theme:r})=>r.colors.background.primary}; + border: 1px solid ${({theme:r})=>r.colors.border.primary}; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + min-width: 120px; + z-index: 100000; +`,Zi=k.div` + padding: 8px 12px; + color: ${({theme:r})=>r.colors.text.primary}; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + + &:hover { + background: ${({theme:r})=>r.colors.background.hover}; + } + + &:first-child { + border-radius: 4px 4px 0 0; + } + + &:last-child { + border-radius: 0 0 4px 4px; + } + + &:only-child { + border-radius: 4px; + } +`;function kx(){return h.jsx(dx,{children:"채널 목록"})}var En=(r=>(r.USER="USER",r.CHANNEL_MANAGER="CHANNEL_MANAGER",r.ADMIN="ADMIN",r))(En||{});function Ex({isOpen:r,channel:i,onClose:s,onUpdateSuccess:l}){const[c,d]=K.useState({name:"",description:""}),[p,g]=K.useState(""),[w,v]=K.useState(!1),{updatePublicChannel:S}=jn();K.useEffect(()=>{i&&r&&(d({name:i.name||"",description:i.description||""}),g(""))},[i,r]);const j=L=>{const{name:T,value:O}=L.target;d(_=>({..._,[T]:O}))},R=async L=>{var T,O;if(L.preventDefault(),!!i){g(""),v(!0);try{if(!c.name.trim()){g("채널 이름을 입력해주세요."),v(!1);return}const _={newName:c.name.trim(),newDescription:c.description.trim()},b=await S(i.id,_);l(b)}catch(_){console.error("채널 수정 실패:",_),g(((O=(T=_.response)==null?void 0:T.data)==null?void 0:O.message)||"채널 수정에 실패했습니다. 다시 시도해주세요.")}finally{v(!1)}}};return!r||!i||i.type!=="PUBLIC"?null:h.jsx(ch,{onClick:s,children:h.jsxs(dh,{onClick:L=>L.stopPropagation(),children:[h.jsxs(fh,{children:[h.jsx(ph,{children:"채널 수정"}),h.jsx(vh,{onClick:s,children:"×"})]}),h.jsx(hh,{children:h.jsxs(mh,{onSubmit:R,children:[p&&h.jsx(xh,{children:p}),h.jsxs(Ro,{children:[h.jsx(Po,{children:"채널 이름"}),h.jsx(Mo,{name:"name",value:c.name,onChange:j,placeholder:"새로운-채널",required:!0,disabled:w})]}),h.jsxs(Ro,{children:[h.jsx(Po,{children:"채널 설명"}),h.jsx(gh,{children:"이 채널의 주제를 설명해주세요."}),h.jsx(Mo,{name:"description",value:c.description,onChange:j,placeholder:"채널 설명을 입력하세요",disabled:w})]}),h.jsx(yh,{type:"submit",disabled:w,children:w?"수정 중...":"채널 수정"})]})})]})})}function rp({channel:r,isActive:i,onClick:s,hasUnread:l}){var U;const{currentUser:c}=pt(),{binaryContents:d}=An(),{deleteChannel:p}=jn(),[g,w]=K.useState(null),[v,S]=K.useState(!1),j=(c==null?void 0:c.role)===En.ADMIN||(c==null?void 0:c.role)===En.CHANNEL_MANAGER;K.useEffect(()=>{const B=()=>{g&&w(null)};if(g)return document.addEventListener("click",B),()=>document.removeEventListener("click",B)},[g]);const R=B=>{w(g===B?null:B)},L=()=>{w(null),S(!0)},T=B=>{S(!1),console.log("Channel updated successfully:",B)},O=()=>{S(!1)},_=async B=>{var I;w(null);const W=r.type==="PUBLIC"?r.name:r.type==="PRIVATE"&&r.participants.length>2?`그룹 채팅 (멤버 ${r.participants.length}명)`:((I=r.participants.filter(M=>M.id!==(c==null?void 0:c.id))[0])==null?void 0:I.username)||"1:1 채팅";if(confirm(`"${W}" 채널을 삭제하시겠습니까?`))try{await p(B),console.log("Channel deleted successfully:",B)}catch(M){console.error("Channel delete failed:",M),alert("채널 삭제에 실패했습니다. 다시 시도해주세요.")}};let b;if(r.type==="PUBLIC")b=h.jsxs(du,{$isActive:i,onClick:s,$hasUnread:l,children:["# ",r.name,j&&h.jsxs(Ia,{children:[h.jsx(Da,{onClick:B=>{B.stopPropagation(),R(r.id)},children:"⋯"}),g===r.id&&h.jsxs(za,{onClick:B=>B.stopPropagation(),children:[h.jsx(Zi,{onClick:()=>L(),children:"✏️ 수정"}),h.jsx(Zi,{onClick:()=>_(r.id),children:"🗑️ 삭제"})]})]})]});else{const B=r.participants;if(B.length>2){const W=B.filter(I=>I.id!==(c==null?void 0:c.id)).map(I=>I.username).join(", ");b=h.jsxs(Za,{$isActive:i,onClick:s,children:[h.jsx(px,{children:B.filter(I=>I.id!==(c==null?void 0:c.id)).slice(0,2).map((I,M)=>{var V;return h.jsx(nn,{src:I.profile?(V=d[I.profile.id])==null?void 0:V.url:St,style:{position:"absolute",left:M*16,zIndex:2-M,width:"24px",height:"24px",border:"2px solid #2a2a2a"}},I.id)})}),h.jsxs(tp,{children:[h.jsx(Zf,{$hasUnread:l,children:W}),h.jsxs(hx,{children:["멤버 ",B.length,"명"]})]}),j&&h.jsxs(Ia,{children:[h.jsx(Da,{onClick:I=>{I.stopPropagation(),R(r.id)},children:"⋯"}),g===r.id&&h.jsx(za,{onClick:I=>I.stopPropagation(),children:h.jsx(Zi,{onClick:()=>_(r.id),children:"🗑️ 삭제"})})]})]})}else{const W=B.filter(I=>I.id!==(c==null?void 0:c.id))[0];b=W?h.jsxs(Za,{$isActive:i,onClick:s,children:[h.jsxs(fx,{children:[h.jsx(nn,{src:W.profile?(U=d[W.profile.id])==null?void 0:U.url:St,alt:"profile"}),h.jsx(Do,{$online:W.online})]}),h.jsx(tp,{children:h.jsx(Zf,{$hasUnread:l,children:W.username})}),j&&h.jsxs(Ia,{children:[h.jsx(Da,{onClick:I=>{I.stopPropagation(),R(r.id)},children:"⋯"}),g===r.id&&h.jsx(za,{onClick:I=>I.stopPropagation(),children:h.jsx(Zi,{onClick:()=>_(r.id),children:"🗑️ 삭제"})})]})]}):h.jsx("div",{})}}return h.jsxs(h.Fragment,{children:[b,h.jsx(Ex,{isOpen:v,channel:r,onClose:O,onUpdateSuccess:T})]})}function jx({isOpen:r,type:i,onClose:s,onCreateSuccess:l}){const[c,d]=K.useState({name:"",description:""}),[p,g]=K.useState(""),[w,v]=K.useState([]),[S,j]=K.useState(""),R=Ar(I=>I.users),L=An(I=>I.binaryContents),{currentUser:T}=pt(),O=K.useMemo(()=>R.filter(I=>I.id!==(T==null?void 0:T.id)).filter(I=>I.username.toLowerCase().includes(p.toLowerCase())||I.email.toLowerCase().includes(p.toLowerCase())),[p,R,T]),_=jn(I=>I.createPublicChannel),b=jn(I=>I.createPrivateChannel),U=I=>{const{name:M,value:V}=I.target;d(ne=>({...ne,[M]:V}))},B=I=>{v(M=>M.includes(I)?M.filter(V=>V!==I):[...M,I])},W=async I=>{var M,V;I.preventDefault(),j("");try{let ne;if(i==="PUBLIC"){if(!c.name.trim()){j("채널 이름을 입력해주세요.");return}const ye={name:c.name,description:c.description};ne=await _(ye)}else{if(w.length===0){j("대화 상대를 선택해주세요.");return}const ye=(T==null?void 0:T.id)&&[...w,T.id]||w;ne=await b(ye)}l(ne)}catch(ne){console.error("채널 생성 실패:",ne),j(((V=(M=ne.response)==null?void 0:M.data)==null?void 0:V.message)||"채널 생성에 실패했습니다. 다시 시도해주세요.")}};return r?h.jsx(ch,{onClick:s,children:h.jsxs(dh,{onClick:I=>I.stopPropagation(),children:[h.jsxs(fh,{children:[h.jsx(ph,{children:i==="PUBLIC"?"채널 만들기":"개인 메시지 시작하기"}),h.jsx(vh,{onClick:s,children:"×"})]}),h.jsx(hh,{children:h.jsxs(mh,{onSubmit:W,children:[S&&h.jsx(xh,{children:S}),i==="PUBLIC"?h.jsxs(h.Fragment,{children:[h.jsxs(Ro,{children:[h.jsx(Po,{children:"채널 이름"}),h.jsx(Mo,{name:"name",value:c.name,onChange:U,placeholder:"새로운-채널",required:!0})]}),h.jsxs(Ro,{children:[h.jsx(Po,{children:"채널 설명"}),h.jsx(gh,{children:"이 채널의 주제를 설명해주세요."}),h.jsx(Mo,{name:"description",value:c.description,onChange:U,placeholder:"채널 설명을 입력하세요"})]})]}):h.jsxs(Ro,{children:[h.jsx(Po,{children:"사용자 검색"}),h.jsx(mx,{type:"text",value:p,onChange:I=>g(I.target.value),placeholder:"사용자명 또는 이메일로 검색"}),h.jsx(gx,{children:O.length>0?O.map(I=>h.jsxs(yx,{children:[h.jsx(vx,{type:"checkbox",checked:w.includes(I.id),onChange:()=>B(I.id)}),I.profile?h.jsx(np,{src:L[I.profile.id].url}):h.jsx(np,{src:St}),h.jsxs(xx,{children:[h.jsx(wx,{children:I.username}),h.jsx(Sx,{children:I.email})]})]},I.id)):h.jsx(Cx,{children:"검색 결과가 없습니다."})})]}),h.jsx(yh,{type:"submit",children:i==="PUBLIC"?"채널 만들기":"대화 시작하기"})]})})]})}):null}function Ax({currentUser:r,activeChannel:i,onChannelSelect:s}){var W,I;const[l,c]=K.useState({PUBLIC:!1,PRIVATE:!1}),[d,p]=K.useState({isOpen:!1,type:null}),g=jn(M=>M.channels),w=jn(M=>M.fetchChannels),v=jn(M=>M.startPolling),S=jn(M=>M.stopPolling),j=Ao(M=>M.fetchReadStatuses),R=Ao(M=>M.updateReadStatus),L=Ao(M=>M.hasUnreadMessages);K.useEffect(()=>{if(r)return w(r.id),j(),v(r.id),()=>{S()}},[r,w,j,v,S]);const T=M=>{c(V=>({...V,[M]:!V[M]}))},O=(M,V)=>{V.stopPropagation(),p({isOpen:!0,type:M})},_=()=>{p({isOpen:!1,type:null})},b=async M=>{try{const ne=(await w(r.id)).find(ye=>ye.id===M.id);ne&&s(ne),_()}catch(V){console.error("채널 생성 실패:",V)}},U=M=>{s(M),R(M.id)},B=g.reduce((M,V)=>(M[V.type]||(M[V.type]=[]),M[V.type].push(V),M),{});return h.jsxs(ux,{children:[h.jsx(kx,{}),h.jsxs(cx,{children:[h.jsxs(Kf,{children:[h.jsxs(Ja,{onClick:()=>T("PUBLIC"),children:[h.jsx(Xf,{$folded:l.PUBLIC,children:"▼"}),h.jsx("span",{children:"일반 채널"}),h.jsx(ep,{onClick:M=>O("PUBLIC",M),children:"+"})]}),h.jsx(Jf,{$folded:l.PUBLIC,children:(W=B.PUBLIC)==null?void 0:W.map(M=>h.jsx(rp,{channel:M,isActive:(i==null?void 0:i.id)===M.id,hasUnread:L(M.id,M.lastMessageAt),onClick:()=>U(M)},M.id))})]}),h.jsxs(Kf,{children:[h.jsxs(Ja,{onClick:()=>T("PRIVATE"),children:[h.jsx(Xf,{$folded:l.PRIVATE,children:"▼"}),h.jsx("span",{children:"개인 메시지"}),h.jsx(ep,{onClick:M=>O("PRIVATE",M),children:"+"})]}),h.jsx(Jf,{$folded:l.PRIVATE,children:(I=B.PRIVATE)==null?void 0:I.map(M=>h.jsx(rp,{channel:M,isActive:(i==null?void 0:i.id)===M.id,hasUnread:L(M.id,M.lastMessageAt),onClick:()=>U(M)},M.id))})]})]}),h.jsx(Rx,{children:h.jsx(ax,{user:r})}),h.jsx(jx,{isOpen:d.isOpen,type:d.type,onClose:_,onCreateSuccess:b})]})}const Rx=k.div` + margin-top: auto; + border-top: 1px solid ${({theme:r})=>r.colors.border.primary}; + background-color: ${({theme:r})=>r.colors.background.tertiary}; +`,Px=k.div` + flex: 1; + display: flex; + flex-direction: column; + background: ${({theme:r})=>r.colors.background.primary}; +`,Tx=k.div` + display: flex; + flex-direction: column; + height: 100%; + background: ${({theme:r})=>r.colors.background.primary}; +`,_x=k(Tx)` + justify-content: center; + align-items: center; + flex: 1; + padding: 0 20px; +`,Nx=k.div` + text-align: center; + max-width: 400px; + padding: 20px; + margin-bottom: 80px; +`,Ox=k.div` + font-size: 48px; + margin-bottom: 16px; + animation: wave 2s infinite; + transform-origin: 70% 70%; + + @keyframes wave { + 0% { transform: rotate(0deg); } + 10% { transform: rotate(14deg); } + 20% { transform: rotate(-8deg); } + 30% { transform: rotate(14deg); } + 40% { transform: rotate(-4deg); } + 50% { transform: rotate(10deg); } + 60% { transform: rotate(0deg); } + 100% { transform: rotate(0deg); } + } +`,Mx=k.h2` + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 28px; + font-weight: 700; + margin-bottom: 16px; +`,Lx=k.p` + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + line-height: 1.6; + word-break: keep-all; +`,op=k.div` + height: 48px; + padding: 0 16px; + background: ${q.colors.background.primary}; + border-bottom: 1px solid ${q.colors.border.primary}; + display: flex; + align-items: center; +`,ip=k.div` + display: flex; + align-items: center; + gap: 8px; + height: 100%; +`,Ix=k.div` + display: flex; + align-items: center; + gap: 12px; + height: 100%; +`,Dx=k(Nr)` + width: 24px; + height: 24px; +`;k.img` + width: 24px; + height: 24px; + border-radius: 50%; +`;const zx=k.div` + position: relative; + width: 40px; + height: 24px; + flex-shrink: 0; +`,$x=k(Do)` + border-color: ${q.colors.background.primary}; + bottom: -3px; + right: -3px; +`,Fx=k.div` + font-size: 12px; + color: ${q.colors.text.muted}; + line-height: 13px; +`,sp=k.div` + font-weight: bold; + color: ${q.colors.text.primary}; + line-height: 20px; + font-size: 16px; +`,Bx=k.div` + flex: 1; + display: flex; + flex-direction: column-reverse; + overflow-y: auto; + position: relative; +`,bx=k.div` + padding: 16px; + display: flex; + flex-direction: column; +`,wh=k.div` + margin-bottom: 16px; + display: flex; + align-items: flex-start; + position: relative; + z-index: 1; +`,Ux=k(Nr)` + margin-right: 16px; + width: 40px; + height: 40px; +`;k.img` + width: 40px; + height: 40px; + border-radius: 50%; +`;const Hx=k.div` + display: flex; + align-items: center; + margin-bottom: 4px; + position: relative; +`,Vx=k.span` + font-weight: bold; + color: ${q.colors.text.primary}; + margin-right: 8px; +`,Wx=k.span` + font-size: 0.75rem; + color: ${q.colors.text.muted}; +`,Yx=k.div` + color: ${q.colors.text.secondary}; + margin-top: 4px; +`,qx=k.form` + display: flex; + align-items: center; + gap: 8px; + padding: 16px; + background: ${({theme:r})=>r.colors.background.secondary}; + position: relative; + z-index: 1; +`,Qx=k.textarea` + flex: 1; + padding: 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border: none; + border-radius: 4px; + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 14px; + resize: none; + min-height: 44px; + max-height: 144px; + + &:focus { + outline: none; + } + + &::placeholder { + color: ${({theme:r})=>r.colors.text.muted}; + } +`,Gx=k.button` + background: none; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`;k.div` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: ${q.colors.text.muted}; + font-size: 16px; + font-weight: 500; + padding: 20px; + text-align: center; +`;const lp=k.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + width: 100%; +`,Kx=k.a` + display: block; + border-radius: 4px; + overflow: hidden; + max-width: 300px; + + img { + width: 100%; + height: auto; + display: block; + } +`,Xx=k.a` + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 8px; + text-decoration: none; + width: fit-content; + + &:hover { + background: ${({theme:r})=>r.colors.background.hover}; + } +`,Jx=k.div` + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: #0B93F6; +`,Zx=k.div` + display: flex; + flex-direction: column; + gap: 2px; +`,e1=k.span` + font-size: 14px; + color: #0B93F6; + font-weight: 500; +`,t1=k.span` + font-size: 13px; + color: ${({theme:r})=>r.colors.text.muted}; +`,n1=k.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 0; +`,Sh=k.div` + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 4px; + max-width: 300px; +`,r1=k(Sh)` + padding: 0; + overflow: hidden; + width: 200px; + height: 120px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`,o1=k.div` + color: #0B93F6; + font-size: 20px; +`,i1=k.div` + font-size: 13px; + color: ${({theme:r})=>r.colors.text.primary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,ap=k.button` + position: absolute; + top: -6px; + right: -6px; + width: 20px; + height: 20px; + border-radius: 50%; + background: ${({theme:r})=>r.colors.background.secondary}; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`,s1=k.div` + position: relative; + margin-left: auto; + z-index: 99999; +`,l1=k.button` + background: none; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + background: ${({theme:r})=>r.colors.background.hover}; + } + + ${wh}:hover & { + opacity: 1; + } +`,a1=k.div` + position: absolute; + top: 0; + background: ${({theme:r})=>r.colors.background.primary}; + border: 1px solid ${({theme:r})=>r.colors.border.primary}; + border-radius: 6px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + width: 80px; + z-index: 99999; + overflow: hidden; +`,up=k.button` + display: flex; + align-items: center; + gap: 8px; + width: fit-content; + background: none; + border: none; + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 14px; + cursor: pointer; + text-align: center ; + + &:hover { + background: ${({theme:r})=>r.colors.background.hover}; + } + + &:first-child { + border-radius: 6px 6px 0 0; + } + + &:last-child { + border-radius: 0 0 6px 6px; + } +`,u1=k.div` + margin-top: 4px; +`,c1=k.textarea` + width: 100%; + max-width: 600px; + min-height: 80px; + padding: 12px 16px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border: 1px solid ${({theme:r})=>r.colors.border.primary}; + border-radius: 4px; + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 14px; + font-family: inherit; + resize: vertical; + outline: none; + box-sizing: border-box; + + &:focus { + border-color: ${({theme:r})=>r.colors.primary}; + } + + &::placeholder { + color: ${({theme:r})=>r.colors.text.muted}; + } +`,d1=k.div` + display: flex; + gap: 8px; + margin-top: 8px; +`,cp=k.button` + padding: 6px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background-color 0.2s ease; + + ${({variant:r,theme:i})=>r==="primary"?` + background: ${i.colors.primary}; + color: white; + + &:hover { + background: ${i.colors.primaryHover||i.colors.primary}; + } + `:` + background: ${i.colors.background.secondary}; + color: ${i.colors.text.secondary}; + + &:hover { + background: ${i.colors.background.hover}; + } + `} +`;function f1({channel:r}){var w;const{currentUser:i}=pt(),s=Ar(v=>v.users),l=An(v=>v.binaryContents);if(!r)return null;if(r.type==="PUBLIC")return h.jsx(op,{children:h.jsx(ip,{children:h.jsxs(sp,{children:["# ",r.name]})})});const c=r.participants.map(v=>s.find(S=>S.id===v.id)).filter(Boolean),d=c.filter(v=>v.id!==(i==null?void 0:i.id)),p=c.length>2,g=c.filter(v=>v.id!==(i==null?void 0:i.id)).map(v=>v.username).join(", ");return h.jsx(op,{children:h.jsx(ip,{children:h.jsxs(Ix,{children:[p?h.jsx(zx,{children:d.slice(0,2).map((v,S)=>{var j;return h.jsx(nn,{src:v.profile?(j=l[v.profile.id])==null?void 0:j.url:St,style:{position:"absolute",left:S*16,zIndex:2-S,width:"24px",height:"24px"}},v.id)})}):h.jsxs(Dx,{children:[h.jsx(nn,{src:d[0].profile?(w=l[d[0].profile.id])==null?void 0:w.url:St}),h.jsx($x,{$online:d[0].online})]}),h.jsxs("div",{children:[h.jsx(sp,{children:g}),p&&h.jsxs(Fx,{children:["멤버 ",c.length,"명"]})]})]})})})}const p1=async(r,i,s)=>{var c;return(await Oe.get("/messages",{params:{channelId:r,cursor:i,size:s.size,sort:(c=s.sort)==null?void 0:c.join(",")}})).data},h1=async(r,i)=>{const s=new FormData,l={content:r.content,channelId:r.channelId,authorId:r.authorId};return s.append("messageCreateRequest",new Blob([JSON.stringify(l)],{type:"application/json"})),i&&i.length>0&&i.forEach(d=>{s.append("attachments",d)}),(await Oe.post("/messages",s,{headers:{"Content-Type":"multipart/form-data"}})).data},m1=async(r,i)=>(await Oe.patch(`/messages/${r}`,i)).data,g1=async r=>{await Oe.delete(`/messages/${r}`)},$a={size:50,sort:["createdAt,desc"]},Ch=Pr((r,i)=>({messages:[],pollingIntervals:{},lastMessageId:null,pagination:{nextCursor:null,pageSize:50,hasNext:!1},fetchMessages:async(s,l,c=$a)=>{try{const d=await p1(s,l,c),p=d.content,g=p.length>0?p[0]:null,w=(g==null?void 0:g.id)!==i().lastMessageId;return r(v=>{var O;const S=!l,j=s!==((O=v.messages[0])==null?void 0:O.channelId),R=S&&(v.messages.length===0||j);let L=[],T={...v.pagination};if(R)L=p,T={nextCursor:d.nextCursor,pageSize:d.size,hasNext:d.hasNext};else if(S){const _=new Set(v.messages.map(U=>U.id));L=[...p.filter(U=>!_.has(U.id)&&(v.messages.length===0||U.createdAt>v.messages[0].createdAt)),...v.messages]}else{const _=new Set(v.messages.map(U=>U.id)),b=p.filter(U=>!_.has(U.id));L=[...v.messages,...b],T={nextCursor:d.nextCursor,pageSize:d.size,hasNext:d.hasNext}}return{messages:L,lastMessageId:(g==null?void 0:g.id)||null,pagination:T}}),w}catch(d){return console.error("메시지 목록 조회 실패:",d),!1}},loadMoreMessages:async s=>{const{pagination:l}=i();l.hasNext&&await i().fetchMessages(s,l.nextCursor,{...$a})},startPolling:s=>{const l=i();if(l.pollingIntervals[s]){const g=l.pollingIntervals[s];typeof g=="number"&&clearTimeout(g)}let c=300;const d=3e3;r(g=>({pollingIntervals:{...g.pollingIntervals,[s]:!0}}));const p=async()=>{const g=i();if(!g.pollingIntervals[s])return;const w=await g.fetchMessages(s,null,$a);if(!(i().messages.length==0)&&w?c=300:c=Math.min(c*1.5,d),i().pollingIntervals[s]){const S=setTimeout(p,c);r(j=>({pollingIntervals:{...j.pollingIntervals,[s]:S}}))}};p()},stopPolling:s=>{const{pollingIntervals:l}=i();if(l[s]){const c=l[s];typeof c=="number"&&clearTimeout(c),r(d=>{const p={...d.pollingIntervals};return delete p[s],{pollingIntervals:p}})}},createMessage:async(s,l)=>{try{const c=await h1(s,l),d=Ao.getState().updateReadStatus;return await d(s.channelId),r(p=>p.messages.some(w=>w.id===c.id)?p:{messages:[c,...p.messages],lastMessageId:c.id}),c}catch(c){throw console.error("메시지 생성 실패:",c),c}},updateMessage:async(s,l)=>{try{const c=await m1(s,{newContent:l});return r(d=>({messages:d.messages.map(p=>p.id===s?{...p,content:l}:p)})),c}catch(c){throw console.error("메시지 업데이트 실패:",c),c}},deleteMessage:async s=>{try{await g1(s),r(l=>({messages:l.messages.filter(c=>c.id!==s)}))}catch(l){throw console.error("메시지 삭제 실패:",l),l}}}));function y1({channel:r}){const[i,s]=K.useState(""),[l,c]=K.useState([]),d=Ch(R=>R.createMessage),{currentUser:p}=pt(),g=async R=>{if(R.preventDefault(),!(!i.trim()&&l.length===0))try{await d({content:i.trim(),channelId:r.id,authorId:(p==null?void 0:p.id)??""},l),s(""),c([])}catch(L){console.error("메시지 전송 실패:",L)}},w=R=>{const L=Array.from(R.target.files||[]);c(T=>[...T,...L]),R.target.value=""},v=R=>{c(L=>L.filter((T,O)=>O!==R))},S=R=>{if(R.key==="Enter"&&!R.shiftKey){if(console.log("Enter key pressed"),R.preventDefault(),R.nativeEvent.isComposing)return;g(R)}},j=(R,L)=>R.type.startsWith("image/")?h.jsxs(r1,{children:[h.jsx("img",{src:URL.createObjectURL(R),alt:R.name}),h.jsx(ap,{onClick:()=>v(L),children:"×"})]},L):h.jsxs(Sh,{children:[h.jsx(o1,{children:"📎"}),h.jsx(i1,{children:R.name}),h.jsx(ap,{onClick:()=>v(L),children:"×"})]},L);return K.useEffect(()=>()=>{l.forEach(R=>{R.type.startsWith("image/")&&URL.revokeObjectURL(URL.createObjectURL(R))})},[l]),r?h.jsxs(h.Fragment,{children:[l.length>0&&h.jsx(n1,{children:l.map((R,L)=>j(R,L))}),h.jsxs(qx,{onSubmit:g,children:[h.jsxs(Gx,{as:"label",children:["+",h.jsx("input",{type:"file",multiple:!0,onChange:w,style:{display:"none"}})]}),h.jsx(Qx,{value:i,onChange:R=>s(R.target.value),onKeyDown:S,placeholder:r.type==="PUBLIC"?`#${r.name}에 메시지 보내기`:"메시지 보내기"})]})]}):null}/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 + +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. + +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */var eu=function(r,i){return eu=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(s,l){s.__proto__=l}||function(s,l){for(var c in l)l.hasOwnProperty(c)&&(s[c]=l[c])},eu(r,i)};function v1(r,i){eu(r,i);function s(){this.constructor=r}r.prototype=i===null?Object.create(i):(s.prototype=i.prototype,new s)}var To=function(){return To=Object.assign||function(i){for(var s,l=1,c=arguments.length;lr?L():i!==!0&&(c=setTimeout(l?T:L,l===void 0?r-j:r))}return v.cancel=w,v}var Sr={Pixel:"Pixel",Percent:"Percent"},dp={unit:Sr.Percent,value:.8};function fp(r){return typeof r=="number"?{unit:Sr.Percent,value:r*100}:typeof r=="string"?r.match(/^(\d*(\.\d+)?)px$/)?{unit:Sr.Pixel,value:parseFloat(r)}:r.match(/^(\d*(\.\d+)?)%$/)?{unit:Sr.Percent,value:parseFloat(r)}:(console.warn('scrollThreshold format is invalid. Valid formats: "120px", "50%"...'),dp):(console.warn("scrollThreshold should be string or number"),dp)}var w1=function(r){v1(i,r);function i(s){var l=r.call(this,s)||this;return l.lastScrollTop=0,l.actionTriggered=!1,l.startY=0,l.currentY=0,l.dragging=!1,l.maxPullDownDistance=0,l.getScrollableTarget=function(){return l.props.scrollableTarget instanceof HTMLElement?l.props.scrollableTarget:typeof l.props.scrollableTarget=="string"?document.getElementById(l.props.scrollableTarget):(l.props.scrollableTarget===null&&console.warn(`You are trying to pass scrollableTarget but it is null. This might + happen because the element may not have been added to DOM yet. + See https://github.com/ankeetmaini/react-infinite-scroll-component/issues/59 for more info. + `),null)},l.onStart=function(c){l.lastScrollTop||(l.dragging=!0,c instanceof MouseEvent?l.startY=c.pageY:c instanceof TouchEvent&&(l.startY=c.touches[0].pageY),l.currentY=l.startY,l._infScroll&&(l._infScroll.style.willChange="transform",l._infScroll.style.transition="transform 0.2s cubic-bezier(0,0,0.31,1)"))},l.onMove=function(c){l.dragging&&(c instanceof MouseEvent?l.currentY=c.pageY:c instanceof TouchEvent&&(l.currentY=c.touches[0].pageY),!(l.currentY=Number(l.props.pullDownToRefreshThreshold)&&l.setState({pullToRefreshThresholdBreached:!0}),!(l.currentY-l.startY>l.maxPullDownDistance*1.5)&&l._infScroll&&(l._infScroll.style.overflow="visible",l._infScroll.style.transform="translate3d(0px, "+(l.currentY-l.startY)+"px, 0px)")))},l.onEnd=function(){l.startY=0,l.currentY=0,l.dragging=!1,l.state.pullToRefreshThresholdBreached&&(l.props.refreshFunction&&l.props.refreshFunction(),l.setState({pullToRefreshThresholdBreached:!1})),requestAnimationFrame(function(){l._infScroll&&(l._infScroll.style.overflow="auto",l._infScroll.style.transform="none",l._infScroll.style.willChange="unset")})},l.onScrollListener=function(c){typeof l.props.onScroll=="function"&&setTimeout(function(){return l.props.onScroll&&l.props.onScroll(c)},0);var d=l.props.height||l._scrollableNode?c.target:document.documentElement.scrollTop?document.documentElement:document.body;if(!l.actionTriggered){var p=l.props.inverse?l.isElementAtTop(d,l.props.scrollThreshold):l.isElementAtBottom(d,l.props.scrollThreshold);p&&l.props.hasMore&&(l.actionTriggered=!0,l.setState({showLoader:!0}),l.props.next&&l.props.next()),l.lastScrollTop=d.scrollTop}},l.state={showLoader:!1,pullToRefreshThresholdBreached:!1,prevDataLength:s.dataLength},l.throttledOnScrollListener=x1(150,l.onScrollListener).bind(l),l.onStart=l.onStart.bind(l),l.onMove=l.onMove.bind(l),l.onEnd=l.onEnd.bind(l),l}return i.prototype.componentDidMount=function(){if(typeof this.props.dataLength>"u")throw new Error('mandatory prop "dataLength" is missing. The prop is needed when loading more content. Check README.md for usage');if(this._scrollableNode=this.getScrollableTarget(),this.el=this.props.height?this._infScroll:this._scrollableNode||window,this.el&&this.el.addEventListener("scroll",this.throttledOnScrollListener),typeof this.props.initialScrollY=="number"&&this.el&&this.el instanceof HTMLElement&&this.el.scrollHeight>this.props.initialScrollY&&this.el.scrollTo(0,this.props.initialScrollY),this.props.pullDownToRefresh&&this.el&&(this.el.addEventListener("touchstart",this.onStart),this.el.addEventListener("touchmove",this.onMove),this.el.addEventListener("touchend",this.onEnd),this.el.addEventListener("mousedown",this.onStart),this.el.addEventListener("mousemove",this.onMove),this.el.addEventListener("mouseup",this.onEnd),this.maxPullDownDistance=this._pullDown&&this._pullDown.firstChild&&this._pullDown.firstChild.getBoundingClientRect().height||0,this.forceUpdate(),typeof this.props.refreshFunction!="function"))throw new Error(`Mandatory prop "refreshFunction" missing. + Pull Down To Refresh functionality will not work + as expected. Check README.md for usage'`)},i.prototype.componentWillUnmount=function(){this.el&&(this.el.removeEventListener("scroll",this.throttledOnScrollListener),this.props.pullDownToRefresh&&(this.el.removeEventListener("touchstart",this.onStart),this.el.removeEventListener("touchmove",this.onMove),this.el.removeEventListener("touchend",this.onEnd),this.el.removeEventListener("mousedown",this.onStart),this.el.removeEventListener("mousemove",this.onMove),this.el.removeEventListener("mouseup",this.onEnd)))},i.prototype.componentDidUpdate=function(s){this.props.dataLength!==s.dataLength&&(this.actionTriggered=!1,this.setState({showLoader:!1}))},i.getDerivedStateFromProps=function(s,l){var c=s.dataLength!==l.prevDataLength;return c?To(To({},l),{prevDataLength:s.dataLength}):null},i.prototype.isElementAtTop=function(s,l){l===void 0&&(l=.8);var c=s===document.body||s===document.documentElement?window.screen.availHeight:s.clientHeight,d=fp(l);return d.unit===Sr.Pixel?s.scrollTop<=d.value+c-s.scrollHeight+1:s.scrollTop<=d.value/100+c-s.scrollHeight+1},i.prototype.isElementAtBottom=function(s,l){l===void 0&&(l=.8);var c=s===document.body||s===document.documentElement?window.screen.availHeight:s.clientHeight,d=fp(l);return d.unit===Sr.Pixel?s.scrollTop+c>=s.scrollHeight-d.value:s.scrollTop+c>=d.value/100*s.scrollHeight},i.prototype.render=function(){var s=this,l=To({height:this.props.height||"auto",overflow:"auto",WebkitOverflowScrolling:"touch"},this.props.style),c=this.props.hasChildren||!!(this.props.children&&this.props.children instanceof Array&&this.props.children.length),d=this.props.pullDownToRefresh&&this.props.height?{overflow:"auto"}:{};return xt.createElement("div",{style:d,className:"infinite-scroll-component__outerdiv"},xt.createElement("div",{className:"infinite-scroll-component "+(this.props.className||""),ref:function(p){return s._infScroll=p},style:l},this.props.pullDownToRefresh&&xt.createElement("div",{style:{position:"relative"},ref:function(p){return s._pullDown=p}},xt.createElement("div",{style:{position:"absolute",left:0,right:0,top:-1*this.maxPullDownDistance}},this.state.pullToRefreshThresholdBreached?this.props.releaseToRefreshContent:this.props.pullDownToRefreshContent)),this.props.children,!this.state.showLoader&&!c&&this.props.hasMore&&this.props.loader,this.state.showLoader&&this.props.hasMore&&this.props.loader,!this.props.hasMore&&this.props.endMessage))},i}(K.Component);const S1=r=>r<1024?r+" B":r<1024*1024?(r/1024).toFixed(2)+" KB":r<1024*1024*1024?(r/(1024*1024)).toFixed(2)+" MB":(r/(1024*1024*1024)).toFixed(2)+" GB";function C1({channel:r}){const{messages:i,fetchMessages:s,loadMoreMessages:l,pagination:c,startPolling:d,stopPolling:p,updateMessage:g,deleteMessage:w}=Ch(),{binaryContents:v,fetchBinaryContent:S}=An(),{currentUser:j}=pt(),[R,L]=K.useState(null),[T,O]=K.useState(null),[_,b]=K.useState("");K.useEffect(()=>{if(r!=null&&r.id)return s(r.id,null),d(r.id),()=>{p(r.id)}},[r==null?void 0:r.id,s,d,p]),K.useEffect(()=>{i.forEach(ie=>{var de;(de=ie.attachments)==null||de.forEach(me=>{v[me.id]||S(me.id)})})},[i,v,S]),K.useEffect(()=>{const ie=()=>{R&&L(null)};if(R)return document.addEventListener("click",ie),()=>document.removeEventListener("click",ie)},[R]);const U=async ie=>{try{const{url:de,fileName:me}=ie,_e=document.createElement("a");_e.href=de,_e.download=me,_e.style.display="none",document.body.appendChild(_e);try{const Ue=await(await window.showSaveFilePicker({suggestedName:ie.fileName,types:[{description:"Files",accept:{"*/*":[".txt",".pdf",".doc",".docx",".xls",".xlsx",".jpg",".jpeg",".png",".gif"]}}]})).createWritable(),Y=await(await fetch(de)).blob();await Ue.write(Y),await Ue.close()}catch(Se){Se.name!=="AbortError"&&_e.click()}document.body.removeChild(_e),window.URL.revokeObjectURL(de)}catch(de){console.error("파일 다운로드 실패:",de)}},B=ie=>ie!=null&&ie.length?ie.map(de=>{const me=v[de.id];return me?me.contentType.startsWith("image/")?h.jsx(lp,{children:h.jsx(Kx,{href:"#",onClick:Se=>{Se.preventDefault(),U(me)},children:h.jsx("img",{src:me.url,alt:me.fileName})})},me.url):h.jsx(lp,{children:h.jsxs(Xx,{href:"#",onClick:Se=>{Se.preventDefault(),U(me)},children:[h.jsx(Jx,{children:h.jsxs("svg",{width:"40",height:"40",viewBox:"0 0 40 40",fill:"none",children:[h.jsx("path",{d:"M8 3C8 1.89543 8.89543 1 10 1H22L32 11V37C32 38.1046 31.1046 39 30 39H10C8.89543 39 8 38.1046 8 37V3Z",fill:"#0B93F6",fillOpacity:"0.1"}),h.jsx("path",{d:"M22 1L32 11H24C22.8954 11 22 10.1046 22 9V1Z",fill:"#0B93F6",fillOpacity:"0.3"}),h.jsx("path",{d:"M13 19H27M13 25H27M13 31H27",stroke:"#0B93F6",strokeWidth:"2",strokeLinecap:"round"})]})}),h.jsxs(Zx,{children:[h.jsx(e1,{children:me.fileName}),h.jsx(t1,{children:S1(me.size)})]})]})},me.url):null}):null,W=ie=>new Date(ie).toLocaleTimeString(),I=()=>{r!=null&&r.id&&l(r.id)},M=ie=>{L(R===ie?null:ie)},V=ie=>{L(null);const de=i.find(me=>me.id===ie);de&&(O(ie),b(de.content))},ne=ie=>{g(ie,_).catch(de=>{console.error("메시지 수정 실패:",de),Oo.emit("api-error",{error:de,alert:!0})}),O(null),b("")},ye=()=>{O(null),b("")},Ie=ie=>{L(null),w(ie)};return h.jsx(Bx,{children:h.jsx("div",{id:"scrollableDiv",style:{height:"100%",overflow:"auto",display:"flex",flexDirection:"column-reverse"},children:h.jsx(w1,{dataLength:i.length,next:I,hasMore:c.hasNext,loader:h.jsx("h4",{style:{textAlign:"center"},children:"메시지를 불러오는 중..."}),scrollableTarget:"scrollableDiv",style:{display:"flex",flexDirection:"column-reverse"},inverse:!0,endMessage:h.jsx("p",{style:{textAlign:"center"},children:h.jsx("b",{children:c.nextCursor!==null?"모든 메시지를 불러왔습니다":""})}),children:h.jsx(bx,{children:[...i].reverse().map(ie=>{var _e;const de=ie.author,me=j&&de&&de.id===j.id;return h.jsxs(wh,{children:[h.jsx(Ux,{children:h.jsx(nn,{src:de&&de.profile?(_e=v[de.profile.id])==null?void 0:_e.url:St,alt:de&&de.username||"알 수 없음"})}),h.jsxs("div",{children:[h.jsxs(Hx,{children:[h.jsx(Vx,{children:de&&de.username||"알 수 없음"}),h.jsx(Wx,{children:W(ie.createdAt)}),me&&h.jsxs(s1,{children:[h.jsx(l1,{onClick:Se=>{Se.stopPropagation(),M(ie.id)},children:"⋯"}),R===ie.id&&h.jsxs(a1,{onClick:Se=>Se.stopPropagation(),children:[h.jsx(up,{onClick:()=>V(ie.id),children:"✏️ 수정"}),h.jsx(up,{onClick:()=>Ie(ie.id),children:"🗑️ 삭제"})]})]})]}),T===ie.id?h.jsxs(u1,{children:[h.jsx(c1,{value:_,onChange:Se=>b(Se.target.value),onKeyDown:Se=>{Se.key==="Escape"?ye():Se.key==="Enter"&&(Se.ctrlKey||Se.metaKey)&&(Se.preventDefault(),ne(ie.id))},placeholder:"메시지를 입력하세요..."}),h.jsxs(d1,{children:[h.jsx(cp,{variant:"secondary",onClick:ye,children:"취소"}),h.jsx(cp,{variant:"primary",onClick:()=>ne(ie.id),children:"저장"})]})]}):h.jsx(Yx,{children:ie.content}),B(ie.attachments)]})]},ie.id)})})})})})}function k1({channel:r}){return r?h.jsxs(Px,{children:[h.jsx(f1,{channel:r}),h.jsx(C1,{channel:r}),h.jsx(y1,{channel:r})]}):h.jsx(_x,{children:h.jsxs(Nx,{children:[h.jsx(Ox,{children:"👋"}),h.jsx(Mx,{children:"채널을 선택해주세요"}),h.jsxs(Lx,{children:["왼쪽의 채널 목록에서 채널을 선택하여",h.jsx("br",{}),"대화를 시작하세요."]})]})})}function E1(r,i="yyyy-MM-dd HH:mm:ss"){if(!r||!(r instanceof Date)||isNaN(r.getTime()))return"";const s=r.getFullYear(),l=String(r.getMonth()+1).padStart(2,"0"),c=String(r.getDate()).padStart(2,"0"),d=String(r.getHours()).padStart(2,"0"),p=String(r.getMinutes()).padStart(2,"0"),g=String(r.getSeconds()).padStart(2,"0");return i.replace("yyyy",s.toString()).replace("MM",l).replace("dd",c).replace("HH",d).replace("mm",p).replace("ss",g)}const j1=k.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,A1=k.div` + background: ${({theme:r})=>r.colors.background.primary}; + border-radius: 8px; + width: 500px; + max-width: 90%; + padding: 24px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +`,R1=k.div` + display: flex; + align-items: center; + margin-bottom: 16px; +`,P1=k.div` + color: ${({theme:r})=>r.colors.status.error}; + font-size: 24px; + margin-right: 12px; +`,T1=k.h3` + color: ${({theme:r})=>r.colors.text.primary}; + margin: 0; + font-size: 18px; +`,_1=k.div` + background: ${({theme:r})=>r.colors.background.tertiary}; + color: ${({theme:r})=>r.colors.text.muted}; + padding: 2px 8px; + border-radius: 4px; + font-size: 14px; + margin-left: auto; +`,N1=k.p` + color: ${({theme:r})=>r.colors.text.secondary}; + margin-bottom: 20px; + line-height: 1.5; + font-weight: 500; +`,O1=k.div` + margin-bottom: 20px; + background: ${({theme:r})=>r.colors.background.secondary}; + border-radius: 6px; + padding: 12px; +`,wo=k.div` + display: flex; + margin-bottom: 8px; + font-size: 14px; +`,So=k.span` + color: ${({theme:r})=>r.colors.text.muted}; + min-width: 100px; +`,Co=k.span` + color: ${({theme:r})=>r.colors.text.secondary}; + word-break: break-word; +`,M1=k.button` + background: ${({theme:r})=>r.colors.brand.primary}; + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + width: 100%; + + &:hover { + background: ${({theme:r})=>r.colors.brand.hover}; + } +`;function L1({isOpen:r,onClose:i,error:s}){var R,L;if(!r)return null;console.log({error:s});const l=(R=s==null?void 0:s.response)==null?void 0:R.data,c=(l==null?void 0:l.status)||((L=s==null?void 0:s.response)==null?void 0:L.status)||"오류",d=(l==null?void 0:l.code)||"",p=(l==null?void 0:l.message)||(s==null?void 0:s.message)||"알 수 없는 오류가 발생했습니다.",g=l!=null&&l.timestamp?new Date(l.timestamp):new Date,w=E1(g),v=(l==null?void 0:l.exceptionType)||"",S=(l==null?void 0:l.details)||{},j=(l==null?void 0:l.requestId)||"";return h.jsx(j1,{onClick:i,children:h.jsxs(A1,{onClick:T=>T.stopPropagation(),children:[h.jsxs(R1,{children:[h.jsx(P1,{children:"⚠️"}),h.jsx(T1,{children:"오류가 발생했습니다"}),h.jsxs(_1,{children:[c,d?` (${d})`:""]})]}),h.jsx(N1,{children:p}),h.jsxs(O1,{children:[h.jsxs(wo,{children:[h.jsx(So,{children:"시간:"}),h.jsx(Co,{children:w})]}),j&&h.jsxs(wo,{children:[h.jsx(So,{children:"요청 ID:"}),h.jsx(Co,{children:j})]}),d&&h.jsxs(wo,{children:[h.jsx(So,{children:"에러 코드:"}),h.jsx(Co,{children:d})]}),v&&h.jsxs(wo,{children:[h.jsx(So,{children:"예외 유형:"}),h.jsx(Co,{children:v})]}),Object.keys(S).length>0&&h.jsxs(wo,{children:[h.jsx(So,{children:"상세 정보:"}),h.jsx(Co,{children:Object.entries(S).map(([T,O])=>h.jsxs("div",{children:[T,": ",String(O)]},T))})]})]}),h.jsx(M1,{onClick:i,children:"확인"})]})})}const I1=k.div` + width: 240px; + background: ${q.colors.background.secondary}; + border-left: 1px solid ${q.colors.border.primary}; +`,D1=k.div` + padding: 16px; + font-size: 14px; + font-weight: bold; + color: ${q.colors.text.muted}; + text-transform: uppercase; +`,z1=k.div` + padding: 8px 16px; + display: flex; + align-items: center; + color: ${q.colors.text.muted}; + &:hover { + background: ${q.colors.background.primary}; + cursor: pointer; + } +`,$1=k(Nr)` + margin-right: 12px; +`;k(nn)``;const F1=k.div` + display: flex; + align-items: center; +`;function B1({member:r}){var l,c,d;const{binaryContents:i,fetchBinaryContent:s}=An();return K.useEffect(()=>{var p;(p=r.profile)!=null&&p.id&&!i[r.profile.id]&&s(r.profile.id)},[(l=r.profile)==null?void 0:l.id,i,s]),h.jsxs(z1,{children:[h.jsxs($1,{children:[h.jsx(nn,{src:(c=r.profile)!=null&&c.id&&((d=i[r.profile.id])==null?void 0:d.url)||St,alt:r.username}),h.jsx(Do,{$online:r.online})]}),h.jsx(F1,{children:r.username})]})}function b1({member:r,onClose:i}){var L,T,O;const{binaryContents:s,fetchBinaryContent:l}=An(),{currentUser:c,updateUserRole:d}=pt(),[p,g]=K.useState(r.role),[w,v]=K.useState(!1);K.useEffect(()=>{var _;(_=r.profile)!=null&&_.id&&!s[r.profile.id]&&l(r.profile.id)},[(L=r.profile)==null?void 0:L.id,s,l]);const S={[En.USER]:{name:"사용자",color:"#2ed573"},[En.CHANNEL_MANAGER]:{name:"채널 관리자",color:"#ff4757"},[En.ADMIN]:{name:"어드민",color:"#0097e6"}},j=_=>{g(_),v(!0)},R=()=>{d(r.id,p),v(!1)};return h.jsx(V1,{onClick:i,children:h.jsxs(W1,{onClick:_=>_.stopPropagation(),children:[h.jsx("h2",{children:"사용자 정보"}),h.jsxs(Y1,{children:[h.jsx(q1,{src:(T=r.profile)!=null&&T.id&&((O=s[r.profile.id])==null?void 0:O.url)||St,alt:r.username}),h.jsx(Q1,{children:r.username}),h.jsx(G1,{children:r.email}),h.jsx(K1,{$online:r.online,children:r.online?"온라인":"오프라인"}),(c==null?void 0:c.role)===En.ADMIN?h.jsx(H1,{value:p,onChange:_=>j(_.target.value),children:Object.entries(S).map(([_,b])=>h.jsx("option",{value:_,style:{marginTop:"8px",textAlign:"center"},children:b.name},_))}):h.jsx(U1,{style:{backgroundColor:S[r.role].color},children:S[r.role].name})]}),h.jsx(X1,{children:(c==null?void 0:c.role)===En.ADMIN&&w&&h.jsx(J1,{onClick:R,disabled:!w,$secondary:!w,children:"저장"})})]})})}const U1=k.div` + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + color: white; + margin-top: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + letter-spacing: 0.3px; +`,H1=k.select` + padding: 10px 16px; + border-radius: 8px; + border: 1.5px solid ${q.colors.border.primary}; + background: ${q.colors.background.primary}; + color: ${q.colors.text.primary}; + font-size: 14px; + width: 140px; + cursor: pointer; + transition: all 0.2s ease; + margin-top: 12px; + font-weight: 500; + + &:hover { + border-color: ${q.colors.brand.primary}; + } + + &:focus { + outline: none; + border-color: ${q.colors.brand.primary}; + box-shadow: 0 0 0 2px ${q.colors.brand.primary}20; + } + + option { + background: ${q.colors.background.primary}; + color: ${q.colors.text.primary}; + padding: 12px; + } +`,V1=k.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,W1=k.div` + background: ${q.colors.background.secondary}; + padding: 40px; + border-radius: 16px; + width: 100%; + max-width: 420px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + + h2 { + color: ${q.colors.text.primary}; + margin-bottom: 32px; + text-align: center; + font-size: 26px; + font-weight: 600; + letter-spacing: -0.5px; + } +`,Y1=k.div` + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 32px; + padding: 24px; + background: ${q.colors.background.primary}; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +`,q1=k.img` + width: 140px; + height: 140px; + border-radius: 50%; + margin-bottom: 20px; + object-fit: cover; + border: 4px solid ${q.colors.background.secondary}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +`,Q1=k.div` + font-size: 22px; + font-weight: 600; + color: ${q.colors.text.primary}; + margin-bottom: 8px; + letter-spacing: -0.3px; +`,G1=k.div` + font-size: 14px; + color: ${q.colors.text.muted}; + margin-bottom: 16px; + font-weight: 500; +`,K1=k.div` + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + background-color: ${({$online:r,theme:i})=>r?i.colors.status.online:i.colors.status.offline}; + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + letter-spacing: 0.3px; +`,X1=k.div` + display: flex; + gap: 12px; + margin-top: 24px; +`,J1=k.button` + width: 100%; + padding: 12px; + border: none; + border-radius: 8px; + background: ${({$secondary:r,theme:i})=>r?"transparent":i.colors.brand.primary}; + color: ${({$secondary:r,theme:i})=>r?i.colors.text.primary:"white"}; + cursor: pointer; + font-weight: 600; + font-size: 15px; + transition: all 0.2s ease; + border: ${({$secondary:r,theme:i})=>r?`1.5px solid ${i.colors.border.primary}`:"none"}; + + &:hover { + background: ${({$secondary:r,theme:i})=>r?i.colors.background.hover:i.colors.brand.hover}; + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } +`;function Z1(){const r=Ar(p=>p.users),i=Ar(p=>p.fetchUsers),{currentUser:s}=pt(),[l,c]=K.useState(null);K.useEffect(()=>{i()},[i]);const d=[...r].sort((p,g)=>p.id===(s==null?void 0:s.id)?-1:g.id===(s==null?void 0:s.id)?1:p.online&&!g.online?-1:!p.online&&g.online?1:p.username.localeCompare(g.username));return h.jsxs(I1,{children:[h.jsxs(D1,{children:["멤버 목록 - ",r.length]}),d.map(p=>h.jsx("div",{onClick:()=>c(p),children:h.jsx(B1,{member:p},p.id)},p.id)),l&&h.jsx(b1,{member:l,onClose:()=>c(null)})]})}function ew(){const{logout:r,fetchCsrfToken:i,fetchMe:s}=pt(),{fetchUsers:l}=Ar(),[c,d]=K.useState(null),[p,g]=K.useState(null),[w,v]=K.useState(!1),[S,j]=K.useState(!0),{currentUser:R}=pt();K.useEffect(()=>{i(),s()},[]),K.useEffect(()=>{(async()=>{try{if(R)try{await l()}catch(O){console.warn("사용자 상태 업데이트 실패. 로그아웃합니다.",O),r()}}catch(O){console.error("초기화 오류:",O)}finally{j(!1)}})()},[R,l,r]),K.useEffect(()=>{const T=U=>{U!=null&&U.error&&g(U.error),U!=null&&U.alert&&v(!0)},O=()=>{r()},_=Oo.on("api-error",T),b=Oo.on("auth-error",O);return()=>{_("api-error",T),b("auth-error",O)}},[r]),K.useEffect(()=>{if(R){const T=setInterval(()=>{l()},6e4);return()=>{clearInterval(T)}}},[R,l]);const L=()=>{v(!1),g(null)};return S?h.jsx(Tf,{theme:q,children:h.jsx(nw,{children:h.jsx(rw,{})})}):h.jsxs(Tf,{theme:q,children:[R?h.jsxs(tw,{children:[h.jsx(Ax,{currentUser:R,activeChannel:c,onChannelSelect:d}),h.jsx(k1,{channel:c}),h.jsx(Z1,{})]}):h.jsx(Ov,{isOpen:!0,onClose:()=>{}}),h.jsx(L1,{isOpen:w,onClose:L,error:p})]})}const tw=k.div` + display: flex; + height: 100vh; + width: 100vw; + position: relative; +`,nw=k.div` + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + background-color: ${({theme:r})=>r.colors.background.primary}; +`,rw=k.div` + width: 40px; + height: 40px; + border: 4px solid ${({theme:r})=>r.colors.background.tertiary}; + border-top: 4px solid ${({theme:r})=>r.colors.brand.primary}; + border-radius: 50%; + animation: spin 1s linear infinite; + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } +`,kh=document.getElementById("root");if(!kh)throw new Error("Root element not found");_g.createRoot(kh).render(h.jsx(K.StrictMode,{children:h.jsx(ew,{})})); diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..479bed6a3da0a8dbdd08a51d81b30e4d4fabae89 GIT binary patch literal 1588 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyacIC_6YK2V5m}KU}$JzVE6?TYIwoGP-?)y@G60U!Dv>Mu*Du8ycRt4Yw>0&$ytddU zdTHwA$vlU)7;*ZQn^d>r9eiw}SEV3v&DP3PpZVm?c2D=&D? zJg+7dT;x9cg;(mDqrovi2QemjySudY+_R1aaySb-B8!2p69!>MhFNnYfC{QST^vI! zPM@6=9?WDY()wLtM|S>=KoQ44K~Zk4us5=<8xs!eeY>~&=ly4!jD%AXj+wvro>aU~ zrMO$=?`j4U&ZyW$Je*!Zo0>H2RZVqmn^V&mZ(9Dkv!~|IuDF1RBN|EPJE zX3ok)rzF<3&vZKWEj4ag73&t}uJvVk^<~M;*V0n54#8@&v!WGjE_hAaeAZEF z$~V4aF>{^dUc7o%=f8f9m%*2vzjfI@vJ2Z97)VU5x-s2*r@e{H>FEn3A3Dr3G&8U| z)>wFiQO&|Yl6}UkXAQ>%q$jNWac-tTL*)AEyto|onkmnmcJLf?71w_<>4WODmBMxF zwGM7``txcQgT`x>(tH-DrT2Kg=4LzpNv>|+a@TgYDZ`5^$KJVb`K=%k^tRpoxP|4? zwXb!O5~dXYKYt*j(YSx+#_rP{TNcK=40T|)+k3s|?t||EQTgwGgs{E0Y+(QPL&Wx4 zMP23By&sn`zn7oCQQLp%-(Axm|M=5-u;TlFiTn5B^PWnb%fAPV8r2flh?11Vl2ohY zqEsNoU}Ruqple{LYiJr`U}|M-Vr62aZD3$!V6dZTmJ5o8-29Zxv`X9>PU+TH>UWRL)v7?M$%n`C9>lAm0fo0?Z*WfcHaTFhX${Qqu! zG&Nv5t*kOqGt)Cl7z{0q_!){?fojB&%z>&2&rB)F04ce=Mv()kL=s7fZ)R?4No7GQ z1K3si1$pWAo5K9i%<&BYs$wuSHMcY{Gc&O;(${(hEL0izk<1CstV(4taB`Zm$nFhL zDhx>~G{}=7Ei)$-=zaa%ypo*!bp5o%vdrZCykdPs#ORw@rkW)uCz=~4Cz={1nkQNs oC7PHSBpVtgnwc6|q*&+yb?5=zccWrGsMu%lboFyt=akR{0N~++#sB~S literal 0 HcmV?d00001 diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 000000000..1c6311fc0 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,26 @@ + + + + + + Discodeit + + + + + +
+ + diff --git a/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java new file mode 100644 index 000000000..d23a59e59 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java @@ -0,0 +1,121 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.exception.user.InvalidCredentialsException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.service.AuthService; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(AuthController.class) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private AuthService authService; + + @Test + @DisplayName("로그인 성공 테스트") + void login_Success() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "testuser", + "Password1!" + ); + + UUID userId = UUID.randomUUID(); + UserDto loggedInUser = new UserDto( + userId, + "testuser", + "test@example.com", + null, + true + ); + + given(authService.login(any(LoginRequest.class))).willReturn(loggedInUser); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.online").value(true)); + } + + @Test + @DisplayName("로그인 실패 테스트 - 존재하지 않는 사용자") + void login_Failure_UserNotFound() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "nonexistentuser", + "Password1!" + ); + + given(authService.login(any(LoginRequest.class))) + .willThrow(UserNotFoundException.withUsername("nonexistentuser")); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("로그인 실패 테스트 - 잘못된 비밀번호") + void login_Failure_InvalidCredentials() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "testuser", + "WrongPassword1!" + ); + + given(authService.login(any(LoginRequest.class))) + .willThrow(InvalidCredentialsException.wrongPassword()); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("로그인 실패 테스트 - 유효하지 않은 요청") + void login_Failure_InvalidRequest() throws Exception { + // Given + LoginRequest invalidRequest = new LoginRequest( + "", // 사용자 이름 비어있음 (NotBlank 위반) + "" // 비밀번호 비어있음 (NotBlank 위반) + ); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java new file mode 100644 index 000000000..a8451d370 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java @@ -0,0 +1,149 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(BinaryContentController.class) +class BinaryContentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private BinaryContentService binaryContentService; + + @MockitoBean + private BinaryContentStorage binaryContentStorage; + + @Test + @DisplayName("바이너리 컨텐츠 조회 성공 테스트") + void find_Success() throws Exception { + // Given + UUID binaryContentId = UUID.randomUUID(); + BinaryContentDto binaryContent = new BinaryContentDto( + binaryContentId, + "test.jpg", + 10240L, + MediaType.IMAGE_JPEG_VALUE + ); + + given(binaryContentService.find(binaryContentId)).willReturn(binaryContent); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", binaryContentId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(binaryContentId.toString())) + .andExpect(jsonPath("$.fileName").value("test.jpg")) + .andExpect(jsonPath("$.size").value(10240)) + .andExpect(jsonPath("$.contentType").value(MediaType.IMAGE_JPEG_VALUE)); + } + + @Test + @DisplayName("바이너리 컨텐츠 조회 실패 테스트 - 존재하지 않는 컨텐츠") + void find_Failure_BinaryContentNotFound() throws Exception { + // Given + UUID nonExistentId = UUID.randomUUID(); + + given(binaryContentService.find(nonExistentId)) + .willThrow(BinaryContentNotFoundException.withId(nonExistentId)); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", nonExistentId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("ID 목록으로 바이너리 컨텐츠 조회 성공 테스트") + void findAllByIdIn_Success() throws Exception { + // Given + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + + List binaryContentIds = List.of(id1, id2); + + List binaryContents = List.of( + new BinaryContentDto(id1, "test1.jpg", 10240L, MediaType.IMAGE_JPEG_VALUE), + new BinaryContentDto(id2, "test2.pdf", 20480L, MediaType.APPLICATION_PDF_VALUE) + ); + + given(binaryContentService.findAllByIdIn(binaryContentIds)).willReturn(binaryContents); + + // When & Then + mockMvc.perform(get("/api/binaryContents") + .param("binaryContentIds", id1.toString(), id2.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(id1.toString())) + .andExpect(jsonPath("$[0].fileName").value("test1.jpg")) + .andExpect(jsonPath("$[1].id").value(id2.toString())) + .andExpect(jsonPath("$[1].fileName").value("test2.pdf")); + } + + @Test + @DisplayName("바이너리 컨텐츠 다운로드 성공 테스트") + void download_Success() throws Exception { + // Given + UUID binaryContentId = UUID.randomUUID(); + BinaryContentDto binaryContent = new BinaryContentDto( + binaryContentId, + "test.jpg", + 10240L, + MediaType.IMAGE_JPEG_VALUE + ); + + given(binaryContentService.find(binaryContentId)).willReturn(binaryContent); + + // doReturn 사용하여 타입 문제 우회 + ResponseEntity mockResponse = ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"test.jpg\"") + .header(HttpHeaders.CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE) + .body(new ByteArrayResource("test data".getBytes())); + + doReturn(mockResponse).when(binaryContentStorage).download(any(BinaryContentDto.class)); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", binaryContentId)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("바이너리 컨텐츠 다운로드 실패 테스트 - 존재하지 않는 컨텐츠") + void download_Failure_BinaryContentNotFound() throws Exception { + // Given + UUID nonExistentId = UUID.randomUUID(); + + given(binaryContentService.find(nonExistentId)) + .willThrow(BinaryContentNotFoundException.withId(nonExistentId)); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", nonExistentId)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java new file mode 100644 index 000000000..facad5130 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java @@ -0,0 +1,274 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.service.ChannelService; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ChannelController.class) +class ChannelControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ChannelService channelService; + + @Test + @DisplayName("공개 채널 생성 성공 테스트") + void createPublicChannel_Success() throws Exception { + // Given + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "test-channel", + "채널 설명입니다." + ); + + UUID channelId = UUID.randomUUID(); + ChannelDto createdChannel = new ChannelDto( + channelId, + ChannelType.PUBLIC, + "test-channel", + "채널 설명입니다.", + new ArrayList<>(), + Instant.now() + ); + + given(channelService.create(any(PublicChannelCreateRequest.class))) + .willReturn(createdChannel); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.type").value("PUBLIC")) + .andExpect(jsonPath("$.name").value("test-channel")) + .andExpect(jsonPath("$.description").value("채널 설명입니다.")); + } + + @Test + @DisplayName("공개 채널 생성 실패 테스트 - 유효하지 않은 요청") + void createPublicChannel_Failure_InvalidRequest() throws Exception { + // Given + PublicChannelCreateRequest invalidRequest = new PublicChannelCreateRequest( + "a", // 최소 길이 위반 (2자 이상이어야 함) + "채널 설명은 최대 255자까지 가능합니다.".repeat(10) // 최대 길이 위반 + ); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비공개 채널 생성 성공 테스트") + void createPrivateChannel_Success() throws Exception { + // Given + List participantIds = List.of(UUID.randomUUID(), UUID.randomUUID()); + PrivateChannelCreateRequest createRequest = new PrivateChannelCreateRequest(participantIds); + + UUID channelId = UUID.randomUUID(); + List participants = new ArrayList<>(); + for (UUID userId : participantIds) { + participants.add(new UserDto(userId, "user-" + userId.toString().substring(0, 5), + "user" + userId.toString().substring(0, 5) + "@example.com", null, false)); + } + + ChannelDto createdChannel = new ChannelDto( + channelId, + ChannelType.PRIVATE, + null, + null, + participants, + Instant.now() + ); + + given(channelService.create(any(PrivateChannelCreateRequest.class))) + .willReturn(createdChannel); + + // When & Then + mockMvc.perform(post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.type").value("PRIVATE")) + .andExpect(jsonPath("$.participants").isArray()) + .andExpect(jsonPath("$.participants.length()").value(2)); + } + + @Test + @DisplayName("공개 채널 업데이트 성공 테스트") + void updateChannel_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "updated-channel", + "업데이트된 채널 설명입니다." + ); + + ChannelDto updatedChannel = new ChannelDto( + channelId, + ChannelType.PUBLIC, + "updated-channel", + "업데이트된 채널 설명입니다.", + new ArrayList<>(), + Instant.now() + ); + + given(channelService.update(eq(channelId), any(PublicChannelUpdateRequest.class))) + .willReturn(updatedChannel); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.name").value("updated-channel")) + .andExpect(jsonPath("$.description").value("업데이트된 채널 설명입니다.")); + } + + @Test + @DisplayName("채널 업데이트 실패 테스트 - 존재하지 않는 채널") + void updateChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "updated-channel", + "업데이트된 채널 설명입니다." + ); + + given(channelService.update(eq(nonExistentChannelId), any(PublicChannelUpdateRequest.class))) + .willThrow(ChannelNotFoundException.withId(nonExistentChannelId)); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", nonExistentChannelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널 업데이트 실패 테스트 - 비공개 채널 업데이트 시도") + void updateChannel_Failure_PrivateChannelUpdate() throws Exception { + // Given + UUID privateChannelId = UUID.randomUUID(); + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "updated-channel", + "업데이트된 채널 설명입니다." + ); + + given(channelService.update(eq(privateChannelId), any(PublicChannelUpdateRequest.class))) + .willThrow(PrivateChannelUpdateException.forChannel(privateChannelId)); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", privateChannelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("채널 삭제 성공 테스트") + void deleteChannel_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + willDoNothing().given(channelService).delete(channelId); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("채널 삭제 실패 테스트 - 존재하지 않는 채널") + void deleteChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + willThrow(ChannelNotFoundException.withId(nonExistentChannelId)) + .given(channelService).delete(nonExistentChannelId); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", nonExistentChannelId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 성공 테스트") + void findAllByUserId_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId1 = UUID.randomUUID(); + UUID channelId2 = UUID.randomUUID(); + + List channels = List.of( + new ChannelDto( + channelId1, + ChannelType.PUBLIC, + "public-channel", + "공개 채널 설명", + new ArrayList<>(), + Instant.now() + ), + new ChannelDto( + channelId2, + ChannelType.PRIVATE, + null, + null, + List.of(new UserDto(userId, "user1", "user1@example.com", null, true)), + Instant.now().minusSeconds(3600) + ) + ); + + given(channelService.findAllByUserId(userId)).willReturn(channels); + + // When & Then + mockMvc.perform(get("/api/channels") + .param("userId", userId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(channelId1.toString())) + .andExpect(jsonPath("$[0].type").value("PUBLIC")) + .andExpect(jsonPath("$[0].name").value("public-channel")) + .andExpect(jsonPath("$[1].id").value(channelId2.toString())) + .andExpect(jsonPath("$[1].type").value("PRIVATE")); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java new file mode 100644 index 000000000..3330e6b08 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java @@ -0,0 +1,304 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.service.MessageService; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(MessageController.class) +class MessageControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private MessageService messageService; + + @Test + @DisplayName("메시지 생성 성공 테스트") + void createMessage_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + MessageCreateRequest createRequest = new MessageCreateRequest( + "안녕하세요, 테스트 메시지입니다.", + channelId, + authorId + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile attachment = new MockMultipartFile( + "attachments", + "test.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test-image".getBytes() + ); + + UUID messageId = UUID.randomUUID(); + Instant now = Instant.now(); + + UserDto author = new UserDto( + authorId, + "testuser", + "test@example.com", + null, + true + ); + + BinaryContentDto attachmentDto = new BinaryContentDto( + UUID.randomUUID(), + "test.jpg", + 10L, + MediaType.IMAGE_JPEG_VALUE + ); + + MessageDto createdMessage = new MessageDto( + messageId, + now, + now, + "안녕하세요, 테스트 메시지입니다.", + channelId, + author, + List.of(attachmentDto) + ); + + given(messageService.create(any(MessageCreateRequest.class), any(List.class))) + .willReturn(createdMessage); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart) + .file(attachment) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(messageId.toString())) + .andExpect(jsonPath("$.content").value("안녕하세요, 테스트 메시지입니다.")) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.author.id").value(authorId.toString())) + .andExpect(jsonPath("$.attachments[0].fileName").value("test.jpg")); + } + + @Test + @DisplayName("메시지 생성 실패 테스트 - 유효하지 않은 요청") + void createMessage_Failure_InvalidRequest() throws Exception { + // Given + MessageCreateRequest invalidRequest = new MessageCreateRequest( + "", // 내용이 비어있음 (NotBlank 위반) + null, // 채널 ID가 비어있음 (NotNull 위반) + null // 작성자 ID가 비어있음 (NotNull 위반) + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("메시지 업데이트 성공 테스트") + void updateMessage_Success() throws Exception { + // Given + UUID messageId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + Instant now = Instant.now(); + + UserDto author = new UserDto( + authorId, + "testuser", + "test@example.com", + null, + true + ); + + MessageDto updatedMessage = new MessageDto( + messageId, + now.minusSeconds(60), + now, + "수정된 메시지 내용입니다.", + channelId, + author, + new ArrayList<>() + ); + + given(messageService.update(eq(messageId), any(MessageUpdateRequest.class))) + .willReturn(updatedMessage); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", messageId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(messageId.toString())) + .andExpect(jsonPath("$.content").value("수정된 메시지 내용입니다.")) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.author.id").value(authorId.toString())); + } + + @Test + @DisplayName("메시지 업데이트 실패 테스트 - 존재하지 않는 메시지") + void updateMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + given(messageService.update(eq(nonExistentMessageId), any(MessageUpdateRequest.class))) + .willThrow(MessageNotFoundException.withId(nonExistentMessageId)); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", nonExistentMessageId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("메시지 삭제 성공 테스트") + void deleteMessage_Success() throws Exception { + // Given + UUID messageId = UUID.randomUUID(); + willDoNothing().given(messageService).delete(messageId); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", messageId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("메시지 삭제 실패 테스트 - 존재하지 않는 메시지") + void deleteMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + willThrow(MessageNotFoundException.withId(nonExistentMessageId)) + .given(messageService).delete(nonExistentMessageId); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", nonExistentMessageId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널별 메시지 목록 조회 성공 테스트") + void findAllByChannelId_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + Instant cursor = Instant.now(); + Pageable pageable = PageRequest.of(0, 50, Sort.Direction.DESC, "createdAt"); + + UserDto author = new UserDto( + authorId, + "testuser", + "test@example.com", + null, + true + ); + + List messages = List.of( + new MessageDto( + UUID.randomUUID(), + cursor.minusSeconds(10), + cursor.minusSeconds(10), + "첫 번째 메시지", + channelId, + author, + new ArrayList<>() + ), + new MessageDto( + UUID.randomUUID(), + cursor.minusSeconds(20), + cursor.minusSeconds(20), + "두 번째 메시지", + channelId, + author, + new ArrayList<>() + ) + ); + + PageResponse pageResponse = new PageResponse<>( + messages, + cursor.minusSeconds(30), // nextCursor 값 + pageable.getPageSize(), + true, // hasNext + (long) messages.size() // totalElements + ); + + given(messageService.findAllByChannelId(eq(channelId), eq(cursor), any(Pageable.class))) + .willReturn(pageResponse); + + // When & Then + mockMvc.perform(get("/api/messages") + .param("channelId", channelId.toString()) + .param("cursor", cursor.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.content[0].content").value("첫 번째 메시지")) + .andExpect(jsonPath("$.content[1].content").value("두 번째 메시지")) + .andExpect(jsonPath("$.nextCursor").exists()) + .andExpect(jsonPath("$.size").value(50)) + .andExpect(jsonPath("$.hasNext").value(true)) + .andExpect(jsonPath("$.totalElements").value(2)); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java new file mode 100644 index 000000000..91772c33c --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java @@ -0,0 +1,172 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.exception.readstatus.ReadStatusNotFoundException; +import com.sprint.mission.discodeit.service.ReadStatusService; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ReadStatusController.class) +class ReadStatusControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ReadStatusService readStatusService; + + @Test + @DisplayName("읽음 상태 생성 성공 테스트") + void create_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + Instant lastReadAt = Instant.now(); + + ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest( + userId, + channelId, + lastReadAt + ); + + UUID readStatusId = UUID.randomUUID(); + ReadStatusDto createdReadStatus = new ReadStatusDto( + readStatusId, + userId, + channelId, + lastReadAt + ); + + given(readStatusService.create(any(ReadStatusCreateRequest.class))) + .willReturn(createdReadStatus); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(readStatusId.toString())) + .andExpect(jsonPath("$.userId").value(userId.toString())) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.lastReadAt").exists()); + } + + @Test + @DisplayName("읽음 상태 생성 실패 테스트 - 유효하지 않은 요청") + void create_Failure_InvalidRequest() throws Exception { + // Given + ReadStatusCreateRequest invalidRequest = new ReadStatusCreateRequest( + null, // userId가 null (NotNull 위반) + null, // channelId가 null (NotNull 위반) + null // lastReadAt이 null (NotNull 위반) + ); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("읽음 상태 업데이트 성공 테스트") + void update_Success() throws Exception { + // Given + UUID readStatusId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + Instant newLastReadAt = Instant.now(); + + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest(newLastReadAt); + + ReadStatusDto updatedReadStatus = new ReadStatusDto( + readStatusId, + userId, + channelId, + newLastReadAt + ); + + given(readStatusService.update(eq(readStatusId), any(ReadStatusUpdateRequest.class))) + .willReturn(updatedReadStatus); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", readStatusId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(readStatusId.toString())) + .andExpect(jsonPath("$.userId").value(userId.toString())) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.lastReadAt").exists()); + } + + @Test + @DisplayName("읽음 상태 업데이트 실패 테스트 - 존재하지 않는 읽음 상태") + void update_Failure_ReadStatusNotFound() throws Exception { + // Given + UUID nonExistentId = UUID.randomUUID(); + Instant newLastReadAt = Instant.now(); + + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest(newLastReadAt); + + given(readStatusService.update(eq(nonExistentId), any(ReadStatusUpdateRequest.class))) + .willThrow(ReadStatusNotFoundException.withId(nonExistentId)); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", nonExistentId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자별 읽음 상태 목록 조회 성공 테스트") + void findAllByUserId_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId1 = UUID.randomUUID(); + UUID channelId2 = UUID.randomUUID(); + Instant now = Instant.now(); + + List readStatuses = List.of( + new ReadStatusDto(UUID.randomUUID(), userId, channelId1, now.minusSeconds(60)), + new ReadStatusDto(UUID.randomUUID(), userId, channelId2, now) + ); + + given(readStatusService.findAllByUserId(userId)).willReturn(readStatuses); + + // When & Then + mockMvc.perform(get("/api/readStatuses") + .param("userId", userId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].userId").value(userId.toString())) + .andExpect(jsonPath("$[0].channelId").value(channelId1.toString())) + .andExpect(jsonPath("$[1].userId").value(userId.toString())) + .andExpect(jsonPath("$[1].channelId").value(channelId2.toString())); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java new file mode 100644 index 000000000..d376362d8 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java @@ -0,0 +1,343 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.service.UserService; +import com.sprint.mission.discodeit.service.UserStatusService; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(UserController.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserService userService; + + @MockitoBean + private UserStatusService userStatusService; + + @Test + @DisplayName("사용자 생성 성공 테스트") + void createUser_Success() throws Exception { + // Given + UserCreateRequest createRequest = new UserCreateRequest( + "testuser", + "test@example.com", + "Password1!" + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test-image".getBytes() + ); + + UUID userId = UUID.randomUUID(); + BinaryContentDto profileDto = new BinaryContentDto( + UUID.randomUUID(), + "profile.jpg", + 12L, + MediaType.IMAGE_JPEG_VALUE + ); + + UserDto createdUser = new UserDto( + userId, + "testuser", + "test@example.com", + profileDto, + false + ); + + given(userService.create(any(UserCreateRequest.class), any(Optional.class))) + .willReturn(createdUser); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.profile.fileName").value("profile.jpg")) + .andExpect(jsonPath("$.online").value(false)); + } + + @Test + @DisplayName("사용자 생성 실패 테스트 - 유효하지 않은 요청") + void createUser_Failure_InvalidRequest() throws Exception { + // Given + UserCreateRequest invalidRequest = new UserCreateRequest( + "t", // 최소 길이 위반 + "invalid-email", // 이메일 형식 위반 + "short" // 비밀번호 정책 위반 + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("사용자 조회 성공 테스트") + void findAllUsers_Success() throws Exception { + // Given + UUID userId1 = UUID.randomUUID(); + UUID userId2 = UUID.randomUUID(); + + UserDto user1 = new UserDto( + userId1, + "user1", + "user1@example.com", + null, + true + ); + + UserDto user2 = new UserDto( + userId2, + "user2", + "user2@example.com", + null, + false + ); + + List users = List.of(user1, user2); + + given(userService.findAll()).willReturn(users); + + // When & Then + mockMvc.perform(get("/api/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(userId1.toString())) + .andExpect(jsonPath("$[0].username").value("user1")) + .andExpect(jsonPath("$[0].online").value(true)) + .andExpect(jsonPath("$[1].id").value(userId2.toString())) + .andExpect(jsonPath("$[1].username").value("user2")) + .andExpect(jsonPath("$[1].online").value(false)); + } + + @Test + @DisplayName("사용자 업데이트 성공 테스트") + void updateUser_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "updated-profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "updated-image".getBytes() + ); + + BinaryContentDto profileDto = new BinaryContentDto( + UUID.randomUUID(), + "updated-profile.jpg", + 14L, + MediaType.IMAGE_JPEG_VALUE + ); + + UserDto updatedUser = new UserDto( + userId, + "updateduser", + "updated@example.com", + profileDto, + true + ); + + given(userService.update(eq(userId), any(UserUpdateRequest.class), any(Optional.class))) + .willReturn(updatedUser); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", userId) + .file(userUpdateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("updateduser")) + .andExpect(jsonPath("$.email").value("updated@example.com")) + .andExpect(jsonPath("$.profile.fileName").value("updated-profile.jpg")) + .andExpect(jsonPath("$.online").value(true)); + } + + @Test + @DisplayName("사용자 업데이트 실패 테스트 - 존재하지 않는 사용자") + void updateUser_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "updated-profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "updated-image".getBytes() + ); + + given(userService.update(eq(nonExistentUserId), any(UserUpdateRequest.class), + any(Optional.class))) + .willThrow(UserNotFoundException.withId(nonExistentUserId)); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", nonExistentUserId) + .file(userUpdateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자 삭제 성공 테스트") + void deleteUser_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + willDoNothing().given(userService).delete(userId); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", userId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("사용자 삭제 실패 테스트 - 존재하지 않는 사용자") + void deleteUser_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + willThrow(UserNotFoundException.withId(nonExistentUserId)) + .given(userService).delete(nonExistentUserId); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", nonExistentUserId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자 상태 업데이트 성공 테스트") + void updateUserStatus_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID statusId = UUID.randomUUID(); + Instant lastActiveAt = Instant.now(); + + UserStatusUpdateRequest updateRequest = new UserStatusUpdateRequest(lastActiveAt); + UserStatusDto updatedStatus = new UserStatusDto(statusId, userId, lastActiveAt); + + given(userStatusService.updateByUserId(eq(userId), any(UserStatusUpdateRequest.class))) + .willReturn(updatedStatus); + + // When & Then + mockMvc.perform(patch("/api/users/{userId}/userStatus", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(statusId.toString())) + .andExpect(jsonPath("$.userId").value(userId.toString())) + .andExpect(content().json(objectMapper.writeValueAsString(updatedStatus))); + } + + @Test + @DisplayName("사용자 상태 업데이트 실패 테스트 - 존재하지 않는 사용자 상태") + void updateUserStatus_Failure_UserStatusNotFound() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + Instant lastActiveAt = Instant.now(); + + UserStatusUpdateRequest updateRequest = new UserStatusUpdateRequest(lastActiveAt); + + given(userStatusService.updateByUserId(eq(userId), any(UserStatusUpdateRequest.class))) + .willThrow(UserNotFoundException.withId(userId)); + + // When & Then + mockMvc.perform(patch("/api/users/{userId}/userStatus", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java new file mode 100644 index 000000000..2dfed05bc --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java @@ -0,0 +1,133 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.UserService; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class AuthApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserService userService; + + @Test + @DisplayName("로그인 API 통합 테스트 - 성공") + void login_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "loginuser", + "login@example.com", + "Password1!" + ); + + userService.create(userRequest, Optional.empty()); + + // 로그인 요청 + LoginRequest loginRequest = new LoginRequest( + "loginuser", + "Password1!" + ); + + String requestBody = objectMapper.writeValueAsString(loginRequest); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.username", is("loginuser"))) + .andExpect(jsonPath("$.email", is("login@example.com"))); + } + + @Test + @DisplayName("로그인 API 통합 테스트 - 실패 (존재하지 않는 사용자)") + void login_Failure_UserNotFound() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "nonexistentuser", + "Password1!" + ); + + String requestBody = objectMapper.writeValueAsString(loginRequest); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("로그인 API 통합 테스트 - 실패 (잘못된 비밀번호)") + void login_Failure_InvalidCredentials() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "loginuser2", + "login2@example.com", + "Password1!" + ); + + userService.create(userRequest, Optional.empty()); + + // 잘못된 비밀번호로 로그인 시도 + LoginRequest loginRequest = new LoginRequest( + "loginuser2", + "WrongPassword1!" + ); + + String requestBody = objectMapper.writeValueAsString(loginRequest); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("로그인 API 통합 테스트 - 실패 (유효하지 않은 요청)") + void login_Failure_InvalidRequest() throws Exception { + // Given + LoginRequest invalidRequest = new LoginRequest( + "", // 사용자 이름 비어있음 (NotBlank 위반) + "" // 비밀번호 비어있음 (NotBlank 위반) + ); + + String requestBody = objectMapper.writeValueAsString(invalidRequest); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java new file mode 100644 index 000000000..871296eaa --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java @@ -0,0 +1,209 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class BinaryContentApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private BinaryContentService binaryContentService; + + @Autowired + private UserService userService; + + @Autowired + private ChannelService channelService; + + @Autowired + private MessageService messageService; + + @Test + @DisplayName("바이너리 컨텐츠 조회 API 통합 테스트") + void findBinaryContent_Success() throws Exception { + // Given + // 테스트 바이너리 컨텐츠 생성 (메시지 첨부파일을 통해 생성) + // 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "contentuser", + "content@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + var channel = channelService.create(channelRequest); + + // 첨부파일이 있는 메시지 생성 + MessageCreateRequest messageRequest = new MessageCreateRequest( + "첨부파일이 있는 메시지입니다.", + channel.id(), + user.id() + ); + + byte[] fileContent = "테스트 파일 내용입니다.".getBytes(); + BinaryContentCreateRequest attachmentRequest = new BinaryContentCreateRequest( + "test.txt", + MediaType.TEXT_PLAIN_VALUE, + fileContent + ); + + MessageDto message = messageService.create(messageRequest, List.of(attachmentRequest)); + UUID binaryContentId = message.attachments().get(0).id(); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", binaryContentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(binaryContentId.toString()))) + .andExpect(jsonPath("$.fileName", is("test.txt"))) + .andExpect(jsonPath("$.contentType", is(MediaType.TEXT_PLAIN_VALUE))) + .andExpect(jsonPath("$.size", is(fileContent.length))); + } + + @Test + @DisplayName("존재하지 않는 바이너리 컨텐츠 조회 API 통합 테스트") + void findBinaryContent_Failure_NotFound() throws Exception { + // Given + UUID nonExistentBinaryContentId = UUID.randomUUID(); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", nonExistentBinaryContentId)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("여러 바이너리 컨텐츠 조회 API 통합 테스트") + void findAllBinaryContentsByIds_Success() throws Exception { + // Given + // 테스트 바이너리 컨텐츠 생성 (메시지 첨부파일을 통해 생성) + UserCreateRequest userRequest = new UserCreateRequest( + "contentuser2", + "content2@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널2", + "테스트 채널 설명입니다." + ); + var channel = channelService.create(channelRequest); + + MessageCreateRequest messageRequest = new MessageCreateRequest( + "첨부파일이 있는 메시지입니다.", + channel.id(), + user.id() + ); + + // 첫 번째 첨부파일 + BinaryContentCreateRequest attachmentRequest1 = new BinaryContentCreateRequest( + "test1.txt", + MediaType.TEXT_PLAIN_VALUE, + "첫 번째 테스트 파일 내용입니다.".getBytes() + ); + + // 두 번째 첨부파일 + BinaryContentCreateRequest attachmentRequest2 = new BinaryContentCreateRequest( + "test2.txt", + MediaType.TEXT_PLAIN_VALUE, + "두 번째 테스트 파일 내용입니다.".getBytes() + ); + + // 첨부파일 두 개를 가진 메시지 생성 + MessageDto message = messageService.create( + messageRequest, + List.of(attachmentRequest1, attachmentRequest2) + ); + + List binaryContentIds = message.attachments().stream() + .map(BinaryContentDto::id) + .toList(); + + // When & Then + mockMvc.perform(get("/api/binaryContents") + .param("binaryContentIds", binaryContentIds.get(0).toString()) + .param("binaryContentIds", binaryContentIds.get(1).toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[*].fileName", hasItems("test1.txt", "test2.txt"))); + } + + @Test + @DisplayName("바이너리 컨텐츠 다운로드 API 통합 테스트") + void downloadBinaryContent_Success() throws Exception { + // Given + String fileContent = "다운로드 테스트 파일 내용입니다."; + BinaryContentCreateRequest createRequest = new BinaryContentCreateRequest( + "download-test.txt", + MediaType.TEXT_PLAIN_VALUE, + fileContent.getBytes() + ); + + BinaryContentDto binaryContent = binaryContentService.create(createRequest); + UUID binaryContentId = binaryContent.id(); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", binaryContentId)) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", + "attachment; filename=\"download-test.txt\"")) + .andExpect(content().contentType(MediaType.TEXT_PLAIN_VALUE)) + .andExpect(content().bytes(fileContent.getBytes())); + } + + @Test + @DisplayName("존재하지 않는 바이너리 컨텐츠 다운로드 API 통합 테스트") + void downloadBinaryContent_Failure_NotFound() throws Exception { + // Given + UUID nonExistentBinaryContentId = UUID.randomUUID(); + + // When & Then + mockMvc.perform( + get("/api/binaryContents/{binaryContentId}/download", nonExistentBinaryContentId)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java new file mode 100644 index 000000000..5917b4d02 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java @@ -0,0 +1,269 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class ChannelApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ChannelService channelService; + + @Autowired + private UserService userService; + + @Test + @DisplayName("공개 채널 생성 API 통합 테스트") + void createPublicChannel_Success() throws Exception { + // Given + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(createRequest); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.type", is(ChannelType.PUBLIC.name()))) + .andExpect(jsonPath("$.name", is("테스트 채널"))) + .andExpect(jsonPath("$.description", is("테스트 채널 설명입니다."))); + } + + @Test + @DisplayName("공개 채널 생성 실패 API 통합 테스트 - 유효하지 않은 요청") + void createPublicChannel_Failure_InvalidRequest() throws Exception { + // Given + PublicChannelCreateRequest invalidRequest = new PublicChannelCreateRequest( + "a", // 최소 길이 위반 + "테스트 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(invalidRequest); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비공개 채널 생성 API 통합 테스트") + void createPrivateChannel_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest1 = new UserCreateRequest( + "user1", + "user1@example.com", + "Password1!" + ); + + UserCreateRequest userRequest2 = new UserCreateRequest( + "user2", + "user2@example.com", + "Password1!" + ); + + UserDto user1 = userService.create(userRequest1, Optional.empty()); + UserDto user2 = userService.create(userRequest2, Optional.empty()); + + List participantIds = List.of(user1.id(), user2.id()); + PrivateChannelCreateRequest createRequest = new PrivateChannelCreateRequest(participantIds); + + String requestBody = objectMapper.writeValueAsString(createRequest); + + // When & Then + mockMvc.perform(post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.type", is(ChannelType.PRIVATE.name()))) + .andExpect(jsonPath("$.participants", hasSize(2))); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 API 통합 테스트") + void findAllChannelsByUserId_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "channeluser", + "channeluser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + UUID userId = user.id(); + + // 공개 채널 생성 + PublicChannelCreateRequest publicChannelRequest = new PublicChannelCreateRequest( + "공개 채널 1", + "공개 채널 설명입니다." + ); + + channelService.create(publicChannelRequest); + + // 비공개 채널 생성 + UserCreateRequest otherUserRequest = new UserCreateRequest( + "otheruser", + "otheruser@example.com", + "Password1!" + ); + + UserDto otherUser = userService.create(otherUserRequest, Optional.empty()); + + PrivateChannelCreateRequest privateChannelRequest = new PrivateChannelCreateRequest( + List.of(userId, otherUser.id()) + ); + + channelService.create(privateChannelRequest); + + // When & Then + mockMvc.perform(get("/api/channels") + .param("userId", userId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].type", is(ChannelType.PUBLIC.name()))) + .andExpect(jsonPath("$[1].type", is(ChannelType.PRIVATE.name()))); + } + + @Test + @DisplayName("채널 업데이트 API 통합 테스트") + void updateChannel_Success() throws Exception { + // Given + // 공개 채널 생성 + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "원본 채널", + "원본 채널 설명입니다." + ); + + ChannelDto createdChannel = channelService.create(createRequest); + UUID channelId = createdChannel.id(); + + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "수정된 채널", + "수정된 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(channelId.toString()))) + .andExpect(jsonPath("$.name", is("수정된 채널"))) + .andExpect(jsonPath("$.description", is("수정된 채널 설명입니다."))); + } + + @Test + @DisplayName("채널 업데이트 실패 API 통합 테스트 - 존재하지 않는 채널") + void updateChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "수정된 채널", + "수정된 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", nonExistentChannelId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널 삭제 API 통합 테스트") + void deleteChannel_Success() throws Exception { + // Given + // 공개 채널 생성 + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "삭제할 채널", + "삭제할 채널 설명입니다." + ); + + ChannelDto createdChannel = channelService.create(createRequest); + UUID channelId = createdChannel.id(); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", channelId)) + .andExpect(status().isNoContent()); + + // 삭제 확인 - 사용자로 채널 조회 시 삭제된 채널은 조회되지 않아야 함 + UserCreateRequest userRequest = new UserCreateRequest( + "testuser", + "testuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + mockMvc.perform(get("/api/channels") + .param("userId", user.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == '" + channelId + "')]").doesNotExist()); + } + + @Test + @DisplayName("채널 삭제 실패 API 통합 테스트 - 존재하지 않는 채널") + void deleteChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", nonExistentChannelId)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java new file mode 100644 index 000000000..092575de3 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java @@ -0,0 +1,307 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.ArrayList; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class MessageApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MessageService messageService; + + @Autowired + private ChannelService channelService; + + @Autowired + private UserService userService; + + @Test + @DisplayName("메시지 생성 API 통합 테스트") + void createMessage_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + // 메시지 생성 요청 + MessageCreateRequest createRequest = new MessageCreateRequest( + "테스트 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile attachmentPart = new MockMultipartFile( + "attachments", + "test.txt", + MediaType.TEXT_PLAIN_VALUE, + "테스트 첨부 파일 내용".getBytes() + ); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart) + .file(attachmentPart)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.content", is("테스트 메시지 내용입니다."))) + .andExpect(jsonPath("$.channelId", is(channel.id().toString()))) + .andExpect(jsonPath("$.author.id", is(user.id().toString()))) + .andExpect(jsonPath("$.attachments", hasSize(1))) + .andExpect(jsonPath("$.attachments[0].fileName", is("test.txt"))); + } + + @Test + @DisplayName("메시지 생성 실패 API 통합 테스트 - 유효하지 않은 요청") + void createMessage_Failure_InvalidRequest() throws Exception { + // Given + MessageCreateRequest invalidRequest = new MessageCreateRequest( + "", // 내용이 비어있음 + UUID.randomUUID(), + UUID.randomUUID() + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("채널별 메시지 목록 조회 API 통합 테스트") + void findAllMessagesByChannelId_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + // 메시지 생성 + MessageCreateRequest messageRequest1 = new MessageCreateRequest( + "첫 번째 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MessageCreateRequest messageRequest2 = new MessageCreateRequest( + "두 번째 메시지 내용입니다.", + channel.id(), + user.id() + ); + + messageService.create(messageRequest1, new ArrayList<>()); + messageService.create(messageRequest2, new ArrayList<>()); + + // When & Then + mockMvc.perform(get("/api/messages") + .param("channelId", channel.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(2))) + .andExpect(jsonPath("$.content[0].content", is("두 번째 메시지 내용입니다."))) + .andExpect(jsonPath("$.content[1].content", is("첫 번째 메시지 내용입니다."))) + .andExpect(jsonPath("$.size").exists()) + .andExpect(jsonPath("$.hasNext").exists()) + .andExpect(jsonPath("$.totalElements").isEmpty()); + } + + @Test + @DisplayName("메시지 업데이트 API 통합 테스트") + void updateMessage_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + // 메시지 생성 + MessageCreateRequest createRequest = new MessageCreateRequest( + "원본 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MessageDto createdMessage = messageService.create(createRequest, new ArrayList<>()); + UUID messageId = createdMessage.id(); + + // 메시지 업데이트 요청 + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", messageId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(messageId.toString()))) + .andExpect(jsonPath("$.content", is("수정된 메시지 내용입니다."))) + .andExpect(jsonPath("$.updatedAt").exists()); + } + + @Test + @DisplayName("메시지 업데이트 실패 API 통합 테스트 - 존재하지 않는 메시지") + void updateMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", nonExistentMessageId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("메시지 삭제 API 통합 테스트") + void deleteMessage_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + // 메시지 생성 + MessageCreateRequest createRequest = new MessageCreateRequest( + "삭제할 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MessageDto createdMessage = messageService.create(createRequest, new ArrayList<>()); + UUID messageId = createdMessage.id(); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", messageId)) + .andExpect(status().isNoContent()); + + // 삭제 확인 - 채널의 메시지 목록 조회 시 삭제된 메시지는 조회되지 않아야 함 + mockMvc.perform(get("/api/messages") + .param("channelId", channel.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(0))); + } + + @Test + @DisplayName("메시지 삭제 실패 API 통합 테스트 - 존재하지 않는 메시지") + void deleteMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", nonExistentMessageId)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java new file mode 100644 index 000000000..8a93c8831 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java @@ -0,0 +1,266 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.ReadStatusService; +import com.sprint.mission.discodeit.service.UserService; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class ReadStatusApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ReadStatusService readStatusService; + + @Autowired + private UserService userService; + + @Autowired + private ChannelService channelService; + + @Test + @DisplayName("읽음 상태 생성 API 통합 테스트") + void createReadStatus_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "readstatususer", + "readstatus@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 공개 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "읽음 상태 테스트 채널", + "읽음 상태 테스트 채널 설명입니다." + ); + ChannelDto channel = channelService.create(channelRequest); + + // 읽음 상태 생성 요청 + Instant lastReadAt = Instant.now(); + ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + lastReadAt + ); + + String requestBody = objectMapper.writeValueAsString(createRequest); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.userId", is(user.id().toString()))) + .andExpect(jsonPath("$.channelId", is(channel.id().toString()))) + .andExpect(jsonPath("$.lastReadAt", is(lastReadAt.toString()))); + } + + @Test + @DisplayName("읽음 상태 생성 실패 API 통합 테스트 - 중복 생성") + void createReadStatus_Failure_Duplicate() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "duplicateuser", + "duplicate@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 공개 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "중복 테스트 채널", + "중복 테스트 채널 설명입니다." + ); + ChannelDto channel = channelService.create(channelRequest); + + // 첫 번째 읽음 상태 생성 요청 (성공) + Instant lastReadAt = Instant.now(); + ReadStatusCreateRequest firstCreateRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + lastReadAt + ); + + String firstRequestBody = objectMapper.writeValueAsString(firstCreateRequest); + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(firstRequestBody)) + .andExpect(status().isCreated()); + + // 두 번째 읽음 상태 생성 요청 (동일 사용자, 동일 채널) - 실패해야 함 + ReadStatusCreateRequest duplicateCreateRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + Instant.now() + ); + + String duplicateRequestBody = objectMapper.writeValueAsString(duplicateCreateRequest); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(duplicateRequestBody)) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("읽음 상태 업데이트 API 통합 테스트") + void updateReadStatus_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "updateuser", + "update@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 공개 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "업데이트 테스트 채널", + "업데이트 테스트 채널 설명입니다." + ); + ChannelDto channel = channelService.create(channelRequest); + + // 읽음 상태 생성 + Instant initialLastReadAt = Instant.now().minusSeconds(3600); // 1시간 전 + ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + initialLastReadAt + ); + + ReadStatusDto createdReadStatus = readStatusService.create(createRequest); + UUID readStatusId = createdReadStatus.id(); + + // 읽음 상태 업데이트 요청 + Instant newLastReadAt = Instant.now(); + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest( + newLastReadAt + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", readStatusId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(readStatusId.toString()))) + .andExpect(jsonPath("$.userId", is(user.id().toString()))) + .andExpect(jsonPath("$.channelId", is(channel.id().toString()))) + .andExpect(jsonPath("$.lastReadAt", is(newLastReadAt.toString()))); + } + + @Test + @DisplayName("읽음 상태 업데이트 실패 API 통합 테스트 - 존재하지 않는 읽음 상태") + void updateReadStatus_Failure_NotFound() throws Exception { + // Given + UUID nonExistentReadStatusId = UUID.randomUUID(); + + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest( + Instant.now() + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", nonExistentReadStatusId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자별 읽음 상태 목록 조회 API 통합 테스트") + void findAllReadStatusesByUserId_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "listuser", + "list@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 여러 채널 생성 + PublicChannelCreateRequest channelRequest1 = new PublicChannelCreateRequest( + "목록 테스트 채널 1", + "목록 테스트 채널 설명입니다." + ); + + PublicChannelCreateRequest channelRequest2 = new PublicChannelCreateRequest( + "목록 테스트 채널 2", + "목록 테스트 채널 설명입니다." + ); + + ChannelDto channel1 = channelService.create(channelRequest1); + ChannelDto channel2 = channelService.create(channelRequest2); + + // 각 채널에 대한 읽음 상태 생성 + ReadStatusCreateRequest createRequest1 = new ReadStatusCreateRequest( + user.id(), + channel1.id(), + Instant.now().minusSeconds(3600) + ); + + ReadStatusCreateRequest createRequest2 = new ReadStatusCreateRequest( + user.id(), + channel2.id(), + Instant.now() + ); + + readStatusService.create(createRequest1); + readStatusService.create(createRequest2); + + // When & Then + mockMvc.perform(get("/api/readStatuses") + .param("userId", user.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[*].channelId", + hasItems(channel1.id().toString(), channel2.id().toString()))); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java new file mode 100644 index 000000000..61d7895bf --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java @@ -0,0 +1,299 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.service.UserService; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class UserApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserService userService; + + + @Test + @DisplayName("사용자 생성 API 통합 테스트") + void createUser_Success() throws Exception { + // Given + UserCreateRequest createRequest = new UserCreateRequest( + "testuser", + "test@example.com", + "Password1!" + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test-image".getBytes() + ); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.username", is("testuser"))) + .andExpect(jsonPath("$.email", is("test@example.com"))) + .andExpect(jsonPath("$.profile.fileName", is("profile.jpg"))) + .andExpect(jsonPath("$.online", is(true))); + } + + @Test + @DisplayName("사용자 생성 실패 API 통합 테스트 - 유효하지 않은 요청") + void createUser_Failure_InvalidRequest() throws Exception { + // Given + UserCreateRequest invalidRequest = new UserCreateRequest( + "t", // 최소 길이 위반 + "invalid-email", // 이메일 형식 위반 + "short" // 비밀번호 정책 위반 + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("모든 사용자 조회 API 통합 테스트") + void findAllUsers_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest userRequest1 = new UserCreateRequest( + "user1", + "user1@example.com", + "Password1!" + ); + + UserCreateRequest userRequest2 = new UserCreateRequest( + "user2", + "user2@example.com", + "Password1!" + ); + + userService.create(userRequest1, Optional.empty()); + userService.create(userRequest2, Optional.empty()); + + // When & Then + mockMvc.perform(get("/api/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].username", is("user1"))) + .andExpect(jsonPath("$[0].email", is("user1@example.com"))) + .andExpect(jsonPath("$[1].username", is("user2"))) + .andExpect(jsonPath("$[1].email", is("user2@example.com"))); + } + + @Test + @DisplayName("사용자 업데이트 API 통합 테스트") + void updateUser_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest createRequest = new UserCreateRequest( + "originaluser", + "original@example.com", + "Password1!" + ); + + UserDto createdUser = userService.create(createRequest, Optional.empty()); + UUID userId = createdUser.id(); + + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "updated-profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "updated-image".getBytes() + ); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", userId) + .file(userUpdateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(userId.toString()))) + .andExpect(jsonPath("$.username", is("updateduser"))) + .andExpect(jsonPath("$.email", is("updated@example.com"))) + .andExpect(jsonPath("$.profile.fileName", is("updated-profile.jpg"))); + } + + @Test + @DisplayName("사용자 업데이트 실패 API 통합 테스트 - 존재하지 않는 사용자") + void updateUser_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", nonExistentUserId) + .file(userUpdateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자 삭제 API 통합 테스트") + void deleteUser_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest createRequest = new UserCreateRequest( + "deleteuser", + "delete@example.com", + "Password1!" + ); + + UserDto createdUser = userService.create(createRequest, Optional.empty()); + UUID userId = createdUser.id(); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", userId)) + .andExpect(status().isNoContent()); + + // 삭제 확인 + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == '" + userId + "')]").doesNotExist()); + } + + @Test + @DisplayName("사용자 삭제 실패 API 통합 테스트 - 존재하지 않는 사용자") + void deleteUser_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", nonExistentUserId)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자 상태 업데이트 API 통합 테스트") + void updateUserStatus_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest createRequest = new UserCreateRequest( + "statususer", + "status@example.com", + "Password1!" + ); + + UserDto createdUser = userService.create(createRequest, Optional.empty()); + UUID userId = createdUser.id(); + + Instant newLastActiveAt = Instant.now(); + UserStatusUpdateRequest statusUpdateRequest = new UserStatusUpdateRequest( + newLastActiveAt + ); + String requestBody = objectMapper.writeValueAsString(statusUpdateRequest); + + // When & Then + mockMvc.perform(patch("/api/users/{userId}/userStatus", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lastActiveAt", is(newLastActiveAt.toString()))); + } + + @Test + @DisplayName("사용자 상태 업데이트 실패 API 통합 테스트 - 존재하지 않는 사용자") + void updateUserStatus_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + UserStatusUpdateRequest statusUpdateRequest = new UserStatusUpdateRequest( + Instant.now() + ); + String requestBody = objectMapper.writeValueAsString(statusUpdateRequest); + + // When & Then + mockMvc.perform(patch("/api/users/{userId}/userStatus", nonExistentUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java new file mode 100644 index 000000000..6d4563153 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java @@ -0,0 +1,96 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * ChannelRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class ChannelRepositoryTest { + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 채널 생성용 테스트 픽스처 + */ + private Channel createTestChannel(ChannelType type, String name) { + Channel channel = new Channel(type, name, "설명: " + name); + return channelRepository.save(channel); + } + + @Test + @DisplayName("타입이 PUBLIC이거나 ID 목록에 포함된 채널을 모두 조회할 수 있다") + void findAllByTypeOrIdIn_ReturnsChannels() { + // given + Channel publicChannel1 = createTestChannel(ChannelType.PUBLIC, "공개채널1"); + Channel publicChannel2 = createTestChannel(ChannelType.PUBLIC, "공개채널2"); + Channel privateChannel1 = createTestChannel(ChannelType.PRIVATE, "비공개채널1"); + Channel privateChannel2 = createTestChannel(ChannelType.PRIVATE, "비공개채널2"); + + channelRepository.saveAll( + Arrays.asList(publicChannel1, publicChannel2, privateChannel1, privateChannel2)); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List selectedPrivateIds = List.of(privateChannel1.getId()); + List foundChannels = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, + selectedPrivateIds); + + // then + assertThat(foundChannels).hasSize(3); // 공개채널 2개 + 선택된 비공개채널 1개 + + // 공개 채널 2개가 모두 포함되어 있는지 확인 + assertThat( + foundChannels.stream().filter(c -> c.getType() == ChannelType.PUBLIC).count()).isEqualTo(2); + + // 선택된 비공개 채널만 포함되어 있는지 확인 + List privateChannels = foundChannels.stream() + .filter(c -> c.getType() == ChannelType.PRIVATE) + .toList(); + assertThat(privateChannels).hasSize(1); + assertThat(privateChannels.get(0).getId()).isEqualTo(privateChannel1.getId()); + } + + @Test + @DisplayName("타입이 PUBLIC이 아니고 ID 목록이 비어있으면 비어있는 리스트를 반환한다") + void findAllByTypeOrIdIn_EmptyList_ReturnsEmptyList() { + // given + Channel privateChannel1 = createTestChannel(ChannelType.PRIVATE, "비공개채널1"); + Channel privateChannel2 = createTestChannel(ChannelType.PRIVATE, "비공개채널2"); + + channelRepository.saveAll(Arrays.asList(privateChannel1, privateChannel2)); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List foundChannels = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, + List.of()); + + // then + assertThat(foundChannels).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java new file mode 100644 index 000000000..3207ebb06 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java @@ -0,0 +1,221 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * MessageRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class MessageRepositoryTest { + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트용 사용자 생성 + */ + private User createTestUser(String username, String email) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + // UserStatus 생성 및 연결 + UserStatus status = new UserStatus(user, Instant.now()); + return userRepository.save(user); + } + + /** + * TestFixture: 테스트용 채널 생성 + */ + private Channel createTestChannel(ChannelType type, String name) { + Channel channel = new Channel(type, name, "설명: " + name); + return channelRepository.save(channel); + } + + /** + * TestFixture: 테스트용 메시지 생성 ReflectionTestUtils를 사용하여 createdAt 필드를 직접 설정 + */ + private Message createTestMessage(String content, Channel channel, User author, + Instant createdAt) { + Message message = new Message(content, channel, author, new ArrayList<>()); + + // 생성 시간이 지정된 경우, ReflectionTestUtils로 설정 + if (createdAt != null) { + ReflectionTestUtils.setField(message, "createdAt", createdAt); + } + + Message savedMessage = messageRepository.save(message); + entityManager.flush(); + + return savedMessage; + } + + @Test + @DisplayName("채널 ID와 생성 시간으로 메시지를 페이징하여 조회할 수 있다") + void findAllByChannelIdWithAuthor_ReturnsMessagesWithAuthor() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널"); + + Instant now = Instant.now(); + Instant fiveMinutesAgo = now.minus(5, ChronoUnit.MINUTES); + Instant tenMinutesAgo = now.minus(10, ChronoUnit.MINUTES); + + // 채널에 세 개의 메시지 생성 (시간 순서대로) + Message message1 = createTestMessage("첫 번째 메시지", channel, user, tenMinutesAgo); + Message message2 = createTestMessage("두 번째 메시지", channel, user, fiveMinutesAgo); + Message message3 = createTestMessage("세 번째 메시지", channel, user, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when - 최신 메시지보다 이전 시간으로 조회 + Slice messages = messageRepository.findAllByChannelIdWithAuthor( + channel.getId(), + now.plus(1, ChronoUnit.MINUTES), // 현재 시간보다 더 미래 + PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + + // then + assertThat(messages).isNotNull(); + assertThat(messages.hasContent()).isTrue(); + assertThat(messages.getNumberOfElements()).isEqualTo(2); // 페이지 크기 만큼만 반환 + assertThat(messages.hasNext()).isTrue(); + + // 시간 역순(최신순)으로 정렬되어 있는지 확인 + List content = messages.getContent(); + assertThat(content.get(0).getCreatedAt()).isAfterOrEqualTo(content.get(1).getCreatedAt()); + + // 저자 정보가 함께 로드되었는지 확인 (FETCH JOIN) + Message firstMessage = content.get(0); + assertThat(Hibernate.isInitialized(firstMessage.getAuthor())).isTrue(); + assertThat(Hibernate.isInitialized(firstMessage.getAuthor().getStatus())).isTrue(); + assertThat(Hibernate.isInitialized(firstMessage.getAuthor().getProfile())).isTrue(); + } + + @Test + @DisplayName("채널의 마지막 메시지 시간을 조회할 수 있다") + void findLastMessageAtByChannelId_ReturnsLastMessageTime() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널"); + + Instant now = Instant.now(); + Instant fiveMinutesAgo = now.minus(5, ChronoUnit.MINUTES); + Instant tenMinutesAgo = now.minus(10, ChronoUnit.MINUTES); + + // 채널에 세 개의 메시지 생성 (시간 순서대로) + createTestMessage("첫 번째 메시지", channel, user, tenMinutesAgo); + createTestMessage("두 번째 메시지", channel, user, fiveMinutesAgo); + Message lastMessage = createTestMessage("세 번째 메시지", channel, user, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional lastMessageAt = messageRepository.findLastMessageAtByChannelId( + channel.getId()); + + // then + assertThat(lastMessageAt).isPresent(); + // 마지막 메시지 시간과 일치하는지 확인 (밀리초 단위 이하의 차이는 무시) + assertThat(lastMessageAt.get().truncatedTo(ChronoUnit.MILLIS)) + .isEqualTo(lastMessage.getCreatedAt().truncatedTo(ChronoUnit.MILLIS)); + } + + @Test + @DisplayName("메시지가 없는 채널에서는 마지막 메시지 시간이 없다") + void findLastMessageAtByChannelId_NoMessages_ReturnsEmpty() { + // given + Channel emptyChannel = createTestChannel(ChannelType.PUBLIC, "빈채널"); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional lastMessageAt = messageRepository.findLastMessageAtByChannelId( + emptyChannel.getId()); + + // then + assertThat(lastMessageAt).isEmpty(); + } + + @Test + @DisplayName("채널의 모든 메시지를 삭제할 수 있다") + void deleteAllByChannelId_DeletesAllMessages() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널"); + Channel otherChannel = createTestChannel(ChannelType.PUBLIC, "다른채널"); + + // 테스트 채널에 메시지 3개 생성 + createTestMessage("첫 번째 메시지", channel, user, null); + createTestMessage("두 번째 메시지", channel, user, null); + createTestMessage("세 번째 메시지", channel, user, null); + + // 다른 채널에 메시지 1개 생성 + createTestMessage("다른 채널 메시지", otherChannel, user, null); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + messageRepository.deleteAllByChannelId(channel.getId()); + entityManager.flush(); + entityManager.clear(); + + // then + // 해당 채널의 메시지는 삭제되었는지 확인 + List channelMessages = messageRepository.findAllByChannelIdWithAuthor( + channel.getId(), + Instant.now().plus(1, ChronoUnit.DAYS), + PageRequest.of(0, 100) + ).getContent(); + assertThat(channelMessages).isEmpty(); + + // 다른 채널의 메시지는 그대로인지 확인 + List otherChannelMessages = messageRepository.findAllByChannelIdWithAuthor( + otherChannel.getId(), + Instant.now().plus(1, ChronoUnit.DAYS), + PageRequest.of(0, 100) + ).getContent(); + assertThat(otherChannelMessages).hasSize(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java new file mode 100644 index 000000000..3dc797ca4 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java @@ -0,0 +1,199 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * ReadStatusRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class ReadStatusRepositoryTest { + + @Autowired + private ReadStatusRepository readStatusRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트용 사용자 생성 + */ + private User createTestUser(String username, String email) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + // UserStatus 생성 및 연결 + UserStatus status = new UserStatus(user, Instant.now()); + return userRepository.save(user); + } + + /** + * TestFixture: 테스트용 채널 생성 + */ + private Channel createTestChannel(ChannelType type, String name) { + Channel channel = new Channel(type, name, "설명: " + name); + return channelRepository.save(channel); + } + + /** + * TestFixture: 테스트용 읽음 상태 생성 + */ + private ReadStatus createTestReadStatus(User user, Channel channel, Instant lastReadAt) { + ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt); + return readStatusRepository.save(readStatus); + } + + @Test + @DisplayName("사용자 ID로 모든 읽음 상태를 조회할 수 있다") + void findAllByUserId_ReturnsReadStatuses() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel1 = createTestChannel(ChannelType.PUBLIC, "채널1"); + Channel channel2 = createTestChannel(ChannelType.PRIVATE, "채널2"); + + Instant now = Instant.now(); + ReadStatus readStatus1 = createTestReadStatus(user, channel1, now.minus(1, ChronoUnit.DAYS)); + ReadStatus readStatus2 = createTestReadStatus(user, channel2, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List readStatuses = readStatusRepository.findAllByUserId(user.getId()); + + // then + assertThat(readStatuses).hasSize(2); + } + + @Test + @DisplayName("채널 ID로 모든 읽음 상태를 사용자 정보와 함께 조회할 수 있다") + void findAllByChannelIdWithUser_ReturnsReadStatusesWithUser() { + // given + User user1 = createTestUser("user1", "user1@example.com"); + User user2 = createTestUser("user2", "user2@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널"); + + Instant now = Instant.now(); + ReadStatus readStatus1 = createTestReadStatus(user1, channel, now.minus(1, ChronoUnit.DAYS)); + ReadStatus readStatus2 = createTestReadStatus(user2, channel, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List readStatuses = readStatusRepository.findAllByChannelIdWithUser( + channel.getId()); + + // then + assertThat(readStatuses).hasSize(2); + + // 사용자 정보가 함께 로드되었는지 확인 (FETCH JOIN) + for (ReadStatus status : readStatuses) { + assertThat(Hibernate.isInitialized(status.getUser())).isTrue(); + assertThat(Hibernate.isInitialized(status.getUser().getStatus())).isTrue(); + assertThat(Hibernate.isInitialized(status.getUser().getProfile())).isTrue(); + } + } + + @Test + @DisplayName("사용자 ID와 채널 ID로 읽음 상태 존재 여부를 확인할 수 있다") + void existsByUserIdAndChannelId_ExistingStatus_ReturnsTrue() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널"); + + ReadStatus readStatus = createTestReadStatus(user, channel, Instant.now()); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Boolean exists = readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId()); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 읽음 상태에 대해 false를 반환한다") + void existsByUserIdAndChannelId_NonExistingStatus_ReturnsFalse() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널"); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // 읽음 상태를 생성하지 않음 + + // when + Boolean exists = readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId()); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("채널의 모든 읽음 상태를 삭제할 수 있다") + void deleteAllByChannelId_DeletesAllReadStatuses() { + // given + User user1 = createTestUser("user1", "user1@example.com"); + User user2 = createTestUser("user2", "user2@example.com"); + + Channel channel = createTestChannel(ChannelType.PUBLIC, "삭제할채널"); + Channel otherChannel = createTestChannel(ChannelType.PUBLIC, "유지할채널"); + + // 삭제할 채널에 읽음 상태 2개 생성 + createTestReadStatus(user1, channel, Instant.now()); + createTestReadStatus(user2, channel, Instant.now()); + + // 유지할 채널에 읽음 상태 1개 생성 + createTestReadStatus(user1, otherChannel, Instant.now()); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + readStatusRepository.deleteAllByChannelId(channel.getId()); + entityManager.flush(); + entityManager.clear(); + + // then + // 해당 채널의 읽음 상태는 삭제되었는지 확인 + List channelReadStatuses = readStatusRepository.findAllByChannelIdWithUser(channel.getId()); + assertThat(channelReadStatuses).isEmpty(); + + // 다른 채널의 읽음 상태는 그대로인지 확인 + List otherChannelReadStatuses = readStatusRepository.findAllByChannelIdWithUser(otherChannel.getId()); + assertThat(otherChannelReadStatuses).hasSize(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java new file mode 100644 index 000000000..84f360a2d --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java @@ -0,0 +1,138 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * UserRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트에서 일관된 상태를 제공하기 위한 고정된 객체 세트 여러 테스트에서 재사용할 수 있는 테스트 데이터를 생성하는 메서드 + */ + private User createTestUser(String username, String email) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + // UserStatus 생성 및 연결 + UserStatus status = new UserStatus(user, Instant.now()); + return user; + } + + @Test + @DisplayName("사용자 이름으로 사용자를 찾을 수 있다") + void findByUsername_ExistingUsername_ReturnsUser() { + // given + String username = "testUser"; + User user = createTestUser(username, "test@example.com"); + userRepository.save(user); + + // 영속성 컨텍스트 초기화 - 1차 캐시 비우기 + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundUser = userRepository.findByUsername(username); + + // then + assertThat(foundUser).isPresent(); + assertThat(foundUser.get().getUsername()).isEqualTo(username); + } + + @Test + @DisplayName("존재하지 않는 사용자 이름으로 검색하면 빈 Optional을 반환한다") + void findByUsername_NonExistingUsername_ReturnsEmptyOptional() { + // given + String nonExistingUsername = "nonExistingUser"; + + // when + Optional foundUser = userRepository.findByUsername(nonExistingUsername); + + // then + assertThat(foundUser).isEmpty(); + } + + @Test + @DisplayName("이메일로 사용자 존재 여부를 확인할 수 있다") + void existsByEmail_ExistingEmail_ReturnsTrue() { + // given + String email = "test@example.com"; + User user = createTestUser("testUser", email); + userRepository.save(user); + + // when + boolean exists = userRepository.existsByEmail(email); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 이메일로 확인하면 false를 반환한다") + void existsByEmail_NonExistingEmail_ReturnsFalse() { + // given + String nonExistingEmail = "nonexisting@example.com"; + + // when + boolean exists = userRepository.existsByEmail(nonExistingEmail); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("모든 사용자를 프로필과 상태 정보와 함께 조회할 수 있다") + void findAllWithProfileAndStatus_ReturnsUsersWithProfileAndStatus() { + // given + User user1 = createTestUser("user1", "user1@example.com"); + User user2 = createTestUser("user2", "user2@example.com"); + + userRepository.saveAll(List.of(user1, user2)); + + // 영속성 컨텍스트 초기화 - 1차 캐시 비우기 + entityManager.flush(); + entityManager.clear(); + + // when + List users = userRepository.findAllWithProfileAndStatus(); + + // then + assertThat(users).hasSize(2); + assertThat(users).extracting("username").containsExactlyInAnyOrder("user1", "user2"); + + // 프로필과 상태 정보가 함께 조회되었는지 확인 - 프록시 초기화 없이도 접근 가능한지 테스트 + User foundUser1 = users.stream().filter(u -> u.getUsername().equals("user1")).findFirst() + .orElseThrow(); + User foundUser2 = users.stream().filter(u -> u.getUsername().equals("user2")).findFirst() + .orElseThrow(); + + // 프록시 초기화 여부 확인 + assertThat(Hibernate.isInitialized(foundUser1.getProfile())).isTrue(); + assertThat(Hibernate.isInitialized(foundUser1.getStatus())).isTrue(); + assertThat(Hibernate.isInitialized(foundUser2.getProfile())).isTrue(); + assertThat(Hibernate.isInitialized(foundUser2.getStatus())).isTrue(); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java new file mode 100644 index 000000000..019b99d2a --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/UserStatusRepositoryTest.java @@ -0,0 +1,117 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * UserStatusRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class UserStatusRepositoryTest { + + @Autowired + private UserStatusRepository userStatusRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트용 사용자와 상태 생성 + */ + private User createTestUserWithStatus(String username, String email, Instant lastActiveAt) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + UserStatus status = new UserStatus(user, lastActiveAt); + return userRepository.save(user); + } + + @Test + @DisplayName("사용자 ID로 상태 정보를 찾을 수 있다") + void findByUserId_ExistingUserId_ReturnsUserStatus() { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + User user = createTestUserWithStatus("testUser", "test@example.com", now); + UUID userId = user.getId(); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundStatus = userStatusRepository.findByUserId(userId); + + // then + assertThat(foundStatus).isPresent(); + assertThat(foundStatus.get().getUser().getId()).isEqualTo(userId); + } + + @Test + @DisplayName("존재하지 않는 사용자 ID로 검색하면 빈 Optional을 반환한다") + void findByUserId_NonExistingUserId_ReturnsEmptyOptional() { + // given + UUID nonExistingUserId = UUID.randomUUID(); + + // when + Optional foundStatus = userStatusRepository.findByUserId(nonExistingUserId); + + // then + assertThat(foundStatus).isEmpty(); + } + + @Test + @DisplayName("UserStatus의 isOnline 메서드는 최근 활동 시간이 5분 이내일 때 true를 반환한다") + void isOnline_LastActiveWithinFiveMinutes_ReturnsTrue() { + // given + Instant now = Instant.now(); + User user = createTestUserWithStatus("testUser", "test@example.com", now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundStatus = userStatusRepository.findByUserId(user.getId()); + + // then + assertThat(foundStatus).isPresent(); + assertThat(foundStatus.get().isOnline()).isTrue(); + } + + @Test + @DisplayName("UserStatus의 isOnline 메서드는 최근 활동 시간이 5분보다 이전일 때 false를 반환한다") + void isOnline_LastActiveBeforeFiveMinutes_ReturnsFalse() { + // given + Instant sixMinutesAgo = Instant.now().minus(6, ChronoUnit.MINUTES); + User user = createTestUserWithStatus("testUser", "test@example.com", sixMinutesAgo); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundStatus = userStatusRepository.findByUserId(user.getId()); + + // then + assertThat(foundStatus).isPresent(); + assertThat(foundStatus.get().isOnline()).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java new file mode 100644 index 000000000..fba3f3d65 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java @@ -0,0 +1,172 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicBinaryContentServiceTest { + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private BinaryContentMapper binaryContentMapper; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @InjectMocks + private BasicBinaryContentService binaryContentService; + + private UUID binaryContentId; + private String fileName; + private String contentType; + private byte[] bytes; + private BinaryContent binaryContent; + private BinaryContentDto binaryContentDto; + + @BeforeEach + void setUp() { + binaryContentId = UUID.randomUUID(); + fileName = "test.jpg"; + contentType = "image/jpeg"; + bytes = "test data".getBytes(); + + binaryContent = new BinaryContent(fileName, (long) bytes.length, contentType); + ReflectionTestUtils.setField(binaryContent, "id", binaryContentId); + + binaryContentDto = new BinaryContentDto( + binaryContentId, + fileName, + (long) bytes.length, + contentType + ); + } + + @Test + @DisplayName("바이너리 콘텐츠 생성 성공") + void createBinaryContent_Success() { + // given + BinaryContentCreateRequest request = new BinaryContentCreateRequest(fileName, contentType, + bytes); + + given(binaryContentRepository.save(any(BinaryContent.class))).will(invocation -> { + BinaryContent binaryContent = invocation.getArgument(0); + ReflectionTestUtils.setField(binaryContent, "id", binaryContentId); + return binaryContent; + }); + given(binaryContentMapper.toDto(any(BinaryContent.class))).willReturn(binaryContentDto); + + // when + BinaryContentDto result = binaryContentService.create(request); + + // then + assertThat(result).isEqualTo(binaryContentDto); + verify(binaryContentRepository).save(any(BinaryContent.class)); + verify(binaryContentStorage).put(binaryContentId, bytes); + } + + @Test + @DisplayName("바이너리 콘텐츠 조회 성공") + void findBinaryContent_Success() { + // given + given(binaryContentRepository.findById(eq(binaryContentId))).willReturn( + Optional.of(binaryContent)); + given(binaryContentMapper.toDto(eq(binaryContent))).willReturn(binaryContentDto); + + // when + BinaryContentDto result = binaryContentService.find(binaryContentId); + + // then + assertThat(result).isEqualTo(binaryContentDto); + } + + @Test + @DisplayName("존재하지 않는 바이너리 콘텐츠 조회 시 예외 발생") + void findBinaryContent_WithNonExistentId_ThrowsException() { + // given + given(binaryContentRepository.findById(eq(binaryContentId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> binaryContentService.find(binaryContentId)) + .isInstanceOf(BinaryContentNotFoundException.class); + } + + @Test + @DisplayName("여러 ID로 바이너리 콘텐츠 목록 조회 성공") + void findAllByIdIn_Success() { + // given + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + List ids = Arrays.asList(id1, id2); + + BinaryContent content1 = new BinaryContent("file1.jpg", 100L, "image/jpeg"); + ReflectionTestUtils.setField(content1, "id", id1); + + BinaryContent content2 = new BinaryContent("file2.jpg", 200L, "image/png"); + ReflectionTestUtils.setField(content2, "id", id2); + + List contents = Arrays.asList(content1, content2); + + BinaryContentDto dto1 = new BinaryContentDto(id1, "file1.jpg", 100L, "image/jpeg"); + BinaryContentDto dto2 = new BinaryContentDto(id2, "file2.jpg", 200L, "image/png"); + + given(binaryContentRepository.findAllById(eq(ids))).willReturn(contents); + given(binaryContentMapper.toDto(eq(content1))).willReturn(dto1); + given(binaryContentMapper.toDto(eq(content2))).willReturn(dto2); + + // when + List result = binaryContentService.findAllByIdIn(ids); + + // then + assertThat(result).containsExactly(dto1, dto2); + } + + @Test + @DisplayName("바이너리 콘텐츠 삭제 성공") + void deleteBinaryContent_Success() { + // given + given(binaryContentRepository.existsById(binaryContentId)).willReturn(true); + + // when + binaryContentService.delete(binaryContentId); + + // then + verify(binaryContentRepository).deleteById(binaryContentId); + } + + @Test + @DisplayName("존재하지 않는 바이너리 콘텐츠 삭제 시 예외 발생") + void deleteBinaryContent_WithNonExistentId_ThrowsException() { + // given + given(binaryContentRepository.existsById(eq(binaryContentId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> binaryContentService.delete(binaryContentId)) + .isInstanceOf(BinaryContentNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java new file mode 100644 index 000000000..da1dd0ca0 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java @@ -0,0 +1,228 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicChannelServiceTest { + + @Mock + private ChannelRepository channelRepository; + + @Mock + private ReadStatusRepository readStatusRepository; + + @Mock + private MessageRepository messageRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChannelMapper channelMapper; + + @InjectMocks + private BasicChannelService channelService; + + private UUID channelId; + private UUID userId; + private String channelName; + private String channelDescription; + private Channel channel; + private ChannelDto channelDto; + private User user; + + @BeforeEach + void setUp() { + channelId = UUID.randomUUID(); + userId = UUID.randomUUID(); + channelName = "testChannel"; + channelDescription = "testDescription"; + + channel = new Channel(ChannelType.PUBLIC, channelName, channelDescription); + ReflectionTestUtils.setField(channel, "id", channelId); + channelDto = new ChannelDto(channelId, ChannelType.PUBLIC, channelName, channelDescription, + List.of(), Instant.now()); + user = new User("testUser", "test@example.com", "password", null); + } + + @Test + @DisplayName("공개 채널 생성 성공") + void createPublicChannel_Success() { + // given + PublicChannelCreateRequest request = new PublicChannelCreateRequest(channelName, + channelDescription); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.create(request); + + // then + assertThat(result).isEqualTo(channelDto); + verify(channelRepository).save(any(Channel.class)); + } + + @Test + @DisplayName("비공개 채널 생성 성공") + void createPrivateChannel_Success() { + // given + List participantIds = List.of(userId); + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(participantIds); + given(userRepository.findAllById(eq(participantIds))).willReturn(List.of(user)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.create(request); + + // then + assertThat(result).isEqualTo(channelDto); + verify(channelRepository).save(any(Channel.class)); + verify(readStatusRepository).saveAll(anyList()); + } + + @Test + @DisplayName("채널 조회 성공") + void findChannel_Success() { + // given + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.find(channelId); + + // then + assertThat(result).isEqualTo(channelDto); + } + + @Test + @DisplayName("존재하지 않는 채널 조회 시 실패") + void findChannel_WithNonExistentId_ThrowsException() { + // given + given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> channelService.find(channelId)) + .isInstanceOf(ChannelNotFoundException.class); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 성공") + void findAllByUserId_Success() { + // given + List readStatuses = List.of(new ReadStatus(user, channel, Instant.now())); + given(readStatusRepository.findAllByUserId(eq(userId))).willReturn(readStatuses); + given(channelRepository.findAllByTypeOrIdIn(eq(ChannelType.PUBLIC), eq(List.of(channel.getId())))) + .willReturn(List.of(channel)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + List result = channelService.findAllByUserId(userId); + + // then + assertThat(result).containsExactly(channelDto); + } + + @Test + @DisplayName("공개 채널 수정 성공") + void updatePublicChannel_Success() { + // given + String newName = "newChannelName"; + String newDescription = "newDescription"; + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest(newName, newDescription); + + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.update(channelId, request); + + // then + assertThat(result).isEqualTo(channelDto); + } + + @Test + @DisplayName("비공개 채널 수정 시도 시 실패") + void updatePrivateChannel_ThrowsException() { + // given + Channel privateChannel = new Channel(ChannelType.PRIVATE, null, null); + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName", + "newDescription"); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(privateChannel)); + + // when & then + assertThatThrownBy(() -> channelService.update(channelId, request)) + .isInstanceOf(PrivateChannelUpdateException.class); + } + + @Test + @DisplayName("존재하지 않는 채널 수정 시도 시 실패") + void updateChannel_WithNonExistentId_ThrowsException() { + // given + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName", + "newDescription"); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> channelService.update(channelId, request)) + .isInstanceOf(ChannelNotFoundException.class); + } + + @Test + @DisplayName("채널 삭제 성공") + void deleteChannel_Success() { + // given + given(channelRepository.existsById(eq(channelId))).willReturn(true); + + // when + channelService.delete(channelId); + + // then + verify(messageRepository).deleteAllByChannelId(eq(channelId)); + verify(readStatusRepository).deleteAllByChannelId(eq(channelId)); + verify(channelRepository).deleteById(eq(channelId)); + } + + @Test + @DisplayName("존재하지 않는 채널 삭제 시도 시 실패") + void deleteChannel_WithNonExistentId_ThrowsException() { + // given + given(channelRepository.existsById(eq(channelId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> channelService.delete(channelId)) + .isInstanceOf(ChannelNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java new file mode 100644 index 000000000..c00150bab --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java @@ -0,0 +1,365 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.PageResponseMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.SliceImpl; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicMessageServiceTest { + + @Mock + private MessageRepository messageRepository; + + @Mock + private ChannelRepository channelRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private MessageMapper messageMapper; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private PageResponseMapper pageResponseMapper; + + @InjectMocks + private BasicMessageService messageService; + + private UUID messageId; + private UUID channelId; + private UUID authorId; + private String content; + private Message message; + private MessageDto messageDto; + private Channel channel; + private User author; + private BinaryContent attachment; + private BinaryContentDto attachmentDto; + + @BeforeEach + void setUp() { + messageId = UUID.randomUUID(); + channelId = UUID.randomUUID(); + authorId = UUID.randomUUID(); + content = "test message"; + + channel = new Channel(ChannelType.PUBLIC, "testChannel", "testDescription"); + ReflectionTestUtils.setField(channel, "id", channelId); + + author = new User("testUser", "test@example.com", "password", null); + ReflectionTestUtils.setField(author, "id", authorId); + + attachment = new BinaryContent("test.txt", 100L, "text/plain"); + ReflectionTestUtils.setField(attachment, "id", UUID.randomUUID()); + attachmentDto = new BinaryContentDto(attachment.getId(), "test.txt", 100L, "text/plain"); + + message = new Message(content, channel, author, List.of(attachment)); + ReflectionTestUtils.setField(message, "id", messageId); + + messageDto = new MessageDto( + messageId, + Instant.now(), + Instant.now(), + content, + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true), + List.of(attachmentDto) + ); + } + + @Test + @DisplayName("메시지 생성 성공") + void createMessage_Success() { + // given + MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId); + BinaryContentCreateRequest attachmentRequest = new BinaryContentCreateRequest("test.txt", "text/plain", new byte[100]); + List attachmentRequests = List.of(attachmentRequest); + + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(userRepository.findById(eq(authorId))).willReturn(Optional.of(author)); + given(binaryContentRepository.save(any(BinaryContent.class))).will(invocation -> { + BinaryContent binaryContent = invocation.getArgument(0); + ReflectionTestUtils.setField(binaryContent, "id", attachment.getId()); + return attachment; + }); + given(messageRepository.save(any(Message.class))).willReturn(message); + given(messageMapper.toDto(any(Message.class))).willReturn(messageDto); + + // when + MessageDto result = messageService.create(request, attachmentRequests); + + // then + assertThat(result).isEqualTo(messageDto); + verify(messageRepository).save(any(Message.class)); + verify(binaryContentStorage).put(eq(attachment.getId()), any(byte[].class)); + } + + @Test + @DisplayName("존재하지 않는 채널에 메시지 생성 시도 시 실패") + void createMessage_WithNonExistentChannel_ThrowsException() { + // given + MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.create(request, List.of())) + .isInstanceOf(ChannelNotFoundException.class); + } + + @Test + @DisplayName("존재하지 않는 작성자로 메시지 생성 시도 시 실패") + void createMessage_WithNonExistentAuthor_ThrowsException() { + // given + MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(userRepository.findById(eq(authorId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.create(request, List.of())) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("메시지 조회 성공") + void findMessage_Success() { + // given + given(messageRepository.findById(eq(messageId))).willReturn(Optional.of(message)); + given(messageMapper.toDto(eq(message))).willReturn(messageDto); + + // when + MessageDto result = messageService.find(messageId); + + // then + assertThat(result).isEqualTo(messageDto); + } + + @Test + @DisplayName("존재하지 않는 메시지 조회 시 실패") + void findMessage_WithNonExistentId_ThrowsException() { + // given + given(messageRepository.findById(eq(messageId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.find(messageId)) + .isInstanceOf(MessageNotFoundException.class); + } + + @Test + @DisplayName("채널별 메시지 목록 조회 성공") + void findAllByChannelId_Success() { + // given + int pageSize = 2; // 페이지 크기를 2로 설정 + Instant createdAt = Instant.now(); + Pageable pageable = PageRequest.of(0, pageSize); + + // 여러 메시지 생성 (페이지 사이즈보다 많게) + Message message1 = new Message(content + "1", channel, author, List.of(attachment)); + Message message2 = new Message(content + "2", channel, author, List.of(attachment)); + Message message3 = new Message(content + "3", channel, author, List.of(attachment)); + + ReflectionTestUtils.setField(message1, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(message2, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(message3, "id", UUID.randomUUID()); + + // 각 메시지에 해당하는 DTO 생성 + Instant message1CreatedAt = Instant.now().minusSeconds(30); + Instant message2CreatedAt = Instant.now().minusSeconds(20); + Instant message3CreatedAt = Instant.now().minusSeconds(10); + + ReflectionTestUtils.setField(message1, "createdAt", message1CreatedAt); + ReflectionTestUtils.setField(message2, "createdAt", message2CreatedAt); + ReflectionTestUtils.setField(message3, "createdAt", message3CreatedAt); + + MessageDto messageDto1 = new MessageDto( + message1.getId(), + message1CreatedAt, + message1CreatedAt, + content + "1", + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true), + List.of(attachmentDto) + ); + + MessageDto messageDto2 = new MessageDto( + message2.getId(), + message2CreatedAt, + message2CreatedAt, + content + "2", + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true), + List.of(attachmentDto) + ); + + // 첫 페이지 결과 세팅 (2개 메시지) + List firstPageMessages = List.of(message1, message2); + List firstPageDtos = List.of(messageDto1, messageDto2); + + // 첫 페이지는 다음 페이지가 있고, 커서는 message2의 생성 시간이어야 함 + SliceImpl firstPageSlice = new SliceImpl<>(firstPageMessages, pageable, true); + PageResponse firstPageResponse = new PageResponse<>( + firstPageDtos, + message2CreatedAt, + pageSize, + true, + null + ); + + // 모의 객체 설정 + given(messageRepository.findAllByChannelIdWithAuthor(eq(channelId), eq(createdAt), eq(pageable))) + .willReturn(firstPageSlice); + given(messageMapper.toDto(eq(message1))).willReturn(messageDto1); + given(messageMapper.toDto(eq(message2))).willReturn(messageDto2); + given(pageResponseMapper.fromSlice(any(), eq(message2CreatedAt))) + .willReturn(firstPageResponse); + + // when + PageResponse result = messageService.findAllByChannelId(channelId, createdAt, + pageable); + + // then + assertThat(result).isEqualTo(firstPageResponse); + assertThat(result.content()).hasSize(pageSize); + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isEqualTo(message2CreatedAt); + + // 두 번째 페이지 테스트 + // given + List secondPageMessages = List.of(message3); + MessageDto messageDto3 = new MessageDto( + message3.getId(), + message3CreatedAt, + message3CreatedAt, + content + "3", + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true), + List.of(attachmentDto) + ); + List secondPageDtos = List.of(messageDto3); + + // 두 번째 페이지는 다음 페이지가 없음 + SliceImpl secondPageSlice = new SliceImpl<>(secondPageMessages, pageable, false); + PageResponse secondPageResponse = new PageResponse<>( + secondPageDtos, + message3CreatedAt, + pageSize, + false, + null + ); + + // 두 번째 페이지 모의 객체 설정 + given(messageRepository.findAllByChannelIdWithAuthor(eq(channelId), eq(message2CreatedAt), eq(pageable))) + .willReturn(secondPageSlice); + given(messageMapper.toDto(eq(message3))).willReturn(messageDto3); + given(pageResponseMapper.fromSlice(any(), eq(message3CreatedAt))) + .willReturn(secondPageResponse); + + // when - 두 번째 페이지 요청 (첫 페이지의 커서 사용) + PageResponse secondResult = messageService.findAllByChannelId(channelId, message2CreatedAt, + pageable); + + // then - 두 번째 페이지 검증 + assertThat(secondResult).isEqualTo(secondPageResponse); + assertThat(secondResult.content()).hasSize(1); // 마지막 페이지는 항목 1개만 있음 + assertThat(secondResult.hasNext()).isFalse(); // 더 이상 다음 페이지 없음 + } + + @Test + @DisplayName("메시지 수정 성공") + void updateMessage_Success() { + // given + String newContent = "updated content"; + MessageUpdateRequest request = new MessageUpdateRequest(newContent); + + given(messageRepository.findById(eq(messageId))).willReturn(Optional.of(message)); + given(messageMapper.toDto(eq(message))).willReturn(messageDto); + + // when + MessageDto result = messageService.update(messageId, request); + + // then + assertThat(result).isEqualTo(messageDto); + } + + @Test + @DisplayName("존재하지 않는 메시지 수정 시도 시 실패") + void updateMessage_WithNonExistentId_ThrowsException() { + // given + MessageUpdateRequest request = new MessageUpdateRequest("new content"); + given(messageRepository.findById(eq(messageId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.update(messageId, request)) + .isInstanceOf(MessageNotFoundException.class); + } + + @Test + @DisplayName("메시지 삭제 성공") + void deleteMessage_Success() { + // given + given(messageRepository.existsById(eq(messageId))).willReturn(true); + + // when + messageService.delete(messageId); + + // then + verify(messageRepository).deleteById(eq(messageId)); + } + + @Test + @DisplayName("존재하지 않는 메시지 삭제 시도 시 실패") + void deleteMessage_WithNonExistentId_ThrowsException() { + // given + given(messageRepository.existsById(eq(messageId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> messageService.delete(messageId)) + .isInstanceOf(MessageNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java new file mode 100644 index 000000000..d165fb710 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java @@ -0,0 +1,184 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicUserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + + @InjectMocks + private BasicUserService userService; + + private UUID userId; + private String username; + private String email; + private String password; + private User user; + private UserDto userDto; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + username = "testUser"; + email = "test@example.com"; + password = "password123"; + + user = new User(username, email, password, null); + ReflectionTestUtils.setField(user, "id", userId); + userDto = new UserDto(userId, username, email, null, true); + } + + @Test + @DisplayName("사용자 생성 성공") + void createUser_Success() { + // given + UserCreateRequest request = new UserCreateRequest(username, email, password); + given(userRepository.existsByEmail(eq(email))).willReturn(false); + given(userRepository.existsByUsername(eq(username))).willReturn(false); + given(userMapper.toDto(any(User.class))).willReturn(userDto); + + // when + UserDto result = userService.create(request, Optional.empty()); + + // then + assertThat(result).isEqualTo(userDto); + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("이미 존재하는 이메일로 사용자 생성 시도 시 실패") + void createUser_WithExistingEmail_ThrowsException() { + // given + UserCreateRequest request = new UserCreateRequest(username, email, password); + given(userRepository.existsByEmail(eq(email))).willReturn(true); + + // when & then + assertThatThrownBy(() -> userService.create(request, Optional.empty())) + .isInstanceOf(UserAlreadyExistsException.class); + } + + @Test + @DisplayName("이미 존재하는 사용자명으로 사용자 생성 시도 시 실패") + void createUser_WithExistingUsername_ThrowsException() { + // given + UserCreateRequest request = new UserCreateRequest(username, email, password); + given(userRepository.existsByEmail(eq(email))).willReturn(false); + given(userRepository.existsByUsername(eq(username))).willReturn(true); + + // when & then + assertThatThrownBy(() -> userService.create(request, Optional.empty())) + .isInstanceOf(UserAlreadyExistsException.class); + } + + @Test + @DisplayName("사용자 조회 성공") + void findUser_Success() { + // given + given(userRepository.findById(eq(userId))).willReturn(Optional.of(user)); + given(userMapper.toDto(any(User.class))).willReturn(userDto); + + // when + UserDto result = userService.find(userId); + + // then + assertThat(result).isEqualTo(userDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 조회 시 실패") + void findUser_WithNonExistentId_ThrowsException() { + // given + given(userRepository.findById(eq(userId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userService.find(userId)) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("사용자 수정 성공") + void updateUser_Success() { + // given + String newUsername = "newUsername"; + String newEmail = "new@example.com"; + String newPassword = "newPassword"; + UserUpdateRequest request = new UserUpdateRequest(newUsername, newEmail, newPassword); + + given(userRepository.findById(eq(userId))).willReturn(Optional.of(user)); + given(userRepository.existsByEmail(eq(newEmail))).willReturn(false); + given(userRepository.existsByUsername(eq(newUsername))).willReturn(false); + given(userMapper.toDto(any(User.class))).willReturn(userDto); + + // when + UserDto result = userService.update(userId, request, Optional.empty()); + + // then + assertThat(result).isEqualTo(userDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 수정 시도 시 실패") + void updateUser_WithNonExistentId_ThrowsException() { + // given + UserUpdateRequest request = new UserUpdateRequest("newUsername", "new@example.com", + "newPassword"); + given(userRepository.findById(eq(userId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userService.update(userId, request, Optional.empty())) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("사용자 삭제 성공") + void deleteUser_Success() { + // given + given(userRepository.existsById(eq(userId))).willReturn(true); + + // when + userService.delete(userId); + + // then + verify(userRepository).deleteById(eq(userId)); + } + + @Test + @DisplayName("존재하지 않는 사용자 삭제 시도 시 실패") + void deleteUser_WithNonExistentId_ThrowsException() { + // given + given(userRepository.existsById(eq(userId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> userService.delete(userId)) + .isInstanceOf(UserNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java new file mode 100644 index 000000000..1fd57a0e9 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusServiceTest.java @@ -0,0 +1,243 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.UserStatusDto; +import com.sprint.mission.discodeit.dto.request.UserStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.exception.userstatus.DuplicateUserStatusException; +import com.sprint.mission.discodeit.exception.userstatus.UserStatusNotFoundException; +import com.sprint.mission.discodeit.mapper.UserStatusMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.repository.UserStatusRepository; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicUserStatusServiceTest { + + @Mock + private UserStatusRepository userStatusRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserStatusMapper userStatusMapper; + + @InjectMocks + private BasicUserStatusService userStatusService; + + private UUID userStatusId; + private UUID userId; + private Instant lastActiveAt; + private User user; + private UserStatus userStatus; + private UserStatusDto userStatusDto; + + @BeforeEach + void setUp() { + userStatusId = UUID.randomUUID(); + userId = UUID.randomUUID(); + lastActiveAt = Instant.now(); + + user = new User("testUser", "test@example.com", "password", null); + ReflectionTestUtils.setField(user, "id", userId); + + userStatus = new UserStatus(user, lastActiveAt); + ReflectionTestUtils.setField(userStatus, "id", userStatusId); + + userStatusDto = new UserStatusDto(userStatusId, userId, lastActiveAt); + } + + @Test + @DisplayName("사용자 상태 생성 성공") + void createUserStatus_Success() { + // given + UserStatusCreateRequest request = new UserStatusCreateRequest(userId, lastActiveAt); + given(userRepository.findById(eq(userId))).willReturn(Optional.of(user)); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto); + + // 사용자에게 기존 상태가 없어야 함 + ReflectionTestUtils.setField(user, "status", null); + + // when + UserStatusDto result = userStatusService.create(request); + + // then + assertThat(result).isEqualTo(userStatusDto); + verify(userStatusRepository).save(any(UserStatus.class)); + } + + @Test + @DisplayName("이미 상태가 있는 사용자에 대한 상태 생성 시도 시 실패") + void createUserStatus_WithExistingStatus_ThrowsException() { + // given + UserStatusCreateRequest request = new UserStatusCreateRequest(userId, lastActiveAt); + given(userRepository.findById(eq(userId))).willReturn(Optional.of(user)); + + // 사용자에게 이미 상태가 있음 + ReflectionTestUtils.setField(user, "status", userStatus); + + // when & then + assertThatThrownBy(() -> userStatusService.create(request)) + .isInstanceOf(DuplicateUserStatusException.class); + } + + @Test + @DisplayName("존재하지 않는 사용자에 대한 상태 생성 시도 시 실패") + void createUserStatus_WithNonExistentUser_ThrowsException() { + // given + UserStatusCreateRequest request = new UserStatusCreateRequest(userId, lastActiveAt); + given(userRepository.findById(eq(userId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userStatusService.create(request)) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("사용자 상태 조회 성공") + void findUserStatus_Success() { + // given + given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.of(userStatus)); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto); + + // when + UserStatusDto result = userStatusService.find(userStatusId); + + // then + assertThat(result).isEqualTo(userStatusDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 상태 조회 시 실패") + void findUserStatus_WithNonExistentId_ThrowsException() { + // given + given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userStatusService.find(userStatusId)) + .isInstanceOf(UserStatusNotFoundException.class); + } + + @Test + @DisplayName("전체 사용자 상태 목록 조회 성공") + void findAllUserStatuses_Success() { + // given + given(userStatusRepository.findAll()).willReturn(List.of(userStatus)); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto); + + // when + List result = userStatusService.findAll(); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0)).isEqualTo(userStatusDto); + } + + @Test + @DisplayName("사용자 상태 수정 성공") + void updateUserStatus_Success() { + // given + Instant newLastActiveAt = Instant.now().plusSeconds(60); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt); + + given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.of(userStatus)); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto); + + // when + UserStatusDto result = userStatusService.update(userStatusId, request); + + // then + assertThat(result).isEqualTo(userStatusDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 상태 수정 시도 시 실패") + void updateUserStatus_WithNonExistentId_ThrowsException() { + // given + Instant newLastActiveAt = Instant.now().plusSeconds(60); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt); + + given(userStatusRepository.findById(eq(userStatusId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userStatusService.update(userStatusId, request)) + .isInstanceOf(UserStatusNotFoundException.class); + } + + @Test + @DisplayName("사용자 ID로 상태 수정 성공") + void updateUserStatusByUserId_Success() { + // given + Instant newLastActiveAt = Instant.now().plusSeconds(60); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt); + + given(userStatusRepository.findByUserId(eq(userId))).willReturn(Optional.of(userStatus)); + given(userStatusMapper.toDto(any(UserStatus.class))).willReturn(userStatusDto); + + // when + UserStatusDto result = userStatusService.updateByUserId(userId, request); + + // then + assertThat(result).isEqualTo(userStatusDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 ID로 상태 수정 시도 시 실패") + void updateUserStatusByUserId_WithNonExistentUserId_ThrowsException() { + // given + Instant newLastActiveAt = Instant.now().plusSeconds(60); + UserStatusUpdateRequest request = new UserStatusUpdateRequest(newLastActiveAt); + + given(userStatusRepository.findByUserId(eq(userId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userStatusService.updateByUserId(userId, request)) + .isInstanceOf(UserStatusNotFoundException.class); + } + + @Test + @DisplayName("사용자 상태 삭제 성공") + void deleteUserStatus_Success() { + // given + given(userStatusRepository.existsById(eq(userStatusId))).willReturn(true); + + // when + userStatusService.delete(userStatusId); + + // then + verify(userStatusRepository).deleteById(eq(userStatusId)); + } + + @Test + @DisplayName("존재하지 않는 사용자 상태 삭제 시도 시 실패") + void deleteUserStatus_WithNonExistentId_ThrowsException() { + // given + given(userStatusRepository.existsById(eq(userStatusId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> userStatusService.delete(userStatusId)) + .isInstanceOf(UserStatusNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java new file mode 100644 index 000000000..9f016686a --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java @@ -0,0 +1,174 @@ +package com.sprint.mission.discodeit.storage.s3; + +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URL; +import java.time.Duration; +import java.util.Properties; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +@Disabled +@Slf4j +@DisplayName("S3 API 테스트") +public class AWSS3Test { + + private static String accessKey; + private static String secretKey; + private static String region; + private static String bucket; + private S3Client s3Client; + private S3Presigner presigner; + private String testKey; + + @BeforeAll + static void loadEnv() throws IOException { + Properties props = new Properties(); + try (FileInputStream fis = new FileInputStream(".env")) { + props.load(fis); + } + + accessKey = props.getProperty("AWS_S3_ACCESS_KEY"); + secretKey = props.getProperty("AWS_S3_SECRET_KEY"); + region = props.getProperty("AWS_S3_REGION"); + bucket = props.getProperty("AWS_S3_BUCKET"); + + if (accessKey == null || secretKey == null || region == null || bucket == null) { + throw new IllegalStateException("AWS S3 설정이 .env 파일에 올바르게 정의되지 않았습니다."); + } + } + + @BeforeEach + void setUp() { + s3Client = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + + presigner = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + + testKey = "test-" + UUID.randomUUID().toString(); + } + + @Test + @DisplayName("S3에 파일을 업로드한다") + void uploadToS3() { + String content = "Hello from .env via Properties!"; + + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .contentType("text/plain") + .build(); + + s3Client.putObject(request, RequestBody.fromString(content)); + log.info("파일 업로드 성공: {}", testKey); + } catch (S3Exception e) { + log.error("파일 업로드 실패: {}", e.getMessage()); + throw e; + } + } + + @Test + @DisplayName("S3에서 파일을 다운로드한다") + void downloadFromS3() { + // 테스트를 위한 파일 먼저 업로드 + String content = "Test content for download"; + PutObjectRequest uploadRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .contentType("text/plain") + .build(); + s3Client.putObject(uploadRequest, RequestBody.fromString(content)); + + try { + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .build(); + + String downloadedContent = s3Client.getObjectAsBytes(request).asUtf8String(); + log.info("다운로드된 파일 내용: {}", downloadedContent); + } catch (S3Exception e) { + log.error("파일 다운로드 실패: {}", e.getMessage()); + throw e; + } + } + + @Test + @DisplayName("S3 파일에 대한 Presigned URL을 생성한다") + void generatePresignedUrl() { + // 테스트를 위한 파일 먼저 업로드 + String content = "Test content for presigned URL"; + PutObjectRequest uploadRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .contentType("text/plain") + .build(); + s3Client.putObject(uploadRequest, RequestBody.fromString(content)); + + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest); + URL url = presignedRequest.url(); + + log.info("생성된 Presigned URL: {}", url); + } catch (S3Exception e) { + log.error("Presigned URL 생성 실패: {}", e.getMessage()); + throw e; + } + } + + @AfterEach + void cleanup() { + try { + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .build(); + s3Client.deleteObject(request); + log.info("테스트 파일 정리 완료: {}", testKey); + } catch (S3Exception e) { + log.error("테스트 파일 정리 실패: {}", e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java new file mode 100644 index 000000000..b758d402f --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java @@ -0,0 +1,147 @@ +package com.sprint.mission.discodeit.storage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import java.io.IOException; +import java.io.InputStream; +import java.util.NoSuchElementException; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + +@Disabled +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("S3BinaryContentStorage 테스트") +class S3BinaryContentStorageTest { + + @Autowired + private S3BinaryContentStorage s3BinaryContentStorage; + + @Value("${discodeit.storage.s3.bucket}") + private String bucket; + + @Value("${discodeit.storage.s3.access-key}") + private String accessKey; + + @Value("${discodeit.storage.s3.secret-key}") + private String secretKey; + + @Value("${discodeit.storage.s3.region}") + private String region; + + private final UUID testId = UUID.randomUUID(); + private final byte[] testData = "테스트 데이터".getBytes(); + + @BeforeEach + void setUp() { + // 테스트 준비 작업 + // 실제 S3BinaryContentStorage는 스프링이 의존성 주입으로 제공 + } + + @AfterEach + void tearDown() { + // 테스트 종료 후 생성된 S3 객체 삭제 + try { + // S3 클라이언트 생성 + S3Client s3Client = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + + // 테스트에서 생성한 객체 삭제 + DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() + .bucket(bucket) + .key(testId.toString()) + .build(); + + s3Client.deleteObject(deleteRequest); + System.out.println("테스트 객체 삭제 완료: " + testId); + } catch (NoSuchKeyException e) { + // 객체가 이미 없는 경우는 무시 + System.out.println("삭제할 객체가 없음: " + testId); + } catch (Exception e) { + // 정리 실패 시 로그만 남기고 테스트는 실패로 처리하지 않음 + System.err.println("테스트 객체 정리 실패: " + e.getMessage()); + } + } + + @Test + @DisplayName("S3에 파일 업로드 성공 테스트") + void put_success() { + // when + UUID resultId = s3BinaryContentStorage.put(testId, testData); + + // then + assertThat(resultId).isEqualTo(testId); + } + + @Test + @DisplayName("S3에서 파일 다운로드 테스트") + void get_success() throws IOException { + // given + s3BinaryContentStorage.put(testId, testData); + + // when + InputStream result = s3BinaryContentStorage.get(testId); + + // then + assertNotNull(result); + + // 내용 검증 + byte[] resultBytes = result.readAllBytes(); + assertThat(resultBytes).isEqualTo(testData); + } + + @Test + @DisplayName("존재하지 않는 파일 조회 시 예외 발생 테스트") + void get_notFound() { + // when & then + assertThatThrownBy(() -> s3BinaryContentStorage.get(UUID.randomUUID())) + .isInstanceOf(NoSuchElementException.class); + } + + @Test + @DisplayName("Presigned URL 생성 테스트") + void download_success() { + // given + s3BinaryContentStorage.put(testId, testData); + BinaryContentDto dto = new BinaryContentDto( + testId, "test.txt", (long) testData.length, "text/plain" + ); + + // when + ResponseEntity response = s3BinaryContentStorage.download(dto); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND); + assertThat(response.getHeaders().get(HttpHeaders.LOCATION)).isNotNull(); + + String location = response.getHeaders().getFirst(HttpHeaders.LOCATION); + assertThat(location).contains(bucket); + assertThat(location).contains(testId.toString()); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 000000000..7df254a4c --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,19 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + +logging: + level: + com.sprint.mission.discodeit: debug + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace \ No newline at end of file From f4e59801b5407d5df00eb5f6926a951d23f7e85b Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Fri, 26 Sep 2025 17:53:46 +0900 Subject: [PATCH 13/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20CS?= =?UTF-8?q?RF=20=EB=B3=B4=ED=98=B8=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../discodeit/config/SecurityConfig.java | 57 +++++++++++++++++++ .../discodeit/exception/ErrorCode.java | 3 +- .../exception/GlobalExceptionHandler.java | 3 +- src/main/resources/application-dev.yaml | 1 + 5 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java diff --git a/build.gradle b/build.gradle index 41fcbe7cc..88006db39 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'software.amazon.awssdk:s3:2.31.7' + implementation 'org.springframework.boot:spring-boot-starter-security' runtimeOnly 'org.postgresql:postgresql' compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java new file mode 100644 index 000000000..29eabbd07 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -0,0 +1,57 @@ +package com.sprint.mission.discodeit.config; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.function.Supplier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.csrf.CsrfTokenRequestHandler; +import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; +import org.springframework.util.StringUtils; + +@Configuration +@EnableWebSecurity(debug = true ) +public class SecurityConfig { + + + @Bean + public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { + + + http + .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) + ; + + return http.build(); + } + + + static class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler { + + private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler(); + private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + Supplier csrfToken) { + + this.xor.handle(request,response,csrfToken); + + } + + @Override + public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) { + String headerValue = request.getHeader(csrfToken.getHeaderName()); + + return (StringUtils.hasText(headerValue) ? this.plain : this.xor).resolveCsrfTokenValue(request, csrfToken); + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java index e8dc58033..243cfc00e 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java @@ -29,7 +29,8 @@ public enum ErrorCode { // Server 에러 코드 INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다."), - INVALID_REQUEST("잘못된 요청입니다."); + INVALID_REQUEST("잘못된 요청입니다."), + DUPLICATE_EMAIL("중복된 이메일입니다."); private final String message; diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java index f5ecc566a..cc58979e1 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import java.util.HashMap; import java.util.Map; +import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties.Http; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; @@ -68,7 +69,7 @@ private HttpStatus determineHttpStatus(DiscodeitException exception) { READ_STATUS_NOT_FOUND, USER_STATUS_NOT_FOUND -> HttpStatus.NOT_FOUND; case DUPLICATE_USER, DUPLICATE_READ_STATUS, DUPLICATE_USER_STATUS -> HttpStatus.CONFLICT; case INVALID_USER_CREDENTIALS -> HttpStatus.UNAUTHORIZED; - case PRIVATE_CHANNEL_UPDATE, INVALID_REQUEST -> HttpStatus.BAD_REQUEST; + case PRIVATE_CHANNEL_UPDATE, INVALID_REQUEST, DUPLICATE_EMAIL -> HttpStatus.BAD_REQUEST; case INTERNAL_SERVER_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; }; } diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 22ae092e6..7b1addb62 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -16,6 +16,7 @@ logging: com.sprint.mission.discodeit: debug org.hibernate.SQL: debug org.hibernate.orm.jdbc.bind: trace + org.springframework.security: trace management: endpoint: From a8a46a8d641e423f305c5cb54a1677fc4acb02aa Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Fri, 26 Sep 2025 17:58:33 +0900 Subject: [PATCH 14/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20CS?= =?UTF-8?q?RF=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mission/discodeit/controller/AuthController.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java index 8d3d2a9f9..e84efb3cf 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -5,9 +5,12 @@ import com.sprint.mission.discodeit.dto.request.LoginRequest; import com.sprint.mission.discodeit.service.AuthService; import jakarta.validation.Valid; +import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -31,4 +34,12 @@ public ResponseEntity login(@RequestBody @Valid LoginRequest loginReque .status(HttpStatus.OK) .body(user); } + + @GetMapping("csrf-token") + public ResponseEntity getCsrfToken(CsrfToken csrfToken) { + String tokenValue = csrfToken.getToken(); + log.debug("CSRF 토큰 요청 : token={}", tokenValue); + + return ResponseEntity.noContent().build(); + } } From 08c0fc73cdaa07b6319351b3e120e706c1916218 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Mon, 29 Sep 2025 16:52:17 +0900 Subject: [PATCH 15/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20Pa?= =?UTF-8?q?ssword=EB=A5=BC=20=ED=95=B4=EC=8B=9C=EC=B2=98=EB=A6=AC=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20Bcrypt=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mission/discodeit/config/SecurityConfig.java | 7 +++++++ .../discodeit/service/basic/BasicUserService.java | 10 ++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java index 29eabbd07..f4387afea 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -8,6 +8,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfToken; @@ -54,4 +56,9 @@ public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfTo return (StringUtils.hasText(headerValue) ? this.plain : this.xor).resolveCsrfTokenValue(request, csrfToken); } } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java index 12a0a3222..69e0d93a6 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -20,6 +20,7 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; @@ -34,6 +35,7 @@ public class BasicUserService implements UserService { private final UserMapper userMapper; private final BinaryContentRepository binaryContentRepository; private final BinaryContentStorage binaryContentStorage; + private final PasswordEncoder passwordEncoder; @Transactional @Override @@ -63,9 +65,13 @@ public UserDto create(UserCreateRequest userCreateRequest, return binaryContent; }) .orElse(null); - String password = userCreateRequest.password(); - User user = new User(username, email, password, nullableProfile); + //String password = userCreateRequest.password(); + + // 해싱된 패스워드로 교체 + String encodedPassword = passwordEncoder.encode(userCreateRequest.password()); + + User user = new User(username, email, encodedPassword, nullableProfile); Instant now = Instant.now(); UserStatus userStatus = new UserStatus(user, now); From a31fc7ad40ecadb6d8e22dbabe623cc2bfa43870 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Tue, 30 Sep 2025 11:28:44 +0900 Subject: [PATCH 16/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20Us?= =?UTF-8?q?erDetails,=20UserDetailsService,=20LoginSuccessHandler=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discodeit/config/SecurityConfig.java | 13 +++-- .../security/DiscodeitUserDetails.java | 54 +++++++++++++++++++ .../security/DiscodeitUserDetailsService.java | 24 +++++++++ .../security/LoginSuccessHandler.java | 34 ++++++++++++ 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java index f4387afea..b7fbba711 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -1,13 +1,17 @@ package com.sprint.mission.discodeit.config; +import com.sprint.mission.discodeit.security.LoginSuccessHandler; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @@ -20,17 +24,20 @@ @Configuration @EnableWebSecurity(debug = true ) +@RequiredArgsConstructor public class SecurityConfig { + private final LoginSuccessHandler loginSuccessHandler; @Bean public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { - http + .formLogin(login -> login.loginProcessingUrl("/api/auth/login") + .successHandler(loginSuccessHandler)) .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) - ; + .authorizeHttpRequests(auth ->auth.anyRequest().authenticated()); return http.build(); } @@ -59,6 +66,6 @@ public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfTo @Bean public PasswordEncoder passwordEncoder() { - return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + return new BCryptPasswordEncoder(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java new file mode 100644 index 000000000..ec493867e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java @@ -0,0 +1,54 @@ +package com.sprint.mission.discodeit.security; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import java.util.Collection; +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +@Getter +@RequiredArgsConstructor +public class DiscodeitUserDetails implements UserDetails { + + private final UserDto userDto; + private final String password; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return userDto.username(); + } + + @Override + public boolean isAccountNonExpired() { + return UserDetails.super.isAccountNonExpired(); + } + + @Override + public boolean isAccountNonLocked() { + return UserDetails.super.isAccountNonLocked(); + } + + @Override + public boolean isCredentialsNonExpired() { + return UserDetails.super.isCredentialsNonExpired(); + } + + @Override + public boolean isEnabled() { + return UserDetails.super.isEnabled(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java new file mode 100644 index 000000000..f33eeada2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java @@ -0,0 +1,24 @@ +package com.sprint.mission.discodeit.security; + +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DiscodeitUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return userRepository.findByUsername(username) + .map(user -> new DiscodeitUserDetails(userMapper.toDto(user), user.getPassword())) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다." + username)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java new file mode 100644 index 000000000..190ff8152 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java @@ -0,0 +1,34 @@ +package com.sprint.mission.discodeit.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.UserDto; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +public class LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final ObjectMapper objectMapper; + + public LoginSuccessHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + DiscodeitUserDetails principal = (DiscodeitUserDetails) authentication.getPrincipal(); + UserDto userDto = principal.getUserDto(); + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), userDto); + + } +} From b139a1875c82012d3fd5ba6826d352850b1e41e1 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Tue, 30 Sep 2025 13:04:41 +0900 Subject: [PATCH 17/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20Lo?= =?UTF-8?q?ginFailureHandler=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discodeit/config/SecurityConfig.java | 7 ++-- .../security/LoginFailureHandler.java | 32 +++++++++++++++++++ .../security/LoginSuccessHandler.java | 6 ++-- 3 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java index b7fbba711..d057c4f9d 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.sprint.mission.discodeit.config; +import com.sprint.mission.discodeit.security.LoginFailureHandler; import com.sprint.mission.discodeit.security.LoginSuccessHandler; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; @@ -30,11 +31,13 @@ public class SecurityConfig { private final LoginSuccessHandler loginSuccessHandler; @Bean - public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain (HttpSecurity http, + LoginFailureHandler loginFailureHandler) throws Exception { http .formLogin(login -> login.loginProcessingUrl("/api/auth/login") - .successHandler(loginSuccessHandler)) + .successHandler(loginSuccessHandler) + .failureHandler(loginFailureHandler)) .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) .authorizeHttpRequests(auth ->auth.anyRequest().authenticated()); diff --git a/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java b/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java new file mode 100644 index 000000000..a619b9dff --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java @@ -0,0 +1,32 @@ +package com.sprint.mission.discodeit.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LoginFailureHandler implements AuthenticationFailureHandler { + + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse(exception, HttpServletResponse.SC_UNAUTHORIZED); + + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java index 190ff8152..740391c36 100644 --- a/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java @@ -6,19 +6,17 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class LoginSuccessHandler implements AuthenticationSuccessHandler { private final ObjectMapper objectMapper; - public LoginSuccessHandler(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; - } - @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { From b3167d7fb6b2958ccaa588c92f4953ad00038587 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Wed, 1 Oct 2025 16:24:42 +0900 Subject: [PATCH 18/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=95=88=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0,=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EB=B6=80=EC=97=AC=20=EC=99=84=EB=A3=8C,=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discodeit/config/SecurityConfig.java | 40 +++++++++++++++---- .../discodeit/controller/AuthController.java | 21 +++++----- .../discodeit/controller/api/AuthApi.java | 18 --------- .../discodeit/dto/request/LoginRequest.java | 6 +-- .../discodeit/repository/UserRepository.java | 2 + .../security/DiscodeitUserDetailsService.java | 6 +-- .../security/LoginFailureHandler.java | 1 - .../security/LoginSuccessHandler.java | 1 + .../discodeit/service/AuthService.java | 2 +- .../service/basic/BasicAuthService.java | 18 --------- 10 files changed, 53 insertions(+), 62 deletions(-) diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java index d057c4f9d..f07cfcc76 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.sprint.mission.discodeit.config; +import com.sprint.mission.discodeit.security.DiscodeitUserDetailsService; import com.sprint.mission.discodeit.security.LoginFailureHandler; import com.sprint.mission.discodeit.security.LoginSuccessHandler; import jakarta.servlet.http.HttpServlet; @@ -9,13 +10,19 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; @@ -29,18 +36,37 @@ public class SecurityConfig { private final LoginSuccessHandler loginSuccessHandler; + private final LoginFailureHandler loginFailureHandler; + private final DiscodeitUserDetailsService userDetailsService; @Bean - public SecurityFilterChain filterChain (HttpSecurity http, - LoginFailureHandler loginFailureHandler) throws Exception { + public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { http - .formLogin(login -> login.loginProcessingUrl("/api/auth/login") + .csrf(csrf -> csrf. + csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) + ) + + .formLogin(login -> login + .loginPage("/index.html") + .loginProcessingUrl("/api/auth/login") .successHandler(loginSuccessHandler) - .failureHandler(loginFailureHandler)) - .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) - .authorizeHttpRequests(auth ->auth.anyRequest().authenticated()); + .failureHandler(loginFailureHandler) + .permitAll() + ) + + .logout(logout -> logout + .logoutUrl("/api/auth/logout") + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.NO_CONTENT)) + .deleteCookies("JSESSIONID")) + + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/index.html", "/assets/**", "/css/**", "/js/**", "/api/auth/**", "/api/users").permitAll() + .anyRequest().authenticated() + ) + .userDetailsService(userDetailsService) + ; return http.build(); } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java index e84efb3cf..b4a1eb30b 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -3,14 +3,18 @@ import com.sprint.mission.discodeit.controller.api.AuthApi; import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; import com.sprint.mission.discodeit.service.AuthService; import jakarta.validation.Valid; +import java.security.Principal; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -25,15 +29,6 @@ public class AuthController implements AuthApi { private final AuthService authService; - @PostMapping(path = "login") - public ResponseEntity login(@RequestBody @Valid LoginRequest loginRequest) { - log.info("로그인 요청: username={}", loginRequest.username()); - UserDto user = authService.login(loginRequest); - log.debug("로그인 응답: {}", user); - return ResponseEntity - .status(HttpStatus.OK) - .body(user); - } @GetMapping("csrf-token") public ResponseEntity getCsrfToken(CsrfToken csrfToken) { @@ -42,4 +37,12 @@ public ResponseEntity getCsrfToken(CsrfToken csrfToken) { return ResponseEntity.noContent().build(); } + + @GetMapping("/me") + public ResponseEntity me(@AuthenticationPrincipal DiscodeitUserDetails userPrincipal) { + if(userPrincipal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + return ResponseEntity.ok(userPrincipal.getUserDto()); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java index ee9ce79f9..9881e31bc 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java @@ -15,22 +15,4 @@ @Tag(name = "Auth", description = "인증 API") public interface AuthApi { - @Operation(summary = "로그인") - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", description = "로그인 성공", - content = @Content(schema = @Schema(implementation = UserDto.class)) - ), - @ApiResponse( - responseCode = "404", description = "사용자를 찾을 수 없음", - content = @Content(examples = @ExampleObject(value = "User with username {username} not found")) - ), - @ApiResponse( - responseCode = "400", description = "비밀번호가 일치하지 않음", - content = @Content(examples = @ExampleObject(value = "Wrong password")) - ) - }) - ResponseEntity login( - @Parameter(description = "로그인 정보") LoginRequest loginRequest - ); } \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java index 40452eea2..324403552 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java @@ -3,11 +3,7 @@ import jakarta.validation.constraints.NotBlank; public record LoginRequest( - @NotBlank(message = "사용자 이름은 필수입니다") - String username, - - @NotBlank(message = "비밀번호는 필수입니다") - String password + ) { } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java index f7103705f..d2765893c 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java @@ -11,6 +11,8 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); + Optional findByEmail(String email); + boolean existsByEmail(String email); boolean existsByUsername(String username); diff --git a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java index f33eeada2..df00a1f55 100644 --- a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java +++ b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java @@ -16,9 +16,9 @@ public class DiscodeitUserDetailsService implements UserDetailsService { private final UserMapper userMapper; @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - return userRepository.findByUsername(username) + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + return userRepository.findByEmail(email) .map(user -> new DiscodeitUserDetails(userMapper.toDto(user), user.getPassword())) - .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다." + username)); + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다." + email)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java b/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java index a619b9dff..d30cf4e15 100644 --- a/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java @@ -21,7 +21,6 @@ public class LoginFailureHandler implements AuthenticationFailureHandler { public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); diff --git a/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java index 740391c36..50bbcc0f0 100644 --- a/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java @@ -26,6 +26,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.setStatus(HttpServletResponse.SC_OK); response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), userDto); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java index a1caf1d2d..57b4fb02a 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java @@ -5,5 +5,5 @@ public interface AuthService { - UserDto login(LoginRequest loginRequest); + } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java index 6785cff2f..24d6d8d96 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -21,22 +21,4 @@ public class BasicAuthService implements AuthService { private final UserRepository userRepository; private final UserMapper userMapper; - @Transactional(readOnly = true) - @Override - public UserDto login(LoginRequest loginRequest) { - log.debug("로그인 시도: username={}", loginRequest.username()); - - String username = loginRequest.username(); - String password = loginRequest.password(); - - User user = userRepository.findByUsername(username) - .orElseThrow(() -> UserNotFoundException.withUsername(username)); - - if (!user.getPassword().equals(password)) { - throw InvalidCredentialsException.wrongPassword(); - } - - log.info("로그인 성공: userId={}, username={}", user.getId(), username); - return userMapper.toDto(user); - } } From a07efb05d62d61c920c9d22d0a4590453604ab54 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Wed, 1 Oct 2025 18:05:31 +0900 Subject: [PATCH 19/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20-=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C.=20=EC=9D=B8=EA=B0=80=20-=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=A0=95=EC=9D=98=EA=B9=8C=EC=A7=80=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discodeit/controller/AuthController.java | 10 ++++- .../mission/discodeit/dto/data/UserDto.java | 4 +- .../dto/request/RoleUpdateRequest.java | 10 +++++ .../sprint/mission/discodeit/entity/Role.java | 7 +++ .../sprint/mission/discodeit/entity/User.java | 12 +++++ .../discodeit/security/AdminInitializer.java | 44 +++++++++++++++++++ .../security/DiscodeitUserDetails.java | 7 ++- .../discodeit/service/AuthService.java | 2 + .../service/basic/BasicAuthService.java | 14 ++++++ .../service/basic/BasicUserService.java | 2 + src/main/resources/application.yaml | 2 +- src/main/resources/schema.sql | 4 ++ 12 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/RoleUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/Role.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java index b4a1eb30b..cfa464cf6 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -3,6 +3,7 @@ import com.sprint.mission.discodeit.controller.api.AuthApi; import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; import com.sprint.mission.discodeit.security.DiscodeitUserDetails; import com.sprint.mission.discodeit.service.AuthService; import jakarta.validation.Valid; @@ -16,8 +17,10 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import lombok.extern.slf4j.Slf4j; @@ -29,7 +32,6 @@ public class AuthController implements AuthApi { private final AuthService authService; - @GetMapping("csrf-token") public ResponseEntity getCsrfToken(CsrfToken csrfToken) { String tokenValue = csrfToken.getToken(); @@ -45,4 +47,10 @@ public ResponseEntity me(@AuthenticationPrincipal DiscodeitUserDetails } return ResponseEntity.ok(userPrincipal.getUserDto()); } + + @PutMapping("/role") + public ResponseEntity updateRole(@RequestBody RoleUpdateRequest request){ + UserDto userDto = authService.updateUserRole(request); + return ResponseEntity.ok(userDto); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java index aa696a69f..7f3b6920b 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java @@ -1,5 +1,6 @@ package com.sprint.mission.discodeit.dto.data; +import com.sprint.mission.discodeit.entity.Role; import java.util.UUID; public record UserDto( @@ -7,7 +8,8 @@ public record UserDto( String username, String email, BinaryContentDto profile, - Boolean online + Boolean online, + Role role ) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/RoleUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/RoleUpdateRequest.java new file mode 100644 index 000000000..fbe60e169 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/RoleUpdateRequest.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.dto.request; + +import com.sprint.mission.discodeit.entity.Role; +import java.util.UUID; + +public record RoleUpdateRequest( + UUID userId, + Role newRole +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Role.java b/src/main/java/com/sprint/mission/discodeit/entity/Role.java new file mode 100644 index 000000000..fb61b456c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Role.java @@ -0,0 +1,7 @@ +package com.sprint.mission.discodeit.entity; + +public enum Role { + ADMIN, + CHANNER_MANAGER, + USER +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/User.java b/src/main/java/com/sprint/mission/discodeit/entity/User.java index 7961aaecc..5f40e2a26 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/User.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/User.java @@ -5,11 +5,15 @@ import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -18,6 +22,8 @@ @Table(name = "users") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA를 위한 기본 생성자 +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder public class User extends BaseUpdatableEntity { @Column(length = 50, nullable = false, unique = true) @@ -34,6 +40,9 @@ public class User extends BaseUpdatableEntity { @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private UserStatus status; + @Enumerated(EnumType.STRING) + private Role role; + public User(String username, String email, String password, BinaryContent profile) { this.username = username; this.email = email; @@ -56,4 +65,7 @@ public void update(String newUsername, String newEmail, String newPassword, this.profile = newProfile; } } + public void updateRole(Role role){ + this.role = role; + } } diff --git a/src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java b/src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java new file mode 100644 index 000000000..e0ac8cfe6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java @@ -0,0 +1,44 @@ +package com.sprint.mission.discodeit.security; + +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.repository.UserRepository; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient.Builder; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AdminInitializer { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @EventListener(ApplicationReadyEvent.class) + public void init() { + //어드민 계정이 존재하는지 확인 + log.info("어드민 계정 초기화 시작"); + if(!userRepository.existsByUsername("admin")){ + User admin = User.builder() + .username("admin") + .email("admin@admin.com") + .password(passwordEncoder.encode("admin123!!")) + .role(Role.ADMIN) + .build(); + + UserStatus userStatus = new UserStatus(admin, Instant.now()); + + userRepository.save(admin); + log.info("어드민 계정 초기화 완료"); + } + + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java index ec493867e..d724efc94 100644 --- a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java +++ b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java @@ -1,6 +1,7 @@ package com.sprint.mission.discodeit.security; import com.sprint.mission.discodeit.dto.data.UserDto; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import lombok.Getter; @@ -19,7 +20,11 @@ public class DiscodeitUserDetails implements UserDetails { @Override public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority("ROLE_USER")); + List authorities = new ArrayList<>(); + if(userDto.role() != null){ + authorities.add(new SimpleGrantedAuthority("ROLE_" + userDto.role().name())); + } + return authorities; } @Override diff --git a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java index 57b4fb02a..542f5f17d 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java @@ -2,8 +2,10 @@ import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; public interface AuthService { + UserDto updateUserRole(RoleUpdateRequest roleUpdateRequest); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java index 24d6d8d96..262deaf80 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -2,6 +2,7 @@ import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; import com.sprint.mission.discodeit.entity.User; import com.sprint.mission.discodeit.exception.user.InvalidCredentialsException; import com.sprint.mission.discodeit.exception.user.UserNotFoundException; @@ -21,4 +22,17 @@ public class BasicAuthService implements AuthService { private final UserRepository userRepository; private final UserMapper userMapper; + @Override + public UserDto updateUserRole(RoleUpdateRequest roleUpdateRequest) { + log.debug("유저 권한 수정 시작"); + + User userId = userRepository.findById(roleUpdateRequest + .userId()).orElseThrow(() -> new UserNotFoundException()); + + userId.updateRole(roleUpdateRequest.newRole()); + log.debug("유저 권한 수정 완료"); + return userMapper.toDto(userId); + + } + } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java index 69e0d93a6..b4e60aca0 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -5,6 +5,7 @@ import com.sprint.mission.discodeit.dto.request.UserCreateRequest; import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Role; import com.sprint.mission.discodeit.entity.User; import com.sprint.mission.discodeit.entity.UserStatus; import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException; @@ -72,6 +73,7 @@ public UserDto create(UserCreateRequest userCreateRequest, String encodedPassword = passwordEncoder.encode(userCreateRequest.password()); User user = new User(username, email, encodedPassword, nullableProfile); + user.updateRole(Role.USER); Instant now = Instant.now(); UserStatus userStatus = new UserStatus(user, now); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index cedbf7c94..637646302 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,7 +9,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: validate + ddl-auto: create open-in-view: false profiles: active: diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index c658649cd..7b6c9f15f 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -8,6 +8,7 @@ CREATE TABLE users username varchar(50) UNIQUE NOT NULL, email varchar(100) UNIQUE NOT NULL, password varchar(60) NOT NULL, + role varchar(20) NOT NULL, profile_id uuid ); @@ -74,6 +75,9 @@ CREATE TABLE read_statuses UNIQUE (user_id, channel_id) ); +ALTER TABLE users + ADD role varchar(20) NOT NULL; + -- 제약 조건 -- User (1) -> BinaryContent (1) From a744e2c9a5ebb01c9c13019091686c4868bba944 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Wed, 1 Oct 2025 19:09:33 +0900 Subject: [PATCH 20/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20-=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=B6=80=EC=97=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discodeit/config/SecurityConfig.java | 24 +++++++++++++++++-- .../service/basic/BasicAuthService.java | 2 ++ .../service/basic/BasicChannelService.java | 4 ++++ src/main/resources/application.yaml | 2 +- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java index f07cfcc76..fe8bb25cb 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -12,6 +12,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -33,6 +34,7 @@ @Configuration @EnableWebSecurity(debug = true ) @RequiredArgsConstructor +@EnableMethodSecurity(securedEnabled = true) public class SecurityConfig { private final LoginSuccessHandler loginSuccessHandler; @@ -62,16 +64,34 @@ public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { .deleteCookies("JSESSIONID")) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/index.html", "/assets/**", "/css/**", "/js/**", "/api/auth/**", "/api/users").permitAll() + .requestMatchers("/", + "/index.html", + "/assets/**", + "/css/**", + "/js/**", + "/api/auth/**", + "/api/users" + ).permitAll() .anyRequest().authenticated() ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, + response, + authException) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("인증이 필요합니다."); + }) + .accessDeniedHandler((request, + response, + accessDeniedException) -> { response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.getWriter().write("권한이 없습니다."); + })) + .userDetailsService(userDetailsService) ; return http.build(); } - static class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler { private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler(); diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java index 262deaf80..16bd20724 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -10,6 +10,7 @@ import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.service.AuthService; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; @@ -23,6 +24,7 @@ public class BasicAuthService implements AuthService { private final UserMapper userMapper; @Override + @PreAuthorize("hasRole('ADMIN')") public UserDto updateUserRole(RoleUpdateRequest roleUpdateRequest) { log.debug("유저 권한 수정 시작"); diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java index 00ab04087..850fb036e 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; @@ -36,6 +37,7 @@ public class BasicChannelService implements ChannelService { @Transactional @Override + @PreAuthorize("hasRole('CHANNEL_MANAGER')") public ChannelDto create(PublicChannelCreateRequest request) { log.debug("채널 생성 시작: {}", request); String name = request.name(); @@ -87,6 +89,7 @@ public List findAllByUserId(UUID userId) { @Transactional @Override + @PreAuthorize("hasRole('CHANNEL_MANAGER')") public ChannelDto update(UUID channelId, PublicChannelUpdateRequest request) { log.debug("채널 수정 시작: id={}, request={}", channelId, request); String newName = request.newName(); @@ -103,6 +106,7 @@ public ChannelDto update(UUID channelId, PublicChannelUpdateRequest request) { @Transactional @Override + @PreAuthorize("hasRole('CHANNEL_MANAGER')") public void delete(UUID channelId) { log.debug("채널 삭제 시작: id={}", channelId); if (!channelRepository.existsById(channelId)) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 637646302..cedbf7c94 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,7 +9,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: create + ddl-auto: validate open-in-view: false profiles: active: From bde9f0840eb775e64eb06ec37d7d07bb75a41fbe Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Wed, 1 Oct 2025 22:27:12 +0900 Subject: [PATCH 21/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20te?= =?UTF-8?q?st.yml=20=ED=8C=8C=EC=9D=BC=EC=97=90=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EB=B6=80=EC=97=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97e594116..be2b5c936 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,6 +20,9 @@ jobs: distribution: 'corretto' cache: gradle + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: 테스트 실행 run: ./gradlew test From eae627a5ae6e7d218c6ce54ae5fc9f0ba073a5c3 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Thu, 2 Oct 2025 14:19:52 +0900 Subject: [PATCH 22/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20?= =?UTF-8?q?=EC=8B=AC=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discodeit/config/SecurityConfig.java | 70 ++++++++++++++++--- .../sprint/mission/discodeit/entity/Role.java | 2 +- .../exception/GlobalExceptionHandler.java | 18 +++++ .../security/DiscodeitUserDetails.java | 16 ++++- .../discodeit/security/MessageSecurity.java | 22 ++++++ .../service/basic/BasicAuthService.java | 20 ++++++ .../service/basic/BasicMessageService.java | 5 +- .../service/basic/BasicUserService.java | 5 +- src/main/resources/static/schema.sql | 7 ++ 9 files changed, 151 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/sprint/mission/discodeit/security/MessageSecurity.java diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java index fe8bb25cb..8328ad233 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -3,32 +3,35 @@ import com.sprint.mission.discodeit.security.DiscodeitUserDetailsService; import com.sprint.mission.discodeit.security.LoginFailureHandler; import com.sprint.mission.discodeit.security.LoginSuccessHandler; -import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.function.Supplier; +import javax.sql.DataSource; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; -import org.springframework.security.config.Customizer; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; +import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.csrf.CsrfTokenRequestHandler; import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; +import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.util.StringUtils; @Configuration @@ -42,7 +45,7 @@ public class SecurityConfig { private final DiscodeitUserDetailsService userDetailsService; @Bean - public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain (HttpSecurity http, SessionRegistry sessionRegistry, DataSource dataSource) throws Exception { http .csrf(csrf -> csrf. @@ -86,8 +89,22 @@ public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { response.getWriter().write("권한이 없습니다."); })) - .userDetailsService(userDetailsService) - ; + .sessionManagement(session ->session. + sessionConcurrency(concurrency -> concurrency + .maximumSessions(1) + .maxSessionsPreventsLogin(false) + .sessionRegistry(sessionRegistry)) + ) + + .rememberMe(remember -> remember + .key("uniqueAndSecret") + .tokenRepository(persistentTokenRepository(dataSource)) + .rememberMeParameter("remember-me") + .tokenValiditySeconds(3600) + .userDetailsService(userDetailsService) + ) + + ; return http.build(); } @@ -117,4 +134,37 @@ public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfTo public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public RoleHierarchy roleHierarchy() { + RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl(); + //계층 구조 정의 + String hierarchy = "ROLE_ADMIN > ROLE_CHANNEL_MANAGER \n ROLE_CHANNEL_MANAGER > ROLE_USER"; + roleHierarchy.setHierarchy(hierarchy); + return roleHierarchy; + } + @Bean + static MethodSecurityExpressionHandler methodSecurityExpressionHandler( + RoleHierarchy roleHierarchy) { + DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); + handler.setRoleHierarchy(roleHierarchy); + return handler; + } + + @Bean + public SessionRegistry sessionRegistry() { // 세션 등록/조회용 레지스트리 + return new SessionRegistryImpl(); + } + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + + @Bean + public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) { + JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl(); + repo.setDataSource(dataSource); + return repo; + } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Role.java b/src/main/java/com/sprint/mission/discodeit/entity/Role.java index fb61b456c..6b9c0b052 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/Role.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/Role.java @@ -2,6 +2,6 @@ public enum Role { ADMIN, - CHANNER_MANAGER, + CHANNEL_MANAGER, USER } diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java index cc58979e1..7fdfaffcc 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -7,6 +7,7 @@ import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties.Http; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -73,4 +74,21 @@ private HttpStatus determineHttpStatus(DiscodeitException exception) { case INTERNAL_SERVER_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; }; } + + // 권한 없음 → 403 + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex) { + log.warn("권한 없음: {}", ex.getMessage()); + + ErrorResponse response = new ErrorResponse( + Instant.now(), + "FORBIDDEN", + "접근 권한이 없습니다", + null, + ex.getClass().getSimpleName(), + HttpStatus.FORBIDDEN.value() + ); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java index d724efc94..c695834be 100644 --- a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java +++ b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java @@ -34,7 +34,7 @@ public String getPassword() { @Override public String getUsername() { - return userDto.username(); + return userDto.email(); } @Override @@ -56,4 +56,18 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return UserDetails.super.isEnabled(); } + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof DiscodeitUserDetails)) return false; + DiscodeitUserDetails that = (DiscodeitUserDetails) o; + return this.getUsername().equals(that.getUsername()); + } + + @Override + public int hashCode() { + return this.getUsername().hashCode(); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/security/MessageSecurity.java b/src/main/java/com/sprint/mission/discodeit/security/MessageSecurity.java new file mode 100644 index 000000000..47343f5a1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/MessageSecurity.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.security; + +import com.sprint.mission.discodeit.repository.MessageRepository; +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component("messageSecurity") +public class MessageSecurity { + + private final MessageRepository messageRepository; + + public MessageSecurity(MessageRepository messageRepository) { + this.messageRepository = messageRepository; + } + + public boolean isMessageAuthor(UUID userId, UUID messageId) { + return messageRepository.findById(messageId) + .map(message -> message.getAuthor().getId().equals(userId)) + .orElse(false); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java index 16bd20724..9d9e3e53c 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -8,9 +8,13 @@ import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; import com.sprint.mission.discodeit.service.AuthService; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; @@ -18,10 +22,12 @@ @Slf4j @RequiredArgsConstructor @Service +@Transactional public class BasicAuthService implements AuthService { private final UserRepository userRepository; private final UserMapper userMapper; + private final SessionRegistry sessionRegistry; @Override @PreAuthorize("hasRole('ADMIN')") @@ -32,9 +38,23 @@ public UserDto updateUserRole(RoleUpdateRequest roleUpdateRequest) { .userId()).orElseThrow(() -> new UserNotFoundException()); userId.updateRole(roleUpdateRequest.newRole()); + + expireUserSession(userId.getEmail()); log.debug("유저 권한 수정 완료"); return userMapper.toDto(userId); } + private void expireUserSession(String email){ + List principals = sessionRegistry.getAllPrincipals(); + for (Object principal : principals) { + if (principal instanceof DiscodeitUserDetails userDetails && + userDetails.getUsername().equals(email)) { + sessionRegistry.getAllSessions(principal, false) + .forEach(SessionInformation::expireNow); + } + } + } + } + diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java index 5516ac518..8b6a9dedc 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java @@ -25,11 +25,12 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import lombok.extern.slf4j.Slf4j; @Slf4j @Service @@ -112,6 +113,7 @@ public PageResponse findAllByChannelId(UUID channelId, Instant creat @Transactional @Override + @PreAuthorize("@messageSecurity.isMessageAuthor(authentication.principal.userDto.id, #messageId)") public MessageDto update(UUID messageId, MessageUpdateRequest request) { log.debug("메시지 수정 시작: id={}, request={}", messageId, request); Message message = messageRepository.findById(messageId) @@ -124,6 +126,7 @@ public MessageDto update(UUID messageId, MessageUpdateRequest request) { @Transactional @Override + @PreAuthorize("@messageSecurity.isMessageAuthor(authentication.principal.userDto.id, #messageId)") public void delete(UUID messageId) { log.debug("메시지 삭제 시작: id={}", messageId); if (!messageRepository.existsById(messageId)) { diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java index b4e60aca0..1d3435523 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -21,10 +21,11 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import lombok.extern.slf4j.Slf4j; @Slf4j @RequiredArgsConstructor @@ -107,6 +108,7 @@ public List findAll() { @Transactional @Override + @PreAuthorize("#userId == authentication.principal.userDto.id") public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, Optional optionalProfileCreateRequest) { log.debug("사용자 수정 시작: id={}, request={}", userId, userUpdateRequest); @@ -151,6 +153,7 @@ public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, @Transactional @Override + @PreAuthorize("#userId == authentication.principal.userDto.id") public void delete(UUID userId) { log.debug("사용자 삭제 시작: id={}", userId); diff --git a/src/main/resources/static/schema.sql b/src/main/resources/static/schema.sql index b7487d57f..321f07762 100644 --- a/src/main/resources/static/schema.sql +++ b/src/main/resources/static/schema.sql @@ -72,4 +72,11 @@ CREATE TABLE message_attachments( attachment_id uuid, FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, FOREIGN KEY (attachment_id) REFERENCES binary_contents(id) ON DELETE CASCADE +); + +CREATE TABLE persistent_logins ( + username VARCHAR(64) NOT NULL, + series VARCHAR(64) PRIMARY KEY, + token VARCHAR(64) NOT NULL, + last_used TIMESTAMP NOT NULL ); \ No newline at end of file From 083c831699bec60e413c1a8526c8e4a2b74e088f Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Thu, 2 Oct 2025 14:28:02 +0900 Subject: [PATCH 23/28] =?UTF-8?q?feat(Security=20=EA=B4=80=EB=A6=AC):=20?= =?UTF-8?q?=EC=8B=AC=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index cedbf7c94..637646302 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,7 +9,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: validate + ddl-auto: create open-in-view: false profiles: active: From a62141755ae57fbb137766ed16879db69f4ece6a Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Thu, 2 Oct 2025 18:26:07 +0900 Subject: [PATCH 24/28] =?UTF-8?q?Merge=20remote-tracking=20branch=20'upstr?= =?UTF-8?q?eam/=EA=B0=95=EC=9D=80=ED=98=81'=20into=20sprint9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 충돌 해결 --- .../mission/discodeit/JavaApplication.java | 59 ----------------- .../jcf/JCFBinaryContentRepository.java | 56 ----------------- .../jcf/JCFReadStatusRepository.java | 58 ----------------- .../jcf/JCFUserStatusRepository.java | 52 --------------- .../service/basic/AuthServiceImpl.java | 21 ------- .../validate/BinaryContentValidator.java | 42 ------------- .../discodeit/validate/ChannelValidator.java | 26 -------- .../discodeit/validate/MessageValidator.java | 44 ------------- .../validate/ReadStatusValidator.java | 48 -------------- .../validate/UserStatusValidator.java | 35 ----------- .../discodeit/validate/UserValidator.java | 63 ------------------- 11 files changed, 504 deletions(-) delete mode 100644 src/main/java/com/sprint/mission/discodeit/JavaApplication.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFBinaryContentRepository.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFReadStatusRepository.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserStatusRepository.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/AuthServiceImpl.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/validate/BinaryContentValidator.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/validate/ChannelValidator.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/validate/MessageValidator.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/validate/ReadStatusValidator.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/validate/UserStatusValidator.java delete mode 100644 src/main/java/com/sprint/mission/discodeit/validate/UserValidator.java diff --git a/src/main/java/com/sprint/mission/discodeit/JavaApplication.java b/src/main/java/com/sprint/mission/discodeit/JavaApplication.java deleted file mode 100644 index 4074f6bcd..000000000 --- a/src/main/java/com/sprint/mission/discodeit/JavaApplication.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.sprint.mission.discodeit; - -import com.sprint.mission.discodeit.entity.*; -import com.sprint.mission.discodeit.repository.BinaryContentRepository; -import com.sprint.mission.discodeit.repository.ChannelRepository; -import com.sprint.mission.discodeit.repository.MessageRepository; -import com.sprint.mission.discodeit.repository.UserRepository; -import com.sprint.mission.discodeit.repository.file.FileChannelRepository; -import com.sprint.mission.discodeit.repository.file.FileMessageRepository; -import com.sprint.mission.discodeit.repository.file.FileUserRepository; -import com.sprint.mission.discodeit.service.ChannelService; -import com.sprint.mission.discodeit.service.MessageService; -import com.sprint.mission.discodeit.service.UserService; -import com.sprint.mission.discodeit.service.basic.BasicChannelService; -import com.sprint.mission.discodeit.service.basic.BasicMessageService; -import com.sprint.mission.discodeit.service.basic.BasicUserService; -import com.sprint.mission.discodeit.validate.UserValidator; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.ApplicationContext; - -public class JavaApplication { - /* static User setupUser(UserService userService) { - User user = userService.create("woody", "woody@codeit.com", "woody1234"); - return user; - } - - static Channel setupChannel(ChannelService channelService) { - Channel channel = channelService.create(ChannelType.PUBLIC, "공지", "공지 채널입니다."); - return channel; - } - - static void messageCreateTest(MessageService messageService, Channel channel, User author) { - Message message = messageService.create("안녕하세요.", channel.getId(), author.getId()); - System.out.println("메시지 생성: " + message.getId()); - } - - - public static void main(String[] args) { - - // 레포지토리 초기화 - UserRepository userRepository = new FileUserRepository(); - ChannelRepository channelRepository = new FileChannelRepository(); - MessageRepository messageRepository = new FileMessageRepository(); - - // 서비스 초기화 - UserService userService = new BasicUserService(userRepository); - ChannelService channelService = new BasicChannelService(channelRepository); - MessageService messageService = new BasicMessageService(messageRepository, channelRepository, userRepository); - - // 셋업 - User user = setupUser(userService); - Channel channel = setupChannel(channelService); - // 테스트 - messageCreateTest(messageService, channel, user); - - - }*/ -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFBinaryContentRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFBinaryContentRepository.java deleted file mode 100644 index 27642eb18..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFBinaryContentRepository.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentCreateDTO; -import com.sprint.mission.discodeit.entity.BinaryContent; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.repository.BinaryContentRepository; -import org.springframework.stereotype.Repository; - -import java.util.*; -import java.util.stream.Collectors; - -@Repository -public class JCFBinaryContentRepository implements BinaryContentRepository { - private final Map data; - - public JCFBinaryContentRepository() { - data = new HashMap<>(); - } - - - - @Override - public UUID create(BinaryContentCreateDTO binaryContentCreateDTO) { - return null; - } - - @Override - public UUID save(BinaryContent binaryContent) { - data.put(binaryContent.id(), binaryContent); - return binaryContent.id(); - } - - @Override - public BinaryContent find(UUID id) { - return data.get(id); - } - - - @Override - public List findAll() { - return new ArrayList<>(data.values()); - } - - @Override - public List findAllByIdIn(List ids) { - return ids.stream() - .map(data::get) - .filter(Objects::nonNull) - .toList(); - } - @Override - public UUID delete(UUID id) { - data.remove(id); - return id; - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFReadStatusRepository.java deleted file mode 100644 index 9de5a85f6..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFReadStatusRepository.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.ReadStatus; -import com.sprint.mission.discodeit.repository.ReadStatusRepository; -import org.springframework.stereotype.Repository; - -import java.util.*; - -@Repository -public class JCFReadStatusRepository implements ReadStatusRepository { - private final Map data = new HashMap<>(); - - @Override - public UUID save(ReadStatus readStatus) { - data.put(readStatus.getId(), readStatus); - return readStatus.getId(); - } - - @Override - public ReadStatus findId(UUID userId) { - return data.get(userId); - } - - @Override - public List findAll() { - return new ArrayList<>(data.values()); - } - - @Override - public List findAllByUserId(UUID userId) { - return data.values() - .stream() - .filter(e -> e.getUserId().equals(userId)).toList(); - } - - @Override - public Optional findByUserIdAndChannelId(UUID userId, UUID channelId) { - return data.values() - .stream() - .filter(readStatus -> readStatus.getUserId().equals(userId) && - readStatus.getChannelId().equals(channelId)) - .findFirst(); - } - - @Override - public UUID update(ReadStatus readStatus) { - data.put(readStatus.getId(), readStatus); - return readStatus.getId(); - } - - @Override - public UUID delete(UUID id) { - data.remove(id); - return id; - } - - -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserStatusRepository.java deleted file mode 100644 index db4efc9a3..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserStatusRepository.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.entity.UserStatus; -import com.sprint.mission.discodeit.entity.UserStatusType; -import com.sprint.mission.discodeit.repository.UserStatusRepository; -import org.springframework.stereotype.Repository; - -import java.util.*; -import java.util.stream.Collectors; - -@Repository -public class JCFUserStatusRepository implements UserStatusRepository { - - private final Map data = new HashMap<>(); - - @Override - public UUID save(UserStatus userStatus) { - data.put(userStatus.getId(), userStatus); - return userStatus.getUserId(); - } - - @Override - public UserStatus find(UUID id) { - return data.get(id); - } - - @Override - public List findAll() { - return new ArrayList<>(data.values()); - } - - @Override - public Optional findById(UUID id) { - return data.values() - .stream() - .filter(userStatus -> userStatus.getId().equals(id)) - .findFirst(); - } - - @Override - public UUID update(UserStatus userStatus) { - data.put(userStatus.getId(), userStatus); - return userStatus.getUserId(); - } - - @Override - public UUID delete(UUID id) { - data.remove(id); - return id; - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/AuthServiceImpl.java b/src/main/java/com/sprint/mission/discodeit/service/basic/AuthServiceImpl.java deleted file mode 100644 index 355d3ccf6..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/AuthServiceImpl.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.sprint.mission.discodeit.service.basic; - -import com.sprint.mission.discodeit.dto.auth.AuthLoginDTO; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.repository.UserRepository; -import com.sprint.mission.discodeit.service.AuthService; - -import java.util.NoSuchElementException; - -public class AuthServiceImpl implements AuthService { - public UserRepository userRepository; - - @Override - public User login(AuthLoginDTO dto) { - return userRepository.findAll() - .stream() - .filter(user -> user.getUsername().equals(dto.username()) && - user.getPassword().equals(dto.password())).findFirst().orElseThrow(() -> new NoSuchElementException("로그인 정보 불일치")); - - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/validate/BinaryContentValidator.java b/src/main/java/com/sprint/mission/discodeit/validate/BinaryContentValidator.java deleted file mode 100644 index a3ba594c0..000000000 --- a/src/main/java/com/sprint/mission/discodeit/validate/BinaryContentValidator.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.sprint.mission.discodeit.validate; - -import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentCreateDTO; -import com.sprint.mission.discodeit.entity.Message; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.repository.BinaryContentRepository; -import com.sprint.mission.discodeit.repository.MessageRepository; -import com.sprint.mission.discodeit.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; -import java.util.UUID; - -@Component -@RequiredArgsConstructor -public class BinaryContentValidator { - private final UserRepository userRepository; - private final MessageRepository messageRepository; - - public void validateBinaryContent(UUID userId, UUID MessageId){ - validateUserId(userId); - validateMessageId(MessageId); - } - - public void validateUserId(UUID userId){ - if(userId != null){ - User findUser = userRepository.findById(userId).orElseThrow(()->new RuntimeException("해당 메세지가 없습니다")); - Optional.ofNullable(findUser) - .orElseThrow(()->new RuntimeException("해당 유저가 없습니다")); - } - } - - public void validateMessageId(UUID messageId){ - if(messageId != null){ - Message findMessage = messageRepository.findById(messageId).orElseThrow(()->new RuntimeException("해당 메세지가 없습니다")); - Optional.ofNullable(findMessage) - .orElseThrow(()->new RuntimeException("해당 유저가 없습니다.")); - } - - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/validate/ChannelValidator.java b/src/main/java/com/sprint/mission/discodeit/validate/ChannelValidator.java deleted file mode 100644 index 610b80924..000000000 --- a/src/main/java/com/sprint/mission/discodeit/validate/ChannelValidator.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.sprint.mission.discodeit.validate; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class ChannelValidator { - - public void validateChannel(String channelName, String channelDescription) { - validateName(channelName); - validateDescription(channelDescription); - } - - public void validateName(String channelName) { - if (channelName == null || channelName.trim().isEmpty()) { - throw new IllegalArgumentException("채널 이름은 null 이거나 공백일 수 없습니다."); - } - } - - public void validateDescription(String channelDescription) { - if (channelDescription == null || channelDescription.trim().isEmpty()) { - throw new IllegalArgumentException("채널 설명은 null 이거나 공백일 수 없습니다."); - } - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/validate/MessageValidator.java b/src/main/java/com/sprint/mission/discodeit/validate/MessageValidator.java deleted file mode 100644 index 67ec52864..000000000 --- a/src/main/java/com/sprint/mission/discodeit/validate/MessageValidator.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.sprint.mission.discodeit.validate; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.repository.ChannelRepository; -import com.sprint.mission.discodeit.repository.UserRepository; -import com.sprint.mission.discodeit.repository.UserStatusRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.NoSuchElementException; -import java.util.Optional; -import java.util.UUID; - -@Component -@RequiredArgsConstructor -public class MessageValidator { - private final UserRepository userRepository; - private final ChannelRepository channelRepository; - - - public void validateMessage(String content, UUID userId, UUID channerId){ - validateContent(content); - validateUserId(userId); - validateChanner(channerId); - } - - public void validateContent(String message){ - if(message == null || message.isEmpty()){ - throw new IllegalArgumentException("입력값은 null 이거나 공백일 수 없습니다."); - } - } - public void validateUserId(UUID userid){ - User findUser = userRepository.findById(userid).orElse(null); - Optional.ofNullable(findUser) - .orElseThrow(() -> new NoSuchElementException("해당 User가 없습니다.")); - } - - public void validateChanner(UUID channerId){ - Channel findChannel = channelRepository.findById(channerId); - Optional.ofNullable(findChannel) - .orElseThrow(() -> new NoSuchElementException("해당 Channel이 없습니다.")); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/validate/ReadStatusValidator.java b/src/main/java/com/sprint/mission/discodeit/validate/ReadStatusValidator.java deleted file mode 100644 index 51ee328b1..000000000 --- a/src/main/java/com/sprint/mission/discodeit/validate/ReadStatusValidator.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.sprint.mission.discodeit.validate; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.repository.ChannelRepository; -import com.sprint.mission.discodeit.repository.ReadStatusRepository; -import com.sprint.mission.discodeit.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.NoSuchElementException; -import java.util.Optional; -import java.util.UUID; - -@Component -@RequiredArgsConstructor -public class ReadStatusValidator { - - private final UserRepository userRepository; - private final ChannelRepository channelRepository; - private final ReadStatusRepository readStatusRepository; - - public void validateReadStatus(UUID userId, UUID channelId){ - validateUserId(userId); - validateChannelId(channelId); - checkDuplicateReadStatus(userId, channelId); - - } - - public void validateUserId(UUID userId) { - User findUser = userRepository.findById(userId).orElseThrow(() -> new IllegalStateException("해당 유저가 없습니다.")); - Optional.ofNullable(findUser) - .orElseThrow(() -> new NoSuchElementException("해당 채널이 없습니다.")); - } - - public void validateChannelId(UUID channelId) { - Channel findChannel = channelRepository.findById(channelId); - Optional.ofNullable(findChannel) - .orElseThrow(() -> new NoSuchElementException("해당 채널이 없습니다.")); - } - - private void checkDuplicateReadStatus(UUID userId, UUID channelId) { - if(readStatusRepository.findByUserIdAndChannelId(userId, channelId).isPresent()){ - throw new IllegalStateException("중복된 ReadStatus가 존재합니다. Userid : " + userId + ", channelId : " + channelId); - } - - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/validate/UserStatusValidator.java b/src/main/java/com/sprint/mission/discodeit/validate/UserStatusValidator.java deleted file mode 100644 index e56f0dd2f..000000000 --- a/src/main/java/com/sprint/mission/discodeit/validate/UserStatusValidator.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.sprint.mission.discodeit.validate; - -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.entity.UserStatus; -import com.sprint.mission.discodeit.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.NoSuchElementException; -import java.util.Optional; -import java.util.UUID; - -@Component -@RequiredArgsConstructor -public class UserStatusValidator { - private final UserRepository userRepository; - - public void validateUserStatus(UUID userId) { - validateUserId(userId); - checkDuplicateUserStatus(userId); - } - - public void validateUserId(UUID userId) { - User findUser = userRepository.findById(userId).orElseThrow(() -> new NoSuchElementException("해당 유저가 없습니다")); - Optional.ofNullable(findUser) - .orElseThrow(() -> new NoSuchElementException("해당 유저가 없습니다")); - } - - private void checkDuplicateUserStatus(UUID userId) { - if(userRepository.findById(userId).isPresent()){ - throw new IllegalStateException("중복된 UserStatus가 존재합니다. Userid : " + userId); - } - } -} - diff --git a/src/main/java/com/sprint/mission/discodeit/validate/UserValidator.java b/src/main/java/com/sprint/mission/discodeit/validate/UserValidator.java deleted file mode 100644 index bee3b6cce..000000000 --- a/src/main/java/com/sprint/mission/discodeit/validate/UserValidator.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.sprint.mission.discodeit.validate; - -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.repository.UserRepository; -import lombok.NoArgsConstructor; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.NoSuchElementException; -import java.util.Optional; -import java.util.UUID; - -@Component -@RequiredArgsConstructor -public class UserValidator { - public final UserRepository userRepository; - - public void validateUser(String username, String email, String password){ - validateUserName(username); - validateUserEmail(email); - validateUserPassword(password); - checkDuplicateUser(username, email); - } - - public void validateUpdateUser(UUID userid, String username, String email){ - validateUserId(userid); - validateUserName(username); - validateUserEmail(email); - checkDuplicateUser(username,email); - } - - public void validateUserId(UUID userId){ - Optional user = userRepository.findById(userId); - Optional.ofNullable(user) - .orElseThrow(() -> new NoSuchElementException("해당 User가 없습니다.")); - } - - public void validateUserName(String name){ - if(name == null || name.trim().isEmpty()){ - throw new IllegalArgumentException("입력 값은 null이거나 공백일 수 없습니다."); - } - } - - public void validateUserEmail(String email){ - if(email == null || email.trim().isEmpty()){ - throw new IllegalArgumentException("입력 값은 null이거나 공백일 수 없습니다."); - } - } - public void validateUserPassword(String password){ - if(password == null || password.trim().isEmpty()){ - throw new IllegalArgumentException("입력 값은 null이거나 공백일 수 없습니다."); - } - } - - public void checkDuplicateUser(String userName, String userEmail){ - boolean check = userRepository.findAll() - .stream().anyMatch(user -> user.getUsername().equals(userName) || user.getEmail().equals(userEmail)); - if(check){ - throw new IllegalArgumentException("이름 혹은 이메일이 중복입니다."); - } - - } -} From 004b49a91683f7e7098872454dfcadcf61413292 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Wed, 15 Oct 2025 11:40:57 +0900 Subject: [PATCH 25/28] =?UTF-8?q?sprint10=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 87 ++ .github/workflows/test.yml | 30 + .gitignore | 50 + Dockerfile | 40 + HELP.md | 22 + README.md | 5 + api-docs_1.2.json | 1278 ++++++++++++++++ build.gradle | 68 + docker-compose.yml | 52 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++ gradlew.bat | 94 ++ settings.gradle | 1 + .../discodeit/DiscodeitApplication.java | 12 + .../mission/discodeit/config/AppConfig.java | 10 + .../config/MDCLoggingInterceptor.java | 49 + .../discodeit/config/SecurityConfig.java | 138 ++ .../discodeit/config/SwaggerConfig.java | 25 + .../discodeit/config/WebMvcConfig.java | 24 + .../discodeit/controller/AuthController.java | 59 + .../controller/BinaryContentController.java | 60 + .../controller/ChannelController.java | 85 ++ .../controller/MessageController.java | 116 ++ .../controller/ReadStatusController.java | 62 + .../discodeit/controller/UserController.java | 107 ++ .../discodeit/controller/api/AuthApi.java | 44 + .../controller/api/BinaryContentApi.java | 57 + .../discodeit/controller/api/ChannelApi.java | 89 ++ .../discodeit/controller/api/MessageApi.java | 90 ++ .../controller/api/ReadStatusApi.java | 67 + .../discodeit/controller/api/UserApi.java | 91 ++ .../discodeit/dto/data/BinaryContentDto.java | 12 + .../discodeit/dto/data/ChannelDto.java | 17 + .../discodeit/dto/data/MessageDto.java | 17 + .../discodeit/dto/data/ReadStatusDto.java | 13 + .../mission/discodeit/dto/data/UserDto.java | 15 + .../request/BinaryContentCreateRequest.java | 19 + .../discodeit/dto/request/LoginRequest.java | 13 + .../dto/request/MessageCreateRequest.java | 20 + .../dto/request/MessageUpdateRequest.java | 12 + .../request/PrivateChannelCreateRequest.java | 16 + .../request/PublicChannelCreateRequest.java | 15 + .../request/PublicChannelUpdateRequest.java | 13 + .../dto/request/ReadStatusCreateRequest.java | 20 + .../dto/request/ReadStatusUpdateRequest.java | 13 + .../dto/request/RoleUpdateRequest.java | 11 + .../dto/request/UserCreateRequest.java | 25 + .../dto/request/UserUpdateRequest.java | 21 + .../discodeit/dto/response/PageResponse.java | 13 + .../discodeit/entity/BinaryContent.java | 29 + .../mission/discodeit/entity/Channel.java | 41 + .../mission/discodeit/entity/ChannelType.java | 6 + .../mission/discodeit/entity/Message.java | 55 + .../mission/discodeit/entity/ReadStatus.java | 47 + .../sprint/mission/discodeit/entity/Role.java | 7 + .../sprint/mission/discodeit/entity/User.java | 64 + .../discodeit/entity/base/BaseEntity.java | 31 + .../entity/base/BaseUpdatableEntity.java | 19 + .../exception/DiscodeitException.java | 32 + .../discodeit/exception/ErrorCode.java | 35 + .../discodeit/exception/ErrorResponse.java | 27 + .../exception/GlobalExceptionHandler.java | 95 ++ .../binarycontent/BinaryContentException.java | 14 + .../BinaryContentNotFoundException.java | 17 + .../exception/channel/ChannelException.java | 14 + .../channel/ChannelNotFoundException.java | 17 + .../PrivateChannelUpdateException.java | 17 + .../exception/message/MessageException.java | 14 + .../message/MessageNotFoundException.java | 17 + .../DuplicateReadStatusException.java | 18 + .../readstatus/ReadStatusException.java | 14 + .../ReadStatusNotFoundException.java | 17 + .../user/InvalidCredentialsException.java | 14 + .../user/UserAlreadyExistsException.java | 21 + .../exception/user/UserException.java | 14 + .../exception/user/UserNotFoundException.java | 23 + .../discodeit/mapper/BinaryContentMapper.java | 11 + .../discodeit/mapper/ChannelMapper.java | 48 + .../discodeit/mapper/MessageMapper.java | 13 + .../discodeit/mapper/PageResponseMapper.java | 30 + .../discodeit/mapper/ReadStatusMapper.java | 14 + .../mission/discodeit/mapper/UserMapper.java | 18 + .../repository/BinaryContentRepository.java | 9 + .../repository/ChannelRepository.java | 12 + .../repository/MessageRepository.java | 31 + .../repository/ReadStatusRepository.java | 24 + .../discodeit/repository/UserRepository.java | 21 + .../discodeit/security/AdminInitializer.java | 46 + .../security/DiscodeitUserDetails.java | 35 + .../security/DiscodeitUserDetailsService.java | 34 + .../Http403ForbiddenAccessDeniedHandler.java | 29 + .../security/LoginFailureHandler.java | 34 + .../security/LoginSuccessHandler.java | 42 + .../discodeit/security/SessionManager.java | 39 + .../security/SpaCsrfTokenRequestHandler.java | 48 + .../discodeit/service/AuthService.java | 11 + .../service/BinaryContentService.java | 17 + .../discodeit/service/ChannelService.java | 23 + .../discodeit/service/MessageService.java | 25 + .../discodeit/service/ReadStatusService.java | 20 + .../discodeit/service/UserService.java | 24 + .../service/basic/BasicAuthService.java | 51 + .../basic/BasicBinaryContentService.java | 80 + .../service/basic/BasicChannelService.java | 122 ++ .../service/basic/BasicMessageService.java | 138 ++ .../service/basic/BasicReadStatusService.java | 107 ++ .../service/basic/BasicUserService.java | 158 ++ .../storage/BinaryContentStorage.java | 15 + .../local/LocalBinaryContentStorage.java | 89 ++ .../storage/s3/S3BinaryContentStorage.java | 151 ++ src/main/resources/application-dev.yaml | 27 + src/main/resources/application-prod.yaml | 25 + src/main/resources/application.yaml | 67 + src/main/resources/fe_bundle_1.2.3.zip | Bin 0 -> 95493 bytes src/main/resources/logback-spring.xml | 35 + src/main/resources/schema.sql | 111 ++ .../resources/static/assets/index-COLcXNzv.js | 1338 +++++++++++++++++ .../static/assets/index-kQJbKSsj.css | 1 + src/main/resources/static/favicon.ico | Bin 0 -> 1588 bytes src/main/resources/static/index.html | 26 + .../controller/AuthControllerTest.java | 136 ++ .../BinaryContentControllerTest.java | 151 ++ .../controller/ChannelControllerTest.java | 286 ++++ .../controller/MessageControllerTest.java | 316 ++++ .../controller/ReadStatusControllerTest.java | 178 +++ .../controller/UserControllerTest.java | 306 ++++ .../integration/AuthApiIntegrationTest.java | 123 ++ .../BinaryContentApiIntegrationTest.java | 217 +++ .../ChannelApiIntegrationTest.java | 288 ++++ .../MessageApiIntegrationTest.java | 350 +++++ .../ReadStatusApiIntegrationTest.java | 280 ++++ .../integration/UserApiIntegrationTest.java | 299 ++++ .../repository/ChannelRepositoryTest.java | 96 ++ .../repository/MessageRepositoryTest.java | 217 +++ .../repository/ReadStatusRepositoryTest.java | 197 +++ .../repository/UserRepositoryTest.java | 132 ++ .../mission/discodeit/security/CsrfTest.java | 34 + .../mission/discodeit/security/LoginTest.java | 137 ++ .../basic/BasicBinaryContentServiceTest.java | 172 +++ .../basic/BasicChannelServiceTest.java | 228 +++ .../basic/BasicMessageServiceTest.java | 370 +++++ .../service/basic/BasicUserServiceTest.java | 188 +++ .../discodeit/storage/s3/AWSS3Test.java | 174 +++ .../s3/S3BinaryContentStorageTest.java | 147 ++ src/test/resources/application-test.yaml | 23 + 146 files changed, 12419 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 HELP.md create mode 100644 README.md create mode 100644 api-docs_1.2.json create mode 100644 build.gradle create mode 100644 docker-compose.yml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/AppConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/AuthController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/MessageController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/UserController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/RoleUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/Channel.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/Message.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/Role.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/User.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/Http403ForbiddenAccessDeniedHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/SessionManager.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/SpaCsrfTokenRequestHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/AuthService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/ChannelService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/MessageService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/UserService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java create mode 100644 src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java create mode 100644 src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java create mode 100644 src/main/resources/application-dev.yaml create mode 100644 src/main/resources/application-prod.yaml create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/fe_bundle_1.2.3.zip create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/main/resources/schema.sql create mode 100644 src/main/resources/static/assets/index-COLcXNzv.js create mode 100644 src/main/resources/static/assets/index-kQJbKSsj.css create mode 100644 src/main/resources/static/favicon.ico create mode 100644 src/main/resources/static/index.html create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/security/CsrfTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/security/LoginTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java create mode 100644 src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java create mode 100644 src/test/resources/application-test.yaml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..068c6dfe6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,87 @@ +name: 배포 + +on: + push: + branches: + - release + +jobs: + build-and-push: + runs-on: ubuntu-latest + outputs: + image_tag: ${{ github.sha }} + + steps: + - uses: actions/checkout@v4 + + - name: AWS CLI 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: us-east-1 + + - name: ECR 로그인 + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + with: + registry-type: public + + - name: Docker 이미지 빌드 및 푸시 + run: | + docker buildx build \ + -t ${{ vars.ECR_REPOSITORY_URI }}:${{ github.sha }} \ + -t ${{ vars.ECR_REPOSITORY_URI }}:latest \ + --push \ + . + deploy: + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: AWS CLI 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ${{ vars.AWS_REGION }} + + - name: ECS 태스크 정의 업데이트 + run: | + TASK_DEFINITION=$( + aws ecs describe-task-definition \ + --task-definition ${{ vars.ECS_TASK_DEFINITION }} + ) + + NEW_TASK_DEFINITION=$( + echo $TASK_DEFINITION | jq \ + --arg IMAGE "${{ vars.ECR_REPOSITORY_URI }}:latest" \ + '.taskDefinition | .containerDefinitions[0].image = $IMAGE | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)' + ) + + # 새로운 태스크 정의 등록 + NEW_TASK_DEF_ARN=$( + aws ecs register-task-definition \ + --cli-input-json "$NEW_TASK_DEFINITION" | \ + jq -r '.taskDefinition.taskDefinitionArn' + ) + + # 환경 파일에 변수 저장 (다음 단계에서 사용 가능) + echo "NEW_TASK_DEF_ARN=$NEW_TASK_DEF_ARN" >> $GITHUB_ENV + + - name: ECS 서비스 중지(프리티어 환경 고려) + run: | + aws ecs update-service \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --service ${{ vars.ECS_SERVICE }} \ + --desired-count 0 + + - name: ECS 서비스 업데이트 + run: | + aws ecs update-service \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --service ${{ vars.ECS_SERVICE }} \ + --task-definition $NEW_TASK_DEF_ARN \ + --desired-count 1 \ + --force-new-deployment + \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..97e594116 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: 테스트 + +on: + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: 체크아웃 + uses: actions/checkout@v4 + + - name: JDK 17 설정 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + cache: gradle + + - name: 테스트 실행 + run: ./gradlew test + + - name: Codecov 테스트 커버리지 업로드 + uses: codecov/codecov-action@v3 + with: + files: build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.CODECOV_TOKEN }} # 퍼블릭 저장소라면 생략 가능 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..f14dfb825 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Discodeit ### +.discodeit + +### 숨김 파일 ### +.* +!.gitignore + + +### Github Actions ### +!.github/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..229bd68aa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# 빌드 스테이지 +FROM amazoncorretto:17 AS builder + +# 작업 디렉토리 설정 +WORKDIR /app + +# Gradle Wrapper 파일 먼저 복사 +COPY gradle ./gradle +COPY gradlew ./gradlew + +# Gradle 캐시를 위한 의존성 파일 복사 +COPY build.gradle settings.gradle ./ + +# 의존성 다운로드 +RUN ./gradlew dependencies + +# 소스 코드 복사 및 빌드 +COPY src ./src +RUN ./gradlew build -x test + + +# 런타임 스테이지 +FROM amazoncorretto:17-alpine3.21 + +# 작업 디렉토리 설정 +WORKDIR /app + +# 프로젝트 정보를 ENV로 설정 +ENV PROJECT_NAME=discodeit \ + PROJECT_VERSION=1.2-M8 \ + JVM_OPTS="" + +# 빌드 스테이지에서 jar 파일만 복사 +COPY --from=builder /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}.jar ./ + +# 80 포트 노출 +EXPOSE 80 + +# jar 파일 실행 +ENTRYPOINT ["sh", "-c", "java ${JVM_OPTS} -jar ${PROJECT_NAME}-${PROJECT_VERSION}.jar"] \ No newline at end of file diff --git a/HELP.md b/HELP.md new file mode 100644 index 000000000..42c5f0023 --- /dev/null +++ b/HELP.md @@ -0,0 +1,22 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.0/gradle-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.0/gradle-plugin/packaging-oci-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/3.4.0/reference/web/servlet.html) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/README.md b/README.md new file mode 100644 index 000000000..a9e03e160 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# 0-spring-mission + +스프린트 미션 모범 답안 리포지토리입니다. + +[![codecov](https://codecov.io/gh/codeit-bootcamp-spring/0-sprint-mission/branch/s8%2Fadvanced/graph/badge.svg?token=XRIA1GENAM)](https://codecov.io/gh/codeit-bootcamp-spring/0-sprint-mission) \ No newline at end of file diff --git a/api-docs_1.2.json b/api-docs_1.2.json new file mode 100644 index 000000000..7253644c9 --- /dev/null +++ b/api-docs_1.2.json @@ -0,0 +1,1278 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Discodeit API 문서", + "description": "Discodeit 프로젝트의 Swagger API 문서입니다.", + "version": "1.2" + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "로컬 서버" + } + ], + "tags": [ + { + "name": "Channel", + "description": "Channel API" + }, + { + "name": "ReadStatus", + "description": "Message 읽음 상태 API" + }, + { + "name": "Message", + "description": "Message API" + }, + { + "name": "User", + "description": "User API" + }, + { + "name": "BinaryContent", + "description": "첨부 파일 API" + }, + { + "name": "Auth", + "description": "인증 API" + } + ], + "paths": { + "/api/users": { + "get": { + "tags": [ + "User" + ], + "summary": "전체 User 목록 조회", + "operationId": "findAll", + "responses": { + "200": { + "description": "User 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "User" + ], + "summary": "User 등록", + "operationId": "create", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "userCreateRequest": { + "$ref": "#/components/schemas/UserCreateRequest" + }, + "profile": { + "type": "string", + "format": "binary", + "description": "User 프로필 이미지" + } + }, + "required": [ + "userCreateRequest" + ] + } + } + } + }, + "responses": { + "201": { + "description": "User가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "400": { + "description": "같은 email 또는 username를 사용하는 User가 이미 존재함", + "content": { + "*/*": { + "example": "User with email {email} already exists" + } + } + } + } + } + }, + "/api/readStatuses": { + "get": { + "tags": [ + "ReadStatus" + ], + "summary": "User의 Message 읽음 상태 목록 조회", + "operationId": "findAllByUserId", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "조회할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Message 읽음 상태 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "ReadStatus" + ], + "summary": "Message 읽음 상태 생성", + "operationId": "create_1", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadStatusCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "이미 읽음 상태가 존재함", + "content": { + "*/*": { + "example": "ReadStatus with userId {userId} and channelId {channelId} already exists" + } + } + }, + "201": { + "description": "Message 읽음 상태가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + }, + "404": { + "description": "Channel 또는 User를 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel | User with id {channelId | userId} not found" + } + } + } + } + } + }, + "/api/messages": { + "get": { + "tags": [ + "Message" + ], + "summary": "Channel의 Message 목록 조회", + "operationId": "findAllByChannelId", + "parameters": [ + { + "name": "channelId", + "in": "query", + "description": "조회할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "description": "페이징 커서 정보", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "pageable", + "in": "query", + "description": "페이징 정보", + "required": true, + "schema": { + "$ref": "#/components/schemas/Pageable" + }, + "example": { + "size": 50, + "sort": "createdAt,desc" + } + } + ], + "responses": { + "200": { + "description": "Message 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PageResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "Message" + ], + "summary": "Message 생성", + "operationId": "create_2", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "messageCreateRequest": { + "$ref": "#/components/schemas/MessageCreateRequest" + }, + "attachments": { + "type": "array", + "description": "Message 첨부 파일들", + "items": { + "type": "string", + "format": "binary" + } + } + }, + "required": [ + "messageCreateRequest" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Message가 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/MessageDto" + } + } + } + }, + "404": { + "description": "Channel 또는 User를 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel | Author with id {channelId | authorId} not found" + } + } + } + } + } + }, + "/api/channels/public": { + "post": { + "tags": [ + "Channel" + ], + "summary": "Public Channel 생성", + "operationId": "create_3", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicChannelCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Public Channel이 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/channels/private": { + "post": { + "tags": [ + "Channel" + ], + "summary": "Private Channel 생성", + "operationId": "create_4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivateChannelCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Private Channel이 성공적으로 생성됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "summary": "로그인", + "operationId": "login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "로그인 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "404": { + "description": "사용자를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with username {username} not found" + } + } + }, + "400": { + "description": "비밀번호가 일치하지 않음", + "content": { + "*/*": { + "example": "Wrong password" + } + } + } + } + } + }, + "/api/users/{userId}": { + "delete": { + "tags": [ + "User" + ], + "summary": "User 삭제", + "operationId": "delete", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "삭제할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "User를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with id {id} not found" + } + } + }, + "204": { + "description": "User가 성공적으로 삭제됨" + } + } + }, + "patch": { + "tags": [ + "User" + ], + "summary": "User 정보 수정", + "operationId": "update", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "수정할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "userUpdateRequest": { + "$ref": "#/components/schemas/UserUpdateRequest" + }, + "profile": { + "type": "string", + "format": "binary", + "description": "수정할 User 프로필 이미지" + } + }, + "required": [ + "userUpdateRequest" + ] + } + } + } + }, + "responses": { + "400": { + "description": "같은 email 또는 username를 사용하는 User가 이미 존재함", + "content": { + "*/*": { + "example": "user with email {newEmail} already exists" + } + } + }, + "200": { + "description": "User 정보가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "404": { + "description": "User를 찾을 수 없음", + "content": { + "*/*": { + "example": "User with id {userId} not found" + } + } + } + } + } + }, + "/api/users/{userId}/userStatus": { + "patch": { + "tags": [ + "User" + ], + "summary": "User 온라인 상태 업데이트", + "operationId": "updateUserStatusByUserId", + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "상태를 변경할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatusUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "해당 User의 UserStatus를 찾을 수 없음", + "content": { + "*/*": { + "example": "UserStatus with userId {userId} not found" + } + } + }, + "200": { + "description": "User 온라인 상태가 성공적으로 업데이트됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/UserStatusDto" + } + } + } + } + } + } + }, + "/api/readStatuses/{readStatusId}": { + "patch": { + "tags": [ + "ReadStatus" + ], + "summary": "Message 읽음 상태 수정", + "operationId": "update_1", + "parameters": [ + { + "name": "readStatusId", + "in": "path", + "description": "수정할 읽음 상태 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadStatusUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Message 읽음 상태를 찾을 수 없음", + "content": { + "*/*": { + "example": "ReadStatus with id {readStatusId} not found" + } + } + }, + "200": { + "description": "Message 읽음 상태가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReadStatusDto" + } + } + } + } + } + } + }, + "/api/messages/{messageId}": { + "delete": { + "tags": [ + "Message" + ], + "summary": "Message 삭제", + "operationId": "delete_1", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "삭제할 Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Message가 성공적으로 삭제됨" + }, + "404": { + "description": "Message를 찾을 수 없음", + "content": { + "*/*": { + "example": "Message with id {messageId} not found" + } + } + } + } + }, + "patch": { + "tags": [ + "Message" + ], + "summary": "Message 내용 수정", + "operationId": "update_2", + "parameters": [ + { + "name": "messageId", + "in": "path", + "description": "수정할 Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Message를 찾을 수 없음", + "content": { + "*/*": { + "example": "Message with id {messageId} not found" + } + } + }, + "200": { + "description": "Message가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/MessageDto" + } + } + } + } + } + } + }, + "/api/channels/{channelId}": { + "delete": { + "tags": [ + "Channel" + ], + "summary": "Channel 삭제", + "operationId": "delete_2", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "삭제할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "Channel을 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel with id {channelId} not found" + } + } + }, + "204": { + "description": "Channel이 성공적으로 삭제됨" + } + } + }, + "patch": { + "tags": [ + "Channel" + ], + "summary": "Channel 정보 수정", + "operationId": "update_3", + "parameters": [ + { + "name": "channelId", + "in": "path", + "description": "수정할 Channel ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicChannelUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "Channel을 찾을 수 없음", + "content": { + "*/*": { + "example": "Channel with id {channelId} not found" + } + } + }, + "400": { + "description": "Private Channel은 수정할 수 없음", + "content": { + "*/*": { + "example": "Private channel cannot be updated" + } + } + }, + "200": { + "description": "Channel 정보가 성공적으로 수정됨", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + }, + "/api/channels": { + "get": { + "tags": [ + "Channel" + ], + "summary": "User가 참여 중인 Channel 목록 조회", + "operationId": "findAll_1", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "조회할 User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Channel 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDto" + } + } + } + } + } + } + } + }, + "/api/binaryContents": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "여러 첨부 파일 조회", + "operationId": "findAllByIdIn", + "parameters": [ + { + "name": "binaryContentIds", + "in": "query", + "description": "조회할 첨부 파일 ID 목록", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + ], + "responses": { + "200": { + "description": "첨부 파일 목록 조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + } + } + } + } + }, + "/api/binaryContents/{binaryContentId}": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "첨부 파일 조회", + "operationId": "find", + "parameters": [ + { + "name": "binaryContentId", + "in": "path", + "description": "조회할 첨부 파일 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "첨부 파일 조회 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + }, + "404": { + "description": "첨부 파일을 찾을 수 없음", + "content": { + "*/*": { + "example": "BinaryContent with id {binaryContentId} not found" + } + } + } + } + } + }, + "/api/binaryContents/{binaryContentId}/download": { + "get": { + "tags": [ + "BinaryContent" + ], + "summary": "파일 다운로드", + "operationId": "download", + "parameters": [ + { + "name": "binaryContentId", + "in": "path", + "description": "다운로드할 파일 ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "파일 다운로드 성공", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UserCreateRequest": { + "type": "object", + "description": "User 생성 정보", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "BinaryContentDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "fileName": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64" + }, + "contentType": { + "type": "string" + } + } + }, + "UserDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "profile": { + "$ref": "#/components/schemas/BinaryContentDto" + }, + "online": { + "type": "boolean" + } + } + }, + "ReadStatusCreateRequest": { + "type": "object", + "description": "Message 읽음 상태 생성 정보", + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadStatusDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MessageCreateRequest": { + "type": "object", + "description": "Message 생성 정보", + "properties": { + "content": { + "type": "string" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "authorId": { + "type": "string", + "format": "uuid" + } + } + }, + "MessageDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "content": { + "type": "string" + }, + "channelId": { + "type": "string", + "format": "uuid" + }, + "author": { + "$ref": "#/components/schemas/UserDto" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinaryContentDto" + } + } + } + }, + "PublicChannelCreateRequest": { + "type": "object", + "description": "Public Channel 생성 정보", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "ChannelDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "PUBLIC", + "PRIVATE" + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + }, + "lastMessageAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PrivateChannelCreateRequest": { + "type": "object", + "description": "Private Channel 생성 정보", + "properties": { + "participantIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "LoginRequest": { + "type": "object", + "description": "로그인 정보", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "UserUpdateRequest": { + "type": "object", + "description": "수정할 User 정보", + "properties": { + "newUsername": { + "type": "string" + }, + "newEmail": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "UserStatusUpdateRequest": { + "type": "object", + "description": "변경할 User 온라인 상태 정보", + "properties": { + "newLastActiveAt": { + "type": "string", + "format": "date-time" + } + } + }, + "UserStatusDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadStatusUpdateRequest": { + "type": "object", + "description": "수정할 읽음 상태 정보", + "properties": { + "newLastReadAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MessageUpdateRequest": { + "type": "object", + "description": "수정할 Message 내용", + "properties": { + "newContent": { + "type": "string" + } + } + }, + "PublicChannelUpdateRequest": { + "type": "object", + "description": "수정할 Channel 정보", + "properties": { + "newName": { + "type": "string" + }, + "newDescription": { + "type": "string" + } + } + }, + "Pageable": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "size": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "sort": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PageResponse": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object" + } + }, + "nextCursor": { + "type": "object" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "hasNext": { + "type": "boolean" + }, + "totalElements": { + "type": "integer", + "format": "int64" + } + } + } + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..da32f000a --- /dev/null +++ b/build.gradle @@ -0,0 +1,68 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.6' + id 'jacoco' +} + +group = 'com.sprint.mission' +version = '2.1-M10' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + testCompileOnly { + extendsFrom testAnnotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'software.amazon.awssdk:s3:2.31.7' + implementation 'org.springframework.boot:spring-boot-starter-security' + + runtimeOnly 'org.postgresql:postgresql' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.mapstruct:mapstruct:1.6.3' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'com.h2database:h2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' +} + +tasks.named('test') { + useJUnitPlatform() +} + +test { + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..3e9c24f85 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.8' + +services: + app: + image: discodeit:local + build: + context: . + dockerfile: Dockerfile + container_name: discodeit + ports: + - "8081:80" + environment: + - SPRING_PROFILES_ACTIVE=prod + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/discodeit + - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} + - STORAGE_TYPE=s3 + - STORAGE_LOCAL_ROOT_PATH=.discodeit/storage + - AWS_S3_ACCESS_KEY=${AWS_S3_ACCESS_KEY} + - AWS_S3_SECRET_KEY=${AWS_S3_SECRET_KEY} + - AWS_S3_REGION=${AWS_S3_REGION} + - AWS_S3_BUCKET=${AWS_S3_BUCKET} + - AWS_S3_PRESIGNED_URL_EXPIRATION=600 + depends_on: + - db + volumes: + - binary-content-storage:/app/.discodeit/storage + networks: + - discodeit-network + + db: + image: postgres:16-alpine + container_name: discodeit-db + environment: + - POSTGRES_DB=discodeit + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + - ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/schema.sql + networks: + - discodeit-network + +volumes: + postgres-data: + binary-content-storage: + +networks: + discodeit-network: + driver: bridge \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..e2847c820 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 000000000..f5feea6d6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..9d21a2183 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..2437dfb29 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'discodeit' diff --git a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java new file mode 100644 index 000000000..8f61230d4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DiscodeitApplication { + + public static void main(String[] args) { + SpringApplication.run(DiscodeitApplication.class, args); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java new file mode 100644 index 000000000..96010621f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class AppConfig { + +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java new file mode 100644 index 000000000..569309f8a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java @@ -0,0 +1,49 @@ +package com.sprint.mission.discodeit.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +/** + * 요청마다 MDC에 컨텍스트 정보를 추가하는 인터셉터 + */ +@Slf4j +public class MDCLoggingInterceptor implements HandlerInterceptor { + + /** + * MDC 로깅에 사용되는 상수 정의 + */ + public static final String REQUEST_ID = "requestId"; + public static final String REQUEST_METHOD = "requestMethod"; + public static final String REQUEST_URI = "requestUri"; + + public static final String REQUEST_ID_HEADER = "Discodeit-Request-ID"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 요청 ID 생성 (UUID) + String requestId = UUID.randomUUID().toString().replaceAll("-", ""); + + // MDC에 컨텍스트 정보 추가 + MDC.put(REQUEST_ID, requestId); + MDC.put(REQUEST_METHOD, request.getMethod()); + MDC.put(REQUEST_URI, request.getRequestURI()); + + // 응답 헤더에 요청 ID 추가 + response.setHeader(REQUEST_ID_HEADER, requestId); + + log.debug("Request started"); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + // 요청 처리 후 MDC 데이터 정리 + log.debug("Request completed"); + MDC.clear(); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java new file mode 100644 index 000000000..807a8fbd4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -0,0 +1,138 @@ +package com.sprint.mission.discodeit.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.security.Http403ForbiddenAccessDeniedHandler; +import com.sprint.mission.discodeit.security.LoginFailureHandler; +import com.sprint.mission.discodeit.security.LoginSuccessHandler; +import com.sprint.mission.discodeit.security.SpaCsrfTokenRequestHandler; +import java.util.List; +import java.util.stream.IntStream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; +import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.session.HttpSessionEventPublisher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.NegatedRequestMatcher; + +@Slf4j +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain( + HttpSecurity http, + LoginSuccessHandler loginSuccessHandler, + LoginFailureHandler loginFailureHandler, + ObjectMapper objectMapper, + SessionRegistry sessionRegistry + ) + throws Exception { + http + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) + ) + .formLogin(login -> login + .loginProcessingUrl("/api/auth/login") + .successHandler(loginSuccessHandler) + .failureHandler(loginFailureHandler) + ) + .logout(logout -> logout + .logoutUrl("/api/auth/logout") + .logoutSuccessHandler( + new HttpStatusReturningLogoutSuccessHandler(HttpStatus.NO_CONTENT)) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/api/auth/csrf-token"), + AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/users"), + AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/auth/login"), + AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/auth/logout"), + new NegatedRequestMatcher(AntPathRequestMatcher.antMatcher("/api/**")) + ).permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(new Http403ForbiddenEntryPoint()) + .accessDeniedHandler(new Http403ForbiddenAccessDeniedHandler(objectMapper)) + ) + .sessionManagement(session -> session + .sessionConcurrency(concurrency -> concurrency + .maximumSessions(1) + .sessionRegistry(sessionRegistry) + ) + ) + .rememberMe(Customizer.withDefaults()) + ; + return http.build(); + } + + @Bean + public CommandLineRunner debugFilterChain(SecurityFilterChain filterChain) { + return args -> { + int filterSize = filterChain.getFilters().size(); + List filterNames = IntStream.range(0, filterSize) + .mapToObj(idx -> String.format("\t[%s/%s] %s", idx + 1, filterSize, + filterChain.getFilters().get(idx).getClass())) + .toList(); + log.debug("Debug Filter Chain...\n{}", String.join(System.lineSeparator(), filterNames)); + }; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.withDefaultRolePrefix() + .role(Role.ADMIN.name()) + .implies(Role.USER.name(), Role.CHANNEL_MANAGER.name()) + + .role(Role.CHANNEL_MANAGER.name()) + .implies(Role.USER.name()) + + .build(); + } + + @Bean + static MethodSecurityExpressionHandler methodSecurityExpressionHandler( + RoleHierarchy roleHierarchy) { + DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); + handler.setRoleHierarchy(roleHierarchy); + return handler; + } + + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java new file mode 100644 index 000000000..f8142c0dc --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Discodeit API 문서") + .description("Discodeit 프로젝트의 Swagger API 문서입니다.") + .version("2.0") + ) + .servers(List.of( + new Server().url("http://localhost:8080").description("로컬 서버") + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java new file mode 100644 index 000000000..21790c7a0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 웹 MVC 설정 클래스 + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Bean + public MDCLoggingInterceptor mdcLoggingInterceptor() { + return new MDCLoggingInterceptor(); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(mdcLoggingInterceptor()) + .addPathPatterns("/**"); // 모든 경로에 적용 + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java new file mode 100644 index 000000000..a398afa58 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -0,0 +1,59 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.AuthApi; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.service.AuthService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/auth") +public class AuthController implements AuthApi { + + private final AuthService authService; + private final UserService userService; + + @GetMapping("csrf-token") + public ResponseEntity getCsrfToken(CsrfToken csrfToken) { + log.debug("CSRF 토큰 요청"); + log.trace("CSRF 토큰: {}", csrfToken.getToken()); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping("me") + public ResponseEntity me(@AuthenticationPrincipal DiscodeitUserDetails userDetails) { + log.info("내 정보 조회 요청"); + UUID userId = userDetails.getUserDto().id(); + UserDto userDto = userService.find(userId); + return ResponseEntity + .status(HttpStatus.OK) + .body(userDto); + } + + @PutMapping("role") + public ResponseEntity updateRole(@RequestBody RoleUpdateRequest request) { + log.info("권한 수정 요청"); + UserDto userDto = authService.updateRole(request); + + return ResponseEntity + .status(HttpStatus.OK) + .body(userDto); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java new file mode 100644 index 000000000..a0b93ffde --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java @@ -0,0 +1,60 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.BinaryContentApi; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/binaryContents") +public class BinaryContentController implements BinaryContentApi { + + private final BinaryContentService binaryContentService; + private final BinaryContentStorage binaryContentStorage; + + @GetMapping(path = "{binaryContentId}") + public ResponseEntity find( + @PathVariable("binaryContentId") UUID binaryContentId) { + log.info("바이너리 컨텐츠 조회 요청: id={}", binaryContentId); + BinaryContentDto binaryContent = binaryContentService.find(binaryContentId); + log.debug("바이너리 컨텐츠 조회 응답: {}", binaryContent); + return ResponseEntity + .status(HttpStatus.OK) + .body(binaryContent); + } + + @GetMapping + public ResponseEntity> findAllByIdIn( + @RequestParam("binaryContentIds") List binaryContentIds) { + log.info("바이너리 컨텐츠 목록 조회 요청: ids={}", binaryContentIds); + List binaryContents = binaryContentService.findAllByIdIn(binaryContentIds); + log.debug("바이너리 컨텐츠 목록 조회 응답: count={}", binaryContents.size()); + return ResponseEntity + .status(HttpStatus.OK) + .body(binaryContents); + } + + @GetMapping(path = "{binaryContentId}/download") + public ResponseEntity download( + @PathVariable("binaryContentId") UUID binaryContentId) { + log.info("바이너리 컨텐츠 다운로드 요청: id={}", binaryContentId); + BinaryContentDto binaryContentDto = binaryContentService.find(binaryContentId); + ResponseEntity response = binaryContentStorage.download(binaryContentDto); + log.debug("바이너리 컨텐츠 다운로드 응답: contentType={}, contentLength={}", + response.getHeaders().getContentType(), response.getHeaders().getContentLength()); + return response; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java new file mode 100644 index 000000000..3c8424236 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java @@ -0,0 +1,85 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.ChannelApi; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.service.ChannelService; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/channels") +public class ChannelController implements ChannelApi { + + private final ChannelService channelService; + + @PostMapping(path = "public") + public ResponseEntity create(@RequestBody @Valid PublicChannelCreateRequest request) { + log.info("공개 채널 생성 요청: {}", request); + ChannelDto createdChannel = channelService.create(request); + log.debug("공개 채널 생성 응답: {}", createdChannel); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdChannel); + } + + @PostMapping(path = "private") + public ResponseEntity create(@RequestBody @Valid PrivateChannelCreateRequest request) { + log.info("비공개 채널 생성 요청: {}", request); + ChannelDto createdChannel = channelService.create(request); + log.debug("비공개 채널 생성 응답: {}", createdChannel); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdChannel); + } + + @PatchMapping(path = "{channelId}") + public ResponseEntity update( + @PathVariable("channelId") UUID channelId, + @RequestBody @Valid PublicChannelUpdateRequest request) { + log.info("채널 수정 요청: id={}, request={}", channelId, request); + ChannelDto updatedChannel = channelService.update(channelId, request); + log.debug("채널 수정 응답: {}", updatedChannel); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedChannel); + } + + @DeleteMapping(path = "{channelId}") + public ResponseEntity delete(@PathVariable("channelId") UUID channelId) { + log.info("채널 삭제 요청: id={}", channelId); + channelService.delete(channelId); + log.debug("채널 삭제 완료"); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + public ResponseEntity> findAll(@RequestParam("userId") UUID userId) { + log.info("사용자별 채널 목록 조회 요청: userId={}", userId); + List channels = channelService.findAllByUserId(userId); + log.debug("사용자별 채널 목록 조회 응답: count={}", channels.size()); + return ResponseEntity + .status(HttpStatus.OK) + .body(channels); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java new file mode 100644 index 000000000..5f7777d02 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java @@ -0,0 +1,116 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.MessageApi; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.service.MessageService; +import jakarta.validation.Valid; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/messages") +public class MessageController implements MessageApi { + + private final MessageService messageService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity create( + @RequestPart("messageCreateRequest") @Valid MessageCreateRequest messageCreateRequest, + @RequestPart(value = "attachments", required = false) List attachments + ) { + log.info("메시지 생성 요청: request={}, attachmentCount={}", + messageCreateRequest, attachments != null ? attachments.size() : 0); + + List attachmentRequests = Optional.ofNullable(attachments) + .map(files -> files.stream() + .map(file -> { + try { + return new BinaryContentCreateRequest( + file.getOriginalFilename(), + file.getContentType(), + file.getBytes() + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .toList()) + .orElse(new ArrayList<>()); + MessageDto createdMessage = messageService.create(messageCreateRequest, attachmentRequests); + log.debug("메시지 생성 응답: {}", createdMessage); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdMessage); + } + + @PatchMapping(path = "{messageId}") + public ResponseEntity update( + @PathVariable("messageId") UUID messageId, + @RequestBody @Valid MessageUpdateRequest request) { + log.info("메시지 수정 요청: id={}, request={}", messageId, request); + MessageDto updatedMessage = messageService.update(messageId, request); + log.debug("메시지 수정 응답: {}", updatedMessage); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedMessage); + } + + @DeleteMapping(path = "{messageId}") + public ResponseEntity delete(@PathVariable("messageId") UUID messageId) { + log.info("메시지 삭제 요청: id={}", messageId); + messageService.delete(messageId); + log.debug("메시지 삭제 완료"); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + public ResponseEntity> findAllByChannelId( + @RequestParam("channelId") UUID channelId, + @RequestParam(value = "cursor", required = false) Instant cursor, + @PageableDefault( + size = 50, + page = 0, + sort = "createdAt", + direction = Direction.DESC + ) Pageable pageable) { + log.info("채널별 메시지 목록 조회 요청: channelId={}, cursor={}, pageable={}", + channelId, cursor, pageable); + PageResponse messages = messageService.findAllByChannelId(channelId, cursor, + pageable); + log.debug("채널별 메시지 목록 조회 응답: totalElements={}", messages.totalElements()); + return ResponseEntity + .status(HttpStatus.OK) + .body(messages); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java new file mode 100644 index 000000000..ac980c066 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java @@ -0,0 +1,62 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.ReadStatusApi; +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.service.ReadStatusService; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/readStatuses") +public class ReadStatusController implements ReadStatusApi { + + private final ReadStatusService readStatusService; + + @PostMapping + public ResponseEntity create(@RequestBody @Valid ReadStatusCreateRequest request) { + log.info("읽음 상태 생성 요청: {}", request); + ReadStatusDto createdReadStatus = readStatusService.create(request); + log.debug("읽음 상태 생성 응답: {}", createdReadStatus); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdReadStatus); + } + + @PatchMapping(path = "{readStatusId}") + public ResponseEntity update(@PathVariable("readStatusId") UUID readStatusId, + @RequestBody @Valid ReadStatusUpdateRequest request) { + log.info("읽음 상태 수정 요청: id={}, request={}", readStatusId, request); + ReadStatusDto updatedReadStatus = readStatusService.update(readStatusId, request); + log.debug("읽음 상태 수정 응답: {}", updatedReadStatus); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedReadStatus); + } + + @GetMapping + public ResponseEntity> findAllByUserId(@RequestParam("userId") UUID userId) { + log.info("사용자별 읽음 상태 목록 조회 요청: userId={}", userId); + List readStatuses = readStatusService.findAllByUserId(userId); + log.debug("사용자별 읽음 상태 목록 조회 응답: count={}", readStatuses.size()); + return ResponseEntity + .status(HttpStatus.OK) + .body(readStatuses); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java new file mode 100644 index 000000000..4cdf75dda --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java @@ -0,0 +1,107 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.controller.api.UserApi; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.service.UserService; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/users") +public class UserController implements UserApi { + + private final UserService userService; + + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) + @Override + public ResponseEntity create( + @RequestPart("userCreateRequest") @Valid UserCreateRequest userCreateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + log.info("사용자 생성 요청: {}", userCreateRequest); + Optional profileRequest = Optional.ofNullable(profile) + .flatMap(this::resolveProfileRequest); + UserDto createdUser = userService.create(userCreateRequest, profileRequest); + log.debug("사용자 생성 응답: {}", createdUser); + return ResponseEntity + .status(HttpStatus.CREATED) + .body(createdUser); + } + + @PatchMapping( + path = "{userId}", + consumes = {MediaType.MULTIPART_FORM_DATA_VALUE} + ) + @Override + public ResponseEntity update( + @PathVariable("userId") UUID userId, + @RequestPart("userUpdateRequest") @Valid UserUpdateRequest userUpdateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + log.info("사용자 수정 요청: id={}, request={}", userId, userUpdateRequest); + Optional profileRequest = Optional.ofNullable(profile) + .flatMap(this::resolveProfileRequest); + UserDto updatedUser = userService.update(userId, userUpdateRequest, profileRequest); + log.debug("사용자 수정 응답: {}", updatedUser); + return ResponseEntity + .status(HttpStatus.OK) + .body(updatedUser); + } + + @DeleteMapping(path = "{userId}") + @Override + public ResponseEntity delete(@PathVariable("userId") UUID userId) { + userService.delete(userId); + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .build(); + } + + @GetMapping + @Override + public ResponseEntity> findAll() { + List users = userService.findAll(); + return ResponseEntity + .status(HttpStatus.OK) + .body(users); + } + + private Optional resolveProfileRequest(MultipartFile profileFile) { + if (profileFile.isEmpty()) { + return Optional.empty(); + } else { + try { + BinaryContentCreateRequest binaryContentCreateRequest = new BinaryContentCreateRequest( + profileFile.getOriginalFilename(), + profileFile.getContentType(), + profileFile.getBytes() + ); + return Optional.of(binaryContentCreateRequest); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java new file mode 100644 index 000000000..d2a7a3ef0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java @@ -0,0 +1,44 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.web.csrf.CsrfToken; + +@Tag(name = "Auth", description = "인증 API") +public interface AuthApi { + + @Operation(summary = "CSRF 토큰 요청") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "CSRF 토큰 요청 성공"), + @ApiResponse(responseCode = "400", description = "CSRF 토큰 요청 실패") + }) + ResponseEntity getCsrfToken( + @Parameter(hidden = true) CsrfToken csrfToken + ); + + @Operation(summary = "세션 정보를 활용한 현재 사용자 정보 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = UserDto.class))), + @ApiResponse(responseCode = "401", description = "올바르지 않은 세션") + }) + ResponseEntity me(@Parameter(hidden = true) DiscodeitUserDetails userDetails); + + @Operation(summary = "사용자 권한 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "권한 변경 성공", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ) + }) + ResponseEntity updateRole( + @Parameter(description = "권한 수정 요청 정보") RoleUpdateRequest request); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java new file mode 100644 index 000000000..883ab8a88 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java @@ -0,0 +1,57 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; + +@Tag(name = "BinaryContent", description = "첨부 파일 API") +public interface BinaryContentApi { + + @Operation(summary = "첨부 파일 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "첨부 파일 조회 성공", + content = @Content(schema = @Schema(implementation = BinaryContentDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "첨부 파일을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "BinaryContent with id {binaryContentId} not found")) + ) + }) + ResponseEntity find( + @Parameter(description = "조회할 첨부 파일 ID") UUID binaryContentId + ); + + @Operation(summary = "여러 첨부 파일 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "첨부 파일 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = BinaryContentDto.class))) + ) + }) + ResponseEntity> findAllByIdIn( + @Parameter(description = "조회할 첨부 파일 ID 목록") List binaryContentIds + ); + + @Operation(summary = "파일 다운로드") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "파일 다운로드 성공", + content = @Content(schema = @Schema(implementation = Resource.class)) + ) + }) + ResponseEntity download( + @Parameter(description = "다운로드할 파일 ID") UUID binaryContentId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java new file mode 100644 index 000000000..af8c7afc7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java @@ -0,0 +1,89 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Channel", description = "Channel API") +public interface ChannelApi { + + @Operation(summary = "Public Channel 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Public Channel이 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ChannelDto.class)) + ) + }) + ResponseEntity create( + @Parameter(description = "Public Channel 생성 정보") PublicChannelCreateRequest request + ); + + @Operation(summary = "Private Channel 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Private Channel이 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ChannelDto.class)) + ) + }) + ResponseEntity create( + @Parameter(description = "Private Channel 생성 정보") PrivateChannelCreateRequest request + ); + + @Operation(summary = "Channel 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Channel 정보가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = ChannelDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel with id {channelId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "Private Channel은 수정할 수 없음", + content = @Content(examples = @ExampleObject(value = "Private channel cannot be updated")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 Channel ID") UUID channelId, + @Parameter(description = "수정할 Channel 정보") PublicChannelUpdateRequest request + ); + + @Operation(summary = "Channel 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", description = "Channel이 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", description = "Channel을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel with id {channelId} not found")) + ) + }) + ResponseEntity delete( + @Parameter(description = "삭제할 Channel ID") UUID channelId + ); + + @Operation(summary = "User가 참여 중인 Channel 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Channel 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class))) + ) + }) + ResponseEntity> findAll( + @Parameter(description = "조회할 User ID") UUID userId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java new file mode 100644 index 000000000..c9a7aebbd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java @@ -0,0 +1,90 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Message", description = "Message API") +public interface MessageApi { + + @Operation(summary = "Message 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Message가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = MessageDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel 또는 User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel | Author with id {channelId | authorId} not found")) + ), + }) + ResponseEntity create( + @Parameter( + description = "Message 생성 정보", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) MessageCreateRequest messageCreateRequest, + @Parameter( + description = "Message 첨부 파일들", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) List attachments + ); + + @Operation(summary = "Message 내용 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = MessageDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Message를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Message with id {messageId} not found")) + ), + }) + ResponseEntity update( + @Parameter(description = "수정할 Message ID") UUID messageId, + @Parameter(description = "수정할 Message 내용") MessageUpdateRequest request + ); + + @Operation(summary = "Message 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", description = "Message가 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", description = "Message를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Message with id {messageId} not found")) + ), + }) + ResponseEntity delete( + @Parameter(description = "삭제할 Message ID") UUID messageId + ); + + @Operation(summary = "Channel의 Message 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 목록 조회 성공", + content = @Content(schema = @Schema(implementation = PageResponse.class)) + ) + }) + ResponseEntity> findAllByChannelId( + @Parameter(description = "조회할 Channel ID") UUID channelId, + @Parameter(description = "페이징 커서 정보") Instant cursor, + @Parameter(description = "페이징 정보", example = "{\"size\": 50, \"sort\": \"createdAt,desc\"}") Pageable pageable + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java new file mode 100644 index 000000000..eb08b359f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java @@ -0,0 +1,67 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +@Tag(name = "ReadStatus", description = "Message 읽음 상태 API") +public interface ReadStatusApi { + + @Operation(summary = "Message 읽음 상태 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Message 읽음 상태가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ReadStatusDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel 또는 User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "Channel | User with id {channelId | userId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "이미 읽음 상태가 존재함", + content = @Content(examples = @ExampleObject(value = "ReadStatus with userId {userId} and channelId {channelId} already exists")) + ) + }) + ResponseEntity create( + @Parameter(description = "Message 읽음 상태 생성 정보") ReadStatusCreateRequest request + ); + + @Operation(summary = "Message 읽음 상태 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 읽음 상태가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = ReadStatusDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "Message 읽음 상태를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "ReadStatus with id {readStatusId} not found")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 읽음 상태 ID") UUID readStatusId, + @Parameter(description = "수정할 읽음 상태 정보") ReadStatusUpdateRequest request + ); + + @Operation(summary = "User의 Message 읽음 상태 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message 읽음 상태 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ReadStatusDto.class))) + ) + }) + ResponseEntity> findAllByUserId( + @Parameter(description = "조회할 User ID") UUID userId + ); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java new file mode 100644 index 000000000..6ab77d163 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java @@ -0,0 +1,91 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "User", description = "User API") +public interface UserApi { + + @Operation(summary = "User 등록") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "User가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ), + @ApiResponse( + responseCode = "400", description = "같은 email 또는 username를 사용하는 User가 이미 존재함", + content = @Content(examples = @ExampleObject(value = "User with email {email} already exists")) + ), + }) + ResponseEntity create( + @Parameter( + description = "User 생성 정보", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) UserCreateRequest userCreateRequest, + @Parameter( + description = "User 프로필 이미지", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) MultipartFile profile + ); + + @Operation(summary = "User 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 정보가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ), + @ApiResponse( + responseCode = "404", description = "User를 찾을 수 없음", + content = @Content(examples = @ExampleObject("User with id {userId} not found")) + ), + @ApiResponse( + responseCode = "400", description = "같은 email 또는 username를 사용하는 User가 이미 존재함", + content = @Content(examples = @ExampleObject("user with email {newEmail} already exists")) + ) + }) + ResponseEntity update( + @Parameter(description = "수정할 User ID") UUID userId, + @Parameter(description = "수정할 User 정보") UserUpdateRequest userUpdateRequest, + @Parameter(description = "수정할 User 프로필 이미지") MultipartFile profile + ); + + @Operation(summary = "User 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "User가 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", + description = "User를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = "User with id {id} not found")) + ) + }) + ResponseEntity delete( + @Parameter(description = "삭제할 User ID") UUID userId + ); + + @Operation(summary = "전체 User 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "User 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) + ) + }) + ResponseEntity> findAll(); +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java new file mode 100644 index 000000000..d44aee484 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/BinaryContentDto.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.util.UUID; + +public record BinaryContentDto( + UUID id, + String fileName, + Long size, + String contentType +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java new file mode 100644 index 000000000..cf9b99080 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/ChannelDto.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.data; + +import com.sprint.mission.discodeit.entity.ChannelType; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record ChannelDto( + UUID id, + ChannelType type, + String name, + String description, + List participants, + Instant lastMessageAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java new file mode 100644 index 000000000..6bcaa0907 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/MessageDto.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record MessageDto( + UUID id, + Instant createdAt, + Instant updatedAt, + String content, + UUID channelId, + UserDto author, + List attachments +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java new file mode 100644 index 000000000..1d0bc2c12 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/ReadStatusDto.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.data; + +import java.time.Instant; +import java.util.UUID; + +public record ReadStatusDto( + UUID id, + UUID userId, + UUID channelId, + Instant lastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java new file mode 100644 index 000000000..7f3b6920b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/UserDto.java @@ -0,0 +1,15 @@ +package com.sprint.mission.discodeit.dto.data; + +import com.sprint.mission.discodeit.entity.Role; +import java.util.UUID; + +public record UserDto( + UUID id, + String username, + String email, + BinaryContentDto profile, + Boolean online, + Role role +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java new file mode 100644 index 000000000..402239697 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/BinaryContentCreateRequest.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record BinaryContentCreateRequest( + @NotBlank(message = "파일 이름은 필수입니다") + @Size(max = 255, message = "파일 이름은 255자 이하여야 합니다") + String fileName, + + @NotBlank(message = "콘텐츠 타입은 필수입니다") + String contentType, + + @NotNull(message = "파일 데이터는 필수입니다") + byte[] bytes +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java new file mode 100644 index 000000000..40452eea2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "사용자 이름은 필수입니다") + String username, + + @NotBlank(message = "비밀번호는 필수입니다") + String password +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java new file mode 100644 index 000000000..366539aee --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageCreateRequest.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.UUID; + +public record MessageCreateRequest( + @NotBlank(message = "메시지 내용은 필수입니다") + @Size(max = 2000, message = "메시지 내용은 2000자 이하여야 합니다") + String content, + + @NotNull(message = "채널 ID는 필수입니다") + UUID channelId, + + @NotNull(message = "작성자 ID는 필수입니다") + UUID authorId +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java new file mode 100644 index 000000000..792ef27c2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/MessageUpdateRequest.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MessageUpdateRequest( + @NotBlank(message = "메시지 내용은 필수입니다") + @Size(max = 2000, message = "메시지 내용은 2000자 이하여야 합니다") + String newContent +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java new file mode 100644 index 000000000..478cf4e32 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PrivateChannelCreateRequest.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.UUID; + +public record PrivateChannelCreateRequest( + @NotNull(message = "참여자 목록은 필수입니다") + @NotEmpty(message = "참여자 목록은 비어있을 수 없습니다") + @Size(min = 2, message = "비공개 채널에는 최소 2명의 참여자가 필요합니다") + List participantIds +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java new file mode 100644 index 000000000..e2e284a02 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelCreateRequest.java @@ -0,0 +1,15 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PublicChannelCreateRequest( + @NotBlank(message = "채널명은 필수입니다") + @Size(min = 2, max = 50, message = "채널명은 2자 이상 50자 이하여야 합니다") + String name, + + @Size(max = 255, message = "채널 설명은 255자 이하여야 합니다") + String description +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java new file mode 100644 index 000000000..e438f761c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/PublicChannelUpdateRequest.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.Size; + +public record PublicChannelUpdateRequest( + @Size(min = 2, max = 50, message = "채널명은 2자 이상 50자 이하여야 합니다") + String newName, + + @Size(max = 255, message = "채널 설명은 255자 이하여야 합니다") + String newDescription +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java new file mode 100644 index 000000000..f7f485199 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusCreateRequest.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import java.time.Instant; +import java.util.UUID; + +public record ReadStatusCreateRequest( + @NotNull(message = "사용자 ID는 필수입니다") + UUID userId, + + @NotNull(message = "채널 ID는 필수입니다") + UUID channelId, + + @NotNull(message = "마지막 읽은 시간은 필수입니다") + @PastOrPresent(message = "마지막 읽은 시간은 현재 또는 과거 시간이어야 합니다") + Instant lastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java new file mode 100644 index 000000000..de197a07f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import java.time.Instant; + +public record ReadStatusUpdateRequest( + @NotNull(message = "새로운 마지막 읽은 시간은 필수입니다") + @PastOrPresent(message = "마지막 읽은 시간은 현재 또는 과거 시간이어야 합니다") + Instant newLastReadAt +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/RoleUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/RoleUpdateRequest.java new file mode 100644 index 000000000..63fb44989 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/RoleUpdateRequest.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.dto.request; + +import com.sprint.mission.discodeit.entity.Role; +import java.util.UUID; + +public record RoleUpdateRequest( + UUID userId, + Role newRole +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java new file mode 100644 index 000000000..a8c888423 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserCreateRequest.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record UserCreateRequest( + @NotBlank(message = "사용자 이름은 필수입니다") + @Size(min = 3, max = 50, message = "사용자 이름은 3자 이상 50자 이하여야 합니다") + String username, + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "유효한 이메일 형식이어야 합니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") + String email, + + @NotBlank(message = "비밀번호는 필수입니다") + @Size(min = 8, max = 60, message = "비밀번호는 8자 이상 60자 이하여야 합니다") + @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{8,}$", + message = "비밀번호는 최소 8자 이상, 숫자, 문자, 특수문자를 포함해야 합니다") + String password +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java new file mode 100644 index 000000000..96b8517c7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/UserUpdateRequest.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record UserUpdateRequest( + @Size(min = 3, max = 50, message = "사용자 이름은 3자 이상 50자 이하여야 합니다") + String newUsername, + + @Email(message = "유효한 이메일 형식이어야 합니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") + String newEmail, + + @Size(min = 8, max = 60, message = "비밀번호는 8자 이상 60자 이하여야 합니다") + @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{8,}$", + message = "비밀번호는 최소 8자 이상, 숫자, 문자, 특수문자를 포함해야 합니다") + String newPassword +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java new file mode 100644 index 000000000..181d532d7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/PageResponse.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto.response; + +import java.util.List; + +public record PageResponse( + List content, + Object nextCursor, + int size, + boolean hasNext, + Long totalElements +) { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java new file mode 100644 index 000000000..88a096848 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java @@ -0,0 +1,29 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "binary_contents") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BinaryContent extends BaseEntity { + + @Column(nullable = false) + private String fileName; + @Column(nullable = false) + private Long size; + @Column(length = 100, nullable = false) + private String contentType; + + public BinaryContent(String fileName, Long size, String contentType) { + this.fileName = fileName; + this.size = size; + this.contentType = contentType; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Channel.java b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java new file mode 100644 index 000000000..101b737bd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java @@ -0,0 +1,41 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "channels") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Channel extends BaseUpdatableEntity { + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ChannelType type; + @Column(length = 100) + private String name; + @Column(length = 500) + private String description; + + public Channel(ChannelType type, String name, String description) { + this.type = type; + this.name = name; + this.description = description; + } + + public void update(String newName, String newDescription) { + if (newName != null && !newName.equals(this.name)) { + this.name = newName; + } + if (newDescription != null && !newDescription.equals(this.description)) { + this.description = newDescription; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java new file mode 100644 index 000000000..4fca37721 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java @@ -0,0 +1,6 @@ +package com.sprint.mission.discodeit.entity; + +public enum ChannelType { + PUBLIC, + PRIVATE, +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Message.java b/src/main/java/com/sprint/mission/discodeit/entity/Message.java new file mode 100644 index 000000000..7fe8865ea --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Message.java @@ -0,0 +1,55 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; + +@Entity +@Table(name = "messages") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Message extends BaseUpdatableEntity { + + @Column(columnDefinition = "text", nullable = false) + private String content; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "channel_id", columnDefinition = "uuid") + private Channel channel; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", columnDefinition = "uuid") + private User author; + @BatchSize(size = 100) + @OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.ALL) + @JoinTable( + name = "message_attachments", + joinColumns = @JoinColumn(name = "message_id"), + inverseJoinColumns = @JoinColumn(name = "attachment_id") + ) + private List attachments = new ArrayList<>(); + + public Message(String content, Channel channel, User author, List attachments) { + this.channel = channel; + this.content = content; + this.author = author; + this.attachments = attachments; + } + + public void update(String newContent) { + if (newContent != null && !newContent.equals(this.content)) { + this.content = newContent; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java new file mode 100644 index 000000000..d51448b96 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java @@ -0,0 +1,47 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "read_statuses", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "channel_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReadStatus extends BaseUpdatableEntity { + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", columnDefinition = "uuid") + private User user; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "channel_id", columnDefinition = "uuid") + private Channel channel; + @Column(columnDefinition = "timestamp with time zone", nullable = false) + private Instant lastReadAt; + + public ReadStatus(User user, Channel channel, Instant lastReadAt) { + this.user = user; + this.channel = channel; + this.lastReadAt = lastReadAt; + } + + public void update(Instant newLastReadAt) { + if (newLastReadAt != null && !newLastReadAt.equals(this.lastReadAt)) { + this.lastReadAt = newLastReadAt; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Role.java b/src/main/java/com/sprint/mission/discodeit/entity/Role.java new file mode 100644 index 000000000..6b9c0b052 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Role.java @@ -0,0 +1,7 @@ +package com.sprint.mission.discodeit.entity; + +public enum Role { + ADMIN, + CHANNEL_MANAGER, + USER +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/User.java b/src/main/java/com/sprint/mission/discodeit/entity/User.java new file mode 100644 index 000000000..6044be8a2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/User.java @@ -0,0 +1,64 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA를 위한 기본 생성자 +public class User extends BaseUpdatableEntity { + + @Column(length = 50, nullable = false, unique = true) + private String username; + @Column(length = 100, nullable = false, unique = true) + private String email; + @Column(length = 60, nullable = false) + private String password; + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "profile_id", columnDefinition = "uuid") + private BinaryContent profile; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role = Role.USER; + + public User(String username, String email, String password, BinaryContent profile) { + this.username = username; + this.email = email; + this.password = password; + this.profile = profile; + } + + public void update(String newUsername, String newEmail, String newPassword, + BinaryContent newProfile) { + if (newUsername != null && !newUsername.equals(this.username)) { + this.username = newUsername; + } + if (newEmail != null && !newEmail.equals(this.email)) { + this.email = newEmail; + } + if (newPassword != null && !newPassword.equals(this.password)) { + this.password = newPassword; + } + if (newProfile != null) { + this.profile = newProfile; + } + } + + public void updateRole(Role newRole) { + if (this.role != newRole) { + this.role = newRole; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java new file mode 100644 index 000000000..f28210164 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseEntity.java @@ -0,0 +1,31 @@ +package com.sprint.mission.discodeit.entity.base; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "uuid", updatable = false, nullable = false) + private UUID id; + + @CreatedDate + @Column(columnDefinition = "timestamp with time zone", updatable = false, nullable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java new file mode 100644 index 000000000..57d1d3169 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/base/BaseUpdatableEntity.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.entity.base; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.LastModifiedDate; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@MappedSuperclass +public abstract class BaseUpdatableEntity extends BaseEntity { + + @LastModifiedDate + @Column(columnDefinition = "timestamp with time zone") + private Instant updatedAt; +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java new file mode 100644 index 000000000..d929a51f8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java @@ -0,0 +1,32 @@ +package com.sprint.mission.discodeit.exception; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; + +@Getter +public class DiscodeitException extends RuntimeException { + private final Instant timestamp; + private final ErrorCode errorCode; + private final Map details; + + public DiscodeitException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + public DiscodeitException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = new HashMap<>(); + } + + public void addDetail(String key, Object value) { + this.details.put(key, value); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java new file mode 100644 index 000000000..1ae84323e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java @@ -0,0 +1,35 @@ +package com.sprint.mission.discodeit.exception; + +import lombok.Getter; + +@Getter +public enum ErrorCode { + // User 관련 에러 코드 + USER_NOT_FOUND("사용자를 찾을 수 없습니다."), + DUPLICATE_USER("이미 존재하는 사용자입니다."), + INVALID_USER_CREDENTIALS("잘못된 사용자 인증 정보입니다."), + + // Channel 관련 에러 코드 + CHANNEL_NOT_FOUND("채널을 찾을 수 없습니다."), + PRIVATE_CHANNEL_UPDATE("비공개 채널은 수정할 수 없습니다."), + + // Message 관련 에러 코드 + MESSAGE_NOT_FOUND("메시지를 찾을 수 없습니다."), + + // BinaryContent 관련 에러 코드 + BINARY_CONTENT_NOT_FOUND("바이너리 컨텐츠를 찾을 수 없습니다."), + + // ReadStatus 관련 에러 코드 + READ_STATUS_NOT_FOUND("읽음 상태를 찾을 수 없습니다."), + DUPLICATE_READ_STATUS("이미 존재하는 읽음 상태입니다."), + + // Server 에러 코드 + INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다."), + INVALID_REQUEST("잘못된 요청입니다."); + + private final String message; + + ErrorCode(String message) { + this.message = message; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java new file mode 100644 index 000000000..6a9ae50ef --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java @@ -0,0 +1,27 @@ +package com.sprint.mission.discodeit.exception; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ErrorResponse { + private final Instant timestamp; + private final String code; + private final String message; + private final Map details; + private final String exceptionType; + private final int status; + + public ErrorResponse(DiscodeitException exception, int status) { + this(Instant.now(), exception.getErrorCode().name(), exception.getMessage(), exception.getDetails(), exception.getClass().getSimpleName(), status); + } + + public ErrorResponse(Exception exception, int status) { + this(Instant.now(), exception.getClass().getSimpleName(), exception.getMessage(), new HashMap<>(), exception.getClass().getSimpleName(), status); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..5f4c24de8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -0,0 +1,95 @@ +package com.sprint.mission.discodeit.exception; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error("예상치 못한 오류 발생: {}", e.getMessage(), e); + ErrorResponse errorResponse = new ErrorResponse(e, HttpStatus.INTERNAL_SERVER_ERROR.value()); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(errorResponse); + } + + @ExceptionHandler(DiscodeitException.class) + public ResponseEntity handleDiscodeitException(DiscodeitException exception) { + log.error("커스텀 예외 발생: code={}, message={}", exception.getErrorCode(), exception.getMessage(), + exception); + HttpStatus status = determineHttpStatus(exception); + ErrorResponse response = new ErrorResponse(exception, status.value()); + return ResponseEntity + .status(status) + .body(response); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions( + MethodArgumentNotValidException ex) { + log.error("요청 유효성 검사 실패: {}", ex.getMessage()); + + Map validationErrors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + validationErrors.put(fieldName, errorMessage); + }); + + ErrorResponse response = new ErrorResponse( + Instant.now(), + "VALIDATION_ERROR", + "요청 데이터 유효성 검사에 실패했습니다", + validationErrors, + ex.getClass().getSimpleName(), + HttpStatus.BAD_REQUEST.value() + ); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(response); + } + + @ExceptionHandler(AuthorizationDeniedException.class) + public ResponseEntity handleAuthorizationDeniedException( + AuthorizationDeniedException ex) { + log.error("권한 거부 오류 발생: {}", ex.getMessage()); + ErrorResponse response = new ErrorResponse( + Instant.now(), + "AUTHORIZATION_DENIED", + "요청에 대한 권한이 없습니다", + null, + ex.getClass().getSimpleName(), + HttpStatus.FORBIDDEN.value() + ); + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(response); + } + + private HttpStatus determineHttpStatus(DiscodeitException exception) { + ErrorCode errorCode = exception.getErrorCode(); + return switch (errorCode) { + case USER_NOT_FOUND, CHANNEL_NOT_FOUND, MESSAGE_NOT_FOUND, BINARY_CONTENT_NOT_FOUND, + READ_STATUS_NOT_FOUND -> HttpStatus.NOT_FOUND; + case DUPLICATE_USER, DUPLICATE_READ_STATUS -> HttpStatus.CONFLICT; + case INVALID_USER_CREDENTIALS -> HttpStatus.UNAUTHORIZED; + case PRIVATE_CHANNEL_UPDATE, INVALID_REQUEST -> HttpStatus.BAD_REQUEST; + case INTERNAL_SERVER_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java new file mode 100644 index 000000000..368025bf2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.binarycontent; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class BinaryContentException extends DiscodeitException { + public BinaryContentException(ErrorCode errorCode) { + super(errorCode); + } + + public BinaryContentException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java new file mode 100644 index 000000000..65ad82363 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentNotFoundException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.binarycontent; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class BinaryContentNotFoundException extends BinaryContentException { + public BinaryContentNotFoundException() { + super(ErrorCode.BINARY_CONTENT_NOT_FOUND); + } + + public static BinaryContentNotFoundException withId(UUID binaryContentId) { + BinaryContentNotFoundException exception = new BinaryContentNotFoundException(); + exception.addDetail("binaryContentId", binaryContentId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java new file mode 100644 index 000000000..1ba3364ba --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class ChannelException extends DiscodeitException { + public ChannelException(ErrorCode errorCode) { + super(errorCode); + } + + public ChannelException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java new file mode 100644 index 000000000..ec7b1f335 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelNotFoundException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.channel; + +import java.util.UUID; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class ChannelNotFoundException extends ChannelException { + public ChannelNotFoundException() { + super(ErrorCode.CHANNEL_NOT_FOUND); + } + + public static ChannelNotFoundException withId(UUID channelId) { + ChannelNotFoundException exception = new ChannelNotFoundException(); + exception.addDetail("channelId", channelId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java new file mode 100644 index 000000000..2b8b1597c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class PrivateChannelUpdateException extends ChannelException { + public PrivateChannelUpdateException() { + super(ErrorCode.PRIVATE_CHANNEL_UPDATE); + } + + public static PrivateChannelUpdateException forChannel(UUID channelId) { + PrivateChannelUpdateException exception = new PrivateChannelUpdateException(); + exception.addDetail("channelId", channelId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java new file mode 100644 index 000000000..289922ed3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class MessageException extends DiscodeitException { + public MessageException(ErrorCode errorCode) { + super(errorCode); + } + + public MessageException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java new file mode 100644 index 000000000..423aafbb3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class MessageNotFoundException extends MessageException { + public MessageNotFoundException() { + super(ErrorCode.MESSAGE_NOT_FOUND); + } + + public static MessageNotFoundException withId(UUID messageId) { + MessageNotFoundException exception = new MessageNotFoundException(); + exception.addDetail("messageId", messageId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java new file mode 100644 index 000000000..5a30692d8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/DuplicateReadStatusException.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class DuplicateReadStatusException extends ReadStatusException { + public DuplicateReadStatusException() { + super(ErrorCode.DUPLICATE_READ_STATUS); + } + + public static DuplicateReadStatusException withUserIdAndChannelId(UUID userId, UUID channelId) { + DuplicateReadStatusException exception = new DuplicateReadStatusException(); + exception.addDetail("userId", userId); + exception.addDetail("channelId", channelId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java new file mode 100644 index 000000000..3860caf2e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class ReadStatusException extends DiscodeitException { + public ReadStatusException(ErrorCode errorCode) { + super(errorCode); + } + + public ReadStatusException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java new file mode 100644 index 000000000..86b9fde75 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusNotFoundException.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.exception.readstatus; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.UUID; + +public class ReadStatusNotFoundException extends ReadStatusException { + public ReadStatusNotFoundException() { + super(ErrorCode.READ_STATUS_NOT_FOUND); + } + + public static ReadStatusNotFoundException withId(UUID readStatusId) { + ReadStatusNotFoundException exception = new ReadStatusNotFoundException(); + exception.addDetail("readStatusId", readStatusId); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java new file mode 100644 index 000000000..d75576fdf --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/InvalidCredentialsException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class InvalidCredentialsException extends UserException { + public InvalidCredentialsException() { + super(ErrorCode.INVALID_USER_CREDENTIALS); + } + + public static InvalidCredentialsException wrongPassword() { + InvalidCredentialsException exception = new InvalidCredentialsException(); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java new file mode 100644 index 000000000..9d0b3b3d1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class UserAlreadyExistsException extends UserException { + public UserAlreadyExistsException() { + super(ErrorCode.DUPLICATE_USER); + } + + public static UserAlreadyExistsException withEmail(String email) { + UserAlreadyExistsException exception = new UserAlreadyExistsException(); + exception.addDetail("email", email); + return exception; + } + + public static UserAlreadyExistsException withUsername(String username) { + UserAlreadyExistsException exception = new UserAlreadyExistsException(); + exception.addDetail("username", username); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java new file mode 100644 index 000000000..f48629706 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.user; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class UserException extends DiscodeitException { + public UserException(ErrorCode errorCode) { + super(errorCode); + } + + public UserException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java new file mode 100644 index 000000000..bd76dfa9e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.exception.user; + +import java.util.UUID; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +public class UserNotFoundException extends UserException { + public UserNotFoundException() { + super(ErrorCode.USER_NOT_FOUND); + } + + public static UserNotFoundException withId(UUID userId) { + UserNotFoundException exception = new UserNotFoundException(); + exception.addDetail("userId", userId); + return exception; + } + + public static UserNotFoundException withUsername(String username) { + UserNotFoundException exception = new UserNotFoundException(); + exception.addDetail("username", username); + return exception; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java new file mode 100644 index 000000000..d3ea1f137 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.entity.BinaryContent; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface BinaryContentMapper { + + BinaryContentDto toDto(BinaryContent binaryContent); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java new file mode 100644 index 000000000..f39a5809c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java @@ -0,0 +1,48 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.beans.factory.annotation.Autowired; + +@Mapper(componentModel = "spring", uses = {UserMapper.class}) +public abstract class ChannelMapper { + + @Autowired + private MessageRepository messageRepository; + @Autowired + private ReadStatusRepository readStatusRepository; + @Autowired + private UserMapper userMapper; + + @Mapping(target = "participants", expression = "java(resolveParticipants(channel))") + @Mapping(target = "lastMessageAt", expression = "java(resolveLastMessageAt(channel))") + abstract public ChannelDto toDto(Channel channel); + + protected Instant resolveLastMessageAt(Channel channel) { + return messageRepository.findLastMessageAtByChannelId( + channel.getId()) + .orElse(Instant.MIN); + } + + protected List resolveParticipants(Channel channel) { + List participants = new ArrayList<>(); + if (channel.getType().equals(ChannelType.PRIVATE)) { + readStatusRepository.findAllByChannelIdWithUser(channel.getId()) + .stream() + .map(ReadStatus::getUser) + .map(userMapper::toDto) + .forEach(participants::add); + } + return participants; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java new file mode 100644 index 000000000..e0301ac08 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.entity.Message; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", uses = {BinaryContentMapper.class, UserMapper.class}) +public interface MessageMapper { + + @Mapping(target = "channelId", source = "channel.id") + MessageDto toDto(Message message); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java new file mode 100644 index 000000000..108a9b59d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java @@ -0,0 +1,30 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.response.PageResponse; +import org.mapstruct.Mapper; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; + +@Mapper(componentModel = "spring") +public interface PageResponseMapper { + + default PageResponse fromSlice(Slice slice, Object nextCursor) { + return new PageResponse<>( + slice.getContent(), + nextCursor, + slice.getSize(), + slice.hasNext(), + null + ); + } + + default PageResponse fromPage(Page page, Object nextCursor) { + return new PageResponse<>( + page.getContent(), + nextCursor, + page.getSize(), + page.hasNext(), + page.getTotalElements() + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java new file mode 100644 index 000000000..af9b85279 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.entity.ReadStatus; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ReadStatusMapper { + + @Mapping(target = "userId", source = "user.id") + @Mapping(target = "channelId", source = "channel.id") + ReadStatusDto toDto(ReadStatus readStatus); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java new file mode 100644 index 000000000..bd49e63aa --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.security.SessionManager; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.beans.factory.annotation.Autowired; + +@Mapper(componentModel = "spring", uses = {BinaryContentMapper.class}) +public abstract class UserMapper { + + @Autowired + protected SessionManager sessionManager; + + @Mapping(target = "online", expression = "java(sessionManager.hasActiveSessions(user.getId()))") + public abstract UserDto toDto(User user); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java new file mode 100644 index 000000000..cbd8c79cf --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BinaryContentRepository extends JpaRepository { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java new file mode 100644 index 000000000..e4b1fd235 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChannelRepository extends JpaRepository { + + List findAllByTypeOrIdIn(ChannelType type, List ids); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java new file mode 100644 index 000000000..6996c05e2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java @@ -0,0 +1,31 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Message; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MessageRepository extends JpaRepository { + + @Query("SELECT m FROM Message m " + + "LEFT JOIN FETCH m.author a " + + "LEFT JOIN FETCH a.profile " + + "WHERE m.channel.id=:channelId AND m.createdAt < :createdAt") + Slice findAllByChannelIdWithAuthor(@Param("channelId") UUID channelId, + @Param("createdAt") Instant createdAt, + Pageable pageable); + + + @Query("SELECT m.createdAt " + + "FROM Message m " + + "WHERE m.channel.id = :channelId " + + "ORDER BY m.createdAt DESC LIMIT 1") + Optional findLastMessageAtByChannelId(@Param("channelId") UUID channelId); + + void deleteAllByChannelId(UUID channelId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java new file mode 100644 index 000000000..ae2a6491d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java @@ -0,0 +1,24 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.ReadStatus; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ReadStatusRepository extends JpaRepository { + + + List findAllByUserId(UUID userId); + + @Query("SELECT r FROM ReadStatus r " + + "JOIN FETCH r.user u " + + "LEFT JOIN FETCH u.profile " + + "WHERE r.channel.id = :channelId") + List findAllByChannelIdWithUser(@Param("channelId") UUID channelId); + + Boolean existsByUserIdAndChannelId(UUID userId, UUID channelId); + + void deleteAllByChannelId(UUID channelId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java new file mode 100644 index 000000000..4fdd8f3b6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.User; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); + + @Query("SELECT u FROM User u " + + "LEFT JOIN FETCH u.profile") + List findAllWithProfile(); +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java b/src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java new file mode 100644 index 000000000..25c3c5eec --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java @@ -0,0 +1,46 @@ +package com.sprint.mission.discodeit.security; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException; +import com.sprint.mission.discodeit.service.AuthService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class AdminInitializer implements ApplicationRunner { + + @Value("${discodeit.admin.username}") + private String username; + @Value("${discodeit.admin.password}") + private String password; + @Value("${discodeit.admin.email}") + private String email; + private final UserService userService; + private final AuthService authService; + + @Override + public void run(ApplicationArguments args) { + // 관리자 계정 초기화 로직 + UserCreateRequest request = new UserCreateRequest(username, email, password); + try { + UserDto admin = userService.create(request, Optional.empty()); + authService.updateRoleInternal(new RoleUpdateRequest(admin.id(), Role.ADMIN)); + log.info("관리자 계정이 성공적으로 생성되었습니다."); + } catch (UserAlreadyExistsException e) { + log.warn("관리자 계정이 이미 존재합니다"); + } catch (Exception e) { + log.error("관리자 계정 생성 중 오류가 발생했습니다.: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java new file mode 100644 index 000000000..4b3a9dc82 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java @@ -0,0 +1,35 @@ +package com.sprint.mission.discodeit.security; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import java.util.Collection; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@EqualsAndHashCode(of = "userDto") +@Getter +@RequiredArgsConstructor +public class DiscodeitUserDetails implements UserDetails { + + private final UserDto userDto; + private final String password; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + userDto.role().name())); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return userDto.username(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java new file mode 100644 index 000000000..b48dd2d12 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java @@ -0,0 +1,34 @@ +package com.sprint.mission.discodeit.security; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DiscodeitUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + + @Transactional(readOnly = true) + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> UserNotFoundException.withUsername(username)); + UserDto userDto = userMapper.toDto(user); + + return new DiscodeitUserDetails( + userDto, + user.getPassword() + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/Http403ForbiddenAccessDeniedHandler.java b/src/main/java/com/sprint/mission/discodeit/security/Http403ForbiddenAccessDeniedHandler.java new file mode 100644 index 000000000..9f47cc2c6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/Http403ForbiddenAccessDeniedHandler.java @@ -0,0 +1,29 @@ +package com.sprint.mission.discodeit.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +@RequiredArgsConstructor +public class Http403ForbiddenAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse(accessDeniedException, + HttpServletResponse.SC_FORBIDDEN); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java b/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java new file mode 100644 index 000000000..cf749dff9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java @@ -0,0 +1,34 @@ +package com.sprint.mission.discodeit.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LoginFailureHandler implements AuthenticationFailureHandler { + + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + log.error("Authentication failed: {}", exception.getMessage(), exception); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse(exception, HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java new file mode 100644 index 000000000..135b31e60 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java @@ -0,0 +1,42 @@ +package com.sprint.mission.discodeit.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + response.setCharacterEncoding("UTF-8"); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + if (authentication.getPrincipal() instanceof DiscodeitUserDetails userDetails) { + response.setStatus(HttpServletResponse.SC_OK); + UserDto userDto = userDetails.getUserDto(); + response.getWriter().write(objectMapper.writeValueAsString(userDto)); + + } else { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + ErrorResponse errorResponse = new ErrorResponse( + new RuntimeException("Authentication failed: Invalid user details"), + HttpServletResponse.SC_UNAUTHORIZED + ); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/SessionManager.java b/src/main/java/com/sprint/mission/discodeit/security/SessionManager.java new file mode 100644 index 000000000..95d2c139a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/SessionManager.java @@ -0,0 +1,39 @@ +package com.sprint.mission.discodeit.security; + +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SessionManager { + + private final SessionRegistry sessionRegistry; + + public List getActiveSessionsByUserId(UUID userId) { + return sessionRegistry.getAllPrincipals().stream() + .filter(principal -> principal instanceof DiscodeitUserDetails) + .map(DiscodeitUserDetails.class::cast) + .filter(details -> details.getUserDto().id().equals(userId)) + .flatMap(details -> sessionRegistry.getAllSessions(details, false).stream()) + .toList(); + } + + public void invalidateSessionsByUserId(UUID userId) { + List activeSessionInfos = getActiveSessionsByUserId(userId); + + if (!activeSessionInfos.isEmpty()) { + activeSessionInfos.forEach(SessionInformation::expireNow); + log.debug("{}개의 세션이 무효화되었습니다.", activeSessionInfos.size()); + } + } + + public boolean hasActiveSessions(UUID userId) { + return !getActiveSessionsByUserId(userId).isEmpty(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/SpaCsrfTokenRequestHandler.java b/src/main/java/com/sprint/mission/discodeit/security/SpaCsrfTokenRequestHandler.java new file mode 100644 index 000000000..6314f37d4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/SpaCsrfTokenRequestHandler.java @@ -0,0 +1,48 @@ +package com.sprint.mission.discodeit.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.function.Supplier; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.csrf.CsrfTokenRequestHandler; +import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; +import org.springframework.util.StringUtils; + +public class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler { + + private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler(); + private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + Supplier csrfToken) { + /* + * Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of + * the CsrfToken when it is rendered in the response body. + */ + this.xor.handle(request, response, csrfToken); + /* + * Render the token value to a cookie by causing the deferred token to be loaded. + */ + csrfToken.get(); + } + + @Override + public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) { + String headerValue = request.getHeader(csrfToken.getHeaderName()); + /* + * If the request contains a request header, use CsrfTokenRequestAttributeHandler + * to resolve the CsrfToken. This applies when a single-page application includes + * the header value automatically, which was obtained via a cookie containing the + * raw CsrfToken. + * + * In all other cases (e.g. if the request contains a request parameter), use + * XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies + * when a server-side rendered form includes the _csrf request parameter as a + * hidden input. + */ + return (StringUtils.hasText(headerValue) ? this.plain : this.xor).resolveCsrfTokenValue(request, + csrfToken); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java new file mode 100644 index 000000000..aba352315 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; + +public interface AuthService { + + UserDto updateRole(RoleUpdateRequest request); + + UserDto updateRoleInternal(RoleUpdateRequest request); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java new file mode 100644 index 000000000..23836a446 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import java.util.List; +import java.util.UUID; + +public interface BinaryContentService { + + BinaryContentDto create(BinaryContentCreateRequest request); + + BinaryContentDto find(UUID binaryContentId); + + List findAllByIdIn(List binaryContentIds); + + void delete(UUID binaryContentId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java new file mode 100644 index 000000000..a082c9ff9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/ChannelService.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import java.util.List; +import java.util.UUID; + +public interface ChannelService { + + ChannelDto create(PublicChannelCreateRequest request); + + ChannelDto create(PrivateChannelCreateRequest request); + + ChannelDto find(UUID channelId); + + List findAllByUserId(UUID userId); + + ChannelDto update(UUID channelId, PublicChannelUpdateRequest request); + + void delete(UUID channelId); +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java new file mode 100644 index 000000000..8ac5ee924 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Pageable; + +public interface MessageService { + + MessageDto create(MessageCreateRequest messageCreateRequest, + List binaryContentCreateRequests); + + MessageDto find(UUID messageId); + + PageResponse findAllByChannelId(UUID channelId, Instant createdAt, Pageable pageable); + + MessageDto update(UUID messageId, MessageUpdateRequest request); + + void delete(UUID messageId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java new file mode 100644 index 000000000..8b0c80a31 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import java.util.List; +import java.util.UUID; + +public interface ReadStatusService { + + ReadStatusDto create(ReadStatusCreateRequest request); + + ReadStatusDto find(UUID readStatusId); + + List findAllByUserId(UUID userId); + + ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request); + + void delete(UUID readStatusId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserService.java b/src/main/java/com/sprint/mission/discodeit/service/UserService.java new file mode 100644 index 000000000..444118780 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/UserService.java @@ -0,0 +1,24 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserService { + + UserDto create(UserCreateRequest userCreateRequest, + Optional profileCreateRequest); + + UserDto find(UUID userId); + + List findAll(); + + UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, + Optional profileCreateRequest); + + void delete(UUID userId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java new file mode 100644 index 000000000..5634575ab --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -0,0 +1,51 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.security.SessionManager; +import com.sprint.mission.discodeit.service.AuthService; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class BasicAuthService implements AuthService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + private final SessionManager sessionManager; + + @PreAuthorize("hasRole('ADMIN')") + @Transactional + @Override + public UserDto updateRole(RoleUpdateRequest request) { + return updateRoleInternal(request); + } + + @Transactional + @Override + public UserDto updateRoleInternal(RoleUpdateRequest request) { + UUID userId = request.userId(); + User user = userRepository.findById(userId) + .orElseThrow(() -> UserNotFoundException.withId(userId)); + + Role newRole = request.newRole(); + user.updateRole(newRole); + + sessionManager.invalidateSessionsByUserId(userId); + + return userMapper.toDto(user); + } + + +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java new file mode 100644 index 000000000..bd50ce57d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java @@ -0,0 +1,80 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class BasicBinaryContentService implements BinaryContentService { + + private final BinaryContentRepository binaryContentRepository; + private final BinaryContentMapper binaryContentMapper; + private final BinaryContentStorage binaryContentStorage; + + @Transactional + @Override + public BinaryContentDto create(BinaryContentCreateRequest request) { + log.debug("바이너리 컨텐츠 생성 시작: fileName={}, size={}, contentType={}", + request.fileName(), request.bytes().length, request.contentType()); + + String fileName = request.fileName(); + byte[] bytes = request.bytes(); + String contentType = request.contentType(); + BinaryContent binaryContent = new BinaryContent( + fileName, + (long) bytes.length, + contentType + ); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + + log.info("바이너리 컨텐츠 생성 완료: id={}, fileName={}, size={}", + binaryContent.getId(), fileName, bytes.length); + return binaryContentMapper.toDto(binaryContent); + } + + @Override + public BinaryContentDto find(UUID binaryContentId) { + log.debug("바이너리 컨텐츠 조회 시작: id={}", binaryContentId); + BinaryContentDto dto = binaryContentRepository.findById(binaryContentId) + .map(binaryContentMapper::toDto) + .orElseThrow(() -> BinaryContentNotFoundException.withId(binaryContentId)); + log.info("바이너리 컨텐츠 조회 완료: id={}, fileName={}", + dto.id(), dto.fileName()); + return dto; + } + + @Override + public List findAllByIdIn(List binaryContentIds) { + log.debug("바이너리 컨텐츠 목록 조회 시작: ids={}", binaryContentIds); + List dtos = binaryContentRepository.findAllById(binaryContentIds).stream() + .map(binaryContentMapper::toDto) + .toList(); + log.info("바이너리 컨텐츠 목록 조회 완료: 조회된 항목 수={}", dtos.size()); + return dtos; + } + + @Transactional + @Override + public void delete(UUID binaryContentId) { + log.debug("바이너리 컨텐츠 삭제 시작: id={}", binaryContentId); + if (!binaryContentRepository.existsById(binaryContentId)) { + throw BinaryContentNotFoundException.withId(binaryContentId); + } + binaryContentRepository.deleteById(binaryContentId); + log.info("바이너리 컨텐츠 삭제 완료: id={}", binaryContentId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java new file mode 100644 index 000000000..1c6952110 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java @@ -0,0 +1,122 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.ChannelService; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BasicChannelService implements ChannelService { + + private final ChannelRepository channelRepository; + // + private final ReadStatusRepository readStatusRepository; + private final MessageRepository messageRepository; + private final UserRepository userRepository; + private final ChannelMapper channelMapper; + + @PreAuthorize("hasRole('CHANNEL_MANAGER')") + @Transactional + @Override + public ChannelDto create(PublicChannelCreateRequest request) { + log.debug("채널 생성 시작: {}", request); + String name = request.name(); + String description = request.description(); + Channel channel = new Channel(ChannelType.PUBLIC, name, description); + + channelRepository.save(channel); + log.info("채널 생성 완료: id={}, name={}", channel.getId(), channel.getName()); + return channelMapper.toDto(channel); + } + + @Transactional + @Override + public ChannelDto create(PrivateChannelCreateRequest request) { + log.debug("채널 생성 시작: {}", request); + Channel channel = new Channel(ChannelType.PRIVATE, null, null); + channelRepository.save(channel); + + List readStatuses = userRepository.findAllById(request.participantIds()).stream() + .map(user -> new ReadStatus(user, channel, channel.getCreatedAt())) + .toList(); + readStatusRepository.saveAll(readStatuses); + + log.info("채널 생성 완료: id={}, name={}", channel.getId(), channel.getName()); + return channelMapper.toDto(channel); + } + + @Transactional(readOnly = true) + @Override + public ChannelDto find(UUID channelId) { + return channelRepository.findById(channelId) + .map(channelMapper::toDto) + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); + } + + @Transactional(readOnly = true) + @Override + public List findAllByUserId(UUID userId) { + List mySubscribedChannelIds = readStatusRepository.findAllByUserId(userId).stream() + .map(ReadStatus::getChannel) + .map(Channel::getId) + .toList(); + + return channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, mySubscribedChannelIds) + .stream() + .map(channelMapper::toDto) + .toList(); + } + + @PreAuthorize("hasRole('CHANNEL_MANAGER')") + @Transactional + @Override + public ChannelDto update(UUID channelId, PublicChannelUpdateRequest request) { + log.debug("채널 수정 시작: id={}, request={}", channelId, request); + String newName = request.newName(); + String newDescription = request.newDescription(); + Channel channel = channelRepository.findById(channelId) + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); + if (channel.getType().equals(ChannelType.PRIVATE)) { + throw PrivateChannelUpdateException.forChannel(channelId); + } + channel.update(newName, newDescription); + log.info("채널 수정 완료: id={}, name={}", channelId, channel.getName()); + return channelMapper.toDto(channel); + } + + @PreAuthorize("hasRole('CHANNEL_MANAGER')") + @Transactional + @Override + public void delete(UUID channelId) { + log.debug("채널 삭제 시작: id={}", channelId); + if (!channelRepository.existsById(channelId)) { + throw ChannelNotFoundException.withId(channelId); + } + + messageRepository.deleteAllByChannelId(channelId); + readStatusRepository.deleteAllByChannelId(channelId); + + channelRepository.deleteById(channelId); + log.info("채널 삭제 완료: id={}", channelId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java new file mode 100644 index 000000000..5bb604f15 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java @@ -0,0 +1,138 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.PageResponseMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BasicMessageService implements MessageService { + + private final MessageRepository messageRepository; + private final ChannelRepository channelRepository; + private final UserRepository userRepository; + private final MessageMapper messageMapper; + private final BinaryContentStorage binaryContentStorage; + private final BinaryContentRepository binaryContentRepository; + private final PageResponseMapper pageResponseMapper; + + @Transactional + @Override + public MessageDto create(MessageCreateRequest messageCreateRequest, + List binaryContentCreateRequests) { + log.debug("메시지 생성 시작: request={}", messageCreateRequest); + UUID channelId = messageCreateRequest.channelId(); + UUID authorId = messageCreateRequest.authorId(); + + Channel channel = channelRepository.findById(channelId) + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); + User author = userRepository.findById(authorId) + .orElseThrow(() -> UserNotFoundException.withId(authorId)); + + List attachments = binaryContentCreateRequests.stream() + .map(attachmentRequest -> { + String fileName = attachmentRequest.fileName(); + String contentType = attachmentRequest.contentType(); + byte[] bytes = attachmentRequest.bytes(); + + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .toList(); + + String content = messageCreateRequest.content(); + Message message = new Message( + content, + channel, + author, + attachments + ); + + messageRepository.save(message); + log.info("메시지 생성 완료: id={}, channelId={}", message.getId(), channelId); + return messageMapper.toDto(message); + } + + @Transactional(readOnly = true) + @Override + public MessageDto find(UUID messageId) { + return messageRepository.findById(messageId) + .map(messageMapper::toDto) + .orElseThrow(() -> MessageNotFoundException.withId(messageId)); + } + + @Transactional(readOnly = true) + @Override + public PageResponse findAllByChannelId(UUID channelId, Instant createAt, + Pageable pageable) { + Slice slice = messageRepository.findAllByChannelIdWithAuthor(channelId, + Optional.ofNullable(createAt).orElse(Instant.now()), + pageable) + .map(messageMapper::toDto); + + Instant nextCursor = null; + if (!slice.getContent().isEmpty()) { + nextCursor = slice.getContent().get(slice.getContent().size() - 1) + .createdAt(); + } + + return pageResponseMapper.fromSlice(slice, nextCursor); + } + + @PreAuthorize("principal.userDto.id == @basicMessageService.find(#messageId).author.id") + @Transactional + @Override + public MessageDto update(UUID messageId, MessageUpdateRequest request) { + log.debug("메시지 수정 시작: id={}, request={}", messageId, request); + Message message = messageRepository.findById(messageId) + .orElseThrow(() -> MessageNotFoundException.withId(messageId)); + + message.update(request.newContent()); + log.info("메시지 수정 완료: id={}, channelId={}", messageId, message.getChannel().getId()); + return messageMapper.toDto(message); + } + + @PreAuthorize("principal.userDto.id == @basicMessageService.find(#messageId).author.id") + @Transactional + @Override + public void delete(UUID messageId) { + log.debug("메시지 삭제 시작: id={}", messageId); + if (!messageRepository.existsById(messageId)) { + throw MessageNotFoundException.withId(messageId); + } + messageRepository.deleteById(messageId); + log.info("메시지 삭제 완료: id={}", messageId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java new file mode 100644 index 000000000..d5787246c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java @@ -0,0 +1,107 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.readstatus.DuplicateReadStatusException; +import com.sprint.mission.discodeit.exception.readstatus.ReadStatusNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.ReadStatusMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.ReadStatusService; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Service +public class BasicReadStatusService implements ReadStatusService { + + private final ReadStatusRepository readStatusRepository; + private final UserRepository userRepository; + private final ChannelRepository channelRepository; + private final ReadStatusMapper readStatusMapper; + + @Transactional + @Override + public ReadStatusDto create(ReadStatusCreateRequest request) { + log.debug("읽음 상태 생성 시작: userId={}, channelId={}", request.userId(), request.channelId()); + + UUID userId = request.userId(); + UUID channelId = request.channelId(); + + User user = userRepository.findById(userId) + .orElseThrow(() -> UserNotFoundException.withId(userId)); + Channel channel = channelRepository.findById(channelId) + .orElseThrow(() -> ChannelNotFoundException.withId(channelId)); + + if (readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId())) { + throw DuplicateReadStatusException.withUserIdAndChannelId(userId, channelId); + } + + Instant lastReadAt = request.lastReadAt(); + ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt); + readStatusRepository.save(readStatus); + + log.info("읽음 상태 생성 완료: id={}, userId={}, channelId={}", + readStatus.getId(), userId, channelId); + return readStatusMapper.toDto(readStatus); + } + + @Transactional(readOnly = true) + @Override + public ReadStatusDto find(UUID readStatusId) { + log.debug("읽음 상태 조회 시작: id={}", readStatusId); + ReadStatusDto dto = readStatusRepository.findById(readStatusId) + .map(readStatusMapper::toDto) + .orElseThrow(() -> ReadStatusNotFoundException.withId(readStatusId)); + log.info("읽음 상태 조회 완료: id={}", readStatusId); + return dto; + } + + @Transactional(readOnly = true) + @Override + public List findAllByUserId(UUID userId) { + log.debug("사용자별 읽음 상태 목록 조회 시작: userId={}", userId); + List dtos = readStatusRepository.findAllByUserId(userId).stream() + .map(readStatusMapper::toDto) + .toList(); + log.info("사용자별 읽음 상태 목록 조회 완료: userId={}, 조회된 항목 수={}", userId, dtos.size()); + return dtos; + } + + @Transactional + @Override + public ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request) { + log.debug("읽음 상태 수정 시작: id={}, newLastReadAt={}", readStatusId, request.newLastReadAt()); + + ReadStatus readStatus = readStatusRepository.findById(readStatusId) + .orElseThrow(() -> ReadStatusNotFoundException.withId(readStatusId)); + readStatus.update(request.newLastReadAt()); + + log.info("읽음 상태 수정 완료: id={}", readStatusId); + return readStatusMapper.toDto(readStatus); + } + + @Transactional + @Override + public void delete(UUID readStatusId) { + log.debug("읽음 상태 삭제 시작: id={}", readStatusId); + if (!readStatusRepository.existsById(readStatusId)) { + throw ReadStatusNotFoundException.withId(readStatusId); + } + readStatusRepository.deleteById(readStatusId); + log.info("읽음 상태 삭제 완료: id={}", readStatusId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java new file mode 100644 index 000000000..cc7fe191e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -0,0 +1,158 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.UserService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class BasicUserService implements UserService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + private final BinaryContentRepository binaryContentRepository; + private final BinaryContentStorage binaryContentStorage; + private final PasswordEncoder passwordEncoder; + + @Transactional + @Override + public UserDto create(UserCreateRequest userCreateRequest, + Optional optionalProfileCreateRequest) { + log.debug("사용자 생성 시작: {}", userCreateRequest); + + String username = userCreateRequest.username(); + String email = userCreateRequest.email(); + + if (userRepository.existsByEmail(email)) { + throw UserAlreadyExistsException.withEmail(email); + } + if (userRepository.existsByUsername(username)) { + throw UserAlreadyExistsException.withUsername(username); + } + + BinaryContent nullableProfile = optionalProfileCreateRequest + .map(profileRequest -> { + String fileName = profileRequest.fileName(); + String contentType = profileRequest.contentType(); + byte[] bytes = profileRequest.bytes(); + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .orElse(null); + String password = userCreateRequest.password(); + String encodedPassword = passwordEncoder.encode(password); + + User user = new User(username, email, encodedPassword, nullableProfile); + + userRepository.save(user); + log.info("사용자 생성 완료: id={}, username={}", user.getId(), username); + return userMapper.toDto(user); + } + + @Transactional(readOnly = true) + @Override + public UserDto find(UUID userId) { + log.debug("사용자 조회 시작: id={}", userId); + UserDto userDto = userRepository.findById(userId) + .map(userMapper::toDto) + .orElseThrow(() -> UserNotFoundException.withId(userId)); + log.info("사용자 조회 완료: id={}", userId); + return userDto; + } + + @Transactional(readOnly = true) + @Override + public List findAll() { + log.debug("모든 사용자 조회 시작"); + List userDtos = userRepository.findAllWithProfile() + .stream() + .map(userMapper::toDto) + .toList(); + log.info("모든 사용자 조회 완료: 총 {}명", userDtos.size()); + return userDtos; + } + + @PreAuthorize("principal.userDto.id == #userId") + @Transactional + @Override + public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, + Optional optionalProfileCreateRequest) { + log.debug("사용자 수정 시작: id={}, request={}", userId, userUpdateRequest); + + User user = userRepository.findById(userId) + .orElseThrow(() -> { + UserNotFoundException exception = UserNotFoundException.withId(userId); + return exception; + }); + + String newUsername = userUpdateRequest.newUsername(); + String newEmail = userUpdateRequest.newEmail(); + + if (userRepository.existsByEmail(newEmail)) { + throw UserAlreadyExistsException.withEmail(newEmail); + } + + if (userRepository.existsByUsername(newUsername)) { + throw UserAlreadyExistsException.withUsername(newUsername); + } + + BinaryContent nullableProfile = optionalProfileCreateRequest + .map(profileRequest -> { + + String fileName = profileRequest.fileName(); + String contentType = profileRequest.contentType(); + byte[] bytes = profileRequest.bytes(); + BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, + contentType); + binaryContentRepository.save(binaryContent); + binaryContentStorage.put(binaryContent.getId(), bytes); + return binaryContent; + }) + .orElse(null); + + String newPassword = userUpdateRequest.newPassword(); + String encodedPassword = Optional.ofNullable(newPassword).map(passwordEncoder::encode) + .orElse(user.getPassword()); + user.update(newUsername, newEmail, encodedPassword, nullableProfile); + + log.info("사용자 수정 완료: id={}", userId); + return userMapper.toDto(user); + } + + @PreAuthorize("principal.userDto.id == #userId") + @Transactional + @Override + public void delete(UUID userId) { + log.debug("사용자 삭제 시작: id={}", userId); + + if (!userRepository.existsById(userId)) { + throw UserNotFoundException.withId(userId); + } + + userRepository.deleteById(userId); + log.info("사용자 삭제 완료: id={}", userId); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java new file mode 100644 index 000000000..f00216c40 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java @@ -0,0 +1,15 @@ +package com.sprint.mission.discodeit.storage; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import java.io.InputStream; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +public interface BinaryContentStorage { + + UUID put(UUID binaryContentId, byte[] bytes); + + InputStream get(UUID binaryContentId); + + ResponseEntity download(BinaryContentDto metaData); +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java new file mode 100644 index 000000000..8922903c0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java @@ -0,0 +1,89 @@ +package com.sprint.mission.discodeit.storage.local; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.NoSuchElementException; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local") +@Component +public class LocalBinaryContentStorage implements BinaryContentStorage { + + private final Path root; + + public LocalBinaryContentStorage( + @Value("${discodeit.storage.local.root-path}") Path root + ) { + this.root = root; + } + + @PostConstruct + public void init() { + if (!Files.exists(root)) { + try { + Files.createDirectories(root); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + } + + public UUID put(UUID binaryContentId, byte[] bytes) { + Path filePath = resolvePath(binaryContentId); + if (Files.exists(filePath)) { + throw new IllegalArgumentException("File with key " + binaryContentId + " already exists"); + } + try (OutputStream outputStream = Files.newOutputStream(filePath)) { + outputStream.write(bytes); + } catch (IOException e) { + throw new RuntimeException(e); + } + return binaryContentId; + } + + public InputStream get(UUID binaryContentId) { + Path filePath = resolvePath(binaryContentId); + if (Files.notExists(filePath)) { + throw new NoSuchElementException("File with key " + binaryContentId + " does not exist"); + } + try { + return Files.newInputStream(filePath); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private Path resolvePath(UUID key) { + return root.resolve(key.toString()); + } + + @Override + public ResponseEntity download(BinaryContentDto metaData) { + InputStream inputStream = get(metaData.id()); + Resource resource = new InputStreamResource(inputStream); + + return ResponseEntity + .status(HttpStatus.OK) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + metaData.fileName() + "\"") + .header(HttpHeaders.CONTENT_TYPE, metaData.contentType()) + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(metaData.size())) + .body(resource); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java new file mode 100644 index 000000000..31b4dc0f3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java @@ -0,0 +1,151 @@ +package com.sprint.mission.discodeit.storage.s3; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.time.Duration; +import java.util.NoSuchElementException; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +@Slf4j +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "s3") +@Component +public class S3BinaryContentStorage implements BinaryContentStorage { + + private final String accessKey; + private final String secretKey; + private final String region; + private final String bucket; + + @Value("${discodeit.storage.s3.presigned-url-expiration:600}") // 기본값 10분 + private long presignedUrlExpirationSeconds; + + public S3BinaryContentStorage( + @Value("${discodeit.storage.s3.access-key}") String accessKey, + @Value("${discodeit.storage.s3.secret-key}") String secretKey, + @Value("${discodeit.storage.s3.region}") String region, + @Value("${discodeit.storage.s3.bucket}") String bucket + ) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.bucket = bucket; + } + + @Override + public UUID put(UUID binaryContentId, byte[] bytes) { + String key = binaryContentId.toString(); + try { + S3Client s3Client = getS3Client(); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + s3Client.putObject(request, RequestBody.fromBytes(bytes)); + log.info("S3에 파일 업로드 성공: {}", key); + + return binaryContentId; + } catch (S3Exception e) { + log.error("S3에 파일 업로드 실패: {}", e.getMessage()); + throw new RuntimeException("S3에 파일 업로드 실패: " + key, e); + } + } + + @Override + public InputStream get(UUID binaryContentId) { + String key = binaryContentId.toString(); + try { + S3Client s3Client = getS3Client(); + + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + byte[] bytes = s3Client.getObjectAsBytes(request).asByteArray(); + return new ByteArrayInputStream(bytes); + } catch (S3Exception e) { + log.error("S3에서 파일 다운로드 실패: {}", e.getMessage()); + throw new NoSuchElementException("File with key " + key + " does not exist"); + } + } + + private S3Client getS3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + } + + @Override + public ResponseEntity download(BinaryContentDto metaData) { + try { + String key = metaData.id().toString(); + String presignedUrl = generatePresignedUrl(key, metaData.contentType()); + + log.info("생성된 Presigned URL: {}", presignedUrl); + + return ResponseEntity + .status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, presignedUrl) + .build(); + } catch (Exception e) { + log.error("Presigned URL 생성 실패: {}", e.getMessage()); + throw new RuntimeException("Presigned URL 생성 실패", e); + } + } + + private String generatePresignedUrl(String key, String contentType) { + try (S3Presigner presigner = getS3Presigner()) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .responseContentType(contentType) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(presignedUrlExpirationSeconds)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + } + + private S3Presigner getS3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 000000000..7b1addb62 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,27 @@ +server: + port: 8080 + +spring: + datasource: + url: jdbc:postgresql://localhost:5432/discodeit + username: discodeit_user + password: discodeit1234 + jpa: + properties: + hibernate: + format_sql: true + +logging: + level: + com.sprint.mission.discodeit: debug + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + org.springframework.security: trace + +management: + endpoint: + health: + show-details: always + info: + env: + enabled: true \ No newline at end of file diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 000000000..3074885d3 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,25 @@ +server: + port: 80 + +spring: + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + jpa: + properties: + hibernate: + format_sql: false + +logging: + level: + com.sprint.mission.discodeit: info + org.hibernate.SQL: info + +management: + endpoint: + health: + show-details: never + info: + env: + enabled: false \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 000000000..1a5d18f65 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,67 @@ +spring: + application: + name: discodeit + servlet: + multipart: + maxFileSize: 10MB # 파일 하나의 최대 크기 + maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 + datasource: + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + profiles: + active: + - dev + config: + import: optional:file:.env[.properties] + +management: + endpoints: + web: + exposure: + include: health,info,metrics,loggers + endpoint: + health: + show-details: always + +info: + name: Discodeit + version: 1.7.0 + java: + version: 17 + spring-boot: + version: 3.4.0 + config: + datasource: + url: ${spring.datasource.url} + driver-class-name: ${spring.datasource.driver-class-name} + jpa: + ddl-auto: ${spring.jpa.hibernate.ddl-auto} + storage: + type: ${discodeit.storage.type} + path: ${discodeit.storage.local.root-path} + multipart: + max-file-size: ${spring.servlet.multipart.maxFileSize} + max-request-size: ${spring.servlet.multipart.maxRequestSize} + +discodeit: + storage: + type: ${STORAGE_TYPE:local} # local | s3 (기본값: local) + local: + root-path: ${STORAGE_LOCAL_ROOT_PATH:.discodeit/storage} + s3: + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} + presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION:600} # (기본값: 10분) + admin: + username: ${DISCODEIT_ADMIN_USERNAME} + email: ${DISCODEIT_ADMIN_EMAIL} + password: ${DISCODEIT_ADMIN_PASSWORD} + +logging: + level: + root: info \ No newline at end of file diff --git a/src/main/resources/fe_bundle_1.2.3.zip b/src/main/resources/fe_bundle_1.2.3.zip new file mode 100644 index 0000000000000000000000000000000000000000..852a0869c0058982bcd14d2879e20e95e78927c2 GIT binary patch literal 95493 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�)sgVbBj<-%VVVoNLDho;1n`FRW7waSo~Id$uF5>U`UOu$3TM7@K@s3pOt;Wy$xAq+zxl#4zys}?vAm48gZ!cxDDH)a*SH}b2A0$|2j z(XV;fsBWcp6NC{=td3-s#l)@LZ<_AX2ln|=wfU@aO-unOCZ~v72h0e2kOc{tgOEe9 zFFz3QD>|$KJD_dgLbL_rZ+Q$512%SGUdSZjrFjyiP$i*}99DE2KEc4nIV7~GgbJfg z3R+<`eWht2If3)r5z8Aip7J6$*!Y$$Ld&$y#%EGJ0cbK6i5IDhHE*^2f^iZj*(v-*t~sP!;~KgG~Q1rIR58|QY`-F0x=h#sEd{Ap}=*Vfhon#z~3D( zb^>x_;zv!-HHkYQlh8y5ivUh6bw=buq#O1;9O4s#6mWoGLjmZ?MTYlO?By5CyxPI9 zj=nY!u%{AFMT9uE-*#Nni61VTHYzC1urT6Y-y|^K*MS!;v4PH|{SkCo^GupVl1Ck| zioHt~7ZuE}xI-4lpV^8`62_lb*# z62g9}muh$y(A=It~sPGpv`tC|6;c7aiV&GyFqlxM-wzVOeid~yUv_y4zT4q~y*bL*K$H$+?6^kqw z%%x}<83ERz?S4`uex1obK4_XKin4)?-}1H|Kc~jKS*f!#>H3;L5;X^3Tn!2pzqe(- z?mMsgG@e(+NZWhG!uC|H^LgtAj)}tT*+UZAAiKvyhwFyNUKN_h$q6E#{D}U;(5z2L zh@l}^1(fDAR6Q5du7J+3TBdY28-U!U{~QHvPJ@N1Isj3mie~ZqxT_^TN<4fQ!}abf1B%3&@c*iPU6+RV%${jjN7s_ zc({UT?dHPB9XW%w@}!9^NS0p<3>xFf-Sz!(U2RohM8ALUHg`ZxHl%E=L35peseFH| zSB_v0{6Oe$QTg)FarG%J$o`z`RRlb7g7Vq#>jf--XSy5PQYoV*g>*8RrPeOPCEryN z#~#-lwoUjsap`AL*Yf25{BhPjolps_Gw-KUs?%qJb|mQmn0gM~ zmf4h<4$U5PHIT{b1pm5O*|M9h_&Ru{rys55lz_(d*;%@oIKt6ss%Ch)Zd7}3{8`Dq zwu5%Mp~kxTj@mv$m8+xnt{~Zlc6H{GT)quQAxUrV$*#BhBTk>SY7PHB-ALIRULUNdn%VG;*y^^P~%N$_1s@k+2Js1c*skJdq*W;tL5ldiweBPMmB~i zWjzleSZ6p8{xx7Y7tS%JgmaWTG>>jVo6vQVi0z$J^}gXnB*5-lgF+| zTbv-)Fsm(d;`$@3W}}9(e(|B7>lK^WOBrbF=auGjEhD0b1(iWoBuXjY!s%o;11oQY zmkWjJ2*91m!?B%ih9CZ$79Ihy^Ewq=C*F#Q+uT*R`|q+r-z1I=F40Si-its7=aY97 zK)&nm82|`{D}pUlcxX(%)mDe#7}jS;XoU*5GSbx1kHY7gD%3g|q3Nm8k#v>e*@{6} zFnWwZf-GiJ8JvFhz#2UPcN`I-kh#&RiA!B#Fcy2>&50Dk{F01%+e7TgiQ_2v2*%O8 zcUOTxdF#<6PLkeh?1y&Lw+bG79=uoYte)-CC3;T*@{(>~A0XfaOxK0!+NlqI0XVwO z#MuIeU2_FT9DTL2B48!RNWQo*50D-}RYBpQenGJN z--?G*o%*7GjdfL?NF4;~UsqcS2)Z$I3oY-_=kk>mgEY39ubccXnahOwYjqGPXZD7EYmj2 z3sCz2s73-b2*iR9P|cLq5!+Jp@UunjuMEjZMk|Gu4(|Kwcwzvo?;mP%f+9 zh|GK7!@6Km>6+0d2=GBJKP~y5@_02(eyt~1K-+0}dUi<4k-;|(w06~L<#B={B@ibg z+2b+&X`%x0>*&@BOV5%t<2I4NifaF1^fSz%=tsI)`*M53NhPOy^BPAXC)PDQ92}@6 zZjrOB|6;`b1-OH1hZ#X}Dp4)N&7?eNsSY!u%QcwL4AXwPfxvEK7+na$v^+!lzNM0W z3SOtWITxEuQ(j}KU>NF`K`f)}D^X6gHeiHqbdpH=UI0`+8l(!?8n zS7$e0#I?@&(|F`jk2U;KtjMC2el!?y4Y>1pEMVvK zj=%Na(0DtR!#LfiA8$f3KeDzBKJ@OD#Tv}%!IF@vyoIoR!5qK@D0Cv551+acW zjlMZdJ*P6(HShJVnmcd(j@6o%bX^Q_5bHtUNy%*?x{*q&05^s*o1DE@%dPA+J&jmk zU|KBLzK=nvbfbAg+*f$;qY5N78rp8F{Z0Fu>zxg&m9*5^P7^W8G!5X#6jGiO%D?W~ zDGkxw8OEBQw;_kPnBP|%jwtFd*dvKU@m%JZh#T{pB1AwdtZsG7bdfXINW%p1SUlt@ z+6UE)Y(Ctg?=OaH?rFeL2h(Z;2HfX^prr)=GLOkDk~#T@hQFwrGAXIwh8#~9aS3=h zMk^qfZ!acVde>}I%a#T3b6wf&;j@N*MP0O`a7#aQM!%351nO2vCdXRQmYMi2h-g6p5vw3tL(k=*RJ3|ShICgE6|eb8|tHdBWR#4EvzxWy&LtutXkJ6(AFQ* zOx7hd8(xyrRkjLDR@aBV+<&KD?wFsO17-EFPDIZ%v&?%K0!i00e%@B6AuJ()l0uc)wTj1#iPtstY4hpZhyMIrd84$o%L z=Y~ZPM#@bFBodf(;4|h5ySGp(%Bw&Yku*OI23Z)v!YGkw4*1ny%D{et}FWd7sT@f9$mp++U29k4}YG|GmV&PY8;{TZ1_1<`utxHl+*6|*F zOD*gwwad$o``siO@+(@y1)1FRlGX-AGi+7x92C06zXCQ{R?o zTbwTD%EDNZX8l8bNEJ4COIq4@e?PDj?wyI(9NUQ(3$;8S;8Ip7mVIcc*WCk6Vh-Bp z=tJB42#FUBF@%8cVq)ho2&b-E#^MGz%YvOO@)3OK!o)gs?EUhG7CrL|I)(|@0$E_5 z&S{+>i<9}pn`#OMMQRvS2oLucJ zq_=Mj*6?DE-}`FBduyc?`91%j7hvEIwv`|*=6dA)Xp(Z6ydvI6ARsdw&=bfSk^vkv z9w*MqA9`?k_FY*~hk+d_>(r*C%inUL9>R<(5zuei8DAZwVPWA82*MDY?N>v0rPj(P*MQ-5Bpp)052R z??c6qjvg7(iW&^W^dfUOj&NE(q0^y7Ryl&~Ix39Whe8&-RgiYjfOhG`mrh1vl=HZ`fGbeL+-;_uJwuBF zOpaM`(tvadauvJ2Soc_TQfkeurT~2uFr4tS27_kQ|vkfnn!hTT8tq6#?4E;k@ z|IGL)a!_={G${M5SXKfBH;r5rBH}nZm#x!_~?tmc_~eodm#ZGFBh7 z)Hj6aaRZ$V{Y{6U0rZhu^`H#@>bGG(Yae1xK*QG&Vd z{g?za83dTg%|W6_07frnv2is^NII>91OOGN$-IV_p!+4j7lv;ycZriV=slH_0#)bI za##wdo(e6(vTZzp9xO`jU<=Fht$V8@p08tAr&5A}1G-y+&)*0KkD8FvH$a~$w{{e$lTWmDiX(eobJ7I}dyH4TDamD-j% zF254GtsGn>mv&YDMSUPBM#T4idx_5=-+wr|c;3XKQ@j{86ZwY@S4k^JW)DyvP?0R} z?u{d$Jt21pV^`?zmkj5<{t=Mt@ynrL&8}3aFKfOZ*D-QraWvPOJA3!`3{MNhzZHWo zahMxtKOE}F8jj+`&7PP7nKRqWdy0BOSWuRD#`Q}Rft6q&+E0mkI-B0r){hf0%w&OV*2zyb?$w7B4x5 z8Eg(ZJ3b#IjIs3;R03eY?p)H7x_Sfws3G)om0U=H>xe2iDxA;inKMPpvM`*?HNBX} zjU4Y4lVWBufMDRw%c1M}U2j3rI2Ly)`|Bv z)k%vdYu^c1!M#nd_ObB$u#>B2jVQt>WAiXYEGaNTC_!d1GCHK?GC`ArSJ!auMCYdp zZ$3Yk@Db?#qD{*ky~STo*Rd_T(N$ znjE;n%5gN8Y|EUC<{&4sr~`MlpSlb5Bzs$HLHdgF`@pO`dy^fYNs_}dU~#^$(>y~c^)q-pF;D|vH@b=84z29s8ew!{`ELhvAu-M^Y z4WBKm#QyrkJPvav%z;Bjb=T4wA&cAL zGC;ROW(W@sU$hOG9qa-2ER8)?`Kq1ff}Fol zAuJQWp7!R9=?7+f3SuYxH*)*kopbxCGQ4t8u-Bg}H6QqJ0I#&g6+p2De0Wa|&b&mk z?hkY~;6lW}Ar9Nqa)~=$1cxNZO4WfJ(n-eq;Q--%L^)$~uGcS|ZBEMm2{?qqVH59c zPz+7ME{6x2s?zB58xPLDAw~1!1Xah?7v1(!4dIY)k+*z(U$@r0GEL!%ypCY`(rY{A z)g;$0vXLi&;oFF9ouM6joN88+c!Wz3BL(V2IGKbDx_AWCWsr=@bS{A75&Lu;5@dy3j z5a;)U=Pnu>ne{v@4Lo)}1oR(u@d5b!sdCT;>yAPn77d%iGwx;xp-$!z1O3iQ9c_QVIOD0g$I?+)no_dPP zDa+4FUQzw!NlLtH>?U$HTP+!G*DqP`s*XDw3M#&eFcdUyCwPYRiwpm-X6LGjyCuV! zH(W0OYOEsj6hzG!xt`U@qQY8t?0B3kyt2rg!b(t}#V>lQng?g0JsvfwchQo*_>`Kb zcik?lwZ}LWdbEm-G$)8ktX{{*c2DT!lnH<3KBiYY_&&A3_X+HMpEvVGC z)w9dg@;Ja>n9SpYIgBh|wPqBUdVZu3#r@I4Kaq@i%lfmDKL7^a6+wpY4X|CuJ)_~} z2zbD^QFUJ!V6B`|ae?BDn*HP=) z#OsFfu^~Y5VRBHNox1)3CXPweOqQB4ea(;k7ks0co05zM*-x-_4s`#oeh(f6i>|~n zkKYhs_MV|ow{9o9H8OK-(g;&NJog@v?j7a~^hGYlC_@>y0!;u&gCb;pbg-x%)Ho`Q zLm^`x!WxC7xjZ@zFu^>cEu@l@KjU9JFlkv5(}Ge+!RZ|EvJjA4YM}mM=pr$SHQT|Y zCgKEKYZfR?d6e~zK2_qkG)r2z^WGuqVhrIDdP)Rwe|6}!A_`+C@0EX0lnv+QeDc zX^!v?%!#eCG>|jIY*VjPiE9r${ITGXOFwD1SI+CkDX}gKIuX)HE-GedICJ`>J9uF2 zwrjvB5)l`az!U$h5Lr&Jud*J=i9unmRXVfScNogdhzl0b2MYPfki+bcSGJ?kYSR?D za8x&dg=5Wdj!J z!8#Qes0RQ=Oh-alkSC@T`1e!nIK`i-4rH>Yib{Okx)hdbip;an1 z7Dnw#!%X)$vg*#Yw7a)Q45x(A=*ZH|n={OV*5I0TqcswvY|%+UFhFRdhuBfI`fm*e z8P=o>1yrRe1X&lVGnRR|g)+B_gzR7?G!+11r~UzY=-NJiwc|%G? zv9*f9^(Bodz-H7u2T^jYe^Ve(z%y^|Jf0M$C{HQd^bDdEOe}mrPlly118Pr5-C6;l z9rSSi8?>e1Smj3lrCLEuUw{;9G#XqyE6M4BeD7G~Nr_P#a>g$A1ULdW2?b$E!0I#a zX8W2Q0?o5Z3|0~|&|aPx{xT7KZP8FyG*c@gTTT`9X%)uMGvj|%uG4KZ?jC>1q_5r^ z$#Q`9mOq0<$JwK|&6pi+KQCMEd>gR4m(k58n%o_aNq|&yb$ix17gMjUur8os*iJ@; z7Hc|8ws~)pCX)N$!aSkUSR=SXEIp@=Q-bkhfxq|>QZse4DF;dqA#x=|YZi19!y;GQ z8BRJ7WDhwHE)i3O15s}eNaWwCWr9PeJ_G=gGK1{dJ)mS2FheNj1C*|LA?yk|+~u3$ zk1MrcnqNUswX$w*oEuFE-lldc*{5Z%(zRgQif4pIfKbpg2;6=yiw!${Hai1eOG&@u z_9wa`sfGSsvxdxRoOqS|<{e&jAyDAJ`|2R-FFOF$6yQ_H=;=6_hu*XOvVYspds2gh zv8~~hN?HTTwkJp=BFNufq2wxGtgUxsXk52Fs+>0IF)Nb0J+BRA!DVz?o z?I|fs;up>if}a-?&vS#kf4qYx)1My8EivIlM1%Qd;twTEAIaOB8_y$C1igN!pCrxT z?$o(HDm#suaXB;C-M7G()b7++3Y;)F6fPo9!6G3YTX3)8V}&|{Qb>5l?ScdxHEBkK z^=sxN*J?LdMxk!hZ+lG91#;VodH<{^k!ervl{d=Ut8XpDlz+N2^i5|B;es=l#5?)qWD{{Rlp_5Lw$9R-mYlp52fs(^IcQEg* zE+6UEGD#V~D6sZiqGB#e3mvUF2?IEMa>gZRgkhDQyQyf@RNU>-@*l!sypK-fvp*oY z#81&QxZPpR3wwIQFpp178S4bgdMv;dJY<7c%R_C)C}{|-76D|0x-QR1d7&;(IM|p=FfG%q`2u&;TI@Gevnv$-jYjX-af~EO z`Ai~N+6(r*nh-cr@8^gnbpJ%R+*0-kLXDvx+||zmV#enWpnj43KCUESTPloZ;dHX% zrvSTq$e!gp=cZvxq2cL4luqV0P|`(KaX0S@!Jh`xsQ?EUu4ig%y+#;~>)^-AXqII( z%8tFw>JcZ9Xf8?mp#f0>Y(Hkmz{B%w_N@$%`_BT!prgOh@_>RuAkIsW)02F2?-u0M zHaSWU^FII^7Y{17nV-Q6>Ero-rIbm@B70$t9|w0hJY|lPMDt+-2vr)3*WbTo`P|{7 z@#&w;dJw7sR-Z7?m9I?pkIs3TKVqfe8t)b|=> zQNb9SJ!2dOY=7xU^rv)Qt5|w&q^L-2wfBm;kjGS*?TkY@fH^q&hFkXL0*%VO#sEd5 zxRqh0)T8F!cQD(pq2)GZYH@Y<(+k#>xPDh(#yC0E%C~1f7Z6JnGd{5}a-p1`g-o)S zcWviFvnd4EVXB$lYh%ANQ=SyWE5*kjvWtx_$tW(Zpjl>DOsi|*|4Ptq9itz1Zq3UN zU=3VlEsm2uDdajqZAymx0n`AKR+O~$>^Y7|6kz4-Vndz9zxu{YA-|v}2K~z)*LBcx zhVe;_0==#$QCxZ&&$~!-=f3K*WzN;A$5??sFyS<$PC`4UB(!GRiVsN{ZqQzD0}v=- zsHl}^Pk$D|dB<`79sCPDYqABOS6C+teAap__e+UnI+0t>9c4tIp+2HX2O><{yIVJ$Vr8wGmUA3)vYg|M`@-;QQ?qt!c+%oM!=tj^36)n z8URJAoY(PQB2a*kqKJ8jh|WZzsBPHENr4>rfLb&-ZhQo1_VHfQgyLEf+%Cg3Zj?yd zLghMjXlsF|X-}2QkDIcPqget(D0h<0Y(@ey-b=^W{io$k(uT+g_ah2?W~~sK`SSt# z((=$HtLZxiEKsKDv9Hn+u2XVQD9|8QfCMXjhGd%Mc9#6ssjPIResGRO&cw)-D9%$Y zhf{O5K0k|q7PfhvPwTl;&`yp$T70c$q5qRaMrTpD?=F>Y>{&LX@>cb!-AkLynLuQj~;4HFIfu9h?`cRofao*ycDnjxR_SZ;?)^ z%zj(>KuwoB9D5_6q7$&#)(GM)r&w5v3J~zbLa_`FW_-0tzccAySfi4ye$8$nj%wn!a^;ZL(=SY3A(+AW%K3`kCx0uiu#LSzt zu5Hwk6J=B-BX#9m&9^l@TIDQ*SmgnJakhx8r}iTn@Zk+m)q(d&W}H1$a8nkR|Fsx@48K>N&5TL`vmjA z5XtDeg6}9JH?QvU>TD|UgBwVam&wbWeM=J;-DNd9WMklwv!$XMO7vC_22nK?^_45o z%L}Mvzc-B63F6fGCbde`Z=6tvpEyeObg~VXgVEXW*$pF)h@B3K)?_+YRIQ0f`nR=N zQ?G3tMp*6HRx_PAn=XGl#_(`9C9bJ!3b|Na{u;E88j;CPw~X?)~yve_bqB)=iHEOTbgAUo&HQrtROK z+|^v~HkWtXS-d$%+Z69>o(^wVdQL09P&L-FT*%bu#EgS+POPWF)GBv*SE)Rqc`qG)xXvZ9*JtsGg&;q!P=Zhk$`I1RsHswFy8UDYc;PB^9JNk=(WKfF><*7q=pqyaYrC^VF@GCv8bCzqlq3A`0kzjhX}%@ z1bG*0@R$lb*mdUNO~#lgSost<_0j5^zNyywk}zL+8EWJ4s-Ywnwvk$WO}cuPD9;Hy zrUCLy!M1;%P5UR*42YwCIpqi#6vg+IN?}z-(JrjkPH>*e9#PT>cLW1Hyy&IjvNfNU zSMrg9_2rlR6)bwGJNn@J5_V|r#0y}!==6Ztocxl{$HHCzEM;QS%7vX<0y}}B$OBiJ^;g?a zeq*vMg|D|N=YO_79Z%K`reN+Wc7x@|Hq8P`nMwXuxPNeQnwFI|Zg?a^;CWvx$x4xy zT(wH$#J3Z*3ysRr1Mpmf;J8iL%e9nJ3o{XAv2Yt9AgiV&PmeqAbPV9_$k|=~!fF>H z2iiXQY;rrD*2)Htm=I*W3cFTGmD1OgA(2ZQT;H3Mwgsg5g@&h`4TT{ygM)BWcMdCf zz<5jDmxjk=4%q>Pp=yLZ)41*LzaX3xcE&}ystlG}q=lJp&|D7V@JYsA25sCqgjzbU z)pVhBj0~zB^3B9!Ul=4d&PeTO1^RAX>G9-IgrHS{4tTtbhS?lnDGl)oTN4@DooziS zM3kjX2<>YB#2nqKl42W)A-{}xr9h^e7pWGR_>vm~+(-8)oe8x` zg-=}a=9sO3l@i?(Z0C(JG77s)*p;F37#oPZQC(mdpBIJ5)k}yGq*;QBlt}pqq4v{x z;19x=MZ@8bze0F?ggPc>YU1&dD(K#2oT>VX!-*6vBj`>ni>Ee_K2y=)VVM4PEfCSl zxhq8_`)B|Ww9s((2kHD4Z$`Jsx8bJeK0w6XIeBb{yg0KH)oGVUuf&};OWA8`s6?l( zOKuLb<;6UUc)RwuOXZV;=23g`xFerFMcSdRn)YGEZy{^ zR7zTX+fzb9v5t$OP~eUm3j?~^?t_VGl|O@2G+jmzmfph4hXaR5=bi*sKVmYq0y}t? z+1FCg;=pWX@M;iFK(F-Yay}}(Etc-CS~`aI&Q<$(nAejyyB{0jT;6DgqmL$$NA*UD zO%4L+K?$jfq(6S2AHYlJD&2Y?*h2dm+aj9a(M;PEz<0STHdC|F_7ks@#zYMKyZ4FR z>uq6cPRY_t%TJ||sKkSSuXLLUeyOw9d!JgI_R48dCgB7xM6A)vj|wq*UBs?Z^oYl) zGE}G(u@NOcsB(Hp6P;J5Wy4?i(GU5Fu`j1?eS&pO_$&>WvsECuu!WJ}IkyqvWR!5$ zl92|i--#BSwK=Xq=~Z9WVkT>GI(J^(qQaoD6-d-Iwn#nwCCNW7po2+y)-(Jm?Rnnt zo;*Bv3GGDgce^#U4b|+JT~TwYA1G-ZNqs9qC(MYw*u&jFEaTg02VI%`PT z)B!aYXdHt}4{ivfFW)p)t|f#sNT*?a$mZQEu{X4?k~FXROD4n*T~AaxLPebO%jtegh&#jOh_$KeEtagcFQI^m*M2WWEsnlsCXx zOSFhO_beZ-x#qX$>JY#EE23=4Tdo^QiE=_1v_rOUcDgQbdG6+#k>Kx-78){QEpWHA zirsep8vfl;jSSU1jd|D9=W)^j4g`$okp+&1dCwt!J@F8Blbm%=W!P%>F8P zA9kShf^!x<8%81>{f&I}RJ>8^h36e#gxsYVGecC*b(YmYOmzoRy6*9AQq~T+#6Unz zzD@K4L7^(~9nLK46D7YFa|PEZUdUhEnI=EcS&al>;GPbulZ=&G;a?_5NEg;yntXWE zN{es82~nu$MBJD4V&VjQK-V#TSRe~Z(1kM0!@=OmILgyrS26!jQ!nA2BVGLF+wP|A zsjX&YD4S-dPKRv~mr6aei4Fkt{?u&V_6i}3F98unGp&?L5j;2h>V>YR;XFek*0Tm> zG7U)>1&2_CQOX)1VKOR`kD3a5_S(P{(0oXM;G&SXUVf?$MnXdRKfc*C;CLv-ysAoZ zMi~eO%KD>oiJpJcOKrq~-wZO{HmU}_R8!oKGT`d$iH);4S@bO{ak>KnVqG4DT461Q zL7&C2KB`Ty*|b%M3_r;PfG10xX6)kJb$IXHd=ZqYAuA8s7{hmBzT&`HnNop)6ZlTu zAih6Gp9&Gr8KDL*D+bAR?A9O&{-1tM&U}+2m}SVZe)d;^pbWT~jsm9M7-%YT5(}J~ zh#8|o5)0Khb)3eA${K5D3l~H2lI?7DA_7P0W4P11QHKBN#br0jD zspGA9kB4YDx$|-zz()%BQ?`c*?@e3fcKuC>;r$0^^=@^p@Dn>vN;i(|(#Ci64O+r+ z%PiPjj+u!RLE)H?=gS8g7UM!JF_naVo9i>iebATIn3Y1E`8i%RyE zjt3-fSotf<*iEwyKBdR4T-0xJ#`zS4Z8}Przt22D$PfJDv&ds)(6T{Y&9F)0JhuER zcMp&tQb9^|XkkG6{6R-PUn;^-QE^^BZKTzilph~P2CI{?BcsaTUm+57fVkxNx0WA% zIG3Z@I=s{}g8vObK)=5zAB2s_JT@)kq?dsCgdmdYGSaFLznfQbTrpnI}MF|AD9g4Fd$AI z=MJ!E46=)8JU#rGqEAJlR6mmNmWI0vhzF~Qol-kFe2I3+ze<}G@fdyKjVF!{r1r$2 zlke{EfnWLVFPpUvia=e z5c7S{e72vngPiSW>~ks#e9zde*qwhs8S!<1y&s_~^&k2R;{aUzhy0~HMK*fRZ?GK7 zjp8#J$qzIy5nr%cchUhq`6O&zgU>k=TEGM_{6K(Z04*4pSl)}_A&O*Vxao zTLRL}{Y-trbm;g46Mx_bkg$(eWcyjjapHW_zk`SRcTnx$0aS&`$8RIVg3lmceHx0% z|D3X0e97l4O1oz~BV_qN$nt6EvyGv_5qEMh-%mom4am1MG{~oIAn72!&7jx>cy@Y) z4=@MV^?{xZtmPNAwga_%vueS&4^Go48^CIa77fOc7|9NabhQv>M9@_y?El{55FyOJ*lSy8Q+9T#M@Wk(j z6kmwhNq_BTz~~{#!G-62OX2~*nciCSX6!AD=Xv10iTan)!}*KA`x>2ktg`~u zJpo%?6W;<1t52}S0enDvZ&Q4dXZ4u_+12M3WLICfU|nlFKwX2{fWd&xAHJU&_;zrN zTOIs>o@}k)2Xtd=6+fWN)*605fvqR_0Ug+SiXYJQ)-(KoX1AUrsGz;A7x>Y^3fuTa zRb$;NSa%02UxUg&mSX{r4))5Ps+AQ;-T?v$({c*a0^kfhK{-7p0Udafz9LQla(a@W z!sO$m1fC+gp8a;Pr4T*0vFgK$DnaheM>Xi0D?CNxKEJoLgj}%WyXtdptk%B|X*1ln z=mjvZ&bpt8 z@vJOx>L9V7)Mrf`2>!jdvVT|#-9|gzLjSZ3ADRG?HcI<~vHA1;Fyj**6|yX`2a9${ z#oXV+G0cI1V1QhK?DcQU-8XbSZmeuNjQhjC*M+~`2vFSvBFg(fb$?oWlfLf@$OgL@ z!moHX1NRbav&@LQ!lQ!(-Dm7tk@1N57yLzTrI;dD2jxViXyixy6wu6Zl-&^V|4zjJ zCv@Yph0EfkCZ~pQ`}?ZH;?QDXA{NZ{LabOY;n4hF1clGg{CjI_vfmc?gB|q;Rq`Gx zd1r6)d{jgW`5KX;_E4EmoH=Y*Nlo6e;t(|`>q1sUq&QU`*i{}ZsM5)>AYHe3NW3k4 z>b#L7qY!M~I>ZPgZfpA6G#cay^#g3G+ zRT%g>$fkW`*x97P=9rp6x2cu->-D9BjO|C5-dOr8zq>mC4)OU*{M+fg3J-uMKZruy zA%8}WDdgwH+9UEfR4u}>z6B`Mf9kQ4F!)1<=mt=@m(hCCGqjD}#551B_(65E#`{Oxf&Tk@8e)jZH&mOB ztjO045b$l71zE&8LDj=3sC%@*97Of{&q!JcRgbhPPW!U4Hs*e(O>~4yK)2_2cYqu9 zx}0;|(eQ)|&%I9bP@RSmOqjF=sTzCB!*AOVvH}2d3RkowIYR4vL9FmgQY-Q_Co`aO zipZd??IT3mm*Pv^x@xduq%hql0IP&Wo3P3GetRm=q}n~vBe$- zg=0Hwknu-kuH0@Syk#rG!xSI3Nt1PP`0g{KqaohT;nUjpykT-Mbq68u_wf3V3neCT z4iY46B#l+`pqNRAcR!`W*UwqyAU7T(0LFcM&C@yv{mVKC%`$1H^aZt2^(wNQgql?* z-EE4&Xx@f5O?qbU^YBM5?g!tK1u}H}eU6*Q!u{v&?#0W+8)E14bJ_X^flik^+D?$d-C&JsDn={zRU=P^E6 z%*CxT%2ERJPYQ){(sG@AhG>lI>Db_GY!>EU#)H;LL7&c(%=1qJiD9@Nee6_yZLgKZ z#PP4~n20?4*%4CHOOhnANOkrdsdAQv?QUvZVWvvSrwo&$RJi<>PjnZo1Q;#hkdDe@ zhML}bV4VdDTS;GO>UW7IJI=^lmtymTDeKz`;#5_a6u9N3MM|pRG(gww7})~xW#@iM z-Dhz!d`Mq~%6s)KmIbmay+;*IK7&@$84pCiO&d_zp0MgfojE5$`Bs==ym5L0L2<-7 zqy9VI8PmRtl7MqP!b^M+U2kC8m&#HnK+8rqQq*BJ=m(zEn#lF3o(zje*^onk+{${bnHV z@_(I+P%iql!+s;F(^D9>=q~siCkhxICk&j96IT2ZIWcw^6UhU1r2}~E#%bWnxnAmsV=v#iNzsG!P`9nJA(pN6DC0w4_F%2QcqHlu*6elu0Q)4gvZYqXvE@7g;%7%bJ zWpOmTfw~6udh{w4mWyPWACUv$5GZ8AB6X!sq15k|_4_;KN zmW13U`V&z=N)W^llL%L2fq^bSu{!@i=J$o(zvS--nHuOV7LsVr8lpv}>POyJdMO`` zD0>e1VWd6u^D!8R8Yr6pLqqg{=?48kJQ1+DAdNY7_)-s*ti^bkkdeM2oah_VBCW=@ z;Fhb~E;hm|3%YE=gJ=k_BAw`nujyna)JgTVDnZG8{X#}Il;Ub(>#YLzquMH0Z$4U0 zp`T4`v6=?0qOLf!P0Y$foTS`;5`%{(3mIN^D8=MMsWkFl${c*;93|v^#c(P=eqxc3 zV}?Xm8900L3-qAkE<0G0cXx-0VtEcx?H4F@icXKx@Gx0K1yZ6UsozV(TUtm#TA>OI zki?DzyqKX^C0J5bGFGKw^XuNPEOJ!Dx7M8e4*YZkdxd$KGE;`VBzY}9-ey|3aFr-d;xLJ0+0!?1sOC{9?nKUFhSoLP_-kbyaN;~WLfh4P_{3{ z2(QBw?}P#Io=|2*bLGPiBUvK6@IT~H6o@J&kNQri4U7Q(hTLqVBP>lfXQZCY#6cDsg$nBPbpSeWjvTlT!0%AQxi zM5rvwro{eU6Pm3e7xjA@0meadLaTM5~I>RSu(Z{4V?HM5hGI}}b zYZhdr1pjOlPWng5=$H-1VK%A|^fAUAwT~;Fj3J-U(CqkK=)rJ|Ta~vw9{V#0mVFFX z2sgw>ZoEyNA+_(ODnbk?M#-L496pLuch(K9Bt=H0bTn;16IZFy7;Zjp@kjvV#?k*>_ z>d~HsWwLV3VCZ^m<8EbQt{QV|pEq>>eS2?6MPA%1!Qd+w9n9R*FzW(}$!i@A5$hh4 zN>yy$^;qt)bvIXg^BuJ67Ff#WYzO{b^$Tj^HXsZR{>2t($NiD19Se4*iQJ*^w~#xO z+CKV!vV91;KQLRs*xmvJI2Ms^o96a4&3%yC8Zs+^U|W7Da%qp3osVgs-UHg`sa=^i zUbVT=F6IzmyF8w|^rNEjBlB$s?G?dD9OB&FDQ(&&G2C94SM{*jaM(zpr1Uq2iZ}~Y zwJoQOef5-Cqo}Wumm9OFVQST_!w!(42pl2h!qAzOhcu$&H`F)I8zc zei;t%X>R4rDLon-vzrj_(Zz8e+l7GB{VsVH9P8%T4SFD`*UN=U#0}q9erf@tB^$F{ zG^D!`cRJXo0gQ2=1)uIfK7RB^ePREGj*y$f4dvO8had8a%U0_Md!0Lv3>}?~j>)aP zKJb!}3LkytgJsz}IT5Od~gbW zP#a)0{}KZ9i5?uQ(t7CM`IttC&Wd0FXa$a|Kz!nYK=XcO)#02f;NxNLsX}2cQLr!g zYL)5})Hl~SHs!kiKJS}rUa2Fnh58WM;g&e*`#Q9|dDPZ|GTc@%>NdR$ALX+oEL*1_ zGc}X|jw37O{IjJ`Idl}EVwtbW*Nco5lZ+4yAB?CZHT<+!E?FAfb3T!=ty}2@LS4P% zyCdZc?*SHO$M-e=pwd67$$mjm+O$f49D>lK;*q_g-tmQFwiRDIvL{Sa1G;+Y%Bvjb z=Buk{QW2e#Z#ldP0f7{L)-+YR&x*7wy_}YJcViosp4aPR>om}^+m$V>`GWK)m#*D` zYb<;d4pfI4H=;*3R1DA?)9&dHyAhqasdVP1+8H(Y0n{bEO%RC2rW@4L*GXdxf@lR~ zkR&@u4L%W_dy3D_MnKP%^IVXVkv>}YSF==)c(xbW4=xEu&k_5DCdY}-w(!PU?jt)P zNFL}@L?|BYXp|Dgeum$3_CDlQ9UMp3<^a7#kV8L3g%{hpqBhsh@W5V<#*X&Ayv+94 z&+unHmxZ*lAP-qv&SiPXY_pnIn)}i{p|Hq+uCKUVf7nQPp>1x}gwnO-z2BhHFCe!>Aul z7nI@X&+wxYFU>@MhKEk{LW6IqF7`9r8vd(Zo<-_209PwXCpV!9~K{%CL-Vn}UD85DL}$5!ymmJjj4p~T zn(x%>$o8C|4$^yaer_28-Q6X&MHOxqK)~MvAN2m{XK`#eP~292-reo^G{9fx&gEC{ zS3oiDldQcR_d;Vw5_NGuE^T|*&)(uLX@f5q;4V<=_dV)WAnt|ClmQA|U;@Q$gRJvj zdi92hR-aFOF>u_C{GjnuUM_{eO#g)B_u^Xk2hb??Hmn?>T!xk7m#Kuc#~M{YFPI)| z`3IoBsMtL}xeVvdV5YjXhiDf5RO=wfvc^xJU4-xL7#OxdwI7YQY`W|T(#U=aeA`j? zDBB#qUAwyr=nz2xp!y5yxw80-3>>bihWw<@UpZTN$_`mk0oKXoNH z{l*sC0m%L!vBx0bHh1#?G~Qz-e2G@A;@uw1)vm8mKHRa?J9h3-?|^Lfb1FY=4UZkI zoIy`O`6Nn(`B*C%ypI|?KHgQli~M(CUnJbV3(K((Vt67$x}9*;N_G~# zhQ3pI4IL5wT^Nmt>v~6lcffk@0^Pj*5dnbS5khHYr2>_XP(s~7*|w_t*MOexxUa0D zhJ@f?#Ut%DG~~;2^ceUKQ$av}@#gGI`QhW$DVcNZh!BLfKr(tj#Rd9z2*NYR`|4eF zS?%A$;${~e&^0T@w z+O!iQ|9|Bm6+NK9FIglyz31Vg1Iez#jn1ozhw~?-_ZCt9P3B6;vq_C&kU?s|(eikg z^himc+^H>WQtF^8xS+;_w7Ce$=3-Gfc`eY*&>aveyC<%)JFbM}?k=P7f|9+A+MMf4 zz5b4j>4iA~x+zb>U+7iij~rs4y+IqFap8D#zsoXC`r+KLD$pzv{nH4(7{8ZWfkaCu zoX>ogaOg7W?n4CucJMFZmdtul#$+JA2{RSr9$A7UevpY@oQ7S|y~-gw_I1~E^_S{t z^(@|TUUTmdepw^fMYCnox7ir6z3}a~xev9IW~rm8PV@o;ilAW5(O!^Pr#Odc)%|v1 zxLKBxtm+EzWyr~eiD6tz!?ha!UyYKqujtJ)^zfxv)f64`k@q+1dqmzdWxm46r&wB< zNqCT!jO@JM_ad`Yu5BM&)b&AHGo{u0MPxxQ*f6c9$c1lD>y^8{eI}#86orN5cZ3ZP zPR1V}J!#WbXDkUFwVKC_@T}G0`ftqU9O6qbg6fS8RCf(j$3Vl&UgHJF_O{l|Zq@YR@kw$>(( zt#|y25UK|pqDGcTaXqQLnzOd@#6ATsV)Z^?K9WADM-{`7ax|w9@937&j-G;C7*9z1 zTx;mcH$%PTH@hRfe6A0(n#qQ=bW6Gr>?0;&qPa4 zLTox#MfzyXzo?L%po{fYv0aem;+#7rWSq);t=srn@{;ewAbJ;3e(98Pla!y9X&)a^ zH?O1{L&0euP$^GGSDen7{Z=vLnBEfa&h3r;SrA#XzeifZ1_1WKp1jtu$#H+=++r}i zR~V%FJuh1io-ADKH_6dEJ6^Ha=bJvc3@n9FAieVY_>rg2dfVtQ)vluS*=dHh400K# z&8FZc=sw|*Cn%HmsHeY{0P#|gW}uOopo$6dWv)p`VoVd3q$@+D0F%(D0U>g>|^RJX_G2x!10eEc}BTCAL+B z-ig?F=n zegjZj@@OvRp%>TeM|+*^ZFSY&-ICbu=n=p$WG!g0N`itz;Sl`zfSL z^lWk4ewAkUL$NhXYHI-;@Zxoes8Pc&fvq{^Iip5l{+ zn^*yg0-IPNy}7KBXijs)nMJawnCq?N2~xagv&Zc`*sG}mUgK3VHjJ0=5+^M6=?uIS zs;+k4q(w_fWsWkN9i=H6Y&l_0;Ojzc|sOLb%tmk|a;p=m00{hBm^Sr`j zq}(0yuno-0HSZdWyw759L*L@)#nXPQT-kk~@;1nyfLV>Dwl9TGeS<%9Mke1sE){qA z*?K4hGQxbf}ED=_0<1=HW} zt?%6g8|RWh|Ami(zE>1;bp_uqN`2U?(t7yc4SYQbc7^4zBcH+*847>7L~LDczl_w| zUuCoWzn^rY5NJFW8$ca4a|JwA`s!6UU}Y2j;HY1|G?m>Y(9m6qfQXj4;O#`7$V#&I z@8O8JPxG>^oI3&?Y+nSA4r@Da3y%zc4H%rqT^}F`fNeHMu&EM+kM(RVitp;eyTDNa z6alEs<{t1P1YKDedb%+Fuf_3%F=rAv2lDqdI^twstB;Dlb_|d&ipCRpz=eMR=M!a& zMXa{BvH4;1>*j{Xtk*)wOi_Rh-=QXlE+b9{w%QXrTD$4kP*{MSiAQnJ>{c#!d&l_F zcm25_Dc&>2IWr%bYqF?U7?%Lx+<1~aM2vmh-(Cw2t z(UNq1tRJ^5%*$uQ)T2m&l9AoL)jw^LCcmA6F@5!xQzz^tYEx0JR7Cc z;C(R@x1E(&xx-uY1UFWeaV`mekT7UDyTQlH0`uq{?23puo#zhb+>arWF#(=^(HI|a z9CSXij-b$a<$%{azI-KFP&%^EhzM7c@s$K1rz&boN zC+7?OyeuFH0m2eDsRPuxHj|DV2+9rd?vHpEo7Cb$ zbCfF|i6_U#fZTY_G33!eX$PLBgB$o>!h=1L$)GMRG`xwRItB}-@dQo7U6>N_ExNuW zf=n)Odu~x{LX1TTbNbQ_6@a8?67Nmi@u?;dohvn6q= zaVsd5d(dNUMDICTTCxtAfLO`X537oqT&eQ?DQu+ZQ9DNS&u-CvH_B#MPh6)8uolrY zdA`RM&SecTD0?q6$yc<%zv{i_t6(C(8YU83x)|U8SdHaIEVYClPUq@p%q$$ssflIr zJn!m6`qMZel@)o4p@On<^*ns1iWuz|z9K0W4V^9I<=37e-z|A9eC`(y;q{JaohidG zs)L|N*L0hrS|{Sz`wOOqQI8@6z%6}(I#3rRB_`!vrHk2x=Lf z%ykYU&)6#?TY0`FUOZmXP^@HlA=IYl&k>_o8V^Y2b?Gfe`%Kq7r1bYxHIuk;X~GV>QoT_GVtdZAS5H-}c&n5+|{7wF+2& zGKp@`9>XfJ-LLr0zhqN^lcJuB@X|0;-3!fTUD|Bc^q5`RY}SQX5K~(jJkd2Tap&Sq zsJ-ZuIMD-!DrvbqmR)$pmWukY^rbQtwW^sSycC(dB-=fTf-->$lE1=x8oRWpf@n!4 zgqvb0Fl4F#M50SoT96H^Y=l|ag()q|Trl2iT-f?N>_T%>7vY8M+r`T%f?9%h1v`HM zTfGxwhvn1H5No+DXU6QfT=pT>@}ugR5mUE3dkuz zW~Q_WLxfWR>16|f;Ko~wlW9h109`l?_d96wP4Ab;L@vK_eLaS{jO5EVm>N-P>c|pd z_)cG)y|b^#-Wi`Ntw)bU4#@o-fCTI49ddV5>WJTKycI(bt^4dPk+dY0vrAz)u(vSa zx8bL_AqFA3r%DQ@)d1?jdMqyM59VPFKkreEhvW-4#?2g$Cjww(v_rYwD4gT|pUknI zn7g~9<9jFkml_Xy_rQprVXMCqt5wMUcX4Y=EkLswOwEOBy^d((nY-1gxo`D##{j!w zyPK+RR2u8LHEZ-z2&qNOsm3xXUp`U-)&=5=z%?eR)B=2gb}tORP|8$9$*x=o!RdUouSElq zw>FsHx?p}g59UkQ%Fs&XLcM%DMwCEEW4&FsUJdEDRY6zlxE}WkCW_2i0 z=AGa&3h`JUOL#bC8TVa+EZbzSQdlE|=*&qlNAc(u=(DuqYVWzmFp&P7{A@_f=3 z^4UfBPRLQX4a&K!X6^XqA`XV}?%oB$0Dmv&5TN1Pi)HB?-5VyHCdAJ4cUR4NqE-#m zNGh#z;jAZryl~c&-z=QyY@m=l z*K3Q>yo5&8>7#kOp8wmV!9tt{|E_bQPm)b9s*JbYNl}kp_Vg03i~c~wl&)SaOR#Z!A)=#oSn@>Hh=VywfW*+Wt>)Hn|-!|4;S1gkF>HPD=Un0-a8 z4Ks)Y+77I=yZ>MaGcp7W2ttl@xGg-) zMOnDrM|BAHu}@zepY>x@@)82(nN&~cOW&H)s#^B-sNvs2EM$;*EtR8@IVbJMO5de> z$s7F`Z(vSjjCfem(pK2iPC=Bmt8MszN-=s%p<5_<2Z=YCdA*>k;hqzDhdRJ!TT0y| z0M0#$va$t5=x8~SYO$6L zBhee|NI}jt+^kZC#Q?ByPN;1-BfzF3sGH~n$LBJ{buee@0*vwn0DrnA#wo@5S6dX6 zf*~#B4OiQE4+nWUAdWN0@Rmjpw3*%QKx4k^^juldhkWp zL&?03(I?9@invoLTIOA3?ZCa%$L-=GI84|&UL@cO6A>l-uu>+Y(^0D)$o~U<*4esb zKZFyzTRC%pFWA#D+bQ!b z&)9vI=TfTG2y42}sPrG+o&w1gZ(3FFP-(8;M7eY)DDF;2(R^G!j50n5Zl#`uh_pbg zN)igq7(YA17r{k04bVSGB@-N_)kByqyT9~WLZi5U4ElSn|8mipnCf6~x}9`6FyJxh zL-!omCGlljExapi6Bl9TL$7{u8ak?PMCbGzD{Wo^C@8I+M*Yh`N?~frT8b7)Ri)4x zdUxJmO--Ezq7J6)_mJ0D)FMiiI?UVx5|`~^5y)%z%EBp7F;SKU)TK3&=p6|wwx*n! zWvQygWtj>O+V|CNFH6&cxutQ4;1i#B0ef3;SB?)55`Sq~@?hw>?1=Mq;i8Og%@TtX)*k z+#-c3Qiz>`V!MgGV$%m!Y*}5Qky?^zbxEdrNivtfDO!n!3Dxzj<L%r`PeFl&c4av?qXT@y-t7|J ztkN0*QMelnM8Yn_-cYe$4$A&A`1&jS<>2cW!qv_~?g5!o$`*dj!ErYjs}`%p!yBL2 z_ec*8`?G}}%oIj+2VtB23Sn4cDC00vmS8*RSq~BXCwyqsCx$Yu@1Q99|8n=H?QI)N zgXs796%ogyA)=PdMJs58ue`{1Y}xXb_(ajsfe4U-O#(6rSf)h2zx`HKZ`A-OJ2_|O z-se7vMWE4WbXRv**IvioZD}~v5w>T($eq%nO)_yd$)vhTCUu*{(?O_SzybX+nCKu= z#{ot&Nhz4d$OLo4a1Fsn)3o9(UU0(?@AfEoA*Fa<#F03*#zIi;ILQCh^y*o$-5&z(D8AFx0Xu_90PJWKdwpO zBG}!A)(gE`@kGM31l|G+Nf}7xrmwY&LfZJGV-XNb_8Fs#%9kxk_~6D zPt~?4?h>;fw8;mhkedY<2AKq7nxQY!LvpT~W^mIi-r(klb_rWG5qy>08{ z!px{XjU5B*Cvh{$lygs(DYuk|xxjD6%n(_}I{E291}tm0eT?E7R8r`8Nc@G9m=cYD zc>CNxoXH51jfM^|qHTd=ib1vC=V_pjwnh6y)`HUG#nPgb-23wYTFs$P%g@+b-D&P5 z8%?DP)I;gel4bOhaNg{OjIjWWNoIU-RJ2Yge8?PgQuRG4knE`(L(LM*Qf?b|NE}jT zT2}0a(<5oY_?EotT09_cqRo62=ibY$g;X`qWc&N2=t1zTosySMpJqU zW}&S^+3i}x2)lAC+|}PbK$}9xMi;oruW>kjJ&oe&nhlyrFIfxjnid^z(OY$|D zRFMhwxKswQI+~ia_BDC{7fz}jJziGeTE*dK6me&oGM!Kt)R)-`cj32V3NU9(h{ldF zY^xVPcsu64NN+LI!`AtAHvRk@Q$kSe=-=Y$4Bl3GspUY-Ye7pg;z`Ng^>G3l1-5cL z73bsCwy@$=irz#@A@^AQ-e?6Kd2!7s81OiQiiZ=7&8&y>kJz}^8xc>Z?U+K2P1e9_ zzCSm{v&}PFiua7hijdRe zF=ENUW-}}wHCoa%@t;pH6H^mIkO0)Y+fc?8fKD(Nc6J^FVMQ{ATGMbNXd~<&ukIS_ z&@1~fb&|s;aT~BY*9k1o$5w*)Sn?d|UQWLJ;E6Nkd8L1?+GFF@SVc~agE4ZVEnSC3 z$|~N=O~qRWdQvW7xixc3gO;~n>?LC!)t7F}1e}y71|G%!4gMVkBl>-6vo5#pI;J@800zgl@z=?HOXaRCXi4X~3GI0>{T? z>6(S*^(FHGMqgLM=JUq6g&4K=8xVpnDWEly*eFCn)bFSOeRP zi6jFggtF3X6~w-Wq=a;PTL?7_#4-}EKOQ%q4OjqwB$R+t!9+@eA}YK~!>ie4M(Ls# zi*T{HvDCv&3I8MAzus28RHrWiGv01K~&hO=iL#@+pGJRaF#fPIt^4bvsiLnkHmk5wcflA7gDNG6~(Pk9C; zxH<{l7m1*h&r!vQ>cBu37-p)^tN2C<$w=gTObeMyGbxl7@VNk7a)A-k*4KY?Lm@mf z!#9RxEbx`{4twju@lRskAw@OyfJIKoO>$tO!ckQr^A#K7+qV20YnhZQZLeq5CNFc! z_0blaJek7p?45f#%S3FW&M}`gbe?Q_)s4w_AQ4ADq$(5|^H|km2}}7!CMuiRkI~VM zHk4AD>UM(j30w3h6wsrBUSqcZ!_3aU2`zGqV#ip>jEb;!qj;82v$>C14zFf@#;LRa z>~gmC15MJYb#0&UwOvymvy)2QjYu`wt)6A2FJP=l3yOarDpW8)q~}77IVx2&BD8Ey z3ta(3J&sERW0@u=L=ESV znowqQ0*rDd@B#WY-`4aWdlX_aWNk?DL*oq{tAj6YhIRnte)piYyR$3yMeK;l9=p*L z0Vagk%hlct%GI_-r&Q-@O`YvhofiWuCa7T#OhB!G(l4WbHjnQoZIPCWrQ(`u^@8Q$ zo%c*}Ng@8Rf>ncbO{?)#sw_hIw58O5UJRVHIM79|SUw&*$NaC(F;D9&5AxFb$_;(w zt|8SayLNKEih!j!ZJ&Al?6h@;1}g5tIEBxi34Vn9@qeo;axT8#p?Fw=K9B*OcE1US z4;+g~o29|~5Sp^|Z%XuUDj1t9sW%G9V9H2I#A>vKF_tRHg?MC=35Ucd(l6^ew=`Iu#l?KyXtG#8K6n5^gk&^#7UD%`pmTaWqh#}MPm#5Lsjma2R; zsban|&3u))F3eA5?@EFek)j{6H^qdTsZBkf2F!knnGPsRDqKh}bCsa00OE2;R9wal z9nR}I47BdQ(_0#^?Qp?((6DSfp|wX+ffyPAUuercdXU#KFv7?RJWVY{rJC}*p9Enu zLeL=tQTy^FtDGW(_XQ zd=`8@DL^z%rl_4v$G3dQ$w~`ogNp+oRb+NBg)FFqg{K=T9aI*e6tZPZ;Nv(7T2(9`BG%{tlCKpt5%k+0IR0=k{x>;w~B_+CJt+bJC#tB9r}Gx+K0#E>OSNj zTfG5fvuw9A>Q<^UewQZu80@!Ylg9^d`Osq|(nMYJz`AM(xEO$aXIYO6>Q~AMO5X`= zDnOT#94ftVDH2u-VKxt&#}|M^a=IONClCp#Z+g z5t;#I!h~jb3%;;!S`mDuONA;cGltNC`T7uVB@wKqWSGR$lW9CmzG;}rP5lCO2T#$| zv6-z?q(vKi(Q_jwZlI33;Wsw&jmF!?21ZXFXB!)$^#H9E@{OSJ3`(6>pbH!LiVl-~ zIDz`6k!iX520SpUt1OvW-hCK6zxy!wb+8hhKOWbj^N)i%6#8aRLC8-B25tT^;Armk z*c_k(yK&i6siD3eH;YUn-@mY*tq9$K3@O!${x0^uyS-%xU~3E6U@dz_h`eSKe;Cwn z-J^k8oB8nWnS1sBy_tLYgERN?KQwbjcigX_eE)CH>dXH(XO$U}VnT49ZuSiBN_FD1hVGQmpq1o*N; zn8l{T&5671bt`2qBjWAIS`d!LK=)c=SSTc!~z&IzLP^WR`|_Zjdtv=HcP)3QF)!j9rw- zYhc}UAK%KPE&s88qBU4s{GaRZQ3eZL+3UOX?yAnFva?pg@GCO2nWs`BbwqADSlABc zri0XzY6mgor|ux}!glk_hTIMb_<0_x-NuX8N{%g{JqnDuJ-Nd4Ww|n_NGQI z-e)22&71em?(rs&PfZ0__I$0LAzoe=O zxhJ<4j`YLeN!|=mKoLwaT66zx>a)1#ncL({Uwmg(@SD2Uarn{+xa zYeS_LTX$DnESkB_R449u$Aw2-jix8Q-^X$@D=Ri7(pNoOxo!(R8;ZFjm#tJD8sD2I zHi*YbRMS1{*P`S^Y+qa{7jmntZ(uG!GJi=of(Q-=Y5SpkdR0mT9YHo#Sqwt7E&A90 zATIi+5vCIYqC6e_1U&~b{-=L!gwXlfEF8rf|N0+zM-LRurkLnh%q0Dp3;9|B2jeK7 zRlH4)#;%yH{Jv6Clh0tEBviVgT;8&_9**t!Vdu@Val93iEFjlOTIC4SyK{r3OC`*G zZE~6Y9*3Wg!z}!L15^qLdU`;w+6VjIXsJ zJy;jtCa^u;QX-7J2w+0hsFxyT3C-^4g-NADGj^uSSgkS4dF+jov)XFO%7s z=YLJN!YFFuor<&^$uoEGxrc?-F6p%Paj z;o#DBxxRHCQFmWY^!}B7?`0H8mn@`|tshx(VXWhVGhXHmVc$R!_%eh;8WpPrjUD-? z;EN+6lw4J=vhWGVekmfgkFrp8qP|O46cy&(J^p08cf~kc9~4#QOXaPmn0fpRl@ipr z1J{P@yJEz*CA?J%fj;vT+}Y{w8rBeqG}ISkjGVq(#ka4tJNv@ey0yFQ)?P>7uLqku zo&DC1&@mI*v_i|#!}{`Zdk=$b%e~qr*b*ZG>;`Jq+LY*1>$l;#3Tytd9M=3b#TZ9D zHVeZL=iH=B=3g+Me>FfouCmGV{BTMhOjN0q@aWP3qf9%BqIE+*0ac|ZU^u1a1g8!2 zz#zUZ4bVw|MKM{+@_s44LOC9ThG(a>)7A5{9{hJ9{*WUpP~nNuxzhFZOA6|pn>i6` z^rWa%qvu&%oI!C@d!-{z-8>_;q}W$Roh1E?_w(uAqS>pz`}v>CxdL75Q6l$YPu+to~w5$dq1!LZ$Ell0wAv+xv^30rd~ zEUtDQc-pY0hlp5na&K`nwqyWw1{I3qU^0x*9yft?r1^sxnZz9GRZ$uGJ?4$XLv5Fk zzuO(@TB<{eg?Ia(&|yJK0sZqXlPHSe%%;R=xz3s5m1UC@9O0!>s}1mAImxoI;*5=8 zQkw}riJOzDxPnb~l`n$S_^7Nk({-B{#mpc7i}(Vce3;?hp*V6VN?;l|2*L96`-oN!v=gbhiDjBt+P z8ht*bmGdF{KY2c6tIvnD{(QJDqZ=BY9_w1GmnBW>pe{4v=)vVokfdR3P2h>5g>Rtgl~Ri%%{$%H3Jszk5i#_RSpDxwmTF}U(P}|gN#uaBMY6>L zy{ArK4-_~oMRZ1_GOM3W)4H1 z*J7Bt#4vM(VZPjfVS3FQlWP9&qZsf1E`~W$D1c*_xxz4_f?Y%{cInCRKV?kBnt!np z!+fd7FkcYERGI_~bHXvq31OHQ0^{?;qU$$rz5_f7?jHeLwdP7ZQ=P*KqA|KK?Cag= zroOe+HC3Z4uuffTY|pZ{eqd?xSKBS5saGJ1I^bb=pb{&zzJBZ!(i|h0|3d%%{g2Q{ zegJpAsKcEv819^-@A3bSKq3*jB9))HWHIycD@C+Wq`s0(I&oAe00U)?11dVw3|X+6Elwt}F^7e6i&9b|W)sS2Z$srreI4Z`a79qa zc7=vAmk+6Mf(kWXQSTF3r)*d%)i7+ttr}+On`5YM<*{rVY9jPym?bx=Hc!G{C%D9Xh~n$GSuif9v$HFhl+TP22X!%alDaBBmgRND zf~iUE+G17@ zpw@%GZ$vWvn1@vAYq`k2udl37#1={IqTh_TcnB8&K|sF0{ZL1r3sKCV1cRZLvH*Z@ zvBcNXA)#OPqor7DW-v;o>P8D4E1YSe84nkgMV=o9FB2313T@09V=R2KhEWBf73eY9 zUTA_b#dde=tJ1u}LQJds`(lhD`rna)iDW*KqN?k+^MEn**gTjJmwGB6OZOuj%7o-9 z+NyZ184LrPUG%XDK^D;QzZSfP=-A zEsfkt&vhJ_Wh*i%y;hU`HNo<@ezrZq%c-_mZ-d*6gkvzfk`?b18uOI`65{fZF|MfLC} z{UdF1_v~QlMvOYHQ$IeYK2V#S8RDUG!*fZKbvRG^O(s{|*@M|W8aItELZ^coIW->C ze&9)8cZgWBMh_}?bcb@&Iw+>lC?>lT_-uK>Q1k?-ja!JQvT^xR3%|E-^=gHqpfV$o zw;D}HwP-q&*?gG}LIK=BP^xdqBa%lKr0AI{ii=7OjV3zmma5NCiS0=;HQS0tGb3`& z0zhbi&HR1fd2%XIou>Ru_&id;v;eR&#}ASU$&iy)J^BzEXMGbSGg{w_RQStnzX&=i zj-?EQxeF!xykvJ(yu;Ak|y=aes_O=x250N-P_yiwAD-9 z?#|Bkc2~W%yVq_(RfI&>0R{AGf3I}_g9I4u?r(Q@ws&?*F+Yo-wJd@H5#_9yQCPs4XzQwKux!(17m@rCQ$jt4|>h{DCBZ422XYl*Haxi?$Pv7Mm z8)w1OT$t+@?D`}XPm!%jBJl!$v#;@VIL@xcNh%GR3A1JlnOfEtmYNfm;gO-`OL;2X zDD+Np6q}<0v$U8PnNBo>f7ED^%8@cmocA)xf1Au60@l0^r_s#Mg=993_n^6j%2OH4 zxkX-qKA^ZT8KV{wevT2x7E3_HTdX;SCshj)_N>4(Nn|lLib>eFu)QEv7uayX+4s-i z*Y&a(sR9oMY=_*_l_7(r_K|$x3{(a@PZj_Lzvwu}jEy76I48?v#Xh=d#3qKSn<9+a z?$q;1z+Df?!ZEke0L)7VUfn}CoU5h@E$N{5k`vCA#MLH3xJ>m^8_?A-8J52@1-1cZ zMHH8VxtfJaR^BR@YqlP=u^*Bf%p97oqEn~d(16wHP{^a90sW@M)gYj!hBTHL8G5OK zJw|@BsahrSo@?xC;76>l_+dC1&$I}u%+y3OYteC-sagT&M)I> z!`CB{6ea#qXMgG7shdV?xKa`vDP``hL)&x4pBZ~8H-k!n;i^~&mYjztx6S2liX=BJ zlt(d7lyE?xEe5x$@XEPbxMPIgjB3wS5_oncZ(R<;?BEQTk0)db=%6#pc);`+iqq~| zw$@|2812HWZ4AG)MWaS8K`;D8QXyzTY0Gy ztGA*Hm)IG>k+RN30oSnn+7*A`D@w9x5r~DXKJ`B6R%#TunSNSvP|4B(mFJ1Kd@CKkqAeNOh@rFD`w;QyEuQI0`h+L{`jS`l4@w;Ti?%$t1FDkPfT2zKZz3ZrT#sf zrZTUmQ|lQaS%6gOd06DxQ8p-M2*E*h==~77j59^TqL4DBO7Y%_M8lp1YNlgFWe5zJ zJVr&XLGbuG=wTgz(M)54fuq;do|`Cem*I`gN*tpgy;cM2#bORk)|J;xft|LwSM$#L z`UEPX=>+^#=MYQPEaDRbE>u`3Frf*3Fqe3YhO%m=`xrO7Ki@h(zsx6N4X;Ts=}$^! zbL}ULGW0pc26N|T?s-0a%PgC|wvJh2xnEhC%K1wDciU<-Hs;XUe`L|suCb;W4s|(8 z-wmeOcnsAa#m&Y{-k>}qlxps|26D}!#8g{p1t4nm;PHz~6l0l}Ogm3J*I)wh_1il9 zjAvO^s%jONgUrM#SUZe8g@j|nk}GVi6g0kcP)Wut1%)&XHV0;2la;)wBq%v!y$mU1 zr~}`nQ8`{706fF1SsXR|wA_QN%nqh?J;=(;pLP`L=g9R^Cu+JRubqDRuJVCtaKFU0 zaom_B!IUXt->?z_^GRgV2qE|M(2VAcF^H*705w|_jF=PIVwK)XJ{r1Zg_R)`Oe;rQ z9zXwYeVTo7)rX945q}S(#c^pehI3|tEhcvfgf#syQC7>AZ85T6^}FM%1Z+C-68-Ke zsjZeZ`%ZSIZB22)9AoedLzA}k1J3VElcp!iH4V|~nvY;!B}`wG$}C2|1Jb}`H&%oB zNLxi6!|_C zboBN*oE-xnkX(&pe{C(?ny8!QU9DLzBdq_lv_>5L8%e!3mZ$o%h6qls3Vs{g#*j<;}jQsI>L<@&|!P z`;UDS+k($lNHrpcHK`WeL8<}F>-o=rU|={=&^)10T>U)CTEnnezii|VyCS~NuVo;^ z6)a-ru!vbul$^CP775*<@RXH%VG<6);1BwR51cAGzCKn-28Hx~%kwM${{8Fg>#ghV zRyG~oZyy{S-2a9Iyi6Ssh3L!k1GW1pu|9)oa+L#n5To%sMdMfL44Z(azBUjyCz^{0 z8V_XsUmpBpV56vsfO}~0a!EQyY@OMo3I}@Me8MW3XyGDM> z#F(&FtH^^B6#(Wg^d39towh~xH&J}`<-ddGW3N|gr3Tg4^z+Bieaz8)wUt_wU-L18 z_DQN!7`c*zg=1$Y>&;spoZ8^=-RDBa&V^!?Eb5N0I|qj9^rt&JZOsERd6*RF-BQp= z5=t=y$|cJ9GBnY%C#T^Vsge~V+MnQ;=V-+Em*oM5S-z5Os79)cKHJHZZqp82dYd&G zfX`n|Wk=Xc=8U0dlKT&Ti2Dz}!~KWf7deGq-wZIkQySo#dcRTZwmBMOu0D5SI(N`K zbgjMm+==Pjsb8&`REVq@{&N=RhdDs_qR63V^pFnEtGKD^dbI~w%#a{k&7^dfF+4&g zH~WH;2e%G(-_Nuc|8KlU)!J@7cH*v*xZWvKEx{Isog-X0psNS91#d>9HPi3p*OoYo z{7`kV?jpJ;ESF`OORn99ONVv6HYkOZV#7cTQ_#TTaP%+c^Z&i8*JwX#Xoj)Nbt91=Y0%>#Q>{{OpiHW4bQFR_oBAMkXlM`_* za{oA1abMx0>-lI0NH)@p1*v5}m~_X}8h$JJr8lWO9YPWQA@5arN zSwKzgy&o3RNmaSpVs$IcwFY3XOAUL)Atwr$9h@-YH&V0@vL+pl216esG+S=03ESRX zre{$=#zDe0I&o|e^w$7$L{3x)hl6yPOUJp-b&^1Fbt>vq=3zkIHQJtfJy1gDT>^$R zY%-1vRnHj}9Y*^hXoJF12TH+rbgde*_OKe^By??r^cM^kR>ogW*SO(^>1CuWiF998 zrMm9AE-01$N2P)|qVha@HKgbag6fS5(bW79Hzp#|;Ig>8DwL@8hQT%V_F&^TNtSBFipcjSiKuwM}ubmU(oF zz)pxfmj0Dwx#i~4QfQQP)h0-tO(1*G^0f7;eu>$z+Gs-POKR6U+#SYM#37H4&d)gZ z%{rS|0tj}`y0-l_0QDSG-9Cvfr2eXGix^SLLTRBZY~(6aKk`jYZO0oKQq`GD%%La? z9K+yT!_8;{>&=AU%j|QGlTXU+ca>#nKgG4>r(|#iov$1_RgzFukv+gqGXp`;k|qj^T}Afh zm4JhlBq^9f9%)6rl zs=p&AwClUr%|EZod<>5HRB0)WyoMnQ9k!b#v=lj6~2{ih3Vijo07l2)UoQK*TW%tg^_0+iaDrCrtjoa z%D-qCT)^7;Ql-O7na##e@lwWEKvSU2wwU9e6S%v9e@8&p9V?>|k|+0i)&lcO?+6#i zVxhd2j?8D^zDq9c(MI73tQ9U6*@{|os0CcZ5ogG|D74=Qjx1}h1lXQvet}}9T`-jF zac~6v{nFFj1n?fA&4%)fpqQ-~umYxHV*@RL3UZzX>H|wdEWHWji804g`VjY7(mO%Q zD~^I=c=2cpLr110B3Q!LmuY||M4Ro!Votw`NRvx$LThZk)cJ;JH>3zcRiQ-{T22KT z(0Cl&(hSEDYAi62v|dSXart1%?+eOc@HEJKPYqCT*9K38HE3^^4!3qoF+bMC-(%kF zPf^dI%}iLkx6rD|o{a%^oQ#u(vBCf#HMZ#`YmWAg?^vTl3oi_^#}9lAUX-Zq=P@gi zhsI||l64(?UKQA+=>&(OU{Q$K`X5=8sE;0e!s~r5=QF5{ZF9Y3-S3k9`=?+fgWUVX zS9-huiR(*)M#FcWAFQu`+KLp$QOYVT;@Y-Kg_2B72_x01rMc{qq_!kT>|&x1iH)BT zHqMO`w0P!8vk6NVTKe6t9bFrjkaT)^Y6|t4{319R3c~H;1&bP;m2HO@jW2n3%?9_g zt#b13%vm~sIZ<#45bi}8;)Et8Ls^$OY_wZmp>tLD%$156i#S(a*^B6menRVJgWcpaUi@ttS< z@g+x4=GtSIEz$maJ1Sha#TV>L)I|JKjzULaZsLsH49D!|$%NfJnw(LmT-k&T5MsJn z7H7nD@{vl0Ct2u(%h2O2f3Pe>vCw`$WC<5z>o1`%yY_P1zV)2miXAfGM?Dqa+cMW@ zi+L-O#Oy~r7JIRB)!0G)Kwj?J%l5wRgfe?8o71t;dD?#i9Vb`1Yc9#j-?V3A09&B5 zwfti2VlVobj&=`F&ZI|*yZZW$K4QP0GP+_bp;U45$RV`ir!{5GQWH(g7i?Rh49U(r zDFt35nF;rfi9IPNQoh|;MrRWpUpm9pVv%dj-I|7JGv~@Y>sOOWpPHtDCes(;lpAol zQ*0=W+UG2b{ojOk9ZX#){O|}qerE)=&xz=MrZ(#R0EA2q#_yCy( z%wyaKA97=e{{iRdQ8t+*`B8EaPd}uSax6~b=T_8f-e4*)ol;w?Yn|iZO}toKPAP@G z)`7}-d4?9nukxNAvKgazhT`W}m_6zl(@(t0(H`kpEC zsff9Oxh{D7{$KCP0OJ6#?M6M|gx>l(R>2WpOmUO>aEpzu>czHipoTC?SJ9KSdRlb= z<*k)T?rlSQBXWBaORM`;ss^N?EU98iEDv+)Z~{w_Q=~l5BvnYl#Sy7HOqPxi@p8Dn z?GaUlp%Wrc)KgVvWW6$%Ap5P>8>%>}Ip?xc1kIWiPfI@2>9xxGZxx|2ExVf5wreC9 z6QY@MSd$e4=(PR%@(y4kox|fzjA`QD#2$JH*2+Nbv_sv@tF)=9GE>e@s@D$HRid$T zM$(r_iC&dB)Mq{Uk2usweErkWgjBKF^hpS?Fo@Qr;sSN{Edcph!QBM&)6N7(^ zgw{cRu<<5r1xl>W%}{_jbnDdZWcQ8jzwpL#0yPyi1tWj9(n9eO-63ik=7yUB}U|p9rq(U z&o7!2=iTYmW@`KOFI4c8$M6yU=LAxS0WVGrbr!}wkot^WqsNb*iuYYl#$H79WJD6c)b3zf$vV2S9#Tw{r7BT&$~A)icfgg+bYv)+ws zc3Frc&%e3T?DC4&vUZgObSVA}es07OZXbMb$Jk<7bGSGkFTEA{F zb3zQ=&5OyAplV@Q^L|({F!bvx8T;?kGr!Tm zZ+go@df@!v4%}UV3uxhe*Xpi=swQU}RP*)V8ZE3m`-gkht?e1#-sv6wU$iwY(72=& zjCb|N9TL>}a47_b+bJ73RJ$*ts+3%1i+a+fV5eF#dq!&bAQWriVo}sMWU{i__JCA~ z3sOdV3PeQTlxQme=1s5?c1E-8P+%vL0=p4<#Bb1{w}P`tHGE4B<@G%jd@b1_qi^uq z4Jomu>GQ5*+P#h2h%&BO5zoq=#Y1rG1+P$G}DD_GnLFbg>tT_%0itzEH$>SwCF=siwQCF;H)5 zFYT1aQ&}6w^=5guH^b(UXQO>AzZlbrFPa8A7EgmOfYq_)@+P=3^oyh(Z{!j<3SzCC z(tIQ_e8#JK6)9l{-KHe z9cXc>MKT(8yDsbyY^>)D+n)3r4Zk#uyS}79B)TlB z44ETWx`?XEqna2pTw|$%!5!6#SdYpgR#speK*$_nIt)dy<3|;PPm5mx$36@Gqi3rW{pbDAq_N7hW&#)^7EY&z z7t(ZA=5TXb?{0BjW(a4L3Mm>{=+a=b+to30NLJn>!5^kM=sD-PI$EXHJzL4zO;jr+ z03|B*ZjD{rYV79aB|Z0+vF+wEU&kPrHtRAJLct!-fM(qH@gRDP9bv(Shg?1>VeQ)8 z^Rr2@_yW>5eID)=t!!Td3nVcbVz^JxtojquQ%a7VyasDDR=W#pwRF ztjm{M?kU&YW5ZUU!W43_56jhlNWqVSVc?;6L^<@FR`I$dg=!!0$r{jJD!3LQs;rtz zs;P{!bqeQN?S7ftw7UM%6R(%mq+Ef`UKIiUfN_Ag)bRp1KWmdfA7ciO(DgZ2HUv>s z@$J~aDP>|QfT5WUdERi0X6(Niu${T6sj#FDMkqLR2r0P}OVehBfMx>GoJl9|TM;us z88ZGoudliF_(E!)Y-E~l-~e>}ns3df14>j3zX9Eq89;~c;b7_bF5g4zv+nel8|I{J z4Rf-_AKwHgr5M0S!8lcpfOxcKkJ8GDpbCuK5t2dfA*>BL^l4kRF4V@T-#7TcO{!;( zWJpiZZc`m9$7M5+VT>JZ#P;mFwK%lY3(U2# zG{}p+%-4jj9G1GgV-VPPUX17~rNW$%EqBcD#GPT2_~dp{GsBab8739%BsdQ9%dJ72 zjK!ylIoexJgD7pb zJaJ4#GB%cYjnSnJ6ZrGT82((U;zUPDFQ9NV4chn7SWuR|B@5tO1|Sh%Rp|+pUaPxv zY;lrd2pU!IeS)T*&!OC^o^x0T+A`!aUf=DbJjH*XtdT9_`vfjY#KO`{rH(O)lAFN0 zfIcqBMXG-huWw$&{-+7cWMW7b_`dX7gNyee=DmELQ1FLu(hj_Za-Be0)^5dLWf%;^ zxGJ-Fd)Ge-n^JC!mN%Z?W*P&QA|95=PV2!WQ_?#J6b23M?|5GC!<0gMXP#)THE|@K z^z!-;d&>Ln1IjSfGq^fT78tRN9Pof-d55~T3+&A-mwx}LQ~+Np4?Q<)3QQ(~bYrL0 z5=jEJQWRm?LCkVj?J-apH3$)Wy0m%Hvfv?F&dCk})cuyHEg@Q-m3h6z@F2o%M~l65 z*~v7?rb#{zY~tQL$ksQrnmS}yb}Ml#{{rsYwx?}vTK&(H=EI?rLBR7r4=QEi+r+8} zF$y)VLr;Q5x-bY6V#ZjrZ%Nm&F72vYdG^ zO5wA!^)76;44RFkJb_Hk& z+W$Fi8ij@UT7N7f`g*-6`w7p7-zS=Ivr6=P2(5{y3<5B~z&G?u`IHzl!Vs}+0oc_T zRR%4;`GF(bJUEW!Ng@sG)tS;E<<;?wq}S2}x=BHMf@wj~WIerx<;0}HRVF))xm8lR zvuSgZ&{?FK9NG2MQ{yX2!g1MQ#=EW&z>)vFm;h@?Hi-(%LU%A!9SlteL*2p9cJPOL zXw;*_Bu!?QarApO{S0Ha1=4vk!zJnvGJe^=1~H{1E&XHsRDvE9B{l|zuDgL5*t)G& zr@P(R_LQA{9>fJMg-voHYquV#paisHThfDHwx?j|W=#>zVmceSa%YCUI8e&%!hs$3Zx59;c28f*g!tR+lSAxLq0{dhvC(TQoiv2^!^DbabJ= z9A?AVZ1-9ZvYr-vblaOJ7?i3P2JHtAFrQeCI>zXif|h86qP5_^L(;jzWM#C6HZcoX zv)yU^lJ()QzrBxF@Yip*=n{YY_JOPbKYlmv!X^Cmx4Tq|KTu=0_4BhyGrNyh9<~cJ zfw7=}6iV55ptMu7K@Pa-}U|PR+~A2C>u|@vf+e zO@*9TCQ3Bld!_Q+BeLnx0){~)WJ76=*Q}6s$+jy{0whwBYrkE%iv1*~*s*AIIG#5V zHtfg;d=OjdT5fa(#<2oZBU$-ONlL)nW2MPjHicDZIG`W2gdu?)bD`*=b(U24Dk;#~ zdEN{$tc13RU27}jKy$m)tf2N?b!|a#7*>_(S=J2jT54mVSedpIpXIe8@w_Y&&$&ok zb*a_MEeDX7W!{Ei;Pxj={VL1&#)8r3ur>b}3;a1uL>cESh#FvM391nVE00(TX+l^5 z5JKM5Qz{Vx*|Y&d07#ssXGkmHM>0lCJS5f>#=eq%Ksuo(7m>oMDrFhVR>n^VYu%;R zy0pEJiVrw|5SSe;V5g(0ryRSGKm_WH_8pL8IwJn+(=)H0o++K4btjG4z0Sc^cdC|; zvRp9iBT8enpiEqRA<3FHEp`2EWM83ez(e!fOLR8Z^n5)D&*teRF-g~UV_xgr1jG#} z3YX25`M&JV=bW)luqeKnJ?K1?^``CgH?#~by@7+sg-c~K-lG5#9GKO~ z)Od4$j+>Jy*9NG)P?{RWP`Go^GW4u-v}mNYNzqM8#||v}>j?!kG&G(k?V?}RDS@== z$NEr9(?RA#jRvdamP<%UMMpqs@eD0n8y2G&>RT3zKsFZ-E-Swp} zaVhIZO<~J_H*RJn7vhrAT1)wGrnsI9Ex!THpx7a%gfnIWrM?d+<15U;=ExrY7zc+* zKoSTzq_pVay%nBGY6g(#gZJj#$l=_ez56~r&CkdTH$jQ`I1azY5%b4#%B>-3q3nm@ zz)7`h3&jp{graCsOjgG72gx9L5iQMILAHFaj1l$fXDThlcquDoTt0de5l=C(JjHb) zMhVw$os{#oW$uE*A$+0Z*GaCTyCos6?WC6@6wKfRy+T0W#KGejz93;eBPY%=3MYOY zJ8sjWHe-kc&@pa#n8aL5nKYs6Gh0@ZH)I049=KG=u3P|rrKucBvnZ^+CyUyr;!)Z% zGRCb+8c%|`xCtf#W!TCSW373^q#dZ?Zb?0PyRCkAl*;xPUb3R|<37B|jsA`u^YojN z48CN#0ncS*jbr)i`K8tfrsYNmoJ(om77d#b9e-cULj2<7PQ8LhBElr?6eHUNXd0qv z?TMf$`dMORqiHrFc=J87SbQpPu1}6E{t=STr6`GC?Z#jGDh=a5dFXoaBZGbx3~cHLj!XKsDPx^27WCUyza)!=t8EX3?gnPf*Xg>>$hw2lmgZr?3Kk2X zMn90imU&PmHPm273DmwPITEj9 z1~B}()Ly-^K4}+l`0(-mUabUx8eKxiD|IiJ+sKz8cgz*BxWa^(C0AD0VO1DD_&03G ze}(jC>8xYD=RPVub9R?htTE%&H~-@gRUZY|@U6 zPoZ4H-_WznVFjc{as@l_50hWqauaUd0xOzmnC>L9w}JfniTE7Zal;bP`d`V#BFDhF zi=OsdaM%lOf>6l}`6Jv3kytjlT}eg0hdudWwTYy=QiY9E7K{$Nb9hMtTw#<+Fr9n{ zV}#4p6_#{byW51l^xCC@#o{DcESjk6Yl%0*=5Z#N@HD3+ZK2I%_611A{9Y?ngV;&R8ll?YYu)O z7zUqxvWFjh7Ev;aXF1%;izRK1w~1PgQr8Qczc32lSf2ss36h^oT0;|G9qf9$FVDyR zB-_~Vn_3qN*qAO+2v8D_U`vQh?S@sy`17!No*<>`E|c{8GK$?_V$|NoI5DbCiJMeF zS{Y{cI(1dpbvUBi_H-_qOKCML*WOdRnaiddVepikRErrzgSWbT0GtJq6;w^yQo2sTNM$+-Wc}%$ob+2BVE=qaf2+Z4%@etKS`{HF9{ix@uJoDm1+JCs1e!Y^Hg7zE7QrV~L!M(ZZf!RYOwH5a*ceoNSxQri7d&8S zuQrH}8Nf&L=mk%$tj>2}KRz1M4s#_J!N=gaKJ`o^OXx*NqUNxQGuPLRZJaT`4q*V1 z4(?Tk7dC_Tss_z&?|U^&N9}|i0NbN!YB_J8nOP_91vBu^rZd(`<+Gv9pIXvL(72$ZZ?fKEPwb;lZq)CU?T*a{@4#7|Y~e z$;4+Ow26-Rl?27;ycfAkPsJsBhR&}Re9sK{p6TvSEcl)=@EK3oB@q@tpAc3nM?*Nt zFg~h@bb#S5>cb(_RK$1)v5AL(aXmHplW~!2`OM6XMuAzX{BCTjbL5RvDLn{fDWw)3 zrTQ9(L(+CyK8!3@+D4+DbMp}uNiTJ>-giCvAfChwY2?nN=cexM@8p(iyvhwRtE;qa zsHQp_H5CQ%$2jC#yS5+Xi!)@4V+lZtEIE)JC!hfU)paZZm^%Oj&0N9q!k6 zTCHD5F&r0trD*a07?Wj^S`~~USHjR-(LL7TEf0<|ag;rH095~4^N4HhQF@n|NVT4y z!@MhjeojW%!N=U{NBPpE00q>ElrPOU6YlZhitmtuViTlvbJID($rIH7lr{Bq!Ws5f zoga9+>+9KK{>tSKa|teRHSD7$dA-xl-CFH7Kxx#r(PvNfW}-H1VEU~@;$t_v5(r_c5APrvnWv2!`NDT zx8Z2m+1rIf293tY3B??W;%}Zc_0}oC-JFjac~FAACtAr~P8umGyac!E@0LsJ4Y43a z57g&lX$+7HK+K}h`B=l?6)I}f2yHNC?MGK|`L>;Yle|R6Ja(~9v32jBtYA$M6*_E- zx?}_;SuPb#(dYvm`+#@LC?$s5%lQKvdVk8glIV&Qq z2z>M4dU$6Ita5pGm2IdpVO6jnDw4a$YL`6Ex~*NP+h}+uKyj`k_2{jrlV}!I;FO(Q zvVP&y_<^~-C*w_Jas#+=qyKK3*TYJiT_2_qg_gr6OAb+H&Gu1fQcnL`ldPctdRKj3 zyd20yah1@Tl*%uaYrXuUv}KIUyP@m;PPF{3dgy0@KlEXz4e-eJfo8^Eq5Ala&g5DA zFe}n1oF?%MYOH)FENO@J*xl0I^II1HHo2k0$`%ie;1bm+w*}HLR$bFYu>)?dcX}RD z<5s8?Ksr)cQF(d$2VN$!KM#iK>ck#A=&l%|>i7_|?CGp2aA3betWVb&p#sg>^-|39 zEJJN;(bHykfj)>%Mgn6kIi;xYG?qO0BX=SiVUKe?4sU7jO=%aI(Bn4)zC730=VNJC zvuYoNfw3el9kmj>M%s-LP6bhnX?ye~`|4Y_E_x6;Nx!=tPkbFJbSFm;F)(R2B(1nE zb(qLwy^5qT78g=VycM_az%`U@f9QCx@ zowhB}@dEmDX+bjgo9gOD8lv2&a!;6-g86c3RI6w{!IR@S5wdh0PdphL7z$$OC+4Bz zF6V*#uvHJ4fw#OUFky_e4=W)Vol~g%#=c4}WWE`#IFq+gTpHQIOahkqL|A|7=ENOL zBu5chiI(iPBS)jY8`{7 z=la1NsZ^{d+Z!d}P%j^bB?-t2rjyJslNk*QO7QY?JV%pmyfoQ&@J1evbg5Az)iJ!4 zYzTozR~(vh*jP#|F|xq2ogsVV1a{74nG-6SN?gj3ia2ZO)L}8AWjQ4+RAb}HY$_Ql zy|((W6H41cK-tyeymMh|AX+i+u_MgYyBxpKZ~K(vPp9OR+NRjB^+XhX&GkF&wzM+{f@@p0 zt?@|5_TWjnB1oZ@KWM3CznKOI8H$aHM&hvQRNyHf_nfs8^E-H!%R&{sfaRp-sXW%!nsew#&(m0j1Ow}|Y#L|id ziAzM4LNQc@ht7tGOY!&J@ar@pzQy$X&}pmEtL1g+)p9qzny|UIOqgeps_h=jUkbxG zl=1dO%0ieXdM^kJ-={2IJCeIK0iWE}Ov{*Zpto(XYF1O_jRiVlM{9SqR8&bw9vUVo zZKjN?A=^EPeiPu1gMCpff_*OkoX3_?fkoWzAZY-#Iw<1iOLvZIPfm6yl*HtZ(#u`> za{1LiYF-U<_tk^?SHrye>O1q*mr2tMQcG-I&>3l@^i`yM@0|le;2wwPB@0uiHWlCHYjHXE}D|NeH1x1IRtwV=G3>4WM!s zb|I~xYy$WmhUX;?*5o;5re-gb`nNhA`x@`XyO_7b5ZSE`j_0qnm;4K1p~d37{aqv^ z=!{y?u|fGYg3Y#09s!Hq^8C8QKRr`OIk##0Qb&|}>oC@SIf9cyv5$0v)Wu|SB_Tkr zKk$xQu;jDw>%?LOMx`ji!1cjfllD1+^g9F z$0>02an2ZDa#~0C11HYud;+UBPlic6Jpmk_eAC2M)kp|kjagscWyN$5KM6L%Rq##S{Iol0LSea#mkEDk%>h;=W<+MhwT7b?;frt5@VJCEzkk#xV>s`&$ ztj3irr>jJ(sgkV97MXyQ3RcTmX{@Nh2ptc~KT7)6?t^eTqKro~Wqq^0Zc0wOXI3-9 zOMPaLd8_fb|5Csn_cMOzHyRt6FnAyq?l#ZMdzfp6pK6c1HRFMGl~S6MlFp?kWk{dU zGfWy1n*=5m!^4)R{la2q0nb%vT|cj6oI*KEN!MYB3q6MY;{VT(Ls zu2NQj#FXP@L)7vZ+R`O3b}a@{MRE3I&un&>Cj*MXk=xUZv2MdVw*c&rRI``%xiCNp zo=RnnM|ownS5zhy^hNM3MN`8NrB1}Yh6Z~Dd7svY7(o>Z>}Pk1Iv)x_`tHIw-e=A_ ze#{O?9hXOUAe0@5umgo>xdrSk2@;HmOp9tYn)UNC8b$|J=zl0kU#3)NcC*Gy6kAxu zB`x~Q%`I&Pohjp!Lgr{-s(p&CQUH8mvqp)$_TZ9P9Vcox#0=7F>Wpq`_@FjLlR_`je)MBofdNu$QAL@bjF+~YFCqiQ@p%M zEMu+p+gC-kjDJaDplWkE>_v^Jal3T@s7tSUeZ+W6;e5YPp@N+b=t5@AEDcWUJ7ArZ zYQ}4GQcDyEmBlVOQ4&OB1W>juuLE$B``71;7Rtz?Eav1-){5##>v0g7FjB> zl6~ER3L_shexfsMlh)4WRR;62sp0=qgWDecfu2bAHXd^ALv{SbpF={ z>h9iL%%wol6LS`GDdCf=Ju_-LL2k#!-lT4FKJQ&n4$Q7(N>0# z(&NEI2XF!%s5oV=Yr8uml3FLLz_tuK<(#U8v>SX$hC8B-5Q2%LgA>rkQXI`H7*dmu zU5zSb^eT$cjuO5+A(t(@Fu`jQ zjKP2`SWzNJ4v>+0i;61&K*(slvGwbwQL&a@ZW#QtF_E((;>AfEJF;%hveAz#{P?{9Ut+6^Izxp;a4 z^n5sqU%`C%4S7&T*`%=)DRhf9G0L{=ooNnA)n#pi@VKVfRFxqyc`X$r6J;XPB#NJ9 z+2?oGim_7sm~3WG!u)bYg|{&-v}y%ryyFzDYLFh58=J+F_Lc&%sVUSl47XendNKsG zEE|*~ioT79{>o^hIMsZPAI@pw9m?M@yq}R~vqn6`&GFo%V%5SKp;1@uqe=&6M;Z2b z8qY4>G1eEtp=v;+YB&%AGFA;*j!T-u#z3175~%c>yMi~-Mq+c=?soTw`>p-fW~bHJ zZf$pV8%uUZpP#>da`@=|`QsVYx6aSsJ$dx@ z$@}x?uiig-`|1$B`1tkttJm+(KfHT#{`&3tKVE-0|NZ&Pm*)?koF6@Z`{Z$uj|99Z zGdrJPvW{`>@zA+b64t4egmsi842OA1(aK7G<*kp)tu`vvLQ^Ex}1K$d6^THC_l${s^VAv z7Rq6&H7L9y5w&fXs7>*fghP^K&Hn<)iUQgz1Ui8dx7p*@$0S`X6CSG^nR!xC6XnUU zw`DP-V$S7I)x2nRQH^DL--g0P2{H8IHR-}o5rWaawm!jW)oh4{XM*N-&@l-nGaJTIA`SiyXO^>LO3Sv&b<5 z(27M)?5P41EOaI2EO42V46$yRW86o|*^M!RA7q+bi-BBAgX}au0~i341(;r8b4>3F zV?Un}pm0-uPxGNAo!_f>3T>bH->oMj$&m3gGefya^k%Hcy#DSoYwKmEgjmK>1!kxsjN2dZiczvba^e^1Jw;Q#j+3US#DX?3b&AL#vFU7bR9EPd_h!+%7igPik3f> zV0$OcL;&$*_5&^`RTORJ(g0t zDiSdxu);x7?kj~j(*45R0D~Y=kHpXnq&dWxM`P@E;$e5-zBvpIr_*rG`aeVAuk3ar zT4D_6z!1hAB=E<~+Z3hcuyU}CyBhGM7UL7}@tc#`Wz zg2o`5Od4V^1EwE%g@*W81dS*jj2mJ!3L5_sHD!)?p^=k`v&*=!~c z9(4Yxiac}OX4f5d-SvDH7B;+sopx(r2c4SmR0(;?zuTO`?8V>W0lee&r>Ctmf6AJQ z^;$5^P2;O^IEb5A%6w>(N=33Xdp#^wr2ZzX+CV|-Foy|TuwguzR+^;AE;aaD?u=G0 zL}qhyb?4(JnbVy_9?nxwFd$ryN?o1+gcD7JHdtQ0Ujbt#wZ za0Y1M(G>m+uh6v-O(I@n2(7~@5N~$PL~3se;N-*gF&vKma3%)XxR|8=qp6V4@vnqz6SLSV3&NQ{VoJr&&Jz0Rb5 zL1laLJ2x#c)s~1efULMx-`O6FpZXV{amtZP3H*o^c0|IcDoSE6?rYCqikMmMH z4~AE&p+l4xQbQXX)rR1i)6he9km&chS9xL|A2JJstC=$muwmfMsRPTG1;SDZVW)0< z44tz{hs#hon!3i+eMh-i>STfXSEX&;R>_`rRruFkozAthD=&8R7j*ikcE{+^x0QpO z@tf+ZkEuqw>fLSKS>xl?QGRABy2pWWL~HAFjw6u~+y1c2^d1AO0b2|9Ic>GmGyBFP z{s1{Z#=iz_KaLB&73?XX0hofre6uYcLpeop`pBbs`=zs3z{Ts>`=yJSLmNLem^r(`O4QiqX0pDH@1sLKG`GFK zSiFvZX}3D`yR%0h+1;VLy%yaa?0G%&Jz7e*&Nkra*n8a4`iVx#)VTM7S z)^@3{wMp;PH_Np$t;h{_x7XL{_W(XXBXpt_wkG~o7EIltR`{V9E)0pqQm z(>0hanp1Z&3AoOVKH-ZDR@{rkMW6KNw9z;d(}0#R;7~Z4#gu>eMN&Qt#5YT7euZ`Y z4o{HaO1y{P=kV_>{Cfl&;1&G4fPWw0{x@9VZ-Rli7YxPAU@RU6zx7_3DSaD!fNC`4 zTRk}#2y6`y+CD%qZax%;03`tLzIO{`z{8CIs0P?4A3V`mZ@|AlhYe5u0KwG!+8>>M z-E5!vK-jFwSlbuyV?$HcAOYC?&@8p-tc8^b3~Iwxds|*PPz>7!mpN?NJsT;sr}3E@ zG|izJZpR=yIB3pCO;}$D4caUof&ZHa|Cd5K?~lm-T0H*+_BnlM^Kg9~ zI{*BaZl@nbFhh+V(6!KWm^kRV4`uW+;1_(AJ?=Pz(_O^D^%~r09a?`G454WP$XD?W zR^&+h2J7nd3>FsrIRvHz|NnqTVW*25@f{`qfmMuey@dbXz$!k3_wFf20tJKjh*%HQ zTI+fgvboi7w_5&t9aE*H;nUjJ7kY8DcYN9?_<=FOVHl~~7W^msIgmX)giquDuo}J0Nn} zK0a>XPBc5Nr_Xe?G@EkCv|)K7j^LHxfY%sv2bUaeNkR=Cpcs4v80QcdH;jEFcq_LW zJp8b+;e2ezf2$gykI5`1k6`emL8Jt+Mf(54+|!aetlm92R@T>3%!CKtR4R_}@_NvU>v{rp*vogshPs zr#TtSi?b`>FPliuqQD!uKe5^Ih+$@?ehg66CO#JOO?*dQjbXrO(T>$wD_5HBQVUA) zsoD^--7*B%b-dBInxb)2V`I88RSSHttLVb*PD{~f-S(a;>uzgmvc0P*xb5v7oi_A9 zCl1}&+0#^IcSnr4KETifB$s$_44e`>y6FR{9-&kyX`FR zi$;C{Xn(2$3+(@;H#eta`||eAUwC&s#TVs~zq{Yr*I+RqIB?dQJNn)BZsl8z;W+;0 zEXMZ!o>NKorU^aqwlM4Qv>dUXpHy1RQT#*9sFUWJ3tx{+|4 zjM7aq&Q9}1wNrXfJKOcM-I!g%&i)JpK^l+3Joy^4mtddPzXYR8X7J9{G#EsQNmJDYOmbgznL>v>R(S2{} z_5T~Ck{iugFgs1oq_q?4s?O6`xTf-#^fYK)bVl*T}8cVBMIhWg4{}miieyh>{ zVmNrE@qo5?S`Ug=mgry`u>P*W9eqs!Cw zj^`vhs*V;m9}c;x4hx1%Ht%)u22N((B(f)Pfc6`(1$|kA;qSJImqANYpmTH#{iFp2 zgqOm+y09@!#^cTKYnY5t#n~Ws6B_fUPL}KTPDgW(omQpyea~;gTJ)NK&VK&$KKyCo z67c$Xh2ggVMG+ z%y)@@>ll*}Ib^?q{{X5bWU{*rBp;tksRr4WJV?L2?=y<0X7Dg|fB^rTt-;JSdkcWU zhSy(%+3eeSY`SeWIf$K zVzHR2?2c4(cBN`Um#AQTRr4ynu+g{$!f(cU06pDkEY*v^bm2LNI6~Hk_XvktQzCub zH5wY4Pmo+)VSFkkVwaXeL3TBZ9B5OO3mY`@rUwCQ;7aFDO}B)1&q3 zW*w)}ghRU0uob!mKv$}_mFb%`Pf3lW6j!*w)ys|lYT!S_21^fCrN?Jbx2*BU^$4d( z9fxM%18aB=H?ZpOH+>)VmT*)qpbH@#n%;edI~uYmOULtu&kC-mK-xF_uhXXNs#Kz# zLSF~szTdR%&_;)2fM~)q%x_VCXF#Q|3tKdZa8_Z6+`L=%GQP~lQ9ND!Wi=G_VMI-2 z>a9y-J;)v%RGT)YX$TvN!MWM74KF)~VY^ zQcufo?3?nQy{rn<`;*yI^Hp=LYRh}mI=@vlhQLPt9w(zqYzj}uXZj`segi!K;Yy7z zn_gXp1h723cn91cTwNzoc8x_h&(SCVFM!8_pWn$K<0Z8CXdF%u`bhG;HjNI)Sr*yb zS)N_-yJ_}0ejH+ay=mx{WW%9c9U5d|B?FtLaco5HK zXt}+;$5T0R|~oSeUVbbk2${oBO?O4T)@>nO}apw8#x80hdTK=9#!xL9!s z*X-e@vJis@a8mDXwYS>a4e>`2{OkVDYqP=6_y5PgL^ji%x=vC6sq9nZ+56*{Poz^d z>`(PfJRV`pY*imR^!WL^!-p@QoWDDH^5pS9f&fUHY#aku(62o7zdQ8u$ldqlr;QK}*6`wDia!yMGc%Oc zkgE~?&UiRiaLUntSIXpmqBi!P#s{!(!=!oTPn{H4fpdYnab{J{@g6nT!T zxiJXSui=a?uE=N*t`e#~$RhbOmVZZxf#DK&h58vMGvt>ejCx$>Jp-EQ*Wk&FG*~UK|{L{rh z+xTY(|Lo!)IQgmerS9`3-Q^SfJi;g;dojG2VCSUY8~$R?KVM*jGX}Pj)pdl zN0M6~C-jHJZ<8=d>F0|6PN^(T3-yDtN4g}C!4GvS4G|V;25drdh2K@7q~HQSlwIYz z-Tfnu%jOIGn_^uOXiSR>oOygO14J%46#3~{JW!ud zzH4wX3CGwEX@lL+QWfNUvvfokb6SeC;)4E8VBpj~^_dKQ@mH<49ARd>=HL=16Mrx1 z56l(*CNv%Sl>VTkR5TEX1$LyM)eZXv|HA6W^?gmtc#urxt{9AyD|E!7B{;-^#$za& z$;C5;12KKAflM=wz{9>HpX1pMU;& z_RoLaE|&kjdA4zXBtAgZ=07(VoAsdc+QPyd;4lg7=>nKffwiuqL?1g@ve(Uq9;VLs&i9E^F6{Npdb{KB)C-WxfS z5+ySj$sFAsk&900w_%L_8Dsi{8!_pCA2w7U@O&)-!PhHIZoHdMF0!$kdqS3>&#Sn; z3cgm%Hma`#m)GGmI-kZvRktH9fGMb?MfcZQVqot}xAa&k z)1|K^-dmX9Ev5c(Yplu!wBnh0Z<72!)V*78>&Vt1_CCMD*4f8Rw#u^PyQyX?vV7Ct zwrorCRolI{L{T(tTB1owmTc(*l0kqVnE-hS5)3jy2FODu$io1chy17SeE%V9t*Ro6 zl6H5W@0*9D&%riXD%NFH)w%!`6Lv=sINL=aAFps%dBJaBmw%n;+6`h}5iq+}8Dvq9m;X#`QN2czYX zaX+`pzjGuv1)~KdDQ)4|YxD!GqhchFqYs(WUi>THpCP;a!Pf^iCv33mkJTy{a%Imu zY#g;6t#=HMT6Di2`;5BFahyzwL@r_^c;>OAxq9APS@D3`8@J)q;pZM&Kw|~Ve5DEw z{GwT=B@N}BHd@2$vHO5Y%fNj6>*_+qWJ%OBG?q;?q*kB45RJ1_UsB>9OAjJh}!9QU7t!!pw4OLiUf6 z%!La|K*VaTp%}2{^N0qEDuscyHt2(3v_?ILnV0ro65AVQxby;iDnJ%R9MZC7lszwX zzS%-*sTeq+v6PB`6FPE?U1U&jSIV^MsD4n22bwG5QIU*+Dxi>L&?~pw-&p7SWGlBW zB2|i`1O+S#E$T+tLh8mn|-DRvrI4YiZU%=O3exsjMg>%&VZ*@cNXEObs1VqKQLuqqsR(GX;X0 z!?0OOJ2TUn`;bmEyw^xVA0#Z4jb;}XLH~;~BTj0QfZH= z>k7L)-bB$vV6Zd`Uyn}uee8IQhhVDd?AE0%cGmQn8%B?xT{IJS6A}w1&2=fsWlRR3 z0J=fRi;Uo*XZ-y$uqFCuH=X9Znn{oG7r0*$Z8MkeT^HmZ=1$qIGb3xkC}0P&AAB-p zpB+_%m9XLy{ zc#^Voe(`DlX&&%j9XvTGV>@oxw*zEXVX7Feus{Bh918ni(@+{kX{f}FzVwKV2;=Y@ zA);?F64(eZE}(aOG~VoJJ~&43U_`=+5O;UPDLR=Ka^}5vsC*$Yzh-0w0 zqn?AIlh|8QuMpGncF!U*Js%V5v!gRkFJcM)7mrB%2WPBZB<HQaH|!ArFD!n+V3}LL9`TWYrXbEXAOa zQ0%IG8?|+sht3Ed;J@)7Ffk%pt$9dzS1m$MY7mwA>dt_-4l~0Ypph#EkLmqr+e(-Z z!Fu!Jh#2r!g(O7n60mFdEAkdu0A+MwEg%7w+_`X&8y1Ohex~=<1q8ywdW2CR=xNM& z?C4WXol=s4$S!8Xl{O$o5=23Om7Dv%-+D4p82RMxrSml;esPq9-);;Iy;%&)7g1J10ye=ivq>xK!c4 zUP(M!g&bme;%?k&E@_x0x}S% zBnIB7ZT3PIjxlRS%?^gBL4<-qXqRfW^RqIACxdp4PEv%4Km4r)|o-?Pzd{~gEBw^&$iC7)xn7$MfM`-*tjNww`=e8b~wBjWR ziRW$s{w@;xRgdG+2#u6<3`-H1%Frpzo4%{-tO^}VQAV^;j@p<^mdhD5-JeXV3zS{S zrb~+8fjmIQiCOO=`-ynxnE9>?-n)X*QdzltFXK(XVG}q?b-t}gF9C3`s4`BAAuEl+ zf<|g_HR`Fve)_QF8IK20fdNc^QpKSuI?Cp2zc~3CP|=}Qi#eDnux(R4KH-upFSi#R zl$MKjb%|pY*mH5i^pE*%CHKO@v(c0)IW4BGf%_*2!8afwy43`AxovC99OJ#;=>Z- zjt4V$mg%RNT^sFDerg0U^v34e%9_Il@b@=vc)ynU`O}*75(|LO{(`VF^O0hf!ZS z)ym9EsMfxIjQ|3m`ueqnzDU3)&;TkhA35;B7$M18=7pnGCzG;rNmotmwKBYYDDM?` zpQd!9qUU{%B7A6%qD#P*YB8lyAoMHXm$X(+;N5A%| z{Jw}N>d}lRkzcfDhR zXvt?+0~3yc&YpbpMGAAmP(u3%AA19@eZ-9_@EXkEE5u?2f?0wpL`L`>W6X^i>PbN|LFQOrh}#{6R4S~&{~aZtttnaDZ*g5 zi208dEbB}xBp45C&4~edmUH|silNm7+EAns zc+BNAExcG3KXzbN7GQvxAd0{W;+;3pJw&=mVunQ>cq@g#C$~_lD|j3w#AxaeRlVq5 zp|cK4x=7AS{cnjZ1O;Zo}1T5>1x=~P|h90r7 zL}*CEK_nE*pzZNi3 zDsuNn$VlMC<0PP-vhO~16V66OKfX$8&>kaW4_Ts6(jYTudfegcrwOJ#O z2OAE=gO2g)46>;xRB_VAtX)N6Hn=69xY#l;A)Db5{pd`<7=1ZL9XnDeh!We(A19A| zHt)u#9ZqIWszd`+fO5zptFsJC63Qi6mT}gEmEhyp9Q)cM@cl2pgZE=`fzYPFvKWfn zwFpat6a^TFyO$JO&I*!VgXaLj%l!0E8Wh$AC{n4O&J-S!a z06oU1+t-<3RA#@?^I?LV&$r1OeJW<=>x*`lPq&8A&L5d`a}b~gaR7E9R|hFNRB=RV zk{}jMMGIPcP|`mXm6X@Rv{{S{oFZ)=2X0ZqG*5#Wv3j}&y{FCk6cbbqdF?X7d&Hsp zN02S+==t%xfC8>u8>&Dr{}||aBS;V}$5ELq!D)oyJmV?(#4wDR$vCKDJHDTUFBP6K z3{kVo`AE$5sm2nY)uB@G2i#70$4r$>a*TaGO2;wE>a zkF7$aJhM=3VR%zbIND&8O+@hGB@wJx2(N{g*~?ad4R9IgU=aDek{wbcA;(v{B{a^B z0qBgSO#$b}h(^K+q3pX%_S|qb-AuFJ4?@%_Cxl!d#9lYC5r*a{t`Wial6~UspGv#$ z%h)ir0PJwe@Y~v@o-Yj=rw^t^f4Fjw=;pqnkvQd^0pe}n0RoXeP16_g1@R|Ey8D#; zKsWZMnyVVMGgsWKtMuO(!C_5SE1I=MbBn{I4&bFA3`XOla0y75aB^FV$K*gUOX(?n ziuAh1>dC&)f>{BpG8=$XqNbjhUE(X?iPhK1WXQ^Y_-ncMhcPj&VQ6Jva`e`(v2h<&mnM(?g~0^;;- zvu!s%*$;Zbw>8+ShHpm#Q?-wRz+GQ|u^ZL?gIUEiE~%);Q~_uN|B2o8@ehbQRE67> z#0ZpE(xY1ZZ$w#8yDHiQsp~~~HK~XAovC$!Oug7sZEV9nxPiva`%DvK*7w;V;ZHG( z1DwRYe&0BFP&mp1A-sM9vF89EnxT9m2!ecj&k;Nn-ApDL{UrQV>~=9s6%L_0=u;^( zYG{I8v$X?u2kWUFVxQQt&^m?o%<>AmDY18GF@DG`(q>RhLFAhoU*Y=zj?Xr`*V%wT zH|)cHjxGKWx&zn&7{D-u&2yiCyZl8d)!AVKQ05(q0WUsj;_m=`((fST#yjD`8~~2S zRt|x+?wBs-X0c}|;2k>zTzbt2C0wZ(5?CdK!!@2O0nV!3!HBOi7zaPwW|>{j9Bs1& zMTw^1eeR^0RVbZ5?WTuY*jj`KtOWum>f^5G%gs8Ja@=4dIuki^1Gd;I{hFKzBTSTH zA6WYbmto)d&{WT*DP}Y?RO0rdnlPHE+NT(eoT3%OzM0GK&)d201DAW+*iYKI@5ee6 z7X#BHvdz2pFXITWxR`V2eE@`fY&Tr{0c{l`LO&&Bl|;WlaW65`X1@vWn47!8r7nOs z>b5)N4RehFwJBLG}fFuLSmP&w2(nP&I~z z$!dZOksbr^Z^-pI=+wZ1hL_GgC|0?Y#bTkeWFnUY2H7B86b(n=7RTd%sZWE{s!S%N zu!sV|k?aDKeF;&4)d(JdDTQ^Gcch*f*dC^-g@@w%3|3(bvC*@RJbn>WO& z^2!*Fr*p1!gew(yJEPl_m6#KnT44U_n!i$lp+20$;aFyeiIxyHI1pz1gI3veE4Pc0 zaFeQPn2sj;<}{2FTLHuI-Z!BS=qE^iz@S?PZ;fGZ!hRfwCLfA)^21_0u(ZHNHJEby zT6WPh$Dx)QbUBe|rxY;0jTbtGEnjfgtb{XI@>lg2sOj{RMIa6D4>}!tu9fHqO4md{ zto!DBUrW(w`ETkFM>Isw21YS5BNV!j_yZn`Ln(l6u3h`B;F|M=C~LVUxvsUSd}XDM zv8DJ6x(V=c)y4!kscvCTpWY+?L7kO-DiO+UoegPCe$T;<9ge9ACBl<;4D8;;LPR_c zdOj@2^&_Qdo{z~jyUjS%m*$#M74ODwdeK&%Ocpwj@-0NW4ZZn<%d|Wc^`^rwF_hn2 ziks96_zzQ78qM#JWtGWlnCg7=bgWGzu4o@K(EXx+B}l~sX24M6JQ=Uu@m#SKpwLc zon)}AKoPzMgbmA#2b>%(Ig1h`SrlLkINszraYcAQp(W;9y<>)jAA4BiAy$)il7^}i zbpnHQ@}hU>(x)zga*?NJqG#uUIY@+*V0VJHU`V?J2F^>QpX``}#FQi+U_*{d-sMhlD?Dj)^a$juWqbiALVuGEt`aG0829e8kZ*@$1GD>mEf&`2)UKiWCBFq#Yk2_5>as) zk;D#SRsf+3g_DVuzjrBB9DD~)cyWt)p`J4)N^o zrMgq|+^wv@o>vRvz=2B=+aHWgadD^~43AC)kYi}TWn?@6GYtt_SeVcZ#ss{G1q~YX zVohhh*U|IrY|Lr88oMKlSY>-_eP@qOjZ8lV93Fo09u3FC9+i6|Gs!i$3hUzONrSSO z?3zCk6$6TTgOOsS_j23-j2&h=BNHgyp<&@wa-6s@6yH^x6$jNMsPi*j+A={h->;i* z_lRw_ekYg$p6!m}%_h#3PtI&iTLwhsX+8xPMWW>ZJ5Bis{RcCEe;5{+BrTfgsU0h;?YiX}-hMD)hO)mUKT zPy)Fok(qk)i*;2jsi20!5-jIoAi;yMJ48HVFQ z=eSxRS%FzKbbdzXE=q5;401n6(0M&VK$eK{DiD3ZIv6pb(*PlOI%Tc6Z)`J+)C zt(1%az?zH*&QD_ zx1WOnpZYTk68=$q5;~S19&bR6VT2kHq<&uWO&3crqm(#u8ajHKK9B@dt}H@EE*^EV z-hJg8{S*nYm=7_>eIi(mRD{YY>>Gx;kqza{6?uM^_Sd7q1kN zK|_W-g`rs~3>XE9_;53_0Dcu4U`j5-82T3fni>vL&DonOHQ}7~`b&XLfnJxQAh1g> zcK@;0Uiyi``u>zct1b9HyPQ20=!OC?wy+=;j*$Q%EV))jnv>%BFW^ZrJS{>=!WEaB zxw7LiALW#NiFyiT!tM$QF(4uxxm_479PxC3(cWG5JTOzumIZ6&Ji1TM-b=UVzXoA{ zFnk-0-&3sGHdEQ$`o`wg_Rj8JiXHgo$3gkHTsNw1Ry@5pDH_iM{_u^LKM&H+gPC1j zZBN-Pp_be>a25xCM)@K-tmxIjc@q)!2ne*xk8QBJnx|;f4T{zkG=b#-R{3Xdl2y=G z%;$VoWZ&gwL_bNfAwouqWJLOWDFCDvA}qm9-199QNo^5|MSta(XTrPqFy4i7>w5aH zkvj|zmy_!EKG)7GxF4x@!vba~FEJixm>LhpN3@(K_T-^+xiF;2LWm>RHpap$lJW}yO<}X|(HY)fg#A4$dU#_Gk%ed&lD{3K(5{(?#IpZ0hQ2Ss!@ph2H8F%!% zSOUZ8^GRUWRcbORz8l}L5Mz(EIn+MM=5Z4m#W54e*!b3CKsFrP*dH)}IA9LrV9ZcR zH%`$C8ffMI`I&JB-*+EvAOoTC4!`$~yk=j(<7&*FVMWh7h7*dXI)dT7&!j_uafILS zq3lPUA9nk}*f<$83Y1XvJflUh65NP!J!UfuLb0us;2XChSBScC>N6fT_Kav$XuM+A ze5f_MZ(QPQ3D|NE@wRa@X5AjNbxe~Y${{wy7Z@i@2BaXP0$7M7PvfWy0~B}K2VxP= zeNG$p59`JO)D(AVvS32`zbX(IU2~8G|avnzOMGn9GM42ELT5%aa zSf*m8Hwe*IgB}X3K$OToAfB9M}g3{rrXEhZw%0&V^B&!e~0IEp{6oL@HGZ z7+0l=+VqSeyX{KUgnXko;T5HJ{4Rp3t3RqGjKHm|1>=x2&NJ$kQh^HK185r&+?fE8 z0>J5XD)mF}zUkSDn0)0Um)uAlR;%CkiwED%FY4v<%D3y{>D3|dt;u36D4ka-#r^YY z{qO)^>*cecq{jre&Vypw=7_(#zP9TjBtHZ z7E?VMO2E5|_DqEEpb*5)DM=G?a;A|gOcT1acx=B;A=OA>2A_wN0pcnT@D`%q8U_e5 z+_<;iaAtOLCX^b4a#Vd9NT%8ynlp=d&On+WP42vJ%i3>l*eI^vKdfc?fCbUtQoZ^L zmHXah>^TCdxadU`jXZ!5=lIq9(#rq_(z2I8xRZA@dD3oSb;&ychd(pWkbPhf?RxLo z=5bxyJ&eMdW5n`fjME(Qr13OLtP)8lIJ$`I(JGieG{QEhc#c~p7v-TFx8Pq3 z)p+~Yv?`;OM(MU?R!NlyTfY)DT~#gvKL{w%Q7*C6$26tuCDSXEjFNhwS=B^Rmf@F| zZU@I3*^0-5G<5QJ#GNfaj~1qcr@y4L*8C;@HM`V5mbK(2 zzJ?C2<7-G)$laPxH=_kWLa@s*WwUPhq8iRIjpJZL_#8#n$X5iBBrM2s*3hDspot2= z7H}(0kUX9Q8Y!ZEM%_$eVRy(rg#FMp-?~N(IfEgPq-&x+$W_u$#8!S9sMw^ekzcDk z9q~M?T(mDnqJ33prIM;5H?Rg?GgzX;mUh>&m)dq8MrXJ2&NY=Ti5Qu%NA-M>i>y_h zok!5F#+akUuJjLW7T_%acq<0DCG~?XMrWNLs4Km2C9fE-0C;t}GhN2;8PII0P?Ch; zN;fbeF=`?~lJ!9vp8iQRih}w=Br2U(HM)Y2Z!5{RU+PglQp-R>{<@Co6Mmwn9eC2{ zX%_=_(9<3~{R2MjAe-~o^(1ao;zCrV_|`-xvFLHvCoG z7+?Jc6vG1G2U0vZM3)&I z#PwF8V_LMrM#rQBo76K0OX)>m@{crtJa)15e~Hre4W)!QG6JS)RfJT|*+&!$a21%_9;t*N_7R^eOs zqciX@5fcdiE(`1@!gB^jcj{VzGP*qwt-OMlQQv_z3I^R95bEhC`Apa4!Y3>0LlQV9U(=tcU&nGHk=#98%L&}4(?1Ie>e z%}Du2JasdwBpJj4Y1ZgRYlvFKeL(S{NHc(Y1UT7`+3vg{i`pFw88y)yPIopgpsS-sp2ptx(lYy++Z4O-r74)jb zPgE#_60y$E>^1Q|_+&7dkj;+OukiHLEX5ycGVx5Mrrb513=oI6;ap(oW!8=Wchkc> zEXSn&s#zf5^3kH5hI(~6S1p9H`z&6L|}do#s3 zP@|JcGMU0ml_;@lsfwluF==v=QnFO|YHOHq@@s=GkRR62j)^F?YyRpA=M!L;(G}kX z5S=Q%uBE^Qw+wUd1hLI>O2c?d2~@6e6$H8JIHKgS2BBFnf5V^c<0x6l+t6xT=w~LaiPW13(}SZT^9?$C&x;0 za`7!#Xfe{jf0_4S(3{7W9o^8hWqg}VR8AL5NHpS~JplX4=Y`*Q^hDnxa5>ck@bgcx z>8X&*B%&k@NP@u#fc1baWrw=1To1<*t9mPd_vZ43QG?Bc3SgkhS!CElN!5vV#*+9@ z;eHg60QvYR^s44gCL~u<3}zi!^>Z#S1R@!S#=|b~cqFJYtBt$;=16GFxI`c$bO0Zc z6jF7?D7a^S^n+#>(_i+wP0u*-S+m{r`#@_NsgcNsMl_>C>8u|ZRY&tqd6Cpeve2=< z#Ia>`P83WuN_$HY^&@5oPo>Lo>Dp6>I*VUdbBwcSV%fPeq{CN9Ub;{iF^%8%MoWp; z?}@dI?QpEZjF{dcD;>klt8%))Gp^7=%)Ii8tmqYG__!+SAmmH((oGq~qTa|-TB#z; zqh-x`K2-@aoCI$In4{IVx3^VXrB$HnoSAOt(JT&)C+~J!k!bsEq2$! zg<9A)t(+VikO&bP0&CSIKOU$dk+2)hF%#+#m>orkkiEVD0(apI^w+32;!LQY=%$^= zp6w1rP&s(Y$mP$+1j;FV5UUdcnl{`iu)`x@OJMJe7LGEyl?U2x6|~=nG;Lb~B^+(q zQ`5v55%;T&MM5JFJEt7K$)w&(6fhwU&2gzRrGUI=s*C7yG#t5mWbc1+Yq~-q$ybPu z82Ta1^@GMmu^hClaj9#Txkl|rUI`~1BQ#VIb1df2KJx{_a%3W$1U4&(n+VOpi-6ht z6)xu!^OP8;ZaC30(u#0a-pI017<5X#-m}ZEyHdBc>d|Wb{L5h_MZfV&aPix!`s+(` zB(}jRr)z+#plrX_9>)c+1&xo04_BC?NNG)sx$*CS6W45?qu^=uXd zn_o7v*(rMzm-Vm^FE72W@lmua+wU85yOS;9J@A^3_Ze*I=e3^!nTvU>;&P^h!|`-F z&Br2EBgKOfVv_oKS_F8F!M<2YIsj;kS0l{p%(&JL8T4+DCF9lqM^@PwTpmItdaw{9O3 zHjah~+azEj$hHbu7RB=-5XNKQ|J8SWUa9NDIWzqjBVs(N5DLOUixV>-?v{+;G~>wR zCF-F)lBma)$r2ZHBY;RVgg;!Vf?j-ff7PPHkt*Qt^bc1jcxW3Q0_O$18a+h6r!l8c z+A}<|Z%dU2wF*4Q z5^!-w)VgvVkl@G{xQ79Z*R;lOUqhBRAJY2Q@Mo=HnE0ph8_Pq9URZMk+s?fZfZZWm zR^aA!x8Z>t0L(0A%b2l$Kcr2u_FH51TXXfjxO$&m-TUqPBM zP!!NT4O9aP$;`(34~fq`Me|6e&Zq<X3TuY}*}8JiUR|j72K|J35A9wB`$EBc^(l|x zOWHjt6H=hXXxbg1g_xL6{9H4KXl6*abT#EVKwJfIth|J@Rk62g`PvBo&VDA0 z;AA2@>ZAd~en9U}DI7vO9c{#bC6OOX1K$p2$iQM{k4zoDqTKuxR}1qW#iCLSaB*uv zL0^)qk6F(sG*|4)Bs{2>qbyW_*Q^MBipo5e8RDI4;TV(0S*=LO%)%sQ3oe(4i;M4^ zMNq;?U^_sYC-DU|a1<2t`R|!|WGU?NLjm(v!pjrk0wfq&esPz!Fpg5VoSSmSB`J;4 z0V3xHG%Z(7lFwOC9Gb7=*_N7*cxWCJ=bgB7;L5o_TS)o{-rW+FS-2Vz;~7&Vx?hN> zyKZzSubEJTs|ZX{43SOToYxx-UpT%g&KR^mv}Jb&IvsRp=g$4{NaZhluu)X7E+!O2 z320VC;r<7+8W7%#Hft87K&?X?3_wcx=`iq#&cqPHSaCI#0Cx*OF**^!?nrhZk2XP_ zl@;Yv7NR{hZxP{6M*1}4skk}KWHK`W?N#q+D^9S+sOFU0q%Bwql`Z%Y41^BIlQ_VW z&KMyYd%LP?%EJ(%aUw>}GlkPR1NYd)UW`K`9xgNT(dznw@20Vg{im8BMoHXrn!mbf z3`UA6wP}#Zl_K_9WTYPl`5ch>S@d0S>6Q&2iRkgKqy-c%i*kIG znsH75xd@i|D`kvABN^t&mYNn?X!n%ke8X30daQo>%4{g*bi z+_r=`^0*5p6Ac&=ie4x-rT!-|0qxxBpda-PGEzcBizT*GDI9TeFM3!#L`4iGNKIX# z98z?6Zuw$DczMu4#+$Q8)&^>51}!ozRQ$J0)N0SLVs7M2DHeqS;GpwMz<4T4y5K`$ z!6VDf^Gw3(8M;+?+u|U}%y9?UdJogPO(wV=gFe>;cv8~j=n<1>wH1v|d_05rZqUJS z)D91NdZO?1^BD`h!fnn|h~ z^2^e|b~53Pp7I?eDc}nXSCf`rLhHNKX}U0`C5%_H#H%i$A`o$h_>?sg`WiBRU8KT! z;GRSsuLt{He!P~;X7$xg!1Q9oEV8pkvKxc=DIv?P{iZcm(_h~nK5}e*YpQET0kbwt z41Fz<&m=-xS>e|;YJTVdP(ZK0qP*O~gnK!84WjyPk~^OID!7U)Prw+pZn|-fJlDwC z8wo2|-tN#uyF1urZy0Zmax~_Bz#_nn08v2NF=8J13sEOUv%~5l>1n&Sy#F``kGJfG99XvG)*p=kbmh+R{#$ zw^~FFlHo5PCS&1);7hzvm_VBw5*OlaoMg>m!)#??tHn{W04^PcA?jqj9^k4rbE3Ej zXp=J_^R_|Eu1rk0i4X-j?Ar{+WZ9`%Zwd1Dt}t z=E!zhV-UxU5kn6TV;J4L!{KpXnrH*?4>2j8R`<;1xkl$;3@e5wEtMWy3dZA#2?{GR~ z8cb$T$7glPS?VmIFp9tR0d>HKUZR4fRA~1w2|84<@UU~MNBLW5TY~_j+r&8zNf&AQ zf@RJF%RFW1;j1G3nc5!~=3ty^f@^FYF! zw%q!O@yv$UFVSWR^i+z)C}?$ed(++m0niYOqJN0tMtpFhG5A@rtf(srGbCiq)%ORk z7E6C!%{=^U=tRuqo>4WL<)l%DTTmj`d|iXjKCPkQU^=yy0!>Ixh)xWt%RHsym^@1jA^G(*2@BHYyfA09^#x~!H!w1YQt(VAUU!4j` z`$ZnSYC-ez%JyRrctB#fb3Qz9mtuzYJAgTnw9OhA_zUh+edg1RczkV()x;%V^dvlg zp@8D*f!qV98tR9l1aJ4qqr-^J9(<tq2ZCYv38pGKflu=TN?I$wE#m>{-ut|Ds6^%9%-7 z5sGH+9i8Z2Q7kqvMA|8C8Fq@>3HB?BwezBus=1o6_nPvcF);&NleoKRjv6&!!7box zi&_BBKs)Wbn|bvUH*Gh(PvXo!XnTb-tL@FMp*6e8 zod^!FGeCki9E^bAO2O0gBVp7>BMR8l<|khR)aho#PUfY=Q*bLd`Jh-DFb9bripn!$ zI!o<0L)QXns@3l`HO(QWfajYcJZ_}YQ0N1Q-+Dp2FzA9X-}G&50i}Ya7uD`i_h)S-WC(DnVb?}HV`;)LhzBnbo1#G`hp@z>!bseW zVj3D!Qx(l$i&Bq27eKuM!qaT`4H)%8GZNm1!d7tCiXY9Ln?lT7wqc>^k=+r>%tGmR z0ko1X=ny3^qBD>0MvL&>aB5ndz+pZ* zC5;5IJ!N!t(OGq6rK+K#MZsCzX(oEj47zxUZ3S-2M*L%C5UDhINPKz{Ij3i$S2SCI zCR0eiaMBgx_meQqS28`{#;DPp7A2Ki&=D*mB0E;e2y%j9<@i%Po0i5YY$YjFUq@iY z+sC5kNy;VFq(CMsD=U@E!e5cW{$z68wG24Vp=jpzsGkzgWad+-#AZ2|%{+dUp3@bn zis64mt@tHEa@W#3J77Y~VpOL28^tSSU;hZowaUg9KUCvuHX``Y}Z8C1IV)p?N zKQ9S~YoQGjzUGOxcvEuYsg1#QzKnNbAtfG`0BL|!CCk&PmvG4*S+AFK|AsDQ7be~!9@XFlz!D;0RPDuUb0V_1>O^A#1(ni8@~s`DO0ls&LMQJ9m2bO zu`>L`h~pHqBYt$j5pPz?JyFTbeye?AmlP~3!Esob8I}>9H!HJ+=JN7a6-;dLeLK9OcZ%ErE|QEhERfNdIc)H85WM$Shw^CfAe#wZQ3-i2VtRYh2Tm@C<3nVfkIW z{{yVAMQC3f$wCdpwYYF@E)gNnrG>+l`83oBy_3jtwX$+-N4lOc!W~!KQA<6(SKY|D zc}ac%hwcjbF}6V49Yp$tDek*2(EiDUPadX!I!vm`WU#Gp3hXR~16aV};59nnFZ#rEJ3f%?m(lHMOE= z7ixxB<=T7;H5sSDs3BvO=WYisI^-m^u)fd)O9+3LXzJ64J4KA9AhGXEMIxxKb>%c1 z9&ruBV)?L-RGl(j>hn&52ssR)?oeec3W+zomK**qSJ6Ry5r}P^Pc%II@fPgJ9r(dF zt1+6IQX{%FQb$135&BIB&iZu94&oHYKdXxOAcM-@(22mrX~$< z$>ppz9q>?Z!OL@=Pu81G0mC;vZ~P&va#Lk}+35Cd{orI6!>ZHFyVvQ&FZc6jP|lDOe)-W}at~gG0+kKwEnhBUxG5 zqFI~W1AKGT%?{?S3qg9Gjxu(a0-SLbbrdBOHAKP~dni-hIw>v?3LIzhAp2jG}5onb9y1^F=YR&+SFA!g1VPYTXnWo}If{CS% zDcYyFt@wyd;g^@ieFX=+EX@HpkA~(YV6bC;twF08m}*#h>XJYg-!ZU{R2i&}=$EMdwq4k512Pi>M(gGn)ya3F%3+V5xZEkP=oXf6n zverNy^#VtO1fmwA(;}i;r@I2`ST0bFogoN4$I- z$cgf};{Ou!mo#f%B1jQzcqV3ZR;p*nw@3*(44lrpEq|O39uw6}Y|^WWt0X%;1nR7i>aGQM#<9x zMdidl*5aF=uu#~?`;93(5vU_x7gTIR$jX`wpSv8Jfd_Hbh{~9DM)Vp)OBMl0FL z1ca^B*+7*jT zl9mxd)EtX(w^ZwDpV%pOeBN`m>6#DrUsQzkUO4vJ!pWaBhXu@ z&yW|)ktfx5Gr{4$Uv;0mKnj-xXpn{Sr$2ocUJhI~vl<{urT&siQ}z*_9y&5^pRr%I zq|GvVWxzUqmkodVaHdP&)7pqY2B>$2Nn2`-m->(PlHDD;L9a_$1PocLIc&Nf@~n7+ zjkQ_>*K4Q5ZJCb|o`n6@ueri^awS|c{?+$%hvyJ#D;CoRdLF&EHB5*AFHP(qntcc? z8+Sa+Cu8WAj3o@2$3Kg{>i3o~2O@4h8k{(*(2qYKKva*zipy*5(6}Bb<`&g~ zl*W@PxWsFQ##inL0%T)m(;!mB1v7d$Wzi$mpJF_4)mHASsC#Hj-lj_I6~x#ppi3&V zIFQ*Tg3PR80ATWk?x>LlFAz67x`@pbHUIbf&T|*JMlfM=SiwC4T-7^1tB!o#YkgBU>{wQ*Fnq-TC2P8?pWD&|3oOJJ%& zz7R{pRQlFWryiE-N*hZ(V%9MsMF$g7r2Vw!6;c^;o=ruk7Te?b1juKKuErCX!qVW( zFM)`@)Us_5vPeLB5T^o~h4*F!$r<0Anm>Qak5`+O4dfseE zJpl!gnjkT#@1H)rDWXWveYU;vQgaD5nQR1Aktm#h*SU#GY_6}yT$#_NHWSK}FH*Q5 zc%rJ|SEEbEF=7|Q^}%>2GNlsgEiIArCn9cW;LU)%AnkP%o+U_**~oMhze`_pDBTOz z#1P6b(M6+Qq8%1ZA(bBe?%4ZocVHvY4Hq>j$#>lJd}C3ZwLTna3~LbdrDS?Nlr~1_ zV^a&KtkpOBVJ0rjbHCV=?yO&|h+9s9oI+EC6d{*#^mu_U%@4`5lIpG<3De?zIu)XP zBtnI{qKMMrnbEwI#mfG1D--yw1(z!l1*|Scysr%Q-Z^#{20|a8?-N znmF3XoJr z_=6IHlR2naqXasoOccSBl`=Adryd6-AL3XS4N>r8i^e$Qw)Q`0TWEZ0tY~IgoyYx_ z1gUB^uG%~;4o}y?k0ej$R+Xa`emhkl^Q=mJMI}R>)y=B(WUiSpj|dcgh)xFi;xF~Q zUNdW)gRXMVCjKYlU24<1>8LUC%77wp3@~QiKr~>7H&7A}gg?t>RVzyjU(r8Giep|y z&yzR`7!YdKOqg%{WAsrqVk>93+$x1D)d+UxP!2IUry(EGaCT0xr>_E+qCw0fPB%`9 zy1>L#HuA~GyH{y$>du>+qK5L1j+0=2;OMA4*-1B6OX4+3(#TMCvz7z{=HL+>kQ!W< zp(0)A&-rgUi6NNzoco4GLt7YRNF7fc^;lu@qY9kM{L!$SnPEv%;}t$;;vy}y9Gx2Q zHMe1>srWShSpbC~D<{j+l+i4OKOuXn3$Dj)&P4->m{fOJK_H9ce4|t2U~vKf47ATv zFG@dV+^gn7ThR`xe>zIG1B#cRe#dvOB@$zLnQxvjZ;$~gB3In_mjvT06N;)8fWgHe z6KavE#u`@pru=+7B&M`nRj1-mlCk(o!a?^UXDVcID>@{;&~PM7>SnAzrp%u*SDW2W zo-m04ye%Ka5JU|YMn52yp3dt}XmMlX8nLEI$uu5^J6HR|O}t7Y0M5Tg5G>5RbA<1T zyhsY9VV_RbiSrJZXR0ObT1C)0rS&MIs-6#po@KTQT5(&L>Me)S2@=;$$<%!_mQ10E z@p)WffaJx-6+Bu_H1H5j@P#&N){2Izsk;_2izw)GE}KJb48|2eUN@IrXNS$e8=nBrfWL0A^JilnzAaIti#9yx7>_AH zFYBz*>k99LKE-A(cM%(svJ8ITfZqk$gttvrwPWQP6l^izvhiXmF4|^ih*yQj$$EB& z6+N={t=e2q3uU{k{BjlI;Eka$$Bxiv8J^Zz z&9=|s%YZF1<9KX;vuLJl9%hoYGcgt+NLXc~Q1*9Gi)rc3{Peb-U^JV5qrWcIJj0+;$A;^&IpiaxY?IhX95C z7|Pe#Df&4N)v>>6a+ zW*7Ea*sIu1ua6n~LilAHMive_9oKSU1U29*(1vphHr8pc=O2h%DNwMBLp`KSjQ9>4 zN?hpuU~g{aa?||GHO|c-4tS_F0_Hx@{pNs@VXI**KKrV-FCyd>i6w5x%gk#ZU#}0Vweoqzyrs8(Pg*;eZ(-CvZ_u;j z2F=;-@F$qr0;Ovt&`{DEptn#n^bt3kH$y%krIR}LN*p5!Kjl3 z0^WAEd@T{pInWDmFS0_MxVr)GMYR#!sU?N z+PHN@0IUz`p!+(^!~l(>(9ViZb1{-p#=EtDpsB!wCTRRekz|L>K!iz^XP$D!N4HTT z;wM)>7$Ns&!@H7Lsg6NVydQrP-=ihVEs%=c5S49ie^nrpRkxjbKvk6HO8io8n;pEN zOx~us^47Hpu*TH@7`Ur8H*g5l`pD8n-$q_dz%{I_#NPrU6(7&trUkJI=y*t6?yVhb z-lTHi%MYyMKBOzD5isc)3T$jXVQmKuq7bgZfq{Xnc|9^Y!)t#M+1agPy*T#?b^wPb zU>`r+9t$Pwsc4?pnO zyE;1bnzcefirdBbPG~YI$|PQeqA};s?4tin9DuBB{#u5!hdv2YX3>JU%t6I&pB&f> z4{dMxqlJ&CX&ITW8K9{?2%M72IaBtT zCk8L<<2}2v-)8r`ASR=B7I%VqKa4|cz4Y$;=Ms)$EL_nDTy8;0<3-;@ zrA(a)T0P4l@um;#*1(~>ZixRRRs7UW_-lV2a%v9f2*#)e&rAXy^UPa<0pd-K&U9!r9=OTV6}?4+ja>}@+TzYDi%tacyU4If z;!wPCwYi4rZ~xW*^|ycfAO7~g{trw4_<#O~fBg6VZs~9T{y+ZHzx_8$fBWzL_ka2~ z|A&!6Z;vS(_!vasDw>Wkop(j&!k#S~hkg%#r=!39>;Lkf{`LQ|^tb=%zy8Pn{y+Zh z-~Fe5`fvZAxH}Y^r)o@O`%&?SAr&uB3oU6}KaJzlmHYOKVF@x5>yk!r2xSID{Krt@ z)sKX#TZco9p`jZg{Q{D{2SQn3KP+8Qj&wBQ;^$<^g#|-9xDMG;PJd!#G2y_u zhqdATqM3s=2m;b>EDd6TFY_H0jWmiWK`ip+(_*4nf(gVRA}DxdrY+_%jCLCz0{b9m zY$FC_CnKKFrKgBD)Pexy<403K`J+e=tOi}=C3RImC5B7Ciy_a_4jO!&-{<7z{S8eB z&ndK?Scg2WA#)-Md?o5#%lTbt+O_Fv(fPbK>M_&QGBvFN073!vaoi3l{0ZMzNq(@x zuYWpBE2)i@pfO!WRm;b_dMFVCza;~5Eubs3&{tf0ex=7M{woP(;9Z2x)5=Ozh=FhA zGbv{~nHSf=@k$zgZAL{Jx9YW7^m(+}*Ncw(H6PTa`Du1OHz0z;_B>rsY}U z(by=eYqUeH-oR^jf1-N_F$ifM)a}WVNDsR7)(p{WcIZ;R7&YEd0~S+^UWNt6B5}bu zRlvs=nVaTcvQH1@74C~NLV+%lKe73BhtpD~TkFdxP&OU~-Y9LcmNd3)K~0J4%h(Ph z@4k_`(XO;IYWFi7tz9KQ7x9j+P@s~~w=x1J5JxEOVoDT*5D#XDqbF99D2J@-qc_C9 z=+P?GG5k=-E{7@!5W8)PJf`dit9O8Ih>cF~7HkF~riYUObX%4ZVCC==9 zIFbh~p{;wbix&6cBd?Fx0>2m~z8I-rOc~(QXV;HDm`rdC<;kQzWxcE!WZAfn%#5z} zvYew4xDQ#1a+{~eL&b7jYM}exY&|#QwqbxEub>`GL5Qw7?khZOCh=L`U0LaN)t_hi z8I009*BQU6QkfsYbD>C|puV19yY5At`h@*avgeqA!yF#5B@(|X`Z3S+zx|K@_<#SW zrN8|*|1&(r+!C6RBEJOOC1tds6eVI~0WX!Lup!u5d29%26l@V>dTwO-mxOE{*Y(`4js0%4wsvzcjy8r6`AetS+{V$jyZBiryScNulVZJ2 zM^uN`&Tc!!Xl3C2@L+R&a|?k1@-+-x85hh5{+B}br~hbv%e39$_kXpt1ky8UpN*xC zJF?q(DC+lo14s-!d3L8S9s}2Tl+UeRzu$Y2FA>;RY0Zo!0#+6P#Q&e!(y<;|l6%cD z^yAqhEC43K>8>JE5*kYuDxU)^egc*T_|H}0LZkllzDoCg#!@bug}%SDt}+p2`IkO@ z*mj2RpiEqz5x|&YTRxx-yqa3tSocSKQV6ltpxJg|MV5AX>6_c`KN?G$n^}B^g2(lx z5BRgRBqpnNgUp#rQ|uN+L(%aZ zAeZQAm0w)YQo?u!FJ1nd(}dN`ZSyXs0JP^G0FCzf_jrN;(Z43ZlFKdxix}#k05C7yAYUzAjEe3o-2<@Y$j;TolZE8 z>!E-q;Wh!0-yb*CoJv6TlvB3={2ZroVllUt0!+9>tAqgJ^ZlK1YgFg?kna%K>dD(cX zbC~SMcRa~9vC}KUf0f$R2|+-b9)94 zhnw*FcwRg@@}7?Hi(>J%Qmbam#UR|YwlA@u+P%EW?L+0!)7x8PNVfM2#{yZp`j-E?ThsEsXv3+lEUu3O|Qg(Z9x4gIY{(fO??F_d%xi8xX zz&mdgce`uDz3!lMd)x_vjg8%O+u1)m?%oyOOQ%KW`0()fpahdUKR&!UD4rF|XUB)l zV$nK-2LQz7-C21TAaaQhZ^h!~UG?s)_*Qh@?y9}w{@WWo>^uAW&fC*ldH<0f0Emx| zN6y~|9|hZWz`RcF!g)o-?j z*N=N^!_i&c8eRC+y{EhEd8cz9wEMfA!R~s$?+rVx)8XFJW_9a%|HK)UUaZ5MeX>#M z)DP;`&P8W*-?uls&lTsX_2P|2UpCUYlP^J#>zzBB&&AJK|GM2hPH*17*57v0et3A* z8y)TCE{AIeZZ6$!t-Yr2{o%omldf+&t?cIQ>3F#Pa6^9lO8dQ1@0Nnci~G~c`dw?izPI;y{n&mxKecM-osFH&&f|0Us(Ur&ev{t z_72XT9$UrB%`+!=esSwJ>hQ9< zw|Bk1=Dpna!adJQUyRNNqjoztINfmkZNGDU@ODt$F1Gfp$y- z`}WvvHJ?9^uFnsO_kORkx90A@cS^76u+=+y{=8NzzKsKKR2)1V=59XU)XMJpT6p)x z8`b=Jw{^Jx+TDk5_ltX{SUV~n4j!Asmlx|aXzuJ>Rd#MK);HGIU#ped&ArX$>(J>vy$)Zl z0F+0~Tzb3TzI|@m+x;{5@Ugmaba=CWQ8_Qwt*)C3?n|D#d45tn8C-6@JiRyVx6dcu z=&YQ(dA8eIS8qGRy*>A-_4e9cJL+CvUhdqyT#j8pjka^Y)7;tEzJD)W?XG7Z(>vW( z=cLs=-?^>5Zu%SP;mb*@bN1G@;B*dp=bP=yWpF?0H>>IFZuhk^ukM91Oo$Yt7 zZ`#lIx7(%QtaRdS?Vq~N%|Sm~Em_+^FzV%Q?=J`2!Q1w0f4%>@xpQ=oJ>EMV>>lpy z0WlbsZw6NReB<(JeSfdh_rGk0*EdJ^TfxC){jT+Ta&>OC-}_bTbLq)By{h+`gJJOM zcgLHZ&)&_o=dW#DZ0?_*^I-4!baZ(-xXxzxhF{wL zZR@saXS2hj_RH?o&Gr4~jo#DS$;PEsOFwqIxofvmJ8Rl6*S!lke($db_3hwzz2^=u zYg_G;jSX)ndtY1MUVq!^W;e%=O?&q&sGk)d-%he$Di=p#XIwei7`D^?==tp+ch~N2 zo}BmEms{7J`t8X$ICEazgW}ef+bXpxpKo*5r`GnUcyw9cJSiO>2YZ*>;l|tL#p8i< zwpo4czTaG54#Kk&K=a4-+V-|TDAm?(j!xg|n+K(n^Dl05cengp+}qxNsz0Aw*RA)1 zOXq>HfC&?H}1s_4oIk;-FJHDPG)kH@xGw(B9k+_jd-z&8@dr>$JA2$4CEIXI`8=IZb+PYZ3=%4MDowxlH>+I?C>!o{GJn8Jeq(49IUml-t zmJht=H!N$9)-TGf!O`O|eRgqlxmnsdKNw|id+X=tZm|rY-GAS^EI(qqx5IPq@vhmc z9yq7YR=Vx&_*Sv>dUgoi9IXRcyUw-l-->VVyQPit_4#|J*xu~D1h>b*<|wyu?WEIB zt?j#)LA(C;v@>{p+}YUZIOpNn_TBw^`STF=JM4tDbY*;Sw!h(5O5@D|Kfpl^6lC|C2aZWL%V4A zi`SRe&C9Lrj=j~sJsxb{Zf)MxFN(Fd5Qcxh+gmH0yZ~Ok+i6xitqr@mUwMAoaE>p+ z%DGdyzo_gS=c?6>?at%w+Te3-Z~JugTHXHAS=&3=JF7O=?ZYp{_np$#QS-jG;cdTV z*PLFn7+8;I_R*J%=aaYKdejQiuY>aEZuYeD>K@%+T-TlsUq*YoSAbrxYuViP#m%j~ zUAfxq^~$>k-J8np`R>8sHhq${>)lb?z5z16GZ=2H4bR$po_}+BcW`lN`+L>E-)fE1 z&*$TGxRYLc>0CVC+>S5akAm0g^~s6%?jKz^clEb=&2n~kD*ofs;IQTH z?9s)*Z;v)wo7tDjmix5bADj(NnyoMYZ+G9?+{Se!_+7uE+2Jq^sR;sn=m7@ef*>hE zB0&-WNl6^7iAMu$u?aN0@uEmndDcqG8+$fM#h%C>IpfqiJ4q^A>v23Y-YVzQ{*(D= zQZ@S%_MH3ZzWoA0Ntw)SKah>&J;ZdZpT#nR#~n*xcTF`mFRcKecNoJJ#xhxpMPPWp_8R0!;FLQQzp! zuIlAS%O^Kij_*8bu9cJ3dSSPdJ?z}*ZLA(WT$^6nXl^eb-P>3_(N{Bz<;H`9!>8zjnMdv%E8NYjrXCxPZL2 zS-n}kz5aN2dgakgbMA5R!L3T7l0RABSf9z3^yOQyj_*H9-k5G#x4JvYbs&P-%E@f5 z+B$i#y0L!JSe>mOm~DG?rLo-EDm*;y&(-_4o;_OGEj*k%spi+Fw~x*3m7Te{(;}2&Gzn{X8%szt{)%Vxc^}8 zM*cXlrO(dnc1w-y!}ZnUs602*xHWh0_(*HssXv@58_$y2#daQk)&U7OPrAAO z*37PcP@c==r>vO=!1CJ<=jIM}%2s!$baFehcxPjMCvopytyjMR0!z-^o~_@S17Yk| z=~?^EYP(l?@a&{%(Yu$aKhETnw`+~0b!WTTX&)3{b} zc5~yTlj$uzs6Sn5wHMcStM?15i(7L!V|#n2oLuZCHj?&nr+K(jG>mR-?lALkYiDsu z@6{JGtvU1Lc%{;sZq?>)RCkuQW;2aM_CcZf$lBFvJN2i=+VP^XT(_;_Qf7I#*}LDr zy?ktUwHwd2vR39sapQPusg+&I9BR8uTgx{d-kdp{o@w_V?r$vc-GLnc{4I?%8JdCIDV)-7n&g? zjdklmt8%lyTV8ok?%&ybmapA6>x)YVg=1swR`cfZ$@J0j%C6RVbZ4u)RByNKdLi3- zSWD&~Z4~FM=H~L^L1HoASxg+k!Lq*5I85fNmFb=P_bNMUxmv$zCZ~^&%c~o=Rv&Jy zZWmT>HXD!U3OmQO?Aqeu@$u7}(@(QK{jgbCysy=lYja!KrEGS4x3M#GP$=J6URh}@ zE-zOeu2&ZqpK9ewVQy}FcWwLGBR#ji)GM@BcOE45#}8V^tC{unwd14NJAmfnlfG8Z zEzM4^_p<*H&wYRXKD&vBj}P;Aa+T$K6?2wcKNPojHVO~svX64Pw5*zmv~sH38>ZGw zS!%19#rUjfPsJ6Fd$wvkNwAfoI?!07xL6p8zE2oYS;Ue20`B|s+rK|C5#2` zZ>5kvX)BfV-Adf6MUAQ1bj7P!m$uW&PP!6rqdP+zZH+a*q3q6Hf<&v>j-7Vl539FD zY-m=XJdM{oza_-|SE!xH7Fxb`jCO3GZjl(h85?cBF=^U18j|Ii|TaM3fqk|RN@DoKR^=M+g8nTKdNi@wppMBM-c2w5UH`TF>t-6-6 z<{Ip ze|YhWpEA|$tG{{w;>Z7Z{=xg_AAAwVOxjNP)rEujQ@gLU57g7@fo{}bB2(m%tu)1F zd$gQFrcr^|fXzJ9?lC zTiNwIlrKK}`uw*aU3~VfB-5sq&5j7db?Ea6?^Zc%aqN;=kXaaH8y6Ev4Uqy;FcgOz z$?F&iR!3Y*&@tnQJBX103KmYRG^RM22_2Qc= zg2*YCgK2ueY`h7WUMS^PA3Z<+)yr2OKbLUI|JOGcfB&vPPDUbFzXQ)~tC}jFovpXk zve8aCE5;c~Il)IlS5ld{z@=n*t9)H{hm%$Hn$y4}dG9t;g=P-gN5%?j=n7GOA33H3i&1xr1?c4B#vbWE#^mpv%hJqzPO0-A+o+|U;wx# zx)=#UWE(^XaM%W4^Q^WBwb_#IJ4>E5lZ=tFj5{2Kx*wpU!u}MdTWV^Cz0e^xY-!>Y z!5s=3;eHF$tB2Q|GCLI*0{S$uY9~T9$K39em>*Ylv)pXwYIe$s^e}Q8w)AcpLz3u@ zpHVk*DDo6akJLI(LsvZ-?j&_e2bQv2$BkYh!V`*lT%fd?X@~kiF_#UoE9OVy`a<=E z=BeQyH~Mp6QP~*LLQyr`F6~I9cn4=Vojq-#nIt!vKNX8%U=qTF3*y5tFA#gUF=SmC z^M!q=rWt_U1d06$;zFmYDJ1kEfKlHw_>L<26S?-pTJV(2hraA#e3IA)T}8uT5HGvba3ZNc4!+-gSLZXIbf`=wq3*@ZO@ z6K7qa3krWsqpEZJXFgZN_h?3&A*{kb;m9q}gxTbP{$`L|ED;okRmCaiN;>5`bHtg!?yC z-!=+_kBf*c#9>~7D5_j$KmOex{_(pnVd?+F^YiEEclX)F^MCLgk8>o@&3P0ooczhL zV82ZyfJ8Y89*`Xshe%cxy;-(Sm`5p-61W7ioFGBp$4~Ed0jRK|Q#FAg%U zTvpyCi*DH9V6wYD@W@8jr@KLbQTcbYuYPm>Pw&3^@I$u-9cm2Btq&(>sF5GDzUM!=ZSqa8VI%(?ZrJEG@g-T0#;M=ILcn;J`6C3MQ_lmB zS@*2jmjH6uYlW`J^)^F$XqPAl`F48e?z5& zOl)ecX29l^o2@&f!G4EhF~qplonL2G$#3Wt&09&wLVD#y*v$~R+J@ssjNHSFt82`M z&QPn~OCHhoYAK|24s)IcOZ%BZHjLrr2kA$6 z#ZXI&Qj7=aU4-ptVqz@2=e&XkPbMbv ziueE|0k?pfRr62WT%uIy@ZimK@zf_HEwYgn*k{SP+){VaiwwdO9qck(*u2fb?~u#i7_XdyocP)JYM7F6aF+{=yh54d>A&yA4V=VyYs>SwGQYJTFJ$ z?5v9cu650$2HIxF!pqb^(9&>CPZx9k1XVpCrxfX@cLjaVVIp`d z_%!5(-G3`6+|iRRhg|}01@%3TLX-f8A{LWUz><%HoKM zpOHWe==es1&!gk3djK@w1VA5AUcC3d=OSMY0VV2wnata(k6-Zn{+HjLfBwb!Z@(q*=JKT}~^KUMG^r1Umw~gS%_QmJ#Ui|XcJ`z>(!Yy?rcn$tBAmKgPqmH6Gz+vM5 z6Ia%!i6~dr2)~?gY5H=<);aGHBEjY8{v`Kxy&diAI>fnbM8DT?&z?|!SacJ5r*jo& z)d=4$zt7|-uA7%)-Suz18g?#qWF5(YZxp9i94MY7%5cgD3Mc=7Ca&P>g)^{P8_g~D zjc_e67E7hD5xugtAQNv^vuu&PY5Jx9v?J4Kly{xa-}Vwu*8RkQM^))Lj$`RIHfK1=k+Pk!~=>$jeqsv{akN^F+SXXLq;9|WojGiJE zFd?%<{MMYM9eL;mWFB}Vz6wf7BkYzu)XE@4_TZdgZki5$4PApvR&0NN{vE|?;wKgM zmV|-fc%TV_LckwuG7&1CMYFR=32k3p7ds0f&a3V>B+$bo%xRTu(LJkd5=I8pT{m!p zJ|I!r9q(uN1tvu@ZQb@!RU&@dnG<9ulMY9XPvZcd`3JzOIkiR^f>*eFJA(*=cKbDm z)_+sFudaBRp~x@1%+38RQ2Mlkm>_?;I<7jo@L`R@OpJuvU{^^(gfa80+Mk$4vWx^j-6qo-4<2QjgotZUJw zdn~$aLL{7^o;bNv$Ay*2*zELdB213IRM+E*7HsVcsw1H?DdVC0ky$#NSHd?5&WR?W zk#eWqBD<`3dEf|2V(^_I@pyQ6rpu!l?W?*FF^BWL875Y_{D-TroS-Iv9rox-+qge6 z4prYc<}?ddobZ}3Xj5VDd@@bXr@(R>mTx8!a!PJuq83ArlJvs#kh)C+5c^>ZoSZzz zK@UZFh1E+$N7C!ip4dHx7m1O0a}IAiwMa3OiFU`G5Tb*34wa2M*Oy1WB3rPNV;N2! zrm0!gvK@#w6(*nFWMWQm0%uLXHi3(l96xFdOKWB zb{R9j^K+QYw3nqMI2CTyLWM(4gI)=Z$HIFn!k>>V@s)pe_GjWzc*w}L2of1938A~t zrJZ$DTuYa?ad-FN?(QC}8<$|g8h3Yhch?Zy9fCUqcXxsWcPC`HGxN^6_s+cU%(uQe ztGfIAQ@?tucI|aepM7?b2EJ-K*0`GF-&{8lk_)v7d>rWO7+~CE($#4(xMq7ohZfV7 zI7QYHE8mOa;_>xd$FFS?>aAh$P#xj<{MKv2deRQc%#G26iR+t^$tLqm>y5V4kI8Ot zsZ+XNfNkTZvRF*CqJCsats&3VBy5f4*%p)a7ZH4#dX; z8L$SMQ!{9{msrf;R*MoXPxLxV!VMYjaF$@sWOX(O*G7dIQpm>F@y;xrgYegBYTRQO zp{O=K0dmNse*@m1Mer`ccdin%_54J;G7E=msu6bGRo9Ejd-*m)sXa|aJKG}oT>Zs$ z-;w-&z45bSgBQGXyCCU_)o~vADe}jOMXONHjZg2dD)8W3wEuc3#T?~=s1VzJ71NRK zxBI>xwn-aHFzS*_TD_H3mTG1@;aJ(2Jj*XCg1$^5WGMPTFSl9s(ehfO(!ig|9*wkgiX1)!%#(5y<%2gwp0y9lH z=$tHiBj!xN)*EW$ld_6+(i&BHl1=|Swp&zv#@}0n5}X*Ats7rS^y!!`7$ z7s0vntsy^wL~#Mjv0LC``&ZAB-hC0Vu-)E$lTvHAXOc*=F!1@rz%?kgbZ2#TN>^}) zKnBn3m^-i zVs|~z1q297V15;7kba{pgT`|g-Z1M7eboEf9(K_*%)nM`TQb~=N}ChTWm?qED-*}j zzwU9HX;8M~EWe@S1z`soz=dRROKG~@;KE(LTJ9r|hUL!$^Bn>W0376r{V<$_sD|*P zv{i8rhl5Bk@rOZydt6iz`_2xKqD!51mf1!GlDMMdbZWZ_5tP|5Fn8Y`_fe-resjUx zJzi|7sP291k6lTWzCa}S9%<@^X;DXj>x+B;17KegH$$$$nYsLr-21H|>6!Cso zp!tyB!0RpW9*Q_Scr&Hn`LJSQfJIG>rtXuz-q6ssrW0~&ie`qH>ZyXrUmErT5|`~M zhG+m+EwWOc(OV3^D7x4&dJXrCfk^ROi?bh!-<}$TW!YI6yU7!LK<4>b;=o872wgnl zevo>Zs>J4?->hxgDh8ZQ){LLpMBHgW9WWzDXX0g##oR{x5!o@o%4c5tyBA=zi1MNP zHA6&LSpXlQ?bXkBXU10q=}NLSFWjDj&awT$Ql(Tqct}tw&d6Em80sqiBskoS@iH3n zQX|Z)yRTbe9IuU?XziDcK2g@g<{y9PL@9RX7FLZWY{C`v-@PKP=MsM-@iI|GdX+@b z^S&1sCx3aR`fRE>25=*jz+7w;5Z^Jp75%s^d4C<)%4csu&h_k0Y*%v8<@vDN`TMwU z4H(K-4u?KE#3sJ-Z3>lAum_EQxHerrmInc-^+gd-*ird|p*fzap33?A(1IMyaFM{J z25RH6VbOy0Z}>+T<7wPxxa7-qvF++Mi4PE1KVlgN78PS!Mb2WPjL;*+h(;7L?wSE+ zu5jL=kJf_b6SG%cG+FFfiYYzA0*q16CXwPjdudz0c62bHPH;sq*5^7j7`54F`}b5P z2Y9eqL_GV7I1co8;VevNlLApGo7mCSJd_}ECcwQ}REl^X+&5H)3k3BP>HP87jT_?E zMv_ytDx>@#7;w?@De+m6G4r{VsL9wNQMJ)jUnV&e1*6heUG%~YIazBnU)Cec!Pj)3 zEdb`yNJjy?q<%+n-a(0EezEb zgG|XfrLqlhGt`=7g3IeZJ_4XB7_H}zjiTKXVcZ1gK3WHoyCPF~?Ugl@;|JV>e-BhU z*S<0Rw&-3-&W)06lErmf+UfOm<_o6PBav}J1U%s&3fDz&h7OTBjFzODb&|u{D8X}o zwQ^8fj&nbJoFr^jJGswFIUK zEtrC4eeg9un$#F*W5Ean%YVp5Y6A+Rd6}pQntn~RFlVLAoJ!oEo4?;rJO$cV+V*;J z;ev_kFCKjuK40wGLFXqy4v}1uyQmElcCd?Lec~G0OL>8!NY<)sT@s`Xi)vzxGLN2# zu)j#6aH^~x6zY{WDi5EuAq@?XGbcMh$CS!N8VzF3L)NyvJ-P%0NSFnKJA}|(1wjg& zp!gkUC0~iW6n-na4tgA2GpKlOYT``8i1Cj%2b z``xFj;41vvkGOAw$eRz{M-wMqo>;!%e$XzL*c%U$H{(OBpXHt70+tDCPj`AP zjuIGcvksu-k^zS=&lGEURy5mZJHP<-=YyHchJ@67z958(Ji+9Bjl};Xx&j zrz!23oHhVYOjlsBh6~nt5)*)i|5_)ayXQCN-J&1=yuvY#Og48F5L;bon{&Vxb>hb8 zX4XGhRtigQGP0Zw_vbr>C&NH{I zRzQT*)lEK4mr{hcl$!vZ`AflZOpsIh@e{)2hYKhw;@^uL+3R$lx5+Xzi{aJIIEcO- zalzdt;Qt~nyt{Plw-9bb!*s|I*C^H(Xa!O~DK2fqzfAwc9#?cIIM@%j~r48`PPb1=1w=H6iex zZ7eL8I0=4fnO^pT9w8b0OH&ILAxPin9_HTm6SlNeqfdJ|%cniQ?bWqQa^i>#{SBxW z%L9PK^*Ze2hkUvp_)*paL?DdhXtF$B1d zJ<*NCBkDaYca(6AyIa!s@NMl|!s4gE%g-N^Uoh>-U_Z2kv|@q`F1dz>beGn$)0w#p zIVo5-uVx4GTvX-PY*;l1Lt6DLFg+}Ay3BT_1kylt{-@2@8aR|ukLLtC0oTNg12-)K$m ziVSxxS$zb|fx_-Wg%n`xTYSp4#k6M8@T3TBg|m|su37C;NU2Sx19k6>JS+P3gDG~ zwly1r%|xzC-=1E4z)+N=PzNb`vkxdN%)klBx22X9+V=1+Xc!wzIikU#Zg2(Psx>EG z^_*rtYES^ZL9v!cdb4?aThTV^cYi?kwA0Qe4!^$1E)6L-qe1na)Wa4K@di|0yo!Q&?}^0dSwKs<^?6{O4jx>%_{Y*X`na68RwZH@@lAalM*GL33f;o;1djfm&K4Y z+Z^fV3XC+9XiF=Ejwf8q8IewM8CDt#9{kvTR+18$cFa1djD%l=5#=?sJ;EWT zA)e}RW8^um#9={bNhgQn&OMCm{G;Hg65wHOqne$rw7we{aJr&JEyXUrKkL6Go>^RH zkIvh}RDm^CB=1c}+Cbvw_f4j1Zhr_}C$7OR$fI#GMfYdrN0aV9$*zaJ(>`OZ|9~a z9T)eU)U2F>NYfFGh05Vbjbq^v_h1F5Wx7O*2XNWOG)=5(nqEQ!db zi~*<9R}PM<>kfD;GDCrP8$e!7e-j%7gIFMM?fV93w&j_6EG?+J(zV^{H8ek*c%8_e ziX;VH*xIo_v`4@zM8v5M@J8+YI8D5@3Gz_AGHJt*>E9xvK+Bc}&$e7eGc6B7$93sryJZV0E$RZt!!XJ9lBmdm9V_K$c+gIo1v*3@(9$H-CbXD+{- zLRV;JC{;Pxd;S#Q{WyR~sUQwsq25SRSrP+T6igyWg7iZJVVL>3&+F8ZHE9Hc>xmJ% zQ*9`jEyp7d2*mq}DT8zv30gy6b~lXcC`Js-Y^^07gUnXWLMn>sm#6^QxqvsPR7L)@ zIL3TD*jRJb!gLS=Ws+QnEc0yYm4j|eLluP}Di>k4BA2MMf1TN^9DJ>RL*!WcD(YIh z=ZmOeE8`xXTj4S?-;-#5C=gX-Erd&ooiho+)Q4^`99EC+oQ<>v=r_8m%lMN3!r+P{ zjX1IFF=6=I>+iRXsoz;vUha%tUK^7X62^)0z4m)9*+XQ@S?Qt!J+F-@&;!8~h*O_y z83Rh0Wsj}lXkhs(i5ZI8P;N#tdO~h(d+-_W zKC*Z@eVN7#4M6Ak+!7)z5__{opQjfy=jHHB-OGmbQFu_I>40JdQn!(V{NPPC&DFEl=H6E2$ph zwlq`*kF#xhP1>#%o2m6oW5&-(-;&voJVPuB_;I*}t8luTi$66{SGp4z>RNs(#wVe9^Fzo zlq`n0tja1f_OyFV3FdL8ZNc>^xt5c9con(*;b}}=WMz(&X zKRb}CAY7xe;8CSDzAjpKR9>YO-FQ{0PG0-oUxQtG_j5H!*H<i;{=zfFM8ld%6sAQyR-LpEmSECkM?|QX1SFUw-wVu9ePtL>I4;sO^Z~~a0 zJZ4*Cf-rmFYUot(tx``%pV|0RehLzr=@B}2^4537gKGw8e*+I6KK)4jc8z&X2k}9V zy>Mt>ij`uGg%eM8c7qsa|6Up74hI_uP#SQ8f7YhbRG)#zDlxzRnflfk5v!l(W>u%i z^5_{WH}t|~Ru_#d4eAvMa!SjUDPEtaN~1kw%%#BB)%AH&fcnk%FlT-b(SO^ zYbBJ1I{QG$TcGJZgGVD9ufR}Rpz&^~OiWjOe3FI4C zN#_!o5{mB8k#Az(#O5~^Yv>S;jaW>!-ea{jCI%a1nP@})OwMPj1P}=!y~#x+31Ftt zt|R>=N0(%!4!rfZb!(~9B@C(-$RvGPN)@l}JQf!wPobRjSUU!#mDVV$zvC+CxWnNG z%=Uy2Z2OpCji$8Z3fp1`WGbrNmE{U6Siegmv`x($rA>d{vVX}N6xr91CH0|*Ks=S4 zYRLg%g;_$$Y#MfHQnOepv0~si69U#t%k7`r{FZk+Ldm_E5#R#OKS36);xz+ejw;cO zRjU@C7v>m4($+jb!SOAA)5$3Hev z_GtF7)lR~~e0O9eEiETshtWGo;=#nsXkUa7R0MxzB^W1VBsq%?f!HMH-N&sQ0gguU z@m}<0_pr0g`ZNJbOU#QDdG3V2zp_8YFUU`bbD_Q#>x0~;m9p6StdKk@o<)wKP6y0d;kt;?>z*b1)*a#=^&Uvv>c zNQPg#Ov0xVC(ZRTsIyiz5s1}LN|FJ14M9r${UCkm@$0{Af&73i|Mh#ke;9{g z;#m=)9}EZ=Cq8CGaCj%Gsw*BF=VU^rnQ1m1q*&__cNk1mXOQTB}E?#Tf@D4LNcoxUxeuFcK#n9I>fWWP7lLFc3Y>|PTe!c&eBhPX4$cKAkD5DlV>Y}$A6#}g z=iKsiUfRXXEK)`WzKMSF(GyWolZ#vNrJl~50dAk%vdZ#NM&fUGpG)pN|u(pY_;s?!bs#jFzknq zc!=LsMqy_32wP~COZ9{ros9PRakE}8 zhxt6xZsmK3EZ9LtCd4Vxx2G7^S|c7~Z+I$u<`rXR9xL)Z!ju>zFis|6A_-$>tV0~8 z`aXVf#^5edhg}4Yc*&T8k1nbB=UGjG!Wxlgzyc6a3=WF~58qK(3VND8tOA8i#=18e zio|FYIGwrt#C0XfmPXH5A5IS-0FG8hd8Qqs=J;!xh**FsbX`LDu*F*{0d#gwuad55 zp4~!Y2z~n(8NPPZ-+j%b%`IS7y5aP@i&c%5G?Ig8%dKT#2K;CN*@163J&ev-TuBxZ zG;*KsY%R|-{LdU{C51uG-LTFMPeo2;hUcZHHj%+zSYtCQbe%*r^PTtcc z4@Lu-i$T>(0+(TO>~h_Lk8W6UdA^G+m%Oa=Nx>hom^taqTGg^~3PIq+!|K583Cdj^ z#a>jeCxFyq{7c4693kI+6*yu>HZpc_*=8i3rlwJmXDz$)L?=c{0lu7mwV-vqMG=J% zGa-)@loT{*xpkn65(5nv?~N23}yQqX$g27<}Jh0ER+^O6m3HOX1;u zc1LWa3X3Z2(-syu0awrw+8iH%dclCbim*=eW^PSqp8J|GkER+FE=4BCLfE)Xq5Nb2 zf?IsnrT437Wg_}!%xRx~*F1zWMZGi78+*AMnl#0H4~;I++7?MHdNc<OxKI zxVtfQv62tlKply}FBk#?Xy@eoMJ9y-{G+AwI9ht2f-vGvi?IIq`u9HA>5>x zM56s8hBzuj=>VMEZcdM3s}K23vqCI$XQ~UW8q-?hB9Ec2whi9ga#$5lGV-~2``QGB z2n`ZO!^h$m7u=WW6poI#u_d5$6B!o4XFA9IV2xJiE6JrcdS^obEe=;*ZMSl!YsrG? z&GFXK(5%H3Oa>l)w;Xvms zbhmFAv#~b9tDD^XY)QW)TCvXc_5j1s6BxQ33N{6Cw>-3(KeC>C;cx@T6h&TPt7wbN zx*TT}K8xEJ|C&Eo551wtZHczx3H5gxZ~Hr5y#}$d*RQs{u2fAQ{FkL{H*D2tY^IuB zDKZckcv66r;-gaJ!K3BB^X(lAJbBKDwkfQGq-+XH`XF3^X0dIbp;A)zw`(8eXek}Fcuw4`g_g`1fOj5YR~nSnr& zmyuR6E1QQtw9wc>WJT!&kSwp#@s@%RKg#S1gXYUXpPdKPIikV732`l`E-la#$({;E z_~Ch2-A@YG_sla^@+uV&5EYSi1fYdYR`eu~xEs}!WW)31fGTMbOD4eQY6*h?&9ys_ zxTpKAxH!>F1Q45I1d5gvJZBt(ily_)u!Y&F8JR%6IE-c(Yx@G-5=QquatVgDOx5%f zG2&1<4eA4QcwXgl5CqZrrZ|_-W|3Ijcppa)mO2FiUe)z7)u2DXK8k7m7)A~dWyaU! za5Ch|65}!F8xI6dF>CS|TPQ2c=r4^>3}*ICX?ARA_ckhxIwK(nU8Xul(ofc;*9`H` zUt+;Ax!5VYrVvl)h5yR5QYm?<>^=LEQ!!lHe9Q%`@$`kx3bw<21XO^UtwW+Jb#eAE!=wIk7NQ;7?m zEH^ild>FbJhYxNXG?VX~#U_T8PY3wq%oYUO-$2EpuYuDIS{%c$^;NVU1gn{S&GI&U z86HeqO@`puW1nrH{ny^Z3P}TVSBcSJ#7L^p%_G1`z_W?6PUknvXu|my8%>&YKVqiO zmwEdfw`H#;{jfozVi=y#CKdscx}O=I>N0FVy}l`1`SO9_i8E`jAb)9}CJ0=r!kBlp z>6Ux*6WD}}hm^!mc{dLqZ+_{)N(wu$p}WRh#ZaZ1WdSM$Ku)g z)xBUEMc|&YYZ-~SEQ@E^^yEwhJ7;J{DYiRJh`-o=1*%BLmkeM ztWk(r7q*z@IB)g6WsRgdt+mPiU9Z8=dxjve z_dM0c?Qn!Q8d>t+x-1_%E37&PHAJXDIMrIOibL2|tX}P+sP$U5A4Hv+k~@5LGDkL3 z6R+0X4ouAo*G@U;s4!I=T|7N)Ots~x-ArvwcE-lf+}YW@R5NAkrvzq!5O3Agm|A+6 zxf`wseJwfy9C3V{<1guN>jr$}`;vlmk{MW{S)tVyzbob%R*o!YAzu?OKL*}|>0g{u zvYZ)GcZ5+!-%cLiGQF}34R?);y0sWDGcsyoh$r3lGCSezbuZl(YcOSz^6to^#Bq&J zFbzwLn||utDfs+s1g0_Ya7JQp?JVrj2ju6JDOgkyDMn)E>(~>cz^1~bI6^Bok&y6_ zNuV8>VNHkWxwF>9#DrOk?@&k&F~9>*FCF>wJv_U>=dmfr%~3uV!=e(!qSLl9_9OLf zQPJQ8p2kp7!D^H&2GKc9&9D0?e6$fSFJyP1Ro}N|q)*A<{R!JpEg88vjE`Y0S-d%I zWLN!}c1TX-cge%l@AC&-mM&7U-tI>&K?S2CSVnAeG{-?QjzN^w>9( |HJx!8ETT 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 BOw#}W literal 0 HcmV?d00001 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..0f338e0b1 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + ${LOG_PATTERN} + + + + + + ${LOG_FILE_PATH}/${LOG_FILE_NAME}.log + + ${LOG_PATTERN} + + + ${LOG_FILE_PATH}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}.log + 30 + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 000000000..347b8c25e --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,111 @@ +-- 테이블 +-- User +CREATE TABLE users +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + username varchar(50) UNIQUE NOT NULL, + email varchar(100) UNIQUE NOT NULL, + password varchar(60) NOT NULL, + profile_id uuid, + role varchar(20) NOT NULL +); + +-- BinaryContent +CREATE TABLE binary_contents +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + file_name varchar(255) NOT NULL, + size bigint NOT NULL, + content_type varchar(100) NOT NULL +-- ,bytes bytea NOT NULL +); + + +-- Channel +CREATE TABLE channels +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + name varchar(100), + description varchar(500), + type varchar(10) NOT NULL +); + +-- Message +CREATE TABLE messages +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + content text, + channel_id uuid NOT NULL, + author_id uuid +); + +-- Message.attachments +CREATE TABLE message_attachments +( + message_id uuid, + attachment_id uuid, + PRIMARY KEY (message_id, attachment_id) +); + +-- ReadStatus +CREATE TABLE read_statuses +( + id uuid PRIMARY KEY, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, + user_id uuid NOT NULL, + channel_id uuid NOT NULL, + last_read_at timestamp with time zone NOT NULL, + UNIQUE (user_id, channel_id) +); + + +-- 제약 조건 +-- User (1) -> BinaryContent (1) +ALTER TABLE users + ADD CONSTRAINT fk_user_binary_content + FOREIGN KEY (profile_id) + REFERENCES binary_contents (id) + ON DELETE SET NULL; + +-- Message (N) -> Channel (1) +ALTER TABLE messages + ADD CONSTRAINT fk_message_channel + FOREIGN KEY (channel_id) + REFERENCES channels (id) + ON DELETE CASCADE; + +-- Message (N) -> Author (1) +ALTER TABLE messages + ADD CONSTRAINT fk_message_user + FOREIGN KEY (author_id) + REFERENCES users (id) + ON DELETE SET NULL; + +-- MessageAttachment (1) -> BinaryContent (1) +ALTER TABLE message_attachments + ADD CONSTRAINT fk_message_attachment_binary_content + FOREIGN KEY (attachment_id) + REFERENCES binary_contents (id) + ON DELETE CASCADE; + +-- ReadStatus (N) -> User (1) +ALTER TABLE read_statuses + ADD CONSTRAINT fk_read_status_user + FOREIGN KEY (user_id) + REFERENCES users (id) + ON DELETE CASCADE; + +-- ReadStatus (N) -> User (1) +ALTER TABLE read_statuses + ADD CONSTRAINT fk_read_status_channel + FOREIGN KEY (channel_id) + REFERENCES channels (id) + ON DELETE CASCADE; \ No newline at end of file diff --git a/src/main/resources/static/assets/index-COLcXNzv.js b/src/main/resources/static/assets/index-COLcXNzv.js new file mode 100644 index 000000000..af587fd74 --- /dev/null +++ b/src/main/resources/static/assets/index-COLcXNzv.js @@ -0,0 +1,1338 @@ +var Cg=Object.defineProperty;var Eg=(r,i,s)=>i in r?Cg(r,i,{enumerable:!0,configurable:!0,writable:!0,value:s}):r[i]=s;var uf=(r,i,s)=>Eg(r,typeof i!="symbol"?i+"":i,s);(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))l(c);new MutationObserver(c=>{for(const d of c)if(d.type==="childList")for(const p of d.addedNodes)p.tagName==="LINK"&&p.rel==="modulepreload"&&l(p)}).observe(document,{childList:!0,subtree:!0});function s(c){const d={};return c.integrity&&(d.integrity=c.integrity),c.referrerPolicy&&(d.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?d.credentials="include":c.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function l(c){if(c.ep)return;c.ep=!0;const d=s(c);fetch(c.href,d)}})();function jg(r){return r&&r.__esModule&&Object.prototype.hasOwnProperty.call(r,"default")?r.default:r}var Ca={exports:{}},xo={},Ea={exports:{}},pe={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var cf;function Ag(){if(cf)return pe;cf=1;var r=Symbol.for("react.element"),i=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),l=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),d=Symbol.for("react.provider"),p=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),w=Symbol.for("react.suspense"),v=Symbol.for("react.memo"),S=Symbol.for("react.lazy"),j=Symbol.iterator;function R(k){return k===null||typeof k!="object"?null:(k=j&&k[j]||k["@@iterator"],typeof k=="function"?k:null)}var L={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},T=Object.assign,N={};function _(k,D,ae){this.props=k,this.context=D,this.refs=N,this.updater=ae||L}_.prototype.isReactComponent={},_.prototype.setState=function(k,D){if(typeof k!="object"&&typeof k!="function"&&k!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,k,D,"setState")},_.prototype.forceUpdate=function(k){this.updater.enqueueForceUpdate(this,k,"forceUpdate")};function V(){}V.prototype=_.prototype;function U(k,D,ae){this.props=k,this.context=D,this.refs=N,this.updater=ae||L}var B=U.prototype=new V;B.constructor=U,T(B,_.prototype),B.isPureReactComponent=!0;var W=Array.isArray,I=Object.prototype.hasOwnProperty,M={current:null},H={key:!0,ref:!0,__self:!0,__source:!0};function ie(k,D,ae){var ce,he={},fe=null,ke=null;if(D!=null)for(ce in D.ref!==void 0&&(ke=D.ref),D.key!==void 0&&(fe=""+D.key),D)I.call(D,ce)&&!H.hasOwnProperty(ce)&&(he[ce]=D[ce]);var ye=arguments.length-2;if(ye===1)he.children=ae;else if(1>>1,D=q[k];if(0>>1;kc(he,Q))fec(ke,he)?(q[k]=ke,q[fe]=Q,k=fe):(q[k]=he,q[ce]=Q,k=ce);else if(fec(ke,Q))q[k]=ke,q[fe]=Q,k=fe;else break e}}return ee}function c(q,ee){var Q=q.sortIndex-ee.sortIndex;return Q!==0?Q:q.id-ee.id}if(typeof performance=="object"&&typeof performance.now=="function"){var d=performance;r.unstable_now=function(){return d.now()}}else{var p=Date,m=p.now();r.unstable_now=function(){return p.now()-m}}var w=[],v=[],S=1,j=null,R=3,L=!1,T=!1,N=!1,_=typeof setTimeout=="function"?setTimeout:null,V=typeof clearTimeout=="function"?clearTimeout:null,U=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function B(q){for(var ee=s(v);ee!==null;){if(ee.callback===null)l(v);else if(ee.startTime<=q)l(v),ee.sortIndex=ee.expirationTime,i(w,ee);else break;ee=s(v)}}function W(q){if(N=!1,B(q),!T)if(s(w)!==null)T=!0,ge(I);else{var ee=s(v);ee!==null&&Ee(W,ee.startTime-q)}}function I(q,ee){T=!1,N&&(N=!1,V(ie),ie=-1),L=!0;var Q=R;try{for(B(ee),j=s(w);j!==null&&(!(j.expirationTime>ee)||q&&!ot());){var k=j.callback;if(typeof k=="function"){j.callback=null,R=j.priorityLevel;var D=k(j.expirationTime<=ee);ee=r.unstable_now(),typeof D=="function"?j.callback=D:j===s(w)&&l(w),B(ee)}else l(w);j=s(w)}if(j!==null)var ae=!0;else{var ce=s(v);ce!==null&&Ee(W,ce.startTime-ee),ae=!1}return ae}finally{j=null,R=Q,L=!1}}var M=!1,H=null,ie=-1,ve=5,Oe=-1;function ot(){return!(r.unstable_now()-Oeq||125k?(q.sortIndex=Q,i(v,q),s(w)===null&&q===s(v)&&(N?(V(ie),ie=-1):N=!0,Ee(W,Q-k))):(q.sortIndex=D,i(w,q),T||L||(T=!0,ge(I))),q},r.unstable_shouldYield=ot,r.unstable_wrapCallback=function(q){var ee=R;return function(){var Q=R;R=ee;try{return q.apply(this,arguments)}finally{R=Q}}}}(Ra)),Ra}var mf;function _g(){return mf||(mf=1,Aa.exports=Tg()),Aa.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var gf;function Ng(){if(gf)return ft;gf=1;var r=ou(),i=_g();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),w=Object.prototype.hasOwnProperty,v=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,S={},j={};function R(e){return w.call(j,e)?!0:w.call(S,e)?!1:v.test(e)?j[e]=!0:(S[e]=!0,!1)}function L(e,t,n,o){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return o?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function T(e,t,n,o){if(t===null||typeof t>"u"||L(e,t,n,o))return!0;if(o)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function N(e,t,n,o,a,u,f){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=o,this.attributeNamespace=a,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=f}var _={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){_[e]=new N(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];_[t]=new N(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){_[e]=new N(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){_[e]=new N(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){_[e]=new N(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){_[e]=new N(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){_[e]=new N(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){_[e]=new N(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){_[e]=new N(e,5,!1,e.toLowerCase(),null,!1,!1)});var V=/[\-:]([a-z])/g;function U(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(V,U);_[t]=new N(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(V,U);_[t]=new N(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(V,U);_[t]=new N(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){_[e]=new N(e,1,!1,e.toLowerCase(),null,!1,!1)}),_.xlinkHref=new N("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){_[e]=new N(e,1,!1,e.toLowerCase(),null,!0,!0)});function B(e,t,n,o){var a=_.hasOwnProperty(t)?_[t]:null;(a!==null?a.type!==0:o||!(2g||a[f]!==u[g]){var y=` +`+a[f].replace(" at new "," at ");return e.displayName&&y.includes("")&&(y=y.replace("",e.displayName)),y}while(1<=f&&0<=g);break}}}finally{ae=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?D(e):""}function he(e){switch(e.tag){case 5:return D(e.type);case 16:return D("Lazy");case 13:return D("Suspense");case 19:return D("SuspenseList");case 0:case 2:case 15:return e=ce(e.type,!1),e;case 11:return e=ce(e.type.render,!1),e;case 1:return e=ce(e.type,!0),e;default:return""}}function fe(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case H:return"Fragment";case M:return"Portal";case ve:return"Profiler";case ie:return"StrictMode";case le:return"Suspense";case me:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case ot:return(e.displayName||"Context")+".Consumer";case Oe:return(e._context.displayName||"Context")+".Provider";case ne:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Re:return t=e.displayName||null,t!==null?t:fe(e.type)||"Memo";case ge:t=e._payload,e=e._init;try{return fe(e(t))}catch{}}return null}function ke(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return fe(t);case 8:return t===ie?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ye(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function we(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Qe(e){var t=we(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),o=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var a=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return a.call(this)},set:function(f){o=""+f,u.call(this,f)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return o},setValue:function(f){o=""+f},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function qt(e){e._valueTracker||(e._valueTracker=Qe(e))}function Tt(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),o="";return e&&(o=we(e)?e.checked?"true":"false":e.value),e=o,e!==n?(t.setValue(e),!0):!1}function Bo(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function _s(e,t){var n=t.checked;return Q({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function mu(e,t){var n=t.defaultValue==null?"":t.defaultValue,o=t.checked!=null?t.checked:t.defaultChecked;n=ye(t.value!=null?t.value:n),e._wrapperState={initialChecked:o,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function gu(e,t){t=t.checked,t!=null&&B(e,"checked",t,!1)}function Ns(e,t){gu(e,t);var n=ye(t.value),o=t.type;if(n!=null)o==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(o==="submit"||o==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Os(e,t.type,n):t.hasOwnProperty("defaultValue")&&Os(e,t.type,ye(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function yu(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var o=t.type;if(!(o!=="submit"&&o!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Os(e,t,n){(t!=="number"||Bo(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Mr=Array.isArray;function Qn(e,t,n,o){if(e=e.options,t){t={};for(var a=0;a"+t.valueOf().toString()+"",t=Fo.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Lr(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Ir={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Ph=["Webkit","ms","Moz","O"];Object.keys(Ir).forEach(function(e){Ph.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Ir[t]=Ir[e]})});function Cu(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Ir.hasOwnProperty(e)&&Ir[e]?(""+t).trim():t+"px"}function Eu(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var o=n.indexOf("--")===0,a=Cu(n,t[n],o);n==="float"&&(n="cssFloat"),o?e.setProperty(n,a):e[n]=a}}var Th=Q({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Is(e,t){if(t){if(Th[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(s(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(s(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(s(61))}if(t.style!=null&&typeof t.style!="object")throw Error(s(62))}}function Ds(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var zs=null;function $s(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Bs=null,Gn=null,Kn=null;function ju(e){if(e=ro(e)){if(typeof Bs!="function")throw Error(s(280));var t=e.stateNode;t&&(t=ui(t),Bs(e.stateNode,e.type,t))}}function Au(e){Gn?Kn?Kn.push(e):Kn=[e]:Gn=e}function Ru(){if(Gn){var e=Gn,t=Kn;if(Kn=Gn=null,ju(e),t)for(e=0;e>>=0,e===0?32:31-(Fh(e)/bh|0)|0}var Wo=64,qo=4194304;function Br(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Yo(e,t){var n=e.pendingLanes;if(n===0)return 0;var o=0,a=e.suspendedLanes,u=e.pingedLanes,f=n&268435455;if(f!==0){var g=f&~a;g!==0?o=Br(g):(u&=f,u!==0&&(o=Br(u)))}else f=n&~a,f!==0?o=Br(f):u!==0&&(o=Br(u));if(o===0)return 0;if(t!==0&&t!==o&&!(t&a)&&(a=o&-o,u=t&-t,a>=u||a===16&&(u&4194240)!==0))return t;if(o&4&&(o|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=o;0n;n++)t.push(e);return t}function Fr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-_t(t),e[t]=n}function Wh(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var o=e.eventTimes;for(e=e.expirationTimes;0=Qr),tc=" ",nc=!1;function rc(e,t){switch(e){case"keyup":return xm.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function oc(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Zn=!1;function Sm(e,t){switch(e){case"compositionend":return oc(t);case"keypress":return t.which!==32?null:(nc=!0,tc);case"textInput":return e=t.data,e===tc&&nc?null:e;default:return null}}function km(e,t){if(Zn)return e==="compositionend"||!rl&&rc(e,t)?(e=Gu(),Jo=Xs=an=null,Zn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=o}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=dc(n)}}function pc(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?pc(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function hc(){for(var e=window,t=Bo();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Bo(e.document)}return t}function sl(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Nm(e){var t=hc(),n=e.focusedElem,o=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&pc(n.ownerDocument.documentElement,n)){if(o!==null&&sl(n)){if(t=o.start,e=o.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var a=n.textContent.length,u=Math.min(o.start,a);o=o.end===void 0?u:Math.min(o.end,a),!e.extend&&u>o&&(a=o,o=u,u=a),a=fc(n,u);var f=fc(n,o);a&&f&&(e.rangeCount!==1||e.anchorNode!==a.node||e.anchorOffset!==a.offset||e.focusNode!==f.node||e.focusOffset!==f.offset)&&(t=t.createRange(),t.setStart(a.node,a.offset),e.removeAllRanges(),u>o?(e.addRange(t),e.extend(f.node,f.offset)):(t.setEnd(f.node,f.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,er=null,ll=null,Jr=null,al=!1;function mc(e,t,n){var o=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;al||er==null||er!==Bo(o)||(o=er,"selectionStart"in o&&sl(o)?o={start:o.selectionStart,end:o.selectionEnd}:(o=(o.ownerDocument&&o.ownerDocument.defaultView||window).getSelection(),o={anchorNode:o.anchorNode,anchorOffset:o.anchorOffset,focusNode:o.focusNode,focusOffset:o.focusOffset}),Jr&&Xr(Jr,o)||(Jr=o,o=si(ll,"onSelect"),0ir||(e.current=wl[ir],wl[ir]=null,ir--)}function Ae(e,t){ir++,wl[ir]=e.current,e.current=t}var fn={},Xe=dn(fn),lt=dn(!1),Tn=fn;function sr(e,t){var n=e.type.contextTypes;if(!n)return fn;var o=e.stateNode;if(o&&o.__reactInternalMemoizedUnmaskedChildContext===t)return o.__reactInternalMemoizedMaskedChildContext;var a={},u;for(u in n)a[u]=t[u];return o&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=a),a}function at(e){return e=e.childContextTypes,e!=null}function ci(){Te(lt),Te(Xe)}function _c(e,t,n){if(Xe.current!==fn)throw Error(s(168));Ae(Xe,t),Ae(lt,n)}function Nc(e,t,n){var o=e.stateNode;if(t=t.childContextTypes,typeof o.getChildContext!="function")return n;o=o.getChildContext();for(var a in o)if(!(a in t))throw Error(s(108,ke(e)||"Unknown",a));return Q({},n,o)}function di(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||fn,Tn=Xe.current,Ae(Xe,e),Ae(lt,lt.current),!0}function Oc(e,t,n){var o=e.stateNode;if(!o)throw Error(s(169));n?(e=Nc(e,t,Tn),o.__reactInternalMemoizedMergedChildContext=e,Te(lt),Te(Xe),Ae(Xe,e)):Te(lt),Ae(lt,n)}var Qt=null,fi=!1,Sl=!1;function Mc(e){Qt===null?Qt=[e]:Qt.push(e)}function Hm(e){fi=!0,Mc(e)}function pn(){if(!Sl&&Qt!==null){Sl=!0;var e=0,t=je;try{var n=Qt;for(je=1;e>=f,a-=f,Gt=1<<32-_t(t)+a|n<se?(qe=oe,oe=null):qe=oe.sibling;var Se=z(E,oe,A[se],b);if(Se===null){oe===null&&(oe=qe);break}e&&oe&&Se.alternate===null&&t(E,oe),x=u(Se,x,se),re===null?te=Se:re.sibling=Se,re=Se,oe=qe}if(se===A.length)return n(E,oe),Ne&&Nn(E,se),te;if(oe===null){for(;sese?(qe=oe,oe=null):qe=oe.sibling;var kn=z(E,oe,Se.value,b);if(kn===null){oe===null&&(oe=qe);break}e&&oe&&kn.alternate===null&&t(E,oe),x=u(kn,x,se),re===null?te=kn:re.sibling=kn,re=kn,oe=qe}if(Se.done)return n(E,oe),Ne&&Nn(E,se),te;if(oe===null){for(;!Se.done;se++,Se=A.next())Se=F(E,Se.value,b),Se!==null&&(x=u(Se,x,se),re===null?te=Se:re.sibling=Se,re=Se);return Ne&&Nn(E,se),te}for(oe=o(E,oe);!Se.done;se++,Se=A.next())Se=G(oe,E,se,Se.value,b),Se!==null&&(e&&Se.alternate!==null&&oe.delete(Se.key===null?se:Se.key),x=u(Se,x,se),re===null?te=Se:re.sibling=Se,re=Se);return e&&oe.forEach(function(kg){return t(E,kg)}),Ne&&Nn(E,se),te}function ze(E,x,A,b){if(typeof A=="object"&&A!==null&&A.type===H&&A.key===null&&(A=A.props.children),typeof A=="object"&&A!==null){switch(A.$$typeof){case I:e:{for(var te=A.key,re=x;re!==null;){if(re.key===te){if(te=A.type,te===H){if(re.tag===7){n(E,re.sibling),x=a(re,A.props.children),x.return=E,E=x;break e}}else if(re.elementType===te||typeof te=="object"&&te!==null&&te.$$typeof===ge&&Bc(te)===re.type){n(E,re.sibling),x=a(re,A.props),x.ref=oo(E,re,A),x.return=E,E=x;break e}n(E,re);break}else t(E,re);re=re.sibling}A.type===H?(x=Bn(A.props.children,E.mode,b,A.key),x.return=E,E=x):(b=Fi(A.type,A.key,A.props,null,E.mode,b),b.ref=oo(E,x,A),b.return=E,E=b)}return f(E);case M:e:{for(re=A.key;x!==null;){if(x.key===re)if(x.tag===4&&x.stateNode.containerInfo===A.containerInfo&&x.stateNode.implementation===A.implementation){n(E,x.sibling),x=a(x,A.children||[]),x.return=E,E=x;break e}else{n(E,x);break}else t(E,x);x=x.sibling}x=va(A,E.mode,b),x.return=E,E=x}return f(E);case ge:return re=A._init,ze(E,x,re(A._payload),b)}if(Mr(A))return J(E,x,A,b);if(ee(A))return Z(E,x,A,b);gi(E,A)}return typeof A=="string"&&A!==""||typeof A=="number"?(A=""+A,x!==null&&x.tag===6?(n(E,x.sibling),x=a(x,A),x.return=E,E=x):(n(E,x),x=ya(A,E.mode,b),x.return=E,E=x),f(E)):n(E,x)}return ze}var cr=Fc(!0),bc=Fc(!1),yi=dn(null),vi=null,dr=null,Rl=null;function Pl(){Rl=dr=vi=null}function Tl(e){var t=yi.current;Te(yi),e._currentValue=t}function _l(e,t,n){for(;e!==null;){var o=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,o!==null&&(o.childLanes|=t)):o!==null&&(o.childLanes&t)!==t&&(o.childLanes|=t),e===n)break;e=e.return}}function fr(e,t){vi=e,Rl=dr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(ut=!0),e.firstContext=null)}function Et(e){var t=e._currentValue;if(Rl!==e)if(e={context:e,memoizedValue:t,next:null},dr===null){if(vi===null)throw Error(s(308));dr=e,vi.dependencies={lanes:0,firstContext:e}}else dr=dr.next=e;return t}var On=null;function Nl(e){On===null?On=[e]:On.push(e)}function Uc(e,t,n,o){var a=t.interleaved;return a===null?(n.next=n,Nl(t)):(n.next=a.next,a.next=n),t.interleaved=n,Xt(e,o)}function Xt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var hn=!1;function Ol(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Hc(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Jt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function mn(e,t,n){var o=e.updateQueue;if(o===null)return null;if(o=o.shared,xe&2){var a=o.pending;return a===null?t.next=t:(t.next=a.next,a.next=t),o.pending=t,Xt(e,n)}return a=o.interleaved,a===null?(t.next=t,Nl(o)):(t.next=a.next,a.next=t),o.interleaved=t,Xt(e,n)}function xi(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var o=t.lanes;o&=e.pendingLanes,n|=o,t.lanes=n,qs(e,n)}}function Vc(e,t){var n=e.updateQueue,o=e.alternate;if(o!==null&&(o=o.updateQueue,n===o)){var a=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var f={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?a=u=f:u=u.next=f,n=n.next}while(n!==null);u===null?a=u=t:u=u.next=t}else a=u=t;n={baseState:o.baseState,firstBaseUpdate:a,lastBaseUpdate:u,shared:o.shared,effects:o.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function wi(e,t,n,o){var a=e.updateQueue;hn=!1;var u=a.firstBaseUpdate,f=a.lastBaseUpdate,g=a.shared.pending;if(g!==null){a.shared.pending=null;var y=g,P=y.next;y.next=null,f===null?u=P:f.next=P,f=y;var $=e.alternate;$!==null&&($=$.updateQueue,g=$.lastBaseUpdate,g!==f&&(g===null?$.firstBaseUpdate=P:g.next=P,$.lastBaseUpdate=y))}if(u!==null){var F=a.baseState;f=0,$=P=y=null,g=u;do{var z=g.lane,G=g.eventTime;if((o&z)===z){$!==null&&($=$.next={eventTime:G,lane:0,tag:g.tag,payload:g.payload,callback:g.callback,next:null});e:{var J=e,Z=g;switch(z=t,G=n,Z.tag){case 1:if(J=Z.payload,typeof J=="function"){F=J.call(G,F,z);break e}F=J;break e;case 3:J.flags=J.flags&-65537|128;case 0:if(J=Z.payload,z=typeof J=="function"?J.call(G,F,z):J,z==null)break e;F=Q({},F,z);break e;case 2:hn=!0}}g.callback!==null&&g.lane!==0&&(e.flags|=64,z=a.effects,z===null?a.effects=[g]:z.push(g))}else G={eventTime:G,lane:z,tag:g.tag,payload:g.payload,callback:g.callback,next:null},$===null?(P=$=G,y=F):$=$.next=G,f|=z;if(g=g.next,g===null){if(g=a.shared.pending,g===null)break;z=g,g=z.next,z.next=null,a.lastBaseUpdate=z,a.shared.pending=null}}while(!0);if($===null&&(y=F),a.baseState=y,a.firstBaseUpdate=P,a.lastBaseUpdate=$,t=a.shared.interleaved,t!==null){a=t;do f|=a.lane,a=a.next;while(a!==t)}else u===null&&(a.shared.lanes=0);In|=f,e.lanes=f,e.memoizedState=F}}function Wc(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var o=zl.transition;zl.transition={};try{e(!1),t()}finally{je=n,zl.transition=o}}function cd(){return jt().memoizedState}function Ym(e,t,n){var o=xn(e);if(n={lane:o,action:n,hasEagerState:!1,eagerState:null,next:null},dd(e))fd(t,n);else if(n=Uc(e,t,n,o),n!==null){var a=st();Dt(n,e,o,a),pd(n,t,o)}}function Qm(e,t,n){var o=xn(e),a={lane:o,action:n,hasEagerState:!1,eagerState:null,next:null};if(dd(e))fd(t,a);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var f=t.lastRenderedState,g=u(f,n);if(a.hasEagerState=!0,a.eagerState=g,Nt(g,f)){var y=t.interleaved;y===null?(a.next=a,Nl(t)):(a.next=y.next,y.next=a),t.interleaved=a;return}}catch{}finally{}n=Uc(e,t,a,o),n!==null&&(a=st(),Dt(n,e,o,a),pd(n,t,o))}}function dd(e){var t=e.alternate;return e===Le||t!==null&&t===Le}function fd(e,t){ao=Ci=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function pd(e,t,n){if(n&4194240){var o=t.lanes;o&=e.pendingLanes,n|=o,t.lanes=n,qs(e,n)}}var Ai={readContext:Et,useCallback:Je,useContext:Je,useEffect:Je,useImperativeHandle:Je,useInsertionEffect:Je,useLayoutEffect:Je,useMemo:Je,useReducer:Je,useRef:Je,useState:Je,useDebugValue:Je,useDeferredValue:Je,useTransition:Je,useMutableSource:Je,useSyncExternalStore:Je,useId:Je,unstable_isNewReconciler:!1},Gm={readContext:Et,useCallback:function(e,t){return Ut().memoizedState=[e,t===void 0?null:t],e},useContext:Et,useEffect:nd,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Ei(4194308,4,id.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ei(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ei(4,2,e,t)},useMemo:function(e,t){var n=Ut();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var o=Ut();return t=n!==void 0?n(t):t,o.memoizedState=o.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},o.queue=e,e=e.dispatch=Ym.bind(null,Le,e),[o.memoizedState,e]},useRef:function(e){var t=Ut();return e={current:e},t.memoizedState=e},useState:ed,useDebugValue:Vl,useDeferredValue:function(e){return Ut().memoizedState=e},useTransition:function(){var e=ed(!1),t=e[0];return e=qm.bind(null,e[1]),Ut().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var o=Le,a=Ut();if(Ne){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),We===null)throw Error(s(349));Ln&30||Gc(o,t,n)}a.memoizedState=n;var u={value:n,getSnapshot:t};return a.queue=u,nd(Xc.bind(null,o,u,e),[e]),o.flags|=2048,fo(9,Kc.bind(null,o,u,n,t),void 0,null),n},useId:function(){var e=Ut(),t=We.identifierPrefix;if(Ne){var n=Kt,o=Gt;n=(o&~(1<<32-_t(o)-1)).toString(32)+n,t=":"+t+"R"+n,n=uo++,0<\/script>",e=e.removeChild(e.firstChild)):typeof o.is=="string"?e=f.createElement(n,{is:o.is}):(e=f.createElement(n),n==="select"&&(f=e,o.multiple?f.multiple=!0:o.size&&(f.size=o.size))):e=f.createElementNS(e,n),e[Ft]=t,e[no]=o,Md(e,t,!1,!1),t.stateNode=e;e:{switch(f=Ds(n,o),n){case"dialog":Pe("cancel",e),Pe("close",e),a=o;break;case"iframe":case"object":case"embed":Pe("load",e),a=o;break;case"video":case"audio":for(a=0;ayr&&(t.flags|=128,o=!0,po(u,!1),t.lanes=4194304)}else{if(!o)if(e=Si(f),e!==null){if(t.flags|=128,o=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),po(u,!0),u.tail===null&&u.tailMode==="hidden"&&!f.alternate&&!Ne)return Ze(t),null}else 2*De()-u.renderingStartTime>yr&&n!==1073741824&&(t.flags|=128,o=!0,po(u,!1),t.lanes=4194304);u.isBackwards?(f.sibling=t.child,t.child=f):(n=u.last,n!==null?n.sibling=f:t.child=f,u.last=f)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=De(),t.sibling=null,n=Me.current,Ae(Me,o?n&1|2:n&1),t):(Ze(t),null);case 22:case 23:return ha(),o=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==o&&(t.flags|=8192),o&&t.mode&1?yt&1073741824&&(Ze(t),t.subtreeFlags&6&&(t.flags|=8192)):Ze(t),null;case 24:return null;case 25:return null}throw Error(s(156,t.tag))}function rg(e,t){switch(Cl(t),t.tag){case 1:return at(t.type)&&ci(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return pr(),Te(lt),Te(Xe),Dl(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Ll(t),null;case 13:if(Te(Me),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));ur()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Te(Me),null;case 4:return pr(),null;case 10:return Tl(t.type._context),null;case 22:case 23:return ha(),null;case 24:return null;default:return null}}var _i=!1,et=!1,og=typeof WeakSet=="function"?WeakSet:Set,X=null;function mr(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(o){Ie(e,t,o)}else n.current=null}function na(e,t,n){try{n()}catch(o){Ie(e,t,o)}}var Dd=!1;function ig(e,t){if(hl=Ko,e=hc(),sl(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var o=n.getSelection&&n.getSelection();if(o&&o.rangeCount!==0){n=o.anchorNode;var a=o.anchorOffset,u=o.focusNode;o=o.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var f=0,g=-1,y=-1,P=0,$=0,F=e,z=null;t:for(;;){for(var G;F!==n||a!==0&&F.nodeType!==3||(g=f+a),F!==u||o!==0&&F.nodeType!==3||(y=f+o),F.nodeType===3&&(f+=F.nodeValue.length),(G=F.firstChild)!==null;)z=F,F=G;for(;;){if(F===e)break t;if(z===n&&++P===a&&(g=f),z===u&&++$===o&&(y=f),(G=F.nextSibling)!==null)break;F=z,z=F.parentNode}F=G}n=g===-1||y===-1?null:{start:g,end:y}}else n=null}n=n||{start:0,end:0}}else n=null;for(ml={focusedElem:e,selectionRange:n},Ko=!1,X=t;X!==null;)if(t=X,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,X=e;else for(;X!==null;){t=X;try{var J=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(J!==null){var Z=J.memoizedProps,ze=J.memoizedState,E=t.stateNode,x=E.getSnapshotBeforeUpdate(t.elementType===t.type?Z:Mt(t.type,Z),ze);E.__reactInternalSnapshotBeforeUpdate=x}break;case 3:var A=t.stateNode.containerInfo;A.nodeType===1?A.textContent="":A.nodeType===9&&A.documentElement&&A.removeChild(A.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(s(163))}}catch(b){Ie(t,t.return,b)}if(e=t.sibling,e!==null){e.return=t.return,X=e;break}X=t.return}return J=Dd,Dd=!1,J}function ho(e,t,n){var o=t.updateQueue;if(o=o!==null?o.lastEffect:null,o!==null){var a=o=o.next;do{if((a.tag&e)===e){var u=a.destroy;a.destroy=void 0,u!==void 0&&na(t,n,u)}a=a.next}while(a!==o)}}function Ni(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var o=n.create;n.destroy=o()}n=n.next}while(n!==t)}}function ra(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function zd(e){var t=e.alternate;t!==null&&(e.alternate=null,zd(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ft],delete t[no],delete t[xl],delete t[bm],delete t[Um])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function $d(e){return e.tag===5||e.tag===3||e.tag===4}function Bd(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||$d(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function oa(e,t,n){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=ai));else if(o!==4&&(e=e.child,e!==null))for(oa(e,t,n),e=e.sibling;e!==null;)oa(e,t,n),e=e.sibling}function ia(e,t,n){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(o!==4&&(e=e.child,e!==null))for(ia(e,t,n),e=e.sibling;e!==null;)ia(e,t,n),e=e.sibling}var Ge=null,Lt=!1;function gn(e,t,n){for(n=n.child;n!==null;)Fd(e,t,n),n=n.sibling}function Fd(e,t,n){if(Bt&&typeof Bt.onCommitFiberUnmount=="function")try{Bt.onCommitFiberUnmount(Vo,n)}catch{}switch(n.tag){case 5:et||mr(n,t);case 6:var o=Ge,a=Lt;Ge=null,gn(e,t,n),Ge=o,Lt=a,Ge!==null&&(Lt?(e=Ge,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Ge.removeChild(n.stateNode));break;case 18:Ge!==null&&(Lt?(e=Ge,n=n.stateNode,e.nodeType===8?vl(e.parentNode,n):e.nodeType===1&&vl(e,n),Wr(e)):vl(Ge,n.stateNode));break;case 4:o=Ge,a=Lt,Ge=n.stateNode.containerInfo,Lt=!0,gn(e,t,n),Ge=o,Lt=a;break;case 0:case 11:case 14:case 15:if(!et&&(o=n.updateQueue,o!==null&&(o=o.lastEffect,o!==null))){a=o=o.next;do{var u=a,f=u.destroy;u=u.tag,f!==void 0&&(u&2||u&4)&&na(n,t,f),a=a.next}while(a!==o)}gn(e,t,n);break;case 1:if(!et&&(mr(n,t),o=n.stateNode,typeof o.componentWillUnmount=="function"))try{o.props=n.memoizedProps,o.state=n.memoizedState,o.componentWillUnmount()}catch(g){Ie(n,t,g)}gn(e,t,n);break;case 21:gn(e,t,n);break;case 22:n.mode&1?(et=(o=et)||n.memoizedState!==null,gn(e,t,n),et=o):gn(e,t,n);break;default:gn(e,t,n)}}function bd(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new og),t.forEach(function(o){var a=hg.bind(null,e,o);n.has(o)||(n.add(o),o.then(a,a))})}}function It(e,t){var n=t.deletions;if(n!==null)for(var o=0;oa&&(a=f),o&=~u}if(o=a,o=De()-o,o=(120>o?120:480>o?480:1080>o?1080:1920>o?1920:3e3>o?3e3:4320>o?4320:1960*lg(o/1960))-o,10e?16:e,vn===null)var o=!1;else{if(e=vn,vn=null,Di=0,xe&6)throw Error(s(331));var a=xe;for(xe|=4,X=e.current;X!==null;){var u=X,f=u.child;if(X.flags&16){var g=u.deletions;if(g!==null){for(var y=0;yDe()-aa?zn(e,0):la|=n),dt(e,t)}function ef(e,t){t===0&&(e.mode&1?(t=qo,qo<<=1,!(qo&130023424)&&(qo=4194304)):t=1);var n=st();e=Xt(e,t),e!==null&&(Fr(e,t,n),dt(e,n))}function pg(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ef(e,n)}function hg(e,t){var n=0;switch(e.tag){case 13:var o=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:o=e.stateNode;break;default:throw Error(s(314))}o!==null&&o.delete(t),ef(e,n)}var tf;tf=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||lt.current)ut=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return ut=!1,tg(e,t,n);ut=!!(e.flags&131072)}else ut=!1,Ne&&t.flags&1048576&&Lc(t,hi,t.index);switch(t.lanes=0,t.tag){case 2:var o=t.type;Ti(e,t),e=t.pendingProps;var a=sr(t,Xe.current);fr(t,n),a=Bl(null,t,o,e,a,n);var u=Fl();return t.flags|=1,typeof a=="object"&&a!==null&&typeof a.render=="function"&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,at(o)?(u=!0,di(t)):u=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,Ol(t),a.updater=Ri,t.stateNode=a,a._reactInternals=t,ql(t,o,e,n),t=Kl(null,t,o,!0,u,n)):(t.tag=0,Ne&&u&&kl(t),it(null,t,a,n),t=t.child),t;case 16:o=t.elementType;e:{switch(Ti(e,t),e=t.pendingProps,a=o._init,o=a(o._payload),t.type=o,a=t.tag=gg(o),e=Mt(o,e),a){case 0:t=Gl(null,t,o,e,n);break e;case 1:t=Rd(null,t,o,e,n);break e;case 11:t=kd(null,t,o,e,n);break e;case 14:t=Cd(null,t,o,Mt(o.type,e),n);break e}throw Error(s(306,o,""))}return t;case 0:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Mt(o,a),Gl(e,t,o,a,n);case 1:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Mt(o,a),Rd(e,t,o,a,n);case 3:e:{if(Pd(t),e===null)throw Error(s(387));o=t.pendingProps,u=t.memoizedState,a=u.element,Hc(e,t),wi(t,o,null,n);var f=t.memoizedState;if(o=f.element,u.isDehydrated)if(u={element:o,isDehydrated:!1,cache:f.cache,pendingSuspenseBoundaries:f.pendingSuspenseBoundaries,transitions:f.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){a=hr(Error(s(423)),t),t=Td(e,t,o,n,a);break e}else if(o!==a){a=hr(Error(s(424)),t),t=Td(e,t,o,n,a);break e}else for(gt=cn(t.stateNode.containerInfo.firstChild),mt=t,Ne=!0,Ot=null,n=bc(t,null,o,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(ur(),o===a){t=Zt(e,t,n);break e}it(e,t,o,n)}t=t.child}return t;case 5:return qc(t),e===null&&jl(t),o=t.type,a=t.pendingProps,u=e!==null?e.memoizedProps:null,f=a.children,gl(o,a)?f=null:u!==null&&gl(o,u)&&(t.flags|=32),Ad(e,t),it(e,t,f,n),t.child;case 6:return e===null&&jl(t),null;case 13:return _d(e,t,n);case 4:return Ml(t,t.stateNode.containerInfo),o=t.pendingProps,e===null?t.child=cr(t,null,o,n):it(e,t,o,n),t.child;case 11:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Mt(o,a),kd(e,t,o,a,n);case 7:return it(e,t,t.pendingProps,n),t.child;case 8:return it(e,t,t.pendingProps.children,n),t.child;case 12:return it(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(o=t.type._context,a=t.pendingProps,u=t.memoizedProps,f=a.value,Ae(yi,o._currentValue),o._currentValue=f,u!==null)if(Nt(u.value,f)){if(u.children===a.children&&!lt.current){t=Zt(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var g=u.dependencies;if(g!==null){f=u.child;for(var y=g.firstContext;y!==null;){if(y.context===o){if(u.tag===1){y=Jt(-1,n&-n),y.tag=2;var P=u.updateQueue;if(P!==null){P=P.shared;var $=P.pending;$===null?y.next=y:(y.next=$.next,$.next=y),P.pending=y}}u.lanes|=n,y=u.alternate,y!==null&&(y.lanes|=n),_l(u.return,n,t),g.lanes|=n;break}y=y.next}}else if(u.tag===10)f=u.type===t.type?null:u.child;else if(u.tag===18){if(f=u.return,f===null)throw Error(s(341));f.lanes|=n,g=f.alternate,g!==null&&(g.lanes|=n),_l(f,n,t),f=u.sibling}else f=u.child;if(f!==null)f.return=u;else for(f=u;f!==null;){if(f===t){f=null;break}if(u=f.sibling,u!==null){u.return=f.return,f=u;break}f=f.return}u=f}it(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,o=t.pendingProps.children,fr(t,n),a=Et(a),o=o(a),t.flags|=1,it(e,t,o,n),t.child;case 14:return o=t.type,a=Mt(o,t.pendingProps),a=Mt(o.type,a),Cd(e,t,o,a,n);case 15:return Ed(e,t,t.type,t.pendingProps,n);case 17:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Mt(o,a),Ti(e,t),t.tag=1,at(o)?(e=!0,di(t)):e=!1,fr(t,n),md(t,o,a),ql(t,o,a,n),Kl(null,t,o,!0,e,n);case 19:return Od(e,t,n);case 22:return jd(e,t,n)}throw Error(s(156,t.tag))};function nf(e,t){return Iu(e,t)}function mg(e,t,n,o){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=o,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Rt(e,t,n,o){return new mg(e,t,n,o)}function ga(e){return e=e.prototype,!(!e||!e.isReactComponent)}function gg(e){if(typeof e=="function")return ga(e)?1:0;if(e!=null){if(e=e.$$typeof,e===ne)return 11;if(e===Re)return 14}return 2}function Sn(e,t){var n=e.alternate;return n===null?(n=Rt(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Fi(e,t,n,o,a,u){var f=2;if(o=e,typeof e=="function")ga(e)&&(f=1);else if(typeof e=="string")f=5;else e:switch(e){case H:return Bn(n.children,a,u,t);case ie:f=8,a|=8;break;case ve:return e=Rt(12,n,t,a|2),e.elementType=ve,e.lanes=u,e;case le:return e=Rt(13,n,t,a),e.elementType=le,e.lanes=u,e;case me:return e=Rt(19,n,t,a),e.elementType=me,e.lanes=u,e;case Ee:return bi(n,a,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Oe:f=10;break e;case ot:f=9;break e;case ne:f=11;break e;case Re:f=14;break e;case ge:f=16,o=null;break e}throw Error(s(130,e==null?e:typeof e,""))}return t=Rt(f,n,t,a),t.elementType=e,t.type=o,t.lanes=u,t}function Bn(e,t,n,o){return e=Rt(7,e,o,t),e.lanes=n,e}function bi(e,t,n,o){return e=Rt(22,e,o,t),e.elementType=Ee,e.lanes=n,e.stateNode={isHidden:!1},e}function ya(e,t,n){return e=Rt(6,e,null,t),e.lanes=n,e}function va(e,t,n){return t=Rt(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function yg(e,t,n,o,a){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ws(0),this.expirationTimes=Ws(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ws(0),this.identifierPrefix=o,this.onRecoverableError=a,this.mutableSourceEagerHydrationData=null}function xa(e,t,n,o,a,u,f,g,y){return e=new yg(e,t,n,g,y),t===1?(t=1,u===!0&&(t|=8)):t=0,u=Rt(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:o,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ol(u),e}function vg(e,t,n){var o=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(i){console.error(i)}}return r(),ja.exports=Ng(),ja.exports}var vf;function Mg(){if(vf)return Qi;vf=1;var r=Og();return Qi.createRoot=r.createRoot,Qi.hydrateRoot=r.hydrateRoot,Qi}var Lg=Mg(),nt=function(){return nt=Object.assign||function(i){for(var s,l=1,c=arguments.length;l0?Ye(Pr,--Pt):0,Er--,be===10&&(Er=1,xs--),be}function zt(){return be=Pt2||Ha(be)>3?"":" "}function Vg(r,i){for(;--i&&zt()&&!(be<48||be>102||be>57&&be<65||be>70&&be<97););return Ss(r,os()+(i<6&&Un()==32&&zt()==32))}function Va(r){for(;zt();)switch(be){case r:return Pt;case 34:case 39:r!==34&&r!==39&&Va(be);break;case 40:r===41&&Va(r);break;case 92:zt();break}return Pt}function Wg(r,i){for(;zt()&&r+be!==57;)if(r+be===84&&Un()===47)break;return"/*"+Ss(i,Pt-1)+"*"+su(r===47?r:zt())}function qg(r){for(;!Ha(Un());)zt();return Ss(r,Pt)}function Yg(r){return Ug(is("",null,null,null,[""],r=bg(r),0,[0],r))}function is(r,i,s,l,c,d,p,m,w){for(var v=0,S=0,j=p,R=0,L=0,T=0,N=1,_=1,V=1,U=0,B="",W=c,I=d,M=l,H=B;_;)switch(T=U,U=zt()){case 40:if(T!=108&&Ye(H,j-1)==58){rs(H+=de(Pa(U),"&","&\f"),"&\f",vp(v?m[v-1]:0))!=-1&&(V=-1);break}case 34:case 39:case 91:H+=Pa(U);break;case 9:case 10:case 13:case 32:H+=Hg(T);break;case 92:H+=Vg(os()-1,7);continue;case 47:switch(Un()){case 42:case 47:jo(Qg(Wg(zt(),os()),i,s,w),w);break;default:H+="/"}break;case 123*N:m[v++]=Wt(H)*V;case 125*N:case 59:case 0:switch(U){case 0:case 125:_=0;case 59+S:V==-1&&(H=de(H,/\f/g,"")),L>0&&Wt(H)-j&&jo(L>32?Sf(H+";",l,s,j-1,w):Sf(de(H," ","")+";",l,s,j-2,w),w);break;case 59:H+=";";default:if(jo(M=wf(H,i,s,v,S,c,m,B,W=[],I=[],j,d),d),U===123)if(S===0)is(H,i,M,M,W,d,j,m,I);else switch(R===99&&Ye(H,3)===110?100:R){case 100:case 108:case 109:case 115:is(r,M,M,l&&jo(wf(r,M,M,0,0,c,m,B,c,W=[],j,I),I),c,I,j,m,l?W:I);break;default:is(H,M,M,M,[""],I,0,m,I)}}v=S=L=0,N=V=1,B=H="",j=p;break;case 58:j=1+Wt(H),L=T;default:if(N<1){if(U==123)--N;else if(U==125&&N++==0&&Fg()==125)continue}switch(H+=su(U),U*N){case 38:V=S>0?1:(H+="\f",-1);break;case 44:m[v++]=(Wt(H)-1)*V,V=1;break;case 64:Un()===45&&(H+=Pa(zt())),R=Un(),S=j=Wt(B=H+=qg(os())),U++;break;case 45:T===45&&Wt(H)==2&&(N=0)}}return d}function wf(r,i,s,l,c,d,p,m,w,v,S,j){for(var R=c-1,L=c===0?d:[""],T=wp(L),N=0,_=0,V=0;N0?L[U]+" "+B:de(B,/&\f/g,L[U])))&&(w[V++]=W);return ws(r,i,s,c===0?vs:m,w,v,S,j)}function Qg(r,i,s,l){return ws(r,i,s,gp,su(Bg()),Cr(r,2,-2),0,l)}function Sf(r,i,s,l,c){return ws(r,i,s,iu,Cr(r,0,l),Cr(r,l+1,-1),l,c)}function kp(r,i,s){switch(zg(r,i)){case 5103:return Ce+"print-"+r+r;case 5737:case 4201:case 3177:case 3433:case 1641:case 4457:case 2921:case 5572:case 6356:case 5844:case 3191:case 6645:case 3005:case 6391:case 5879:case 5623:case 6135:case 4599:case 4855:case 4215:case 6389:case 5109:case 5365:case 5621:case 3829:return Ce+r+r;case 4789:return Ao+r+r;case 5349:case 4246:case 4810:case 6968:case 2756:return Ce+r+Ao+r+_e+r+r;case 5936:switch(Ye(r,i+11)){case 114:return Ce+r+_e+de(r,/[svh]\w+-[tblr]{2}/,"tb")+r;case 108:return Ce+r+_e+de(r,/[svh]\w+-[tblr]{2}/,"tb-rl")+r;case 45:return Ce+r+_e+de(r,/[svh]\w+-[tblr]{2}/,"lr")+r}case 6828:case 4268:case 2903:return Ce+r+_e+r+r;case 6165:return Ce+r+_e+"flex-"+r+r;case 5187:return Ce+r+de(r,/(\w+).+(:[^]+)/,Ce+"box-$1$2"+_e+"flex-$1$2")+r;case 5443:return Ce+r+_e+"flex-item-"+de(r,/flex-|-self/g,"")+(tn(r,/flex-|baseline/)?"":_e+"grid-row-"+de(r,/flex-|-self/g,""))+r;case 4675:return Ce+r+_e+"flex-line-pack"+de(r,/align-content|flex-|-self/g,"")+r;case 5548:return Ce+r+_e+de(r,"shrink","negative")+r;case 5292:return Ce+r+_e+de(r,"basis","preferred-size")+r;case 6060:return Ce+"box-"+de(r,"-grow","")+Ce+r+_e+de(r,"grow","positive")+r;case 4554:return Ce+de(r,/([^-])(transform)/g,"$1"+Ce+"$2")+r;case 6187:return de(de(de(r,/(zoom-|grab)/,Ce+"$1"),/(image-set)/,Ce+"$1"),r,"")+r;case 5495:case 3959:return de(r,/(image-set\([^]*)/,Ce+"$1$`$1");case 4968:return de(de(r,/(.+:)(flex-)?(.*)/,Ce+"box-pack:$3"+_e+"flex-pack:$3"),/s.+-b[^;]+/,"justify")+Ce+r+r;case 4200:if(!tn(r,/flex-|baseline/))return _e+"grid-column-align"+Cr(r,i)+r;break;case 2592:case 3360:return _e+de(r,"template-","")+r;case 4384:case 3616:return s&&s.some(function(l,c){return i=c,tn(l.props,/grid-\w+-end/)})?~rs(r+(s=s[i].value),"span",0)?r:_e+de(r,"-start","")+r+_e+"grid-row-span:"+(~rs(s,"span",0)?tn(s,/\d+/):+tn(s,/\d+/)-+tn(r,/\d+/))+";":_e+de(r,"-start","")+r;case 4896:case 4128:return s&&s.some(function(l){return tn(l.props,/grid-\w+-start/)})?r:_e+de(de(r,"-end","-span"),"span ","")+r;case 4095:case 3583:case 4068:case 2532:return de(r,/(.+)-inline(.+)/,Ce+"$1$2")+r;case 8116:case 7059:case 5753:case 5535:case 5445:case 5701:case 4933:case 4677:case 5533:case 5789:case 5021:case 4765:if(Wt(r)-1-i>6)switch(Ye(r,i+1)){case 109:if(Ye(r,i+4)!==45)break;case 102:return de(r,/(.+:)(.+)-([^]+)/,"$1"+Ce+"$2-$3$1"+Ao+(Ye(r,i+3)==108?"$3":"$2-$3"))+r;case 115:return~rs(r,"stretch",0)?kp(de(r,"stretch","fill-available"),i,s)+r:r}break;case 5152:case 5920:return de(r,/(.+?):(\d+)(\s*\/\s*(span)?\s*(\d+))?(.*)/,function(l,c,d,p,m,w,v){return _e+c+":"+d+v+(p?_e+c+"-span:"+(m?w:+w-+d)+v:"")+r});case 4949:if(Ye(r,i+6)===121)return de(r,":",":"+Ce)+r;break;case 6444:switch(Ye(r,Ye(r,14)===45?18:11)){case 120:return de(r,/(.+:)([^;\s!]+)(;|(\s+)?!.+)?/,"$1"+Ce+(Ye(r,14)===45?"inline-":"")+"box$3$1"+Ce+"$2$3$1"+_e+"$2box$3")+r;case 100:return de(r,":",":"+_e)+r}break;case 5719:case 2647:case 2135:case 3927:case 2391:return de(r,"scroll-","scroll-snap-")+r}return r}function fs(r,i){for(var s="",l=0;l-1&&!r.return)switch(r.type){case iu:r.return=kp(r.value,r.length,s);return;case yp:return fs([Cn(r,{value:de(r.value,"@","@"+Ce)})],l);case vs:if(r.length)return $g(s=r.props,function(c){switch(tn(c,l=/(::plac\w+|:read-\w+)/)){case":read-only":case":read-write":xr(Cn(r,{props:[de(c,/:(read-\w+)/,":"+Ao+"$1")]})),xr(Cn(r,{props:[c]})),Ua(r,{props:xf(s,l)});break;case"::placeholder":xr(Cn(r,{props:[de(c,/:(plac\w+)/,":"+Ce+"input-$1")]})),xr(Cn(r,{props:[de(c,/:(plac\w+)/,":"+Ao+"$1")]})),xr(Cn(r,{props:[de(c,/:(plac\w+)/,_e+"input-$1")]})),xr(Cn(r,{props:[c]})),Ua(r,{props:xf(s,l)});break}return""})}}var Zg={animationIterationCount:1,aspectRatio:1,borderImageOutset:1,borderImageSlice:1,borderImageWidth:1,boxFlex:1,boxFlexGroup:1,boxOrdinalGroup:1,columnCount:1,columns:1,flex:1,flexGrow:1,flexPositive:1,flexShrink:1,flexNegative:1,flexOrder:1,gridRow:1,gridRowEnd:1,gridRowSpan:1,gridRowStart:1,gridColumn:1,gridColumnEnd:1,gridColumnSpan:1,gridColumnStart:1,msGridRow:1,msGridRowSpan:1,msGridColumn:1,msGridColumnSpan:1,fontWeight:1,lineHeight:1,opacity:1,order:1,orphans:1,tabSize:1,widows:1,zIndex:1,zoom:1,WebkitLineClamp:1,fillOpacity:1,floodOpacity:1,stopOpacity:1,strokeDasharray:1,strokeDashoffset:1,strokeMiterlimit:1,strokeOpacity:1,strokeWidth:1},vt={},jr=typeof process<"u"&&vt!==void 0&&(vt.REACT_APP_SC_ATTR||vt.SC_ATTR)||"data-styled",Cp="active",Ep="data-styled-version",ks="6.1.14",lu=`/*!sc*/ +`,ps=typeof window<"u"&&"HTMLElement"in window,ey=!!(typeof SC_DISABLE_SPEEDY=="boolean"?SC_DISABLE_SPEEDY:typeof process<"u"&&vt!==void 0&&vt.REACT_APP_SC_DISABLE_SPEEDY!==void 0&&vt.REACT_APP_SC_DISABLE_SPEEDY!==""?vt.REACT_APP_SC_DISABLE_SPEEDY!=="false"&&vt.REACT_APP_SC_DISABLE_SPEEDY:typeof process<"u"&&vt!==void 0&&vt.SC_DISABLE_SPEEDY!==void 0&&vt.SC_DISABLE_SPEEDY!==""&&vt.SC_DISABLE_SPEEDY!=="false"&&vt.SC_DISABLE_SPEEDY),Cs=Object.freeze([]),Ar=Object.freeze({});function ty(r,i,s){return s===void 0&&(s=Ar),r.theme!==s.theme&&r.theme||i||s.theme}var jp=new Set(["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","big","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","keygen","label","legend","li","link","main","map","mark","menu","menuitem","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","section","select","small","source","span","strong","style","sub","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","track","u","ul","use","var","video","wbr","circle","clipPath","defs","ellipse","foreignObject","g","image","line","linearGradient","marker","mask","path","pattern","polygon","polyline","radialGradient","rect","stop","svg","text","tspan"]),ny=/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~-]+/g,ry=/(^-|-$)/g;function kf(r){return r.replace(ny,"-").replace(ry,"")}var oy=/(a)(d)/gi,Gi=52,Cf=function(r){return String.fromCharCode(r+(r>25?39:97))};function Wa(r){var i,s="";for(i=Math.abs(r);i>Gi;i=i/Gi|0)s=Cf(i%Gi)+s;return(Cf(i%Gi)+s).replace(oy,"$1-$2")}var Ta,Ap=5381,wr=function(r,i){for(var s=i.length;s;)r=33*r^i.charCodeAt(--s);return r},Rp=function(r){return wr(Ap,r)};function iy(r){return Wa(Rp(r)>>>0)}function sy(r){return r.displayName||r.name||"Component"}function _a(r){return typeof r=="string"&&!0}var Pp=typeof Symbol=="function"&&Symbol.for,Tp=Pp?Symbol.for("react.memo"):60115,ly=Pp?Symbol.for("react.forward_ref"):60112,ay={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},uy={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},_p={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},cy=((Ta={})[ly]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},Ta[Tp]=_p,Ta);function Ef(r){return("type"in(i=r)&&i.type.$$typeof)===Tp?_p:"$$typeof"in r?cy[r.$$typeof]:ay;var i}var dy=Object.defineProperty,fy=Object.getOwnPropertyNames,jf=Object.getOwnPropertySymbols,py=Object.getOwnPropertyDescriptor,hy=Object.getPrototypeOf,Af=Object.prototype;function Np(r,i,s){if(typeof i!="string"){if(Af){var l=hy(i);l&&l!==Af&&Np(r,l,s)}var c=fy(i);jf&&(c=c.concat(jf(i)));for(var d=Ef(r),p=Ef(i),m=0;m0?" Args: ".concat(i.join(", ")):""))}var my=function(){function r(i){this.groupSizes=new Uint32Array(512),this.length=512,this.tag=i}return r.prototype.indexOfGroup=function(i){for(var s=0,l=0;l=this.groupSizes.length){for(var l=this.groupSizes,c=l.length,d=c;i>=d;)if((d<<=1)<0)throw qn(16,"".concat(i));this.groupSizes=new Uint32Array(d),this.groupSizes.set(l),this.length=d;for(var p=c;p=this.length||this.groupSizes[i]===0)return s;for(var l=this.groupSizes[i],c=this.indexOfGroup(i),d=c+l,p=c;p=0){var l=document.createTextNode(s);return this.element.insertBefore(l,this.nodes[i]||null),this.length++,!0}return!1},r.prototype.deleteRule=function(i){this.element.removeChild(this.nodes[i]),this.length--},r.prototype.getRule=function(i){return i0&&(_+="".concat(V,","))}),w+="".concat(T).concat(N,'{content:"').concat(_,'"}').concat(lu)},S=0;S0?".".concat(i):R},S=w.slice();S.push(function(R){R.type===vs&&R.value.includes("&")&&(R.props[0]=R.props[0].replace(Ay,s).replace(l,v))}),p.prefix&&S.push(Jg),S.push(Gg);var j=function(R,L,T,N){L===void 0&&(L=""),T===void 0&&(T=""),N===void 0&&(N="&"),i=N,s=L,l=new RegExp("\\".concat(s,"\\b"),"g");var _=R.replace(Ry,""),V=Yg(T||L?"".concat(T," ").concat(L," { ").concat(_," }"):_);p.namespace&&(V=Lp(V,p.namespace));var U=[];return fs(V,Kg(S.concat(Xg(function(B){return U.push(B)})))),U};return j.hash=w.length?w.reduce(function(R,L){return L.name||qn(15),wr(R,L.name)},Ap).toString():"",j}var Ty=new Mp,Ya=Py(),Ip=xt.createContext({shouldForwardProp:void 0,styleSheet:Ty,stylis:Ya});Ip.Consumer;xt.createContext(void 0);function _f(){return K.useContext(Ip)}var _y=function(){function r(i,s){var l=this;this.inject=function(c,d){d===void 0&&(d=Ya);var p=l.name+d.hash;c.hasNameForId(l.id,p)||c.insertRules(l.id,p,d(l.rules,p,"@keyframes"))},this.name=i,this.id="sc-keyframes-".concat(i),this.rules=s,uu(this,function(){throw qn(12,String(l.name))})}return r.prototype.getName=function(i){return i===void 0&&(i=Ya),this.name+i.hash},r}(),Ny=function(r){return r>="A"&&r<="Z"};function Nf(r){for(var i="",s=0;s>>0);if(!s.hasNameForId(this.componentId,p)){var m=l(d,".".concat(p),void 0,this.componentId);s.insertRules(this.componentId,p,m)}c=Fn(c,p),this.staticRulesId=p}else{for(var w=wr(this.baseHash,l.hash),v="",S=0;S>>0);s.hasNameForId(this.componentId,L)||s.insertRules(this.componentId,L,l(v,".".concat(L),void 0,this.componentId)),c=Fn(c,L)}}return c},r}(),ms=xt.createContext(void 0);ms.Consumer;function Of(r){var i=xt.useContext(ms),s=K.useMemo(function(){return function(l,c){if(!l)throw qn(14);if(Wn(l)){var d=l(c);return d}if(Array.isArray(l)||typeof l!="object")throw qn(8);return c?nt(nt({},c),l):l}(r.theme,i)},[r.theme,i]);return r.children?xt.createElement(ms.Provider,{value:s},r.children):null}var Na={};function Iy(r,i,s){var l=au(r),c=r,d=!_a(r),p=i.attrs,m=p===void 0?Cs:p,w=i.componentId,v=w===void 0?function(W,I){var M=typeof W!="string"?"sc":kf(W);Na[M]=(Na[M]||0)+1;var H="".concat(M,"-").concat(iy(ks+M+Na[M]));return I?"".concat(I,"-").concat(H):H}(i.displayName,i.parentComponentId):w,S=i.displayName,j=S===void 0?function(W){return _a(W)?"styled.".concat(W):"Styled(".concat(sy(W),")")}(r):S,R=i.displayName&&i.componentId?"".concat(kf(i.displayName),"-").concat(i.componentId):i.componentId||v,L=l&&c.attrs?c.attrs.concat(m).filter(Boolean):m,T=i.shouldForwardProp;if(l&&c.shouldForwardProp){var N=c.shouldForwardProp;if(i.shouldForwardProp){var _=i.shouldForwardProp;T=function(W,I){return N(W,I)&&_(W,I)}}else T=N}var V=new Ly(s,R,l?c.componentStyle:void 0);function U(W,I){return function(M,H,ie){var ve=M.attrs,Oe=M.componentStyle,ot=M.defaultProps,ne=M.foldedComponentIds,le=M.styledComponentId,me=M.target,Re=xt.useContext(ms),ge=_f(),Ee=M.shouldForwardProp||ge.shouldForwardProp,q=ty(H,Re,ot)||Ar,ee=function(he,fe,ke){for(var ye,we=nt(nt({},fe),{className:void 0,theme:ke}),Qe=0;Qe{let i;const s=new Set,l=(v,S)=>{const j=typeof v=="function"?v(i):v;if(!Object.is(j,i)){const R=i;i=S??(typeof j!="object"||j===null)?j:Object.assign({},i,j),s.forEach(L=>L(i,R))}},c=()=>i,m={setState:l,getState:c,getInitialState:()=>w,subscribe:v=>(s.add(v),()=>s.delete(v))},w=i=r(l,c,m);return m},zy=r=>r?If(r):If,$y=r=>r;function By(r,i=$y){const s=xt.useSyncExternalStore(r.subscribe,()=>i(r.getState()),()=>i(r.getInitialState()));return xt.useDebugValue(s),s}const Df=r=>{const i=zy(r),s=l=>By(i,l);return Object.assign(s,i),s},Tr=r=>r?Df(r):Df;function Bp(r,i){return function(){return r.apply(i,arguments)}}const{toString:Fy}=Object.prototype,{getPrototypeOf:cu}=Object,Es=(r=>i=>{const s=Fy.call(i);return r[s]||(r[s]=s.slice(8,-1).toLowerCase())})(Object.create(null)),$t=r=>(r=r.toLowerCase(),i=>Es(i)===r),js=r=>i=>typeof i===r,{isArray:_r}=Array,Mo=js("undefined");function by(r){return r!==null&&!Mo(r)&&r.constructor!==null&&!Mo(r.constructor)&&wt(r.constructor.isBuffer)&&r.constructor.isBuffer(r)}const Fp=$t("ArrayBuffer");function Uy(r){let i;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?i=ArrayBuffer.isView(r):i=r&&r.buffer&&Fp(r.buffer),i}const Hy=js("string"),wt=js("function"),bp=js("number"),As=r=>r!==null&&typeof r=="object",Vy=r=>r===!0||r===!1,as=r=>{if(Es(r)!=="object")return!1;const i=cu(r);return(i===null||i===Object.prototype||Object.getPrototypeOf(i)===null)&&!(Symbol.toStringTag in r)&&!(Symbol.iterator in r)},Wy=$t("Date"),qy=$t("File"),Yy=$t("Blob"),Qy=$t("FileList"),Gy=r=>As(r)&&wt(r.pipe),Ky=r=>{let i;return r&&(typeof FormData=="function"&&r instanceof FormData||wt(r.append)&&((i=Es(r))==="formdata"||i==="object"&&wt(r.toString)&&r.toString()==="[object FormData]"))},Xy=$t("URLSearchParams"),[Jy,Zy,e0,t0]=["ReadableStream","Request","Response","Headers"].map($t),n0=r=>r.trim?r.trim():r.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function Io(r,i,{allOwnKeys:s=!1}={}){if(r===null||typeof r>"u")return;let l,c;if(typeof r!="object"&&(r=[r]),_r(r))for(l=0,c=r.length;l0;)if(c=s[l],i===c.toLowerCase())return c;return null}const bn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,Hp=r=>!Mo(r)&&r!==bn;function Ga(){const{caseless:r}=Hp(this)&&this||{},i={},s=(l,c)=>{const d=r&&Up(i,c)||c;as(i[d])&&as(l)?i[d]=Ga(i[d],l):as(l)?i[d]=Ga({},l):_r(l)?i[d]=l.slice():i[d]=l};for(let l=0,c=arguments.length;l(Io(i,(c,d)=>{s&&wt(c)?r[d]=Bp(c,s):r[d]=c},{allOwnKeys:l}),r),o0=r=>(r.charCodeAt(0)===65279&&(r=r.slice(1)),r),i0=(r,i,s,l)=>{r.prototype=Object.create(i.prototype,l),r.prototype.constructor=r,Object.defineProperty(r,"super",{value:i.prototype}),s&&Object.assign(r.prototype,s)},s0=(r,i,s,l)=>{let c,d,p;const m={};if(i=i||{},r==null)return i;do{for(c=Object.getOwnPropertyNames(r),d=c.length;d-- >0;)p=c[d],(!l||l(p,r,i))&&!m[p]&&(i[p]=r[p],m[p]=!0);r=s!==!1&&cu(r)}while(r&&(!s||s(r,i))&&r!==Object.prototype);return i},l0=(r,i,s)=>{r=String(r),(s===void 0||s>r.length)&&(s=r.length),s-=i.length;const l=r.indexOf(i,s);return l!==-1&&l===s},a0=r=>{if(!r)return null;if(_r(r))return r;let i=r.length;if(!bp(i))return null;const s=new Array(i);for(;i-- >0;)s[i]=r[i];return s},u0=(r=>i=>r&&i instanceof r)(typeof Uint8Array<"u"&&cu(Uint8Array)),c0=(r,i)=>{const l=(r&&r[Symbol.iterator]).call(r);let c;for(;(c=l.next())&&!c.done;){const d=c.value;i.call(r,d[0],d[1])}},d0=(r,i)=>{let s;const l=[];for(;(s=r.exec(i))!==null;)l.push(s);return l},f0=$t("HTMLFormElement"),p0=r=>r.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(s,l,c){return l.toUpperCase()+c}),zf=(({hasOwnProperty:r})=>(i,s)=>r.call(i,s))(Object.prototype),h0=$t("RegExp"),Vp=(r,i)=>{const s=Object.getOwnPropertyDescriptors(r),l={};Io(s,(c,d)=>{let p;(p=i(c,d,r))!==!1&&(l[d]=p||c)}),Object.defineProperties(r,l)},m0=r=>{Vp(r,(i,s)=>{if(wt(r)&&["arguments","caller","callee"].indexOf(s)!==-1)return!1;const l=r[s];if(wt(l)){if(i.enumerable=!1,"writable"in i){i.writable=!1;return}i.set||(i.set=()=>{throw Error("Can not rewrite read-only method '"+s+"'")})}})},g0=(r,i)=>{const s={},l=c=>{c.forEach(d=>{s[d]=!0})};return _r(r)?l(r):l(String(r).split(i)),s},y0=()=>{},v0=(r,i)=>r!=null&&Number.isFinite(r=+r)?r:i,Oa="abcdefghijklmnopqrstuvwxyz",$f="0123456789",Wp={DIGIT:$f,ALPHA:Oa,ALPHA_DIGIT:Oa+Oa.toUpperCase()+$f},x0=(r=16,i=Wp.ALPHA_DIGIT)=>{let s="";const{length:l}=i;for(;r--;)s+=i[Math.random()*l|0];return s};function w0(r){return!!(r&&wt(r.append)&&r[Symbol.toStringTag]==="FormData"&&r[Symbol.iterator])}const S0=r=>{const i=new Array(10),s=(l,c)=>{if(As(l)){if(i.indexOf(l)>=0)return;if(!("toJSON"in l)){i[c]=l;const d=_r(l)?[]:{};return Io(l,(p,m)=>{const w=s(p,c+1);!Mo(w)&&(d[m]=w)}),i[c]=void 0,d}}return l};return s(r,0)},k0=$t("AsyncFunction"),C0=r=>r&&(As(r)||wt(r))&&wt(r.then)&&wt(r.catch),qp=((r,i)=>r?setImmediate:i?((s,l)=>(bn.addEventListener("message",({source:c,data:d})=>{c===bn&&d===s&&l.length&&l.shift()()},!1),c=>{l.push(c),bn.postMessage(s,"*")}))(`axios@${Math.random()}`,[]):s=>setTimeout(s))(typeof setImmediate=="function",wt(bn.postMessage)),E0=typeof queueMicrotask<"u"?queueMicrotask.bind(bn):typeof process<"u"&&process.nextTick||qp,O={isArray:_r,isArrayBuffer:Fp,isBuffer:by,isFormData:Ky,isArrayBufferView:Uy,isString:Hy,isNumber:bp,isBoolean:Vy,isObject:As,isPlainObject:as,isReadableStream:Jy,isRequest:Zy,isResponse:e0,isHeaders:t0,isUndefined:Mo,isDate:Wy,isFile:qy,isBlob:Yy,isRegExp:h0,isFunction:wt,isStream:Gy,isURLSearchParams:Xy,isTypedArray:u0,isFileList:Qy,forEach:Io,merge:Ga,extend:r0,trim:n0,stripBOM:o0,inherits:i0,toFlatObject:s0,kindOf:Es,kindOfTest:$t,endsWith:l0,toArray:a0,forEachEntry:c0,matchAll:d0,isHTMLForm:f0,hasOwnProperty:zf,hasOwnProp:zf,reduceDescriptors:Vp,freezeMethods:m0,toObjectSet:g0,toCamelCase:p0,noop:y0,toFiniteNumber:v0,findKey:Up,global:bn,isContextDefined:Hp,ALPHABET:Wp,generateString:x0,isSpecCompliantForm:w0,toJSONObject:S0,isAsyncFn:k0,isThenable:C0,setImmediate:qp,asap:E0};function ue(r,i,s,l,c){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack,this.message=r,this.name="AxiosError",i&&(this.code=i),s&&(this.config=s),l&&(this.request=l),c&&(this.response=c,this.status=c.status?c.status:null)}O.inherits(ue,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:O.toJSONObject(this.config),code:this.code,status:this.status}}});const Yp=ue.prototype,Qp={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach(r=>{Qp[r]={value:r}});Object.defineProperties(ue,Qp);Object.defineProperty(Yp,"isAxiosError",{value:!0});ue.from=(r,i,s,l,c,d)=>{const p=Object.create(Yp);return O.toFlatObject(r,p,function(w){return w!==Error.prototype},m=>m!=="isAxiosError"),ue.call(p,r.message,i,s,l,c),p.cause=r,p.name=r.name,d&&Object.assign(p,d),p};const j0=null;function Ka(r){return O.isPlainObject(r)||O.isArray(r)}function Gp(r){return O.endsWith(r,"[]")?r.slice(0,-2):r}function Bf(r,i,s){return r?r.concat(i).map(function(c,d){return c=Gp(c),!s&&d?"["+c+"]":c}).join(s?".":""):i}function A0(r){return O.isArray(r)&&!r.some(Ka)}const R0=O.toFlatObject(O,{},null,function(i){return/^is[A-Z]/.test(i)});function Rs(r,i,s){if(!O.isObject(r))throw new TypeError("target must be an object");i=i||new FormData,s=O.toFlatObject(s,{metaTokens:!0,dots:!1,indexes:!1},!1,function(N,_){return!O.isUndefined(_[N])});const l=s.metaTokens,c=s.visitor||S,d=s.dots,p=s.indexes,w=(s.Blob||typeof Blob<"u"&&Blob)&&O.isSpecCompliantForm(i);if(!O.isFunction(c))throw new TypeError("visitor must be a function");function v(T){if(T===null)return"";if(O.isDate(T))return T.toISOString();if(!w&&O.isBlob(T))throw new ue("Blob is not supported. Use a Buffer instead.");return O.isArrayBuffer(T)||O.isTypedArray(T)?w&&typeof Blob=="function"?new Blob([T]):Buffer.from(T):T}function S(T,N,_){let V=T;if(T&&!_&&typeof T=="object"){if(O.endsWith(N,"{}"))N=l?N:N.slice(0,-2),T=JSON.stringify(T);else if(O.isArray(T)&&A0(T)||(O.isFileList(T)||O.endsWith(N,"[]"))&&(V=O.toArray(T)))return N=Gp(N),V.forEach(function(B,W){!(O.isUndefined(B)||B===null)&&i.append(p===!0?Bf([N],W,d):p===null?N:N+"[]",v(B))}),!1}return Ka(T)?!0:(i.append(Bf(_,N,d),v(T)),!1)}const j=[],R=Object.assign(R0,{defaultVisitor:S,convertValue:v,isVisitable:Ka});function L(T,N){if(!O.isUndefined(T)){if(j.indexOf(T)!==-1)throw Error("Circular reference detected in "+N.join("."));j.push(T),O.forEach(T,function(V,U){(!(O.isUndefined(V)||V===null)&&c.call(i,V,O.isString(U)?U.trim():U,N,R))===!0&&L(V,N?N.concat(U):[U])}),j.pop()}}if(!O.isObject(r))throw new TypeError("data must be an object");return L(r),i}function Ff(r){const i={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(r).replace(/[!'()~]|%20|%00/g,function(l){return i[l]})}function du(r,i){this._pairs=[],r&&Rs(r,this,i)}const Kp=du.prototype;Kp.append=function(i,s){this._pairs.push([i,s])};Kp.toString=function(i){const s=i?function(l){return i.call(this,l,Ff)}:Ff;return this._pairs.map(function(c){return s(c[0])+"="+s(c[1])},"").join("&")};function P0(r){return encodeURIComponent(r).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}function Xp(r,i,s){if(!i)return r;const l=s&&s.encode||P0;O.isFunction(s)&&(s={serialize:s});const c=s&&s.serialize;let d;if(c?d=c(i,s):d=O.isURLSearchParams(i)?i.toString():new du(i,s).toString(l),d){const p=r.indexOf("#");p!==-1&&(r=r.slice(0,p)),r+=(r.indexOf("?")===-1?"?":"&")+d}return r}class bf{constructor(){this.handlers=[]}use(i,s,l){return this.handlers.push({fulfilled:i,rejected:s,synchronous:l?l.synchronous:!1,runWhen:l?l.runWhen:null}),this.handlers.length-1}eject(i){this.handlers[i]&&(this.handlers[i]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(i){O.forEach(this.handlers,function(l){l!==null&&i(l)})}}const Jp={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},T0=typeof URLSearchParams<"u"?URLSearchParams:du,_0=typeof FormData<"u"?FormData:null,N0=typeof Blob<"u"?Blob:null,O0={isBrowser:!0,classes:{URLSearchParams:T0,FormData:_0,Blob:N0},protocols:["http","https","file","blob","url","data"]},fu=typeof window<"u"&&typeof document<"u",Xa=typeof navigator=="object"&&navigator||void 0,M0=fu&&(!Xa||["ReactNative","NativeScript","NS"].indexOf(Xa.product)<0),L0=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",I0=fu&&window.location.href||"http://localhost",D0=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:fu,hasStandardBrowserEnv:M0,hasStandardBrowserWebWorkerEnv:L0,navigator:Xa,origin:I0},Symbol.toStringTag,{value:"Module"})),tt={...D0,...O0};function z0(r,i){return Rs(r,new tt.classes.URLSearchParams,Object.assign({visitor:function(s,l,c,d){return tt.isNode&&O.isBuffer(s)?(this.append(l,s.toString("base64")),!1):d.defaultVisitor.apply(this,arguments)}},i))}function $0(r){return O.matchAll(/\w+|\[(\w*)]/g,r).map(i=>i[0]==="[]"?"":i[1]||i[0])}function B0(r){const i={},s=Object.keys(r);let l;const c=s.length;let d;for(l=0;l=s.length;return p=!p&&O.isArray(c)?c.length:p,w?(O.hasOwnProp(c,p)?c[p]=[c[p],l]:c[p]=l,!m):((!c[p]||!O.isObject(c[p]))&&(c[p]=[]),i(s,l,c[p],d)&&O.isArray(c[p])&&(c[p]=B0(c[p])),!m)}if(O.isFormData(r)&&O.isFunction(r.entries)){const s={};return O.forEachEntry(r,(l,c)=>{i($0(l),c,s,0)}),s}return null}function F0(r,i,s){if(O.isString(r))try{return(i||JSON.parse)(r),O.trim(r)}catch(l){if(l.name!=="SyntaxError")throw l}return(0,JSON.stringify)(r)}const Do={transitional:Jp,adapter:["xhr","http","fetch"],transformRequest:[function(i,s){const l=s.getContentType()||"",c=l.indexOf("application/json")>-1,d=O.isObject(i);if(d&&O.isHTMLForm(i)&&(i=new FormData(i)),O.isFormData(i))return c?JSON.stringify(Zp(i)):i;if(O.isArrayBuffer(i)||O.isBuffer(i)||O.isStream(i)||O.isFile(i)||O.isBlob(i)||O.isReadableStream(i))return i;if(O.isArrayBufferView(i))return i.buffer;if(O.isURLSearchParams(i))return s.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),i.toString();let m;if(d){if(l.indexOf("application/x-www-form-urlencoded")>-1)return z0(i,this.formSerializer).toString();if((m=O.isFileList(i))||l.indexOf("multipart/form-data")>-1){const w=this.env&&this.env.FormData;return Rs(m?{"files[]":i}:i,w&&new w,this.formSerializer)}}return d||c?(s.setContentType("application/json",!1),F0(i)):i}],transformResponse:[function(i){const s=this.transitional||Do.transitional,l=s&&s.forcedJSONParsing,c=this.responseType==="json";if(O.isResponse(i)||O.isReadableStream(i))return i;if(i&&O.isString(i)&&(l&&!this.responseType||c)){const p=!(s&&s.silentJSONParsing)&&c;try{return JSON.parse(i)}catch(m){if(p)throw m.name==="SyntaxError"?ue.from(m,ue.ERR_BAD_RESPONSE,this,null,this.response):m}}return i}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:tt.classes.FormData,Blob:tt.classes.Blob},validateStatus:function(i){return i>=200&&i<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};O.forEach(["delete","get","head","post","put","patch"],r=>{Do.headers[r]={}});const b0=O.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),U0=r=>{const i={};let s,l,c;return r&&r.split(` +`).forEach(function(p){c=p.indexOf(":"),s=p.substring(0,c).trim().toLowerCase(),l=p.substring(c+1).trim(),!(!s||i[s]&&b0[s])&&(s==="set-cookie"?i[s]?i[s].push(l):i[s]=[l]:i[s]=i[s]?i[s]+", "+l:l)}),i},Uf=Symbol("internals");function wo(r){return r&&String(r).trim().toLowerCase()}function us(r){return r===!1||r==null?r:O.isArray(r)?r.map(us):String(r)}function H0(r){const i=Object.create(null),s=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let l;for(;l=s.exec(r);)i[l[1]]=l[2];return i}const V0=r=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(r.trim());function Ma(r,i,s,l,c){if(O.isFunction(l))return l.call(this,i,s);if(c&&(i=s),!!O.isString(i)){if(O.isString(l))return i.indexOf(l)!==-1;if(O.isRegExp(l))return l.test(i)}}function W0(r){return r.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(i,s,l)=>s.toUpperCase()+l)}function q0(r,i){const s=O.toCamelCase(" "+i);["get","set","has"].forEach(l=>{Object.defineProperty(r,l+s,{value:function(c,d,p){return this[l].call(this,i,c,d,p)},configurable:!0})})}class pt{constructor(i){i&&this.set(i)}set(i,s,l){const c=this;function d(m,w,v){const S=wo(w);if(!S)throw new Error("header name must be a non-empty string");const j=O.findKey(c,S);(!j||c[j]===void 0||v===!0||v===void 0&&c[j]!==!1)&&(c[j||w]=us(m))}const p=(m,w)=>O.forEach(m,(v,S)=>d(v,S,w));if(O.isPlainObject(i)||i instanceof this.constructor)p(i,s);else if(O.isString(i)&&(i=i.trim())&&!V0(i))p(U0(i),s);else if(O.isHeaders(i))for(const[m,w]of i.entries())d(w,m,l);else i!=null&&d(s,i,l);return this}get(i,s){if(i=wo(i),i){const l=O.findKey(this,i);if(l){const c=this[l];if(!s)return c;if(s===!0)return H0(c);if(O.isFunction(s))return s.call(this,c,l);if(O.isRegExp(s))return s.exec(c);throw new TypeError("parser must be boolean|regexp|function")}}}has(i,s){if(i=wo(i),i){const l=O.findKey(this,i);return!!(l&&this[l]!==void 0&&(!s||Ma(this,this[l],l,s)))}return!1}delete(i,s){const l=this;let c=!1;function d(p){if(p=wo(p),p){const m=O.findKey(l,p);m&&(!s||Ma(l,l[m],m,s))&&(delete l[m],c=!0)}}return O.isArray(i)?i.forEach(d):d(i),c}clear(i){const s=Object.keys(this);let l=s.length,c=!1;for(;l--;){const d=s[l];(!i||Ma(this,this[d],d,i,!0))&&(delete this[d],c=!0)}return c}normalize(i){const s=this,l={};return O.forEach(this,(c,d)=>{const p=O.findKey(l,d);if(p){s[p]=us(c),delete s[d];return}const m=i?W0(d):String(d).trim();m!==d&&delete s[d],s[m]=us(c),l[m]=!0}),this}concat(...i){return this.constructor.concat(this,...i)}toJSON(i){const s=Object.create(null);return O.forEach(this,(l,c)=>{l!=null&&l!==!1&&(s[c]=i&&O.isArray(l)?l.join(", "):l)}),s}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([i,s])=>i+": "+s).join(` +`)}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(i){return i instanceof this?i:new this(i)}static concat(i,...s){const l=new this(i);return s.forEach(c=>l.set(c)),l}static accessor(i){const l=(this[Uf]=this[Uf]={accessors:{}}).accessors,c=this.prototype;function d(p){const m=wo(p);l[m]||(q0(c,p),l[m]=!0)}return O.isArray(i)?i.forEach(d):d(i),this}}pt.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);O.reduceDescriptors(pt.prototype,({value:r},i)=>{let s=i[0].toUpperCase()+i.slice(1);return{get:()=>r,set(l){this[s]=l}}});O.freezeMethods(pt);function La(r,i){const s=this||Do,l=i||s,c=pt.from(l.headers);let d=l.data;return O.forEach(r,function(m){d=m.call(s,d,c.normalize(),i?i.status:void 0)}),c.normalize(),d}function eh(r){return!!(r&&r.__CANCEL__)}function Nr(r,i,s){ue.call(this,r??"canceled",ue.ERR_CANCELED,i,s),this.name="CanceledError"}O.inherits(Nr,ue,{__CANCEL__:!0});function th(r,i,s){const l=s.config.validateStatus;!s.status||!l||l(s.status)?r(s):i(new ue("Request failed with status code "+s.status,[ue.ERR_BAD_REQUEST,ue.ERR_BAD_RESPONSE][Math.floor(s.status/100)-4],s.config,s.request,s))}function Y0(r){const i=/^([-+\w]{1,25})(:?\/\/|:)/.exec(r);return i&&i[1]||""}function Q0(r,i){r=r||10;const s=new Array(r),l=new Array(r);let c=0,d=0,p;return i=i!==void 0?i:1e3,function(w){const v=Date.now(),S=l[d];p||(p=v),s[c]=w,l[c]=v;let j=d,R=0;for(;j!==c;)R+=s[j++],j=j%r;if(c=(c+1)%r,c===d&&(d=(d+1)%r),v-p{s=S,c=null,d&&(clearTimeout(d),d=null),r.apply(null,v)};return[(...v)=>{const S=Date.now(),j=S-s;j>=l?p(v,S):(c=v,d||(d=setTimeout(()=>{d=null,p(c)},l-j)))},()=>c&&p(c)]}const gs=(r,i,s=3)=>{let l=0;const c=Q0(50,250);return G0(d=>{const p=d.loaded,m=d.lengthComputable?d.total:void 0,w=p-l,v=c(w),S=p<=m;l=p;const j={loaded:p,total:m,progress:m?p/m:void 0,bytes:w,rate:v||void 0,estimated:v&&m&&S?(m-p)/v:void 0,event:d,lengthComputable:m!=null,[i?"download":"upload"]:!0};r(j)},s)},Hf=(r,i)=>{const s=r!=null;return[l=>i[0]({lengthComputable:s,total:r,loaded:l}),i[1]]},Vf=r=>(...i)=>O.asap(()=>r(...i)),K0=tt.hasStandardBrowserEnv?((r,i)=>s=>(s=new URL(s,tt.origin),r.protocol===s.protocol&&r.host===s.host&&(i||r.port===s.port)))(new URL(tt.origin),tt.navigator&&/(msie|trident)/i.test(tt.navigator.userAgent)):()=>!0,X0=tt.hasStandardBrowserEnv?{write(r,i,s,l,c,d){const p=[r+"="+encodeURIComponent(i)];O.isNumber(s)&&p.push("expires="+new Date(s).toGMTString()),O.isString(l)&&p.push("path="+l),O.isString(c)&&p.push("domain="+c),d===!0&&p.push("secure"),document.cookie=p.join("; ")},read(r){const i=document.cookie.match(new RegExp("(^|;\\s*)("+r+")=([^;]*)"));return i?decodeURIComponent(i[3]):null},remove(r){this.write(r,"",Date.now()-864e5)}}:{write(){},read(){return null},remove(){}};function J0(r){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(r)}function Z0(r,i){return i?r.replace(/\/?\/$/,"")+"/"+i.replace(/^\/+/,""):r}function nh(r,i){return r&&!J0(i)?Z0(r,i):i}const Wf=r=>r instanceof pt?{...r}:r;function Yn(r,i){i=i||{};const s={};function l(v,S,j,R){return O.isPlainObject(v)&&O.isPlainObject(S)?O.merge.call({caseless:R},v,S):O.isPlainObject(S)?O.merge({},S):O.isArray(S)?S.slice():S}function c(v,S,j,R){if(O.isUndefined(S)){if(!O.isUndefined(v))return l(void 0,v,j,R)}else return l(v,S,j,R)}function d(v,S){if(!O.isUndefined(S))return l(void 0,S)}function p(v,S){if(O.isUndefined(S)){if(!O.isUndefined(v))return l(void 0,v)}else return l(void 0,S)}function m(v,S,j){if(j in i)return l(v,S);if(j in r)return l(void 0,v)}const w={url:d,method:d,data:d,baseURL:p,transformRequest:p,transformResponse:p,paramsSerializer:p,timeout:p,timeoutMessage:p,withCredentials:p,withXSRFToken:p,adapter:p,responseType:p,xsrfCookieName:p,xsrfHeaderName:p,onUploadProgress:p,onDownloadProgress:p,decompress:p,maxContentLength:p,maxBodyLength:p,beforeRedirect:p,transport:p,httpAgent:p,httpsAgent:p,cancelToken:p,socketPath:p,responseEncoding:p,validateStatus:m,headers:(v,S,j)=>c(Wf(v),Wf(S),j,!0)};return O.forEach(Object.keys(Object.assign({},r,i)),function(S){const j=w[S]||c,R=j(r[S],i[S],S);O.isUndefined(R)&&j!==m||(s[S]=R)}),s}const rh=r=>{const i=Yn({},r);let{data:s,withXSRFToken:l,xsrfHeaderName:c,xsrfCookieName:d,headers:p,auth:m}=i;i.headers=p=pt.from(p),i.url=Xp(nh(i.baseURL,i.url),r.params,r.paramsSerializer),m&&p.set("Authorization","Basic "+btoa((m.username||"")+":"+(m.password?unescape(encodeURIComponent(m.password)):"")));let w;if(O.isFormData(s)){if(tt.hasStandardBrowserEnv||tt.hasStandardBrowserWebWorkerEnv)p.setContentType(void 0);else if((w=p.getContentType())!==!1){const[v,...S]=w?w.split(";").map(j=>j.trim()).filter(Boolean):[];p.setContentType([v||"multipart/form-data",...S].join("; "))}}if(tt.hasStandardBrowserEnv&&(l&&O.isFunction(l)&&(l=l(i)),l||l!==!1&&K0(i.url))){const v=c&&d&&X0.read(d);v&&p.set(c,v)}return i},ev=typeof XMLHttpRequest<"u",tv=ev&&function(r){return new Promise(function(s,l){const c=rh(r);let d=c.data;const p=pt.from(c.headers).normalize();let{responseType:m,onUploadProgress:w,onDownloadProgress:v}=c,S,j,R,L,T;function N(){L&&L(),T&&T(),c.cancelToken&&c.cancelToken.unsubscribe(S),c.signal&&c.signal.removeEventListener("abort",S)}let _=new XMLHttpRequest;_.open(c.method.toUpperCase(),c.url,!0),_.timeout=c.timeout;function V(){if(!_)return;const B=pt.from("getAllResponseHeaders"in _&&_.getAllResponseHeaders()),I={data:!m||m==="text"||m==="json"?_.responseText:_.response,status:_.status,statusText:_.statusText,headers:B,config:r,request:_};th(function(H){s(H),N()},function(H){l(H),N()},I),_=null}"onloadend"in _?_.onloadend=V:_.onreadystatechange=function(){!_||_.readyState!==4||_.status===0&&!(_.responseURL&&_.responseURL.indexOf("file:")===0)||setTimeout(V)},_.onabort=function(){_&&(l(new ue("Request aborted",ue.ECONNABORTED,r,_)),_=null)},_.onerror=function(){l(new ue("Network Error",ue.ERR_NETWORK,r,_)),_=null},_.ontimeout=function(){let W=c.timeout?"timeout of "+c.timeout+"ms exceeded":"timeout exceeded";const I=c.transitional||Jp;c.timeoutErrorMessage&&(W=c.timeoutErrorMessage),l(new ue(W,I.clarifyTimeoutError?ue.ETIMEDOUT:ue.ECONNABORTED,r,_)),_=null},d===void 0&&p.setContentType(null),"setRequestHeader"in _&&O.forEach(p.toJSON(),function(W,I){_.setRequestHeader(I,W)}),O.isUndefined(c.withCredentials)||(_.withCredentials=!!c.withCredentials),m&&m!=="json"&&(_.responseType=c.responseType),v&&([R,T]=gs(v,!0),_.addEventListener("progress",R)),w&&_.upload&&([j,L]=gs(w),_.upload.addEventListener("progress",j),_.upload.addEventListener("loadend",L)),(c.cancelToken||c.signal)&&(S=B=>{_&&(l(!B||B.type?new Nr(null,r,_):B),_.abort(),_=null)},c.cancelToken&&c.cancelToken.subscribe(S),c.signal&&(c.signal.aborted?S():c.signal.addEventListener("abort",S)));const U=Y0(c.url);if(U&&tt.protocols.indexOf(U)===-1){l(new ue("Unsupported protocol "+U+":",ue.ERR_BAD_REQUEST,r));return}_.send(d||null)})},nv=(r,i)=>{const{length:s}=r=r?r.filter(Boolean):[];if(i||s){let l=new AbortController,c;const d=function(v){if(!c){c=!0,m();const S=v instanceof Error?v:this.reason;l.abort(S instanceof ue?S:new Nr(S instanceof Error?S.message:S))}};let p=i&&setTimeout(()=>{p=null,d(new ue(`timeout ${i} of ms exceeded`,ue.ETIMEDOUT))},i);const m=()=>{r&&(p&&clearTimeout(p),p=null,r.forEach(v=>{v.unsubscribe?v.unsubscribe(d):v.removeEventListener("abort",d)}),r=null)};r.forEach(v=>v.addEventListener("abort",d));const{signal:w}=l;return w.unsubscribe=()=>O.asap(m),w}},rv=function*(r,i){let s=r.byteLength;if(s{const c=ov(r,i);let d=0,p,m=w=>{p||(p=!0,l&&l(w))};return new ReadableStream({async pull(w){try{const{done:v,value:S}=await c.next();if(v){m(),w.close();return}let j=S.byteLength;if(s){let R=d+=j;s(R)}w.enqueue(new Uint8Array(S))}catch(v){throw m(v),v}},cancel(w){return m(w),c.return()}},{highWaterMark:2})},Ps=typeof fetch=="function"&&typeof Request=="function"&&typeof Response=="function",oh=Ps&&typeof ReadableStream=="function",sv=Ps&&(typeof TextEncoder=="function"?(r=>i=>r.encode(i))(new TextEncoder):async r=>new Uint8Array(await new Response(r).arrayBuffer())),ih=(r,...i)=>{try{return!!r(...i)}catch{return!1}},lv=oh&&ih(()=>{let r=!1;const i=new Request(tt.origin,{body:new ReadableStream,method:"POST",get duplex(){return r=!0,"half"}}).headers.has("Content-Type");return r&&!i}),Yf=64*1024,Ja=oh&&ih(()=>O.isReadableStream(new Response("").body)),ys={stream:Ja&&(r=>r.body)};Ps&&(r=>{["text","arrayBuffer","blob","formData","stream"].forEach(i=>{!ys[i]&&(ys[i]=O.isFunction(r[i])?s=>s[i]():(s,l)=>{throw new ue(`Response type '${i}' is not supported`,ue.ERR_NOT_SUPPORT,l)})})})(new Response);const av=async r=>{if(r==null)return 0;if(O.isBlob(r))return r.size;if(O.isSpecCompliantForm(r))return(await new Request(tt.origin,{method:"POST",body:r}).arrayBuffer()).byteLength;if(O.isArrayBufferView(r)||O.isArrayBuffer(r))return r.byteLength;if(O.isURLSearchParams(r)&&(r=r+""),O.isString(r))return(await sv(r)).byteLength},uv=async(r,i)=>{const s=O.toFiniteNumber(r.getContentLength());return s??av(i)},cv=Ps&&(async r=>{let{url:i,method:s,data:l,signal:c,cancelToken:d,timeout:p,onDownloadProgress:m,onUploadProgress:w,responseType:v,headers:S,withCredentials:j="same-origin",fetchOptions:R}=rh(r);v=v?(v+"").toLowerCase():"text";let L=nv([c,d&&d.toAbortSignal()],p),T;const N=L&&L.unsubscribe&&(()=>{L.unsubscribe()});let _;try{if(w&&lv&&s!=="get"&&s!=="head"&&(_=await uv(S,l))!==0){let I=new Request(i,{method:"POST",body:l,duplex:"half"}),M;if(O.isFormData(l)&&(M=I.headers.get("content-type"))&&S.setContentType(M),I.body){const[H,ie]=Hf(_,gs(Vf(w)));l=qf(I.body,Yf,H,ie)}}O.isString(j)||(j=j?"include":"omit");const V="credentials"in Request.prototype;T=new Request(i,{...R,signal:L,method:s.toUpperCase(),headers:S.normalize().toJSON(),body:l,duplex:"half",credentials:V?j:void 0});let U=await fetch(T);const B=Ja&&(v==="stream"||v==="response");if(Ja&&(m||B&&N)){const I={};["status","statusText","headers"].forEach(ve=>{I[ve]=U[ve]});const M=O.toFiniteNumber(U.headers.get("content-length")),[H,ie]=m&&Hf(M,gs(Vf(m),!0))||[];U=new Response(qf(U.body,Yf,H,()=>{ie&&ie(),N&&N()}),I)}v=v||"text";let W=await ys[O.findKey(ys,v)||"text"](U,r);return!B&&N&&N(),await new Promise((I,M)=>{th(I,M,{data:W,headers:pt.from(U.headers),status:U.status,statusText:U.statusText,config:r,request:T})})}catch(V){throw N&&N(),V&&V.name==="TypeError"&&/fetch/i.test(V.message)?Object.assign(new ue("Network Error",ue.ERR_NETWORK,r,T),{cause:V.cause||V}):ue.from(V,V&&V.code,r,T)}}),Za={http:j0,xhr:tv,fetch:cv};O.forEach(Za,(r,i)=>{if(r){try{Object.defineProperty(r,"name",{value:i})}catch{}Object.defineProperty(r,"adapterName",{value:i})}});const Qf=r=>`- ${r}`,dv=r=>O.isFunction(r)||r===null||r===!1,sh={getAdapter:r=>{r=O.isArray(r)?r:[r];const{length:i}=r;let s,l;const c={};for(let d=0;d`adapter ${m} `+(w===!1?"is not supported by the environment":"is not available in the build"));let p=i?d.length>1?`since : +`+d.map(Qf).join(` +`):" "+Qf(d[0]):"as no adapter specified";throw new ue("There is no suitable adapter to dispatch the request "+p,"ERR_NOT_SUPPORT")}return l},adapters:Za};function Ia(r){if(r.cancelToken&&r.cancelToken.throwIfRequested(),r.signal&&r.signal.aborted)throw new Nr(null,r)}function Gf(r){return Ia(r),r.headers=pt.from(r.headers),r.data=La.call(r,r.transformRequest),["post","put","patch"].indexOf(r.method)!==-1&&r.headers.setContentType("application/x-www-form-urlencoded",!1),sh.getAdapter(r.adapter||Do.adapter)(r).then(function(l){return Ia(r),l.data=La.call(r,r.transformResponse,l),l.headers=pt.from(l.headers),l},function(l){return eh(l)||(Ia(r),l&&l.response&&(l.response.data=La.call(r,r.transformResponse,l.response),l.response.headers=pt.from(l.response.headers))),Promise.reject(l)})}const lh="1.7.9",Ts={};["object","boolean","number","function","string","symbol"].forEach((r,i)=>{Ts[r]=function(l){return typeof l===r||"a"+(i<1?"n ":" ")+r}});const Kf={};Ts.transitional=function(i,s,l){function c(d,p){return"[Axios v"+lh+"] Transitional option '"+d+"'"+p+(l?". "+l:"")}return(d,p,m)=>{if(i===!1)throw new ue(c(p," has been removed"+(s?" in "+s:"")),ue.ERR_DEPRECATED);return s&&!Kf[p]&&(Kf[p]=!0,console.warn(c(p," has been deprecated since v"+s+" and will be removed in the near future"))),i?i(d,p,m):!0}};Ts.spelling=function(i){return(s,l)=>(console.warn(`${l} is likely a misspelling of ${i}`),!0)};function fv(r,i,s){if(typeof r!="object")throw new ue("options must be an object",ue.ERR_BAD_OPTION_VALUE);const l=Object.keys(r);let c=l.length;for(;c-- >0;){const d=l[c],p=i[d];if(p){const m=r[d],w=m===void 0||p(m,d,r);if(w!==!0)throw new ue("option "+d+" must be "+w,ue.ERR_BAD_OPTION_VALUE);continue}if(s!==!0)throw new ue("Unknown option "+d,ue.ERR_BAD_OPTION)}}const cs={assertOptions:fv,validators:Ts},Vt=cs.validators;class Vn{constructor(i){this.defaults=i,this.interceptors={request:new bf,response:new bf}}async request(i,s){try{return await this._request(i,s)}catch(l){if(l instanceof Error){let c={};Error.captureStackTrace?Error.captureStackTrace(c):c=new Error;const d=c.stack?c.stack.replace(/^.+\n/,""):"";try{l.stack?d&&!String(l.stack).endsWith(d.replace(/^.+\n.+\n/,""))&&(l.stack+=` +`+d):l.stack=d}catch{}}throw l}}_request(i,s){typeof i=="string"?(s=s||{},s.url=i):s=i||{},s=Yn(this.defaults,s);const{transitional:l,paramsSerializer:c,headers:d}=s;l!==void 0&&cs.assertOptions(l,{silentJSONParsing:Vt.transitional(Vt.boolean),forcedJSONParsing:Vt.transitional(Vt.boolean),clarifyTimeoutError:Vt.transitional(Vt.boolean)},!1),c!=null&&(O.isFunction(c)?s.paramsSerializer={serialize:c}:cs.assertOptions(c,{encode:Vt.function,serialize:Vt.function},!0)),cs.assertOptions(s,{baseUrl:Vt.spelling("baseURL"),withXsrfToken:Vt.spelling("withXSRFToken")},!0),s.method=(s.method||this.defaults.method||"get").toLowerCase();let p=d&&O.merge(d.common,d[s.method]);d&&O.forEach(["delete","get","head","post","put","patch","common"],T=>{delete d[T]}),s.headers=pt.concat(p,d);const m=[];let w=!0;this.interceptors.request.forEach(function(N){typeof N.runWhen=="function"&&N.runWhen(s)===!1||(w=w&&N.synchronous,m.unshift(N.fulfilled,N.rejected))});const v=[];this.interceptors.response.forEach(function(N){v.push(N.fulfilled,N.rejected)});let S,j=0,R;if(!w){const T=[Gf.bind(this),void 0];for(T.unshift.apply(T,m),T.push.apply(T,v),R=T.length,S=Promise.resolve(s);j{if(!l._listeners)return;let d=l._listeners.length;for(;d-- >0;)l._listeners[d](c);l._listeners=null}),this.promise.then=c=>{let d;const p=new Promise(m=>{l.subscribe(m),d=m}).then(c);return p.cancel=function(){l.unsubscribe(d)},p},i(function(d,p,m){l.reason||(l.reason=new Nr(d,p,m),s(l.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(i){if(this.reason){i(this.reason);return}this._listeners?this._listeners.push(i):this._listeners=[i]}unsubscribe(i){if(!this._listeners)return;const s=this._listeners.indexOf(i);s!==-1&&this._listeners.splice(s,1)}toAbortSignal(){const i=new AbortController,s=l=>{i.abort(l)};return this.subscribe(s),i.signal.unsubscribe=()=>this.unsubscribe(s),i.signal}static source(){let i;return{token:new pu(function(c){i=c}),cancel:i}}}function pv(r){return function(s){return r.apply(null,s)}}function hv(r){return O.isObject(r)&&r.isAxiosError===!0}const eu={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511};Object.entries(eu).forEach(([r,i])=>{eu[i]=r});function ah(r){const i=new Vn(r),s=Bp(Vn.prototype.request,i);return O.extend(s,Vn.prototype,i,{allOwnKeys:!0}),O.extend(s,i,null,{allOwnKeys:!0}),s.create=function(c){return ah(Yn(r,c))},s}const Be=ah(Do);Be.Axios=Vn;Be.CanceledError=Nr;Be.CancelToken=pu;Be.isCancel=eh;Be.VERSION=lh;Be.toFormData=Rs;Be.AxiosError=ue;Be.Cancel=Be.CanceledError;Be.all=function(i){return Promise.all(i)};Be.spread=pv;Be.isAxiosError=hv;Be.mergeConfig=Yn;Be.AxiosHeaders=pt;Be.formToJSON=r=>Zp(O.isHTMLForm(r)?new FormData(r):r);Be.getAdapter=sh.getAdapter;Be.HttpStatusCode=eu;Be.default=Be;const uh={apiBaseUrl:"/api"};class mv{constructor(){uf(this,"events",{})}on(i,s){return this.events[i]||(this.events[i]=[]),this.events[i].push(s),()=>this.off(i,s)}off(i,s){this.events[i]&&(this.events[i]=this.events[i].filter(l=>l!==s))}emit(i,s){this.events[i]&&this.events[i].forEach(l=>{l(s)})}}const Sr=new mv,gv=async(r,i)=>{const s=new FormData;return s.append("username",r),s.append("password",i),(await zo.post("/auth/login",s,{headers:{"Content-Type":"multipart/form-data"}})).data},yv=async r=>(await zo.post("/users",r,{headers:{"Content-Type":"multipart/form-data"}})).data,vv=async()=>{await zo.get("/auth/csrf-token")},xv=async()=>{await zo.post("/auth/logout")},wv=async()=>(await zo.post("/auth/refresh")).data,Sv=async(r,i)=>{const s={userId:r,newRole:i};return(await $e.put("/auth/role",s)).data},rt=Tr((r,i)=>({currentUser:null,accessToken:null,login:async(s,l)=>{const{userDto:c,accessToken:d}=await gv(s,l);await i().fetchCsrfToken(),r({currentUser:c,accessToken:d})},logout:async()=>{await xv(),i().clear(),i().fetchCsrfToken()},fetchCsrfToken:async()=>{await vv()},refreshToken:async()=>{i().clear();const{userDto:s,accessToken:l}=await wv();r({currentUser:s,accessToken:l})},clear:()=>{r({currentUser:null,accessToken:null})},updateUserRole:async(s,l)=>{await Sv(s,l)}}));let So=[],Xi=!1;const $e=Be.create({baseURL:uh.apiBaseUrl,headers:{"Content-Type":"application/json"},withCredentials:!0}),zo=Be.create({baseURL:uh.apiBaseUrl,headers:{"Content-Type":"application/json"},withCredentials:!0});$e.interceptors.request.use(r=>{const i=rt.getState().accessToken;return i&&(r.headers.Authorization=`Bearer ${i}`),r},r=>Promise.reject(r));$e.interceptors.response.use(r=>r,async r=>{var s,l,c,d;const i=(s=r.response)==null?void 0:s.data;if(i){const p=(c=(l=r.response)==null?void 0:l.headers)==null?void 0:c["discodeit-request-id"];p&&(i.requestId=p),r.response.data=i}if(console.log({error:r,errorResponse:i}),Sr.emit("api-error",{error:r,alert:((d=r.response)==null?void 0:d.status)===403}),r.response&&r.response.status===401){const p=r.config;if(p&&p.headers&&p.headers._retry)return Sr.emit("auth-error"),Promise.reject(r);if(Xi&&p)return new Promise((m,w)=>{So.push({config:p,resolve:m,reject:w})});if(p){Xi=!0;try{return await rt.getState().refreshToken(),So.forEach(({config:m,resolve:w,reject:v})=>{m.headers=m.headers||{},m.headers._retry="true",$e(m).then(w).catch(v)}),p.headers=p.headers||{},p.headers._retry="true",So=[],Xi=!1,$e(p)}catch(m){return So.forEach(({reject:w})=>w(m)),So=[],Xi=!1,Sr.emit("auth-error"),Promise.reject(m)}}}return Promise.reject(r)});const kv=async(r,i)=>(await $e.patch(`/users/${r}`,i,{headers:{"Content-Type":"multipart/form-data"}})).data,Cv=async()=>(await $e.get("/users")).data,Rr=Tr(r=>({users:[],fetchUsers:async()=>{try{const i=await Cv();r({users:i})}catch(i){console.error("사용자 목록 조회 실패:",i)}}})),Y={colors:{brand:{primary:"#5865F2",hover:"#4752C4"},background:{primary:"#1a1a1a",secondary:"#2a2a2a",tertiary:"#333333",input:"#40444B",hover:"rgba(255, 255, 255, 0.1)"},text:{primary:"#ffffff",secondary:"#cccccc",muted:"#999999"},status:{online:"#43b581",idle:"#faa61a",dnd:"#f04747",offline:"#747f8d",error:"#ED4245"},border:{primary:"#404040"}}},ch=C.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,dh=C.div` + background: ${Y.colors.background.primary}; + padding: 32px; + border-radius: 8px; + width: 440px; + + h2 { + color: ${Y.colors.text.primary}; + margin-bottom: 24px; + font-size: 24px; + font-weight: bold; + } + + form { + display: flex; + flex-direction: column; + gap: 16px; + } +`,Ro=C.input` + width: 100%; + padding: 10px; + border-radius: 4px; + background: ${Y.colors.background.input}; + border: none; + color: ${Y.colors.text.primary}; + font-size: 16px; + + &::placeholder { + color: ${Y.colors.text.muted}; + } + + &:focus { + outline: none; + } +`;C.input.attrs({type:"checkbox"})` + width: 16px; + height: 16px; + padding: 0; + border-radius: 4px; + background: ${Y.colors.background.input}; + border: none; + color: ${Y.colors.text.primary}; + cursor: pointer; + + &:focus { + outline: none; + } + + &:checked { + background: ${Y.colors.brand.primary}; + } +`;const fh=C.button` + width: 100%; + padding: 12px; + border-radius: 4px; + background: ${Y.colors.brand.primary}; + color: white; + font-size: 16px; + font-weight: 500; + border: none; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: ${Y.colors.brand.hover}; + } +`,ph=C.div` + color: ${Y.colors.status.error}; + font-size: 14px; + text-align: center; +`,Ev=C.p` + text-align: center; + margin-top: 16px; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 14px; +`,jv=C.span` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`,Ji=C.div` + margin-bottom: 20px; +`,Zi=C.label` + display: block; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +`,Da=C.span` + color: ${({theme:r})=>r.colors.status.error}; +`,Av=C.div` + display: flex; + flex-direction: column; + align-items: center; + margin: 10px 0; +`,Rv=C.img` + width: 80px; + height: 80px; + border-radius: 50%; + margin-bottom: 10px; + object-fit: cover; +`,Pv=C.input` + display: none; +`,Tv=C.label` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +`,_v=C.span` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`,Nv=C(_v)` + display: block; + text-align: center; + margin-top: 16px; +`,St="data:image/png;base64,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",Ov=({isOpen:r,onClose:i})=>{const[s,l]=K.useState(""),[c,d]=K.useState(""),[p,m]=K.useState(""),[w,v]=K.useState(null),[S,j]=K.useState(null),[R,L]=K.useState(""),{fetchCsrfToken:T}=rt(),N=K.useCallback(()=>{S&&URL.revokeObjectURL(S),j(null),v(null),l(""),d(""),m(""),L("")},[S]),_=K.useCallback(()=>{N(),i()},[]),V=B=>{var I;const W=(I=B.target.files)==null?void 0:I[0];if(W){v(W);const M=new FileReader;M.onloadend=()=>{j(M.result)},M.readAsDataURL(W)}},U=async B=>{B.preventDefault(),L("");try{const W=new FormData;W.append("userCreateRequest",new Blob([JSON.stringify({email:s,username:c,password:p})],{type:"application/json"})),w&&W.append("profile",w),await yv(W),await T(),i()}catch{L("회원가입에 실패했습니다.")}};return r?h.jsx(ch,{children:h.jsxs(dh,{children:[h.jsx("h2",{children:"계정 만들기"}),h.jsxs("form",{onSubmit:U,children:[h.jsxs(Ji,{children:[h.jsxs(Zi,{children:["이메일 ",h.jsx(Da,{children:"*"})]}),h.jsx(Ro,{type:"email",value:s,onChange:B=>l(B.target.value),required:!0})]}),h.jsxs(Ji,{children:[h.jsxs(Zi,{children:["사용자명 ",h.jsx(Da,{children:"*"})]}),h.jsx(Ro,{type:"text",value:c,onChange:B=>d(B.target.value),required:!0})]}),h.jsxs(Ji,{children:[h.jsxs(Zi,{children:["비밀번호 ",h.jsx(Da,{children:"*"})]}),h.jsx(Ro,{type:"password",value:p,onChange:B=>m(B.target.value),required:!0})]}),h.jsxs(Ji,{children:[h.jsx(Zi,{children:"프로필 이미지"}),h.jsxs(Av,{children:[h.jsx(Rv,{src:S||St,alt:"profile"}),h.jsx(Pv,{type:"file",accept:"image/*",onChange:V,id:"profile-image"}),h.jsx(Tv,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),R&&h.jsx(ph,{children:R}),h.jsx(fh,{type:"submit",children:"계속하기"}),h.jsx(Nv,{onClick:_,children:"이미 계정이 있으신가요?"})]})]})}):null},Mv=({isOpen:r,onClose:i})=>{const[s,l]=K.useState(""),[c,d]=K.useState(""),[p,m]=K.useState(""),[w,v]=K.useState(!1),{login:S}=rt(),{fetchUsers:j}=Rr(),R=K.useCallback(()=>{l(""),d(""),m(""),v(!1)},[]),L=K.useCallback(()=>{R(),v(!0)},[R,i]),T=async()=>{var N;try{await S(s,c),await j(),R(),i()}catch(_){console.error("로그인 에러:",_),((N=_.response)==null?void 0:N.status)===401?m("아이디 또는 비밀번호가 올바르지 않습니다."):m("로그인에 실패했습니다.")}};return r?h.jsxs(h.Fragment,{children:[h.jsx(ch,{children:h.jsxs(dh,{children:[h.jsx("h2",{children:"돌아오신 것을 환영해요!"}),h.jsxs("form",{onSubmit:N=>{N.preventDefault(),T()},children:[h.jsx(Ro,{type:"text",placeholder:"사용자 이름",value:s,onChange:N=>l(N.target.value)}),h.jsx(Ro,{type:"password",placeholder:"비밀번호",value:c,onChange:N=>d(N.target.value)}),p&&h.jsx(ph,{children:p}),h.jsx(fh,{type:"submit",children:"로그인"})]}),h.jsxs(Ev,{children:["계정이 필요한가요? ",h.jsx(jv,{onClick:L,children:"가입하기"})]})]})}),h.jsx(Ov,{isOpen:w,onClose:()=>v(!1)})]}):null},Lv=async r=>(await $e.get(`/channels?userId=${r}`)).data,Iv=async r=>(await $e.post("/channels/public",r)).data,Dv=async r=>{const i={participantIds:r};return(await $e.post("/channels/private",i)).data},zv=async(r,i)=>(await $e.patch(`/channels/${r}`,i)).data,$v=async r=>{await $e.delete(`/channels/${r}`)},Bv=async r=>(await $e.get("/readStatuses",{params:{userId:r}})).data,Fv=async(r,i)=>{const s={newLastReadAt:i};return(await $e.patch(`/readStatuses/${r}`,s)).data},bv=async(r,i,s)=>{const l={userId:r,channelId:i,lastReadAt:s};return(await $e.post("/readStatuses",l)).data},Po=Tr((r,i)=>({readStatuses:{},fetchReadStatuses:async()=>{try{const{currentUser:s}=rt.getState();if(!s)return;const c=(await Bv(s.id)).reduce((d,p)=>(d[p.channelId]={id:p.id,lastReadAt:p.lastReadAt},d),{});r({readStatuses:c})}catch(s){console.error("읽음 상태 조회 실패:",s)}},updateReadStatus:async s=>{try{const{currentUser:l}=rt.getState();if(!l)return;const c=i().readStatuses[s];let d;c?d=await Fv(c.id,new Date().toISOString()):d=await bv(l.id,s,new Date().toISOString()),r(p=>({readStatuses:{...p.readStatuses,[s]:{id:d.id,lastReadAt:d.lastReadAt}}}))}catch(l){console.error("읽음 상태 업데이트 실패:",l)}},hasUnreadMessages:(s,l)=>{const c=i().readStatuses[s],d=c==null?void 0:c.lastReadAt;return!d||new Date(l)>new Date(d)}})),jn=Tr((r,i)=>({channels:[],pollingInterval:null,loading:!1,error:null,fetchChannels:async s=>{r({loading:!0,error:null});try{const l=await Lv(s);r(d=>{const p=new Set(d.channels.map(S=>S.id)),m=l.filter(S=>!p.has(S.id));return{channels:[...d.channels.filter(S=>l.some(j=>j.id===S.id)),...m],loading:!1}});const{fetchReadStatuses:c}=Po.getState();return c(),l}catch(l){return r({error:l,loading:!1}),[]}},startPolling:s=>{const l=i().pollingInterval;l&&clearInterval(l);const c=setInterval(()=>{i().fetchChannels(s)},3e3);r({pollingInterval:c})},stopPolling:()=>{const s=i().pollingInterval;s&&(clearInterval(s),r({pollingInterval:null}))},createPublicChannel:async s=>{try{const l=await Iv(s);return r(c=>c.channels.some(p=>p.id===l.id)?c:{channels:[...c.channels,{...l,participantIds:[],lastMessageAt:new Date().toISOString()}]}),l}catch(l){throw console.error("공개 채널 생성 실패:",l),l}},createPrivateChannel:async s=>{try{const l=await Dv(s);return r(c=>c.channels.some(p=>p.id===l.id)?c:{channels:[...c.channels,{...l,participantIds:s,lastMessageAt:new Date().toISOString()}]}),l}catch(l){throw console.error("비공개 채널 생성 실패:",l),l}},updatePublicChannel:async(s,l)=>{try{const c=await zv(s,l);return r(d=>({channels:d.channels.map(p=>p.id===s?{...p,...c}:p)})),c}catch(c){throw console.error("채널 수정 실패:",c),c}},deleteChannel:async s=>{try{await $v(s),r(l=>({channels:l.channels.filter(c=>c.id!==s)}))}catch(l){throw console.error("채널 삭제 실패:",l),l}}})),Uv=async r=>(await $e.get(`/binaryContents/${r}`)).data,Hv=async r=>({blob:(await $e.get(`/binaryContents/${r}/download`,{responseType:"blob"})).data}),An=Tr((r,i)=>({binaryContents:{},fetchBinaryContent:async s=>{if(i().binaryContents[s])return i().binaryContents[s];try{const l=await Uv(s),{contentType:c,fileName:d,size:p}=l,m=await Hv(s),w=URL.createObjectURL(m.blob),v={url:w,contentType:c,fileName:d,size:p,revokeUrl:()=>URL.revokeObjectURL(w)};return r(S=>({binaryContents:{...S.binaryContents,[s]:v}})),v}catch(l){return console.error("첨부파일 정보 조회 실패:",l),null}},clearBinaryContent:s=>{const{binaryContents:l}=i(),c=l[s];c!=null&&c.revokeUrl&&(c.revokeUrl(),r(d=>{const{[s]:p,...m}=d.binaryContents;return{binaryContents:m}}))},clearBinaryContents:s=>{const{binaryContents:l}=i(),c=[];s.forEach(d=>{const p=l[d];p&&(p.revokeUrl&&p.revokeUrl(),c.push(d))}),c.length>0&&r(d=>{const p={...d.binaryContents};return c.forEach(m=>{delete p[m]}),{binaryContents:p}})},clearAllBinaryContents:()=>{const{binaryContents:s}=i();Object.values(s).forEach(l=>{l.revokeUrl&&l.revokeUrl()}),r({binaryContents:{}})}})),$o=C.div` + position: absolute; + bottom: -3px; + right: -3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: ${r=>r.$online?Y.colors.status.online:Y.colors.status.offline}; + border: 4px solid ${r=>r.$background||Y.colors.background.secondary}; +`;C.div` + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; + background: ${r=>Y.colors.status[r.status||"offline"]||Y.colors.status.offline}; +`;const Or=C.div` + position: relative; + width: ${r=>r.$size||"32px"}; + height: ${r=>r.$size||"32px"}; + flex-shrink: 0; + margin: ${r=>r.$margin||"0"}; +`,nn=C.img` + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + border: ${r=>r.$border||"none"}; +`;function Vv({isOpen:r,onClose:i,user:s}){var M,H;const[l,c]=K.useState(s.username),[d,p]=K.useState(s.email),[m,w]=K.useState(""),[v,S]=K.useState(null),[j,R]=K.useState(""),[L,T]=K.useState(null),{binaryContents:N,fetchBinaryContent:_}=An(),{logout:V,refreshToken:U}=rt();K.useEffect(()=>{var ie;(ie=s.profile)!=null&&ie.id&&!N[s.profile.id]&&_(s.profile.id)},[s.profile,N,_]);const B=()=>{c(s.username),p(s.email),w(""),S(null),T(null),R(""),i()},W=ie=>{var Oe;const ve=(Oe=ie.target.files)==null?void 0:Oe[0];if(ve){S(ve);const ot=new FileReader;ot.onloadend=()=>{T(ot.result)},ot.readAsDataURL(ve)}},I=async ie=>{ie.preventDefault(),R("");try{const ve=new FormData,Oe={};l!==s.username&&(Oe.newUsername=l),d!==s.email&&(Oe.newEmail=d),m&&(Oe.newPassword=m),(Object.keys(Oe).length>0||v)&&(ve.append("userUpdateRequest",new Blob([JSON.stringify(Oe)],{type:"application/json"})),v&&ve.append("profile",v),await kv(s.id,ve),await U()),i()}catch{R("사용자 정보 수정에 실패했습니다.")}};return r?h.jsx(Wv,{children:h.jsxs(qv,{children:[h.jsx("h2",{children:"프로필 수정"}),h.jsxs("form",{onSubmit:I,children:[h.jsxs(es,{children:[h.jsx(ts,{children:"프로필 이미지"}),h.jsxs(Qv,{children:[h.jsx(Gv,{src:L||((M=s.profile)!=null&&M.id?(H=N[s.profile.id])==null?void 0:H.url:void 0)||St,alt:"profile"}),h.jsx(Kv,{type:"file",accept:"image/*",onChange:W,id:"profile-image"}),h.jsx(Xv,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),h.jsxs(es,{children:[h.jsxs(ts,{children:["사용자명 ",h.jsx(Jf,{children:"*"})]}),h.jsx(za,{type:"text",value:l,onChange:ie=>c(ie.target.value),required:!0})]}),h.jsxs(es,{children:[h.jsxs(ts,{children:["이메일 ",h.jsx(Jf,{children:"*"})]}),h.jsx(za,{type:"email",value:d,onChange:ie=>p(ie.target.value),required:!0})]}),h.jsxs(es,{children:[h.jsx(ts,{children:"새 비밀번호"}),h.jsx(za,{type:"password",placeholder:"변경하지 않으려면 비워두세요",value:m,onChange:ie=>w(ie.target.value)})]}),j&&h.jsx(Yv,{children:j}),h.jsxs(Jv,{children:[h.jsx(Xf,{type:"button",onClick:B,$secondary:!0,children:"취소"}),h.jsx(Xf,{type:"submit",children:"저장"})]})]}),h.jsx(Zv,{onClick:V,children:"로그아웃"})]})}):null}const Wv=C.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,qv=C.div` + background: ${({theme:r})=>r.colors.background.secondary}; + padding: 32px; + border-radius: 5px; + width: 100%; + max-width: 480px; + + h2 { + color: ${({theme:r})=>r.colors.text.primary}; + margin-bottom: 24px; + text-align: center; + font-size: 24px; + } +`,za=C.input` + width: 100%; + padding: 10px; + margin-bottom: 10px; + border: none; + border-radius: 4px; + background: ${({theme:r})=>r.colors.background.input}; + color: ${({theme:r})=>r.colors.text.primary}; + + &::placeholder { + color: ${({theme:r})=>r.colors.text.muted}; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${({theme:r})=>r.colors.brand.primary}; + } +`,Xf=C.button` + width: 100%; + padding: 10px; + border: none; + border-radius: 4px; + background: ${({$secondary:r,theme:i})=>r?"transparent":i.colors.brand.primary}; + color: ${({theme:r})=>r.colors.text.primary}; + cursor: pointer; + font-weight: 500; + + &:hover { + background: ${({$secondary:r,theme:i})=>r?i.colors.background.hover:i.colors.brand.hover}; + } +`,Yv=C.div` + color: ${({theme:r})=>r.colors.status.error}; + font-size: 14px; + margin-bottom: 10px; +`,Qv=C.div` + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 20px; +`,Gv=C.img` + width: 100px; + height: 100px; + border-radius: 50%; + margin-bottom: 10px; + object-fit: cover; +`,Kv=C.input` + display: none; +`,Xv=C.label` + color: ${({theme:r})=>r.colors.brand.primary}; + cursor: pointer; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +`,Jv=C.div` + display: flex; + gap: 10px; + margin-top: 20px; +`,Zv=C.button` + width: 100%; + padding: 10px; + margin-top: 16px; + border: none; + border-radius: 4px; + background: transparent; + color: ${({theme:r})=>r.colors.status.error}; + cursor: pointer; + font-weight: 500; + + &:hover { + background: ${({theme:r})=>r.colors.status.error}20; + } +`,es=C.div` + margin-bottom: 20px; +`,ts=C.label` + display: block; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +`,Jf=C.span` + color: ${({theme:r})=>r.colors.status.error}; +`,ex=C.div` + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background-color: ${({theme:r})=>r.colors.background.tertiary}; + width: 100%; + height: 52px; +`,tx=C(Or)``;C(nn)``;const nx=C.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +`,rx=C.div` + font-weight: 500; + color: ${({theme:r})=>r.colors.text.primary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.875rem; + line-height: 1.2; +`,ox=C.div` + font-size: 0.75rem; + color: ${({theme:r})=>r.colors.text.secondary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +`,ix=C.div` + display: flex; + align-items: center; + flex-shrink: 0; +`,sx=C.button` + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: ${({theme:r})=>r.colors.text.secondary}; + font-size: 18px; + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`;function lx({user:r}){var d,p;const[i,s]=K.useState(!1),{binaryContents:l,fetchBinaryContent:c}=An();return K.useEffect(()=>{var m;(m=r.profile)!=null&&m.id&&!l[r.profile.id]&&c(r.profile.id)},[r.profile,l,c]),h.jsxs(h.Fragment,{children:[h.jsxs(ex,{children:[h.jsxs(tx,{children:[h.jsx(nn,{src:(d=r.profile)!=null&&d.id?(p=l[r.profile.id])==null?void 0:p.url:St,alt:r.username}),h.jsx($o,{$online:!0})]}),h.jsxs(nx,{children:[h.jsx(rx,{children:r.username}),h.jsx(ox,{children:"온라인"})]}),h.jsx(ix,{children:h.jsx(sx,{onClick:()=>s(!0),children:"⚙️"})})]}),h.jsx(Vv,{isOpen:i,onClose:()=>s(!1),user:r})]})}const ax=C.div` + width: 240px; + background: ${Y.colors.background.secondary}; + border-right: 1px solid ${Y.colors.border.primary}; + display: flex; + flex-direction: column; +`,ux=C.div` + flex: 1; + overflow-y: auto; +`,cx=C.div` + padding: 16px; + font-size: 16px; + font-weight: bold; + color: ${Y.colors.text.primary}; +`,hu=C.div` + height: 34px; + padding: 0 8px; + margin: 1px 8px; + display: flex; + align-items: center; + gap: 6px; + color: ${r=>r.$hasUnread?r.theme.colors.text.primary:r.theme.colors.text.muted}; + font-weight: ${r=>r.$hasUnread?"600":"normal"}; + cursor: pointer; + background: ${r=>r.$isActive?r.theme.colors.background.hover:"transparent"}; + border-radius: 4px; + + &:hover { + background: ${r=>r.theme.colors.background.hover}; + color: ${r=>r.theme.colors.text.primary}; + } +`,Zf=C.div` + margin-bottom: 8px; +`,tu=C.div` + padding: 8px 16px; + display: flex; + align-items: center; + color: ${Y.colors.text.muted}; + text-transform: uppercase; + font-size: 12px; + font-weight: 600; + cursor: pointer; + user-select: none; + + & > span:nth-child(2) { + flex: 1; + margin-right: auto; + } + + &:hover { + color: ${Y.colors.text.primary}; + } +`,ep=C.span` + margin-right: 4px; + font-size: 10px; + transition: transform 0.2s; + transform: rotate(${r=>r.$folded?"-90deg":"0deg"}); +`,tp=C.div` + display: ${r=>r.$folded?"none":"block"}; +`,nu=C(hu)` + height: ${r=>r.hasSubtext?"42px":"34px"}; +`,dx=C(Or)` + width: 32px; + height: 32px; + margin: 0 8px; +`,np=C.div` + font-size: 16px; + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${r=>r.$isActive||r.$hasUnread?r.theme.colors.text.primary:r.theme.colors.text.muted}; + font-weight: ${r=>r.$hasUnread?"600":"normal"}; +`;C($o)` + border-color: ${Y.colors.background.primary}; +`;const rp=C.button` + background: none; + border: none; + color: ${Y.colors.text.muted}; + font-size: 18px; + padding: 0; + cursor: pointer; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s, color 0.2s; + + ${tu}:hover & { + opacity: 1; + } + + &:hover { + color: ${Y.colors.text.primary}; + } +`,fx=C(Or)` + width: 40px; + height: 24px; + margin: 0 8px; +`,px=C.div` + font-size: 12px; + line-height: 13px; + color: ${Y.colors.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,op=C.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; +`,hh=C.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,mh=C.div` + background: ${Y.colors.background.primary}; + border-radius: 4px; + width: 440px; + max-width: 90%; +`,gh=C.div` + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; +`,yh=C.h2` + color: ${Y.colors.text.primary}; + font-size: 20px; + font-weight: 600; + margin: 0; +`,vh=C.div` + padding: 0 16px 16px; +`,xh=C.form` + display: flex; + flex-direction: column; + gap: 16px; +`,To=C.div` + display: flex; + flex-direction: column; + gap: 8px; +`,_o=C.label` + color: ${Y.colors.text.primary}; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +`,wh=C.p` + color: ${Y.colors.text.muted}; + font-size: 14px; + margin: -4px 0 0; +`,Lo=C.input` + padding: 10px; + background: ${Y.colors.background.tertiary}; + border: none; + border-radius: 3px; + color: ${Y.colors.text.primary}; + font-size: 16px; + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${Y.colors.status.online}; + } + + &::placeholder { + color: ${Y.colors.text.muted}; + } +`,Sh=C.button` + margin-top: 8px; + padding: 12px; + background: ${Y.colors.status.online}; + color: white; + border: none; + border-radius: 3px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #3ca374; + } +`,kh=C.button` + background: none; + border: none; + color: ${Y.colors.text.muted}; + font-size: 24px; + cursor: pointer; + padding: 4px; + line-height: 1; + + &:hover { + color: ${Y.colors.text.primary}; + } +`,hx=C(Lo)` + margin-bottom: 8px; +`,mx=C.div` + max-height: 300px; + overflow-y: auto; + background: ${Y.colors.background.tertiary}; + border-radius: 4px; +`,gx=C.div` + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: ${Y.colors.background.hover}; + } + + & + & { + border-top: 1px solid ${Y.colors.border.primary}; + } +`,yx=C.input` + margin-right: 12px; + width: 16px; + height: 16px; + cursor: pointer; +`,ip=C.img` + width: 32px; + height: 32px; + border-radius: 50%; + margin-right: 12px; +`,vx=C.div` + flex: 1; + min-width: 0; +`,xx=C.div` + color: ${Y.colors.text.primary}; + font-size: 14px; + font-weight: 500; +`,wx=C.div` + color: ${Y.colors.text.muted}; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,Sx=C.div` + padding: 16px; + text-align: center; + color: ${Y.colors.text.muted}; +`,Ch=C.div` + color: ${Y.colors.status.error}; + font-size: 14px; + padding: 8px 0; + text-align: center; + background-color: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 4px; + margin-bottom: 8px; +`,$a=C.div` + position: relative; + margin-left: auto; + z-index: 99999; +`,Ba=C.button` + background: none; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + cursor: pointer; + padding: 4px; + border-radius: 3px; + opacity: 0; + transition: opacity 0.2s, background 0.2s; + + &:hover { + background: ${({theme:r})=>r.colors.background.hover}; + color: ${({theme:r})=>r.colors.text.primary}; + } + + ${hu}:hover &, + ${nu}:hover & { + opacity: 1; + } +`,Fa=C.div` + position: absolute; + top: 100%; + right: 0; + background: ${({theme:r})=>r.colors.background.primary}; + border: 1px solid ${({theme:r})=>r.colors.border.primary}; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + min-width: 120px; + z-index: 100000; +`,ns=C.div` + padding: 8px 12px; + color: ${({theme:r})=>r.colors.text.primary}; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + + &:hover { + background: ${({theme:r})=>r.colors.background.hover}; + } + + &:first-child { + border-radius: 4px 4px 0 0; + } + + &:last-child { + border-radius: 0 0 4px 4px; + } + + &:only-child { + border-radius: 4px; + } +`;function kx(){return h.jsx(cx,{children:"채널 목록"})}var En=(r=>(r.USER="USER",r.CHANNEL_MANAGER="CHANNEL_MANAGER",r.ADMIN="ADMIN",r))(En||{});function Cx({isOpen:r,channel:i,onClose:s,onUpdateSuccess:l}){const[c,d]=K.useState({name:"",description:""}),[p,m]=K.useState(""),[w,v]=K.useState(!1),{updatePublicChannel:S}=jn();K.useEffect(()=>{i&&r&&(d({name:i.name||"",description:i.description||""}),m(""))},[i,r]);const j=L=>{const{name:T,value:N}=L.target;d(_=>({..._,[T]:N}))},R=async L=>{var T,N;if(L.preventDefault(),!!i){m(""),v(!0);try{if(!c.name.trim()){m("채널 이름을 입력해주세요."),v(!1);return}const _={newName:c.name.trim(),newDescription:c.description.trim()},V=await S(i.id,_);l(V)}catch(_){console.error("채널 수정 실패:",_),m(((N=(T=_.response)==null?void 0:T.data)==null?void 0:N.message)||"채널 수정에 실패했습니다. 다시 시도해주세요.")}finally{v(!1)}}};return!r||!i||i.type!=="PUBLIC"?null:h.jsx(hh,{onClick:s,children:h.jsxs(mh,{onClick:L=>L.stopPropagation(),children:[h.jsxs(gh,{children:[h.jsx(yh,{children:"채널 수정"}),h.jsx(kh,{onClick:s,children:"×"})]}),h.jsx(vh,{children:h.jsxs(xh,{onSubmit:R,children:[p&&h.jsx(Ch,{children:p}),h.jsxs(To,{children:[h.jsx(_o,{children:"채널 이름"}),h.jsx(Lo,{name:"name",value:c.name,onChange:j,placeholder:"새로운-채널",required:!0,disabled:w})]}),h.jsxs(To,{children:[h.jsx(_o,{children:"채널 설명"}),h.jsx(wh,{children:"이 채널의 주제를 설명해주세요."}),h.jsx(Lo,{name:"description",value:c.description,onChange:j,placeholder:"채널 설명을 입력하세요",disabled:w})]}),h.jsx(Sh,{type:"submit",disabled:w,children:w?"수정 중...":"채널 수정"})]})})]})})}function sp({channel:r,isActive:i,onClick:s,hasUnread:l}){var U;const{currentUser:c}=rt(),{binaryContents:d}=An(),{deleteChannel:p}=jn(),[m,w]=K.useState(null),[v,S]=K.useState(!1),j=(c==null?void 0:c.role)===En.ADMIN||(c==null?void 0:c.role)===En.CHANNEL_MANAGER;K.useEffect(()=>{const B=()=>{m&&w(null)};if(m)return document.addEventListener("click",B),()=>document.removeEventListener("click",B)},[m]);const R=B=>{w(m===B?null:B)},L=()=>{w(null),S(!0)},T=B=>{S(!1),console.log("Channel updated successfully:",B)},N=()=>{S(!1)},_=async B=>{var I;w(null);const W=r.type==="PUBLIC"?r.name:r.type==="PRIVATE"&&r.participants.length>2?`그룹 채팅 (멤버 ${r.participants.length}명)`:((I=r.participants.filter(M=>M.id!==(c==null?void 0:c.id))[0])==null?void 0:I.username)||"1:1 채팅";if(confirm(`"${W}" 채널을 삭제하시겠습니까?`))try{await p(B),console.log("Channel deleted successfully:",B)}catch(M){console.error("Channel delete failed:",M),alert("채널 삭제에 실패했습니다. 다시 시도해주세요.")}};let V;if(r.type==="PUBLIC")V=h.jsxs(hu,{$isActive:i,onClick:s,$hasUnread:l,children:["# ",r.name,j&&h.jsxs($a,{children:[h.jsx(Ba,{onClick:B=>{B.stopPropagation(),R(r.id)},children:"⋯"}),m===r.id&&h.jsxs(Fa,{onClick:B=>B.stopPropagation(),children:[h.jsx(ns,{onClick:()=>L(),children:"✏️ 수정"}),h.jsx(ns,{onClick:()=>_(r.id),children:"🗑️ 삭제"})]})]})]});else{const B=r.participants;if(B.length>2){const W=B.filter(I=>I.id!==(c==null?void 0:c.id)).map(I=>I.username).join(", ");V=h.jsxs(nu,{$isActive:i,onClick:s,children:[h.jsx(fx,{children:B.filter(I=>I.id!==(c==null?void 0:c.id)).slice(0,2).map((I,M)=>{var H;return h.jsx(nn,{src:I.profile?(H=d[I.profile.id])==null?void 0:H.url:St,style:{position:"absolute",left:M*16,zIndex:2-M,width:"24px",height:"24px",border:"2px solid #2a2a2a"}},I.id)})}),h.jsxs(op,{children:[h.jsx(np,{$hasUnread:l,children:W}),h.jsxs(px,{children:["멤버 ",B.length,"명"]})]}),j&&h.jsxs($a,{children:[h.jsx(Ba,{onClick:I=>{I.stopPropagation(),R(r.id)},children:"⋯"}),m===r.id&&h.jsx(Fa,{onClick:I=>I.stopPropagation(),children:h.jsx(ns,{onClick:()=>_(r.id),children:"🗑️ 삭제"})})]})]})}else{const W=B.filter(I=>I.id!==(c==null?void 0:c.id))[0];V=W?h.jsxs(nu,{$isActive:i,onClick:s,children:[h.jsxs(dx,{children:[h.jsx(nn,{src:W.profile?(U=d[W.profile.id])==null?void 0:U.url:St,alt:"profile"}),h.jsx($o,{$online:W.online})]}),h.jsx(op,{children:h.jsx(np,{$hasUnread:l,children:W.username})}),j&&h.jsxs($a,{children:[h.jsx(Ba,{onClick:I=>{I.stopPropagation(),R(r.id)},children:"⋯"}),m===r.id&&h.jsx(Fa,{onClick:I=>I.stopPropagation(),children:h.jsx(ns,{onClick:()=>_(r.id),children:"🗑️ 삭제"})})]})]}):h.jsx("div",{})}}return h.jsxs(h.Fragment,{children:[V,h.jsx(Cx,{isOpen:v,channel:r,onClose:N,onUpdateSuccess:T})]})}function Ex({isOpen:r,type:i,onClose:s,onCreateSuccess:l}){const[c,d]=K.useState({name:"",description:""}),[p,m]=K.useState(""),[w,v]=K.useState([]),[S,j]=K.useState(""),R=Rr(I=>I.users),L=An(I=>I.binaryContents),{currentUser:T}=rt(),N=K.useMemo(()=>R.filter(I=>I.id!==(T==null?void 0:T.id)).filter(I=>I.username.toLowerCase().includes(p.toLowerCase())||I.email.toLowerCase().includes(p.toLowerCase())),[p,R,T]),_=jn(I=>I.createPublicChannel),V=jn(I=>I.createPrivateChannel),U=I=>{const{name:M,value:H}=I.target;d(ie=>({...ie,[M]:H}))},B=I=>{v(M=>M.includes(I)?M.filter(H=>H!==I):[...M,I])},W=async I=>{var M,H;I.preventDefault(),j("");try{let ie;if(i==="PUBLIC"){if(!c.name.trim()){j("채널 이름을 입력해주세요.");return}const ve={name:c.name,description:c.description};ie=await _(ve)}else{if(w.length===0){j("대화 상대를 선택해주세요.");return}const ve=(T==null?void 0:T.id)&&[...w,T.id]||w;ie=await V(ve)}l(ie)}catch(ie){console.error("채널 생성 실패:",ie),j(((H=(M=ie.response)==null?void 0:M.data)==null?void 0:H.message)||"채널 생성에 실패했습니다. 다시 시도해주세요.")}};return r?h.jsx(hh,{onClick:s,children:h.jsxs(mh,{onClick:I=>I.stopPropagation(),children:[h.jsxs(gh,{children:[h.jsx(yh,{children:i==="PUBLIC"?"채널 만들기":"개인 메시지 시작하기"}),h.jsx(kh,{onClick:s,children:"×"})]}),h.jsx(vh,{children:h.jsxs(xh,{onSubmit:W,children:[S&&h.jsx(Ch,{children:S}),i==="PUBLIC"?h.jsxs(h.Fragment,{children:[h.jsxs(To,{children:[h.jsx(_o,{children:"채널 이름"}),h.jsx(Lo,{name:"name",value:c.name,onChange:U,placeholder:"새로운-채널",required:!0})]}),h.jsxs(To,{children:[h.jsx(_o,{children:"채널 설명"}),h.jsx(wh,{children:"이 채널의 주제를 설명해주세요."}),h.jsx(Lo,{name:"description",value:c.description,onChange:U,placeholder:"채널 설명을 입력하세요"})]})]}):h.jsxs(To,{children:[h.jsx(_o,{children:"사용자 검색"}),h.jsx(hx,{type:"text",value:p,onChange:I=>m(I.target.value),placeholder:"사용자명 또는 이메일로 검색"}),h.jsx(mx,{children:N.length>0?N.map(I=>h.jsxs(gx,{children:[h.jsx(yx,{type:"checkbox",checked:w.includes(I.id),onChange:()=>B(I.id)}),I.profile?h.jsx(ip,{src:L[I.profile.id].url}):h.jsx(ip,{src:St}),h.jsxs(vx,{children:[h.jsx(xx,{children:I.username}),h.jsx(wx,{children:I.email})]})]},I.id)):h.jsx(Sx,{children:"검색 결과가 없습니다."})})]}),h.jsx(Sh,{type:"submit",children:i==="PUBLIC"?"채널 만들기":"대화 시작하기"})]})})]})}):null}function jx({currentUser:r,activeChannel:i,onChannelSelect:s}){var W,I;const[l,c]=K.useState({PUBLIC:!1,PRIVATE:!1}),[d,p]=K.useState({isOpen:!1,type:null}),m=jn(M=>M.channels),w=jn(M=>M.fetchChannels),v=jn(M=>M.startPolling),S=jn(M=>M.stopPolling),j=Po(M=>M.fetchReadStatuses),R=Po(M=>M.updateReadStatus),L=Po(M=>M.hasUnreadMessages);K.useEffect(()=>{if(r)return w(r.id),j(),v(r.id),()=>{S()}},[r,w,j,v,S]);const T=M=>{c(H=>({...H,[M]:!H[M]}))},N=(M,H)=>{H.stopPropagation(),p({isOpen:!0,type:M})},_=()=>{p({isOpen:!1,type:null})},V=async M=>{try{const ie=(await w(r.id)).find(ve=>ve.id===M.id);ie&&s(ie),_()}catch(H){console.error("채널 생성 실패:",H)}},U=M=>{s(M),R(M.id)},B=m.reduce((M,H)=>(M[H.type]||(M[H.type]=[]),M[H.type].push(H),M),{});return h.jsxs(ax,{children:[h.jsx(kx,{}),h.jsxs(ux,{children:[h.jsxs(Zf,{children:[h.jsxs(tu,{onClick:()=>T("PUBLIC"),children:[h.jsx(ep,{$folded:l.PUBLIC,children:"▼"}),h.jsx("span",{children:"일반 채널"}),h.jsx(rp,{onClick:M=>N("PUBLIC",M),children:"+"})]}),h.jsx(tp,{$folded:l.PUBLIC,children:(W=B.PUBLIC)==null?void 0:W.map(M=>h.jsx(sp,{channel:M,isActive:(i==null?void 0:i.id)===M.id,hasUnread:L(M.id,M.lastMessageAt),onClick:()=>U(M)},M.id))})]}),h.jsxs(Zf,{children:[h.jsxs(tu,{onClick:()=>T("PRIVATE"),children:[h.jsx(ep,{$folded:l.PRIVATE,children:"▼"}),h.jsx("span",{children:"개인 메시지"}),h.jsx(rp,{onClick:M=>N("PRIVATE",M),children:"+"})]}),h.jsx(tp,{$folded:l.PRIVATE,children:(I=B.PRIVATE)==null?void 0:I.map(M=>h.jsx(sp,{channel:M,isActive:(i==null?void 0:i.id)===M.id,hasUnread:L(M.id,M.lastMessageAt),onClick:()=>U(M)},M.id))})]})]}),h.jsx(Ax,{children:h.jsx(lx,{user:r})}),h.jsx(Ex,{isOpen:d.isOpen,type:d.type,onClose:_,onCreateSuccess:V})]})}const Ax=C.div` + margin-top: auto; + border-top: 1px solid ${({theme:r})=>r.colors.border.primary}; + background-color: ${({theme:r})=>r.colors.background.tertiary}; +`,Rx=C.div` + flex: 1; + display: flex; + flex-direction: column; + background: ${({theme:r})=>r.colors.background.primary}; +`,Px=C.div` + display: flex; + flex-direction: column; + height: 100%; + background: ${({theme:r})=>r.colors.background.primary}; +`,Tx=C(Px)` + justify-content: center; + align-items: center; + flex: 1; + padding: 0 20px; +`,_x=C.div` + text-align: center; + max-width: 400px; + padding: 20px; + margin-bottom: 80px; +`,Nx=C.div` + font-size: 48px; + margin-bottom: 16px; + animation: wave 2s infinite; + transform-origin: 70% 70%; + + @keyframes wave { + 0% { transform: rotate(0deg); } + 10% { transform: rotate(14deg); } + 20% { transform: rotate(-8deg); } + 30% { transform: rotate(14deg); } + 40% { transform: rotate(-4deg); } + 50% { transform: rotate(10deg); } + 60% { transform: rotate(0deg); } + 100% { transform: rotate(0deg); } + } +`,Ox=C.h2` + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 28px; + font-weight: 700; + margin-bottom: 16px; +`,Mx=C.p` + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + line-height: 1.6; + word-break: keep-all; +`,lp=C.div` + height: 48px; + padding: 0 16px; + background: ${Y.colors.background.primary}; + border-bottom: 1px solid ${Y.colors.border.primary}; + display: flex; + align-items: center; +`,ap=C.div` + display: flex; + align-items: center; + gap: 8px; + height: 100%; +`,Lx=C.div` + display: flex; + align-items: center; + gap: 12px; + height: 100%; +`,Ix=C(Or)` + width: 24px; + height: 24px; +`;C.img` + width: 24px; + height: 24px; + border-radius: 50%; +`;const Dx=C.div` + position: relative; + width: 40px; + height: 24px; + flex-shrink: 0; +`,zx=C($o)` + border-color: ${Y.colors.background.primary}; + bottom: -3px; + right: -3px; +`,$x=C.div` + font-size: 12px; + color: ${Y.colors.text.muted}; + line-height: 13px; +`,up=C.div` + font-weight: bold; + color: ${Y.colors.text.primary}; + line-height: 20px; + font-size: 16px; +`,Bx=C.div` + flex: 1; + display: flex; + flex-direction: column-reverse; + overflow-y: auto; + position: relative; +`,Fx=C.div` + padding: 16px; + display: flex; + flex-direction: column; +`,Eh=C.div` + margin-bottom: 16px; + display: flex; + align-items: flex-start; + position: relative; + z-index: 1; +`,bx=C(Or)` + margin-right: 16px; + width: 40px; + height: 40px; +`;C.img` + width: 40px; + height: 40px; + border-radius: 50%; +`;const Ux=C.div` + display: flex; + align-items: center; + margin-bottom: 4px; + position: relative; +`,Hx=C.span` + font-weight: bold; + color: ${Y.colors.text.primary}; + margin-right: 8px; +`,Vx=C.span` + font-size: 0.75rem; + color: ${Y.colors.text.muted}; +`,Wx=C.div` + color: ${Y.colors.text.secondary}; + margin-top: 4px; +`,qx=C.form` + display: flex; + align-items: center; + gap: 8px; + padding: 16px; + background: ${({theme:r})=>r.colors.background.secondary}; + position: relative; + z-index: 1; +`,Yx=C.textarea` + flex: 1; + padding: 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border: none; + border-radius: 4px; + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 14px; + resize: none; + min-height: 44px; + max-height: 144px; + + &:focus { + outline: none; + } + + &::placeholder { + color: ${({theme:r})=>r.colors.text.muted}; + } +`,Qx=C.button` + background: none; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`;C.div` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: ${Y.colors.text.muted}; + font-size: 16px; + font-weight: 500; + padding: 20px; + text-align: center; +`;const cp=C.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + width: 100%; +`,Gx=C.a` + display: block; + border-radius: 4px; + overflow: hidden; + max-width: 300px; + + img { + width: 100%; + height: auto; + display: block; + } +`,Kx=C.a` + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 8px; + text-decoration: none; + width: fit-content; + + &:hover { + background: ${({theme:r})=>r.colors.background.hover}; + } +`,Xx=C.div` + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: #0B93F6; +`,Jx=C.div` + display: flex; + flex-direction: column; + gap: 2px; +`,Zx=C.span` + font-size: 14px; + color: #0B93F6; + font-weight: 500; +`,e1=C.span` + font-size: 13px; + color: ${({theme:r})=>r.colors.text.muted}; +`,t1=C.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 0; +`,jh=C.div` + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border-radius: 4px; + max-width: 300px; +`,n1=C(jh)` + padding: 0; + overflow: hidden; + width: 200px; + height: 120px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`,r1=C.div` + color: #0B93F6; + font-size: 20px; +`,o1=C.div` + font-size: 13px; + color: ${({theme:r})=>r.colors.text.primary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,dp=C.button` + position: absolute; + top: -6px; + right: -6px; + width: 20px; + height: 20px; + border-radius: 50%; + background: ${({theme:r})=>r.colors.background.secondary}; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + } +`,i1=C.div` + position: relative; + margin-left: auto; + z-index: 99999; +`,s1=C.button` + background: none; + border: none; + color: ${({theme:r})=>r.colors.text.muted}; + font-size: 16px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + + &:hover { + color: ${({theme:r})=>r.colors.text.primary}; + background: ${({theme:r})=>r.colors.background.hover}; + } + + ${Eh}:hover & { + opacity: 1; + } +`,l1=C.div` + position: absolute; + top: 0; + background: ${({theme:r})=>r.colors.background.primary}; + border: 1px solid ${({theme:r})=>r.colors.border.primary}; + border-radius: 6px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + width: 80px; + z-index: 99999; + overflow: hidden; +`,fp=C.button` + display: flex; + align-items: center; + gap: 8px; + width: fit-content; + background: none; + border: none; + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 14px; + cursor: pointer; + text-align: center ; + + &:hover { + background: ${({theme:r})=>r.colors.background.hover}; + } + + &:first-child { + border-radius: 6px 6px 0 0; + } + + &:last-child { + border-radius: 0 0 6px 6px; + } +`,a1=C.div` + margin-top: 4px; +`,u1=C.textarea` + width: 100%; + max-width: 600px; + min-height: 80px; + padding: 12px 16px; + background: ${({theme:r})=>r.colors.background.tertiary}; + border: 1px solid ${({theme:r})=>r.colors.border.primary}; + border-radius: 4px; + color: ${({theme:r})=>r.colors.text.primary}; + font-size: 14px; + font-family: inherit; + resize: vertical; + outline: none; + box-sizing: border-box; + + &:focus { + border-color: ${({theme:r})=>r.colors.primary}; + } + + &::placeholder { + color: ${({theme:r})=>r.colors.text.muted}; + } +`,c1=C.div` + display: flex; + gap: 8px; + margin-top: 8px; +`,pp=C.button` + padding: 6px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background-color 0.2s ease; + + ${({variant:r,theme:i})=>r==="primary"?` + background: ${i.colors.primary}; + color: white; + + &:hover { + background: ${i.colors.primaryHover||i.colors.primary}; + } + `:` + background: ${i.colors.background.secondary}; + color: ${i.colors.text.secondary}; + + &:hover { + background: ${i.colors.background.hover}; + } + `} +`;function d1({channel:r}){var w;const{currentUser:i}=rt(),s=Rr(v=>v.users),l=An(v=>v.binaryContents);if(!r)return null;if(r.type==="PUBLIC")return h.jsx(lp,{children:h.jsx(ap,{children:h.jsxs(up,{children:["# ",r.name]})})});const c=r.participants.map(v=>s.find(S=>S.id===v.id)).filter(Boolean),d=c.filter(v=>v.id!==(i==null?void 0:i.id)),p=c.length>2,m=c.filter(v=>v.id!==(i==null?void 0:i.id)).map(v=>v.username).join(", ");return h.jsx(lp,{children:h.jsx(ap,{children:h.jsxs(Lx,{children:[p?h.jsx(Dx,{children:d.slice(0,2).map((v,S)=>{var j;return h.jsx(nn,{src:v.profile?(j=l[v.profile.id])==null?void 0:j.url:St,style:{position:"absolute",left:S*16,zIndex:2-S,width:"24px",height:"24px"}},v.id)})}):h.jsxs(Ix,{children:[h.jsx(nn,{src:d[0].profile?(w=l[d[0].profile.id])==null?void 0:w.url:St}),h.jsx(zx,{$online:d[0].online})]}),h.jsxs("div",{children:[h.jsx(up,{children:m}),p&&h.jsxs($x,{children:["멤버 ",c.length,"명"]})]})]})})})}const f1=async(r,i,s)=>{var c;return(await $e.get("/messages",{params:{channelId:r,cursor:i,size:s.size,sort:(c=s.sort)==null?void 0:c.join(",")}})).data},p1=async(r,i)=>{const s=new FormData,l={content:r.content,channelId:r.channelId,authorId:r.authorId};return s.append("messageCreateRequest",new Blob([JSON.stringify(l)],{type:"application/json"})),i&&i.length>0&&i.forEach(d=>{s.append("attachments",d)}),(await $e.post("/messages",s,{headers:{"Content-Type":"multipart/form-data"}})).data},h1=async(r,i)=>(await $e.patch(`/messages/${r}`,i)).data,m1=async r=>{await $e.delete(`/messages/${r}`)},ba={size:50,sort:["createdAt,desc"]},Ah=Tr((r,i)=>({messages:[],pollingIntervals:{},lastMessageId:null,pagination:{nextCursor:null,pageSize:50,hasNext:!1},fetchMessages:async(s,l,c=ba)=>{try{const d=await f1(s,l,c),p=d.content,m=p.length>0?p[0]:null,w=(m==null?void 0:m.id)!==i().lastMessageId;return r(v=>{var N;const S=!l,j=s!==((N=v.messages[0])==null?void 0:N.channelId),R=S&&(v.messages.length===0||j);let L=[],T={...v.pagination};if(R)L=p,T={nextCursor:d.nextCursor,pageSize:d.size,hasNext:d.hasNext};else if(S){const _=new Set(v.messages.map(U=>U.id));L=[...p.filter(U=>!_.has(U.id)&&(v.messages.length===0||U.createdAt>v.messages[0].createdAt)),...v.messages]}else{const _=new Set(v.messages.map(U=>U.id)),V=p.filter(U=>!_.has(U.id));L=[...v.messages,...V],T={nextCursor:d.nextCursor,pageSize:d.size,hasNext:d.hasNext}}return{messages:L,lastMessageId:(m==null?void 0:m.id)||null,pagination:T}}),w}catch(d){return console.error("메시지 목록 조회 실패:",d),!1}},loadMoreMessages:async s=>{const{pagination:l}=i();l.hasNext&&await i().fetchMessages(s,l.nextCursor,{...ba})},startPolling:s=>{const l=i();if(l.pollingIntervals[s]){const m=l.pollingIntervals[s];typeof m=="number"&&clearTimeout(m)}let c=300;const d=3e3;r(m=>({pollingIntervals:{...m.pollingIntervals,[s]:!0}}));const p=async()=>{const m=i();if(!m.pollingIntervals[s])return;const w=await m.fetchMessages(s,null,ba);if(!(i().messages.length==0)&&w?c=300:c=Math.min(c*1.5,d),i().pollingIntervals[s]){const S=setTimeout(p,c);r(j=>({pollingIntervals:{...j.pollingIntervals,[s]:S}}))}};p()},stopPolling:s=>{const{pollingIntervals:l}=i();if(l[s]){const c=l[s];typeof c=="number"&&clearTimeout(c),r(d=>{const p={...d.pollingIntervals};return delete p[s],{pollingIntervals:p}})}},createMessage:async(s,l)=>{try{const c=await p1(s,l),d=Po.getState().updateReadStatus;return await d(s.channelId),r(p=>p.messages.some(w=>w.id===c.id)?p:{messages:[c,...p.messages],lastMessageId:c.id}),c}catch(c){throw console.error("메시지 생성 실패:",c),c}},updateMessage:async(s,l)=>{try{const c=await h1(s,{newContent:l});return r(d=>({messages:d.messages.map(p=>p.id===s?{...p,content:l}:p)})),c}catch(c){throw console.error("메시지 업데이트 실패:",c),c}},deleteMessage:async s=>{try{await m1(s),r(l=>({messages:l.messages.filter(c=>c.id!==s)}))}catch(l){throw console.error("메시지 삭제 실패:",l),l}}}));function g1({channel:r}){const[i,s]=K.useState(""),[l,c]=K.useState([]),d=Ah(R=>R.createMessage),{currentUser:p}=rt(),m=async R=>{if(R.preventDefault(),!(!i.trim()&&l.length===0))try{await d({content:i.trim(),channelId:r.id,authorId:(p==null?void 0:p.id)??""},l),s(""),c([])}catch(L){console.error("메시지 전송 실패:",L)}},w=R=>{const L=Array.from(R.target.files||[]);c(T=>[...T,...L]),R.target.value=""},v=R=>{c(L=>L.filter((T,N)=>N!==R))},S=R=>{if(R.key==="Enter"&&!R.shiftKey){if(console.log("Enter key pressed"),R.preventDefault(),R.nativeEvent.isComposing)return;m(R)}},j=(R,L)=>R.type.startsWith("image/")?h.jsxs(n1,{children:[h.jsx("img",{src:URL.createObjectURL(R),alt:R.name}),h.jsx(dp,{onClick:()=>v(L),children:"×"})]},L):h.jsxs(jh,{children:[h.jsx(r1,{children:"📎"}),h.jsx(o1,{children:R.name}),h.jsx(dp,{onClick:()=>v(L),children:"×"})]},L);return K.useEffect(()=>()=>{l.forEach(R=>{R.type.startsWith("image/")&&URL.revokeObjectURL(URL.createObjectURL(R))})},[l]),r?h.jsxs(h.Fragment,{children:[l.length>0&&h.jsx(t1,{children:l.map((R,L)=>j(R,L))}),h.jsxs(qx,{onSubmit:m,children:[h.jsxs(Qx,{as:"label",children:["+",h.jsx("input",{type:"file",multiple:!0,onChange:w,style:{display:"none"}})]}),h.jsx(Yx,{value:i,onChange:R=>s(R.target.value),onKeyDown:S,placeholder:r.type==="PUBLIC"?`#${r.name}에 메시지 보내기`:"메시지 보내기"})]})]}):null}/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 + +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. + +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */var ru=function(r,i){return ru=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(s,l){s.__proto__=l}||function(s,l){for(var c in l)l.hasOwnProperty(c)&&(s[c]=l[c])},ru(r,i)};function y1(r,i){ru(r,i);function s(){this.constructor=r}r.prototype=i===null?Object.create(i):(s.prototype=i.prototype,new s)}var No=function(){return No=Object.assign||function(i){for(var s,l=1,c=arguments.length;lr?L():i!==!0&&(c=setTimeout(l?T:L,l===void 0?r-j:r))}return v.cancel=w,v}var kr={Pixel:"Pixel",Percent:"Percent"},hp={unit:kr.Percent,value:.8};function mp(r){return typeof r=="number"?{unit:kr.Percent,value:r*100}:typeof r=="string"?r.match(/^(\d*(\.\d+)?)px$/)?{unit:kr.Pixel,value:parseFloat(r)}:r.match(/^(\d*(\.\d+)?)%$/)?{unit:kr.Percent,value:parseFloat(r)}:(console.warn('scrollThreshold format is invalid. Valid formats: "120px", "50%"...'),hp):(console.warn("scrollThreshold should be string or number"),hp)}var x1=function(r){y1(i,r);function i(s){var l=r.call(this,s)||this;return l.lastScrollTop=0,l.actionTriggered=!1,l.startY=0,l.currentY=0,l.dragging=!1,l.maxPullDownDistance=0,l.getScrollableTarget=function(){return l.props.scrollableTarget instanceof HTMLElement?l.props.scrollableTarget:typeof l.props.scrollableTarget=="string"?document.getElementById(l.props.scrollableTarget):(l.props.scrollableTarget===null&&console.warn(`You are trying to pass scrollableTarget but it is null. This might + happen because the element may not have been added to DOM yet. + See https://github.com/ankeetmaini/react-infinite-scroll-component/issues/59 for more info. + `),null)},l.onStart=function(c){l.lastScrollTop||(l.dragging=!0,c instanceof MouseEvent?l.startY=c.pageY:c instanceof TouchEvent&&(l.startY=c.touches[0].pageY),l.currentY=l.startY,l._infScroll&&(l._infScroll.style.willChange="transform",l._infScroll.style.transition="transform 0.2s cubic-bezier(0,0,0.31,1)"))},l.onMove=function(c){l.dragging&&(c instanceof MouseEvent?l.currentY=c.pageY:c instanceof TouchEvent&&(l.currentY=c.touches[0].pageY),!(l.currentY=Number(l.props.pullDownToRefreshThreshold)&&l.setState({pullToRefreshThresholdBreached:!0}),!(l.currentY-l.startY>l.maxPullDownDistance*1.5)&&l._infScroll&&(l._infScroll.style.overflow="visible",l._infScroll.style.transform="translate3d(0px, "+(l.currentY-l.startY)+"px, 0px)")))},l.onEnd=function(){l.startY=0,l.currentY=0,l.dragging=!1,l.state.pullToRefreshThresholdBreached&&(l.props.refreshFunction&&l.props.refreshFunction(),l.setState({pullToRefreshThresholdBreached:!1})),requestAnimationFrame(function(){l._infScroll&&(l._infScroll.style.overflow="auto",l._infScroll.style.transform="none",l._infScroll.style.willChange="unset")})},l.onScrollListener=function(c){typeof l.props.onScroll=="function"&&setTimeout(function(){return l.props.onScroll&&l.props.onScroll(c)},0);var d=l.props.height||l._scrollableNode?c.target:document.documentElement.scrollTop?document.documentElement:document.body;if(!l.actionTriggered){var p=l.props.inverse?l.isElementAtTop(d,l.props.scrollThreshold):l.isElementAtBottom(d,l.props.scrollThreshold);p&&l.props.hasMore&&(l.actionTriggered=!0,l.setState({showLoader:!0}),l.props.next&&l.props.next()),l.lastScrollTop=d.scrollTop}},l.state={showLoader:!1,pullToRefreshThresholdBreached:!1,prevDataLength:s.dataLength},l.throttledOnScrollListener=v1(150,l.onScrollListener).bind(l),l.onStart=l.onStart.bind(l),l.onMove=l.onMove.bind(l),l.onEnd=l.onEnd.bind(l),l}return i.prototype.componentDidMount=function(){if(typeof this.props.dataLength>"u")throw new Error('mandatory prop "dataLength" is missing. The prop is needed when loading more content. Check README.md for usage');if(this._scrollableNode=this.getScrollableTarget(),this.el=this.props.height?this._infScroll:this._scrollableNode||window,this.el&&this.el.addEventListener("scroll",this.throttledOnScrollListener),typeof this.props.initialScrollY=="number"&&this.el&&this.el instanceof HTMLElement&&this.el.scrollHeight>this.props.initialScrollY&&this.el.scrollTo(0,this.props.initialScrollY),this.props.pullDownToRefresh&&this.el&&(this.el.addEventListener("touchstart",this.onStart),this.el.addEventListener("touchmove",this.onMove),this.el.addEventListener("touchend",this.onEnd),this.el.addEventListener("mousedown",this.onStart),this.el.addEventListener("mousemove",this.onMove),this.el.addEventListener("mouseup",this.onEnd),this.maxPullDownDistance=this._pullDown&&this._pullDown.firstChild&&this._pullDown.firstChild.getBoundingClientRect().height||0,this.forceUpdate(),typeof this.props.refreshFunction!="function"))throw new Error(`Mandatory prop "refreshFunction" missing. + Pull Down To Refresh functionality will not work + as expected. Check README.md for usage'`)},i.prototype.componentWillUnmount=function(){this.el&&(this.el.removeEventListener("scroll",this.throttledOnScrollListener),this.props.pullDownToRefresh&&(this.el.removeEventListener("touchstart",this.onStart),this.el.removeEventListener("touchmove",this.onMove),this.el.removeEventListener("touchend",this.onEnd),this.el.removeEventListener("mousedown",this.onStart),this.el.removeEventListener("mousemove",this.onMove),this.el.removeEventListener("mouseup",this.onEnd)))},i.prototype.componentDidUpdate=function(s){this.props.dataLength!==s.dataLength&&(this.actionTriggered=!1,this.setState({showLoader:!1}))},i.getDerivedStateFromProps=function(s,l){var c=s.dataLength!==l.prevDataLength;return c?No(No({},l),{prevDataLength:s.dataLength}):null},i.prototype.isElementAtTop=function(s,l){l===void 0&&(l=.8);var c=s===document.body||s===document.documentElement?window.screen.availHeight:s.clientHeight,d=mp(l);return d.unit===kr.Pixel?s.scrollTop<=d.value+c-s.scrollHeight+1:s.scrollTop<=d.value/100+c-s.scrollHeight+1},i.prototype.isElementAtBottom=function(s,l){l===void 0&&(l=.8);var c=s===document.body||s===document.documentElement?window.screen.availHeight:s.clientHeight,d=mp(l);return d.unit===kr.Pixel?s.scrollTop+c>=s.scrollHeight-d.value:s.scrollTop+c>=d.value/100*s.scrollHeight},i.prototype.render=function(){var s=this,l=No({height:this.props.height||"auto",overflow:"auto",WebkitOverflowScrolling:"touch"},this.props.style),c=this.props.hasChildren||!!(this.props.children&&this.props.children instanceof Array&&this.props.children.length),d=this.props.pullDownToRefresh&&this.props.height?{overflow:"auto"}:{};return xt.createElement("div",{style:d,className:"infinite-scroll-component__outerdiv"},xt.createElement("div",{className:"infinite-scroll-component "+(this.props.className||""),ref:function(p){return s._infScroll=p},style:l},this.props.pullDownToRefresh&&xt.createElement("div",{style:{position:"relative"},ref:function(p){return s._pullDown=p}},xt.createElement("div",{style:{position:"absolute",left:0,right:0,top:-1*this.maxPullDownDistance}},this.state.pullToRefreshThresholdBreached?this.props.releaseToRefreshContent:this.props.pullDownToRefreshContent)),this.props.children,!this.state.showLoader&&!c&&this.props.hasMore&&this.props.loader,this.state.showLoader&&this.props.hasMore&&this.props.loader,!this.props.hasMore&&this.props.endMessage))},i}(K.Component);const w1=r=>r<1024?r+" B":r<1024*1024?(r/1024).toFixed(2)+" KB":r<1024*1024*1024?(r/(1024*1024)).toFixed(2)+" MB":(r/(1024*1024*1024)).toFixed(2)+" GB";function S1({channel:r}){const{messages:i,fetchMessages:s,loadMoreMessages:l,pagination:c,startPolling:d,stopPolling:p,updateMessage:m,deleteMessage:w}=Ah(),{binaryContents:v,fetchBinaryContent:S,clearBinaryContents:j}=An(),{currentUser:R}=rt(),[L,T]=K.useState(null),[N,_]=K.useState(null),[V,U]=K.useState("");K.useEffect(()=>{if(r!=null&&r.id)return s(r.id,null),d(r.id),()=>{p(r.id)}},[r==null?void 0:r.id,s,d,p]),K.useEffect(()=>{i.forEach(ne=>{var le;(le=ne.attachments)==null||le.forEach(me=>{v[me.id]||S(me.id)})})},[i,v,S]),K.useEffect(()=>()=>{const ne=i.map(le=>{var me;return(me=le.attachments)==null?void 0:me.map(Re=>Re.id)}).flat();j(ne)},[j]),K.useEffect(()=>{const ne=()=>{L&&T(null)};if(L)return document.addEventListener("click",ne),()=>document.removeEventListener("click",ne)},[L]);const B=async ne=>{try{const{url:le,fileName:me}=ne,Re=document.createElement("a");Re.href=le,Re.download=me,Re.style.display="none",document.body.appendChild(Re);try{const Ee=await(await window.showSaveFilePicker({suggestedName:ne.fileName,types:[{description:"Files",accept:{"*/*":[".txt",".pdf",".doc",".docx",".xls",".xlsx",".jpg",".jpeg",".png",".gif"]}}]})).createWritable(),ee=await(await fetch(le)).blob();await Ee.write(ee),await Ee.close()}catch(ge){ge.name!=="AbortError"&&Re.click()}document.body.removeChild(Re),window.URL.revokeObjectURL(le)}catch(le){console.error("파일 다운로드 실패:",le)}},W=ne=>ne!=null&&ne.length?ne.map(le=>{const me=v[le.id];return me?me.contentType.startsWith("image/")?h.jsx(cp,{children:h.jsx(Gx,{href:"#",onClick:ge=>{ge.preventDefault(),B(me)},children:h.jsx("img",{src:me.url,alt:me.fileName})})},me.url):h.jsx(cp,{children:h.jsxs(Kx,{href:"#",onClick:ge=>{ge.preventDefault(),B(me)},children:[h.jsx(Xx,{children:h.jsxs("svg",{width:"40",height:"40",viewBox:"0 0 40 40",fill:"none",children:[h.jsx("path",{d:"M8 3C8 1.89543 8.89543 1 10 1H22L32 11V37C32 38.1046 31.1046 39 30 39H10C8.89543 39 8 38.1046 8 37V3Z",fill:"#0B93F6",fillOpacity:"0.1"}),h.jsx("path",{d:"M22 1L32 11H24C22.8954 11 22 10.1046 22 9V1Z",fill:"#0B93F6",fillOpacity:"0.3"}),h.jsx("path",{d:"M13 19H27M13 25H27M13 31H27",stroke:"#0B93F6",strokeWidth:"2",strokeLinecap:"round"})]})}),h.jsxs(Jx,{children:[h.jsx(Zx,{children:me.fileName}),h.jsx(e1,{children:w1(me.size)})]})]})},me.url):null}):null,I=ne=>new Date(ne).toLocaleTimeString(),M=()=>{r!=null&&r.id&&l(r.id)},H=ne=>{T(L===ne?null:ne)},ie=ne=>{T(null);const le=i.find(me=>me.id===ne);le&&(_(ne),U(le.content))},ve=ne=>{m(ne,V).catch(le=>{console.error("메시지 수정 실패:",le),Sr.emit("api-error",{error:le,alert:!0})}),_(null),U("")},Oe=()=>{_(null),U("")},ot=ne=>{T(null),w(ne)};return h.jsx(Bx,{children:h.jsx("div",{id:"scrollableDiv",style:{height:"100%",overflow:"auto",display:"flex",flexDirection:"column-reverse"},children:h.jsx(x1,{dataLength:i.length,next:M,hasMore:c.hasNext,loader:h.jsx("h4",{style:{textAlign:"center"},children:"메시지를 불러오는 중..."}),scrollableTarget:"scrollableDiv",style:{display:"flex",flexDirection:"column-reverse"},inverse:!0,endMessage:h.jsx("p",{style:{textAlign:"center"},children:h.jsx("b",{children:c.nextCursor!==null?"모든 메시지를 불러왔습니다":""})}),children:h.jsx(Fx,{children:[...i].reverse().map(ne=>{var Re;const le=ne.author,me=R&&le&&le.id===R.id;return h.jsxs(Eh,{children:[h.jsx(bx,{children:h.jsx(nn,{src:le&&le.profile?(Re=v[le.profile.id])==null?void 0:Re.url:St,alt:le&&le.username||"알 수 없음"})}),h.jsxs("div",{children:[h.jsxs(Ux,{children:[h.jsx(Hx,{children:le&&le.username||"알 수 없음"}),h.jsx(Vx,{children:I(ne.createdAt)}),me&&h.jsxs(i1,{children:[h.jsx(s1,{onClick:ge=>{ge.stopPropagation(),H(ne.id)},children:"⋯"}),L===ne.id&&h.jsxs(l1,{onClick:ge=>ge.stopPropagation(),children:[h.jsx(fp,{onClick:()=>ie(ne.id),children:"✏️ 수정"}),h.jsx(fp,{onClick:()=>ot(ne.id),children:"🗑️ 삭제"})]})]})]}),N===ne.id?h.jsxs(a1,{children:[h.jsx(u1,{value:V,onChange:ge=>U(ge.target.value),onKeyDown:ge=>{ge.key==="Escape"?Oe():ge.key==="Enter"&&(ge.ctrlKey||ge.metaKey)&&(ge.preventDefault(),ve(ne.id))},placeholder:"메시지를 입력하세요..."}),h.jsxs(c1,{children:[h.jsx(pp,{variant:"secondary",onClick:Oe,children:"취소"}),h.jsx(pp,{variant:"primary",onClick:()=>ve(ne.id),children:"저장"})]})]}):h.jsx(Wx,{children:ne.content}),W(ne.attachments)]})]},ne.id)})})})})})}function k1({channel:r}){return r?h.jsxs(Rx,{children:[h.jsx(d1,{channel:r}),h.jsx(S1,{channel:r}),h.jsx(g1,{channel:r})]}):h.jsx(Tx,{children:h.jsxs(_x,{children:[h.jsx(Nx,{children:"👋"}),h.jsx(Ox,{children:"채널을 선택해주세요"}),h.jsxs(Mx,{children:["왼쪽의 채널 목록에서 채널을 선택하여",h.jsx("br",{}),"대화를 시작하세요."]})]})})}function C1(r,i="yyyy-MM-dd HH:mm:ss"){if(!r||!(r instanceof Date)||isNaN(r.getTime()))return"";const s=r.getFullYear(),l=String(r.getMonth()+1).padStart(2,"0"),c=String(r.getDate()).padStart(2,"0"),d=String(r.getHours()).padStart(2,"0"),p=String(r.getMinutes()).padStart(2,"0"),m=String(r.getSeconds()).padStart(2,"0");return i.replace("yyyy",s.toString()).replace("MM",l).replace("dd",c).replace("HH",d).replace("mm",p).replace("ss",m)}const E1=C.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,j1=C.div` + background: ${({theme:r})=>r.colors.background.primary}; + border-radius: 8px; + width: 500px; + max-width: 90%; + padding: 24px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +`,A1=C.div` + display: flex; + align-items: center; + margin-bottom: 16px; +`,R1=C.div` + color: ${({theme:r})=>r.colors.status.error}; + font-size: 24px; + margin-right: 12px; +`,P1=C.h3` + color: ${({theme:r})=>r.colors.text.primary}; + margin: 0; + font-size: 18px; +`,T1=C.div` + background: ${({theme:r})=>r.colors.background.tertiary}; + color: ${({theme:r})=>r.colors.text.muted}; + padding: 2px 8px; + border-radius: 4px; + font-size: 14px; + margin-left: auto; +`,_1=C.p` + color: ${({theme:r})=>r.colors.text.secondary}; + margin-bottom: 20px; + line-height: 1.5; + font-weight: 500; +`,N1=C.div` + margin-bottom: 20px; + background: ${({theme:r})=>r.colors.background.secondary}; + border-radius: 6px; + padding: 12px; +`,ko=C.div` + display: flex; + margin-bottom: 8px; + font-size: 14px; +`,Co=C.span` + color: ${({theme:r})=>r.colors.text.muted}; + min-width: 100px; +`,Eo=C.span` + color: ${({theme:r})=>r.colors.text.secondary}; + word-break: break-word; +`,O1=C.button` + background: ${({theme:r})=>r.colors.brand.primary}; + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + width: 100%; + + &:hover { + background: ${({theme:r})=>r.colors.brand.hover}; + } +`;function M1({isOpen:r,onClose:i,error:s}){var R,L;if(!r)return null;console.log({error:s});const l=(R=s==null?void 0:s.response)==null?void 0:R.data,c=(l==null?void 0:l.status)||((L=s==null?void 0:s.response)==null?void 0:L.status)||"오류",d=(l==null?void 0:l.code)||"",p=(l==null?void 0:l.message)||(s==null?void 0:s.message)||"알 수 없는 오류가 발생했습니다.",m=l!=null&&l.timestamp?new Date(l.timestamp):new Date,w=C1(m),v=(l==null?void 0:l.exceptionType)||"",S=(l==null?void 0:l.details)||{},j=(l==null?void 0:l.requestId)||"";return h.jsx(E1,{onClick:i,children:h.jsxs(j1,{onClick:T=>T.stopPropagation(),children:[h.jsxs(A1,{children:[h.jsx(R1,{children:"⚠️"}),h.jsx(P1,{children:"오류가 발생했습니다"}),h.jsxs(T1,{children:[c,d?` (${d})`:""]})]}),h.jsx(_1,{children:p}),h.jsxs(N1,{children:[h.jsxs(ko,{children:[h.jsx(Co,{children:"시간:"}),h.jsx(Eo,{children:w})]}),j&&h.jsxs(ko,{children:[h.jsx(Co,{children:"요청 ID:"}),h.jsx(Eo,{children:j})]}),d&&h.jsxs(ko,{children:[h.jsx(Co,{children:"에러 코드:"}),h.jsx(Eo,{children:d})]}),v&&h.jsxs(ko,{children:[h.jsx(Co,{children:"예외 유형:"}),h.jsx(Eo,{children:v})]}),Object.keys(S).length>0&&h.jsxs(ko,{children:[h.jsx(Co,{children:"상세 정보:"}),h.jsx(Eo,{children:Object.entries(S).map(([T,N])=>h.jsxs("div",{children:[T,": ",String(N)]},T))})]})]}),h.jsx(O1,{onClick:i,children:"확인"})]})})}const L1=C.div` + width: 240px; + background: ${Y.colors.background.secondary}; + border-left: 1px solid ${Y.colors.border.primary}; +`,I1=C.div` + padding: 16px; + font-size: 14px; + font-weight: bold; + color: ${Y.colors.text.muted}; + text-transform: uppercase; +`,D1=C.div` + padding: 8px 16px; + display: flex; + align-items: center; + color: ${Y.colors.text.muted}; + &:hover { + background: ${Y.colors.background.primary}; + cursor: pointer; + } +`,z1=C(Or)` + margin-right: 12px; +`;C(nn)``;const $1=C.div` + display: flex; + align-items: center; +`;function B1({member:r}){var l,c,d;const{binaryContents:i,fetchBinaryContent:s}=An();return K.useEffect(()=>{var p;(p=r.profile)!=null&&p.id&&!i[r.profile.id]&&s(r.profile.id)},[(l=r.profile)==null?void 0:l.id,i,s]),h.jsxs(D1,{children:[h.jsxs(z1,{children:[h.jsx(nn,{src:(c=r.profile)!=null&&c.id&&((d=i[r.profile.id])==null?void 0:d.url)||St,alt:r.username}),h.jsx($o,{$online:r.online})]}),h.jsx($1,{children:r.username})]})}function F1({member:r,onClose:i}){var L,T,N;const{binaryContents:s,fetchBinaryContent:l}=An(),{currentUser:c,updateUserRole:d}=rt(),[p,m]=K.useState(r.role),[w,v]=K.useState(!1);K.useEffect(()=>{var _;(_=r.profile)!=null&&_.id&&!s[r.profile.id]&&l(r.profile.id)},[(L=r.profile)==null?void 0:L.id,s,l]);const S={[En.USER]:{name:"사용자",color:"#2ed573"},[En.CHANNEL_MANAGER]:{name:"채널 관리자",color:"#ff4757"},[En.ADMIN]:{name:"어드민",color:"#0097e6"}},j=_=>{m(_),v(!0)},R=()=>{d(r.id,p),v(!1)};return h.jsx(H1,{onClick:i,children:h.jsxs(V1,{onClick:_=>_.stopPropagation(),children:[h.jsx("h2",{children:"사용자 정보"}),h.jsxs(W1,{children:[h.jsx(q1,{src:(T=r.profile)!=null&&T.id&&((N=s[r.profile.id])==null?void 0:N.url)||St,alt:r.username}),h.jsx(Y1,{children:r.username}),h.jsx(Q1,{children:r.email}),h.jsx(G1,{$online:r.online,children:r.online?"온라인":"오프라인"}),(c==null?void 0:c.role)===En.ADMIN?h.jsx(U1,{value:p,onChange:_=>j(_.target.value),children:Object.entries(S).map(([_,V])=>h.jsx("option",{value:_,style:{marginTop:"8px",textAlign:"center"},children:V.name},_))}):h.jsx(b1,{style:{backgroundColor:S[r.role].color},children:S[r.role].name})]}),h.jsx(K1,{children:(c==null?void 0:c.role)===En.ADMIN&&w&&h.jsx(X1,{onClick:R,disabled:!w,$secondary:!w,children:"저장"})})]})})}const b1=C.div` + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + color: white; + margin-top: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + letter-spacing: 0.3px; +`,U1=C.select` + padding: 10px 16px; + border-radius: 8px; + border: 1.5px solid ${Y.colors.border.primary}; + background: ${Y.colors.background.primary}; + color: ${Y.colors.text.primary}; + font-size: 14px; + width: 140px; + cursor: pointer; + transition: all 0.2s ease; + margin-top: 12px; + font-weight: 500; + + &:hover { + border-color: ${Y.colors.brand.primary}; + } + + &:focus { + outline: none; + border-color: ${Y.colors.brand.primary}; + box-shadow: 0 0 0 2px ${Y.colors.brand.primary}20; + } + + option { + background: ${Y.colors.background.primary}; + color: ${Y.colors.text.primary}; + padding: 12px; + } +`,H1=C.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,V1=C.div` + background: ${Y.colors.background.secondary}; + padding: 40px; + border-radius: 16px; + width: 100%; + max-width: 420px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + + h2 { + color: ${Y.colors.text.primary}; + margin-bottom: 32px; + text-align: center; + font-size: 26px; + font-weight: 600; + letter-spacing: -0.5px; + } +`,W1=C.div` + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 32px; + padding: 24px; + background: ${Y.colors.background.primary}; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +`,q1=C.img` + width: 140px; + height: 140px; + border-radius: 50%; + margin-bottom: 20px; + object-fit: cover; + border: 4px solid ${Y.colors.background.secondary}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +`,Y1=C.div` + font-size: 22px; + font-weight: 600; + color: ${Y.colors.text.primary}; + margin-bottom: 8px; + letter-spacing: -0.3px; +`,Q1=C.div` + font-size: 14px; + color: ${Y.colors.text.muted}; + margin-bottom: 16px; + font-weight: 500; +`,G1=C.div` + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + background-color: ${({$online:r,theme:i})=>r?i.colors.status.online:i.colors.status.offline}; + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + letter-spacing: 0.3px; +`,K1=C.div` + display: flex; + gap: 12px; + margin-top: 24px; +`,X1=C.button` + width: 100%; + padding: 12px; + border: none; + border-radius: 8px; + background: ${({$secondary:r,theme:i})=>r?"transparent":i.colors.brand.primary}; + color: ${({$secondary:r,theme:i})=>r?i.colors.text.primary:"white"}; + cursor: pointer; + font-weight: 600; + font-size: 15px; + transition: all 0.2s ease; + border: ${({$secondary:r,theme:i})=>r?`1.5px solid ${i.colors.border.primary}`:"none"}; + + &:hover { + background: ${({$secondary:r,theme:i})=>r?i.colors.background.hover:i.colors.brand.hover}; + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } +`;function J1(){const r=Rr(p=>p.users),i=Rr(p=>p.fetchUsers),{currentUser:s}=rt(),[l,c]=K.useState(null);K.useEffect(()=>{i()},[i]);const d=[...r].sort((p,m)=>p.id===(s==null?void 0:s.id)?-1:m.id===(s==null?void 0:s.id)?1:p.online&&!m.online?-1:!p.online&&m.online?1:p.username.localeCompare(m.username));return h.jsxs(L1,{children:[h.jsxs(I1,{children:["멤버 목록 - ",r.length]}),d.map(p=>h.jsx("div",{onClick:()=>c(p),children:h.jsx(B1,{member:p},p.id)},p.id)),l&&h.jsx(F1,{member:l,onClose:()=>c(null)})]})}function Z1(){const{logout:r,fetchCsrfToken:i,refreshToken:s}=rt(),{fetchUsers:l}=Rr(),[c,d]=K.useState(null),[p,m]=K.useState(null),[w,v]=K.useState(!1),[S,j]=K.useState(!0),{currentUser:R}=rt();K.useEffect(()=>{i(),s()},[]),K.useEffect(()=>{(async()=>{try{if(R)try{await l()}catch(N){console.warn("사용자 상태 업데이트 실패. 로그아웃합니다.",N),r()}}catch(N){console.error("초기화 오류:",N)}finally{j(!1)}})()},[R,l,r]),K.useEffect(()=>{const T=U=>{U!=null&&U.error&&m(U.error),U!=null&&U.alert&&v(!0)},N=()=>{r()},_=Sr.on("api-error",T),V=Sr.on("auth-error",N);return()=>{_("api-error",T),V("auth-error",N)}},[r]),K.useEffect(()=>{if(R){const T=setInterval(()=>{l()},6e4);return()=>{clearInterval(T)}}},[R,l]);const L=()=>{v(!1),m(null)};return S?h.jsx(Of,{theme:Y,children:h.jsx(tw,{children:h.jsx(nw,{})})}):h.jsxs(Of,{theme:Y,children:[R?h.jsxs(ew,{children:[h.jsx(jx,{currentUser:R,activeChannel:c,onChannelSelect:d}),h.jsx(k1,{channel:c}),h.jsx(J1,{})]}):h.jsx(Mv,{isOpen:!0,onClose:()=>{}}),h.jsx(M1,{isOpen:w,onClose:L,error:p})]})}const ew=C.div` + display: flex; + height: 100vh; + width: 100vw; + position: relative; +`,tw=C.div` + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + background-color: ${({theme:r})=>r.colors.background.primary}; +`,nw=C.div` + width: 40px; + height: 40px; + border: 4px solid ${({theme:r})=>r.colors.background.tertiary}; + border-top: 4px solid ${({theme:r})=>r.colors.brand.primary}; + border-radius: 50%; + animation: spin 1s linear infinite; + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } +`,Rh=document.getElementById("root");if(!Rh)throw new Error("Root element not found");Lg.createRoot(Rh).render(h.jsx(K.StrictMode,{children:h.jsx(Z1,{})})); diff --git a/src/main/resources/static/assets/index-kQJbKSsj.css b/src/main/resources/static/assets/index-kQJbKSsj.css new file mode 100644 index 000000000..096eb4112 --- /dev/null +++ b/src/main/resources/static/assets/index-kQJbKSsj.css @@ -0,0 +1 @@ +:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}} diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..479bed6a3da0a8dbdd08a51d81b30e4d4fabae89 GIT binary patch literal 1588 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyacIC_6YK2V5m}KU}$JzVE6?TYIwoGP-?)y@G60U!Dv>Mu*Du8ycRt4Yw>0&$ytddU zdTHwA$vlU)7;*ZQn^d>r9eiw}SEV3v&DP3PpZVm?c2D=&D? zJg+7dT;x9cg;(mDqrovi2QemjySudY+_R1aaySb-B8!2p69!>MhFNnYfC{QST^vI! zPM@6=9?WDY()wLtM|S>=KoQ44K~Zk4us5=<8xs!eeY>~&=ly4!jD%AXj+wvro>aU~ zrMO$=?`j4U&ZyW$Je*!Zo0>H2RZVqmn^V&mZ(9Dkv!~|IuDF1RBN|EPJE zX3ok)rzF<3&vZKWEj4ag73&t}uJvVk^<~M;*V0n54#8@&v!WGjE_hAaeAZEF z$~V4aF>{^dUc7o%=f8f9m%*2vzjfI@vJ2Z97)VU5x-s2*r@e{H>FEn3A3Dr3G&8U| z)>wFiQO&|Yl6}UkXAQ>%q$jNWac-tTL*)AEyto|onkmnmcJLf?71w_<>4WODmBMxF zwGM7``txcQgT`x>(tH-DrT2Kg=4LzpNv>|+a@TgYDZ`5^$KJVb`K=%k^tRpoxP|4? zwXb!O5~dXYKYt*j(YSx+#_rP{TNcK=40T|)+k3s|?t||EQTgwGgs{E0Y+(QPL&Wx4 zMP23By&sn`zn7oCQQLp%-(Axm|M=5-u;TlFiTn5B^PWnb%fAPV8r2flh?11Vl2ohY zqEsNoU}Ruqple{LYiJr`U}|M-Vr62aZD3$!V6dZTmJ5o8-29Zxv`X9>PU+TH>UWRL)v7?M$%n`C9>lAm0fo0?Z*WfcHaTFhX${Qqu! zG&Nv5t*kOqGt)Cl7z{0q_!){?fojB&%z>&2&rB)F04ce=Mv()kL=s7fZ)R?4No7GQ z1K3si1$pWAo5K9i%<&BYs$wuSHMcY{Gc&O;(${(hEL0izk<1CstV(4taB`Zm$nFhL zDhx>~G{}=7Ei)$-=zaa%ypo*!bp5o%vdrZCykdPs#ORw@rkW)uCz=~4Cz={1nkQNs oC7PHSBpVtgnwc6|q*&+yb?5=zccWrGsMu%lboFyt=akR{0N~++#sB~S literal 0 HcmV?d00001 diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 000000000..f4fcc0e9f --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,26 @@ + + + + + + Discodeit + + + + + +
+ + diff --git a/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java new file mode 100644 index 000000000..c5a3a46a9 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java @@ -0,0 +1,136 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.service.AuthService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserDetailsService userDetailsService; + + @MockitoBean + private AuthService authService; + + @MockitoBean + private UserService userService; + + @Test + @DisplayName("현재 사용자 정보 조회 - 성공") + void me_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UserDto userDto = new UserDto( + userId, + "testuser", + "test@example.com", + null, + false, + Role.USER + ); + + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(userDto, "encodedPassword"); + + given(userService.find(userId)).willReturn(userDto); + + // When & Then + mockMvc.perform(get("/api/auth/me") + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.role").value("USER")); + } + + @Test + @DisplayName("현재 사용자 정보 조회 - 인증되지 않은 사용자") + void me_Unauthorized() throws Exception { + // When & Then + mockMvc.perform(get("/api/auth/me") + .with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("권한 업데이트 - 성공") + void updateRole_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + RoleUpdateRequest request = new RoleUpdateRequest(userId, Role.ADMIN); + UserDto updatedUserDto = new UserDto( + userId, + "testuser", + "test@example.com", + null, + false, + Role.ADMIN + ); + UserDto mockUserDto = new UserDto(userId, "testuser", "test@example.com", null, false, + Role.USER); + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(mockUserDto, "password"); + + given(authService.updateRole(any(RoleUpdateRequest.class))).willReturn(updatedUserDto); + + // When & Then + mockMvc.perform(put("/api/auth/role") + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.role").value("ADMIN")); + } + + @Test + @DisplayName("권한 업데이트 - 인증되지 않은 사용자") + void updateRole_Unauthorized() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + RoleUpdateRequest request = new RoleUpdateRequest(userId, Role.ADMIN); + + // When & Then + mockMvc.perform(put("/api/auth/role") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java new file mode 100644 index 000000000..b0263b009 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java @@ -0,0 +1,151 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(BinaryContentController.class) +@AutoConfigureMockMvc(addFilters = false) +class BinaryContentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private BinaryContentService binaryContentService; + + @MockitoBean + private BinaryContentStorage binaryContentStorage; + + @Test + @DisplayName("바이너리 컨텐츠 조회 성공 테스트") + void find_Success() throws Exception { + // Given + UUID binaryContentId = UUID.randomUUID(); + BinaryContentDto binaryContent = new BinaryContentDto( + binaryContentId, + "test.jpg", + 10240L, + MediaType.IMAGE_JPEG_VALUE + ); + + given(binaryContentService.find(binaryContentId)).willReturn(binaryContent); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", binaryContentId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(binaryContentId.toString())) + .andExpect(jsonPath("$.fileName").value("test.jpg")) + .andExpect(jsonPath("$.size").value(10240)) + .andExpect(jsonPath("$.contentType").value(MediaType.IMAGE_JPEG_VALUE)); + } + + @Test + @DisplayName("바이너리 컨텐츠 조회 실패 테스트 - 존재하지 않는 컨텐츠") + void find_Failure_BinaryContentNotFound() throws Exception { + // Given + UUID nonExistentId = UUID.randomUUID(); + + given(binaryContentService.find(nonExistentId)) + .willThrow(BinaryContentNotFoundException.withId(nonExistentId)); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", nonExistentId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("ID 목록으로 바이너리 컨텐츠 조회 성공 테스트") + void findAllByIdIn_Success() throws Exception { + // Given + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + + List binaryContentIds = List.of(id1, id2); + + List binaryContents = List.of( + new BinaryContentDto(id1, "test1.jpg", 10240L, MediaType.IMAGE_JPEG_VALUE), + new BinaryContentDto(id2, "test2.pdf", 20480L, MediaType.APPLICATION_PDF_VALUE) + ); + + given(binaryContentService.findAllByIdIn(binaryContentIds)).willReturn(binaryContents); + + // When & Then + mockMvc.perform(get("/api/binaryContents") + .param("binaryContentIds", id1.toString(), id2.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(id1.toString())) + .andExpect(jsonPath("$[0].fileName").value("test1.jpg")) + .andExpect(jsonPath("$[1].id").value(id2.toString())) + .andExpect(jsonPath("$[1].fileName").value("test2.pdf")); + } + + @Test + @DisplayName("바이너리 컨텐츠 다운로드 성공 테스트") + void download_Success() throws Exception { + // Given + UUID binaryContentId = UUID.randomUUID(); + BinaryContentDto binaryContent = new BinaryContentDto( + binaryContentId, + "test.jpg", + 10240L, + MediaType.IMAGE_JPEG_VALUE + ); + + given(binaryContentService.find(binaryContentId)).willReturn(binaryContent); + + // doReturn 사용하여 타입 문제 우회 + ResponseEntity mockResponse = ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"test.jpg\"") + .header(HttpHeaders.CONTENT_TYPE, MediaType.IMAGE_JPEG_VALUE) + .body(new ByteArrayResource("test data".getBytes())); + + doReturn(mockResponse).when(binaryContentStorage).download(any(BinaryContentDto.class)); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", binaryContentId)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("바이너리 컨텐츠 다운로드 실패 테스트 - 존재하지 않는 컨텐츠") + void download_Failure_BinaryContentNotFound() throws Exception { + // Given + UUID nonExistentId = UUID.randomUUID(); + + given(binaryContentService.find(nonExistentId)) + .willThrow(BinaryContentNotFoundException.withId(nonExistentId)); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", nonExistentId)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java new file mode 100644 index 000000000..f968bf85b --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java @@ -0,0 +1,286 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.service.ChannelService; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ChannelController.class) +@AutoConfigureMockMvc(addFilters = false) +class ChannelControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ChannelService channelService; + + @Test + @DisplayName("공개 채널 생성 성공 테스트") + void createPublicChannel_Success() throws Exception { + // Given + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "test-channel", + "채널 설명입니다." + ); + + UUID channelId = UUID.randomUUID(); + ChannelDto createdChannel = new ChannelDto( + channelId, + ChannelType.PUBLIC, + "test-channel", + "채널 설명입니다.", + new ArrayList<>(), + Instant.now() + ); + + given(channelService.create(any(PublicChannelCreateRequest.class))) + .willReturn(createdChannel); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.type").value("PUBLIC")) + .andExpect(jsonPath("$.name").value("test-channel")) + .andExpect(jsonPath("$.description").value("채널 설명입니다.")); + } + + @Test + @DisplayName("공개 채널 생성 실패 테스트 - 유효하지 않은 요청") + void createPublicChannel_Failure_InvalidRequest() throws Exception { + // Given + PublicChannelCreateRequest invalidRequest = new PublicChannelCreateRequest( + "a", // 최소 길이 위반 (2자 이상이어야 함) + "채널 설명은 최대 255자까지 가능합니다.".repeat(10) // 최대 길이 위반 + ); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비공개 채널 생성 성공 테스트") + void createPrivateChannel_Success() throws Exception { + // Given + List participantIds = List.of(UUID.randomUUID(), UUID.randomUUID()); + PrivateChannelCreateRequest createRequest = new PrivateChannelCreateRequest(participantIds); + + UUID channelId = UUID.randomUUID(); + List participants = new ArrayList<>(); + for (UUID userId : participantIds) { + participants.add(new UserDto(userId, "user-" + userId.toString().substring(0, 5), + "user" + userId.toString().substring(0, 5) + "@example.com", null, false, Role.USER)); + } + + ChannelDto createdChannel = new ChannelDto( + channelId, + ChannelType.PRIVATE, + null, + null, + participants, + Instant.now() + ); + + given(channelService.create(any(PrivateChannelCreateRequest.class))) + .willReturn(createdChannel); + + // When & Then + mockMvc.perform(post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.type").value("PRIVATE")) + .andExpect(jsonPath("$.participants").isArray()) + .andExpect(jsonPath("$.participants.length()").value(2)); + } + + @Test + @DisplayName("공개 채널 업데이트 성공 테스트") + void updateChannel_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "updated-channel", + "업데이트된 채널 설명입니다." + ); + + ChannelDto updatedChannel = new ChannelDto( + channelId, + ChannelType.PUBLIC, + "updated-channel", + "업데이트된 채널 설명입니다.", + new ArrayList<>(), + Instant.now() + ); + + given(channelService.update(eq(channelId), any(PublicChannelUpdateRequest.class))) + .willReturn(updatedChannel); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.name").value("updated-channel")) + .andExpect(jsonPath("$.description").value("업데이트된 채널 설명입니다.")); + } + + @Test + @DisplayName("채널 업데이트 실패 테스트 - 존재하지 않는 채널") + void updateChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "updated-channel", + "업데이트된 채널 설명입니다." + ); + + given(channelService.update(eq(nonExistentChannelId), any(PublicChannelUpdateRequest.class))) + .willThrow(ChannelNotFoundException.withId(nonExistentChannelId)); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", nonExistentChannelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널 업데이트 실패 테스트 - 비공개 채널 업데이트 시도") + void updateChannel_Failure_PrivateChannelUpdate() throws Exception { + // Given + UUID privateChannelId = UUID.randomUUID(); + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "updated-channel", + "업데이트된 채널 설명입니다." + ); + + given(channelService.update(eq(privateChannelId), any(PublicChannelUpdateRequest.class))) + .willThrow(PrivateChannelUpdateException.forChannel(privateChannelId)); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", privateChannelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("채널 삭제 성공 테스트") + void deleteChannel_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + willDoNothing().given(channelService).delete(channelId); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("채널 삭제 실패 테스트 - 존재하지 않는 채널") + void deleteChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + willThrow(ChannelNotFoundException.withId(nonExistentChannelId)) + .given(channelService).delete(nonExistentChannelId); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", nonExistentChannelId) + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 성공 테스트") + void findAllByUserId_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId1 = UUID.randomUUID(); + UUID channelId2 = UUID.randomUUID(); + + List channels = List.of( + new ChannelDto( + channelId1, + ChannelType.PUBLIC, + "public-channel", + "공개 채널 설명", + new ArrayList<>(), + Instant.now() + ), + new ChannelDto( + channelId2, + ChannelType.PRIVATE, + null, + null, + List.of(new UserDto(userId, "user1", "user1@example.com", null, true, Role.USER)), + Instant.now().minusSeconds(3600) + ) + ); + + given(channelService.findAllByUserId(userId)).willReturn(channels); + + // When & Then + mockMvc.perform(get("/api/channels") + .param("userId", userId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(channelId1.toString())) + .andExpect(jsonPath("$[0].type").value("PUBLIC")) + .andExpect(jsonPath("$[0].name").value("public-channel")) + .andExpect(jsonPath("$[1].id").value(channelId2.toString())) + .andExpect(jsonPath("$[1].type").value("PRIVATE")); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java new file mode 100644 index 000000000..e51eca77b --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java @@ -0,0 +1,316 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.service.MessageService; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(MessageController.class) +@AutoConfigureMockMvc(addFilters = false) +class MessageControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private MessageService messageService; + + @Test + @DisplayName("메시지 생성 성공 테스트") + void createMessage_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + MessageCreateRequest createRequest = new MessageCreateRequest( + "안녕하세요, 테스트 메시지입니다.", + channelId, + authorId + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile attachment = new MockMultipartFile( + "attachments", + "test.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test-image".getBytes() + ); + + UUID messageId = UUID.randomUUID(); + Instant now = Instant.now(); + + UserDto author = new UserDto( + authorId, + "testuser", + "test@example.com", + null, + true, + Role.USER + ); + + BinaryContentDto attachmentDto = new BinaryContentDto( + UUID.randomUUID(), + "test.jpg", + 10L, + MediaType.IMAGE_JPEG_VALUE + ); + + MessageDto createdMessage = new MessageDto( + messageId, + now, + now, + "안녕하세요, 테스트 메시지입니다.", + channelId, + author, + List.of(attachmentDto) + ); + + given(messageService.create(any(MessageCreateRequest.class), any(List.class))) + .willReturn(createdMessage); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart) + .file(attachment) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(messageId.toString())) + .andExpect(jsonPath("$.content").value("안녕하세요, 테스트 메시지입니다.")) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.author.id").value(authorId.toString())) + .andExpect(jsonPath("$.attachments[0].fileName").value("test.jpg")); + } + + @Test + @DisplayName("메시지 생성 실패 테스트 - 유효하지 않은 요청") + void createMessage_Failure_InvalidRequest() throws Exception { + // Given + MessageCreateRequest invalidRequest = new MessageCreateRequest( + "", // 내용이 비어있음 (NotBlank 위반) + null, // 채널 ID가 비어있음 (NotNull 위반) + null // 작성자 ID가 비어있음 (NotNull 위반) + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("메시지 업데이트 성공 테스트") + void updateMessage_Success() throws Exception { + // Given + UUID messageId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + Instant now = Instant.now(); + + UserDto author = new UserDto( + authorId, + "testuser", + "test@example.com", + null, + true, + Role.USER + ); + + MessageDto updatedMessage = new MessageDto( + messageId, + now.minusSeconds(60), + now, + "수정된 메시지 내용입니다.", + channelId, + author, + new ArrayList<>() + ); + + given(messageService.update(eq(messageId), any(MessageUpdateRequest.class))) + .willReturn(updatedMessage); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", messageId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(messageId.toString())) + .andExpect(jsonPath("$.content").value("수정된 메시지 내용입니다.")) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.author.id").value(authorId.toString())); + } + + @Test + @DisplayName("메시지 업데이트 실패 테스트 - 존재하지 않는 메시지") + void updateMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + given(messageService.update(eq(nonExistentMessageId), any(MessageUpdateRequest.class))) + .willThrow(MessageNotFoundException.withId(nonExistentMessageId)); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", nonExistentMessageId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("메시지 삭제 성공 테스트") + void deleteMessage_Success() throws Exception { + // Given + UUID messageId = UUID.randomUUID(); + willDoNothing().given(messageService).delete(messageId); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", messageId) + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("메시지 삭제 실패 테스트 - 존재하지 않는 메시지") + void deleteMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + willThrow(MessageNotFoundException.withId(nonExistentMessageId)) + .given(messageService).delete(nonExistentMessageId); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", nonExistentMessageId) + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널별 메시지 목록 조회 성공 테스트") + void findAllByChannelId_Success() throws Exception { + // Given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + Instant cursor = Instant.now(); + Pageable pageable = PageRequest.of(0, 50, Sort.Direction.DESC, "createdAt"); + + UserDto author = new UserDto( + authorId, + "testuser", + "test@example.com", + null, + true, + Role.USER + ); + + List messages = List.of( + new MessageDto( + UUID.randomUUID(), + cursor.minusSeconds(10), + cursor.minusSeconds(10), + "첫 번째 메시지", + channelId, + author, + new ArrayList<>() + ), + new MessageDto( + UUID.randomUUID(), + cursor.minusSeconds(20), + cursor.minusSeconds(20), + "두 번째 메시지", + channelId, + author, + new ArrayList<>() + ) + ); + + PageResponse pageResponse = new PageResponse<>( + messages, + cursor.minusSeconds(30), // nextCursor 값 + pageable.getPageSize(), + true, // hasNext + (long) messages.size() // totalElements + ); + + given(messageService.findAllByChannelId(eq(channelId), eq(cursor), any(Pageable.class))) + .willReturn(pageResponse); + + // When & Then + mockMvc.perform(get("/api/messages") + .param("channelId", channelId.toString()) + .param("cursor", cursor.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.content[0].content").value("첫 번째 메시지")) + .andExpect(jsonPath("$.content[1].content").value("두 번째 메시지")) + .andExpect(jsonPath("$.nextCursor").exists()) + .andExpect(jsonPath("$.size").value(50)) + .andExpect(jsonPath("$.hasNext").value(true)) + .andExpect(jsonPath("$.totalElements").value(2)); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java new file mode 100644 index 000000000..f1124f2ca --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java @@ -0,0 +1,178 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.exception.readstatus.ReadStatusNotFoundException; +import com.sprint.mission.discodeit.service.ReadStatusService; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ReadStatusController.class) +@AutoConfigureMockMvc(addFilters = false) +class ReadStatusControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ReadStatusService readStatusService; + + @Test + @DisplayName("읽음 상태 생성 성공 테스트") + void create_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + Instant lastReadAt = Instant.now(); + + ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest( + userId, + channelId, + lastReadAt + ); + + UUID readStatusId = UUID.randomUUID(); + ReadStatusDto createdReadStatus = new ReadStatusDto( + readStatusId, + userId, + channelId, + lastReadAt + ); + + given(readStatusService.create(any(ReadStatusCreateRequest.class))) + .willReturn(createdReadStatus); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(readStatusId.toString())) + .andExpect(jsonPath("$.userId").value(userId.toString())) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.lastReadAt").exists()); + } + + @Test + @DisplayName("읽음 상태 생성 실패 테스트 - 유효하지 않은 요청") + void create_Failure_InvalidRequest() throws Exception { + // Given + ReadStatusCreateRequest invalidRequest = new ReadStatusCreateRequest( + null, // userId가 null (NotNull 위반) + null, // channelId가 null (NotNull 위반) + null // lastReadAt이 null (NotNull 위반) + ); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("읽음 상태 업데이트 성공 테스트") + void update_Success() throws Exception { + // Given + UUID readStatusId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + Instant newLastReadAt = Instant.now(); + + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest(newLastReadAt); + + ReadStatusDto updatedReadStatus = new ReadStatusDto( + readStatusId, + userId, + channelId, + newLastReadAt + ); + + given(readStatusService.update(eq(readStatusId), any(ReadStatusUpdateRequest.class))) + .willReturn(updatedReadStatus); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", readStatusId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(readStatusId.toString())) + .andExpect(jsonPath("$.userId").value(userId.toString())) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.lastReadAt").exists()); + } + + @Test + @DisplayName("읽음 상태 업데이트 실패 테스트 - 존재하지 않는 읽음 상태") + void update_Failure_ReadStatusNotFound() throws Exception { + // Given + UUID nonExistentId = UUID.randomUUID(); + Instant newLastReadAt = Instant.now(); + + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest(newLastReadAt); + + given(readStatusService.update(eq(nonExistentId), any(ReadStatusUpdateRequest.class))) + .willThrow(ReadStatusNotFoundException.withId(nonExistentId)); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", nonExistentId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest)) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자별 읽음 상태 목록 조회 성공 테스트") + void findAllByUserId_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UUID channelId1 = UUID.randomUUID(); + UUID channelId2 = UUID.randomUUID(); + Instant now = Instant.now(); + + List readStatuses = List.of( + new ReadStatusDto(UUID.randomUUID(), userId, channelId1, now.minusSeconds(60)), + new ReadStatusDto(UUID.randomUUID(), userId, channelId2, now) + ); + + given(readStatusService.findAllByUserId(userId)).willReturn(readStatuses); + + // When & Then + mockMvc.perform(get("/api/readStatuses") + .param("userId", userId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].userId").value(userId.toString())) + .andExpect(jsonPath("$[0].channelId").value(channelId1.toString())) + .andExpect(jsonPath("$[1].userId").value(userId.toString())) + .andExpect(jsonPath("$[1].channelId").value(channelId2.toString())); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java new file mode 100644 index 000000000..662cc950c --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java @@ -0,0 +1,306 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.service.UserService; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(UserController.class) +@AutoConfigureMockMvc(addFilters = false) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserService userService; + + + @Test + @DisplayName("사용자 생성 성공 테스트") + void createUser_Success() throws Exception { + // Given + UserCreateRequest createRequest = new UserCreateRequest( + "testuser", + "test@example.com", + "Password1!" + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test-image".getBytes() + ); + + UUID userId = UUID.randomUUID(); + BinaryContentDto profileDto = new BinaryContentDto( + UUID.randomUUID(), + "profile.jpg", + 12L, + MediaType.IMAGE_JPEG_VALUE + ); + + UserDto createdUser = new UserDto( + userId, + "testuser", + "test@example.com", + profileDto, + false, + Role.USER + ); + + given(userService.create(any(UserCreateRequest.class), any(Optional.class))) + .willReturn(createdUser); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.profile.fileName").value("profile.jpg")) + .andExpect(jsonPath("$.online").value(false)); + } + + @Test + @DisplayName("사용자 생성 실패 테스트 - 유효하지 않은 요청") + void createUser_Failure_InvalidRequest() throws Exception { + // Given + UserCreateRequest invalidRequest = new UserCreateRequest( + "t", // 최소 길이 위반 + "invalid-email", // 이메일 형식 위반 + "short" // 비밀번호 정책 위반 + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("사용자 조회 성공 테스트") + void findAllUsers_Success() throws Exception { + // Given + UUID userId1 = UUID.randomUUID(); + UUID userId2 = UUID.randomUUID(); + + UserDto user1 = new UserDto( + userId1, + "user1", + "user1@example.com", + null, + true, + Role.USER + ); + + UserDto user2 = new UserDto( + userId2, + "user2", + "user2@example.com", + null, + false, + Role.USER + ); + + List users = List.of(user1, user2); + + given(userService.findAll()).willReturn(users); + + // When & Then + mockMvc.perform(get("/api/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(userId1.toString())) + .andExpect(jsonPath("$[0].username").value("user1")) + .andExpect(jsonPath("$[0].online").value(true)) + .andExpect(jsonPath("$[1].id").value(userId2.toString())) + .andExpect(jsonPath("$[1].username").value("user2")) + .andExpect(jsonPath("$[1].online").value(false)); + } + + @Test + @DisplayName("사용자 업데이트 성공 테스트") + void updateUser_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "updated-profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "updated-image".getBytes() + ); + + BinaryContentDto profileDto = new BinaryContentDto( + UUID.randomUUID(), + "updated-profile.jpg", + 14L, + MediaType.IMAGE_JPEG_VALUE + ); + + UserDto updatedUser = new UserDto( + userId, + "updateduser", + "updated@example.com", + profileDto, + true, + Role.USER + ); + + given(userService.update(eq(userId), any(UserUpdateRequest.class), any(Optional.class))) + .willReturn(updatedUser); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", userId) + .file(userUpdateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + }) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("updateduser")) + .andExpect(jsonPath("$.email").value("updated@example.com")) + .andExpect(jsonPath("$.profile.fileName").value("updated-profile.jpg")) + .andExpect(jsonPath("$.online").value(true)); + } + + @Test + @DisplayName("사용자 업데이트 실패 테스트 - 존재하지 않는 사용자") + void updateUser_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "updated-profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "updated-image".getBytes() + ); + + given(userService.update(eq(nonExistentUserId), any(UserUpdateRequest.class), + any(Optional.class))) + .willThrow(UserNotFoundException.withId(nonExistentUserId)); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", nonExistentUserId) + .file(userUpdateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + }) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자 삭제 성공 테스트") + void deleteUser_Success() throws Exception { + // Given + UUID userId = UUID.randomUUID(); + willDoNothing().given(userService).delete(userId); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", userId) + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("사용자 삭제 실패 테스트 - 존재하지 않는 사용자") + void deleteUser_Failure_UserNotFound() throws Exception { + // Given + UUID nonExistentUserId = UUID.randomUUID(); + willThrow(UserNotFoundException.withId(nonExistentUserId)) + .given(userService).delete(nonExistentUserId); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", nonExistentUserId) + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java new file mode 100644 index 000000000..e0313828f --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java @@ -0,0 +1,123 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.UserService; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.MultiValueMap; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AuthApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserService userService; + + @Test + @DisplayName("로그인 API 통합 테스트 - 성공") + void login_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "loginuser", + "login@example.com", + "Password1!" + ); + + userService.create(userRequest, Optional.empty()); + + // 로그인 요청 + LoginRequest loginRequest = new LoginRequest( + "loginuser", + "Password1!" + ); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .formFields(MultiValueMap.fromMultiValue(Map.of( + "username", List.of(loginRequest.username()), + "password", List.of(loginRequest.password()) + )))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.username", is("loginuser"))) + .andExpect(jsonPath("$.email", is("login@example.com"))); + } + + @Test + @DisplayName("로그인 API 통합 테스트 - 실패 (존재하지 않는 사용자)") + void login_Failure_UserNotFound() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "nonexistentuser", + "Password1!" + ); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .formFields(MultiValueMap.fromMultiValue(Map.of( + "username", List.of(loginRequest.username()), + "password", List.of(loginRequest.password()) + )))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("로그인 API 통합 테스트 - 실패 (잘못된 비밀번호)") + void login_Failure_InvalidCredentials() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "loginuser2", + "login2@example.com", + "Password1!" + ); + + userService.create(userRequest, Optional.empty()); + + // 잘못된 비밀번호로 로그인 시도 + LoginRequest loginRequest = new LoginRequest( + "loginuser2", + "WrongPassword1!" + ); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .formFields(MultiValueMap.fromMultiValue(Map.of( + "username", List.of(loginRequest.username()), + "password", List.of(loginRequest.password()) + )))) + .andExpect(status().isUnauthorized()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java new file mode 100644 index 000000000..47ad4e7bc --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/BinaryContentApiIntegrationTest.java @@ -0,0 +1,217 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class BinaryContentApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private BinaryContentService binaryContentService; + + @Autowired + private UserService userService; + + @Autowired + private ChannelService channelService; + + @Autowired + private MessageService messageService; + + @Test + @WithMockUser(roles = "CHANNEL_MANAGER") + @DisplayName("바이너리 컨텐츠 조회 API 통합 테스트") + void findBinaryContent_Success() throws Exception { + // Given + // 테스트 바이너리 컨텐츠 생성 (메시지 첨부파일을 통해 생성) + // 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "contentuser", + "content@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + var channel = channelService.create(channelRequest); + + // 첨부파일이 있는 메시지 생성 + MessageCreateRequest messageRequest = new MessageCreateRequest( + "첨부파일이 있는 메시지입니다.", + channel.id(), + user.id() + ); + + byte[] fileContent = "테스트 파일 내용입니다.".getBytes(); + BinaryContentCreateRequest attachmentRequest = new BinaryContentCreateRequest( + "test.txt", + MediaType.TEXT_PLAIN_VALUE, + fileContent + ); + + MessageDto message = messageService.create(messageRequest, List.of(attachmentRequest)); + UUID binaryContentId = message.attachments().get(0).id(); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", binaryContentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(binaryContentId.toString()))) + .andExpect(jsonPath("$.fileName", is("test.txt"))) + .andExpect(jsonPath("$.contentType", is(MediaType.TEXT_PLAIN_VALUE))) + .andExpect(jsonPath("$.size", is(fileContent.length))); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("존재하지 않는 바이너리 컨텐츠 조회 API 통합 테스트") + void findBinaryContent_Failure_NotFound() throws Exception { + // Given + UUID nonExistentBinaryContentId = UUID.randomUUID(); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}", nonExistentBinaryContentId)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(roles = "CHANNEL_MANAGER") + @DisplayName("여러 바이너리 컨텐츠 조회 API 통합 테스트") + void findAllBinaryContentsByIds_Success() throws Exception { + // Given + // 테스트 바이너리 컨텐츠 생성 (메시지 첨부파일을 통해 생성) + UserCreateRequest userRequest = new UserCreateRequest( + "contentuser2", + "content2@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널2", + "테스트 채널 설명입니다." + ); + var channel = channelService.create(channelRequest); + + MessageCreateRequest messageRequest = new MessageCreateRequest( + "첨부파일이 있는 메시지입니다.", + channel.id(), + user.id() + ); + + // 첫 번째 첨부파일 + BinaryContentCreateRequest attachmentRequest1 = new BinaryContentCreateRequest( + "test1.txt", + MediaType.TEXT_PLAIN_VALUE, + "첫 번째 테스트 파일 내용입니다.".getBytes() + ); + + // 두 번째 첨부파일 + BinaryContentCreateRequest attachmentRequest2 = new BinaryContentCreateRequest( + "test2.txt", + MediaType.TEXT_PLAIN_VALUE, + "두 번째 테스트 파일 내용입니다.".getBytes() + ); + + // 첨부파일 두 개를 가진 메시지 생성 + MessageDto message = messageService.create( + messageRequest, + List.of(attachmentRequest1, attachmentRequest2) + ); + + List binaryContentIds = message.attachments().stream() + .map(BinaryContentDto::id) + .toList(); + + // When & Then + mockMvc.perform(get("/api/binaryContents") + .param("binaryContentIds", binaryContentIds.get(0).toString()) + .param("binaryContentIds", binaryContentIds.get(1).toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[*].fileName", hasItems("test1.txt", "test2.txt"))); + } + + @Test + @WithMockUser(roles = "CHANNEL_MANAGER") + @DisplayName("바이너리 컨텐츠 다운로드 API 통합 테스트") + void downloadBinaryContent_Success() throws Exception { + // Given + String fileContent = "다운로드 테스트 파일 내용입니다."; + BinaryContentCreateRequest createRequest = new BinaryContentCreateRequest( + "download-test.txt", + MediaType.TEXT_PLAIN_VALUE, + fileContent.getBytes() + ); + + BinaryContentDto binaryContent = binaryContentService.create(createRequest); + UUID binaryContentId = binaryContent.id(); + + // When & Then + mockMvc.perform(get("/api/binaryContents/{binaryContentId}/download", binaryContentId)) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", + "attachment; filename=\"download-test.txt\"")) + .andExpect(content().contentType(MediaType.TEXT_PLAIN_VALUE)) + .andExpect(content().bytes(fileContent.getBytes())); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("존재하지 않는 바이너리 컨텐츠 다운로드 API 통합 테스트") + void downloadBinaryContent_Failure_NotFound() throws Exception { + // Given + UUID nonExistentBinaryContentId = UUID.randomUUID(); + + // When & Then + mockMvc.perform( + get("/api/binaryContents/{binaryContentId}/download", nonExistentBinaryContentId)) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java new file mode 100644 index 000000000..f6629a375 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/ChannelApiIntegrationTest.java @@ -0,0 +1,288 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class ChannelApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ChannelService channelService; + + @Autowired + private UserService userService; + + @Test + @DisplayName("공개 채널 생성 API 통합 테스트") + @WithMockUser(roles = "CHANNEL_MANAGER") + void createPublicChannel_Success() throws Exception { + // Given + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(createRequest); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.type", is(ChannelType.PUBLIC.name()))) + .andExpect(jsonPath("$.name", is("테스트 채널"))) + .andExpect(jsonPath("$.description", is("테스트 채널 설명입니다."))); + } + + @Test + @DisplayName("공개 채널 생성 실패 API 통합 테스트 - 유효하지 않은 요청") + @WithMockUser(roles = "CHANNEL_MANAGER") + void createPublicChannel_Failure_InvalidRequest() throws Exception { + // Given + PublicChannelCreateRequest invalidRequest = new PublicChannelCreateRequest( + "a", // 최소 길이 위반 + "테스트 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(invalidRequest); + + // When & Then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비공개 채널 생성 API 통합 테스트") + @WithMockUser(roles = "USER") + void createPrivateChannel_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest1 = new UserCreateRequest( + "user1", + "user1@example.com", + "Password1!" + ); + + UserCreateRequest userRequest2 = new UserCreateRequest( + "user2", + "user2@example.com", + "Password1!" + ); + + UserDto user1 = userService.create(userRequest1, Optional.empty()); + UserDto user2 = userService.create(userRequest2, Optional.empty()); + + List participantIds = List.of(user1.id(), user2.id()); + PrivateChannelCreateRequest createRequest = new PrivateChannelCreateRequest(participantIds); + + String requestBody = objectMapper.writeValueAsString(createRequest); + + // When & Then + mockMvc.perform(post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.type", is(ChannelType.PRIVATE.name()))) + .andExpect(jsonPath("$.participants", hasSize(2))); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 API 통합 테스트") + @WithMockUser(roles = "CHANNEL_MANAGER") + void findAllChannelsByUserId_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "channeluser", + "channeluser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + UUID userId = user.id(); + + // 공개 채널 생성 + PublicChannelCreateRequest publicChannelRequest = new PublicChannelCreateRequest( + "공개 채널 1", + "공개 채널 설명입니다." + ); + + channelService.create(publicChannelRequest); + + // 비공개 채널 생성 + UserCreateRequest otherUserRequest = new UserCreateRequest( + "otheruser", + "otheruser@example.com", + "Password1!" + ); + + UserDto otherUser = userService.create(otherUserRequest, Optional.empty()); + + PrivateChannelCreateRequest privateChannelRequest = new PrivateChannelCreateRequest( + List.of(userId, otherUser.id()) + ); + + channelService.create(privateChannelRequest); + + // When & Then + mockMvc.perform(get("/api/channels") + .param("userId", userId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].type", is(ChannelType.PUBLIC.name()))) + .andExpect(jsonPath("$[1].type", is(ChannelType.PRIVATE.name()))); + } + + @Test + @DisplayName("채널 업데이트 API 통합 테스트") + @WithMockUser(roles = "CHANNEL_MANAGER") + void updateChannel_Success() throws Exception { + // Given + // 공개 채널 생성 + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "원본 채널", + "원본 채널 설명입니다." + ); + + ChannelDto createdChannel = channelService.create(createRequest); + UUID channelId = createdChannel.id(); + + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "수정된 채널", + "수정된 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(channelId.toString()))) + .andExpect(jsonPath("$.name", is("수정된 채널"))) + .andExpect(jsonPath("$.description", is("수정된 채널 설명입니다."))); + } + + @Test + @DisplayName("채널 업데이트 실패 API 통합 테스트 - 존재하지 않는 채널") + @WithMockUser(roles = "CHANNEL_MANAGER") + void updateChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + + PublicChannelUpdateRequest updateRequest = new PublicChannelUpdateRequest( + "수정된 채널", + "수정된 채널 설명입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/channels/{channelId}", nonExistentChannelId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널 삭제 API 통합 테스트") + @WithMockUser(roles = "CHANNEL_MANAGER") + void deleteChannel_Success() throws Exception { + // Given + // 공개 채널 생성 + PublicChannelCreateRequest createRequest = new PublicChannelCreateRequest( + "삭제할 채널", + "삭제할 채널 설명입니다." + ); + + ChannelDto createdChannel = channelService.create(createRequest); + UUID channelId = createdChannel.id(); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", channelId) + .with(csrf())) + .andExpect(status().isNoContent()); + + // 삭제 확인 - 사용자로 채널 조회 시 삭제된 채널은 조회되지 않아야 함 + UserCreateRequest userRequest = new UserCreateRequest( + "testuser", + "testuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + mockMvc.perform(get("/api/channels") + .param("userId", user.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == '" + channelId + "')]").doesNotExist()); + } + + @Test + @DisplayName("채널 삭제 실패 API 통합 테스트 - 존재하지 않는 채널") + @WithMockUser(roles = "CHANNEL_MANAGER") + void deleteChannel_Failure_ChannelNotFound() throws Exception { + // Given + UUID nonExistentChannelId = UUID.randomUUID(); + + // When & Then + mockMvc.perform(delete("/api/channels/{channelId}", nonExistentChannelId) + .with(csrf())) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java new file mode 100644 index 000000000..f012e42a9 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/MessageApiIntegrationTest.java @@ -0,0 +1,350 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.service.UserService; +import java.util.ArrayList; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class MessageApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MessageService messageService; + + @Autowired + private ChannelService channelService; + + @Autowired + private UserService userService; + + @Test + @WithMockUser(roles = "CHANNEL_MANAGER") + @DisplayName("메시지 생성 API 통합 테스트") + void createMessage_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + // 메시지 생성 요청 + MessageCreateRequest createRequest = new MessageCreateRequest( + "테스트 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile attachmentPart = new MockMultipartFile( + "attachments", + "test.txt", + MediaType.TEXT_PLAIN_VALUE, + "테스트 첨부 파일 내용".getBytes() + ); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart) + .file(attachmentPart) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.content", is("테스트 메시지 내용입니다."))) + .andExpect(jsonPath("$.channelId", is(channel.id().toString()))) + .andExpect(jsonPath("$.author.id", is(user.id().toString()))) + .andExpect(jsonPath("$.attachments", hasSize(1))) + .andExpect(jsonPath("$.attachments[0].fileName", is("test.txt"))); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("메시지 생성 실패 API 통합 테스트 - 유효하지 않은 요청") + void createMessage_Failure_InvalidRequest() throws Exception { + // Given + MessageCreateRequest invalidRequest = new MessageCreateRequest( + "", // 내용이 비어있음 + UUID.randomUUID(), + UUID.randomUUID() + ); + + MockMultipartFile messageCreateRequestPart = new MockMultipartFile( + "messageCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/messages") + .file(messageCreateRequestPart) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(roles = "CHANNEL_MANAGER") + @DisplayName("채널별 메시지 목록 조회 API 통합 테스트") + void findAllMessagesByChannelId_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + + // 메시지 생성 + MessageCreateRequest messageRequest1 = new MessageCreateRequest( + "첫 번째 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MessageCreateRequest messageRequest2 = new MessageCreateRequest( + "두 번째 메시지 내용입니다.", + channel.id(), + user.id() + ); + + messageService.create(messageRequest1, new ArrayList<>()); + messageService.create(messageRequest2, new ArrayList<>()); + + // When & Then + mockMvc.perform(get("/api/messages") + .param("channelId", channel.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(2))) + .andExpect(jsonPath("$.content[0].content", is("두 번째 메시지 내용입니다."))) + .andExpect(jsonPath("$.content[1].content", is("첫 번째 메시지 내용입니다."))) + .andExpect(jsonPath("$.size").exists()) + .andExpect(jsonPath("$.hasNext").exists()) + .andExpect(jsonPath("$.totalElements").isEmpty()); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("메시지 업데이트 API 통합 테스트") + void updateMessage_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(user, "Password1!"); + + // 메시지 생성 + MessageCreateRequest createRequest = new MessageCreateRequest( + "원본 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MessageDto createdMessage = messageService.create(createRequest, new ArrayList<>()); + UUID messageId = createdMessage.id(); + + // 메시지 업데이트 요청 + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", messageId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(messageId.toString()))) + .andExpect(jsonPath("$.content", is("수정된 메시지 내용입니다."))) + .andExpect(jsonPath("$.updatedAt").exists()); + } + + @Test + @DisplayName("메시지 업데이트 실패 API 통합 테스트 - 존재하지 않는 메시지") + void updateMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + + // 테스트 사용자 생성 (권한 검증을 위해) + UserCreateRequest userRequest = new UserCreateRequest( + "testuser", + "test@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(user, "Password1!"); + + MessageUpdateRequest updateRequest = new MessageUpdateRequest( + "수정된 메시지 내용입니다." + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/messages/{messageId}", nonExistentMessageId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("메시지 삭제 API 통합 테스트") + void deleteMessage_Success() throws Exception { + // Given + // 테스트 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "테스트 채널", + "테스트 채널 설명입니다." + ); + + ChannelDto channel = channelService.create(channelRequest); + + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "messageuser", + "messageuser@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(user, "Password1!"); + + // 메시지 생성 + MessageCreateRequest createRequest = new MessageCreateRequest( + "삭제할 메시지 내용입니다.", + channel.id(), + user.id() + ); + + MessageDto createdMessage = messageService.create(createRequest, new ArrayList<>()); + UUID messageId = createdMessage.id(); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", messageId) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isNoContent()); + + // 삭제 확인 - 채널의 메시지 목록 조회 시 삭제된 메시지는 조회되지 않아야 함 + mockMvc.perform(get("/api/messages") + .param("channelId", channel.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(0))); + } + + @Test + @DisplayName("메시지 삭제 실패 API 통합 테스트 - 존재하지 않는 메시지") + void deleteMessage_Failure_MessageNotFound() throws Exception { + // Given + UUID nonExistentMessageId = UUID.randomUUID(); + + // 테스트 사용자 생성 (권한 검증을 위해) + UserCreateRequest userRequest = new UserCreateRequest( + "testuser", + "test@example.com", + "Password1!" + ); + + UserDto user = userService.create(userRequest, Optional.empty()); + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(user, "Password1!"); + + // When & Then + mockMvc.perform(delete("/api/messages/{messageId}", nonExistentMessageId) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java new file mode 100644 index 000000000..c289ae985 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/ReadStatusApiIntegrationTest.java @@ -0,0 +1,280 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.data.ReadStatusDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.service.ChannelService; +import com.sprint.mission.discodeit.service.ReadStatusService; +import com.sprint.mission.discodeit.service.UserService; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.security.test.context.support.WithMockUser; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class ReadStatusApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ReadStatusService readStatusService; + + @Autowired + private UserService userService; + + @Autowired + private ChannelService channelService; + + @Test + @WithMockUser(roles = "CHANNEL_MANAGER") + @DisplayName("읽음 상태 생성 API 통합 테스트") + void createReadStatus_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "readstatususer", + "readstatus@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 공개 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "읽음 상태 테스트 채널", + "읽음 상태 테스트 채널 설명입니다." + ); + ChannelDto channel = channelService.create(channelRequest); + + // 읽음 상태 생성 요청 + Instant lastReadAt = Instant.now(); + ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + lastReadAt + ); + + String requestBody = objectMapper.writeValueAsString(createRequest); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.userId", is(user.id().toString()))) + .andExpect(jsonPath("$.channelId", is(channel.id().toString()))) + .andExpect(jsonPath("$.lastReadAt", is(lastReadAt.toString()))); + } + + @Test + @WithMockUser(roles = "CHANNEL_MANAGER") + @DisplayName("읽음 상태 생성 실패 API 통합 테스트 - 중복 생성") + void createReadStatus_Failure_Duplicate() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "duplicateuser", + "duplicate@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 공개 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "중복 테스트 채널", + "중복 테스트 채널 설명입니다." + ); + ChannelDto channel = channelService.create(channelRequest); + + // 첫 번째 읽음 상태 생성 요청 (성공) + Instant lastReadAt = Instant.now(); + ReadStatusCreateRequest firstCreateRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + lastReadAt + ); + + String firstRequestBody = objectMapper.writeValueAsString(firstCreateRequest); + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(firstRequestBody) + .with(csrf())) + .andExpect(status().isCreated()); + + // 두 번째 읽음 상태 생성 요청 (동일 사용자, 동일 채널) - 실패해야 함 + ReadStatusCreateRequest duplicateCreateRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + Instant.now() + ); + + String duplicateRequestBody = objectMapper.writeValueAsString(duplicateCreateRequest); + + // When & Then + mockMvc.perform(post("/api/readStatuses") + .contentType(MediaType.APPLICATION_JSON) + .content(duplicateRequestBody) + .with(csrf())) + .andExpect(status().isConflict()); + } + + @Test + @WithMockUser(roles = "CHANNEL_MANAGER") + @DisplayName("읽음 상태 업데이트 API 통합 테스트") + void updateReadStatus_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "updateuser", + "update@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 공개 채널 생성 + PublicChannelCreateRequest channelRequest = new PublicChannelCreateRequest( + "업데이트 테스트 채널", + "업데이트 테스트 채널 설명입니다." + ); + ChannelDto channel = channelService.create(channelRequest); + + // 읽음 상태 생성 + Instant initialLastReadAt = Instant.now().minusSeconds(3600); // 1시간 전 + ReadStatusCreateRequest createRequest = new ReadStatusCreateRequest( + user.id(), + channel.id(), + initialLastReadAt + ); + + ReadStatusDto createdReadStatus = readStatusService.create(createRequest); + UUID readStatusId = createdReadStatus.id(); + + // 읽음 상태 업데이트 요청 + Instant newLastReadAt = Instant.now(); + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest( + newLastReadAt + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", readStatusId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(readStatusId.toString()))) + .andExpect(jsonPath("$.userId", is(user.id().toString()))) + .andExpect(jsonPath("$.channelId", is(channel.id().toString()))) + .andExpect(jsonPath("$.lastReadAt", is(newLastReadAt.toString()))); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("읽음 상태 업데이트 실패 API 통합 테스트 - 존재하지 않는 읽음 상태") + void updateReadStatus_Failure_NotFound() throws Exception { + // Given + UUID nonExistentReadStatusId = UUID.randomUUID(); + + ReadStatusUpdateRequest updateRequest = new ReadStatusUpdateRequest( + Instant.now() + ); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // When & Then + mockMvc.perform(patch("/api/readStatuses/{readStatusId}", nonExistentReadStatusId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(roles = "CHANNEL_MANAGER") + @DisplayName("사용자별 읽음 상태 목록 조회 API 통합 테스트") + void findAllReadStatusesByUserId_Success() throws Exception { + // Given + // 테스트 사용자 생성 + UserCreateRequest userRequest = new UserCreateRequest( + "listuser", + "list@example.com", + "Password1!" + ); + UserDto user = userService.create(userRequest, Optional.empty()); + + // 여러 채널 생성 + PublicChannelCreateRequest channelRequest1 = new PublicChannelCreateRequest( + "목록 테스트 채널 1", + "목록 테스트 채널 설명입니다." + ); + + PublicChannelCreateRequest channelRequest2 = new PublicChannelCreateRequest( + "목록 테스트 채널 2", + "목록 테스트 채널 설명입니다." + ); + + ChannelDto channel1 = channelService.create(channelRequest1); + ChannelDto channel2 = channelService.create(channelRequest2); + + // 각 채널에 대한 읽음 상태 생성 + ReadStatusCreateRequest createRequest1 = new ReadStatusCreateRequest( + user.id(), + channel1.id(), + Instant.now().minusSeconds(3600) + ); + + ReadStatusCreateRequest createRequest2 = new ReadStatusCreateRequest( + user.id(), + channel2.id(), + Instant.now() + ); + + readStatusService.create(createRequest1); + readStatusService.create(createRequest2); + + // When & Then + mockMvc.perform(get("/api/readStatuses") + .param("userId", user.id().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[*].channelId", + hasItems(channel1.id().toString(), channel2.id().toString()))); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java new file mode 100644 index 000000000..ed89bcfbe --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/UserApiIntegrationTest.java @@ -0,0 +1,299 @@ +package com.sprint.mission.discodeit.integration; + +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.service.UserService; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class UserApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserService userService; + + + @Test + @WithMockUser(roles = "USER") + @DisplayName("사용자 생성 API 통합 테스트") + void createUser_Success() throws Exception { + // Given + UserCreateRequest createRequest = new UserCreateRequest( + "testuser", + "test@example.com", + "Password1!" + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(createRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test-image".getBytes() + ); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.username", is("testuser"))) + .andExpect(jsonPath("$.email", is("test@example.com"))) + .andExpect(jsonPath("$.profile.fileName", is("profile.jpg"))) + .andExpect(jsonPath("$.online", is(false))); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("사용자 생성 실패 API 통합 테스트 - 유효하지 않은 요청") + void createUser_Failure_InvalidRequest() throws Exception { + // Given + UserCreateRequest invalidRequest = new UserCreateRequest( + "t", // 최소 길이 위반 + "invalid-email", // 이메일 형식 위반 + "short" // 비밀번호 정책 위반 + ); + + MockMultipartFile userCreateRequestPart = new MockMultipartFile( + "userCreateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(invalidRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/users") + .file(userCreateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(roles = "USER") + @DisplayName("모든 사용자 조회 API 통합 테스트") + void findAllUsers_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest userRequest1 = new UserCreateRequest( + "user1", + "user1@example.com", + "Password1!" + ); + + UserCreateRequest userRequest2 = new UserCreateRequest( + "user2", + "user2@example.com", + "Password1!" + ); + + userService.create(userRequest1, Optional.empty()); + userService.create(userRequest2, Optional.empty()); + + // When & Then + mockMvc.perform(get("/api/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(3))) // 시스템 사용자 포함하여 3개 + .andExpect(jsonPath("$[?(@.username == 'user1')].email", hasItems("user1@example.com"))) + .andExpect(jsonPath("$[?(@.username == 'user2')].email", hasItems("user2@example.com"))); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("사용자 업데이트 API 통합 테스트") + void updateUser_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest createRequest = new UserCreateRequest( + "originaluser", + "original@example.com", + "Password1!" + ); + + UserDto createdUser = userService.create(createRequest, Optional.empty()); + UUID userId = createdUser.id(); + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(createdUser, "Password1!"); + + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + MockMultipartFile profilePart = new MockMultipartFile( + "profile", + "updated-profile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "updated-image".getBytes() + ); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", userId) + .file(userUpdateRequestPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + }) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(userId.toString()))) + .andExpect(jsonPath("$.username", is("updateduser"))) + .andExpect(jsonPath("$.email", is("updated@example.com"))) + .andExpect(jsonPath("$.profile.fileName", is("updated-profile.jpg"))); + } + + @Test + @DisplayName("사용자 업데이트 실패 API 통합 테스트 - 존재하지 않는 사용자") + void updateUser_Failure_UserNotFound() throws Exception { + // Given + // 존재하지 않는 UUID를 사용하여 DiscodeitUserDetails 생성 + UUID nonExistentUserId = UUID.fromString("00000000-0000-0000-0000-000000000999"); + + // 가짜 UserDto 생성 (인증용) + UserDto fakeUser = new UserDto( + nonExistentUserId, + "fakeuser", + "fake@example.com", + null, + false, +Role.USER + ); + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(fakeUser, "Password1!"); + + UserUpdateRequest updateRequest = new UserUpdateRequest( + "updateduser", + "updated@example.com", + "UpdatedPassword1!" + ); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile( + "userUpdateRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(updateRequest) + ); + + // When & Then + mockMvc.perform(multipart("/api/users/{userId}", nonExistentUserId) + .file(userUpdateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .with(request -> { + request.setMethod("PATCH"); + return request; + }) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("사용자 삭제 API 통합 테스트") + void deleteUser_Success() throws Exception { + // Given + // 테스트 사용자 생성 - Service를 통해 초기화 + UserCreateRequest createRequest = new UserCreateRequest( + "deleteuser", + "delete@example.com", + "Password1!" + ); + + UserDto createdUser = userService.create(createRequest, Optional.empty()); + UUID userId = createdUser.id(); + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(createdUser, "Password1!"); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", userId) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isNoContent()); + + // 삭제 확인 + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == '" + userId + "')]").doesNotExist()); + } + + @Test + @DisplayName("사용자 삭제 실패 API 통합 테스트 - 존재하지 않는 사용자") + void deleteUser_Failure_UserNotFound() throws Exception { + // Given + // 존재하지 않는 UUID를 사용하여 DiscodeitUserDetails 생성 + UUID nonExistentUserId = UUID.fromString("00000000-0000-0000-0000-000000000999"); + + // 가짜 UserDto 생성 (인증용) + UserDto fakeUser = new UserDto( + nonExistentUserId, + "fakeuser", + "fake@example.com", + null, + false, +Role.USER + ); + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(fakeUser, "Password1!"); + + // When & Then + mockMvc.perform(delete("/api/users/{userId}", nonExistentUserId) + .with(csrf()) + .with(user(userDetails))) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java new file mode 100644 index 000000000..6d4563153 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java @@ -0,0 +1,96 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * ChannelRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class ChannelRepositoryTest { + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 채널 생성용 테스트 픽스처 + */ + private Channel createTestChannel(ChannelType type, String name) { + Channel channel = new Channel(type, name, "설명: " + name); + return channelRepository.save(channel); + } + + @Test + @DisplayName("타입이 PUBLIC이거나 ID 목록에 포함된 채널을 모두 조회할 수 있다") + void findAllByTypeOrIdIn_ReturnsChannels() { + // given + Channel publicChannel1 = createTestChannel(ChannelType.PUBLIC, "공개채널1"); + Channel publicChannel2 = createTestChannel(ChannelType.PUBLIC, "공개채널2"); + Channel privateChannel1 = createTestChannel(ChannelType.PRIVATE, "비공개채널1"); + Channel privateChannel2 = createTestChannel(ChannelType.PRIVATE, "비공개채널2"); + + channelRepository.saveAll( + Arrays.asList(publicChannel1, publicChannel2, privateChannel1, privateChannel2)); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List selectedPrivateIds = List.of(privateChannel1.getId()); + List foundChannels = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, + selectedPrivateIds); + + // then + assertThat(foundChannels).hasSize(3); // 공개채널 2개 + 선택된 비공개채널 1개 + + // 공개 채널 2개가 모두 포함되어 있는지 확인 + assertThat( + foundChannels.stream().filter(c -> c.getType() == ChannelType.PUBLIC).count()).isEqualTo(2); + + // 선택된 비공개 채널만 포함되어 있는지 확인 + List privateChannels = foundChannels.stream() + .filter(c -> c.getType() == ChannelType.PRIVATE) + .toList(); + assertThat(privateChannels).hasSize(1); + assertThat(privateChannels.get(0).getId()).isEqualTo(privateChannel1.getId()); + } + + @Test + @DisplayName("타입이 PUBLIC이 아니고 ID 목록이 비어있으면 비어있는 리스트를 반환한다") + void findAllByTypeOrIdIn_EmptyList_ReturnsEmptyList() { + // given + Channel privateChannel1 = createTestChannel(ChannelType.PRIVATE, "비공개채널1"); + Channel privateChannel2 = createTestChannel(ChannelType.PRIVATE, "비공개채널2"); + + channelRepository.saveAll(Arrays.asList(privateChannel1, privateChannel2)); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List foundChannels = channelRepository.findAllByTypeOrIdIn(ChannelType.PUBLIC, + List.of()); + + // then + assertThat(foundChannels).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java new file mode 100644 index 000000000..39e4fa6cf --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java @@ -0,0 +1,217 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * MessageRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class MessageRepositoryTest { + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트용 사용자 생성 + */ + private User createTestUser(String username, String email) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + return userRepository.save(user); + } + + /** + * TestFixture: 테스트용 채널 생성 + */ + private Channel createTestChannel(ChannelType type, String name) { + Channel channel = new Channel(type, name, "설명: " + name); + return channelRepository.save(channel); + } + + /** + * TestFixture: 테스트용 메시지 생성 ReflectionTestUtils를 사용하여 createdAt 필드를 직접 설정 + */ + private Message createTestMessage(String content, Channel channel, User author, + Instant createdAt) { + Message message = new Message(content, channel, author, new ArrayList<>()); + + // 생성 시간이 지정된 경우, ReflectionTestUtils로 설정 + if (createdAt != null) { + ReflectionTestUtils.setField(message, "createdAt", createdAt); + } + + Message savedMessage = messageRepository.save(message); + entityManager.flush(); + + return savedMessage; + } + + @Test + @DisplayName("채널 ID와 생성 시간으로 메시지를 페이징하여 조회할 수 있다") + void findAllByChannelIdWithAuthor_ReturnsMessagesWithAuthor() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널"); + + Instant now = Instant.now(); + Instant fiveMinutesAgo = now.minus(5, ChronoUnit.MINUTES); + Instant tenMinutesAgo = now.minus(10, ChronoUnit.MINUTES); + + // 채널에 세 개의 메시지 생성 (시간 순서대로) + Message message1 = createTestMessage("첫 번째 메시지", channel, user, tenMinutesAgo); + Message message2 = createTestMessage("두 번째 메시지", channel, user, fiveMinutesAgo); + Message message3 = createTestMessage("세 번째 메시지", channel, user, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when - 최신 메시지보다 이전 시간으로 조회 + Slice messages = messageRepository.findAllByChannelIdWithAuthor( + channel.getId(), + now.plus(1, ChronoUnit.MINUTES), // 현재 시간보다 더 미래 + PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")) + ); + + // then + assertThat(messages).isNotNull(); + assertThat(messages.hasContent()).isTrue(); + assertThat(messages.getNumberOfElements()).isEqualTo(2); // 페이지 크기 만큼만 반환 + assertThat(messages.hasNext()).isTrue(); + + // 시간 역순(최신순)으로 정렬되어 있는지 확인 + List content = messages.getContent(); + assertThat(content.get(0).getCreatedAt()).isAfterOrEqualTo(content.get(1).getCreatedAt()); + + // 저자 정보가 함께 로드되었는지 확인 (FETCH JOIN) + Message firstMessage = content.get(0); + assertThat(Hibernate.isInitialized(firstMessage.getAuthor())).isTrue(); + assertThat(Hibernate.isInitialized(firstMessage.getAuthor().getProfile())).isTrue(); + } + + @Test + @DisplayName("채널의 마지막 메시지 시간을 조회할 수 있다") + void findLastMessageAtByChannelId_ReturnsLastMessageTime() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널"); + + Instant now = Instant.now(); + Instant fiveMinutesAgo = now.minus(5, ChronoUnit.MINUTES); + Instant tenMinutesAgo = now.minus(10, ChronoUnit.MINUTES); + + // 채널에 세 개의 메시지 생성 (시간 순서대로) + createTestMessage("첫 번째 메시지", channel, user, tenMinutesAgo); + createTestMessage("두 번째 메시지", channel, user, fiveMinutesAgo); + Message lastMessage = createTestMessage("세 번째 메시지", channel, user, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional lastMessageAt = messageRepository.findLastMessageAtByChannelId( + channel.getId()); + + // then + assertThat(lastMessageAt).isPresent(); + // 마지막 메시지 시간과 일치하는지 확인 (밀리초 단위 이하의 차이는 무시) + assertThat(lastMessageAt.get().truncatedTo(ChronoUnit.MILLIS)) + .isEqualTo(lastMessage.getCreatedAt().truncatedTo(ChronoUnit.MILLIS)); + } + + @Test + @DisplayName("메시지가 없는 채널에서는 마지막 메시지 시간이 없다") + void findLastMessageAtByChannelId_NoMessages_ReturnsEmpty() { + // given + Channel emptyChannel = createTestChannel(ChannelType.PUBLIC, "빈채널"); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Optional lastMessageAt = messageRepository.findLastMessageAtByChannelId( + emptyChannel.getId()); + + // then + assertThat(lastMessageAt).isEmpty(); + } + + @Test + @DisplayName("채널의 모든 메시지를 삭제할 수 있다") + void deleteAllByChannelId_DeletesAllMessages() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "테스트채널"); + Channel otherChannel = createTestChannel(ChannelType.PUBLIC, "다른채널"); + + // 테스트 채널에 메시지 3개 생성 + createTestMessage("첫 번째 메시지", channel, user, null); + createTestMessage("두 번째 메시지", channel, user, null); + createTestMessage("세 번째 메시지", channel, user, null); + + // 다른 채널에 메시지 1개 생성 + createTestMessage("다른 채널 메시지", otherChannel, user, null); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + messageRepository.deleteAllByChannelId(channel.getId()); + entityManager.flush(); + entityManager.clear(); + + // then + // 해당 채널의 메시지는 삭제되었는지 확인 + List channelMessages = messageRepository.findAllByChannelIdWithAuthor( + channel.getId(), + Instant.now().plus(1, ChronoUnit.DAYS), + PageRequest.of(0, 100) + ).getContent(); + assertThat(channelMessages).isEmpty(); + + // 다른 채널의 메시지는 그대로인지 확인 + List otherChannelMessages = messageRepository.findAllByChannelIdWithAuthor( + otherChannel.getId(), + Instant.now().plus(1, ChronoUnit.DAYS), + PageRequest.of(0, 100) + ).getContent(); + assertThat(otherChannelMessages).hasSize(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java new file mode 100644 index 000000000..aa475562a --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/ReadStatusRepositoryTest.java @@ -0,0 +1,197 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * ReadStatusRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class ReadStatusRepositoryTest { + + @Autowired + private ReadStatusRepository readStatusRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChannelRepository channelRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트용 사용자 생성 + */ + private User createTestUser(String username, String email) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + return userRepository.save(user); + } + + /** + * TestFixture: 테스트용 채널 생성 + */ + private Channel createTestChannel(ChannelType type, String name) { + Channel channel = new Channel(type, name, "설명: " + name); + return channelRepository.save(channel); + } + + /** + * TestFixture: 테스트용 읽음 상태 생성 + */ + private ReadStatus createTestReadStatus(User user, Channel channel, Instant lastReadAt) { + ReadStatus readStatus = new ReadStatus(user, channel, lastReadAt); + return readStatusRepository.save(readStatus); + } + + @Test + @DisplayName("사용자 ID로 모든 읽음 상태를 조회할 수 있다") + void findAllByUserId_ReturnsReadStatuses() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel1 = createTestChannel(ChannelType.PUBLIC, "채널1"); + Channel channel2 = createTestChannel(ChannelType.PRIVATE, "채널2"); + + Instant now = Instant.now(); + ReadStatus readStatus1 = createTestReadStatus(user, channel1, now.minus(1, ChronoUnit.DAYS)); + ReadStatus readStatus2 = createTestReadStatus(user, channel2, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List readStatuses = readStatusRepository.findAllByUserId(user.getId()); + + // then + assertThat(readStatuses).hasSize(2); + } + + @Test + @DisplayName("채널 ID로 모든 읽음 상태를 사용자 정보와 함께 조회할 수 있다") + void findAllByChannelIdWithUser_ReturnsReadStatusesWithUser() { + // given + User user1 = createTestUser("user1", "user1@example.com"); + User user2 = createTestUser("user2", "user2@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널"); + + Instant now = Instant.now(); + ReadStatus readStatus1 = createTestReadStatus(user1, channel, now.minus(1, ChronoUnit.DAYS)); + ReadStatus readStatus2 = createTestReadStatus(user2, channel, now); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + List readStatuses = readStatusRepository.findAllByChannelIdWithUser( + channel.getId()); + + // then + assertThat(readStatuses).hasSize(2); + + // 사용자 정보가 함께 로드되었는지 확인 (FETCH JOIN) + for (ReadStatus status : readStatuses) { + assertThat(Hibernate.isInitialized(status.getUser())).isTrue(); + assertThat(Hibernate.isInitialized(status.getUser().getProfile())).isTrue(); + } + } + + @Test + @DisplayName("사용자 ID와 채널 ID로 읽음 상태 존재 여부를 확인할 수 있다") + void existsByUserIdAndChannelId_ExistingStatus_ReturnsTrue() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널"); + + ReadStatus readStatus = createTestReadStatus(user, channel, Instant.now()); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + Boolean exists = readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId()); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 읽음 상태에 대해 false를 반환한다") + void existsByUserIdAndChannelId_NonExistingStatus_ReturnsFalse() { + // given + User user = createTestUser("testUser", "test@example.com"); + Channel channel = createTestChannel(ChannelType.PUBLIC, "공개채널"); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // 읽음 상태를 생성하지 않음 + + // when + Boolean exists = readStatusRepository.existsByUserIdAndChannelId(user.getId(), channel.getId()); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("채널의 모든 읽음 상태를 삭제할 수 있다") + void deleteAllByChannelId_DeletesAllReadStatuses() { + // given + User user1 = createTestUser("user1", "user1@example.com"); + User user2 = createTestUser("user2", "user2@example.com"); + + Channel channel = createTestChannel(ChannelType.PUBLIC, "삭제할채널"); + Channel otherChannel = createTestChannel(ChannelType.PUBLIC, "유지할채널"); + + // 삭제할 채널에 읽음 상태 2개 생성 + createTestReadStatus(user1, channel, Instant.now()); + createTestReadStatus(user2, channel, Instant.now()); + + // 유지할 채널에 읽음 상태 1개 생성 + createTestReadStatus(user1, otherChannel, Instant.now()); + + // 영속성 컨텍스트 초기화 + entityManager.flush(); + entityManager.clear(); + + // when + readStatusRepository.deleteAllByChannelId(channel.getId()); + entityManager.flush(); + entityManager.clear(); + + // then + // 해당 채널의 읽음 상태는 삭제되었는지 확인 + List channelReadStatuses = readStatusRepository.findAllByChannelIdWithUser( + channel.getId()); + assertThat(channelReadStatuses).isEmpty(); + + // 다른 채널의 읽음 상태는 그대로인지 확인 + List otherChannelReadStatuses = readStatusRepository.findAllByChannelIdWithUser( + otherChannel.getId()); + assertThat(otherChannelReadStatuses).hasSize(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java new file mode 100644 index 000000000..94bc50641 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java @@ -0,0 +1,132 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import java.util.List; +import java.util.Optional; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.test.context.ActiveProfiles; + +/** + * UserRepository 슬라이스 테스트 + */ +@DataJpaTest +@EnableJpaAuditing +@ActiveProfiles("test") +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + /** + * TestFixture: 테스트에서 일관된 상태를 제공하기 위한 고정된 객체 세트 여러 테스트에서 재사용할 수 있는 테스트 데이터를 생성하는 메서드 + */ + private User createTestUser(String username, String email) { + BinaryContent profile = new BinaryContent("profile.jpg", 1024L, "image/jpeg"); + User user = new User(username, email, "password123!@#", profile); + return user; + } + + @Test + @DisplayName("사용자 이름으로 사용자를 찾을 수 있다") + void findByUsername_ExistingUsername_ReturnsUser() { + // given + String username = "testUser"; + User user = createTestUser(username, "test@example.com"); + userRepository.save(user); + + // 영속성 컨텍스트 초기화 - 1차 캐시 비우기 + entityManager.flush(); + entityManager.clear(); + + // when + Optional foundUser = userRepository.findByUsername(username); + + // then + assertThat(foundUser).isPresent(); + assertThat(foundUser.get().getUsername()).isEqualTo(username); + } + + @Test + @DisplayName("존재하지 않는 사용자 이름으로 검색하면 빈 Optional을 반환한다") + void findByUsername_NonExistingUsername_ReturnsEmptyOptional() { + // given + String nonExistingUsername = "nonExistingUser"; + + // when + Optional foundUser = userRepository.findByUsername(nonExistingUsername); + + // then + assertThat(foundUser).isEmpty(); + } + + @Test + @DisplayName("이메일로 사용자 존재 여부를 확인할 수 있다") + void existsByEmail_ExistingEmail_ReturnsTrue() { + // given + String email = "test@example.com"; + User user = createTestUser("testUser", email); + userRepository.save(user); + + // when + boolean exists = userRepository.existsByEmail(email); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 이메일로 확인하면 false를 반환한다") + void existsByEmail_NonExistingEmail_ReturnsFalse() { + // given + String nonExistingEmail = "nonexisting@example.com"; + + // when + boolean exists = userRepository.existsByEmail(nonExistingEmail); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("모든 사용자를 프로필과 함께 조회할 수 있다") + void findAllWithProfileAndStatus_ReturnsUsersWithProfileAndStatus() { + // given + User user1 = createTestUser("user1", "user1@example.com"); + User user2 = createTestUser("user2", "user2@example.com"); + + userRepository.saveAll(List.of(user1, user2)); + + // 영속성 컨텍스트 초기화 - 1차 캐시 비우기 + entityManager.flush(); + entityManager.clear(); + + // when + List users = userRepository.findAllWithProfile(); + + // then + assertThat(users).hasSize(2); + assertThat(users).extracting("username").containsExactlyInAnyOrder("user1", "user2"); + + // 프로필과 상태 정보가 함께 조회되었는지 확인 - 프록시 초기화 없이도 접근 가능한지 테스트 + User foundUser1 = users.stream().filter(u -> u.getUsername().equals("user1")).findFirst() + .orElseThrow(); + User foundUser2 = users.stream().filter(u -> u.getUsername().equals("user2")).findFirst() + .orElseThrow(); + + // 프록시 초기화 여부 확인 + assertThat(Hibernate.isInitialized(foundUser1.getProfile())).isTrue(); + assertThat(Hibernate.isInitialized(foundUser2.getProfile())).isTrue(); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/security/CsrfTest.java b/src/test/java/com/sprint/mission/discodeit/security/CsrfTest.java new file mode 100644 index 000000000..270aba61d --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/security/CsrfTest.java @@ -0,0 +1,34 @@ +package com.sprint.mission.discodeit.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +public class CsrfTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("CSRF 토큰 요청 테스트") + void getCsrfToken() throws Exception { + // When & Then - CSRF 토큰 엔드포인트가 정상적으로 호출되는지만 확인 + mockMvc.perform(get("/api/auth/csrf-token")) + .andExpect(status().isNoContent()) + .andExpect(cookie().exists("XSRF-TOKEN")) + ; + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/security/LoginTest.java b/src/test/java/com/sprint/mission/discodeit/security/LoginTest.java new file mode 100644 index 000000000..9c1befeb3 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/security/LoginTest.java @@ -0,0 +1,137 @@ +package com.sprint.mission.discodeit.security; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.util.MultiValueMap; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +public class LoginTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private PasswordEncoder passwordEncoder; + @MockitoBean + private UserDetailsService userDetailsService; + + @Test + @DisplayName("로그인 성공 테스트") + void login_Success() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "testuser", + "Password1!" + ); + + UUID userId = UUID.randomUUID(); + UserDto loggedInUser = new UserDto( + userId, + "testuser", + "test@example.com", + null, + false, + Role.USER + ); + + given(userDetailsService.loadUserByUsername(any(String.class))) + .willReturn(new DiscodeitUserDetails(loggedInUser, passwordEncoder.encode("Password1!"))); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .formFields(MultiValueMap.fromMultiValue(Map.of( + "username", List.of(loginRequest.username()), + "password", List.of(loginRequest.password()) + )))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.online").value(false)); + } + + @Test + @DisplayName("로그인 실패 테스트 - 존재하지 않는 사용자") + void login_Failure_UserNotFound() throws Exception { + // Given + + LoginRequest loginRequest = new LoginRequest( + "nonexistentuser", + "Password1!" + ); + + given(userDetailsService.loadUserByUsername(any(String.class))) + .willThrow(UserNotFoundException.withUsername(loginRequest.username())); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .formFields(MultiValueMap.fromMultiValue(Map.of( + "username", List.of(loginRequest.username()), + "password", List.of(loginRequest.password()) + )))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("로그인 실패 테스트 - 잘못된 비밀번호") + void login_Failure_InvalidCredentials() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest( + "testuser", + "WrongPassword1!" + ); + UUID userId = UUID.randomUUID(); + UserDto loggedInUser = new UserDto( + userId, + "testuser", + "test@example.com", + null, + false, + Role.USER + ); + + given(userDetailsService.loadUserByUsername(any(String.class))) + .willReturn(new DiscodeitUserDetails(loggedInUser, passwordEncoder.encode("Password1!"))); + + // When & Then + mockMvc.perform(post("/api/auth/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .formFields(MultiValueMap.fromMultiValue(Map.of( + "username", List.of(loginRequest.username()), + "password", List.of(loginRequest.password()) + )))) + .andExpect(status().isUnauthorized()); + } + +} diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java new file mode 100644 index 000000000..fba3f3d65 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentServiceTest.java @@ -0,0 +1,172 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicBinaryContentServiceTest { + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private BinaryContentMapper binaryContentMapper; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @InjectMocks + private BasicBinaryContentService binaryContentService; + + private UUID binaryContentId; + private String fileName; + private String contentType; + private byte[] bytes; + private BinaryContent binaryContent; + private BinaryContentDto binaryContentDto; + + @BeforeEach + void setUp() { + binaryContentId = UUID.randomUUID(); + fileName = "test.jpg"; + contentType = "image/jpeg"; + bytes = "test data".getBytes(); + + binaryContent = new BinaryContent(fileName, (long) bytes.length, contentType); + ReflectionTestUtils.setField(binaryContent, "id", binaryContentId); + + binaryContentDto = new BinaryContentDto( + binaryContentId, + fileName, + (long) bytes.length, + contentType + ); + } + + @Test + @DisplayName("바이너리 콘텐츠 생성 성공") + void createBinaryContent_Success() { + // given + BinaryContentCreateRequest request = new BinaryContentCreateRequest(fileName, contentType, + bytes); + + given(binaryContentRepository.save(any(BinaryContent.class))).will(invocation -> { + BinaryContent binaryContent = invocation.getArgument(0); + ReflectionTestUtils.setField(binaryContent, "id", binaryContentId); + return binaryContent; + }); + given(binaryContentMapper.toDto(any(BinaryContent.class))).willReturn(binaryContentDto); + + // when + BinaryContentDto result = binaryContentService.create(request); + + // then + assertThat(result).isEqualTo(binaryContentDto); + verify(binaryContentRepository).save(any(BinaryContent.class)); + verify(binaryContentStorage).put(binaryContentId, bytes); + } + + @Test + @DisplayName("바이너리 콘텐츠 조회 성공") + void findBinaryContent_Success() { + // given + given(binaryContentRepository.findById(eq(binaryContentId))).willReturn( + Optional.of(binaryContent)); + given(binaryContentMapper.toDto(eq(binaryContent))).willReturn(binaryContentDto); + + // when + BinaryContentDto result = binaryContentService.find(binaryContentId); + + // then + assertThat(result).isEqualTo(binaryContentDto); + } + + @Test + @DisplayName("존재하지 않는 바이너리 콘텐츠 조회 시 예외 발생") + void findBinaryContent_WithNonExistentId_ThrowsException() { + // given + given(binaryContentRepository.findById(eq(binaryContentId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> binaryContentService.find(binaryContentId)) + .isInstanceOf(BinaryContentNotFoundException.class); + } + + @Test + @DisplayName("여러 ID로 바이너리 콘텐츠 목록 조회 성공") + void findAllByIdIn_Success() { + // given + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + List ids = Arrays.asList(id1, id2); + + BinaryContent content1 = new BinaryContent("file1.jpg", 100L, "image/jpeg"); + ReflectionTestUtils.setField(content1, "id", id1); + + BinaryContent content2 = new BinaryContent("file2.jpg", 200L, "image/png"); + ReflectionTestUtils.setField(content2, "id", id2); + + List contents = Arrays.asList(content1, content2); + + BinaryContentDto dto1 = new BinaryContentDto(id1, "file1.jpg", 100L, "image/jpeg"); + BinaryContentDto dto2 = new BinaryContentDto(id2, "file2.jpg", 200L, "image/png"); + + given(binaryContentRepository.findAllById(eq(ids))).willReturn(contents); + given(binaryContentMapper.toDto(eq(content1))).willReturn(dto1); + given(binaryContentMapper.toDto(eq(content2))).willReturn(dto2); + + // when + List result = binaryContentService.findAllByIdIn(ids); + + // then + assertThat(result).containsExactly(dto1, dto2); + } + + @Test + @DisplayName("바이너리 콘텐츠 삭제 성공") + void deleteBinaryContent_Success() { + // given + given(binaryContentRepository.existsById(binaryContentId)).willReturn(true); + + // when + binaryContentService.delete(binaryContentId); + + // then + verify(binaryContentRepository).deleteById(binaryContentId); + } + + @Test + @DisplayName("존재하지 않는 바이너리 콘텐츠 삭제 시 예외 발생") + void deleteBinaryContent_WithNonExistentId_ThrowsException() { + // given + given(binaryContentRepository.existsById(eq(binaryContentId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> binaryContentService.delete(binaryContentId)) + .isInstanceOf(BinaryContentNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java new file mode 100644 index 000000000..da1dd0ca0 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java @@ -0,0 +1,228 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.ChannelDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelUpdateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicChannelServiceTest { + + @Mock + private ChannelRepository channelRepository; + + @Mock + private ReadStatusRepository readStatusRepository; + + @Mock + private MessageRepository messageRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChannelMapper channelMapper; + + @InjectMocks + private BasicChannelService channelService; + + private UUID channelId; + private UUID userId; + private String channelName; + private String channelDescription; + private Channel channel; + private ChannelDto channelDto; + private User user; + + @BeforeEach + void setUp() { + channelId = UUID.randomUUID(); + userId = UUID.randomUUID(); + channelName = "testChannel"; + channelDescription = "testDescription"; + + channel = new Channel(ChannelType.PUBLIC, channelName, channelDescription); + ReflectionTestUtils.setField(channel, "id", channelId); + channelDto = new ChannelDto(channelId, ChannelType.PUBLIC, channelName, channelDescription, + List.of(), Instant.now()); + user = new User("testUser", "test@example.com", "password", null); + } + + @Test + @DisplayName("공개 채널 생성 성공") + void createPublicChannel_Success() { + // given + PublicChannelCreateRequest request = new PublicChannelCreateRequest(channelName, + channelDescription); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.create(request); + + // then + assertThat(result).isEqualTo(channelDto); + verify(channelRepository).save(any(Channel.class)); + } + + @Test + @DisplayName("비공개 채널 생성 성공") + void createPrivateChannel_Success() { + // given + List participantIds = List.of(userId); + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(participantIds); + given(userRepository.findAllById(eq(participantIds))).willReturn(List.of(user)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.create(request); + + // then + assertThat(result).isEqualTo(channelDto); + verify(channelRepository).save(any(Channel.class)); + verify(readStatusRepository).saveAll(anyList()); + } + + @Test + @DisplayName("채널 조회 성공") + void findChannel_Success() { + // given + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.find(channelId); + + // then + assertThat(result).isEqualTo(channelDto); + } + + @Test + @DisplayName("존재하지 않는 채널 조회 시 실패") + void findChannel_WithNonExistentId_ThrowsException() { + // given + given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> channelService.find(channelId)) + .isInstanceOf(ChannelNotFoundException.class); + } + + @Test + @DisplayName("사용자별 채널 목록 조회 성공") + void findAllByUserId_Success() { + // given + List readStatuses = List.of(new ReadStatus(user, channel, Instant.now())); + given(readStatusRepository.findAllByUserId(eq(userId))).willReturn(readStatuses); + given(channelRepository.findAllByTypeOrIdIn(eq(ChannelType.PUBLIC), eq(List.of(channel.getId())))) + .willReturn(List.of(channel)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + List result = channelService.findAllByUserId(userId); + + // then + assertThat(result).containsExactly(channelDto); + } + + @Test + @DisplayName("공개 채널 수정 성공") + void updatePublicChannel_Success() { + // given + String newName = "newChannelName"; + String newDescription = "newDescription"; + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest(newName, newDescription); + + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto); + + // when + ChannelDto result = channelService.update(channelId, request); + + // then + assertThat(result).isEqualTo(channelDto); + } + + @Test + @DisplayName("비공개 채널 수정 시도 시 실패") + void updatePrivateChannel_ThrowsException() { + // given + Channel privateChannel = new Channel(ChannelType.PRIVATE, null, null); + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName", + "newDescription"); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(privateChannel)); + + // when & then + assertThatThrownBy(() -> channelService.update(channelId, request)) + .isInstanceOf(PrivateChannelUpdateException.class); + } + + @Test + @DisplayName("존재하지 않는 채널 수정 시도 시 실패") + void updateChannel_WithNonExistentId_ThrowsException() { + // given + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName", + "newDescription"); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> channelService.update(channelId, request)) + .isInstanceOf(ChannelNotFoundException.class); + } + + @Test + @DisplayName("채널 삭제 성공") + void deleteChannel_Success() { + // given + given(channelRepository.existsById(eq(channelId))).willReturn(true); + + // when + channelService.delete(channelId); + + // then + verify(messageRepository).deleteAllByChannelId(eq(channelId)); + verify(readStatusRepository).deleteAllByChannelId(eq(channelId)); + verify(channelRepository).deleteById(eq(channelId)); + } + + @Test + @DisplayName("존재하지 않는 채널 삭제 시도 시 실패") + void deleteChannel_WithNonExistentId_ThrowsException() { + // given + given(channelRepository.existsById(eq(channelId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> channelService.delete(channelId)) + .isInstanceOf(ChannelNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java new file mode 100644 index 000000000..08e25cb60 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java @@ -0,0 +1,370 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.MessageDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.PageResponseMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.SliceImpl; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicMessageServiceTest { + + @Mock + private MessageRepository messageRepository; + + @Mock + private ChannelRepository channelRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private MessageMapper messageMapper; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private PageResponseMapper pageResponseMapper; + + @InjectMocks + private BasicMessageService messageService; + + private UUID messageId; + private UUID channelId; + private UUID authorId; + private String content; + private Message message; + private MessageDto messageDto; + private Channel channel; + private User author; + private BinaryContent attachment; + private BinaryContentDto attachmentDto; + + @BeforeEach + void setUp() { + messageId = UUID.randomUUID(); + channelId = UUID.randomUUID(); + authorId = UUID.randomUUID(); + content = "test message"; + + channel = new Channel(ChannelType.PUBLIC, "testChannel", "testDescription"); + ReflectionTestUtils.setField(channel, "id", channelId); + + author = new User("testUser", "test@example.com", "password", null); + ReflectionTestUtils.setField(author, "id", authorId); + + attachment = new BinaryContent("test.txt", 100L, "text/plain"); + ReflectionTestUtils.setField(attachment, "id", UUID.randomUUID()); + attachmentDto = new BinaryContentDto(attachment.getId(), "test.txt", 100L, "text/plain"); + + message = new Message(content, channel, author, List.of(attachment)); + ReflectionTestUtils.setField(message, "id", messageId); + + messageDto = new MessageDto( + messageId, + Instant.now(), + Instant.now(), + content, + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true, Role.USER), + List.of(attachmentDto) + ); + } + + @Test + @DisplayName("메시지 생성 성공") + void createMessage_Success() { + // given + MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId); + BinaryContentCreateRequest attachmentRequest = new BinaryContentCreateRequest("test.txt", + "text/plain", new byte[100]); + List attachmentRequests = List.of(attachmentRequest); + + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(userRepository.findById(eq(authorId))).willReturn(Optional.of(author)); + given(binaryContentRepository.save(any(BinaryContent.class))).will(invocation -> { + BinaryContent binaryContent = invocation.getArgument(0); + ReflectionTestUtils.setField(binaryContent, "id", attachment.getId()); + return attachment; + }); + given(messageRepository.save(any(Message.class))).willReturn(message); + given(messageMapper.toDto(any(Message.class))).willReturn(messageDto); + + // when + MessageDto result = messageService.create(request, attachmentRequests); + + // then + assertThat(result).isEqualTo(messageDto); + verify(messageRepository).save(any(Message.class)); + verify(binaryContentStorage).put(eq(attachment.getId()), any(byte[].class)); + } + + @Test + @DisplayName("존재하지 않는 채널에 메시지 생성 시도 시 실패") + void createMessage_WithNonExistentChannel_ThrowsException() { + // given + MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.create(request, List.of())) + .isInstanceOf(ChannelNotFoundException.class); + } + + @Test + @DisplayName("존재하지 않는 작성자로 메시지 생성 시도 시 실패") + void createMessage_WithNonExistentAuthor_ThrowsException() { + // given + MessageCreateRequest request = new MessageCreateRequest(content, channelId, authorId); + given(channelRepository.findById(eq(channelId))).willReturn(Optional.of(channel)); + given(userRepository.findById(eq(authorId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.create(request, List.of())) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("메시지 조회 성공") + void findMessage_Success() { + // given + given(messageRepository.findById(eq(messageId))).willReturn(Optional.of(message)); + given(messageMapper.toDto(eq(message))).willReturn(messageDto); + + // when + MessageDto result = messageService.find(messageId); + + // then + assertThat(result).isEqualTo(messageDto); + } + + @Test + @DisplayName("존재하지 않는 메시지 조회 시 실패") + void findMessage_WithNonExistentId_ThrowsException() { + // given + given(messageRepository.findById(eq(messageId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.find(messageId)) + .isInstanceOf(MessageNotFoundException.class); + } + + @Test + @DisplayName("채널별 메시지 목록 조회 성공") + void findAllByChannelId_Success() { + // given + int pageSize = 2; // 페이지 크기를 2로 설정 + Instant createdAt = Instant.now(); + Pageable pageable = PageRequest.of(0, pageSize); + + // 여러 메시지 생성 (페이지 사이즈보다 많게) + Message message1 = new Message(content + "1", channel, author, List.of(attachment)); + Message message2 = new Message(content + "2", channel, author, List.of(attachment)); + Message message3 = new Message(content + "3", channel, author, List.of(attachment)); + + ReflectionTestUtils.setField(message1, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(message2, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(message3, "id", UUID.randomUUID()); + + // 각 메시지에 해당하는 DTO 생성 + Instant message1CreatedAt = Instant.now().minusSeconds(30); + Instant message2CreatedAt = Instant.now().minusSeconds(20); + Instant message3CreatedAt = Instant.now().minusSeconds(10); + + ReflectionTestUtils.setField(message1, "createdAt", message1CreatedAt); + ReflectionTestUtils.setField(message2, "createdAt", message2CreatedAt); + ReflectionTestUtils.setField(message3, "createdAt", message3CreatedAt); + + MessageDto messageDto1 = new MessageDto( + message1.getId(), + message1CreatedAt, + message1CreatedAt, + content + "1", + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true, Role.USER), + List.of(attachmentDto) + ); + + MessageDto messageDto2 = new MessageDto( + message2.getId(), + message2CreatedAt, + message2CreatedAt, + content + "2", + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true, Role.USER), + List.of(attachmentDto) + ); + + // 첫 페이지 결과 세팅 (2개 메시지) + List firstPageMessages = List.of(message1, message2); + List firstPageDtos = List.of(messageDto1, messageDto2); + + // 첫 페이지는 다음 페이지가 있고, 커서는 message2의 생성 시간이어야 함 + SliceImpl firstPageSlice = new SliceImpl<>(firstPageMessages, pageable, true); + PageResponse firstPageResponse = new PageResponse<>( + firstPageDtos, + message2CreatedAt, + pageSize, + true, + null + ); + + // 모의 객체 설정 + given( + messageRepository.findAllByChannelIdWithAuthor(eq(channelId), eq(createdAt), eq(pageable))) + .willReturn(firstPageSlice); + given(messageMapper.toDto(eq(message1))).willReturn(messageDto1); + given(messageMapper.toDto(eq(message2))).willReturn(messageDto2); + given(pageResponseMapper.fromSlice(any(), eq(message2CreatedAt))) + .willReturn(firstPageResponse); + + // when + PageResponse result = messageService.findAllByChannelId(channelId, createdAt, + pageable); + + // then + assertThat(result).isEqualTo(firstPageResponse); + assertThat(result.content()).hasSize(pageSize); + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isEqualTo(message2CreatedAt); + + // 두 번째 페이지 테스트 + // given + List secondPageMessages = List.of(message3); + MessageDto messageDto3 = new MessageDto( + message3.getId(), + message3CreatedAt, + message3CreatedAt, + content + "3", + channelId, + new UserDto(authorId, "testUser", "test@example.com", null, true, Role.USER), + List.of(attachmentDto) + ); + List secondPageDtos = List.of(messageDto3); + + // 두 번째 페이지는 다음 페이지가 없음 + SliceImpl secondPageSlice = new SliceImpl<>(secondPageMessages, pageable, false); + PageResponse secondPageResponse = new PageResponse<>( + secondPageDtos, + message3CreatedAt, + pageSize, + false, + null + ); + + // 두 번째 페이지 모의 객체 설정 + given(messageRepository.findAllByChannelIdWithAuthor(eq(channelId), eq(message2CreatedAt), + eq(pageable))) + .willReturn(secondPageSlice); + given(messageMapper.toDto(eq(message3))).willReturn(messageDto3); + given(pageResponseMapper.fromSlice(any(), eq(message3CreatedAt))) + .willReturn(secondPageResponse); + + // when - 두 번째 페이지 요청 (첫 페이지의 커서 사용) + PageResponse secondResult = messageService.findAllByChannelId(channelId, + message2CreatedAt, + pageable); + + // then - 두 번째 페이지 검증 + assertThat(secondResult).isEqualTo(secondPageResponse); + assertThat(secondResult.content()).hasSize(1); // 마지막 페이지는 항목 1개만 있음 + assertThat(secondResult.hasNext()).isFalse(); // 더 이상 다음 페이지 없음 + } + + @Test + @DisplayName("메시지 수정 성공") + void updateMessage_Success() { + // given + String newContent = "updated content"; + MessageUpdateRequest request = new MessageUpdateRequest(newContent); + + given(messageRepository.findById(eq(messageId))).willReturn(Optional.of(message)); + given(messageMapper.toDto(eq(message))).willReturn(messageDto); + + // when + MessageDto result = messageService.update(messageId, request); + + // then + assertThat(result).isEqualTo(messageDto); + } + + @Test + @DisplayName("존재하지 않는 메시지 수정 시도 시 실패") + void updateMessage_WithNonExistentId_ThrowsException() { + // given + MessageUpdateRequest request = new MessageUpdateRequest("new content"); + given(messageRepository.findById(eq(messageId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageService.update(messageId, request)) + .isInstanceOf(MessageNotFoundException.class); + } + + @Test + @DisplayName("메시지 삭제 성공") + void deleteMessage_Success() { + // given + given(messageRepository.existsById(eq(messageId))).willReturn(true); + + // when + messageService.delete(messageId); + + // then + verify(messageRepository).deleteById(eq(messageId)); + } + + @Test + @DisplayName("존재하지 않는 메시지 삭제 시도 시 실패") + void deleteMessage_WithNonExistentId_ThrowsException() { + // given + given(messageRepository.existsById(eq(messageId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> messageService.delete(messageId)) + .isInstanceOf(MessageNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java new file mode 100644 index 000000000..1dd89c512 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java @@ -0,0 +1,188 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BasicUserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private BasicUserService userService; + + private UUID userId; + private String username; + private String email; + private String password; + private User user; + private UserDto userDto; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + username = "testUser"; + email = "test@example.com"; + password = "password123"; + + user = new User(username, email, password, null); + ReflectionTestUtils.setField(user, "id", userId); + userDto = new UserDto(userId, username, email, null, true, Role.USER); + } + + @Test + @DisplayName("사용자 생성 성공") + void createUser_Success() { + // given + UserCreateRequest request = new UserCreateRequest(username, email, password); + given(userRepository.existsByEmail(eq(email))).willReturn(false); + given(userRepository.existsByUsername(eq(username))).willReturn(false); + given(userMapper.toDto(any(User.class))).willReturn(userDto); + + // when + UserDto result = userService.create(request, Optional.empty()); + + // then + assertThat(result).isEqualTo(userDto); + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("이미 존재하는 이메일로 사용자 생성 시도 시 실패") + void createUser_WithExistingEmail_ThrowsException() { + // given + UserCreateRequest request = new UserCreateRequest(username, email, password); + given(userRepository.existsByEmail(eq(email))).willReturn(true); + + // when & then + assertThatThrownBy(() -> userService.create(request, Optional.empty())) + .isInstanceOf(UserAlreadyExistsException.class); + } + + @Test + @DisplayName("이미 존재하는 사용자명으로 사용자 생성 시도 시 실패") + void createUser_WithExistingUsername_ThrowsException() { + // given + UserCreateRequest request = new UserCreateRequest(username, email, password); + given(userRepository.existsByEmail(eq(email))).willReturn(false); + given(userRepository.existsByUsername(eq(username))).willReturn(true); + + // when & then + assertThatThrownBy(() -> userService.create(request, Optional.empty())) + .isInstanceOf(UserAlreadyExistsException.class); + } + + @Test + @DisplayName("사용자 조회 성공") + void findUser_Success() { + // given + given(userRepository.findById(eq(userId))).willReturn(Optional.of(user)); + given(userMapper.toDto(any(User.class))).willReturn(userDto); + + // when + UserDto result = userService.find(userId); + + // then + assertThat(result).isEqualTo(userDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 조회 시 실패") + void findUser_WithNonExistentId_ThrowsException() { + // given + given(userRepository.findById(eq(userId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userService.find(userId)) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("사용자 수정 성공") + void updateUser_Success() { + // given + String newUsername = "newUsername"; + String newEmail = "new@example.com"; + String newPassword = "newPassword"; + UserUpdateRequest request = new UserUpdateRequest(newUsername, newEmail, newPassword); + + given(userRepository.findById(eq(userId))).willReturn(Optional.of(user)); + given(userRepository.existsByEmail(eq(newEmail))).willReturn(false); + given(userRepository.existsByUsername(eq(newUsername))).willReturn(false); + given(userMapper.toDto(any(User.class))).willReturn(userDto); + + // when + UserDto result = userService.update(userId, request, Optional.empty()); + + // then + assertThat(result).isEqualTo(userDto); + } + + @Test + @DisplayName("존재하지 않는 사용자 수정 시도 시 실패") + void updateUser_WithNonExistentId_ThrowsException() { + // given + UserUpdateRequest request = new UserUpdateRequest("newUsername", "new@example.com", + "newPassword"); + given(userRepository.findById(eq(userId))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userService.update(userId, request, Optional.empty())) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("사용자 삭제 성공") + void deleteUser_Success() { + // given + given(userRepository.existsById(eq(userId))).willReturn(true); + + // when + userService.delete(userId); + + // then + verify(userRepository).deleteById(eq(userId)); + } + + @Test + @DisplayName("존재하지 않는 사용자 삭제 시도 시 실패") + void deleteUser_WithNonExistentId_ThrowsException() { + // given + given(userRepository.existsById(eq(userId))).willReturn(false); + + // when & then + assertThatThrownBy(() -> userService.delete(userId)) + .isInstanceOf(UserNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java new file mode 100644 index 000000000..9f016686a --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java @@ -0,0 +1,174 @@ +package com.sprint.mission.discodeit.storage.s3; + +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URL; +import java.time.Duration; +import java.util.Properties; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +@Disabled +@Slf4j +@DisplayName("S3 API 테스트") +public class AWSS3Test { + + private static String accessKey; + private static String secretKey; + private static String region; + private static String bucket; + private S3Client s3Client; + private S3Presigner presigner; + private String testKey; + + @BeforeAll + static void loadEnv() throws IOException { + Properties props = new Properties(); + try (FileInputStream fis = new FileInputStream(".env")) { + props.load(fis); + } + + accessKey = props.getProperty("AWS_S3_ACCESS_KEY"); + secretKey = props.getProperty("AWS_S3_SECRET_KEY"); + region = props.getProperty("AWS_S3_REGION"); + bucket = props.getProperty("AWS_S3_BUCKET"); + + if (accessKey == null || secretKey == null || region == null || bucket == null) { + throw new IllegalStateException("AWS S3 설정이 .env 파일에 올바르게 정의되지 않았습니다."); + } + } + + @BeforeEach + void setUp() { + s3Client = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + + presigner = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + + testKey = "test-" + UUID.randomUUID().toString(); + } + + @Test + @DisplayName("S3에 파일을 업로드한다") + void uploadToS3() { + String content = "Hello from .env via Properties!"; + + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .contentType("text/plain") + .build(); + + s3Client.putObject(request, RequestBody.fromString(content)); + log.info("파일 업로드 성공: {}", testKey); + } catch (S3Exception e) { + log.error("파일 업로드 실패: {}", e.getMessage()); + throw e; + } + } + + @Test + @DisplayName("S3에서 파일을 다운로드한다") + void downloadFromS3() { + // 테스트를 위한 파일 먼저 업로드 + String content = "Test content for download"; + PutObjectRequest uploadRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .contentType("text/plain") + .build(); + s3Client.putObject(uploadRequest, RequestBody.fromString(content)); + + try { + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .build(); + + String downloadedContent = s3Client.getObjectAsBytes(request).asUtf8String(); + log.info("다운로드된 파일 내용: {}", downloadedContent); + } catch (S3Exception e) { + log.error("파일 다운로드 실패: {}", e.getMessage()); + throw e; + } + } + + @Test + @DisplayName("S3 파일에 대한 Presigned URL을 생성한다") + void generatePresignedUrl() { + // 테스트를 위한 파일 먼저 업로드 + String content = "Test content for presigned URL"; + PutObjectRequest uploadRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .contentType("text/plain") + .build(); + s3Client.putObject(uploadRequest, RequestBody.fromString(content)); + + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest); + URL url = presignedRequest.url(); + + log.info("생성된 Presigned URL: {}", url); + } catch (S3Exception e) { + log.error("Presigned URL 생성 실패: {}", e.getMessage()); + throw e; + } + } + + @AfterEach + void cleanup() { + try { + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucket) + .key(testKey) + .build(); + s3Client.deleteObject(request); + log.info("테스트 파일 정리 완료: {}", testKey); + } catch (S3Exception e) { + log.error("테스트 파일 정리 실패: {}", e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java new file mode 100644 index 000000000..b758d402f --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java @@ -0,0 +1,147 @@ +package com.sprint.mission.discodeit.storage.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import java.io.IOException; +import java.io.InputStream; +import java.util.NoSuchElementException; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + +@Disabled +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("S3BinaryContentStorage 테스트") +class S3BinaryContentStorageTest { + + @Autowired + private S3BinaryContentStorage s3BinaryContentStorage; + + @Value("${discodeit.storage.s3.bucket}") + private String bucket; + + @Value("${discodeit.storage.s3.access-key}") + private String accessKey; + + @Value("${discodeit.storage.s3.secret-key}") + private String secretKey; + + @Value("${discodeit.storage.s3.region}") + private String region; + + private final UUID testId = UUID.randomUUID(); + private final byte[] testData = "테스트 데이터".getBytes(); + + @BeforeEach + void setUp() { + // 테스트 준비 작업 + // 실제 S3BinaryContentStorage는 스프링이 의존성 주입으로 제공 + } + + @AfterEach + void tearDown() { + // 테스트 종료 후 생성된 S3 객체 삭제 + try { + // S3 클라이언트 생성 + S3Client s3Client = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + + // 테스트에서 생성한 객체 삭제 + DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() + .bucket(bucket) + .key(testId.toString()) + .build(); + + s3Client.deleteObject(deleteRequest); + System.out.println("테스트 객체 삭제 완료: " + testId); + } catch (NoSuchKeyException e) { + // 객체가 이미 없는 경우는 무시 + System.out.println("삭제할 객체가 없음: " + testId); + } catch (Exception e) { + // 정리 실패 시 로그만 남기고 테스트는 실패로 처리하지 않음 + System.err.println("테스트 객체 정리 실패: " + e.getMessage()); + } + } + + @Test + @DisplayName("S3에 파일 업로드 성공 테스트") + void put_success() { + // when + UUID resultId = s3BinaryContentStorage.put(testId, testData); + + // then + assertThat(resultId).isEqualTo(testId); + } + + @Test + @DisplayName("S3에서 파일 다운로드 테스트") + void get_success() throws IOException { + // given + s3BinaryContentStorage.put(testId, testData); + + // when + InputStream result = s3BinaryContentStorage.get(testId); + + // then + assertNotNull(result); + + // 내용 검증 + byte[] resultBytes = result.readAllBytes(); + assertThat(resultBytes).isEqualTo(testData); + } + + @Test + @DisplayName("존재하지 않는 파일 조회 시 예외 발생 테스트") + void get_notFound() { + // when & then + assertThatThrownBy(() -> s3BinaryContentStorage.get(UUID.randomUUID())) + .isInstanceOf(NoSuchElementException.class); + } + + @Test + @DisplayName("Presigned URL 생성 테스트") + void download_success() { + // given + s3BinaryContentStorage.put(testId, testData); + BinaryContentDto dto = new BinaryContentDto( + testId, "test.txt", (long) testData.length, "text/plain" + ); + + // when + ResponseEntity response = s3BinaryContentStorage.download(dto); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND); + assertThat(response.getHeaders().get(HttpHeaders.LOCATION)).isNotNull(); + + String location = response.getHeaders().getFirst(HttpHeaders.LOCATION); + assertThat(location).contains(bucket); + assertThat(location).contains(testId.toString()); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 000000000..741eb8625 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,23 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + sql: + init: + mode: never + +logging: + level: + com.sprint.mission.discodeit: debug + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + org.springframework.security: trace \ No newline at end of file From dd014703bad75e74dfe50e3e33313cd11c7df3e9 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Tue, 21 Oct 2025 22:17:55 +0900 Subject: [PATCH 26/28] =?UTF-8?q?jwt=20=ED=86=A0=ED=81=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 + .../discodeit/config/SecurityConfig.java | 30 ++-- .../discodeit/controller/AuthController.java | 110 ++++++++++++- .../handler/JwtLoginSuccessHandler.java | 95 +++++++++++ .../jwt/JwtAuthenticationFilter.java | 91 +++++++++++ .../discodeit/jwt/JwtTokenProvider.java | 149 ++++++++++++++++++ .../discodeit/jwt/RefreshTokenService.java | 22 +++ .../mission/discodeit/jwt/dto/JwtDto.java | 13 ++ .../discodeit/repository/UserRepository.java | 2 + src/main/resources/application-dev.yaml | 7 +- src/main/resources/application.yaml | 6 +- 11 files changed, 512 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/sprint/mission/discodeit/handler/JwtLoginSuccessHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/sprint/mission/discodeit/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/sprint/mission/discodeit/jwt/RefreshTokenService.java create mode 100644 src/main/java/com/sprint/mission/discodeit/jwt/dto/JwtDto.java diff --git a/build.gradle b/build.gradle index da32f000a..51bd4649f 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'software.amazon.awssdk:s3:2.31.7' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'com.nimbusds:nimbus-jose-jwt:10.3' runtimeOnly 'org.postgresql:postgresql' @@ -49,6 +50,10 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' } tasks.named('test') { diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java index 807a8fbd4..d21a8c308 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -2,9 +2,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.handler.JwtLoginSuccessHandler; +import com.sprint.mission.discodeit.jwt.JwtAuthenticationFilter; import com.sprint.mission.discodeit.security.Http403ForbiddenAccessDeniedHandler; import com.sprint.mission.discodeit.security.LoginFailureHandler; -import com.sprint.mission.discodeit.security.LoginSuccessHandler; import com.sprint.mission.discodeit.security.SpaCsrfTokenRequestHandler; import java.util.List; import java.util.stream.IntStream; @@ -22,12 +23,14 @@ import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.session.HttpSessionEventPublisher; @@ -43,12 +46,13 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain( HttpSecurity http, - LoginSuccessHandler loginSuccessHandler, + JwtLoginSuccessHandler jwtloginSuccessHandler, LoginFailureHandler loginFailureHandler, + JwtAuthenticationFilter jwtAuthenticationFilter, // ✅ 여기서 메서드 인자로 받기! ObjectMapper objectMapper, SessionRegistry sessionRegistry - ) - throws Exception { + ) throws Exception { + http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) @@ -56,9 +60,10 @@ public SecurityFilterChain filterChain( ) .formLogin(login -> login .loginProcessingUrl("/api/auth/login") - .successHandler(loginSuccessHandler) + .successHandler(jwtloginSuccessHandler) .failureHandler(loginFailureHandler) ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // ✅ 정상 주입 .logout(logout -> logout .logoutUrl("/api/auth/logout") .logoutSuccessHandler( @@ -70,6 +75,7 @@ public SecurityFilterChain filterChain( AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/users"), AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/auth/login"), AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/auth/logout"), + AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/auth/refresh"), new NegatedRequestMatcher(AntPathRequestMatcher.antMatcher("/api/**")) ).permitAll() .anyRequest().authenticated() @@ -79,13 +85,10 @@ public SecurityFilterChain filterChain( .accessDeniedHandler(new Http403ForbiddenAccessDeniedHandler(objectMapper)) ) .sessionManagement(session -> session - .sessionConcurrency(concurrency -> concurrency - .maximumSessions(1) - .sessionRegistry(sessionRegistry) - ) + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - .rememberMe(Customizer.withDefaults()) - ; + .rememberMe(Customizer.withDefaults()); + return http.build(); } @@ -111,16 +114,13 @@ public RoleHierarchy roleHierarchy() { return RoleHierarchyImpl.withDefaultRolePrefix() .role(Role.ADMIN.name()) .implies(Role.USER.name(), Role.CHANNEL_MANAGER.name()) - .role(Role.CHANNEL_MANAGER.name()) .implies(Role.USER.name()) - .build(); } @Bean - static MethodSecurityExpressionHandler methodSecurityExpressionHandler( - RoleHierarchy roleHierarchy) { + static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) { DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); handler.setRoleHierarchy(roleHierarchy); return handler; diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java index a398afa58..824f654e7 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -1,19 +1,39 @@ package com.sprint.mission.discodeit.controller; +import com.nimbusds.jose.JOSEException; import com.sprint.mission.discodeit.controller.api.AuthApi; import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import com.sprint.mission.discodeit.jwt.JwtTokenProvider; +import com.sprint.mission.discodeit.jwt.RefreshTokenService; +import com.sprint.mission.discodeit.jwt.dto.JwtDto; +import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.security.DiscodeitUserDetails; import com.sprint.mission.discodeit.service.AuthService; import com.sprint.mission.discodeit.service.UserService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.text.ParseException; +import java.util.Date; +import java.util.Map; +import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,6 +47,10 @@ public class AuthController implements AuthApi { private final AuthService authService; private final UserService userService; + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final UserDetailsService userDetailsService; + private final RefreshTokenService refreshTokenService; @GetMapping("csrf-token") public ResponseEntity getCsrfToken(CsrfToken csrfToken) { @@ -56,4 +80,88 @@ public ResponseEntity updateRole(@RequestBody RoleUpdateRequest request .status(HttpStatus.OK) .body(userDto); } -} + @PostMapping("/refresh") + public ResponseEntity refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { + + try { + // 1️⃣ 쿠키에서 Refresh Token 추출 + String refreshToken = extractRefreshTokenFromCookies(request); + if (refreshToken == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Refresh token not found")); + } + + // 2️⃣ Refresh Token 유효성 검사 + if (!jwtTokenProvider.validateToken(refreshToken)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid or expired refresh token")); + } + + String email = jwtTokenProvider.getSubject(refreshToken); + + Optional optionalUser = userRepository.findByEmail(email); + if (optionalUser.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "User not found")); + } + + User user = optionalUser.get(); + + Date newAccessTokenExp = jwtTokenProvider.getTokenExpiration(jwtTokenProvider.getAccessTokenExpirationMinutes()); + Map claims = Map.of( + "roles", user.getRole(), + "email", user.getEmail() + ); + + String newAccessToken = jwtTokenProvider.generateAccessToken( + claims, + user.getEmail(), + newAccessTokenExp + ); + + Date newRefreshTokenExp = jwtTokenProvider.getTokenExpiration(jwtTokenProvider.getRefreshTokenExpirationMinutes()); + String newRefreshToken = jwtTokenProvider.generateRefreshToken( + user.getEmail(), + newRefreshTokenExp + ); + + Cookie refreshCookie = new Cookie("REFRESH_TOKEN", newRefreshToken); + refreshCookie.setHttpOnly(true); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge((int) jwtTokenProvider.getRefreshTokenValidity()); + response.addCookie(refreshCookie); + + UserDto userDto = new UserDto( + user.getId(), + user.getUsername(), + user.getEmail(), + null, + true, // 현재 로그인 중으로 간주 + user.getRole() + ); + + JwtDto jwtDto = new JwtDto(userDto, newAccessToken); + + return ResponseEntity.ok(jwtDto); + + } catch (JOSEException e) { + log.error("JWT parsing/verification error", e); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid refresh token")); + } catch (Exception e) { + log.error("Unexpected error during token refresh", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Token refresh failed")); + } + } + + private String extractRefreshTokenFromCookies(HttpServletRequest request) { + if (request.getCookies() == null) return null; + for (Cookie cookie : request.getCookies()) { + if ("REFRESH_TOKEN".equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + } diff --git a/src/main/java/com/sprint/mission/discodeit/handler/JwtLoginSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/handler/JwtLoginSuccessHandler.java new file mode 100644 index 000000000..115420f93 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/handler/JwtLoginSuccessHandler.java @@ -0,0 +1,95 @@ +package com.sprint.mission.discodeit.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.jwt.JwtTokenProvider; +import com.sprint.mission.discodeit.jwt.dto.JwtDto; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtLoginSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + // 인증된 사용자 정보 가져오기 + User user = (User) authentication.getPrincipal(); + + // Access 토큰, RefreshToken 생성 + Date accessTokenExp = jwtTokenProvider.getTokenExpiration(jwtTokenProvider.getAccessTokenExpirationMinutes()); + Date refreshTokenExp = jwtTokenProvider.getTokenExpiration(jwtTokenProvider.getRefreshTokenExpirationMinutes()); + + Map claims = new HashMap<>(); + claims.put("email", user.getEmail()); + claims.put("roles", user.getRole()); + + try { + String accessToken = jwtTokenProvider.generateAccessToken( + claims, user.getEmail(), accessTokenExp); + + String refreshToken = jwtTokenProvider.generateRefreshToken( + user.getEmail(), refreshTokenExp); + + // Refresh Token 쿠키에 저장 + Cookie refreshCookie = new Cookie("REFRESH_TOKEN", refreshToken); + refreshCookie.setHttpOnly(true); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(jwtTokenProvider.getRefreshTokenExpirationMinutes() * 60); + response.addCookie(refreshCookie); + + // BinaryContentDto 매핑 + BinaryContentDto profileDto = null; + if (user.getProfile() != null) { + profileDto = new BinaryContentDto( + user.getProfile().getId(), + user.getProfile().getFileName(), + user.getProfile().getSize(), + user.getProfile().getContentType() + ); + } + + // UserDto 생성 + UserDto userDto = new UserDto( + user.getId(), // User 엔티티에서 가져오기 + user.getUsername(), + user.getEmail(), + profileDto, + true, // Boolean 필드 getter + user.getRole() + ); + + // Access Token 응답 Body로 반환 + JwtDto jwtDto = new JwtDto(userDto, refreshToken); + + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + objectMapper.writeValue(response.getWriter(), jwtDto); + + } catch (Exception e) { + throw new RuntimeException("JWT 생성 중 오류 발생", e); + } + } + + +} diff --git a/src/main/java/com/sprint/mission/discodeit/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sprint/mission/discodeit/jwt/JwtAuthenticationFilter.java new file mode 100644 index 000000000..6dbf5cc17 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,91 @@ +package com.sprint.mission.discodeit.jwt; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.security.DiscodeitUserDetailsService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final UserMapper userMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + // Authorization 헤더 추출 + String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + // 토큰 추출 + String token = authHeader.substring(7); + + // 토큰 유효성 검사 + if (!jwtTokenProvider.validateToken(token)) { + log.debug("Invalid JWT token"); + filterChain.doFilter(request, response); + return; + } + + // 토큰에서 이메일(또는 username) 추출 + String email = jwtTokenProvider.getEmailFromToken(token); + + // 5. SecurityContext에 인증 정보 설정 + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + + userRepository.findByEmail(email).ifPresent(user -> { + // 🔹 엔티티 → DTO 변환 + UserDto userDto = userMapper.toDto(user); + + // 🔹 UserDetails 구현체 생성 + DiscodeitUserDetails userDetails = new DiscodeitUserDetails(userDto, user.getPassword()); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("JWT 인증 완료: {}", email); + }); + } + } catch (Exception e){ + + log.error("JWT 인증 필터 처리 중 오류", e); + } + + // 다음 필터로 진행 + filterChain.doFilter(request, response); + } + +} + diff --git a/src/main/java/com/sprint/mission/discodeit/jwt/JwtTokenProvider.java b/src/main/java/com/sprint/mission/discodeit/jwt/JwtTokenProvider.java new file mode 100644 index 000000000..f047f8cb4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/jwt/JwtTokenProvider.java @@ -0,0 +1,149 @@ +package com.sprint.mission.discodeit.jwt; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import io.jsonwebtoken.Jwts; +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.util.Calendar; +import java.util.Date; +import java.util.Map; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class JwtTokenProvider { + + @Getter + @Value("${jwt.key}") + private String secretKey; + + @Getter + @Value("${jwt.access-token-expiration-minutes}") + private int accessTokenExpirationMinutes; + + @Getter + @Value("${jwt.refresh-token-expiration-minutes}") + private int refreshTokenExpirationMinutes; + +// // Base64 인코딩 +// public String encodedBase64SecretKey(String secretKey) { +// return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8)); +// } + + //Access Token 생성 + public String generateAccessToken(Map claims, + String subject, + Date expiration) throws JOSEException { + + JWSSigner signer = new MACSigner(secretKey.getBytes(StandardCharsets.UTF_8)); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(subject) + .expirationTime(expiration) + .issueTime(new Date()) + .claim("roles", claims.get("roles")) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJWT.sign(signer); + return signedJWT.serialize(); + } + + //RefreshToken 생성 + public String generateRefreshToken(String subject, Date expiration) + throws JOSEException { + + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(subject) + .expirationTime(expiration) + .issueTime(new Date()) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); + signedJWT.sign(new MACSigner(secretKey.getBytes(StandardCharsets.UTF_8))); + return signedJWT.serialize(); + } + + //Claims 추출 + public JWTClaimsSet getClaims(String jws) + throws ParseException, JOSEException { + + SignedJWT signedJWT = SignedJWT.parse(jws); + signedJWT.verify(new MACVerifier(secretKey.getBytes(StandardCharsets.UTF_8))); + + return signedJWT.getJWTClaimsSet(); + + } + + //시그니처 검증 + public void verifySignature(String jws) + throws ParseException, JOSEException { + + SignedJWT signedJWT = SignedJWT.parse(jws); + if (!signedJWT.verify(new MACVerifier(secretKey.getBytes(StandardCharsets.UTF_8)))) throw new SecurityException("서명 검증 실패"); + + } + + //만료 시간 계산 + public Date getTokenExpiration(int expirationMinutes) { + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, expirationMinutes); + Date expiration = calendar.getTime(); + + return expiration; + } + + public boolean validateToken(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + boolean isVerified = signedJWT.verify(new MACVerifier(secretKey.getBytes(StandardCharsets.UTF_8))); + Date expiration = signedJWT.getJWTClaimsSet().getExpirationTime(); + return isVerified && expiration.after(new Date()); + } catch (Exception e) { + log.error("Invalid JWT token", e); + return false; + } + } + + public String getEmailFromToken(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + return signedJWT.getJWTClaimsSet().getSubject(); + } catch (Exception e) { + return null; + } + } + + public String getUsername(String token) { + return Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + public long getRefreshTokenValidity() { + return refreshTokenExpirationMinutes * 60L; + } + + // subject(email or username) 추출 + public String getSubject(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + return signedJWT.getJWTClaimsSet().getSubject(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/jwt/RefreshTokenService.java b/src/main/java/com/sprint/mission/discodeit/jwt/RefreshTokenService.java new file mode 100644 index 000000000..8e61c349c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/jwt/RefreshTokenService.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.jwt; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final Map refreshTokens = new ConcurrentHashMap<>(); + + public void rotateRefreshToken(String username, String newToken){ + refreshTokens.put(username, newToken); + } + + public boolean isValid(String username, String token){ + return token.equals(refreshTokens.get(username)); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/jwt/dto/JwtDto.java b/src/main/java/com/sprint/mission/discodeit/jwt/dto/JwtDto.java new file mode 100644 index 000000000..610e07193 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/jwt/dto/JwtDto.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.jwt.dto; + +import com.sprint.mission.discodeit.dto.data.UserDto; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class JwtDto { + public UserDto userDto; + public String accessToken; + +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java index 4fdd8f3b6..e34fb1169 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java @@ -18,4 +18,6 @@ public interface UserRepository extends JpaRepository { @Query("SELECT u FROM User u " + "LEFT JOIN FETCH u.profile") List findAllWithProfile(); + + Optional findByEmail(String email); } diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 7b1addb62..c64fcb431 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -24,4 +24,9 @@ management: show-details: always info: env: - enabled: true \ No newline at end of file + enabled: true +discodeit: + admin: + username: admin + email: admin@admin.com + password: 1234 \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1a5d18f65..400a60a14 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -64,4 +64,8 @@ discodeit: logging: level: - root: info \ No newline at end of file + root: info +jwt: + key: 87aslkdjaosdjasdjo879nfiownofwef987wefmklsndfoery978sdflhsdfower978 + access-token-expiration-minutes: 30 + refresh-token-expiration-minutes: 420 \ No newline at end of file From 7ad802c513a969c135e4b11ed8a8ad08d4d3b61d Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Tue, 28 Oct 2025 16:38:14 +0900 Subject: [PATCH 27/28] =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 13 +- .../mission/discodeit/config/AppConfig.java | 2 + .../mission/discodeit/config/AsyncConfig.java | 55 + .../mission/discodeit/config/CacheConfig.java | 32 + .../mission/discodeit/config/RetryConfig.java | 10 + .../discodeit/config/SecurityConfig.java | 47 +- .../discodeit/config/SwaggerConfig.java | 2 +- .../discodeit/controller/AuthController.java | 133 +- .../controller/MessageController.java | 2 + .../controller/NotificationController.java | 40 + .../discodeit/controller/api/AuthApi.java | 22 +- .../mission/discodeit/dto/data/JwtDto.java | 7 + .../discodeit/dto/data/JwtInformation.java | 18 + .../discodeit/dto/data/NotificationDto.java | 23 + .../dto/request/ReadStatusUpdateRequest.java | 3 +- .../discodeit/entity/BinaryContent.java | 10 + .../discodeit/entity/BinaryContentStatus.java | 7 + .../discodeit/entity/Notification.java | 33 + .../mission/discodeit/entity/ReadStatus.java | 3 + .../event/BinaryContentCreatedEvent.java | 19 + .../BinaryContentCreatedEventListener.java | 36 + .../discodeit/event/MessageCreatedEvent.java | 22 + .../event/MessageCreatedEventListener.java | 32 + .../NotificationRequiredEventListener.java | 66 + .../discodeit/event/RoleUpdatedEvent.java | 22 + .../event/RoleUpdatedEventListener.java | 31 + .../discodeit/exception/ErrorCode.java | 7 +- .../exception/GlobalExceptionHandler.java | 2 +- .../NotificationNotFoundException.java | 14 + .../mission/discodeit/mapper/UserMapper.java | 6 +- .../repository/NotificationRepository.java | 10 + .../repository/ReadStatusRepository.java | 2 + .../discodeit/repository/UserRepository.java | 1 - .../security/jwt/InMemoryJwtRegistry.java | 129 ++ .../security/jwt/JwtAuthenticationFilter.java | 94 + .../security/jwt/JwtLoginSuccessHandler.java | 84 + .../security/jwt/JwtLogoutHandler.java | 41 + .../discodeit/security/jwt/JwtRegistry.java | 21 + .../security/jwt/JwtTokenProvider.java | 185 ++ .../discodeit/service/AuthService.java | 3 + .../service/basic/BasicAuthService.java | 66 +- .../basic/BasicBinaryContentService.java | 25 +- .../service/basic/BasicChannelService.java | 4 + .../service/basic/BasicMessageService.java | 13 +- .../basic/BasicNotificationService.java | 83 + .../service/basic/BasicReadStatusService.java | 1 + .../service/basic/BasicUserService.java | 13 +- .../local/LocalBinaryContentStorage.java | 6 + .../storage/s3/S3BinaryContentStorage.java | 39 +- src/main/resources/application-dev.yaml | 7 +- src/main/resources/application.yaml | 35 +- src/main/resources/schema.sql | 13 +- .../resources/static/assets/index-DB4IjbRs.js | 1572 +++++++++++++++++ src/main/resources/static/index.html | 2 +- .../controller/AuthControllerTest.java | 29 - .../BinaryContentControllerTest.java | 7 +- .../controller/ChannelControllerTest.java | 7 +- .../controller/MessageControllerTest.java | 7 +- .../controller/ReadStatusControllerTest.java | 7 +- .../controller/UserControllerTest.java | 7 +- .../integration/AuthApiIntegrationTest.java | 7 +- .../mission/discodeit/security/LoginTest.java | 9 +- .../jwt/JwtAuthenticationFilterTest.java | 153 ++ .../jwt/JwtLoginSuccessHandlerTest.java | 130 ++ .../security/jwt/JwtTokenProviderTest.java | 208 +++ src/test/resources/application-test.yaml | 9 + 66 files changed, 3512 insertions(+), 236 deletions(-) create mode 100644 src/main/java/com/sprint/mission/discodeit/config/AsyncConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/CacheConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/config/RetryConfig.java create mode 100644 src/main/java/com/sprint/mission/discodeit/controller/NotificationController.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/JwtDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/JwtInformation.java create mode 100644 src/main/java/com/sprint/mission/discodeit/dto/data/NotificationDto.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/BinaryContentStatus.java create mode 100644 src/main/java/com/sprint/mission/discodeit/entity/Notification.java create mode 100644 src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEvent.java create mode 100644 src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEventListener.java create mode 100644 src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEvent.java create mode 100644 src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEventListener.java create mode 100644 src/main/java/com/sprint/mission/discodeit/event/NotificationRequiredEventListener.java create mode 100644 src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEvent.java create mode 100644 src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEventListener.java create mode 100644 src/main/java/com/sprint/mission/discodeit/exception/notification/NotificationNotFoundException.java create mode 100644 src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/jwt/InMemoryJwtRegistry.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLogoutHandler.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/jwt/JwtRegistry.java create mode 100644 src/main/java/com/sprint/mission/discodeit/security/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/sprint/mission/discodeit/service/basic/BasicNotificationService.java create mode 100644 src/main/resources/static/assets/index-DB4IjbRs.js create mode 100644 src/test/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilterTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandlerTest.java create mode 100644 src/test/java/com/sprint/mission/discodeit/security/jwt/JwtTokenProviderTest.java diff --git a/build.gradle b/build.gradle index 51bd4649f..9babc4038 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'com.sprint.mission' -version = '2.1-M10' +version = '2.2-M11' java { toolchain { @@ -36,7 +36,7 @@ dependencies { implementation 'software.amazon.awssdk:s3:2.31.7' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'com.nimbusds:nimbus-jose-jwt:10.3' - + runtimeOnly 'org.postgresql:postgresql' compileOnly 'org.projectlombok:lombok' @@ -50,10 +50,11 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' - - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine' + implementation 'org.springframework.boot:spring-boot-starter-actuator' } tasks.named('test') { diff --git a/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java index 96010621f..cff706f3b 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java @@ -2,9 +2,11 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @Configuration @EnableJpaAuditing +@EnableScheduling public class AppConfig { } \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/AsyncConfig.java b/src/main/java/com/sprint/mission/discodeit/config/AsyncConfig.java new file mode 100644 index 000000000..7fa768f77 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/AsyncConfig.java @@ -0,0 +1,55 @@ +package com.sprint.mission.discodeit.config; + +import java.util.Map; +import java.util.concurrent.Executor; +import org.slf4j.MDC; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskDecorator; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("Async"); + executor.setTaskDecorator(mdcAndSecurityContextDecorator()); + executor.initialize(); + return executor; + } + + private TaskDecorator mdcAndSecurityContextDecorator() { + SecurityContextHolderStrategy strategy = SecurityContextHolder.getContextHolderStrategy(); + + return runnable -> { + Map contextMap = MDC.getCopyOfContextMap(); + SecurityContext context = strategy.getContext(); + + return () -> { + try { + if (contextMap != null) { + MDC.setContextMap(contextMap); + } + if (context != null) { + strategy.setContext(context); + } + runnable.run(); + } finally { + MDC.clear(); + strategy.clearContext(); + } + }; + }; + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/CacheConfig.java b/src/main/java/com/sprint/mission/discodeit/config/CacheConfig.java new file mode 100644 index 000000000..a03f4036d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/CacheConfig.java @@ -0,0 +1,32 @@ +package com.sprint.mission.discodeit.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.concurrent.TimeUnit; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public Caffeine caffeineConfig() { + return Caffeine.newBuilder() + .expireAfterWrite(5, TimeUnit.MINUTES) + .maximumSize(500) + .recordStats(); + } + + @Bean + public CacheManager cacheManager(Caffeine caffeine) { + CaffeineCacheManager cacheManager = new CaffeineCacheManager( + "userChannels", "userNotifications", "users" // 캐시 이름들 + ); + cacheManager.setCaffeine(caffeine); + return cacheManager; + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/RetryConfig.java b/src/main/java/com/sprint/mission/discodeit/config/RetryConfig.java new file mode 100644 index 000000000..bde153ba6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/RetryConfig.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@Configuration +@EnableRetry +public class RetryConfig { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java index d21a8c308..aa03ba51a 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -2,11 +2,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sprint.mission.discodeit.entity.Role; -import com.sprint.mission.discodeit.handler.JwtLoginSuccessHandler; -import com.sprint.mission.discodeit.jwt.JwtAuthenticationFilter; import com.sprint.mission.discodeit.security.Http403ForbiddenAccessDeniedHandler; import com.sprint.mission.discodeit.security.LoginFailureHandler; import com.sprint.mission.discodeit.security.SpaCsrfTokenRequestHandler; +import com.sprint.mission.discodeit.security.jwt.InMemoryJwtRegistry; +import com.sprint.mission.discodeit.security.jwt.JwtAuthenticationFilter; +import com.sprint.mission.discodeit.security.jwt.JwtLoginSuccessHandler; +import com.sprint.mission.discodeit.security.jwt.JwtLogoutHandler; +import com.sprint.mission.discodeit.security.jwt.JwtRegistry; +import com.sprint.mission.discodeit.security.jwt.JwtTokenProvider; import java.util.List; import java.util.stream.IntStream; import lombok.extern.slf4j.Slf4j; @@ -19,13 +23,10 @@ import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.session.SessionRegistry; -import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @@ -33,7 +34,6 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.NegatedRequestMatcher; @@ -46,13 +46,13 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain( HttpSecurity http, - JwtLoginSuccessHandler jwtloginSuccessHandler, + JwtLoginSuccessHandler jwtLoginSuccessHandler, LoginFailureHandler loginFailureHandler, - JwtAuthenticationFilter jwtAuthenticationFilter, // ✅ 여기서 메서드 인자로 받기! ObjectMapper objectMapper, - SessionRegistry sessionRegistry - ) throws Exception { - + JwtAuthenticationFilter jwtAuthenticationFilter, + JwtLogoutHandler jwtLogoutHandler + ) + throws Exception { http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) @@ -60,12 +60,12 @@ public SecurityFilterChain filterChain( ) .formLogin(login -> login .loginProcessingUrl("/api/auth/login") - .successHandler(jwtloginSuccessHandler) + .successHandler(jwtLoginSuccessHandler) .failureHandler(loginFailureHandler) ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // ✅ 정상 주입 .logout(logout -> logout .logoutUrl("/api/auth/logout") + .addLogoutHandler(jwtLogoutHandler) .logoutSuccessHandler( new HttpStatusReturningLogoutSuccessHandler(HttpStatus.NO_CONTENT)) ) @@ -74,8 +74,8 @@ public SecurityFilterChain filterChain( AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/api/auth/csrf-token"), AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/users"), AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/auth/login"), - AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/auth/logout"), AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/auth/refresh"), + AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/api/auth/logout"), new NegatedRequestMatcher(AntPathRequestMatcher.antMatcher("/api/**")) ).permitAll() .anyRequest().authenticated() @@ -87,8 +87,9 @@ public SecurityFilterChain filterChain( .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - .rememberMe(Customizer.withDefaults()); - + // Add JWT authentication filter + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + ; return http.build(); } @@ -114,25 +115,23 @@ public RoleHierarchy roleHierarchy() { return RoleHierarchyImpl.withDefaultRolePrefix() .role(Role.ADMIN.name()) .implies(Role.USER.name(), Role.CHANNEL_MANAGER.name()) + .role(Role.CHANNEL_MANAGER.name()) .implies(Role.USER.name()) + .build(); } @Bean - static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) { + static MethodSecurityExpressionHandler methodSecurityExpressionHandler( + RoleHierarchy roleHierarchy) { DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); handler.setRoleHierarchy(roleHierarchy); return handler; } @Bean - public SessionRegistry sessionRegistry() { - return new SessionRegistryImpl(); - } - - @Bean - public HttpSessionEventPublisher httpSessionEventPublisher() { - return new HttpSessionEventPublisher(); + public JwtRegistry jwtRegistry(JwtTokenProvider jwtTokenProvider) { + return new InMemoryJwtRegistry(1, jwtTokenProvider); } } \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java index f8142c0dc..d384d850f 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java @@ -16,7 +16,7 @@ public OpenAPI customOpenAPI() { .info(new Info() .title("Discodeit API 문서") .description("Discodeit 프로젝트의 Swagger API 문서입니다.") - .version("2.0") + .version("2.2") ) .servers(List.of( new Server().url("http://localhost:8080").description("로컬 서버") diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java index 824f654e7..5fa19d58d 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -1,37 +1,21 @@ package com.sprint.mission.discodeit.controller; -import com.nimbusds.jose.JOSEException; import com.sprint.mission.discodeit.controller.api.AuthApi; +import com.sprint.mission.discodeit.dto.data.JwtDto; +import com.sprint.mission.discodeit.dto.data.JwtInformation; import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.exception.ErrorResponse; -import com.sprint.mission.discodeit.jwt.JwtTokenProvider; -import com.sprint.mission.discodeit.jwt.RefreshTokenService; -import com.sprint.mission.discodeit.jwt.dto.JwtDto; -import com.sprint.mission.discodeit.repository.UserRepository; -import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.security.jwt.JwtTokenProvider; import com.sprint.mission.discodeit.service.AuthService; import com.sprint.mission.discodeit.service.UserService; import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.text.ParseException; -import java.util.Date; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -47,10 +31,7 @@ public class AuthController implements AuthApi { private final AuthService authService; private final UserService userService; - private final UserRepository userRepository; private final JwtTokenProvider jwtTokenProvider; - private final UserDetailsService userDetailsService; - private final RefreshTokenService refreshTokenService; @GetMapping("csrf-token") public ResponseEntity getCsrfToken(CsrfToken csrfToken) { @@ -61,14 +42,22 @@ public ResponseEntity getCsrfToken(CsrfToken csrfToken) { .build(); } - @GetMapping("me") - public ResponseEntity me(@AuthenticationPrincipal DiscodeitUserDetails userDetails) { - log.info("내 정보 조회 요청"); - UUID userId = userDetails.getUserDto().id(); - UserDto userDto = userService.find(userId); + @PostMapping("refresh") + public ResponseEntity refresh(@CookieValue("REFRESH_TOKEN") String refreshToken, + HttpServletResponse response) { + log.info("토큰 리프레시 요청"); + JwtInformation jwtInformation = authService.refreshToken(refreshToken); + Cookie refreshCookie = jwtTokenProvider.genereateRefreshTokenCookie( + jwtInformation.getRefreshToken()); + response.addCookie(refreshCookie); + + JwtDto body = new JwtDto( + jwtInformation.getUserDto(), + jwtInformation.getAccessToken() + ); return ResponseEntity .status(HttpStatus.OK) - .body(userDto); + .body(body); } @PutMapping("role") @@ -80,88 +69,4 @@ public ResponseEntity updateRole(@RequestBody RoleUpdateRequest request .status(HttpStatus.OK) .body(userDto); } - @PostMapping("/refresh") - public ResponseEntity refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { - - try { - // 1️⃣ 쿠키에서 Refresh Token 추출 - String refreshToken = extractRefreshTokenFromCookies(request); - if (refreshToken == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Refresh token not found")); - } - - // 2️⃣ Refresh Token 유효성 검사 - if (!jwtTokenProvider.validateToken(refreshToken)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Invalid or expired refresh token")); - } - - String email = jwtTokenProvider.getSubject(refreshToken); - - Optional optionalUser = userRepository.findByEmail(email); - if (optionalUser.isEmpty()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "User not found")); - } - - User user = optionalUser.get(); - - Date newAccessTokenExp = jwtTokenProvider.getTokenExpiration(jwtTokenProvider.getAccessTokenExpirationMinutes()); - Map claims = Map.of( - "roles", user.getRole(), - "email", user.getEmail() - ); - - String newAccessToken = jwtTokenProvider.generateAccessToken( - claims, - user.getEmail(), - newAccessTokenExp - ); - - Date newRefreshTokenExp = jwtTokenProvider.getTokenExpiration(jwtTokenProvider.getRefreshTokenExpirationMinutes()); - String newRefreshToken = jwtTokenProvider.generateRefreshToken( - user.getEmail(), - newRefreshTokenExp - ); - - Cookie refreshCookie = new Cookie("REFRESH_TOKEN", newRefreshToken); - refreshCookie.setHttpOnly(true); - refreshCookie.setPath("/"); - refreshCookie.setMaxAge((int) jwtTokenProvider.getRefreshTokenValidity()); - response.addCookie(refreshCookie); - - UserDto userDto = new UserDto( - user.getId(), - user.getUsername(), - user.getEmail(), - null, - true, // 현재 로그인 중으로 간주 - user.getRole() - ); - - JwtDto jwtDto = new JwtDto(userDto, newAccessToken); - - return ResponseEntity.ok(jwtDto); - - } catch (JOSEException e) { - log.error("JWT parsing/verification error", e); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "Invalid refresh token")); - } catch (Exception e) { - log.error("Unexpected error during token refresh", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Token refresh failed")); - } - } - - private String extractRefreshTokenFromCookies(HttpServletRequest request) { - if (request.getCookies() == null) return null; - for (Cookie cookie : request.getCookies()) { - if ("REFRESH_TOKEN".equals(cookie.getName())) { - return cookie.getValue(); - } - } - return null; - } - } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java index 5f7777d02..ceb40ddcf 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java @@ -7,6 +7,7 @@ import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; import com.sprint.mission.discodeit.dto.response.PageResponse; import com.sprint.mission.discodeit.service.MessageService; +import io.micrometer.core.annotation.Timed; import jakarta.validation.Valid; import java.io.IOException; import java.time.Instant; @@ -42,6 +43,7 @@ public class MessageController implements MessageApi { private final MessageService messageService; + @Timed("message.create.async") @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity create( @RequestPart("messageCreateRequest") @Valid MessageCreateRequest messageCreateRequest, diff --git a/src/main/java/com/sprint/mission/discodeit/controller/NotificationController.java b/src/main/java/com/sprint/mission/discodeit/controller/NotificationController.java new file mode 100644 index 000000000..cc6d2bd20 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/NotificationController.java @@ -0,0 +1,40 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.dto.data.NotificationDto; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.service.basic.BasicNotificationService; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final BasicNotificationService notificationService; + + @GetMapping + public ResponseEntity> getNotifications( + @AuthenticationPrincipal DiscodeitUserDetails userDetails + ) { + List notifications = notificationService.getNotifications(userDetails); + return ResponseEntity.ok(notifications); + } + + @DeleteMapping("/{notificationId}") + public ResponseEntity deleteNotification( + @PathVariable UUID notificationId, + @AuthenticationPrincipal DiscodeitUserDetails userDetails + ) { + notificationService.deleteNotification(notificationId, userDetails); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java index d2a7a3ef0..9aa1e737c 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java @@ -1,8 +1,8 @@ package com.sprint.mission.discodeit.controller.api; +import com.sprint.mission.discodeit.dto.data.JwtDto; import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; -import com.sprint.mission.discodeit.security.DiscodeitUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.ResponseEntity; import org.springframework.security.web.csrf.CsrfToken; @@ -25,12 +26,6 @@ ResponseEntity getCsrfToken( @Parameter(hidden = true) CsrfToken csrfToken ); - @Operation(summary = "세션 정보를 활용한 현재 사용자 정보 조회") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = UserDto.class))), - @ApiResponse(responseCode = "401", description = "올바르지 않은 세션") - }) - ResponseEntity me(@Parameter(hidden = true) DiscodeitUserDetails userDetails); @Operation(summary = "사용자 권한 수정") @ApiResponses(value = { @@ -41,4 +36,17 @@ ResponseEntity getCsrfToken( }) ResponseEntity updateRole( @Parameter(description = "권한 수정 요청 정보") RoleUpdateRequest request); + + @Operation(summary = "토큰 리프레시") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "토큰 리프레시 성공", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰") + }) + ResponseEntity refresh( + @Parameter(description = "리프레시 토큰") String refreshToken, + @Parameter(hidden = true) HttpServletResponse response + ); } \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/JwtDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/JwtDto.java new file mode 100644 index 000000000..eb64d7f80 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/JwtDto.java @@ -0,0 +1,7 @@ +package com.sprint.mission.discodeit.dto.data; + +public record JwtDto( + UserDto userDto, + String accessToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/JwtInformation.java b/src/main/java/com/sprint/mission/discodeit/dto/data/JwtInformation.java new file mode 100644 index 000000000..18039aca3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/JwtInformation.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.dto.data; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class JwtInformation { + + private UserDto userDto; + private String accessToken; + private String refreshToken; + + public void rotate(String newAccessToken, String newRefreshToken) { + this.accessToken = newAccessToken; + this.refreshToken = newRefreshToken; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/dto/data/NotificationDto.java b/src/main/java/com/sprint/mission/discodeit/dto/data/NotificationDto.java new file mode 100644 index 000000000..3728021d6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/data/NotificationDto.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.dto.data; + +import com.sprint.mission.discodeit.entity.Notification; +import java.time.Instant; +import java.util.UUID; + +public record NotificationDto( + UUID id, + Instant createdAt, + UUID receiverId, + String title, + String content +) { + public static NotificationDto from(Notification notification) { + return new NotificationDto( + notification.getId(), + notification.getCreatedAt(), + notification.getReceiverId(), + notification.getTitle(), + notification.getContent() + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java index de197a07f..104490465 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/ReadStatusUpdateRequest.java @@ -7,7 +7,8 @@ public record ReadStatusUpdateRequest( @NotNull(message = "새로운 마지막 읽은 시간은 필수입니다") @PastOrPresent(message = "마지막 읽은 시간은 현재 또는 과거 시간이어야 합니다") - Instant newLastReadAt + Instant newLastReadAt, + boolean newNotificationEnabled ) { } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java index 88a096848..c54b6e931 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java @@ -4,6 +4,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import java.util.UUID; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -21,9 +22,18 @@ public class BinaryContent extends BaseEntity { @Column(length = 100, nullable = false) private String contentType; + // 파일 저장 성공 여부 추가 + private BinaryContentStatus status; + public BinaryContent(String fileName, Long size, String contentType) { this.fileName = fileName; this.size = size; this.contentType = contentType; + this.status = BinaryContentStatus.PROCESSING; + } + + //저장 상태 변경용 메서드 + public void updateStatus(BinaryContentStatus status) { + this.status = status; } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContentStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContentStatus.java new file mode 100644 index 000000000..e3f4823d6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContentStatus.java @@ -0,0 +1,7 @@ +package com.sprint.mission.discodeit.entity; + +public enum BinaryContentStatus { + PROCESSING, + SUCCESS, + FAIL +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Notification.java b/src/main/java/com/sprint/mission/discodeit/entity/Notification.java new file mode 100644 index 000000000..159242148 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Notification.java @@ -0,0 +1,33 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.PrePersist; +import java.time.Instant; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends BaseEntity { + + @Column(nullable = false) + private UUID receiverId; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + public Notification(UUID receiverId, String title, String content) { + this.receiverId = receiverId; + this.title = title; + this.content = content; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java index d51448b96..beace2325 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java @@ -33,10 +33,13 @@ public class ReadStatus extends BaseUpdatableEntity { @Column(columnDefinition = "timestamp with time zone", nullable = false) private Instant lastReadAt; + private boolean notificationEnabled; + public ReadStatus(User user, Channel channel, Instant lastReadAt) { this.user = user; this.channel = channel; this.lastReadAt = lastReadAt; + this.notificationEnabled = (channel.getType() == ChannelType.PRIVATE); } public void update(Instant newLastReadAt) { diff --git a/src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEvent.java b/src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEvent.java new file mode 100644 index 000000000..24b67bafc --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEvent.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class BinaryContentCreatedEvent extends ApplicationEvent { + private final BinaryContent binaryContent; + private final byte[] data; + + public BinaryContentCreatedEvent(Object source, BinaryContent binaryContent, byte[] data) { + super(source); + this.binaryContent = binaryContent; + this.data = data; + } + + +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEventListener.java b/src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEventListener.java new file mode 100644 index 000000000..621d1334e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEventListener.java @@ -0,0 +1,36 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.BinaryContentStatus; +import com.sprint.mission.discodeit.service.basic.BasicBinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BinaryContentCreatedEventListener { + + private final BinaryContentStorage binaryContentStorage; + private final BasicBinaryContentService basicBinaryContentService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(BinaryContentCreatedEvent event) { + BinaryContent content = event.getBinaryContent(); + + try{ + binaryContentStorage.put(event.getBinaryContent().getId(), event.getData()); + log.info("Binary content stored: {}", event.getBinaryContent().getId()); + + basicBinaryContentService.updateStatus(content.getId(), BinaryContentStatus.SUCCESS); + }catch(Exception e){ + log.error("Failed to store binary content {}", event.getBinaryContent().getId(), e); + basicBinaryContentService.updateStatus(content.getId(), BinaryContentStatus.FAIL); + } + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEvent.java b/src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEvent.java new file mode 100644 index 000000000..b15b6f330 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEvent.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.entity.Message; +import java.time.Clock; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class MessageCreatedEvent extends ApplicationEvent { + + public final Message message; + + public MessageCreatedEvent(Object source, Message message) { + super(source); + this.message = message; + } + + public MessageCreatedEvent(Object source, Clock clock, Message message) { + super(source, clock); + this.message = message; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEventListener.java b/src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEventListener.java new file mode 100644 index 000000000..b4e508cf0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEventListener.java @@ -0,0 +1,32 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MessageCreatedEventListener { + + private final ReadStatusRepository readStatusRepository; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleMessageCreatedEvent(MessageCreatedEvent event){ + Message message = event.getMessage(); + + List subscribers = readStatusRepository.findAllByChannelIdAndNotificationEnabledTrue(message.getChannel().getId()); + + for (ReadStatus subscriber : subscribers) { + log.info("[{}] 채널에 새 메세지 알림 : 사용자 = {}, 메세지ID = {}", message.getChannel().getId(), subscriber.getUser().getId(), message.getId()); + } + + + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/NotificationRequiredEventListener.java b/src/main/java/com/sprint/mission/discodeit/event/NotificationRequiredEventListener.java new file mode 100644 index 000000000..8a9895aa4 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/NotificationRequiredEventListener.java @@ -0,0 +1,66 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.service.basic.BasicNotificationService; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationRequiredEventListener { + + private final BasicNotificationService notificationService; + private final ReadStatusRepository readStatusRepository; + + /** + * 메시지 생성 시 알림 생성 + */ + @TransactionalEventListener + @Async("taskExecutor") + public void on(MessageCreatedEvent event) { + Message message = event.getMessage(); + + // 보낸 사람은 제외 + UUID senderId = message.getAuthor().getId(); + UUID channelId = message.getChannel().getId(); + + // 해당 채널의 알림 활성화된 사용자 조회 + List activeReadStatuses = + readStatusRepository.findAllByChannelIdAndNotificationEnabledTrue(channelId); + + String title = String.format("%s (#%s)", message.getAuthor().getUsername(), message.getChannel().getName()); + String content = message.getContent(); + + activeReadStatuses.stream() + .map(ReadStatus::getUser) + .filter(user -> !user.getId().equals(senderId)) // 보낸 사람 제외 + .forEach(user -> + notificationService.createNotification(user.getId(), title, content) + ); + + log.info("MessageCreatedEvent -> {} users notified", activeReadStatuses.size()); + } + + /** + * 권한 변경 시 알림 생성 + */ + @TransactionalEventListener + @Async("taskExecutor") + public void on(RoleUpdatedEvent event) { + String title = "권한이 변경되었습니다."; + String content = event.getOldRole().name() + " -> " + event.getNewRole().name(); + + notificationService.createNotification(event.getUserId(), title, content); + + log.info("RoleUpdatedEvent -> notification sent to user {}", event.getUserId()); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEvent.java b/src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEvent.java new file mode 100644 index 000000000..07facbed3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEvent.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.entity.Role; +import java.time.Clock; +import java.util.UUID; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class RoleUpdatedEvent extends ApplicationEvent { + private final UUID userId; + private final Role oldRole; + private final Role newRole; + + public RoleUpdatedEvent(Object source, UUID userId, Role oldRole, Role newRole) { + super(source); + this.userId = userId; + this.oldRole = oldRole; + this.newRole = newRole; + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEventListener.java b/src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEventListener.java new file mode 100644 index 000000000..70a34abc1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEventListener.java @@ -0,0 +1,31 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.service.basic.BasicNotificationService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@Slf4j +public class RoleUpdatedEventListener { + + private final BasicNotificationService basicNotificationService; + + public RoleUpdatedEventListener(BasicNotificationService basicNotificationService) { + this.basicNotificationService = basicNotificationService; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleRoleUpdatedEvent(RoleUpdatedEvent event) { + log.info("User [{}] role changed: {} → {}", + event.getUserId(), + event.getOldRole(), + event.getNewRole() + ); + + basicNotificationService.sendRoleUpdatedNotification(event.getUserId(), event.getNewRole().name()); + + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java index 1ae84323e..b7e420f1b 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java @@ -25,7 +25,12 @@ public enum ErrorCode { // Server 에러 코드 INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다."), - INVALID_REQUEST("잘못된 요청입니다."); + INVALID_REQUEST("잘못된 요청입니다."), + + // Security 관련 에러 코드 + INVALID_TOKEN("토큰이 유효하지 않습니다."), + INVALID_USER_DETAILS("사용자 인증 정보(UserDetails)가 유효하지 않습니다."), + ; private final String message; diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java index 5f4c24de8..b704cd862 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -87,7 +87,7 @@ private HttpStatus determineHttpStatus(DiscodeitException exception) { case USER_NOT_FOUND, CHANNEL_NOT_FOUND, MESSAGE_NOT_FOUND, BINARY_CONTENT_NOT_FOUND, READ_STATUS_NOT_FOUND -> HttpStatus.NOT_FOUND; case DUPLICATE_USER, DUPLICATE_READ_STATUS -> HttpStatus.CONFLICT; - case INVALID_USER_CREDENTIALS -> HttpStatus.UNAUTHORIZED; + case INVALID_USER_CREDENTIALS, INVALID_TOKEN, INVALID_USER_DETAILS -> HttpStatus.UNAUTHORIZED; case PRIVATE_CHANNEL_UPDATE, INVALID_REQUEST -> HttpStatus.BAD_REQUEST; case INTERNAL_SERVER_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; }; diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notification/NotificationNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/notification/NotificationNotFoundException.java new file mode 100644 index 000000000..e1010c34b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/notification/NotificationNotFoundException.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.exception.notification; + +import java.util.UUID; + +public class NotificationNotFoundException extends RuntimeException{ + private NotificationNotFoundException(String message) { + super(message); + } + + public static NotificationNotFoundException withId(UUID id) { + return new NotificationNotFoundException("Notification not found with id: " + id); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java index bd49e63aa..f42ddc96c 100644 --- a/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java @@ -2,7 +2,7 @@ import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.security.SessionManager; +import com.sprint.mission.discodeit.security.jwt.JwtRegistry; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.springframework.beans.factory.annotation.Autowired; @@ -11,8 +11,8 @@ public abstract class UserMapper { @Autowired - protected SessionManager sessionManager; + protected JwtRegistry jwtRegistry; - @Mapping(target = "online", expression = "java(sessionManager.hasActiveSessions(user.getId()))") + @Mapping(target = "online", expression = "java(jwtRegistry.hasActiveJwtInformationByUserId(user.getId()))") public abstract UserDto toDto(User user); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java new file mode 100644 index 000000000..3916077c8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Notification; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository { + List findAllByReceiverIdOrderByCreatedAtDesc(UUID receiverId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java index ae2a6491d..6369c5a6a 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java @@ -21,4 +21,6 @@ public interface ReadStatusRepository extends JpaRepository { Boolean existsByUserIdAndChannelId(UUID userId, UUID channelId); void deleteAllByChannelId(UUID channelId); + + List findAllByChannelIdAndNotificationEnabledTrue(UUID id); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java index e34fb1169..95b8e5dd7 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java @@ -19,5 +19,4 @@ public interface UserRepository extends JpaRepository { + "LEFT JOIN FETCH u.profile") List findAllWithProfile(); - Optional findByEmail(String email); } diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/InMemoryJwtRegistry.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/InMemoryJwtRegistry.java new file mode 100644 index 000000000..52caf8265 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/InMemoryJwtRegistry.java @@ -0,0 +1,129 @@ +package com.sprint.mission.discodeit.security.jwt; + +import com.sprint.mission.discodeit.dto.data.JwtInformation; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; + + +@RequiredArgsConstructor +public class InMemoryJwtRegistry implements JwtRegistry { + + // > + private final Map> origin = new ConcurrentHashMap<>(); + private final Set accessTokenIndexes = ConcurrentHashMap.newKeySet(); + private final Set refreshTokenIndexes = ConcurrentHashMap.newKeySet(); + + private final int maxActiveJwtCount; + private final JwtTokenProvider jwtTokenProvider; + + @Override + public void registerJwtInformation(JwtInformation jwtInformation) { + origin.compute(jwtInformation.getUserDto().id(), (key, queue) -> { + if (queue == null) { + queue = new ConcurrentLinkedQueue<>(); + } + // If the queue exceeds the max size, remove the oldest token + if (queue.size() >= maxActiveJwtCount) { + JwtInformation deprecatedJwtInformation = queue.poll();// Remove the oldest token + if (deprecatedJwtInformation != null) { + removeTokenIndex( + deprecatedJwtInformation.getAccessToken(), + deprecatedJwtInformation.getRefreshToken() + ); + } + } + queue.add(jwtInformation); // Add the new token + addTokenIndex( + jwtInformation.getAccessToken(), + jwtInformation.getRefreshToken() + ); + return queue; + }); + } + + @Override + public void invalidateJwtInformationByUserId(UUID userId) { + origin.computeIfPresent(userId, (key, queue) -> { + queue.forEach(jwtInformation -> { + removeTokenIndex( + jwtInformation.getAccessToken(), + jwtInformation.getRefreshToken() + ); + }); + queue.clear(); // Clear the queue for this user + return null; // Remove the user from the registry + }); + } + + @Override + public boolean hasActiveJwtInformationByUserId(UUID userId) { + return origin.containsKey(userId); + } + + @Override + public boolean hasActiveJwtInformationByAccessToken(String accessToken) { + return accessTokenIndexes.contains(accessToken); + } + + @Override + public boolean hasActiveJwtInformationByRefreshToken(String refreshToken) { + return refreshTokenIndexes.contains(refreshToken); + } + + @Override + public void rotateJwtInformation(String refreshToken, JwtInformation newJwtInformation) { + origin.computeIfPresent(newJwtInformation.getUserDto().id(), (key, queue) -> { + queue.stream().filter(jwtInformation -> jwtInformation.getRefreshToken().equals(refreshToken)) + .findFirst() + .ifPresent(jwtInformation -> { + removeTokenIndex(jwtInformation.getAccessToken(), jwtInformation.getRefreshToken()); + jwtInformation.rotate( + newJwtInformation.getAccessToken(), + newJwtInformation.getRefreshToken() + ); + addTokenIndex( + newJwtInformation.getAccessToken(), + newJwtInformation.getRefreshToken() + ); + }); + return queue; + }); + } + + @Scheduled(fixedDelay = 1000 * 60 * 5) + @Override + public void clearExpiredJwtInformation() { + origin.entrySet().removeIf(entry -> { + Queue queue = entry.getValue(); + queue.removeIf(jwtInformation -> { + boolean isExpired = + !jwtTokenProvider.validateAccessToken(jwtInformation.getAccessToken()) || + !jwtTokenProvider.validateRefreshToken(jwtInformation.getRefreshToken()); + if (isExpired) { + removeTokenIndex( + jwtInformation.getAccessToken(), + jwtInformation.getRefreshToken() + ); + } + return isExpired; + }); + return queue.isEmpty(); // Remove the entry if the queue is empty + }); + } + + private void addTokenIndex(String accessToken, String refreshToken) { + accessTokenIndexes.add(accessToken); + refreshTokenIndexes.add(refreshToken); + } + + private void removeTokenIndex(String accessToken, String refreshToken) { + accessTokenIndexes.remove(accessToken); + refreshTokenIndexes.remove(refreshToken); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 000000000..fb58b0fe6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,94 @@ +package com.sprint.mission.discodeit.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider tokenProvider; + private final UserDetailsService userDetailsService; + private final ObjectMapper objectMapper; + private final JwtRegistry jwtRegistry; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + String token = resolveToken(request); + + if (StringUtils.hasText(token)) { + if (tokenProvider.validateAccessToken(token) && jwtRegistry.hasActiveJwtInformationByAccessToken( + token)) { + String username = tokenProvider.getUsernameFromToken(token); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + authentication.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("Set authentication for user: {}", username); + } else { + log.debug("Invalid JWT token"); + sendErrorResponse(response, "Invalid JWT token", HttpServletResponse.SC_UNAUTHORIZED); + return; + } + } + } catch (Exception e) { + log.debug("JWT authentication failed: {}", e.getMessage()); + SecurityContextHolder.clearContext(); + sendErrorResponse(response, "JWT authentication failed", HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + private void sendErrorResponse(HttpServletResponse response, String message, int status) + throws IOException { + ErrorResponse errorResponse = new ErrorResponse(new RuntimeException(message), status); + + response.setStatus(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + String jsonResponse = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(jsonResponse); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandler.java new file mode 100644 index 000000000..d7ce4dca2 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandler.java @@ -0,0 +1,84 @@ +package com.sprint.mission.discodeit.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JOSEException; +import com.sprint.mission.discodeit.dto.data.JwtDto; +import com.sprint.mission.discodeit.dto.data.JwtInformation; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtLoginSuccessHandler implements AuthenticationSuccessHandler { + + private final ObjectMapper objectMapper; + private final JwtTokenProvider tokenProvider; + private final JwtRegistry jwtRegistry; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + response.setCharacterEncoding("UTF-8"); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + if (authentication.getPrincipal() instanceof DiscodeitUserDetails userDetails) { + try { + String accessToken = tokenProvider.generateAccessToken(userDetails); + String refreshToken = tokenProvider.generateRefreshToken(userDetails); + + // Set refresh token in HttpOnly cookie + Cookie refreshCookie = tokenProvider.genereateRefreshTokenCookie(refreshToken); + response.addCookie(refreshCookie); + + JwtDto jwtDto = new JwtDto( + userDetails.getUserDto(), + accessToken + ); + + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write(objectMapper.writeValueAsString(jwtDto)); + + jwtRegistry.registerJwtInformation( + new JwtInformation( + userDetails.getUserDto(), + accessToken, + refreshToken + ) + ); + + log.info("JWT access and refresh tokens issued for user: {}", userDetails.getUsername()); + + } catch (JOSEException e) { + log.error("Failed to generate JWT token for user: {}", userDetails.getUsername(), e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + ErrorResponse errorResponse = new ErrorResponse( + new RuntimeException("Token generation failed"), + HttpServletResponse.SC_INTERNAL_SERVER_ERROR + ); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + } else { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + ErrorResponse errorResponse = new ErrorResponse( + new RuntimeException("Authentication failed: Invalid user details"), + HttpServletResponse.SC_UNAUTHORIZED + ); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLogoutHandler.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLogoutHandler.java new file mode 100644 index 000000000..aa053de62 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLogoutHandler.java @@ -0,0 +1,41 @@ +package com.sprint.mission.discodeit.security.jwt; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtLogoutHandler implements LogoutHandler { + + private final JwtTokenProvider tokenProvider; + private final JwtRegistry jwtRegistry; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) { + + // Clear refresh token cookie + Cookie refreshTokenExpirationCookie = tokenProvider.genereateRefreshTokenExpirationCookie(); + response.addCookie(refreshTokenExpirationCookie); + + Arrays.stream(request.getCookies()) + .filter(cookie -> cookie.getName().equals(JwtTokenProvider.REFRESH_TOKEN_COOKIE_NAME)) + .findFirst() + .ifPresent(cookie -> { + String refreshToken = cookie.getValue(); + UUID userId = tokenProvider.getUserId(refreshToken); + jwtRegistry.invalidateJwtInformationByUserId(userId); + }); + + log.debug("JWT logout handler executed - refresh token cookie cleared"); + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtRegistry.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtRegistry.java new file mode 100644 index 000000000..b153c41cc --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtRegistry.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.security.jwt; + +import com.sprint.mission.discodeit.dto.data.JwtInformation; +import java.util.UUID; + +public interface JwtRegistry { + + void registerJwtInformation(JwtInformation jwtInformation); + + void invalidateJwtInformationByUserId(UUID userId); + + boolean hasActiveJwtInformationByUserId(UUID userId); + + boolean hasActiveJwtInformationByAccessToken(String accessToken); + + boolean hasActiveJwtInformationByRefreshToken(String refreshToken); + + void rotateJwtInformation(String refreshToken, JwtInformation newJwtInformation); + + void clearExpiredJwtInformation(); +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtTokenProvider.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtTokenProvider.java new file mode 100644 index 000000000..debfafdda --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtTokenProvider.java @@ -0,0 +1,185 @@ +package com.sprint.mission.discodeit.security.jwt; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import jakarta.servlet.http.Cookie; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class JwtTokenProvider { + + public static final String REFRESH_TOKEN_COOKIE_NAME = "REFRESH_TOKEN"; + + private final int accessTokenExpirationMs; + private final int refreshTokenExpirationMs; + + private final JWSSigner accessTokenSigner; + private final JWSVerifier accessTokenVerifier; + private final JWSSigner refreshTokenSigner; + private final JWSVerifier refreshTokenVerifier; + + public JwtTokenProvider( + @Value("${discodeit.jwt.access-token.secret}") String accessTokenSecret, + @Value("${discodeit.jwt.access-token.expiration-ms}") int accessTokenExpirationMs, + @Value("${discodeit.jwt.refresh-token.secret}") String refreshTokenSecret, + @Value("${discodeit.jwt.refresh-token.expiration-ms}") int refreshTokenExpirationMs) + throws JOSEException { + + this.accessTokenExpirationMs = accessTokenExpirationMs; + this.refreshTokenExpirationMs = refreshTokenExpirationMs; + + byte[] accessSecretBytes = accessTokenSecret.getBytes(StandardCharsets.UTF_8); + this.accessTokenSigner = new MACSigner(accessSecretBytes); + this.accessTokenVerifier = new MACVerifier(accessSecretBytes); + + byte[] refreshSecretBytes = refreshTokenSecret.getBytes(StandardCharsets.UTF_8); + this.refreshTokenSigner = new MACSigner(refreshSecretBytes); + this.refreshTokenVerifier = new MACVerifier(refreshSecretBytes); + } + + public String generateAccessToken(DiscodeitUserDetails userDetails) throws JOSEException { + return generateToken(userDetails, accessTokenExpirationMs, accessTokenSigner, "access"); + } + + public String generateRefreshToken(DiscodeitUserDetails userDetails) throws JOSEException { + return generateToken(userDetails, refreshTokenExpirationMs, refreshTokenSigner, "refresh"); + } + + private String generateToken(DiscodeitUserDetails userDetails, int expirationMs, JWSSigner signer, + String tokenType) throws JOSEException { + String tokenId = UUID.randomUUID().toString(); + UserDto user = userDetails.getUserDto(); + + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expirationMs); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(user.username()) + .jwtID(tokenId) + .claim("userId", user.id().toString()) + .claim("type", tokenType) + .claim("roles", userDetails.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList())) + .issueTime(now) + .expirationTime(expiryDate) + .build(); + + SignedJWT signedJWT = new SignedJWT( + new JWSHeader(JWSAlgorithm.HS256), + claimsSet + ); + + signedJWT.sign(signer); + String token = signedJWT.serialize(); + + log.debug("Generated {} token for user: {}", tokenType, user.username()); + return token; + } + + public boolean validateAccessToken(String token) { + return validateToken(token, accessTokenVerifier, "access"); + } + + public boolean validateRefreshToken(String token) { + return validateToken(token, refreshTokenVerifier, "refresh"); + } + + private boolean validateToken(String token, JWSVerifier verifier, String expectedType) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + + // Verify signature + if (!signedJWT.verify(verifier)) { + log.debug("JWT signature verification failed for {} token", expectedType); + return false; + } + + // Check token type + String tokenType = (String) signedJWT.getJWTClaimsSet().getClaim("type"); + if (!expectedType.equals(tokenType)) { + log.debug("JWT token type mismatch: expected {}, got {}", expectedType, tokenType); + return false; + } + + // Check expiration + Date expirationTime = signedJWT.getJWTClaimsSet().getExpirationTime(); + if (expirationTime == null || expirationTime.before(new Date())) { + log.debug("JWT {} token expired", expectedType); + return false; + } + + return true; + } catch (Exception e) { + log.debug("JWT {} token validation failed: {}", expectedType, e.getMessage()); + return false; + } + } + + public String getUsernameFromToken(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + return signedJWT.getJWTClaimsSet().getSubject(); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid JWT token", e); + } + } + + public String getTokenId(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + return signedJWT.getJWTClaimsSet().getJWTID(); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid JWT token", e); + } + } + + public UUID getUserId(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + String userIdStr = (String) signedJWT.getJWTClaimsSet().getClaim("userId"); + if (userIdStr == null) { + throw new IllegalArgumentException("User ID claim not found in JWT token"); + } + return UUID.fromString(userIdStr); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid JWT token", e); + } + } + + public Cookie genereateRefreshTokenCookie(String refreshToken) { + // Set refresh token in HttpOnly cookie + Cookie refreshCookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(true); // Use HTTPS in production + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(refreshTokenExpirationMs / 1000); + return refreshCookie; + } + + public Cookie genereateRefreshTokenExpirationCookie() { + Cookie refreshCookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, ""); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(true); // Use HTTPS in production + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(0); + return refreshCookie; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java index aba352315..dd3f6cda3 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java @@ -1,5 +1,6 @@ package com.sprint.mission.discodeit.service; +import com.sprint.mission.discodeit.dto.data.JwtInformation; import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; @@ -8,4 +9,6 @@ public interface AuthService { UserDto updateRole(RoleUpdateRequest request); UserDto updateRoleInternal(RoleUpdateRequest request); + + JwtInformation refreshToken(String refreshToken); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java index 5634575ab..b4021e99b 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -1,18 +1,28 @@ package com.sprint.mission.discodeit.service.basic; +import com.nimbusds.jose.JOSEException; +import com.sprint.mission.discodeit.dto.data.JwtInformation; import com.sprint.mission.discodeit.dto.data.UserDto; import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; import com.sprint.mission.discodeit.entity.Role; import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.event.RoleUpdatedEvent; +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.repository.UserRepository; -import com.sprint.mission.discodeit.security.SessionManager; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.security.jwt.JwtRegistry; +import com.sprint.mission.discodeit.security.jwt.JwtTokenProvider; import com.sprint.mission.discodeit.service.AuthService; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,7 +33,10 @@ public class BasicAuthService implements AuthService { private final UserRepository userRepository; private final UserMapper userMapper; - private final SessionManager sessionManager; + private final JwtRegistry jwtRegistry; + private final JwtTokenProvider tokenProvider; + private final UserDetailsService userDetailsService; + private final ApplicationEventPublisher applicationEventPublisher; @PreAuthorize("hasRole('ADMIN')") @Transactional @@ -39,13 +52,58 @@ public UserDto updateRoleInternal(RoleUpdateRequest request) { User user = userRepository.findById(userId) .orElseThrow(() -> UserNotFoundException.withId(userId)); + Role oldRole = user.getRole(); Role newRole = request.newRole(); - user.updateRole(newRole); - sessionManager.invalidateSessionsByUserId(userId); + if(!oldRole.equals(newRole)) { + user.updateRole(newRole); + + applicationEventPublisher.publishEvent( + new RoleUpdatedEvent(this, userId, oldRole, newRole) + ); + } + + jwtRegistry.invalidateJwtInformationByUserId(userId); return userMapper.toDto(user); } + @Override + public JwtInformation refreshToken(String refreshToken) { + // Validate refresh token + if (!tokenProvider.validateRefreshToken(refreshToken) + || !jwtRegistry.hasActiveJwtInformationByRefreshToken(refreshToken)) { + log.error("Invalid or expired refresh token: {}", refreshToken); + throw new DiscodeitException(ErrorCode.INVALID_TOKEN); + } + + String username = tokenProvider.getUsernameFromToken(refreshToken); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (!(userDetails instanceof DiscodeitUserDetails discodeitUserDetails)) { + throw new DiscodeitException(ErrorCode.INVALID_USER_DETAILS); + } + try { + String newAccessToken = tokenProvider.generateAccessToken(discodeitUserDetails); + String newRefreshToken = tokenProvider.generateRefreshToken(discodeitUserDetails); + log.info("Access token refreshed for user: {}", username); + + JwtInformation newJwtInformation = new JwtInformation( + discodeitUserDetails.getUserDto(), + newAccessToken, + newRefreshToken + ); + jwtRegistry.rotateJwtInformation( + refreshToken, + newJwtInformation + ); + + return newJwtInformation; + + } catch (JOSEException e) { + log.error("Failed to generate new tokens for user: {}", username, e); + throw new DiscodeitException(ErrorCode.INTERNAL_SERVER_ERROR, e); + } + } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java index bd50ce57d..918a83cf1 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java @@ -2,7 +2,10 @@ import com.sprint.mission.discodeit.dto.data.BinaryContentDto; import com.sprint.mission.discodeit.dto.request.BinaryContentCreateRequest; +import com.sprint.mission.discodeit.entity.BinaryContentStatus; +import com.sprint.mission.discodeit.event.BinaryContentCreatedEvent; import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentException; import com.sprint.mission.discodeit.exception.binarycontent.BinaryContentNotFoundException; import com.sprint.mission.discodeit.mapper.BinaryContentMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; @@ -12,7 +15,9 @@ import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Slf4j @@ -23,6 +28,7 @@ public class BasicBinaryContentService implements BinaryContentService { private final BinaryContentRepository binaryContentRepository; private final BinaryContentMapper binaryContentMapper; private final BinaryContentStorage binaryContentStorage; + private final ApplicationEventPublisher applicationEventPublisher; @Transactional @Override @@ -38,12 +44,14 @@ public BinaryContentDto create(BinaryContentCreateRequest request) { (long) bytes.length, contentType ); - binaryContentRepository.save(binaryContent); - binaryContentStorage.put(binaryContent.getId(), bytes); + BinaryContent saved = binaryContentRepository.save(binaryContent); + + applicationEventPublisher.publishEvent(new BinaryContentCreatedEvent(this, saved, bytes)); + log.info("바이너리 컨텐츠 생성 완료: id={}, fileName={}, size={}", binaryContent.getId(), fileName, bytes.length); - return binaryContentMapper.toDto(binaryContent); + return binaryContentMapper.toDto(saved); } @Override @@ -77,4 +85,15 @@ public void delete(UUID binaryContentId) { binaryContentRepository.deleteById(binaryContentId); log.info("바이너리 컨텐츠 삭제 완료: id={}", binaryContentId); } + + //바이너리 컨텐츠 상태를 갱신하는 메서드 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public BinaryContentDto updateStatus(UUID binaryContentId, BinaryContentStatus status) { + log.debug("바이너리 컨텐츠 상태 업데이트 시작: id={}", binaryContentId); + BinaryContent content = binaryContentRepository.findById(binaryContentId).orElseThrow(() -> new BinaryContentNotFoundException().withId(binaryContentId)); + + content.updateStatus(status); + BinaryContent saved = binaryContentRepository.save(content); + return binaryContentMapper.toDto(saved); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java index 1c6952110..1bf6ea6f2 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java @@ -18,6 +18,8 @@ import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,6 +40,7 @@ public class BasicChannelService implements ChannelService { @PreAuthorize("hasRole('CHANNEL_MANAGER')") @Transactional @Override + @CacheEvict(value = "userChannels", key = "#request.name()") public ChannelDto create(PublicChannelCreateRequest request) { log.debug("채널 생성 시작: {}", request); String name = request.name(); @@ -75,6 +78,7 @@ public ChannelDto find(UUID channelId) { @Transactional(readOnly = true) @Override + @Cacheable(value = "userChannels", key = "#userId") public List findAllByUserId(UUID userId) { List mySubscribedChannelIds = readStatusRepository.findAllByUserId(userId).stream() .map(ReadStatus::getChannel) diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java index 5bb604f15..f8e443b72 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java @@ -9,6 +9,8 @@ import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.Message; import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.event.BinaryContentCreatedEvent; +import com.sprint.mission.discodeit.event.MessageCreatedEvent; import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; import com.sprint.mission.discodeit.exception.message.MessageNotFoundException; import com.sprint.mission.discodeit.exception.user.UserNotFoundException; @@ -25,6 +27,7 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.access.prepost.PreAuthorize; @@ -44,6 +47,7 @@ public class BasicMessageService implements MessageService { private final BinaryContentStorage binaryContentStorage; private final BinaryContentRepository binaryContentRepository; private final PageResponseMapper pageResponseMapper; + private final ApplicationEventPublisher applicationEventPublisher; @Transactional @Override @@ -66,8 +70,8 @@ public MessageDto create(MessageCreateRequest messageCreateRequest, BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, contentType); - binaryContentRepository.save(binaryContent); - binaryContentStorage.put(binaryContent.getId(), bytes); + BinaryContent saved = binaryContentRepository.save(binaryContent); + applicationEventPublisher.publishEvent(new BinaryContentCreatedEvent(this, saved, bytes)); return binaryContent; }) .toList(); @@ -80,7 +84,10 @@ public MessageDto create(MessageCreateRequest messageCreateRequest, attachments ); - messageRepository.save(message); + Message saved = messageRepository.save(message); + + applicationEventPublisher.publishEvent(new MessageCreatedEvent(this, saved)); + log.info("메시지 생성 완료: id={}, channelId={}", message.getId(), channelId); return messageMapper.toDto(message); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicNotificationService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicNotificationService.java new file mode 100644 index 000000000..12e5b9ef8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicNotificationService.java @@ -0,0 +1,83 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.dto.data.NotificationDto; +import com.sprint.mission.discodeit.entity.Notification; +import com.sprint.mission.discodeit.exception.notification.NotificationNotFoundException; +import com.sprint.mission.discodeit.repository.NotificationRepository; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BasicNotificationService { + + private final NotificationRepository notificationRepository; + + @Transactional + public void createNotification(UUID receiverId, String title, String content) { + Notification notification = new Notification(receiverId, title, content); + notificationRepository.save(notification); + } + + @Transactional(readOnly = true) + @Cacheable(value = "userNotifications", key = "#receiverId") + public List getNotifications(DiscodeitUserDetails userDetails) { + return notificationRepository.findAllByReceiverIdOrderByCreatedAtDesc(userDetails.getUserDto().id()) + .stream() + .map(NotificationDto::from) + .toList(); + } + + @Transactional + @CacheEvict(value = "userNotifications", key = "#notificationId") + public void deleteNotification(UUID notificationId, DiscodeitUserDetails userDetails) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> NotificationNotFoundException.withId(notificationId)); + + if (!notification.getReceiverId().equals(userDetails.getUserDto().id())) { + throw new AccessDeniedException("자신의 알림만 삭제할 수 있습니다."); + } + + notificationRepository.delete(notification); + + } + @Transactional + public void sendRoleUpdatedNotification(UUID receiverId, String newRole) { + Notification notification = new Notification( + receiverId, + "권한 변경 알림", + "당신의 권한이 " + newRole + "(으)로 변경되었습니다." + ); + notificationRepository.save(notification); + } + + public void sendAdminAlert(String operationName, String binaryContentId, Exception exception) { + String requestId = MDC.get("RequestId"); // MDC에 저장된 RequestId 가져오기 + + // 알림 내용 구성 + String message = String.format(""" + 🚨 [비동기 작업 실패 알림] + RequestId: %s + Operation: %s + BinaryContentId: %s + Error: %s + """, + requestId != null ? requestId : "N/A", + operationName, + binaryContentId, + exception.getMessage() + ); + + log.warn(message); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java index d5787246c..46ad53a30 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java @@ -84,6 +84,7 @@ public List findAllByUserId(UUID userId) { @Transactional @Override public ReadStatusDto update(UUID readStatusId, ReadStatusUpdateRequest request) { + log.debug("읽음 상태 수정 시작: id={}, newLastReadAt={}", readStatusId, request.newLastReadAt()); ReadStatus readStatus = readStatusRepository.findById(readStatusId) diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java index cc7fe191e..3e874de16 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -6,6 +6,7 @@ import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; import com.sprint.mission.discodeit.entity.BinaryContent; import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.event.BinaryContentCreatedEvent; import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException; import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.mapper.UserMapper; @@ -18,6 +19,8 @@ import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -33,6 +36,7 @@ public class BasicUserService implements UserService { private final BinaryContentRepository binaryContentRepository; private final BinaryContentStorage binaryContentStorage; private final PasswordEncoder passwordEncoder; + private final ApplicationEventPublisher applicationEventPublisher; @Transactional @Override @@ -57,8 +61,8 @@ public UserDto create(UserCreateRequest userCreateRequest, byte[] bytes = profileRequest.bytes(); BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, contentType); - binaryContentRepository.save(binaryContent); - binaryContentStorage.put(binaryContent.getId(), bytes); + BinaryContent saved = binaryContentRepository.save(binaryContent); + applicationEventPublisher.publishEvent(new BinaryContentCreatedEvent(this, saved, bytes)); return binaryContent; }) .orElse(null); @@ -85,6 +89,7 @@ public UserDto find(UUID userId) { @Transactional(readOnly = true) @Override + @Cacheable(value = "users") public List findAll() { log.debug("모든 사용자 조회 시작"); List userDtos = userRepository.findAllWithProfile() @@ -127,8 +132,8 @@ public UserDto update(UUID userId, UserUpdateRequest userUpdateRequest, byte[] bytes = profileRequest.bytes(); BinaryContent binaryContent = new BinaryContent(fileName, (long) bytes.length, contentType); - binaryContentRepository.save(binaryContent); - binaryContentStorage.put(binaryContent.getId(), bytes); + BinaryContent saved = binaryContentRepository.save(binaryContent); + applicationEventPublisher.publishEvent(new BinaryContentCreatedEvent(this, saved, bytes)); return binaryContent; }) .orElse(null); diff --git a/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java index 8922903c0..42ce75068 100644 --- a/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java +++ b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java @@ -44,6 +44,12 @@ public void init() { } public UUID put(UUID binaryContentId, byte[] bytes) { + try { + Thread.sleep(3000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Thread interrupted while simulating delay", e); + } Path filePath = resolvePath(binaryContentId); if (Files.exists(filePath)) { throw new IllegalArgumentException("File with key " + binaryContentId + " already exists"); diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java index 31b4dc0f3..647cd18aa 100644 --- a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java @@ -1,6 +1,7 @@ package com.sprint.mission.discodeit.storage.s3; import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.service.basic.BasicNotificationService; import com.sprint.mission.discodeit.storage.BinaryContentStorage; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -13,6 +14,9 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Component; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -35,6 +39,7 @@ public class S3BinaryContentStorage implements BinaryContentStorage { private final String secretKey; private final String region; private final String bucket; + private final BasicNotificationService basicNotificationService; @Value("${discodeit.storage.s3.presigned-url-expiration:600}") // 기본값 10분 private long presignedUrlExpirationSeconds; @@ -43,14 +48,21 @@ public S3BinaryContentStorage( @Value("${discodeit.storage.s3.access-key}") String accessKey, @Value("${discodeit.storage.s3.secret-key}") String secretKey, @Value("${discodeit.storage.s3.region}") String region, - @Value("${discodeit.storage.s3.bucket}") String bucket + @Value("${discodeit.storage.s3.bucket}") String bucket, + BasicNotificationService basicNotificationService ) { this.accessKey = accessKey; this.secretKey = secretKey; this.region = region; this.bucket = bucket; + this.basicNotificationService = basicNotificationService; } + @Retryable( + value = { S3Exception.class }, + maxAttempts = 3, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) @Override public UUID put(UUID binaryContentId, byte[] bytes) { String key = binaryContentId.toString(); @@ -67,11 +79,32 @@ public UUID put(UUID binaryContentId, byte[] bytes) { return binaryContentId; } catch (S3Exception e) { - log.error("S3에 파일 업로드 실패: {}", e.getMessage()); - throw new RuntimeException("S3에 파일 업로드 실패: " + key, e); + log.warn("S3에 파일 업로드 실패. 재시도 예정: key = {}, message = {} ", key, e.getMessage()); + throw e; } } + @Recover + public UUID recover(S3Exception e, UUID binaryContentId, byte[] bytes) { + log.error("S3 업로드 완전 실패 key = {} 모든 재시도 실패", binaryContentId, e); + + logToSystem(binaryContentId, e); + + basicNotificationService.sendAdminAlert( + "S3 업로드", + binaryContentId.toString(), + e + ); + + throw new RuntimeException("S3 업로드 실패 (모든 재시도 실패)", e); + } + + private void logToSystem(UUID binaryContentId, Exception e) { + // 추가적인 에러 로깅 시스템 연동 가능 (ex: DB, Sentry 등) + log.error("[S3_UPLOAD_FAILURE] id={} reason={}", binaryContentId, e.getMessage()); + } + + @Override public InputStream get(UUID binaryContentId) { String key = binaryContentId.toString(); diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index c64fcb431..7b1addb62 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -24,9 +24,4 @@ management: show-details: always info: env: - enabled: true -discodeit: - admin: - username: admin - email: admin@admin.com - password: 1234 \ No newline at end of file + enabled: true \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 400a60a14..79fbad608 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,7 +9,7 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: validate + ddl-auto: create-drop open-in-view: false profiles: active: @@ -21,10 +21,13 @@ management: endpoints: web: exposure: - include: health,info,metrics,loggers + include: health,info,metrics,loggers,caches endpoint: health: show-details: always + observations: + annotations: + enabled: true info: name: Discodeit @@ -45,6 +48,15 @@ info: multipart: max-file-size: ${spring.servlet.multipart.maxFileSize} max-request-size: ${spring.servlet.multipart.maxRequestSize} + show-sql: true + cache: + type: caffeine + caffeine: + spc : > + maximumSize=100, + expireAfterAccess=600s, + recordStats + discodeit: storage: @@ -58,14 +70,17 @@ discodeit: bucket: ${AWS_S3_BUCKET} presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION:600} # (기본값: 10분) admin: - username: ${DISCODEIT_ADMIN_USERNAME} - email: ${DISCODEIT_ADMIN_EMAIL} - password: ${DISCODEIT_ADMIN_PASSWORD} + username: ${DISCODEIT_ADMIN_USERNAME:admin} + email: ${DISCODEIT_ADMIN_EMAIL:admin@admin.com} + password: ${DISCODEIT_ADMIN_PASSWORD:admin123} + jwt: + access-token: + secret: ${JWT_ACCESS_SECRET:your-access-token-secret-key-here-make-it-long-and-random} + expiration-ms: ${JWT_ACCESS_EXPIRATION_MS:1800000} # 30 minutes + refresh-token: + secret: ${JWT_REFRESH_SECRET:your-refresh-token-secret-key-here-make-it-different-and-long} + expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000} # 7 days logging: level: - root: info -jwt: - key: 87aslkdjaosdjasdjo879nfiownofwef987wefmklsndfoery978sdflhsdfower978 - access-token-expiration-minutes: 30 - refresh-token-expiration-minutes: 420 \ No newline at end of file + root: info \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 347b8c25e..88b97d09d 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -17,9 +17,11 @@ CREATE TABLE binary_contents ( id uuid PRIMARY KEY, created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone, file_name varchar(255) NOT NULL, size bigint NOT NULL, - content_type varchar(100) NOT NULL + content_type varchar(100) NOT NULL, + status varchar(20) NOT NULL -- ,bytes bytea NOT NULL ); @@ -63,6 +65,7 @@ CREATE TABLE read_statuses user_id uuid NOT NULL, channel_id uuid NOT NULL, last_read_at timestamp with time zone NOT NULL, + notification_enabled boolean NOT NULL, UNIQUE (user_id, channel_id) ); @@ -108,4 +111,10 @@ ALTER TABLE read_statuses ADD CONSTRAINT fk_read_status_channel FOREIGN KEY (channel_id) REFERENCES channels (id) - ON DELETE CASCADE; \ No newline at end of file + ON DELETE CASCADE; + +ALTER TABLE read_statuses ADD COLUMN notification_enabled BOOLEAN DEFAULT FALSE; + +UPDATE read_statuses SET notification_enabled = FALSE WHERE notification_enabled IS NULL; + +ALTER TABLE read_statuses ALTER COLUMN notification_enabled SET NOT NULL; \ No newline at end of file diff --git a/src/main/resources/static/assets/index-DB4IjbRs.js b/src/main/resources/static/assets/index-DB4IjbRs.js new file mode 100644 index 000000000..3056ff41a --- /dev/null +++ b/src/main/resources/static/assets/index-DB4IjbRs.js @@ -0,0 +1,1572 @@ +var Vg=Object.defineProperty;var Wg=(n,i,s)=>i in n?Vg(n,i,{enumerable:!0,configurable:!0,writable:!0,value:s}):n[i]=s;var kf=(n,i,s)=>Wg(n,typeof i!="symbol"?i+"":i,s);(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))l(c);new MutationObserver(c=>{for(const d of c)if(d.type==="childList")for(const f of d.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&l(f)}).observe(document,{childList:!0,subtree:!0});function s(c){const d={};return c.integrity&&(d.integrity=c.integrity),c.referrerPolicy&&(d.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?d.credentials="include":c.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function l(c){if(c.ep)return;c.ep=!0;const d=s(c);fetch(c.href,d)}})();function mu(n){return n&&n.__esModule&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n}var Ma={exports:{}},Co={},_a={exports:{}},ke={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Cf;function qg(){if(Cf)return ke;Cf=1;var n=Symbol.for("react.element"),i=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),l=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),d=Symbol.for("react.provider"),f=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),x=Symbol.for("react.suspense"),y=Symbol.for("react.memo"),S=Symbol.for("react.lazy"),j=Symbol.iterator;function $(w){return w===null||typeof w!="object"?null:(w=j&&w[j]||w["@@iterator"],typeof w=="function"?w:null)}var I={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},k=Object.assign,A={};function _(w,L,ie){this.props=w,this.context=L,this.refs=A,this.updater=ie||I}_.prototype.isReactComponent={},_.prototype.setState=function(w,L){if(typeof w!="object"&&typeof w!="function"&&w!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,w,L,"setState")},_.prototype.forceUpdate=function(w){this.updater.enqueueForceUpdate(this,w,"forceUpdate")};function J(){}J.prototype=_.prototype;function G(w,L,ie){this.props=w,this.context=L,this.refs=A,this.updater=ie||I}var H=G.prototype=new J;H.constructor=G,k(H,_.prototype),H.isPureReactComponent=!0;var X=Array.isArray,D=Object.prototype.hasOwnProperty,N={current:null},Q={key:!0,ref:!0,__self:!0,__source:!0};function le(w,L,ie){var ae,de={},he=null,we=null;if(L!=null)for(ae in L.ref!==void 0&&(we=L.ref),L.key!==void 0&&(he=""+L.key),L)D.call(L,ae)&&!Q.hasOwnProperty(ae)&&(de[ae]=L[ae]);var ye=arguments.length-2;if(ye===1)de.children=ie;else if(1>>1,L=P[w];if(0>>1;wc(de,B))hec(we,de)?(P[w]=we,P[he]=B,w=he):(P[w]=de,P[ae]=B,w=ae);else if(hec(we,B))P[w]=we,P[he]=B,w=he;else break e}}return F}function c(P,F){var B=P.sortIndex-F.sortIndex;return B!==0?B:P.id-F.id}if(typeof performance=="object"&&typeof performance.now=="function"){var d=performance;n.unstable_now=function(){return d.now()}}else{var f=Date,m=f.now();n.unstable_now=function(){return f.now()-m}}var x=[],y=[],S=1,j=null,$=3,I=!1,k=!1,A=!1,_=typeof setTimeout=="function"?setTimeout:null,J=typeof clearTimeout=="function"?clearTimeout:null,G=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function H(P){for(var F=s(y);F!==null;){if(F.callback===null)l(y);else if(F.startTime<=P)l(y),F.sortIndex=F.expirationTime,i(x,F);else break;F=s(y)}}function X(P){if(A=!1,H(P),!k)if(s(x)!==null)k=!0,b(D);else{var F=s(y);F!==null&&W(X,F.startTime-P)}}function D(P,F){k=!1,A&&(A=!1,J(le),le=-1),I=!0;var B=$;try{for(H(F),j=s(x);j!==null&&(!(j.expirationTime>F)||P&&!pe());){var w=j.callback;if(typeof w=="function"){j.callback=null,$=j.priorityLevel;var L=w(j.expirationTime<=F);F=n.unstable_now(),typeof L=="function"?j.callback=L:j===s(x)&&l(x),H(F)}else l(x);j=s(x)}if(j!==null)var ie=!0;else{var ae=s(y);ae!==null&&W(X,ae.startTime-F),ie=!1}return ie}finally{j=null,$=B,I=!1}}var N=!1,Q=null,le=-1,Se=5,ge=-1;function pe(){return!(n.unstable_now()-geP||125w?(P.sortIndex=B,i(y,P),s(x)===null&&P===s(y)&&(A?(J(le),le=-1):A=!0,W(X,B-w))):(P.sortIndex=L,i(x,P),k||I||(k=!0,b(D))),P},n.unstable_shouldYield=pe,n.unstable_wrapCallback=function(P){var F=$;return function(){var B=$;$=F;try{return P.apply(this,arguments)}finally{$=B}}}}($a)),$a}var Pf;function Xg(){return Pf||(Pf=1,Na.exports=Kg()),Na.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Mf;function Jg(){if(Mf)return ht;Mf=1;var n=gu(),i=Xg();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,r=1;r"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),x=Object.prototype.hasOwnProperty,y=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,S={},j={};function $(e){return x.call(j,e)?!0:x.call(S,e)?!1:y.test(e)?j[e]=!0:(S[e]=!0,!1)}function I(e,t,r,o){if(r!==null&&r.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return o?!1:r!==null?!r.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function k(e,t,r,o){if(t===null||typeof t>"u"||I(e,t,r,o))return!0;if(o)return!1;if(r!==null)switch(r.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function A(e,t,r,o,a,u,p){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=o,this.attributeNamespace=a,this.mustUseProperty=r,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=p}var _={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){_[e]=new A(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];_[t]=new A(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){_[e]=new A(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){_[e]=new A(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){_[e]=new A(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){_[e]=new A(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){_[e]=new A(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){_[e]=new A(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){_[e]=new A(e,5,!1,e.toLowerCase(),null,!1,!1)});var J=/[\-:]([a-z])/g;function G(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(J,G);_[t]=new A(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(J,G);_[t]=new A(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(J,G);_[t]=new A(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){_[e]=new A(e,1,!1,e.toLowerCase(),null,!1,!1)}),_.xlinkHref=new A("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){_[e]=new A(e,1,!1,e.toLowerCase(),null,!0,!0)});function H(e,t,r,o){var a=_.hasOwnProperty(t)?_[t]:null;(a!==null?a.type!==0:o||!(2g||a[p]!==u[g]){var v=` +`+a[p].replace(" at new "," at ");return e.displayName&&v.includes("")&&(v=v.replace("",e.displayName)),v}while(1<=p&&0<=g);break}}}finally{ie=!1,Error.prepareStackTrace=r}return(e=e?e.displayName||e.name:"")?L(e):""}function de(e){switch(e.tag){case 5:return L(e.type);case 16:return L("Lazy");case 13:return L("Suspense");case 19:return L("SuspenseList");case 0:case 2:case 15:return e=ae(e.type,!1),e;case 11:return e=ae(e.type.render,!1),e;case 1:return e=ae(e.type,!0),e;default:return""}}function he(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Q:return"Fragment";case N:return"Portal";case Se:return"Profiler";case le:return"StrictMode";case Fe:return"Suspense";case V:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case pe:return(e.displayName||"Context")+".Consumer";case ge:return(e._context.displayName||"Context")+".Provider";case Be:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case z:return t=e.displayName||null,t!==null?t:he(e.type)||"Memo";case b:t=e._payload,e=e._init;try{return he(e(t))}catch{}}return null}function we(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return he(t);case 8:return t===le?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ye(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function xe(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ee(e){var t=xe(e)?"checked":"value",r=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),o=""+e[t];if(!e.hasOwnProperty(t)&&typeof r<"u"&&typeof r.get=="function"&&typeof r.set=="function"){var a=r.get,u=r.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return a.call(this)},set:function(p){o=""+p,u.call(this,p)}}),Object.defineProperty(e,t,{enumerable:r.enumerable}),{getValue:function(){return o},setValue:function(p){o=""+p},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function We(e){e._valueTracker||(e._valueTracker=Ee(e))}function Xe(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var r=t.getValue(),o="";return e&&(o=xe(e)?e.checked?"true":"false":e.value),e=o,e!==r?(t.setValue(e),!0):!1}function qt(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Ds(e,t){var r=t.checked;return B({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:r??e._wrapperState.initialChecked})}function Pu(e,t){var r=t.defaultValue==null?"":t.defaultValue,o=t.checked!=null?t.checked:t.defaultChecked;r=ye(t.value!=null?t.value:r),e._wrapperState={initialChecked:o,initialValue:r,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Mu(e,t){t=t.checked,t!=null&&H(e,"checked",t,!1)}function Is(e,t){Mu(e,t);var r=ye(t.value),o=t.type;if(r!=null)o==="number"?(r===0&&e.value===""||e.value!=r)&&(e.value=""+r):e.value!==""+r&&(e.value=""+r);else if(o==="submit"||o==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?bs(e,t.type,r):t.hasOwnProperty("defaultValue")&&bs(e,t.type,ye(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function _u(e,t,r){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var o=t.type;if(!(o!=="submit"&&o!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,r||t===e.value||(e.value=t),e.defaultValue=t}r=e.name,r!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,r!==""&&(e.name=r)}function bs(e,t,r){(t!=="number"||qt(e.ownerDocument)!==e)&&(r==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+r&&(e.defaultValue=""+r))}var Ir=Array.isArray;function Xn(e,t,r,o){if(e=e.options,t){t={};for(var a=0;a"+t.valueOf().toString()+"",t=Uo.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function br(e,t){if(t){var r=e.firstChild;if(r&&r===e.lastChild&&r.nodeType===3){r.nodeValue=t;return}}e.textContent=t}var zr={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Kh=["Webkit","ms","Moz","O"];Object.keys(zr).forEach(function(e){Kh.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),zr[t]=zr[e]})});function Du(e,t,r){return t==null||typeof t=="boolean"||t===""?"":r||typeof t!="number"||t===0||zr.hasOwnProperty(e)&&zr[e]?(""+t).trim():t+"px"}function Iu(e,t){e=e.style;for(var r in t)if(t.hasOwnProperty(r)){var o=r.indexOf("--")===0,a=Du(r,t[r],o);r==="float"&&(r="cssFloat"),o?e.setProperty(r,a):e[r]=a}}var Xh=B({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Fs(e,t){if(t){if(Xh[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(s(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(s(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(s(61))}if(t.style!=null&&typeof t.style!="object")throw Error(s(62))}}function Us(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Hs=null;function Ys(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Vs=null,Jn=null,Zn=null;function bu(e){if(e=lo(e)){if(typeof Vs!="function")throw Error(s(280));var t=e.stateNode;t&&(t=di(t),Vs(e.stateNode,e.type,t))}}function zu(e){Jn?Zn?Zn.push(e):Zn=[e]:Jn=e}function Bu(){if(Jn){var e=Jn,t=Zn;if(Zn=Jn=null,bu(e),t)for(e=0;e>>=0,e===0?32:31-(am(e)/um|0)|0}var qo=64,Qo=4194304;function Hr(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Go(e,t){var r=e.pendingLanes;if(r===0)return 0;var o=0,a=e.suspendedLanes,u=e.pingedLanes,p=r&268435455;if(p!==0){var g=p&~a;g!==0?o=Hr(g):(u&=p,u!==0&&(o=Hr(u)))}else p=r&~a,p!==0?o=Hr(p):u!==0&&(o=Hr(u));if(o===0)return 0;if(t!==0&&t!==o&&!(t&a)&&(a=o&-o,u=t&-t,a>=u||a===16&&(u&4194240)!==0))return t;if(o&4&&(o|=r&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=o;0r;r++)t.push(e);return t}function Yr(e,t,r){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Tt(t),e[t]=r}function pm(e,t){var r=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var o=e.eventTimes;for(e=e.expirationTimes;0=Jr),hc=" ",mc=!1;function gc(e,t){switch(e){case"keyup":return Fm.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function yc(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var nr=!1;function Hm(e,t){switch(e){case"compositionend":return yc(t);case"keypress":return t.which!==32?null:(mc=!0,hc);case"textInput":return e=t.data,e===hc&&mc?null:e;default:return null}}function Ym(e,t){if(nr)return e==="compositionend"||!ul&&gc(e,t)?(e=ac(),ei=rl=un=null,nr=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:r,offset:t-e};e=o}e:{for(;r;){if(r.nextSibling){r=r.nextSibling;break e}r=r.parentNode}r=void 0}r=Ec(r)}}function Ac(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ac(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Rc(){for(var e=window,t=qt();t instanceof e.HTMLIFrameElement;){try{var r=typeof t.contentWindow.location.href=="string"}catch{r=!1}if(r)e=t.contentWindow;else break;t=qt(e.document)}return t}function fl(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Zm(e){var t=Rc(),r=e.focusedElem,o=e.selectionRange;if(t!==r&&r&&r.ownerDocument&&Ac(r.ownerDocument.documentElement,r)){if(o!==null&&fl(r)){if(t=o.start,e=o.end,e===void 0&&(e=t),"selectionStart"in r)r.selectionStart=t,r.selectionEnd=Math.min(e,r.value.length);else if(e=(t=r.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var a=r.textContent.length,u=Math.min(o.start,a);o=o.end===void 0?u:Math.min(o.end,a),!e.extend&&u>o&&(a=o,o=u,u=a),a=jc(r,u);var p=jc(r,o);a&&p&&(e.rangeCount!==1||e.anchorNode!==a.node||e.anchorOffset!==a.offset||e.focusNode!==p.node||e.focusOffset!==p.offset)&&(t=t.createRange(),t.setStart(a.node,a.offset),e.removeAllRanges(),u>o?(e.addRange(t),e.extend(p.node,p.offset)):(t.setEnd(p.node,p.offset),e.addRange(t)))}}for(t=[],e=r;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof r.focus=="function"&&r.focus(),r=0;r=document.documentMode,rr=null,pl=null,no=null,hl=!1;function Pc(e,t,r){var o=r.window===r?r.document:r.nodeType===9?r:r.ownerDocument;hl||rr==null||rr!==qt(o)||(o=rr,"selectionStart"in o&&fl(o)?o={start:o.selectionStart,end:o.selectionEnd}:(o=(o.ownerDocument&&o.ownerDocument.defaultView||window).getSelection(),o={anchorNode:o.anchorNode,anchorOffset:o.anchorOffset,focusNode:o.focusNode,focusOffset:o.focusOffset}),no&&to(no,o)||(no=o,o=ai(pl,"onSelect"),0ar||(e.current=Al[ar],Al[ar]=null,ar--)}function Pe(e,t){ar++,Al[ar]=e.current,e.current=t}var pn={},tt=fn(pn),ut=fn(!1),_n=pn;function ur(e,t){var r=e.type.contextTypes;if(!r)return pn;var o=e.stateNode;if(o&&o.__reactInternalMemoizedUnmaskedChildContext===t)return o.__reactInternalMemoizedMaskedChildContext;var a={},u;for(u in r)a[u]=t[u];return o&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=a),a}function ct(e){return e=e.childContextTypes,e!=null}function fi(){_e(ut),_e(tt)}function Hc(e,t,r){if(tt.current!==pn)throw Error(s(168));Pe(tt,t),Pe(ut,r)}function Yc(e,t,r){var o=e.stateNode;if(t=t.childContextTypes,typeof o.getChildContext!="function")return r;o=o.getChildContext();for(var a in o)if(!(a in t))throw Error(s(108,we(e)||"Unknown",a));return B({},r,o)}function pi(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||pn,_n=tt.current,Pe(tt,e),Pe(ut,ut.current),!0}function Vc(e,t,r){var o=e.stateNode;if(!o)throw Error(s(169));r?(e=Yc(e,t,_n),o.__reactInternalMemoizedMergedChildContext=e,_e(ut),_e(tt),Pe(tt,e)):_e(ut),Pe(ut,r)}var Gt=null,hi=!1,Rl=!1;function Wc(e){Gt===null?Gt=[e]:Gt.push(e)}function dg(e){hi=!0,Wc(e)}function hn(){if(!Rl&&Gt!==null){Rl=!0;var e=0,t=Re;try{var r=Gt;for(Re=1;e>=p,a-=p,Kt=1<<32-Tt(t)+a|r<fe?(Ge=ce,ce=null):Ge=ce.sibling;var je=U(R,ce,M[fe],K);if(je===null){ce===null&&(ce=Ge);break}e&&ce&&je.alternate===null&&t(R,ce),C=u(je,C,fe),ue===null?se=je:ue.sibling=je,ue=je,ce=Ge}if(fe===M.length)return r(R,ce),Ne&&Nn(R,fe),se;if(ce===null){for(;fefe?(Ge=ce,ce=null):Ge=ce.sibling;var Cn=U(R,ce,je.value,K);if(Cn===null){ce===null&&(ce=Ge);break}e&&ce&&Cn.alternate===null&&t(R,ce),C=u(Cn,C,fe),ue===null?se=Cn:ue.sibling=Cn,ue=Cn,ce=Ge}if(je.done)return r(R,ce),Ne&&Nn(R,fe),se;if(ce===null){for(;!je.done;fe++,je=M.next())je=q(R,je.value,K),je!==null&&(C=u(je,C,fe),ue===null?se=je:ue.sibling=je,ue=je);return Ne&&Nn(R,fe),se}for(ce=o(R,ce);!je.done;fe++,je=M.next())je=te(ce,R,fe,je.value,K),je!==null&&(e&&je.alternate!==null&&ce.delete(je.key===null?fe:je.key),C=u(je,C,fe),ue===null?se=je:ue.sibling=je,ue=je);return e&&ce.forEach(function(Yg){return t(R,Yg)}),Ne&&Nn(R,fe),se}function be(R,C,M,K){if(typeof M=="object"&&M!==null&&M.type===Q&&M.key===null&&(M=M.props.children),typeof M=="object"&&M!==null){switch(M.$$typeof){case D:e:{for(var se=M.key,ue=C;ue!==null;){if(ue.key===se){if(se=M.type,se===Q){if(ue.tag===7){r(R,ue.sibling),C=a(ue,M.props.children),C.return=R,R=C;break e}}else if(ue.elementType===se||typeof se=="object"&&se!==null&&se.$$typeof===b&&Jc(se)===ue.type){r(R,ue.sibling),C=a(ue,M.props),C.ref=ao(R,ue,M),C.return=R,R=C;break e}r(R,ue);break}else t(R,ue);ue=ue.sibling}M.type===Q?(C=Bn(M.props.children,R.mode,K,M.key),C.return=R,R=C):(K=Ui(M.type,M.key,M.props,null,R.mode,K),K.ref=ao(R,C,M),K.return=R,R=K)}return p(R);case N:e:{for(ue=M.key;C!==null;){if(C.key===ue)if(C.tag===4&&C.stateNode.containerInfo===M.containerInfo&&C.stateNode.implementation===M.implementation){r(R,C.sibling),C=a(C,M.children||[]),C.return=R,R=C;break e}else{r(R,C);break}else t(R,C);C=C.sibling}C=Ea(M,R.mode,K),C.return=R,R=C}return p(R);case b:return ue=M._init,be(R,C,ue(M._payload),K)}if(Ir(M))return re(R,C,M,K);if(F(M))return oe(R,C,M,K);xi(R,M)}return typeof M=="string"&&M!==""||typeof M=="number"?(M=""+M,C!==null&&C.tag===6?(r(R,C.sibling),C=a(C,M),C.return=R,R=C):(r(R,C),C=Ca(M,R.mode,K),C.return=R,R=C),p(R)):r(R,C)}return be}var pr=Zc(!0),ed=Zc(!1),vi=fn(null),wi=null,hr=null,$l=null;function Ol(){$l=hr=wi=null}function Ll(e){var t=vi.current;_e(vi),e._currentValue=t}function Dl(e,t,r){for(;e!==null;){var o=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,o!==null&&(o.childLanes|=t)):o!==null&&(o.childLanes&t)!==t&&(o.childLanes|=t),e===r)break;e=e.return}}function mr(e,t){wi=e,$l=hr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(dt=!0),e.firstContext=null)}function At(e){var t=e._currentValue;if($l!==e)if(e={context:e,memoizedValue:t,next:null},hr===null){if(wi===null)throw Error(s(308));hr=e,wi.dependencies={lanes:0,firstContext:e}}else hr=hr.next=e;return t}var $n=null;function Il(e){$n===null?$n=[e]:$n.push(e)}function td(e,t,r,o){var a=t.interleaved;return a===null?(r.next=r,Il(t)):(r.next=a.next,a.next=r),t.interleaved=r,Jt(e,o)}function Jt(e,t){e.lanes|=t;var r=e.alternate;for(r!==null&&(r.lanes|=t),r=e,e=e.return;e!==null;)e.childLanes|=t,r=e.alternate,r!==null&&(r.childLanes|=t),r=e,e=e.return;return r.tag===3?r.stateNode:null}var mn=!1;function bl(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function nd(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Zt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function gn(e,t,r){var o=e.updateQueue;if(o===null)return null;if(o=o.shared,Ce&2){var a=o.pending;return a===null?t.next=t:(t.next=a.next,a.next=t),o.pending=t,Jt(e,r)}return a=o.interleaved,a===null?(t.next=t,Il(o)):(t.next=a.next,a.next=t),o.interleaved=t,Jt(e,r)}function Si(e,t,r){if(t=t.updateQueue,t!==null&&(t=t.shared,(r&4194240)!==0)){var o=t.lanes;o&=e.pendingLanes,r|=o,t.lanes=r,Js(e,r)}}function rd(e,t){var r=e.updateQueue,o=e.alternate;if(o!==null&&(o=o.updateQueue,r===o)){var a=null,u=null;if(r=r.firstBaseUpdate,r!==null){do{var p={eventTime:r.eventTime,lane:r.lane,tag:r.tag,payload:r.payload,callback:r.callback,next:null};u===null?a=u=p:u=u.next=p,r=r.next}while(r!==null);u===null?a=u=t:u=u.next=t}else a=u=t;r={baseState:o.baseState,firstBaseUpdate:a,lastBaseUpdate:u,shared:o.shared,effects:o.effects},e.updateQueue=r;return}e=r.lastBaseUpdate,e===null?r.firstBaseUpdate=t:e.next=t,r.lastBaseUpdate=t}function ki(e,t,r,o){var a=e.updateQueue;mn=!1;var u=a.firstBaseUpdate,p=a.lastBaseUpdate,g=a.shared.pending;if(g!==null){a.shared.pending=null;var v=g,T=v.next;v.next=null,p===null?u=T:p.next=T,p=v;var Y=e.alternate;Y!==null&&(Y=Y.updateQueue,g=Y.lastBaseUpdate,g!==p&&(g===null?Y.firstBaseUpdate=T:g.next=T,Y.lastBaseUpdate=v))}if(u!==null){var q=a.baseState;p=0,Y=T=v=null,g=u;do{var U=g.lane,te=g.eventTime;if((o&U)===U){Y!==null&&(Y=Y.next={eventTime:te,lane:0,tag:g.tag,payload:g.payload,callback:g.callback,next:null});e:{var re=e,oe=g;switch(U=t,te=r,oe.tag){case 1:if(re=oe.payload,typeof re=="function"){q=re.call(te,q,U);break e}q=re;break e;case 3:re.flags=re.flags&-65537|128;case 0:if(re=oe.payload,U=typeof re=="function"?re.call(te,q,U):re,U==null)break e;q=B({},q,U);break e;case 2:mn=!0}}g.callback!==null&&g.lane!==0&&(e.flags|=64,U=a.effects,U===null?a.effects=[g]:U.push(g))}else te={eventTime:te,lane:U,tag:g.tag,payload:g.payload,callback:g.callback,next:null},Y===null?(T=Y=te,v=q):Y=Y.next=te,p|=U;if(g=g.next,g===null){if(g=a.shared.pending,g===null)break;U=g,g=U.next,U.next=null,a.lastBaseUpdate=U,a.shared.pending=null}}while(!0);if(Y===null&&(v=q),a.baseState=v,a.firstBaseUpdate=T,a.lastBaseUpdate=Y,t=a.shared.interleaved,t!==null){a=t;do p|=a.lane,a=a.next;while(a!==t)}else u===null&&(a.shared.lanes=0);Dn|=p,e.lanes=p,e.memoizedState=q}}function od(e,t,r){if(e=t.effects,t.effects=null,e!==null)for(t=0;tr?r:4,e(!0);var o=Hl.transition;Hl.transition={};try{e(!1),t()}finally{Re=r,Hl.transition=o}}function Cd(){return Rt().memoizedState}function mg(e,t,r){var o=wn(e);if(r={lane:o,action:r,hasEagerState:!1,eagerState:null,next:null},Ed(e))jd(t,r);else if(r=td(e,t,r,o),r!==null){var a=at();It(r,e,o,a),Ad(r,t,o)}}function gg(e,t,r){var o=wn(e),a={lane:o,action:r,hasEagerState:!1,eagerState:null,next:null};if(Ed(e))jd(t,a);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var p=t.lastRenderedState,g=u(p,r);if(a.hasEagerState=!0,a.eagerState=g,Nt(g,p)){var v=t.interleaved;v===null?(a.next=a,Il(t)):(a.next=v.next,v.next=a),t.interleaved=a;return}}catch{}finally{}r=td(e,t,a,o),r!==null&&(a=at(),It(r,e,o,a),Ad(r,t,o))}}function Ed(e){var t=e.alternate;return e===Oe||t!==null&&t===Oe}function jd(e,t){po=ji=!0;var r=e.pending;r===null?t.next=t:(t.next=r.next,r.next=t),e.pending=t}function Ad(e,t,r){if(r&4194240){var o=t.lanes;o&=e.pendingLanes,r|=o,t.lanes=r,Js(e,r)}}var Pi={readContext:At,useCallback:nt,useContext:nt,useEffect:nt,useImperativeHandle:nt,useInsertionEffect:nt,useLayoutEffect:nt,useMemo:nt,useReducer:nt,useRef:nt,useState:nt,useDebugValue:nt,useDeferredValue:nt,useTransition:nt,useMutableSource:nt,useSyncExternalStore:nt,useId:nt,unstable_isNewReconciler:!1},yg={readContext:At,useCallback:function(e,t){return Ht().memoizedState=[e,t===void 0?null:t],e},useContext:At,useEffect:md,useImperativeHandle:function(e,t,r){return r=r!=null?r.concat([e]):null,Ai(4194308,4,xd.bind(null,t,e),r)},useLayoutEffect:function(e,t){return Ai(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ai(4,2,e,t)},useMemo:function(e,t){var r=Ht();return t=t===void 0?null:t,e=e(),r.memoizedState=[e,t],e},useReducer:function(e,t,r){var o=Ht();return t=r!==void 0?r(t):t,o.memoizedState=o.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},o.queue=e,e=e.dispatch=mg.bind(null,Oe,e),[o.memoizedState,e]},useRef:function(e){var t=Ht();return e={current:e},t.memoizedState=e},useState:pd,useDebugValue:Kl,useDeferredValue:function(e){return Ht().memoizedState=e},useTransition:function(){var e=pd(!1),t=e[0];return e=hg.bind(null,e[1]),Ht().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,r){var o=Oe,a=Ht();if(Ne){if(r===void 0)throw Error(s(407));r=r()}else{if(r=t(),Qe===null)throw Error(s(349));Ln&30||ad(o,t,r)}a.memoizedState=r;var u={value:r,getSnapshot:t};return a.queue=u,md(cd.bind(null,o,u,e),[e]),o.flags|=2048,go(9,ud.bind(null,o,u,r,t),void 0,null),r},useId:function(){var e=Ht(),t=Qe.identifierPrefix;if(Ne){var r=Xt,o=Kt;r=(o&~(1<<32-Tt(o)-1)).toString(32)+r,t=":"+t+"R"+r,r=ho++,0<\/script>",e=e.removeChild(e.firstChild)):typeof o.is=="string"?e=p.createElement(r,{is:o.is}):(e=p.createElement(r),r==="select"&&(p=e,o.multiple?p.multiple=!0:o.size&&(p.size=o.size))):e=p.createElementNS(e,r),e[Ft]=t,e[so]=o,Wd(e,t,!1,!1),t.stateNode=e;e:{switch(p=Us(r,o),r){case"dialog":Me("cancel",e),Me("close",e),a=o;break;case"iframe":case"object":case"embed":Me("load",e),a=o;break;case"video":case"audio":for(a=0;awr&&(t.flags|=128,o=!0,yo(u,!1),t.lanes=4194304)}else{if(!o)if(e=Ci(p),e!==null){if(t.flags|=128,o=!0,r=e.updateQueue,r!==null&&(t.updateQueue=r,t.flags|=4),yo(u,!0),u.tail===null&&u.tailMode==="hidden"&&!p.alternate&&!Ne)return rt(t),null}else 2*Ie()-u.renderingStartTime>wr&&r!==1073741824&&(t.flags|=128,o=!0,yo(u,!1),t.lanes=4194304);u.isBackwards?(p.sibling=t.child,t.child=p):(r=u.last,r!==null?r.sibling=p:t.child=p,u.last=p)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=Ie(),t.sibling=null,r=$e.current,Pe($e,o?r&1|2:r&1),t):(rt(t),null);case 22:case 23:return wa(),o=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==o&&(t.flags|=8192),o&&t.mode&1?vt&1073741824&&(rt(t),t.subtreeFlags&6&&(t.flags|=8192)):rt(t),null;case 24:return null;case 25:return null}throw Error(s(156,t.tag))}function jg(e,t){switch(Ml(t),t.tag){case 1:return ct(t.type)&&fi(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return gr(),_e(ut),_e(tt),Ul(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Bl(t),null;case 13:if(_e($e),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));fr()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return _e($e),null;case 4:return gr(),null;case 10:return Ll(t.type._context),null;case 22:case 23:return wa(),null;case 24:return null;default:return null}}var Ni=!1,ot=!1,Ag=typeof WeakSet=="function"?WeakSet:Set,ne=null;function xr(e,t){var r=e.ref;if(r!==null)if(typeof r=="function")try{r(null)}catch(o){De(e,t,o)}else r.current=null}function aa(e,t,r){try{r()}catch(o){De(e,t,o)}}var Gd=!1;function Rg(e,t){if(wl=Jo,e=Rc(),fl(e)){if("selectionStart"in e)var r={start:e.selectionStart,end:e.selectionEnd};else e:{r=(r=e.ownerDocument)&&r.defaultView||window;var o=r.getSelection&&r.getSelection();if(o&&o.rangeCount!==0){r=o.anchorNode;var a=o.anchorOffset,u=o.focusNode;o=o.focusOffset;try{r.nodeType,u.nodeType}catch{r=null;break e}var p=0,g=-1,v=-1,T=0,Y=0,q=e,U=null;t:for(;;){for(var te;q!==r||a!==0&&q.nodeType!==3||(g=p+a),q!==u||o!==0&&q.nodeType!==3||(v=p+o),q.nodeType===3&&(p+=q.nodeValue.length),(te=q.firstChild)!==null;)U=q,q=te;for(;;){if(q===e)break t;if(U===r&&++T===a&&(g=p),U===u&&++Y===o&&(v=p),(te=q.nextSibling)!==null)break;q=U,U=q.parentNode}q=te}r=g===-1||v===-1?null:{start:g,end:v}}else r=null}r=r||{start:0,end:0}}else r=null;for(Sl={focusedElem:e,selectionRange:r},Jo=!1,ne=t;ne!==null;)if(t=ne,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,ne=e;else for(;ne!==null;){t=ne;try{var re=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(re!==null){var oe=re.memoizedProps,be=re.memoizedState,R=t.stateNode,C=R.getSnapshotBeforeUpdate(t.elementType===t.type?oe:Ot(t.type,oe),be);R.__reactInternalSnapshotBeforeUpdate=C}break;case 3:var M=t.stateNode.containerInfo;M.nodeType===1?M.textContent="":M.nodeType===9&&M.documentElement&&M.removeChild(M.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(s(163))}}catch(K){De(t,t.return,K)}if(e=t.sibling,e!==null){e.return=t.return,ne=e;break}ne=t.return}return re=Gd,Gd=!1,re}function xo(e,t,r){var o=t.updateQueue;if(o=o!==null?o.lastEffect:null,o!==null){var a=o=o.next;do{if((a.tag&e)===e){var u=a.destroy;a.destroy=void 0,u!==void 0&&aa(t,r,u)}a=a.next}while(a!==o)}}function $i(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var r=t=t.next;do{if((r.tag&e)===e){var o=r.create;r.destroy=o()}r=r.next}while(r!==t)}}function ua(e){var t=e.ref;if(t!==null){var r=e.stateNode;switch(e.tag){case 5:e=r;break;default:e=r}typeof t=="function"?t(e):t.current=e}}function Kd(e){var t=e.alternate;t!==null&&(e.alternate=null,Kd(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ft],delete t[so],delete t[jl],delete t[ug],delete t[cg])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Xd(e){return e.tag===5||e.tag===3||e.tag===4}function Jd(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Xd(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function ca(e,t,r){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?r.nodeType===8?r.parentNode.insertBefore(e,t):r.insertBefore(e,t):(r.nodeType===8?(t=r.parentNode,t.insertBefore(e,r)):(t=r,t.appendChild(e)),r=r._reactRootContainer,r!=null||t.onclick!==null||(t.onclick=ci));else if(o!==4&&(e=e.child,e!==null))for(ca(e,t,r),e=e.sibling;e!==null;)ca(e,t,r),e=e.sibling}function da(e,t,r){var o=e.tag;if(o===5||o===6)e=e.stateNode,t?r.insertBefore(e,t):r.appendChild(e);else if(o!==4&&(e=e.child,e!==null))for(da(e,t,r),e=e.sibling;e!==null;)da(e,t,r),e=e.sibling}var Je=null,Lt=!1;function yn(e,t,r){for(r=r.child;r!==null;)Zd(e,t,r),r=r.sibling}function Zd(e,t,r){if(Bt&&typeof Bt.onCommitFiberUnmount=="function")try{Bt.onCommitFiberUnmount(Wo,r)}catch{}switch(r.tag){case 5:ot||xr(r,t);case 6:var o=Je,a=Lt;Je=null,yn(e,t,r),Je=o,Lt=a,Je!==null&&(Lt?(e=Je,r=r.stateNode,e.nodeType===8?e.parentNode.removeChild(r):e.removeChild(r)):Je.removeChild(r.stateNode));break;case 18:Je!==null&&(Lt?(e=Je,r=r.stateNode,e.nodeType===8?El(e.parentNode,r):e.nodeType===1&&El(e,r),Gr(e)):El(Je,r.stateNode));break;case 4:o=Je,a=Lt,Je=r.stateNode.containerInfo,Lt=!0,yn(e,t,r),Je=o,Lt=a;break;case 0:case 11:case 14:case 15:if(!ot&&(o=r.updateQueue,o!==null&&(o=o.lastEffect,o!==null))){a=o=o.next;do{var u=a,p=u.destroy;u=u.tag,p!==void 0&&(u&2||u&4)&&aa(r,t,p),a=a.next}while(a!==o)}yn(e,t,r);break;case 1:if(!ot&&(xr(r,t),o=r.stateNode,typeof o.componentWillUnmount=="function"))try{o.props=r.memoizedProps,o.state=r.memoizedState,o.componentWillUnmount()}catch(g){De(r,t,g)}yn(e,t,r);break;case 21:yn(e,t,r);break;case 22:r.mode&1?(ot=(o=ot)||r.memoizedState!==null,yn(e,t,r),ot=o):yn(e,t,r);break;default:yn(e,t,r)}}function ef(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var r=e.stateNode;r===null&&(r=e.stateNode=new Ag),t.forEach(function(o){var a=Dg.bind(null,e,o);r.has(o)||(r.add(o),o.then(a,a))})}}function Dt(e,t){var r=t.deletions;if(r!==null)for(var o=0;oa&&(a=p),o&=~u}if(o=a,o=Ie()-o,o=(120>o?120:480>o?480:1080>o?1080:1920>o?1920:3e3>o?3e3:4320>o?4320:1960*Mg(o/1960))-o,10e?16:e,vn===null)var o=!1;else{if(e=vn,vn=null,bi=0,Ce&6)throw Error(s(331));var a=Ce;for(Ce|=4,ne=e.current;ne!==null;){var u=ne,p=u.child;if(ne.flags&16){var g=u.deletions;if(g!==null){for(var v=0;vIe()-ha?bn(e,0):pa|=r),pt(e,t)}function hf(e,t){t===0&&(e.mode&1?(t=Qo,Qo<<=1,!(Qo&130023424)&&(Qo=4194304)):t=1);var r=at();e=Jt(e,t),e!==null&&(Yr(e,t,r),pt(e,r))}function Lg(e){var t=e.memoizedState,r=0;t!==null&&(r=t.retryLane),hf(e,r)}function Dg(e,t){var r=0;switch(e.tag){case 13:var o=e.stateNode,a=e.memoizedState;a!==null&&(r=a.retryLane);break;case 19:o=e.stateNode;break;default:throw Error(s(314))}o!==null&&o.delete(t),hf(e,r)}var mf;mf=function(e,t,r){if(e!==null)if(e.memoizedProps!==t.pendingProps||ut.current)dt=!0;else{if(!(e.lanes&r)&&!(t.flags&128))return dt=!1,Cg(e,t,r);dt=!!(e.flags&131072)}else dt=!1,Ne&&t.flags&1048576&&qc(t,gi,t.index);switch(t.lanes=0,t.tag){case 2:var o=t.type;Ti(e,t),e=t.pendingProps;var a=ur(t,tt.current);mr(t,r),a=Vl(null,t,o,e,a,r);var u=Wl();return t.flags|=1,typeof a=="object"&&a!==null&&typeof a.render=="function"&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,ct(o)?(u=!0,pi(t)):u=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,bl(t),a.updater=Mi,t.stateNode=a,a._reactInternals=t,Jl(t,o,e,r),t=na(null,t,o,!0,u,r)):(t.tag=0,Ne&&u&&Pl(t),lt(null,t,a,r),t=t.child),t;case 16:o=t.elementType;e:{switch(Ti(e,t),e=t.pendingProps,a=o._init,o=a(o._payload),t.type=o,a=t.tag=bg(o),e=Ot(o,e),a){case 0:t=ta(null,t,o,e,r);break e;case 1:t=Bd(null,t,o,e,r);break e;case 11:t=Ld(null,t,o,e,r);break e;case 14:t=Dd(null,t,o,Ot(o.type,e),r);break e}throw Error(s(306,o,""))}return t;case 0:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Ot(o,a),ta(e,t,o,a,r);case 1:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Ot(o,a),Bd(e,t,o,a,r);case 3:e:{if(Fd(t),e===null)throw Error(s(387));o=t.pendingProps,u=t.memoizedState,a=u.element,nd(e,t),ki(t,o,null,r);var p=t.memoizedState;if(o=p.element,u.isDehydrated)if(u={element:o,isDehydrated:!1,cache:p.cache,pendingSuspenseBoundaries:p.pendingSuspenseBoundaries,transitions:p.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){a=yr(Error(s(423)),t),t=Ud(e,t,o,r,a);break e}else if(o!==a){a=yr(Error(s(424)),t),t=Ud(e,t,o,r,a);break e}else for(xt=dn(t.stateNode.containerInfo.firstChild),yt=t,Ne=!0,$t=null,r=ed(t,null,o,r),t.child=r;r;)r.flags=r.flags&-3|4096,r=r.sibling;else{if(fr(),o===a){t=en(e,t,r);break e}lt(e,t,o,r)}t=t.child}return t;case 5:return id(t),e===null&&Tl(t),o=t.type,a=t.pendingProps,u=e!==null?e.memoizedProps:null,p=a.children,kl(o,a)?p=null:u!==null&&kl(o,u)&&(t.flags|=32),zd(e,t),lt(e,t,p,r),t.child;case 6:return e===null&&Tl(t),null;case 13:return Hd(e,t,r);case 4:return zl(t,t.stateNode.containerInfo),o=t.pendingProps,e===null?t.child=pr(t,null,o,r):lt(e,t,o,r),t.child;case 11:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Ot(o,a),Ld(e,t,o,a,r);case 7:return lt(e,t,t.pendingProps,r),t.child;case 8:return lt(e,t,t.pendingProps.children,r),t.child;case 12:return lt(e,t,t.pendingProps.children,r),t.child;case 10:e:{if(o=t.type._context,a=t.pendingProps,u=t.memoizedProps,p=a.value,Pe(vi,o._currentValue),o._currentValue=p,u!==null)if(Nt(u.value,p)){if(u.children===a.children&&!ut.current){t=en(e,t,r);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var g=u.dependencies;if(g!==null){p=u.child;for(var v=g.firstContext;v!==null;){if(v.context===o){if(u.tag===1){v=Zt(-1,r&-r),v.tag=2;var T=u.updateQueue;if(T!==null){T=T.shared;var Y=T.pending;Y===null?v.next=v:(v.next=Y.next,Y.next=v),T.pending=v}}u.lanes|=r,v=u.alternate,v!==null&&(v.lanes|=r),Dl(u.return,r,t),g.lanes|=r;break}v=v.next}}else if(u.tag===10)p=u.type===t.type?null:u.child;else if(u.tag===18){if(p=u.return,p===null)throw Error(s(341));p.lanes|=r,g=p.alternate,g!==null&&(g.lanes|=r),Dl(p,r,t),p=u.sibling}else p=u.child;if(p!==null)p.return=u;else for(p=u;p!==null;){if(p===t){p=null;break}if(u=p.sibling,u!==null){u.return=p.return,p=u;break}p=p.return}u=p}lt(e,t,a.children,r),t=t.child}return t;case 9:return a=t.type,o=t.pendingProps.children,mr(t,r),a=At(a),o=o(a),t.flags|=1,lt(e,t,o,r),t.child;case 14:return o=t.type,a=Ot(o,t.pendingProps),a=Ot(o.type,a),Dd(e,t,o,a,r);case 15:return Id(e,t,t.type,t.pendingProps,r);case 17:return o=t.type,a=t.pendingProps,a=t.elementType===o?a:Ot(o,a),Ti(e,t),t.tag=1,ct(o)?(e=!0,pi(t)):e=!1,mr(t,r),Pd(t,o,a),Jl(t,o,a,r),na(null,t,o,!0,e,r);case 19:return Vd(e,t,r);case 22:return bd(e,t,r)}throw Error(s(156,t.tag))};function gf(e,t){return Qu(e,t)}function Ig(e,t,r,o){this.tag=e,this.key=r,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=o,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Mt(e,t,r,o){return new Ig(e,t,r,o)}function ka(e){return e=e.prototype,!(!e||!e.isReactComponent)}function bg(e){if(typeof e=="function")return ka(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Be)return 11;if(e===z)return 14}return 2}function kn(e,t){var r=e.alternate;return r===null?(r=Mt(e.tag,t,e.key,e.mode),r.elementType=e.elementType,r.type=e.type,r.stateNode=e.stateNode,r.alternate=e,e.alternate=r):(r.pendingProps=t,r.type=e.type,r.flags=0,r.subtreeFlags=0,r.deletions=null),r.flags=e.flags&14680064,r.childLanes=e.childLanes,r.lanes=e.lanes,r.child=e.child,r.memoizedProps=e.memoizedProps,r.memoizedState=e.memoizedState,r.updateQueue=e.updateQueue,t=e.dependencies,r.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},r.sibling=e.sibling,r.index=e.index,r.ref=e.ref,r}function Ui(e,t,r,o,a,u){var p=2;if(o=e,typeof e=="function")ka(e)&&(p=1);else if(typeof e=="string")p=5;else e:switch(e){case Q:return Bn(r.children,a,u,t);case le:p=8,a|=8;break;case Se:return e=Mt(12,r,t,a|2),e.elementType=Se,e.lanes=u,e;case Fe:return e=Mt(13,r,t,a),e.elementType=Fe,e.lanes=u,e;case V:return e=Mt(19,r,t,a),e.elementType=V,e.lanes=u,e;case W:return Hi(r,a,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ge:p=10;break e;case pe:p=9;break e;case Be:p=11;break e;case z:p=14;break e;case b:p=16,o=null;break e}throw Error(s(130,e==null?e:typeof e,""))}return t=Mt(p,r,t,a),t.elementType=e,t.type=o,t.lanes=u,t}function Bn(e,t,r,o){return e=Mt(7,e,o,t),e.lanes=r,e}function Hi(e,t,r,o){return e=Mt(22,e,o,t),e.elementType=W,e.lanes=r,e.stateNode={isHidden:!1},e}function Ca(e,t,r){return e=Mt(6,e,null,t),e.lanes=r,e}function Ea(e,t,r){return t=Mt(4,e.children!==null?e.children:[],e.key,t),t.lanes=r,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function zg(e,t,r,o,a){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Xs(0),this.expirationTimes=Xs(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Xs(0),this.identifierPrefix=o,this.onRecoverableError=a,this.mutableSourceEagerHydrationData=null}function ja(e,t,r,o,a,u,p,g,v){return e=new zg(e,t,r,g,v),t===1?(t=1,u===!0&&(t|=8)):t=0,u=Mt(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:o,isDehydrated:r,cache:null,transitions:null,pendingSuspenseBoundaries:null},bl(u),e}function Bg(e,t,r){var o=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(i){console.error(i)}}return n(),Ta.exports=Jg(),Ta.exports}var Tf;function ey(){if(Tf)return Ki;Tf=1;var n=Zg();return Ki.createRoot=n.createRoot,Ki.hydrateRoot=n.hydrateRoot,Ki}var ty=ey(),st=function(){return st=Object.assign||function(i){for(var s,l=1,c=arguments.length;l0?Ke($r,--_t):0,Mr--,He===10&&(Mr=1,js--),He}function bt(){return He=_t2||eu(He)>3?"":" "}function dy(n,i){for(;--i&&bt()&&!(He<48||He>102||He>57&&He<65||He>70&&He<97););return Rs(n,ls()+(i<6&&Yn()==32&&bt()==32))}function tu(n){for(;bt();)switch(He){case n:return _t;case 34:case 39:n!==34&&n!==39&&tu(He);break;case 40:n===41&&tu(n);break;case 92:bt();break}return _t}function fy(n,i){for(;bt()&&n+He!==57;)if(n+He===84&&Yn()===47)break;return"/*"+Rs(i,_t-1)+"*"+xu(n===47?n:bt())}function py(n){for(;!eu(Yn());)bt();return Rs(n,_t)}function hy(n){return uy(as("",null,null,null,[""],n=ay(n),0,[0],n))}function as(n,i,s,l,c,d,f,m,x){for(var y=0,S=0,j=f,$=0,I=0,k=0,A=1,_=1,J=1,G=0,H="",X=c,D=d,N=l,Q=H;_;)switch(k=G,G=bt()){case 40:if(k!=108&&Ke(Q,j-1)==58){ss(Q+=ve(Oa(G),"&","&\f"),"&\f",Ip(y?m[y-1]:0))!=-1&&(J=-1);break}case 34:case 39:case 91:Q+=Oa(G);break;case 9:case 10:case 13:case 32:Q+=cy(k);break;case 92:Q+=dy(ls()-1,7);continue;case 47:switch(Yn()){case 42:case 47:Mo(my(fy(bt(),ls()),i,s,x),x);break;default:Q+="/"}break;case 123*A:m[y++]=Wt(Q)*J;case 125*A:case 59:case 0:switch(G){case 0:case 125:_=0;case 59+S:J==-1&&(Q=ve(Q,/\f/g,"")),I>0&&Wt(Q)-j&&Mo(I>32?Of(Q+";",l,s,j-1,x):Of(ve(Q," ","")+";",l,s,j-2,x),x);break;case 59:Q+=";";default:if(Mo(N=$f(Q,i,s,y,S,c,m,H,X=[],D=[],j,d),d),G===123)if(S===0)as(Q,i,N,N,X,d,j,m,D);else switch($===99&&Ke(Q,3)===110?100:$){case 100:case 108:case 109:case 115:as(n,N,N,l&&Mo($f(n,N,N,0,0,c,m,H,c,X=[],j,D),D),c,D,j,m,l?X:D);break;default:as(Q,N,N,N,[""],D,0,m,D)}}y=S=I=0,A=J=1,H=Q="",j=f;break;case 58:j=1+Wt(Q),I=k;default:if(A<1){if(G==123)--A;else if(G==125&&A++==0&&ly()==125)continue}switch(Q+=xu(G),G*A){case 38:J=S>0?1:(Q+="\f",-1);break;case 44:m[y++]=(Wt(Q)-1)*J,J=1;break;case 64:Yn()===45&&(Q+=Oa(bt())),$=Yn(),S=j=Wt(H=Q+=py(ls())),G++;break;case 45:k===45&&Wt(Q)==2&&(A=0)}}return d}function $f(n,i,s,l,c,d,f,m,x,y,S,j){for(var $=c-1,I=c===0?d:[""],k=zp(I),A=0,_=0,J=0;A0?I[G]+" "+H:ve(H,/&\f/g,I[G])))&&(x[J++]=X);return As(n,i,s,c===0?Es:m,x,y,S,j)}function my(n,i,s,l){return As(n,i,s,Lp,xu(sy()),Pr(n,2,-2),0,l)}function Of(n,i,s,l,c){return As(n,i,s,yu,Pr(n,0,l),Pr(n,l+1,-1),l,c)}function Fp(n,i,s){switch(oy(n,i)){case 5103:return Ae+"print-"+n+n;case 5737:case 4201:case 3177:case 3433:case 1641:case 4457:case 2921:case 5572:case 6356:case 5844:case 3191:case 6645:case 3005:case 6391:case 5879:case 5623:case 6135:case 4599:case 4855:case 4215:case 6389:case 5109:case 5365:case 5621:case 3829:return Ae+n+n;case 4789:return _o+n+n;case 5349:case 4246:case 4810:case 6968:case 2756:return Ae+n+_o+n+Te+n+n;case 5936:switch(Ke(n,i+11)){case 114:return Ae+n+Te+ve(n,/[svh]\w+-[tblr]{2}/,"tb")+n;case 108:return Ae+n+Te+ve(n,/[svh]\w+-[tblr]{2}/,"tb-rl")+n;case 45:return Ae+n+Te+ve(n,/[svh]\w+-[tblr]{2}/,"lr")+n}case 6828:case 4268:case 2903:return Ae+n+Te+n+n;case 6165:return Ae+n+Te+"flex-"+n+n;case 5187:return Ae+n+ve(n,/(\w+).+(:[^]+)/,Ae+"box-$1$2"+Te+"flex-$1$2")+n;case 5443:return Ae+n+Te+"flex-item-"+ve(n,/flex-|-self/g,"")+(nn(n,/flex-|baseline/)?"":Te+"grid-row-"+ve(n,/flex-|-self/g,""))+n;case 4675:return Ae+n+Te+"flex-line-pack"+ve(n,/align-content|flex-|-self/g,"")+n;case 5548:return Ae+n+Te+ve(n,"shrink","negative")+n;case 5292:return Ae+n+Te+ve(n,"basis","preferred-size")+n;case 6060:return Ae+"box-"+ve(n,"-grow","")+Ae+n+Te+ve(n,"grow","positive")+n;case 4554:return Ae+ve(n,/([^-])(transform)/g,"$1"+Ae+"$2")+n;case 6187:return ve(ve(ve(n,/(zoom-|grab)/,Ae+"$1"),/(image-set)/,Ae+"$1"),n,"")+n;case 5495:case 3959:return ve(n,/(image-set\([^]*)/,Ae+"$1$`$1");case 4968:return ve(ve(n,/(.+:)(flex-)?(.*)/,Ae+"box-pack:$3"+Te+"flex-pack:$3"),/s.+-b[^;]+/,"justify")+Ae+n+n;case 4200:if(!nn(n,/flex-|baseline/))return Te+"grid-column-align"+Pr(n,i)+n;break;case 2592:case 3360:return Te+ve(n,"template-","")+n;case 4384:case 3616:return s&&s.some(function(l,c){return i=c,nn(l.props,/grid-\w+-end/)})?~ss(n+(s=s[i].value),"span",0)?n:Te+ve(n,"-start","")+n+Te+"grid-row-span:"+(~ss(s,"span",0)?nn(s,/\d+/):+nn(s,/\d+/)-+nn(n,/\d+/))+";":Te+ve(n,"-start","")+n;case 4896:case 4128:return s&&s.some(function(l){return nn(l.props,/grid-\w+-start/)})?n:Te+ve(ve(n,"-end","-span"),"span ","")+n;case 4095:case 3583:case 4068:case 2532:return ve(n,/(.+)-inline(.+)/,Ae+"$1$2")+n;case 8116:case 7059:case 5753:case 5535:case 5445:case 5701:case 4933:case 4677:case 5533:case 5789:case 5021:case 4765:if(Wt(n)-1-i>6)switch(Ke(n,i+1)){case 109:if(Ke(n,i+4)!==45)break;case 102:return ve(n,/(.+:)(.+)-([^]+)/,"$1"+Ae+"$2-$3$1"+_o+(Ke(n,i+3)==108?"$3":"$2-$3"))+n;case 115:return~ss(n,"stretch",0)?Fp(ve(n,"stretch","fill-available"),i,s)+n:n}break;case 5152:case 5920:return ve(n,/(.+?):(\d+)(\s*\/\s*(span)?\s*(\d+))?(.*)/,function(l,c,d,f,m,x,y){return Te+c+":"+d+y+(f?Te+c+"-span:"+(m?x:+x-+d)+y:"")+n});case 4949:if(Ke(n,i+6)===121)return ve(n,":",":"+Ae)+n;break;case 6444:switch(Ke(n,Ke(n,14)===45?18:11)){case 120:return ve(n,/(.+:)([^;\s!]+)(;|(\s+)?!.+)?/,"$1"+Ae+(Ke(n,14)===45?"inline-":"")+"box$3$1"+Ae+"$2$3$1"+Te+"$2box$3")+n;case 100:return ve(n,":",":"+Te)+n}break;case 5719:case 2647:case 2135:case 3927:case 2391:return ve(n,"scroll-","scroll-snap-")+n}return n}function xs(n,i){for(var s="",l=0;l-1&&!n.return)switch(n.type){case yu:n.return=Fp(n.value,n.length,s);return;case Dp:return xs([En(n,{value:ve(n.value,"@","@"+Ae)})],l);case Es:if(n.length)return iy(s=n.props,function(c){switch(nn(c,l=/(::plac\w+|:read-\w+)/)){case":read-only":case":read-write":kr(En(n,{props:[ve(c,/:(read-\w+)/,":"+_o+"$1")]})),kr(En(n,{props:[c]})),Za(n,{props:Nf(s,l)});break;case"::placeholder":kr(En(n,{props:[ve(c,/:(plac\w+)/,":"+Ae+"input-$1")]})),kr(En(n,{props:[ve(c,/:(plac\w+)/,":"+_o+"$1")]})),kr(En(n,{props:[ve(c,/:(plac\w+)/,Te+"input-$1")]})),kr(En(n,{props:[c]})),Za(n,{props:Nf(s,l)});break}return""})}}var wy={animationIterationCount:1,aspectRatio:1,borderImageOutset:1,borderImageSlice:1,borderImageWidth:1,boxFlex:1,boxFlexGroup:1,boxOrdinalGroup:1,columnCount:1,columns:1,flex:1,flexGrow:1,flexPositive:1,flexShrink:1,flexNegative:1,flexOrder:1,gridRow:1,gridRowEnd:1,gridRowSpan:1,gridRowStart:1,gridColumn:1,gridColumnEnd:1,gridColumnSpan:1,gridColumnStart:1,msGridRow:1,msGridRowSpan:1,msGridColumn:1,msGridColumnSpan:1,fontWeight:1,lineHeight:1,opacity:1,order:1,orphans:1,tabSize:1,widows:1,zIndex:1,zoom:1,WebkitLineClamp:1,fillOpacity:1,floodOpacity:1,stopOpacity:1,strokeDasharray:1,strokeDashoffset:1,strokeMiterlimit:1,strokeOpacity:1,strokeWidth:1},wt={},_r=typeof process<"u"&&wt!==void 0&&(wt.REACT_APP_SC_ATTR||wt.SC_ATTR)||"data-styled",Up="active",Hp="data-styled-version",Ps="6.1.14",vu=`/*!sc*/ +`,vs=typeof window<"u"&&"HTMLElement"in window,Sy=!!(typeof SC_DISABLE_SPEEDY=="boolean"?SC_DISABLE_SPEEDY:typeof process<"u"&&wt!==void 0&&wt.REACT_APP_SC_DISABLE_SPEEDY!==void 0&&wt.REACT_APP_SC_DISABLE_SPEEDY!==""?wt.REACT_APP_SC_DISABLE_SPEEDY!=="false"&&wt.REACT_APP_SC_DISABLE_SPEEDY:typeof process<"u"&&wt!==void 0&&wt.SC_DISABLE_SPEEDY!==void 0&&wt.SC_DISABLE_SPEEDY!==""&&wt.SC_DISABLE_SPEEDY!=="false"&&wt.SC_DISABLE_SPEEDY),Ms=Object.freeze([]),Tr=Object.freeze({});function ky(n,i,s){return s===void 0&&(s=Tr),n.theme!==s.theme&&n.theme||i||s.theme}var Yp=new Set(["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","big","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","keygen","label","legend","li","link","main","map","mark","menu","menuitem","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","section","select","small","source","span","strong","style","sub","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","track","u","ul","use","var","video","wbr","circle","clipPath","defs","ellipse","foreignObject","g","image","line","linearGradient","marker","mask","path","pattern","polygon","polyline","radialGradient","rect","stop","svg","text","tspan"]),Cy=/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~-]+/g,Ey=/(^-|-$)/g;function Lf(n){return n.replace(Cy,"-").replace(Ey,"")}var jy=/(a)(d)/gi,Xi=52,Df=function(n){return String.fromCharCode(n+(n>25?39:97))};function nu(n){var i,s="";for(i=Math.abs(n);i>Xi;i=i/Xi|0)s=Df(i%Xi)+s;return(Df(i%Xi)+s).replace(jy,"$1-$2")}var La,Vp=5381,Er=function(n,i){for(var s=i.length;s;)n=33*n^i.charCodeAt(--s);return n},Wp=function(n){return Er(Vp,n)};function Ay(n){return nu(Wp(n)>>>0)}function Ry(n){return n.displayName||n.name||"Component"}function Da(n){return typeof n=="string"&&!0}var qp=typeof Symbol=="function"&&Symbol.for,Qp=qp?Symbol.for("react.memo"):60115,Py=qp?Symbol.for("react.forward_ref"):60112,My={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},_y={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},Gp={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},Ty=((La={})[Py]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},La[Qp]=Gp,La);function If(n){return("type"in(i=n)&&i.type.$$typeof)===Qp?Gp:"$$typeof"in n?Ty[n.$$typeof]:My;var i}var Ny=Object.defineProperty,$y=Object.getOwnPropertyNames,bf=Object.getOwnPropertySymbols,Oy=Object.getOwnPropertyDescriptor,Ly=Object.getPrototypeOf,zf=Object.prototype;function Kp(n,i,s){if(typeof i!="string"){if(zf){var l=Ly(i);l&&l!==zf&&Kp(n,l,s)}var c=$y(i);bf&&(c=c.concat(bf(i)));for(var d=If(n),f=If(i),m=0;m0?" Args: ".concat(i.join(", ")):""))}var Dy=function(){function n(i){this.groupSizes=new Uint32Array(512),this.length=512,this.tag=i}return n.prototype.indexOfGroup=function(i){for(var s=0,l=0;l=this.groupSizes.length){for(var l=this.groupSizes,c=l.length,d=c;i>=d;)if((d<<=1)<0)throw Qn(16,"".concat(i));this.groupSizes=new Uint32Array(d),this.groupSizes.set(l),this.length=d;for(var f=c;f=this.length||this.groupSizes[i]===0)return s;for(var l=this.groupSizes[i],c=this.indexOfGroup(i),d=c+l,f=c;f=0){var l=document.createTextNode(s);return this.element.insertBefore(l,this.nodes[i]||null),this.length++,!0}return!1},n.prototype.deleteRule=function(i){this.element.removeChild(this.nodes[i]),this.length--},n.prototype.getRule=function(i){return i0&&(_+="".concat(J,","))}),x+="".concat(k).concat(A,'{content:"').concat(_,'"}').concat(vu)},S=0;S0?".".concat(i):$},S=x.slice();S.push(function($){$.type===Es&&$.value.includes("&")&&($.props[0]=$.props[0].replace(qy,s).replace(l,y))}),f.prefix&&S.push(vy),S.push(gy);var j=function($,I,k,A){I===void 0&&(I=""),k===void 0&&(k=""),A===void 0&&(A="&"),i=A,s=I,l=new RegExp("\\".concat(s,"\\b"),"g");var _=$.replace(Qy,""),J=hy(k||I?"".concat(k," ").concat(I," { ").concat(_," }"):_);f.namespace&&(J=Zp(J,f.namespace));var G=[];return xs(J,yy(S.concat(xy(function(H){return G.push(H)})))),G};return j.hash=x.length?x.reduce(function($,I){return I.name||Qn(15),Er($,I.name)},Vp).toString():"",j}var Ky=new Jp,ou=Gy(),eh=St.createContext({shouldForwardProp:void 0,styleSheet:Ky,stylis:ou});eh.Consumer;St.createContext(void 0);function Hf(){return Z.useContext(eh)}var Xy=function(){function n(i,s){var l=this;this.inject=function(c,d){d===void 0&&(d=ou);var f=l.name+d.hash;c.hasNameForId(l.id,f)||c.insertRules(l.id,f,d(l.rules,f,"@keyframes"))},this.name=i,this.id="sc-keyframes-".concat(i),this.rules=s,Su(this,function(){throw Qn(12,String(l.name))})}return n.prototype.getName=function(i){return i===void 0&&(i=ou),this.name+i.hash},n}(),Jy=function(n){return n>="A"&&n<="Z"};function Yf(n){for(var i="",s=0;s>>0);if(!s.hasNameForId(this.componentId,f)){var m=l(d,".".concat(f),void 0,this.componentId);s.insertRules(this.componentId,f,m)}c=Un(c,f),this.staticRulesId=f}else{for(var x=Er(this.baseHash,l.hash),y="",S=0;S>>0);s.hasNameForId(this.componentId,I)||s.insertRules(this.componentId,I,l(y,".".concat(I),void 0,this.componentId)),c=Un(c,I)}}return c},n}(),Ss=St.createContext(void 0);Ss.Consumer;function Vf(n){var i=St.useContext(Ss),s=Z.useMemo(function(){return function(l,c){if(!l)throw Qn(14);if(qn(l)){var d=l(c);return d}if(Array.isArray(l)||typeof l!="object")throw Qn(8);return c?st(st({},c),l):l}(n.theme,i)},[n.theme,i]);return n.children?St.createElement(Ss.Provider,{value:s},n.children):null}var Ia={};function n0(n,i,s){var l=wu(n),c=n,d=!Da(n),f=i.attrs,m=f===void 0?Ms:f,x=i.componentId,y=x===void 0?function(X,D){var N=typeof X!="string"?"sc":Lf(X);Ia[N]=(Ia[N]||0)+1;var Q="".concat(N,"-").concat(Ay(Ps+N+Ia[N]));return D?"".concat(D,"-").concat(Q):Q}(i.displayName,i.parentComponentId):x,S=i.displayName,j=S===void 0?function(X){return Da(X)?"styled.".concat(X):"Styled(".concat(Ry(X),")")}(n):S,$=i.displayName&&i.componentId?"".concat(Lf(i.displayName),"-").concat(i.componentId):i.componentId||y,I=l&&c.attrs?c.attrs.concat(m).filter(Boolean):m,k=i.shouldForwardProp;if(l&&c.shouldForwardProp){var A=c.shouldForwardProp;if(i.shouldForwardProp){var _=i.shouldForwardProp;k=function(X,D){return A(X,D)&&_(X,D)}}else k=A}var J=new t0(s,$,l?c.componentStyle:void 0);function G(X,D){return function(N,Q,le){var Se=N.attrs,ge=N.componentStyle,pe=N.defaultProps,Be=N.foldedComponentIds,Fe=N.styledComponentId,V=N.target,z=St.useContext(Ss),b=Hf(),W=N.shouldForwardProp||b.shouldForwardProp,P=ky(Q,z,pe)||Tr,F=function(de,he,we){for(var ye,xe=st(st({},he),{className:void 0,theme:we}),Ee=0;Ee{let i;const s=new Set,l=(y,S)=>{const j=typeof y=="function"?y(i):y;if(!Object.is(j,i)){const $=i;i=S??(typeof j!="object"||j===null)?j:Object.assign({},i,j),s.forEach(I=>I(i,$))}},c=()=>i,m={setState:l,getState:c,getInitialState:()=>x,subscribe:y=>(s.add(y),()=>s.delete(y))},x=i=n(l,c,m);return m},o0=n=>n?Qf(n):Qf,i0=n=>n;function s0(n,i=i0){const s=St.useSyncExternalStore(n.subscribe,()=>i(n.getState()),()=>i(n.getInitialState()));return St.useDebugValue(s),s}const Gf=n=>{const i=o0(n),s=l=>s0(i,l);return Object.assign(s,i),s},Kn=n=>n?Gf(n):Gf;function oh(n,i){return function(){return n.apply(i,arguments)}}const{toString:l0}=Object.prototype,{getPrototypeOf:ku}=Object,_s=(n=>i=>{const s=l0.call(i);return n[s]||(n[s]=s.slice(8,-1).toLowerCase())})(Object.create(null)),zt=n=>(n=n.toLowerCase(),i=>_s(i)===n),Ts=n=>i=>typeof i===n,{isArray:Or}=Array,Do=Ts("undefined");function a0(n){return n!==null&&!Do(n)&&n.constructor!==null&&!Do(n.constructor)&&kt(n.constructor.isBuffer)&&n.constructor.isBuffer(n)}const ih=zt("ArrayBuffer");function u0(n){let i;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?i=ArrayBuffer.isView(n):i=n&&n.buffer&&ih(n.buffer),i}const c0=Ts("string"),kt=Ts("function"),sh=Ts("number"),Ns=n=>n!==null&&typeof n=="object",d0=n=>n===!0||n===!1,ds=n=>{if(_s(n)!=="object")return!1;const i=ku(n);return(i===null||i===Object.prototype||Object.getPrototypeOf(i)===null)&&!(Symbol.toStringTag in n)&&!(Symbol.iterator in n)},f0=zt("Date"),p0=zt("File"),h0=zt("Blob"),m0=zt("FileList"),g0=n=>Ns(n)&&kt(n.pipe),y0=n=>{let i;return n&&(typeof FormData=="function"&&n instanceof FormData||kt(n.append)&&((i=_s(n))==="formdata"||i==="object"&&kt(n.toString)&&n.toString()==="[object FormData]"))},x0=zt("URLSearchParams"),[v0,w0,S0,k0]=["ReadableStream","Request","Response","Headers"].map(zt),C0=n=>n.trim?n.trim():n.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function bo(n,i,{allOwnKeys:s=!1}={}){if(n===null||typeof n>"u")return;let l,c;if(typeof n!="object"&&(n=[n]),Or(n))for(l=0,c=n.length;l0;)if(c=s[l],i===c.toLowerCase())return c;return null}const Hn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,ah=n=>!Do(n)&&n!==Hn;function su(){const{caseless:n}=ah(this)&&this||{},i={},s=(l,c)=>{const d=n&&lh(i,c)||c;ds(i[d])&&ds(l)?i[d]=su(i[d],l):ds(l)?i[d]=su({},l):Or(l)?i[d]=l.slice():i[d]=l};for(let l=0,c=arguments.length;l(bo(i,(c,d)=>{s&&kt(c)?n[d]=oh(c,s):n[d]=c},{allOwnKeys:l}),n),j0=n=>(n.charCodeAt(0)===65279&&(n=n.slice(1)),n),A0=(n,i,s,l)=>{n.prototype=Object.create(i.prototype,l),n.prototype.constructor=n,Object.defineProperty(n,"super",{value:i.prototype}),s&&Object.assign(n.prototype,s)},R0=(n,i,s,l)=>{let c,d,f;const m={};if(i=i||{},n==null)return i;do{for(c=Object.getOwnPropertyNames(n),d=c.length;d-- >0;)f=c[d],(!l||l(f,n,i))&&!m[f]&&(i[f]=n[f],m[f]=!0);n=s!==!1&&ku(n)}while(n&&(!s||s(n,i))&&n!==Object.prototype);return i},P0=(n,i,s)=>{n=String(n),(s===void 0||s>n.length)&&(s=n.length),s-=i.length;const l=n.indexOf(i,s);return l!==-1&&l===s},M0=n=>{if(!n)return null;if(Or(n))return n;let i=n.length;if(!sh(i))return null;const s=new Array(i);for(;i-- >0;)s[i]=n[i];return s},_0=(n=>i=>n&&i instanceof n)(typeof Uint8Array<"u"&&ku(Uint8Array)),T0=(n,i)=>{const l=(n&&n[Symbol.iterator]).call(n);let c;for(;(c=l.next())&&!c.done;){const d=c.value;i.call(n,d[0],d[1])}},N0=(n,i)=>{let s;const l=[];for(;(s=n.exec(i))!==null;)l.push(s);return l},$0=zt("HTMLFormElement"),O0=n=>n.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(s,l,c){return l.toUpperCase()+c}),Kf=(({hasOwnProperty:n})=>(i,s)=>n.call(i,s))(Object.prototype),L0=zt("RegExp"),uh=(n,i)=>{const s=Object.getOwnPropertyDescriptors(n),l={};bo(s,(c,d)=>{let f;(f=i(c,d,n))!==!1&&(l[d]=f||c)}),Object.defineProperties(n,l)},D0=n=>{uh(n,(i,s)=>{if(kt(n)&&["arguments","caller","callee"].indexOf(s)!==-1)return!1;const l=n[s];if(kt(l)){if(i.enumerable=!1,"writable"in i){i.writable=!1;return}i.set||(i.set=()=>{throw Error("Can not rewrite read-only method '"+s+"'")})}})},I0=(n,i)=>{const s={},l=c=>{c.forEach(d=>{s[d]=!0})};return Or(n)?l(n):l(String(n).split(i)),s},b0=()=>{},z0=(n,i)=>n!=null&&Number.isFinite(n=+n)?n:i,ba="abcdefghijklmnopqrstuvwxyz",Xf="0123456789",ch={DIGIT:Xf,ALPHA:ba,ALPHA_DIGIT:ba+ba.toUpperCase()+Xf},B0=(n=16,i=ch.ALPHA_DIGIT)=>{let s="";const{length:l}=i;for(;n--;)s+=i[Math.random()*l|0];return s};function F0(n){return!!(n&&kt(n.append)&&n[Symbol.toStringTag]==="FormData"&&n[Symbol.iterator])}const U0=n=>{const i=new Array(10),s=(l,c)=>{if(Ns(l)){if(i.indexOf(l)>=0)return;if(!("toJSON"in l)){i[c]=l;const d=Or(l)?[]:{};return bo(l,(f,m)=>{const x=s(f,c+1);!Do(x)&&(d[m]=x)}),i[c]=void 0,d}}return l};return s(n,0)},H0=zt("AsyncFunction"),Y0=n=>n&&(Ns(n)||kt(n))&&kt(n.then)&&kt(n.catch),dh=((n,i)=>n?setImmediate:i?((s,l)=>(Hn.addEventListener("message",({source:c,data:d})=>{c===Hn&&d===s&&l.length&&l.shift()()},!1),c=>{l.push(c),Hn.postMessage(s,"*")}))(`axios@${Math.random()}`,[]):s=>setTimeout(s))(typeof setImmediate=="function",kt(Hn.postMessage)),V0=typeof queueMicrotask<"u"?queueMicrotask.bind(Hn):typeof process<"u"&&process.nextTick||dh,O={isArray:Or,isArrayBuffer:ih,isBuffer:a0,isFormData:y0,isArrayBufferView:u0,isString:c0,isNumber:sh,isBoolean:d0,isObject:Ns,isPlainObject:ds,isReadableStream:v0,isRequest:w0,isResponse:S0,isHeaders:k0,isUndefined:Do,isDate:f0,isFile:p0,isBlob:h0,isRegExp:L0,isFunction:kt,isStream:g0,isURLSearchParams:x0,isTypedArray:_0,isFileList:m0,forEach:bo,merge:su,extend:E0,trim:C0,stripBOM:j0,inherits:A0,toFlatObject:R0,kindOf:_s,kindOfTest:zt,endsWith:P0,toArray:M0,forEachEntry:T0,matchAll:N0,isHTMLForm:$0,hasOwnProperty:Kf,hasOwnProp:Kf,reduceDescriptors:uh,freezeMethods:D0,toObjectSet:I0,toCamelCase:O0,noop:b0,toFiniteNumber:z0,findKey:lh,global:Hn,isContextDefined:ah,ALPHABET:ch,generateString:B0,isSpecCompliantForm:F0,toJSONObject:U0,isAsyncFn:H0,isThenable:Y0,setImmediate:dh,asap:V0};function me(n,i,s,l,c){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack,this.message=n,this.name="AxiosError",i&&(this.code=i),s&&(this.config=s),l&&(this.request=l),c&&(this.response=c,this.status=c.status?c.status:null)}O.inherits(me,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:O.toJSONObject(this.config),code:this.code,status:this.status}}});const fh=me.prototype,ph={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach(n=>{ph[n]={value:n}});Object.defineProperties(me,ph);Object.defineProperty(fh,"isAxiosError",{value:!0});me.from=(n,i,s,l,c,d)=>{const f=Object.create(fh);return O.toFlatObject(n,f,function(x){return x!==Error.prototype},m=>m!=="isAxiosError"),me.call(f,n.message,i,s,l,c),f.cause=n,f.name=n.name,d&&Object.assign(f,d),f};const W0=null;function lu(n){return O.isPlainObject(n)||O.isArray(n)}function hh(n){return O.endsWith(n,"[]")?n.slice(0,-2):n}function Jf(n,i,s){return n?n.concat(i).map(function(c,d){return c=hh(c),!s&&d?"["+c+"]":c}).join(s?".":""):i}function q0(n){return O.isArray(n)&&!n.some(lu)}const Q0=O.toFlatObject(O,{},null,function(i){return/^is[A-Z]/.test(i)});function $s(n,i,s){if(!O.isObject(n))throw new TypeError("target must be an object");i=i||new FormData,s=O.toFlatObject(s,{metaTokens:!0,dots:!1,indexes:!1},!1,function(A,_){return!O.isUndefined(_[A])});const l=s.metaTokens,c=s.visitor||S,d=s.dots,f=s.indexes,x=(s.Blob||typeof Blob<"u"&&Blob)&&O.isSpecCompliantForm(i);if(!O.isFunction(c))throw new TypeError("visitor must be a function");function y(k){if(k===null)return"";if(O.isDate(k))return k.toISOString();if(!x&&O.isBlob(k))throw new me("Blob is not supported. Use a Buffer instead.");return O.isArrayBuffer(k)||O.isTypedArray(k)?x&&typeof Blob=="function"?new Blob([k]):Buffer.from(k):k}function S(k,A,_){let J=k;if(k&&!_&&typeof k=="object"){if(O.endsWith(A,"{}"))A=l?A:A.slice(0,-2),k=JSON.stringify(k);else if(O.isArray(k)&&q0(k)||(O.isFileList(k)||O.endsWith(A,"[]"))&&(J=O.toArray(k)))return A=hh(A),J.forEach(function(H,X){!(O.isUndefined(H)||H===null)&&i.append(f===!0?Jf([A],X,d):f===null?A:A+"[]",y(H))}),!1}return lu(k)?!0:(i.append(Jf(_,A,d),y(k)),!1)}const j=[],$=Object.assign(Q0,{defaultVisitor:S,convertValue:y,isVisitable:lu});function I(k,A){if(!O.isUndefined(k)){if(j.indexOf(k)!==-1)throw Error("Circular reference detected in "+A.join("."));j.push(k),O.forEach(k,function(J,G){(!(O.isUndefined(J)||J===null)&&c.call(i,J,O.isString(G)?G.trim():G,A,$))===!0&&I(J,A?A.concat(G):[G])}),j.pop()}}if(!O.isObject(n))throw new TypeError("data must be an object");return I(n),i}function Zf(n){const i={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(n).replace(/[!'()~]|%20|%00/g,function(l){return i[l]})}function Cu(n,i){this._pairs=[],n&&$s(n,this,i)}const mh=Cu.prototype;mh.append=function(i,s){this._pairs.push([i,s])};mh.toString=function(i){const s=i?function(l){return i.call(this,l,Zf)}:Zf;return this._pairs.map(function(c){return s(c[0])+"="+s(c[1])},"").join("&")};function G0(n){return encodeURIComponent(n).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}function gh(n,i,s){if(!i)return n;const l=s&&s.encode||G0;O.isFunction(s)&&(s={serialize:s});const c=s&&s.serialize;let d;if(c?d=c(i,s):d=O.isURLSearchParams(i)?i.toString():new Cu(i,s).toString(l),d){const f=n.indexOf("#");f!==-1&&(n=n.slice(0,f)),n+=(n.indexOf("?")===-1?"?":"&")+d}return n}class ep{constructor(){this.handlers=[]}use(i,s,l){return this.handlers.push({fulfilled:i,rejected:s,synchronous:l?l.synchronous:!1,runWhen:l?l.runWhen:null}),this.handlers.length-1}eject(i){this.handlers[i]&&(this.handlers[i]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(i){O.forEach(this.handlers,function(l){l!==null&&i(l)})}}const yh={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},K0=typeof URLSearchParams<"u"?URLSearchParams:Cu,X0=typeof FormData<"u"?FormData:null,J0=typeof Blob<"u"?Blob:null,Z0={isBrowser:!0,classes:{URLSearchParams:K0,FormData:X0,Blob:J0},protocols:["http","https","file","blob","url","data"]},Eu=typeof window<"u"&&typeof document<"u",au=typeof navigator=="object"&&navigator||void 0,ex=Eu&&(!au||["ReactNative","NativeScript","NS"].indexOf(au.product)<0),tx=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",nx=Eu&&window.location.href||"http://localhost",rx=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:Eu,hasStandardBrowserEnv:ex,hasStandardBrowserWebWorkerEnv:tx,navigator:au,origin:nx},Symbol.toStringTag,{value:"Module"})),it={...rx,...Z0};function ox(n,i){return $s(n,new it.classes.URLSearchParams,Object.assign({visitor:function(s,l,c,d){return it.isNode&&O.isBuffer(s)?(this.append(l,s.toString("base64")),!1):d.defaultVisitor.apply(this,arguments)}},i))}function ix(n){return O.matchAll(/\w+|\[(\w*)]/g,n).map(i=>i[0]==="[]"?"":i[1]||i[0])}function sx(n){const i={},s=Object.keys(n);let l;const c=s.length;let d;for(l=0;l=s.length;return f=!f&&O.isArray(c)?c.length:f,x?(O.hasOwnProp(c,f)?c[f]=[c[f],l]:c[f]=l,!m):((!c[f]||!O.isObject(c[f]))&&(c[f]=[]),i(s,l,c[f],d)&&O.isArray(c[f])&&(c[f]=sx(c[f])),!m)}if(O.isFormData(n)&&O.isFunction(n.entries)){const s={};return O.forEachEntry(n,(l,c)=>{i(ix(l),c,s,0)}),s}return null}function lx(n,i,s){if(O.isString(n))try{return(i||JSON.parse)(n),O.trim(n)}catch(l){if(l.name!=="SyntaxError")throw l}return(0,JSON.stringify)(n)}const zo={transitional:yh,adapter:["xhr","http","fetch"],transformRequest:[function(i,s){const l=s.getContentType()||"",c=l.indexOf("application/json")>-1,d=O.isObject(i);if(d&&O.isHTMLForm(i)&&(i=new FormData(i)),O.isFormData(i))return c?JSON.stringify(xh(i)):i;if(O.isArrayBuffer(i)||O.isBuffer(i)||O.isStream(i)||O.isFile(i)||O.isBlob(i)||O.isReadableStream(i))return i;if(O.isArrayBufferView(i))return i.buffer;if(O.isURLSearchParams(i))return s.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),i.toString();let m;if(d){if(l.indexOf("application/x-www-form-urlencoded")>-1)return ox(i,this.formSerializer).toString();if((m=O.isFileList(i))||l.indexOf("multipart/form-data")>-1){const x=this.env&&this.env.FormData;return $s(m?{"files[]":i}:i,x&&new x,this.formSerializer)}}return d||c?(s.setContentType("application/json",!1),lx(i)):i}],transformResponse:[function(i){const s=this.transitional||zo.transitional,l=s&&s.forcedJSONParsing,c=this.responseType==="json";if(O.isResponse(i)||O.isReadableStream(i))return i;if(i&&O.isString(i)&&(l&&!this.responseType||c)){const f=!(s&&s.silentJSONParsing)&&c;try{return JSON.parse(i)}catch(m){if(f)throw m.name==="SyntaxError"?me.from(m,me.ERR_BAD_RESPONSE,this,null,this.response):m}}return i}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:it.classes.FormData,Blob:it.classes.Blob},validateStatus:function(i){return i>=200&&i<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};O.forEach(["delete","get","head","post","put","patch"],n=>{zo.headers[n]={}});const ax=O.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),ux=n=>{const i={};let s,l,c;return n&&n.split(` +`).forEach(function(f){c=f.indexOf(":"),s=f.substring(0,c).trim().toLowerCase(),l=f.substring(c+1).trim(),!(!s||i[s]&&ax[s])&&(s==="set-cookie"?i[s]?i[s].push(l):i[s]=[l]:i[s]=i[s]?i[s]+", "+l:l)}),i},tp=Symbol("internals");function Eo(n){return n&&String(n).trim().toLowerCase()}function fs(n){return n===!1||n==null?n:O.isArray(n)?n.map(fs):String(n)}function cx(n){const i=Object.create(null),s=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let l;for(;l=s.exec(n);)i[l[1]]=l[2];return i}const dx=n=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(n.trim());function za(n,i,s,l,c){if(O.isFunction(l))return l.call(this,i,s);if(c&&(i=s),!!O.isString(i)){if(O.isString(l))return i.indexOf(l)!==-1;if(O.isRegExp(l))return l.test(i)}}function fx(n){return n.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(i,s,l)=>s.toUpperCase()+l)}function px(n,i){const s=O.toCamelCase(" "+i);["get","set","has"].forEach(l=>{Object.defineProperty(n,l+s,{value:function(c,d,f){return this[l].call(this,i,c,d,f)},configurable:!0})})}class mt{constructor(i){i&&this.set(i)}set(i,s,l){const c=this;function d(m,x,y){const S=Eo(x);if(!S)throw new Error("header name must be a non-empty string");const j=O.findKey(c,S);(!j||c[j]===void 0||y===!0||y===void 0&&c[j]!==!1)&&(c[j||x]=fs(m))}const f=(m,x)=>O.forEach(m,(y,S)=>d(y,S,x));if(O.isPlainObject(i)||i instanceof this.constructor)f(i,s);else if(O.isString(i)&&(i=i.trim())&&!dx(i))f(ux(i),s);else if(O.isHeaders(i))for(const[m,x]of i.entries())d(x,m,l);else i!=null&&d(s,i,l);return this}get(i,s){if(i=Eo(i),i){const l=O.findKey(this,i);if(l){const c=this[l];if(!s)return c;if(s===!0)return cx(c);if(O.isFunction(s))return s.call(this,c,l);if(O.isRegExp(s))return s.exec(c);throw new TypeError("parser must be boolean|regexp|function")}}}has(i,s){if(i=Eo(i),i){const l=O.findKey(this,i);return!!(l&&this[l]!==void 0&&(!s||za(this,this[l],l,s)))}return!1}delete(i,s){const l=this;let c=!1;function d(f){if(f=Eo(f),f){const m=O.findKey(l,f);m&&(!s||za(l,l[m],m,s))&&(delete l[m],c=!0)}}return O.isArray(i)?i.forEach(d):d(i),c}clear(i){const s=Object.keys(this);let l=s.length,c=!1;for(;l--;){const d=s[l];(!i||za(this,this[d],d,i,!0))&&(delete this[d],c=!0)}return c}normalize(i){const s=this,l={};return O.forEach(this,(c,d)=>{const f=O.findKey(l,d);if(f){s[f]=fs(c),delete s[d];return}const m=i?fx(d):String(d).trim();m!==d&&delete s[d],s[m]=fs(c),l[m]=!0}),this}concat(...i){return this.constructor.concat(this,...i)}toJSON(i){const s=Object.create(null);return O.forEach(this,(l,c)=>{l!=null&&l!==!1&&(s[c]=i&&O.isArray(l)?l.join(", "):l)}),s}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([i,s])=>i+": "+s).join(` +`)}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(i){return i instanceof this?i:new this(i)}static concat(i,...s){const l=new this(i);return s.forEach(c=>l.set(c)),l}static accessor(i){const l=(this[tp]=this[tp]={accessors:{}}).accessors,c=this.prototype;function d(f){const m=Eo(f);l[m]||(px(c,f),l[m]=!0)}return O.isArray(i)?i.forEach(d):d(i),this}}mt.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);O.reduceDescriptors(mt.prototype,({value:n},i)=>{let s=i[0].toUpperCase()+i.slice(1);return{get:()=>n,set(l){this[s]=l}}});O.freezeMethods(mt);function Ba(n,i){const s=this||zo,l=i||s,c=mt.from(l.headers);let d=l.data;return O.forEach(n,function(m){d=m.call(s,d,c.normalize(),i?i.status:void 0)}),c.normalize(),d}function vh(n){return!!(n&&n.__CANCEL__)}function Lr(n,i,s){me.call(this,n??"canceled",me.ERR_CANCELED,i,s),this.name="CanceledError"}O.inherits(Lr,me,{__CANCEL__:!0});function wh(n,i,s){const l=s.config.validateStatus;!s.status||!l||l(s.status)?n(s):i(new me("Request failed with status code "+s.status,[me.ERR_BAD_REQUEST,me.ERR_BAD_RESPONSE][Math.floor(s.status/100)-4],s.config,s.request,s))}function hx(n){const i=/^([-+\w]{1,25})(:?\/\/|:)/.exec(n);return i&&i[1]||""}function mx(n,i){n=n||10;const s=new Array(n),l=new Array(n);let c=0,d=0,f;return i=i!==void 0?i:1e3,function(x){const y=Date.now(),S=l[d];f||(f=y),s[c]=x,l[c]=y;let j=d,$=0;for(;j!==c;)$+=s[j++],j=j%n;if(c=(c+1)%n,c===d&&(d=(d+1)%n),y-f{s=S,c=null,d&&(clearTimeout(d),d=null),n.apply(null,y)};return[(...y)=>{const S=Date.now(),j=S-s;j>=l?f(y,S):(c=y,d||(d=setTimeout(()=>{d=null,f(c)},l-j)))},()=>c&&f(c)]}const ks=(n,i,s=3)=>{let l=0;const c=mx(50,250);return gx(d=>{const f=d.loaded,m=d.lengthComputable?d.total:void 0,x=f-l,y=c(x),S=f<=m;l=f;const j={loaded:f,total:m,progress:m?f/m:void 0,bytes:x,rate:y||void 0,estimated:y&&m&&S?(m-f)/y:void 0,event:d,lengthComputable:m!=null,[i?"download":"upload"]:!0};n(j)},s)},np=(n,i)=>{const s=n!=null;return[l=>i[0]({lengthComputable:s,total:n,loaded:l}),i[1]]},rp=n=>(...i)=>O.asap(()=>n(...i)),yx=it.hasStandardBrowserEnv?((n,i)=>s=>(s=new URL(s,it.origin),n.protocol===s.protocol&&n.host===s.host&&(i||n.port===s.port)))(new URL(it.origin),it.navigator&&/(msie|trident)/i.test(it.navigator.userAgent)):()=>!0,xx=it.hasStandardBrowserEnv?{write(n,i,s,l,c,d){const f=[n+"="+encodeURIComponent(i)];O.isNumber(s)&&f.push("expires="+new Date(s).toGMTString()),O.isString(l)&&f.push("path="+l),O.isString(c)&&f.push("domain="+c),d===!0&&f.push("secure"),document.cookie=f.join("; ")},read(n){const i=document.cookie.match(new RegExp("(^|;\\s*)("+n+")=([^;]*)"));return i?decodeURIComponent(i[3]):null},remove(n){this.write(n,"",Date.now()-864e5)}}:{write(){},read(){return null},remove(){}};function vx(n){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(n)}function wx(n,i){return i?n.replace(/\/?\/$/,"")+"/"+i.replace(/^\/+/,""):n}function Sh(n,i){return n&&!vx(i)?wx(n,i):i}const op=n=>n instanceof mt?{...n}:n;function Gn(n,i){i=i||{};const s={};function l(y,S,j,$){return O.isPlainObject(y)&&O.isPlainObject(S)?O.merge.call({caseless:$},y,S):O.isPlainObject(S)?O.merge({},S):O.isArray(S)?S.slice():S}function c(y,S,j,$){if(O.isUndefined(S)){if(!O.isUndefined(y))return l(void 0,y,j,$)}else return l(y,S,j,$)}function d(y,S){if(!O.isUndefined(S))return l(void 0,S)}function f(y,S){if(O.isUndefined(S)){if(!O.isUndefined(y))return l(void 0,y)}else return l(void 0,S)}function m(y,S,j){if(j in i)return l(y,S);if(j in n)return l(void 0,y)}const x={url:d,method:d,data:d,baseURL:f,transformRequest:f,transformResponse:f,paramsSerializer:f,timeout:f,timeoutMessage:f,withCredentials:f,withXSRFToken:f,adapter:f,responseType:f,xsrfCookieName:f,xsrfHeaderName:f,onUploadProgress:f,onDownloadProgress:f,decompress:f,maxContentLength:f,maxBodyLength:f,beforeRedirect:f,transport:f,httpAgent:f,httpsAgent:f,cancelToken:f,socketPath:f,responseEncoding:f,validateStatus:m,headers:(y,S,j)=>c(op(y),op(S),j,!0)};return O.forEach(Object.keys(Object.assign({},n,i)),function(S){const j=x[S]||c,$=j(n[S],i[S],S);O.isUndefined($)&&j!==m||(s[S]=$)}),s}const kh=n=>{const i=Gn({},n);let{data:s,withXSRFToken:l,xsrfHeaderName:c,xsrfCookieName:d,headers:f,auth:m}=i;i.headers=f=mt.from(f),i.url=gh(Sh(i.baseURL,i.url),n.params,n.paramsSerializer),m&&f.set("Authorization","Basic "+btoa((m.username||"")+":"+(m.password?unescape(encodeURIComponent(m.password)):"")));let x;if(O.isFormData(s)){if(it.hasStandardBrowserEnv||it.hasStandardBrowserWebWorkerEnv)f.setContentType(void 0);else if((x=f.getContentType())!==!1){const[y,...S]=x?x.split(";").map(j=>j.trim()).filter(Boolean):[];f.setContentType([y||"multipart/form-data",...S].join("; "))}}if(it.hasStandardBrowserEnv&&(l&&O.isFunction(l)&&(l=l(i)),l||l!==!1&&yx(i.url))){const y=c&&d&&xx.read(d);y&&f.set(c,y)}return i},Sx=typeof XMLHttpRequest<"u",kx=Sx&&function(n){return new Promise(function(s,l){const c=kh(n);let d=c.data;const f=mt.from(c.headers).normalize();let{responseType:m,onUploadProgress:x,onDownloadProgress:y}=c,S,j,$,I,k;function A(){I&&I(),k&&k(),c.cancelToken&&c.cancelToken.unsubscribe(S),c.signal&&c.signal.removeEventListener("abort",S)}let _=new XMLHttpRequest;_.open(c.method.toUpperCase(),c.url,!0),_.timeout=c.timeout;function J(){if(!_)return;const H=mt.from("getAllResponseHeaders"in _&&_.getAllResponseHeaders()),D={data:!m||m==="text"||m==="json"?_.responseText:_.response,status:_.status,statusText:_.statusText,headers:H,config:n,request:_};wh(function(Q){s(Q),A()},function(Q){l(Q),A()},D),_=null}"onloadend"in _?_.onloadend=J:_.onreadystatechange=function(){!_||_.readyState!==4||_.status===0&&!(_.responseURL&&_.responseURL.indexOf("file:")===0)||setTimeout(J)},_.onabort=function(){_&&(l(new me("Request aborted",me.ECONNABORTED,n,_)),_=null)},_.onerror=function(){l(new me("Network Error",me.ERR_NETWORK,n,_)),_=null},_.ontimeout=function(){let X=c.timeout?"timeout of "+c.timeout+"ms exceeded":"timeout exceeded";const D=c.transitional||yh;c.timeoutErrorMessage&&(X=c.timeoutErrorMessage),l(new me(X,D.clarifyTimeoutError?me.ETIMEDOUT:me.ECONNABORTED,n,_)),_=null},d===void 0&&f.setContentType(null),"setRequestHeader"in _&&O.forEach(f.toJSON(),function(X,D){_.setRequestHeader(D,X)}),O.isUndefined(c.withCredentials)||(_.withCredentials=!!c.withCredentials),m&&m!=="json"&&(_.responseType=c.responseType),y&&([$,k]=ks(y,!0),_.addEventListener("progress",$)),x&&_.upload&&([j,I]=ks(x),_.upload.addEventListener("progress",j),_.upload.addEventListener("loadend",I)),(c.cancelToken||c.signal)&&(S=H=>{_&&(l(!H||H.type?new Lr(null,n,_):H),_.abort(),_=null)},c.cancelToken&&c.cancelToken.subscribe(S),c.signal&&(c.signal.aborted?S():c.signal.addEventListener("abort",S)));const G=hx(c.url);if(G&&it.protocols.indexOf(G)===-1){l(new me("Unsupported protocol "+G+":",me.ERR_BAD_REQUEST,n));return}_.send(d||null)})},Cx=(n,i)=>{const{length:s}=n=n?n.filter(Boolean):[];if(i||s){let l=new AbortController,c;const d=function(y){if(!c){c=!0,m();const S=y instanceof Error?y:this.reason;l.abort(S instanceof me?S:new Lr(S instanceof Error?S.message:S))}};let f=i&&setTimeout(()=>{f=null,d(new me(`timeout ${i} of ms exceeded`,me.ETIMEDOUT))},i);const m=()=>{n&&(f&&clearTimeout(f),f=null,n.forEach(y=>{y.unsubscribe?y.unsubscribe(d):y.removeEventListener("abort",d)}),n=null)};n.forEach(y=>y.addEventListener("abort",d));const{signal:x}=l;return x.unsubscribe=()=>O.asap(m),x}},Ex=function*(n,i){let s=n.byteLength;if(s{const c=jx(n,i);let d=0,f,m=x=>{f||(f=!0,l&&l(x))};return new ReadableStream({async pull(x){try{const{done:y,value:S}=await c.next();if(y){m(),x.close();return}let j=S.byteLength;if(s){let $=d+=j;s($)}x.enqueue(new Uint8Array(S))}catch(y){throw m(y),y}},cancel(x){return m(x),c.return()}},{highWaterMark:2})},Os=typeof fetch=="function"&&typeof Request=="function"&&typeof Response=="function",Ch=Os&&typeof ReadableStream=="function",Rx=Os&&(typeof TextEncoder=="function"?(n=>i=>n.encode(i))(new TextEncoder):async n=>new Uint8Array(await new Response(n).arrayBuffer())),Eh=(n,...i)=>{try{return!!n(...i)}catch{return!1}},Px=Ch&&Eh(()=>{let n=!1;const i=new Request(it.origin,{body:new ReadableStream,method:"POST",get duplex(){return n=!0,"half"}}).headers.has("Content-Type");return n&&!i}),sp=64*1024,uu=Ch&&Eh(()=>O.isReadableStream(new Response("").body)),Cs={stream:uu&&(n=>n.body)};Os&&(n=>{["text","arrayBuffer","blob","formData","stream"].forEach(i=>{!Cs[i]&&(Cs[i]=O.isFunction(n[i])?s=>s[i]():(s,l)=>{throw new me(`Response type '${i}' is not supported`,me.ERR_NOT_SUPPORT,l)})})})(new Response);const Mx=async n=>{if(n==null)return 0;if(O.isBlob(n))return n.size;if(O.isSpecCompliantForm(n))return(await new Request(it.origin,{method:"POST",body:n}).arrayBuffer()).byteLength;if(O.isArrayBufferView(n)||O.isArrayBuffer(n))return n.byteLength;if(O.isURLSearchParams(n)&&(n=n+""),O.isString(n))return(await Rx(n)).byteLength},_x=async(n,i)=>{const s=O.toFiniteNumber(n.getContentLength());return s??Mx(i)},Tx=Os&&(async n=>{let{url:i,method:s,data:l,signal:c,cancelToken:d,timeout:f,onDownloadProgress:m,onUploadProgress:x,responseType:y,headers:S,withCredentials:j="same-origin",fetchOptions:$}=kh(n);y=y?(y+"").toLowerCase():"text";let I=Cx([c,d&&d.toAbortSignal()],f),k;const A=I&&I.unsubscribe&&(()=>{I.unsubscribe()});let _;try{if(x&&Px&&s!=="get"&&s!=="head"&&(_=await _x(S,l))!==0){let D=new Request(i,{method:"POST",body:l,duplex:"half"}),N;if(O.isFormData(l)&&(N=D.headers.get("content-type"))&&S.setContentType(N),D.body){const[Q,le]=np(_,ks(rp(x)));l=ip(D.body,sp,Q,le)}}O.isString(j)||(j=j?"include":"omit");const J="credentials"in Request.prototype;k=new Request(i,{...$,signal:I,method:s.toUpperCase(),headers:S.normalize().toJSON(),body:l,duplex:"half",credentials:J?j:void 0});let G=await fetch(k);const H=uu&&(y==="stream"||y==="response");if(uu&&(m||H&&A)){const D={};["status","statusText","headers"].forEach(Se=>{D[Se]=G[Se]});const N=O.toFiniteNumber(G.headers.get("content-length")),[Q,le]=m&&np(N,ks(rp(m),!0))||[];G=new Response(ip(G.body,sp,Q,()=>{le&&le(),A&&A()}),D)}y=y||"text";let X=await Cs[O.findKey(Cs,y)||"text"](G,n);return!H&&A&&A(),await new Promise((D,N)=>{wh(D,N,{data:X,headers:mt.from(G.headers),status:G.status,statusText:G.statusText,config:n,request:k})})}catch(J){throw A&&A(),J&&J.name==="TypeError"&&/fetch/i.test(J.message)?Object.assign(new me("Network Error",me.ERR_NETWORK,n,k),{cause:J.cause||J}):me.from(J,J&&J.code,n,k)}}),cu={http:W0,xhr:kx,fetch:Tx};O.forEach(cu,(n,i)=>{if(n){try{Object.defineProperty(n,"name",{value:i})}catch{}Object.defineProperty(n,"adapterName",{value:i})}});const lp=n=>`- ${n}`,Nx=n=>O.isFunction(n)||n===null||n===!1,jh={getAdapter:n=>{n=O.isArray(n)?n:[n];const{length:i}=n;let s,l;const c={};for(let d=0;d`adapter ${m} `+(x===!1?"is not supported by the environment":"is not available in the build"));let f=i?d.length>1?`since : +`+d.map(lp).join(` +`):" "+lp(d[0]):"as no adapter specified";throw new me("There is no suitable adapter to dispatch the request "+f,"ERR_NOT_SUPPORT")}return l},adapters:cu};function Fa(n){if(n.cancelToken&&n.cancelToken.throwIfRequested(),n.signal&&n.signal.aborted)throw new Lr(null,n)}function ap(n){return Fa(n),n.headers=mt.from(n.headers),n.data=Ba.call(n,n.transformRequest),["post","put","patch"].indexOf(n.method)!==-1&&n.headers.setContentType("application/x-www-form-urlencoded",!1),jh.getAdapter(n.adapter||zo.adapter)(n).then(function(l){return Fa(n),l.data=Ba.call(n,n.transformResponse,l),l.headers=mt.from(l.headers),l},function(l){return vh(l)||(Fa(n),l&&l.response&&(l.response.data=Ba.call(n,n.transformResponse,l.response),l.response.headers=mt.from(l.response.headers))),Promise.reject(l)})}const Ah="1.7.9",Ls={};["object","boolean","number","function","string","symbol"].forEach((n,i)=>{Ls[n]=function(l){return typeof l===n||"a"+(i<1?"n ":" ")+n}});const up={};Ls.transitional=function(i,s,l){function c(d,f){return"[Axios v"+Ah+"] Transitional option '"+d+"'"+f+(l?". "+l:"")}return(d,f,m)=>{if(i===!1)throw new me(c(f," has been removed"+(s?" in "+s:"")),me.ERR_DEPRECATED);return s&&!up[f]&&(up[f]=!0,console.warn(c(f," has been deprecated since v"+s+" and will be removed in the near future"))),i?i(d,f,m):!0}};Ls.spelling=function(i){return(s,l)=>(console.warn(`${l} is likely a misspelling of ${i}`),!0)};function $x(n,i,s){if(typeof n!="object")throw new me("options must be an object",me.ERR_BAD_OPTION_VALUE);const l=Object.keys(n);let c=l.length;for(;c-- >0;){const d=l[c],f=i[d];if(f){const m=n[d],x=m===void 0||f(m,d,n);if(x!==!0)throw new me("option "+d+" must be "+x,me.ERR_BAD_OPTION_VALUE);continue}if(s!==!0)throw new me("Unknown option "+d,me.ERR_BAD_OPTION)}}const ps={assertOptions:$x,validators:Ls},Vt=ps.validators;class Wn{constructor(i){this.defaults=i,this.interceptors={request:new ep,response:new ep}}async request(i,s){try{return await this._request(i,s)}catch(l){if(l instanceof Error){let c={};Error.captureStackTrace?Error.captureStackTrace(c):c=new Error;const d=c.stack?c.stack.replace(/^.+\n/,""):"";try{l.stack?d&&!String(l.stack).endsWith(d.replace(/^.+\n.+\n/,""))&&(l.stack+=` +`+d):l.stack=d}catch{}}throw l}}_request(i,s){typeof i=="string"?(s=s||{},s.url=i):s=i||{},s=Gn(this.defaults,s);const{transitional:l,paramsSerializer:c,headers:d}=s;l!==void 0&&ps.assertOptions(l,{silentJSONParsing:Vt.transitional(Vt.boolean),forcedJSONParsing:Vt.transitional(Vt.boolean),clarifyTimeoutError:Vt.transitional(Vt.boolean)},!1),c!=null&&(O.isFunction(c)?s.paramsSerializer={serialize:c}:ps.assertOptions(c,{encode:Vt.function,serialize:Vt.function},!0)),ps.assertOptions(s,{baseUrl:Vt.spelling("baseURL"),withXsrfToken:Vt.spelling("withXSRFToken")},!0),s.method=(s.method||this.defaults.method||"get").toLowerCase();let f=d&&O.merge(d.common,d[s.method]);d&&O.forEach(["delete","get","head","post","put","patch","common"],k=>{delete d[k]}),s.headers=mt.concat(f,d);const m=[];let x=!0;this.interceptors.request.forEach(function(A){typeof A.runWhen=="function"&&A.runWhen(s)===!1||(x=x&&A.synchronous,m.unshift(A.fulfilled,A.rejected))});const y=[];this.interceptors.response.forEach(function(A){y.push(A.fulfilled,A.rejected)});let S,j=0,$;if(!x){const k=[ap.bind(this),void 0];for(k.unshift.apply(k,m),k.push.apply(k,y),$=k.length,S=Promise.resolve(s);j<$;)S=S.then(k[j++],k[j++]);return S}$=m.length;let I=s;for(j=0;j<$;){const k=m[j++],A=m[j++];try{I=k(I)}catch(_){A.call(this,_);break}}try{S=ap.call(this,I)}catch(k){return Promise.reject(k)}for(j=0,$=y.length;j<$;)S=S.then(y[j++],y[j++]);return S}getUri(i){i=Gn(this.defaults,i);const s=Sh(i.baseURL,i.url);return gh(s,i.params,i.paramsSerializer)}}O.forEach(["delete","get","head","options"],function(i){Wn.prototype[i]=function(s,l){return this.request(Gn(l||{},{method:i,url:s,data:(l||{}).data}))}});O.forEach(["post","put","patch"],function(i){function s(l){return function(d,f,m){return this.request(Gn(m||{},{method:i,headers:l?{"Content-Type":"multipart/form-data"}:{},url:d,data:f}))}}Wn.prototype[i]=s(),Wn.prototype[i+"Form"]=s(!0)});class ju{constructor(i){if(typeof i!="function")throw new TypeError("executor must be a function.");let s;this.promise=new Promise(function(d){s=d});const l=this;this.promise.then(c=>{if(!l._listeners)return;let d=l._listeners.length;for(;d-- >0;)l._listeners[d](c);l._listeners=null}),this.promise.then=c=>{let d;const f=new Promise(m=>{l.subscribe(m),d=m}).then(c);return f.cancel=function(){l.unsubscribe(d)},f},i(function(d,f,m){l.reason||(l.reason=new Lr(d,f,m),s(l.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(i){if(this.reason){i(this.reason);return}this._listeners?this._listeners.push(i):this._listeners=[i]}unsubscribe(i){if(!this._listeners)return;const s=this._listeners.indexOf(i);s!==-1&&this._listeners.splice(s,1)}toAbortSignal(){const i=new AbortController,s=l=>{i.abort(l)};return this.subscribe(s),i.signal.unsubscribe=()=>this.unsubscribe(s),i.signal}static source(){let i;return{token:new ju(function(c){i=c}),cancel:i}}}function Ox(n){return function(s){return n.apply(null,s)}}function Lx(n){return O.isObject(n)&&n.isAxiosError===!0}const du={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511};Object.entries(du).forEach(([n,i])=>{du[i]=n});function Rh(n){const i=new Wn(n),s=oh(Wn.prototype.request,i);return O.extend(s,Wn.prototype,i,{allOwnKeys:!0}),O.extend(s,i,null,{allOwnKeys:!0}),s.create=function(c){return Rh(Gn(n,c))},s}const ze=Rh(zo);ze.Axios=Wn;ze.CanceledError=Lr;ze.CancelToken=ju;ze.isCancel=vh;ze.VERSION=Ah;ze.toFormData=$s;ze.AxiosError=me;ze.Cancel=ze.CanceledError;ze.all=function(i){return Promise.all(i)};ze.spread=Ox;ze.isAxiosError=Lx;ze.mergeConfig=Gn;ze.AxiosHeaders=mt;ze.formToJSON=n=>xh(O.isHTMLForm(n)?new FormData(n):n);ze.getAdapter=jh.getAdapter;ze.HttpStatusCode=du;ze.default=ze;const Ph={apiBaseUrl:"/api"};class Dx{constructor(){kf(this,"events",{})}on(i,s){return this.events[i]||(this.events[i]=[]),this.events[i].push(s),()=>this.off(i,s)}off(i,s){this.events[i]&&(this.events[i]=this.events[i].filter(l=>l!==s))}emit(i,s){this.events[i]&&this.events[i].forEach(l=>{l(s)})}}const jr=new Dx,Ix=async(n,i)=>{const s=new FormData;return s.append("username",n),s.append("password",i),(await Bo.post("/auth/login",s,{headers:{"Content-Type":"multipart/form-data"}})).data},bx=async n=>(await Bo.post("/users",n,{headers:{"Content-Type":"multipart/form-data"}})).data,zx=async()=>{await Bo.get("/auth/csrf-token")},Bx=async()=>{await Bo.post("/auth/logout")},Fx=async()=>(await Bo.post("/auth/refresh")).data,Ux=async(n,i)=>{const s={userId:n,newRole:i};return(await Le.put("/auth/role",s)).data},et=Kn((n,i)=>({currentUser:null,accessToken:null,login:async(s,l)=>{const{userDto:c,accessToken:d}=await Ix(s,l);await i().fetchCsrfToken(),n({currentUser:c,accessToken:d})},logout:async()=>{await Bx(),i().clear(),i().fetchCsrfToken()},fetchCsrfToken:async()=>{await zx()},refreshToken:async()=>{i().clear();const{userDto:s,accessToken:l}=await Fx();n({currentUser:s,accessToken:l})},clear:()=>{n({currentUser:null,accessToken:null})},updateUserRole:async(s,l)=>{await Ux(s,l)}}));let jo=[],Zi=!1;const Le=ze.create({baseURL:Ph.apiBaseUrl,headers:{"Content-Type":"application/json"},withCredentials:!0}),Bo=ze.create({baseURL:Ph.apiBaseUrl,headers:{"Content-Type":"application/json"},withCredentials:!0});Le.interceptors.request.use(n=>{const i=et.getState().accessToken;return i&&(n.headers.Authorization=`Bearer ${i}`),n},n=>Promise.reject(n));Le.interceptors.response.use(n=>n,async n=>{var s,l,c,d;const i=(s=n.response)==null?void 0:s.data;if(i){const f=(c=(l=n.response)==null?void 0:l.headers)==null?void 0:c["discodeit-request-id"];f&&(i.requestId=f),n.response.data=i}if(console.log({error:n,errorResponse:i}),jr.emit("api-error",{error:n,alert:((d=n.response)==null?void 0:d.status)===403}),n.response&&n.response.status===401){const f=n.config;if(f&&f.headers&&f.headers._retry)return jr.emit("auth-error"),Promise.reject(n);if(Zi&&f)return new Promise((m,x)=>{jo.push({config:f,resolve:m,reject:x})});if(f){Zi=!0;try{return await et.getState().refreshToken(),jo.forEach(({config:m,resolve:x,reject:y})=>{m.headers=m.headers||{},m.headers._retry="true",Le(m).then(x).catch(y)}),f.headers=f.headers||{},f.headers._retry="true",jo=[],Zi=!1,Le(f)}catch(m){return jo.forEach(({reject:x})=>x(m)),jo=[],Zi=!1,jr.emit("auth-error"),Promise.reject(m)}}}return Promise.reject(n)});const Hx=async(n,i)=>(await Le.patch(`/users/${n}`,i,{headers:{"Content-Type":"multipart/form-data"}})).data,Yx=async()=>(await Le.get("/users")).data,Nr=Kn(n=>({users:[],fetchUsers:async()=>{try{const i=await Yx();n({users:i})}catch(i){console.error("사용자 목록 조회 실패:",i)}}})),ee={colors:{brand:{primary:"#5865F2",hover:"#4752C4"},background:{primary:"#1a1a1a",secondary:"#2a2a2a",tertiary:"#333333",input:"#40444B",hover:"rgba(255, 255, 255, 0.1)"},text:{primary:"#ffffff",secondary:"#cccccc",muted:"#999999"},status:{online:"#43b581",idle:"#faa61a",dnd:"#f04747",offline:"#747f8d",error:"#ED4245"},border:{primary:"#404040"}}},Mh=E.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,_h=E.div` + background: ${ee.colors.background.primary}; + padding: 32px; + border-radius: 8px; + width: 440px; + + h2 { + color: ${ee.colors.text.primary}; + margin-bottom: 24px; + font-size: 24px; + font-weight: bold; + } + + form { + display: flex; + flex-direction: column; + gap: 16px; + } +`,To=E.input` + width: 100%; + padding: 10px; + border-radius: 4px; + background: ${ee.colors.background.input}; + border: none; + color: ${ee.colors.text.primary}; + font-size: 16px; + + &::placeholder { + color: ${ee.colors.text.muted}; + } + + &:focus { + outline: none; + } +`;E.input.attrs({type:"checkbox"})` + width: 16px; + height: 16px; + padding: 0; + border-radius: 4px; + background: ${ee.colors.background.input}; + border: none; + color: ${ee.colors.text.primary}; + cursor: pointer; + + &:focus { + outline: none; + } + + &:checked { + background: ${ee.colors.brand.primary}; + } +`;const Th=E.button` + width: 100%; + padding: 12px; + border-radius: 4px; + background: ${ee.colors.brand.primary}; + color: white; + font-size: 16px; + font-weight: 500; + border: none; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: ${ee.colors.brand.hover}; + } +`,Nh=E.div` + color: ${ee.colors.status.error}; + font-size: 14px; + text-align: center; +`,Vx=E.p` + text-align: center; + margin-top: 16px; + color: ${({theme:n})=>n.colors.text.muted}; + font-size: 14px; +`,Wx=E.span` + color: ${({theme:n})=>n.colors.brand.primary}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`,es=E.div` + margin-bottom: 20px; +`,ts=E.label` + display: block; + color: ${({theme:n})=>n.colors.text.muted}; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +`,Ua=E.span` + color: ${({theme:n})=>n.colors.status.error}; +`,qx=E.div` + display: flex; + flex-direction: column; + align-items: center; + margin: 10px 0; +`,Qx=E.img` + width: 80px; + height: 80px; + border-radius: 50%; + margin-bottom: 10px; + object-fit: cover; +`,Gx=E.input` + display: none; +`,Kx=E.label` + color: ${({theme:n})=>n.colors.brand.primary}; + cursor: pointer; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +`,Xx=E.span` + color: ${({theme:n})=>n.colors.brand.primary}; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +`,Jx=E(Xx)` + display: block; + text-align: center; + margin-top: 16px; +`,Ct="data:image/png;base64,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",Zx=({isOpen:n,onClose:i})=>{const[s,l]=Z.useState(""),[c,d]=Z.useState(""),[f,m]=Z.useState(""),[x,y]=Z.useState(null),[S,j]=Z.useState(null),[$,I]=Z.useState(""),{fetchCsrfToken:k}=et(),A=Z.useCallback(()=>{S&&URL.revokeObjectURL(S),j(null),y(null),l(""),d(""),m(""),I("")},[S]),_=Z.useCallback(()=>{A(),i()},[]),J=H=>{var D;const X=(D=H.target.files)==null?void 0:D[0];if(X){y(X);const N=new FileReader;N.onloadend=()=>{j(N.result)},N.readAsDataURL(X)}},G=async H=>{H.preventDefault(),I("");try{const X=new FormData;X.append("userCreateRequest",new Blob([JSON.stringify({email:s,username:c,password:f})],{type:"application/json"})),x&&X.append("profile",x),await bx(X),await k(),i()}catch{I("회원가입에 실패했습니다.")}};return n?h.jsx(Mh,{children:h.jsxs(_h,{children:[h.jsx("h2",{children:"계정 만들기"}),h.jsxs("form",{onSubmit:G,children:[h.jsxs(es,{children:[h.jsxs(ts,{children:["이메일 ",h.jsx(Ua,{children:"*"})]}),h.jsx(To,{type:"email",value:s,onChange:H=>l(H.target.value),required:!0})]}),h.jsxs(es,{children:[h.jsxs(ts,{children:["사용자명 ",h.jsx(Ua,{children:"*"})]}),h.jsx(To,{type:"text",value:c,onChange:H=>d(H.target.value),required:!0})]}),h.jsxs(es,{children:[h.jsxs(ts,{children:["비밀번호 ",h.jsx(Ua,{children:"*"})]}),h.jsx(To,{type:"password",value:f,onChange:H=>m(H.target.value),required:!0})]}),h.jsxs(es,{children:[h.jsx(ts,{children:"프로필 이미지"}),h.jsxs(qx,{children:[h.jsx(Qx,{src:S||Ct,alt:"profile"}),h.jsx(Gx,{type:"file",accept:"image/*",onChange:J,id:"profile-image"}),h.jsx(Kx,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),$&&h.jsx(Nh,{children:$}),h.jsx(Th,{type:"submit",children:"계속하기"}),h.jsx(Jx,{onClick:_,children:"이미 계정이 있으신가요?"})]})]})}):null},ev=({isOpen:n,onClose:i})=>{const[s,l]=Z.useState(""),[c,d]=Z.useState(""),[f,m]=Z.useState(""),[x,y]=Z.useState(!1),{login:S}=et(),{fetchUsers:j}=Nr(),$=Z.useCallback(()=>{l(""),d(""),m(""),y(!1)},[]),I=Z.useCallback(()=>{$(),y(!0)},[$,i]),k=async()=>{var A;try{await S(s,c),await j(),$(),i()}catch(_){console.error("로그인 에러:",_),((A=_.response)==null?void 0:A.status)===401?m("아이디 또는 비밀번호가 올바르지 않습니다."):m("로그인에 실패했습니다.")}};return n?h.jsxs(h.Fragment,{children:[h.jsx(Mh,{children:h.jsxs(_h,{children:[h.jsx("h2",{children:"돌아오신 것을 환영해요!"}),h.jsxs("form",{onSubmit:A=>{A.preventDefault(),k()},children:[h.jsx(To,{type:"text",placeholder:"사용자 이름",value:s,onChange:A=>l(A.target.value)}),h.jsx(To,{type:"password",placeholder:"비밀번호",value:c,onChange:A=>d(A.target.value)}),f&&h.jsx(Nh,{children:f}),h.jsx(Th,{type:"submit",children:"로그인"})]}),h.jsxs(Vx,{children:["계정이 필요한가요? ",h.jsx(Wx,{onClick:I,children:"가입하기"})]})]})}),h.jsx(Zx,{isOpen:x,onClose:()=>y(!1)})]}):null},tv=async n=>(await Le.get(`/channels?userId=${n}`)).data,nv=async n=>(await Le.post("/channels/public",n)).data,rv=async n=>{const i={participantIds:n};return(await Le.post("/channels/private",i)).data},ov=async(n,i)=>(await Le.patch(`/channels/${n}`,i)).data,iv=async n=>{await Le.delete(`/channels/${n}`)},sv=async n=>(await Le.get("/readStatuses",{params:{userId:n}})).data,cp=async(n,{newLastReadAt:i,newNotificationEnabled:s})=>{const l={newLastReadAt:i,newNotificationEnabled:s};return(await Le.patch(`/readStatuses/${n}`,l)).data},lv=async(n,i,s)=>{const l={userId:n,channelId:i,lastReadAt:s};return(await Le.post("/readStatuses",l)).data},Ar=Kn((n,i)=>({readStatuses:{},fetchReadStatuses:async()=>{try{const{currentUser:s}=et.getState();if(!s)return;const c=(await sv(s.id)).reduce((d,f)=>(d[f.channelId]={id:f.id,lastReadAt:f.lastReadAt,notificationEnabled:f.notificationEnabled},d),{});n({readStatuses:c})}catch(s){console.error("읽음 상태 조회 실패:",s)}},updateReadStatus:async s=>{try{const{currentUser:l}=et.getState();if(!l)return;const c=i().readStatuses[s];let d;c?d=await cp(c.id,{newLastReadAt:new Date().toISOString(),newNotificationEnabled:null}):d=await lv(l.id,s,new Date().toISOString()),n(f=>({readStatuses:{...f.readStatuses,[s]:{id:d.id,lastReadAt:d.lastReadAt,notificationEnabled:d.notificationEnabled}}}))}catch(l){console.error("읽음 상태 업데이트 실패:",l)}},updateNotificationEnabled:async(s,l)=>{try{const{currentUser:c}=et.getState();if(!c)return;const d=i().readStatuses[s];let f;if(d)f=await cp(d.id,{newLastReadAt:null,newNotificationEnabled:l});else return;n(m=>({readStatuses:{...m.readStatuses,[s]:{id:f.id,lastReadAt:f.lastReadAt,notificationEnabled:f.notificationEnabled}}}))}catch(c){console.error("알림 상태 업데이트 실패:",c)}},hasUnreadMessages:(s,l)=>{const c=i().readStatuses[s],d=c==null?void 0:c.lastReadAt;return!d||new Date(l)>new Date(d)}})),An=Kn((n,i)=>({channels:[],pollingInterval:null,loading:!1,error:null,fetchChannels:async s=>{n({loading:!0,error:null});try{const l=await tv(s);n(d=>{const f=new Set(d.channels.map(S=>S.id)),m=l.filter(S=>!f.has(S.id));return{channels:[...d.channels.filter(S=>l.some(j=>j.id===S.id)),...m],loading:!1}});const{fetchReadStatuses:c}=Ar.getState();return c(),l}catch(l){return n({error:l,loading:!1}),[]}},startPolling:s=>{const l=i().pollingInterval;l&&clearInterval(l);const c=setInterval(()=>{i().fetchChannels(s)},3e3);n({pollingInterval:c})},stopPolling:()=>{const s=i().pollingInterval;s&&(clearInterval(s),n({pollingInterval:null}))},createPublicChannel:async s=>{try{const l=await nv(s);return n(c=>c.channels.some(f=>f.id===l.id)?c:{channels:[...c.channels,{...l,participantIds:[],lastMessageAt:new Date().toISOString()}]}),l}catch(l){throw console.error("공개 채널 생성 실패:",l),l}},createPrivateChannel:async s=>{try{const l=await rv(s);return n(c=>c.channels.some(f=>f.id===l.id)?c:{channels:[...c.channels,{...l,participantIds:s,lastMessageAt:new Date().toISOString()}]}),l}catch(l){throw console.error("비공개 채널 생성 실패:",l),l}},updatePublicChannel:async(s,l)=>{try{const c=await ov(s,l);return n(d=>({channels:d.channels.map(f=>f.id===s?{...f,...c}:f)})),c}catch(c){throw console.error("채널 수정 실패:",c),c}},deleteChannel:async s=>{try{await iv(s),n(l=>({channels:l.channels.filter(c=>c.id!==s)}))}catch(l){throw console.error("채널 삭제 실패:",l),l}}})),dp=async n=>(await Le.get(`/binaryContents/${n}`)).data,fp=async n=>({blob:(await Le.get(`/binaryContents/${n}/download`,{responseType:"blob"})).data});var jn=(n=>(n.USER="USER",n.CHANNEL_MANAGER="CHANNEL_MANAGER",n.ADMIN="ADMIN",n))(jn||{}),Cr=(n=>(n.PROCESSING="PROCESSING",n.SUCCESS="SUCCESS",n.FAIL="FAIL",n))(Cr||{});let Fn={};const Rn=Kn((n,i)=>({binaryContents:{},pollingIds:new Set,fetchBinaryContent:async s=>{if(i().binaryContents[s])return i().binaryContents[s];try{const l=await dp(s),{contentType:c,fileName:d,size:f,status:m}=l,x={contentType:c,fileName:d,size:f,status:m};if(m===Cr.SUCCESS){const y=await fp(s),S=URL.createObjectURL(y.blob);x.url=S,x.revokeUrl=()=>URL.revokeObjectURL(S)}return n(y=>({binaryContents:{...y.binaryContents,[s]:x}})),x}catch(l){return console.error("첨부파일 정보 조회 실패:",l),null}},startPolling:s=>{if(Fn[s])return;const l=setInterval(async()=>{try{const c=await dp(s),{status:d}=c;if(d===Cr.SUCCESS){console.log(`Polling: ${s} 상태가 SUCCESS로 변경됨`);const f=await fp(s),m=URL.createObjectURL(f.blob);n(x=>({binaryContents:{...x.binaryContents,[s]:{...x.binaryContents[s],url:m,status:Cr.SUCCESS,revokeUrl:()=>URL.revokeObjectURL(m)}}})),i().stopPolling(s)}else d===Cr.FAIL?(console.log(`Polling: ${s} 상태가 FAIL로 변경됨`),n(f=>({binaryContents:{...f.binaryContents,[s]:{...f.binaryContents[s],status:Cr.FAIL}}})),i().stopPolling(s)):console.log(`Polling: ${s} 상태가 여전히 PROCESSING임`)}catch(c){console.error("polling 중 오류:",c),i().stopPolling(s)}},2e3);Fn[s]=l,n(c=>({pollingIds:new Set([...c.pollingIds,s])}))},stopPolling:s=>{Fn[s]&&(clearInterval(Fn[s]),delete Fn[s]),n(l=>{const c=new Set(l.pollingIds);return c.delete(s),{pollingIds:c}})},clearAllPolling:()=>{Object.values(Fn).forEach(s=>{clearInterval(s)}),Fn={},n({pollingIds:new Set})},clearBinaryContent:s=>{const{binaryContents:l}=i(),c=l[s];c!=null&&c.revokeUrl&&(c.revokeUrl(),n(d=>{const{[s]:f,...m}=d.binaryContents;return{binaryContents:m}}))},clearBinaryContents:s=>{const{binaryContents:l}=i(),c=[];s.forEach(d=>{const f=l[d];f&&(f.revokeUrl&&f.revokeUrl(),c.push(d))}),c.length>0&&n(d=>{const f={...d.binaryContents};return c.forEach(m=>{delete f[m]}),{binaryContents:f}})},clearAllBinaryContents:()=>{const{binaryContents:s}=i();Object.values(s).forEach(l=>{l.revokeUrl&&l.revokeUrl()}),n({binaryContents:{}})}})),Fo=E.div` + position: absolute; + bottom: -3px; + right: -3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: ${n=>n.$online?ee.colors.status.online:ee.colors.status.offline}; + border: 4px solid ${n=>n.$background||ee.colors.background.secondary}; +`;E.div` + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; + background: ${n=>ee.colors.status[n.status||"offline"]||ee.colors.status.offline}; +`;const Dr=E.div` + position: relative; + width: ${n=>n.$size||"32px"}; + height: ${n=>n.$size||"32px"}; + flex-shrink: 0; + margin: ${n=>n.$margin||"0"}; +`,rn=E.img` + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + border: ${n=>n.$border||"none"}; +`;function av({isOpen:n,onClose:i,user:s}){var N,Q;const[l,c]=Z.useState(s.username),[d,f]=Z.useState(s.email),[m,x]=Z.useState(""),[y,S]=Z.useState(null),[j,$]=Z.useState(""),[I,k]=Z.useState(null),{binaryContents:A,fetchBinaryContent:_}=Rn(),{logout:J,refreshToken:G}=et();Z.useEffect(()=>{var le;(le=s.profile)!=null&&le.id&&!A[s.profile.id]&&_(s.profile.id)},[s.profile,A,_]);const H=()=>{c(s.username),f(s.email),x(""),S(null),k(null),$(""),i()},X=le=>{var ge;const Se=(ge=le.target.files)==null?void 0:ge[0];if(Se){S(Se);const pe=new FileReader;pe.onloadend=()=>{k(pe.result)},pe.readAsDataURL(Se)}},D=async le=>{le.preventDefault(),$("");try{const Se=new FormData,ge={};l!==s.username&&(ge.newUsername=l),d!==s.email&&(ge.newEmail=d),m&&(ge.newPassword=m),(Object.keys(ge).length>0||y)&&(Se.append("userUpdateRequest",new Blob([JSON.stringify(ge)],{type:"application/json"})),y&&Se.append("profile",y),await Hx(s.id,Se),await G()),i()}catch{$("사용자 정보 수정에 실패했습니다.")}};return n?h.jsx(uv,{children:h.jsxs(cv,{children:[h.jsx("h2",{children:"프로필 수정"}),h.jsxs("form",{onSubmit:D,children:[h.jsxs(ns,{children:[h.jsx(rs,{children:"프로필 이미지"}),h.jsxs(fv,{children:[h.jsx(pv,{src:I||((N=s.profile)!=null&&N.id?(Q=A[s.profile.id])==null?void 0:Q.url:void 0)||Ct,alt:"profile"}),h.jsx(hv,{type:"file",accept:"image/*",onChange:X,id:"profile-image"}),h.jsx(mv,{htmlFor:"profile-image",children:"이미지 변경"})]})]}),h.jsxs(ns,{children:[h.jsxs(rs,{children:["사용자명 ",h.jsx(hp,{children:"*"})]}),h.jsx(Ha,{type:"text",value:l,onChange:le=>c(le.target.value),required:!0})]}),h.jsxs(ns,{children:[h.jsxs(rs,{children:["이메일 ",h.jsx(hp,{children:"*"})]}),h.jsx(Ha,{type:"email",value:d,onChange:le=>f(le.target.value),required:!0})]}),h.jsxs(ns,{children:[h.jsx(rs,{children:"새 비밀번호"}),h.jsx(Ha,{type:"password",placeholder:"변경하지 않으려면 비워두세요",value:m,onChange:le=>x(le.target.value)})]}),j&&h.jsx(dv,{children:j}),h.jsxs(gv,{children:[h.jsx(pp,{type:"button",onClick:H,$secondary:!0,children:"취소"}),h.jsx(pp,{type:"submit",children:"저장"})]})]}),h.jsx(yv,{onClick:J,children:"로그아웃"})]})}):null}const uv=E.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,cv=E.div` + background: ${({theme:n})=>n.colors.background.secondary}; + padding: 32px; + border-radius: 5px; + width: 100%; + max-width: 480px; + + h2 { + color: ${({theme:n})=>n.colors.text.primary}; + margin-bottom: 24px; + text-align: center; + font-size: 24px; + } +`,Ha=E.input` + width: 100%; + padding: 10px; + margin-bottom: 10px; + border: none; + border-radius: 4px; + background: ${({theme:n})=>n.colors.background.input}; + color: ${({theme:n})=>n.colors.text.primary}; + + &::placeholder { + color: ${({theme:n})=>n.colors.text.muted}; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${({theme:n})=>n.colors.brand.primary}; + } +`,pp=E.button` + width: 100%; + padding: 10px; + border: none; + border-radius: 4px; + background: ${({$secondary:n,theme:i})=>n?"transparent":i.colors.brand.primary}; + color: ${({theme:n})=>n.colors.text.primary}; + cursor: pointer; + font-weight: 500; + + &:hover { + background: ${({$secondary:n,theme:i})=>n?i.colors.background.hover:i.colors.brand.hover}; + } +`,dv=E.div` + color: ${({theme:n})=>n.colors.status.error}; + font-size: 14px; + margin-bottom: 10px; +`,fv=E.div` + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 20px; +`,pv=E.img` + width: 100px; + height: 100px; + border-radius: 50%; + margin-bottom: 10px; + object-fit: cover; +`,hv=E.input` + display: none; +`,mv=E.label` + color: ${({theme:n})=>n.colors.brand.primary}; + cursor: pointer; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +`,gv=E.div` + display: flex; + gap: 10px; + margin-top: 20px; +`,yv=E.button` + width: 100%; + padding: 10px; + margin-top: 16px; + border: none; + border-radius: 4px; + background: transparent; + color: ${({theme:n})=>n.colors.status.error}; + cursor: pointer; + font-weight: 500; + + &:hover { + background: ${({theme:n})=>n.colors.status.error}20; + } +`,ns=E.div` + margin-bottom: 20px; +`,rs=E.label` + display: block; + color: ${({theme:n})=>n.colors.text.muted}; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +`,hp=E.span` + color: ${({theme:n})=>n.colors.status.error}; +`,xv=E.div` + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background-color: ${({theme:n})=>n.colors.background.tertiary}; + width: 100%; + height: 52px; +`,vv=E(Dr)``;E(rn)``;const wv=E.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +`,Sv=E.div` + font-weight: 500; + color: ${({theme:n})=>n.colors.text.primary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.875rem; + line-height: 1.2; +`,kv=E.div` + font-size: 0.75rem; + color: ${({theme:n})=>n.colors.text.secondary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +`,Cv=E.div` + display: flex; + align-items: center; + flex-shrink: 0; +`,Ev=E.button` + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: ${({theme:n})=>n.colors.text.secondary}; + font-size: 18px; + + &:hover { + color: ${({theme:n})=>n.colors.text.primary}; + } +`;function jv({user:n}){var d,f;const[i,s]=Z.useState(!1),{binaryContents:l,fetchBinaryContent:c}=Rn();return Z.useEffect(()=>{var m;(m=n.profile)!=null&&m.id&&!l[n.profile.id]&&c(n.profile.id)},[n.profile,l,c]),h.jsxs(h.Fragment,{children:[h.jsxs(xv,{children:[h.jsxs(vv,{children:[h.jsx(rn,{src:(d=n.profile)!=null&&d.id?(f=l[n.profile.id])==null?void 0:f.url:Ct,alt:n.username}),h.jsx(Fo,{$online:!0})]}),h.jsxs(wv,{children:[h.jsx(Sv,{children:n.username}),h.jsx(kv,{children:"온라인"})]}),h.jsx(Cv,{children:h.jsx(Ev,{onClick:()=>s(!0),children:"⚙️"})})]}),h.jsx(av,{isOpen:i,onClose:()=>s(!1),user:n})]})}const Av=E.div` + width: 240px; + background: ${ee.colors.background.secondary}; + border-right: 1px solid ${ee.colors.border.primary}; + display: flex; + flex-direction: column; +`,Rv=E.div` + flex: 1; + overflow-y: auto; +`,Pv=E.div` + padding: 16px; + font-size: 16px; + font-weight: bold; + color: ${ee.colors.text.primary}; +`,Au=E.div` + height: 34px; + padding: 0 8px; + margin: 1px 8px; + display: flex; + align-items: center; + gap: 6px; + color: ${n=>n.$hasUnread?n.theme.colors.text.primary:n.theme.colors.text.muted}; + font-weight: ${n=>n.$hasUnread?"600":"normal"}; + cursor: pointer; + background: ${n=>n.$isActive?n.theme.colors.background.hover:"transparent"}; + border-radius: 4px; + + &:hover { + background: ${n=>n.theme.colors.background.hover}; + color: ${n=>n.theme.colors.text.primary}; + } +`,mp=E.div` + margin-bottom: 8px; +`,fu=E.div` + padding: 8px 16px; + display: flex; + align-items: center; + color: ${ee.colors.text.muted}; + text-transform: uppercase; + font-size: 12px; + font-weight: 600; + cursor: pointer; + user-select: none; + + & > span:nth-child(2) { + flex: 1; + margin-right: auto; + } + + &:hover { + color: ${ee.colors.text.primary}; + } +`,gp=E.span` + margin-right: 4px; + font-size: 10px; + transition: transform 0.2s; + transform: rotate(${n=>n.$folded?"-90deg":"0deg"}); +`,yp=E.div` + display: ${n=>n.$folded?"none":"block"}; +`,pu=E(Au)` + height: ${n=>n.hasSubtext?"42px":"34px"}; +`,Mv=E(Dr)` + width: 32px; + height: 32px; + margin: 0 8px; +`,xp=E.div` + font-size: 16px; + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${n=>n.$isActive||n.$hasUnread?n.theme.colors.text.primary:n.theme.colors.text.muted}; + font-weight: ${n=>n.$hasUnread?"600":"normal"}; +`;E(Fo)` + border-color: ${ee.colors.background.primary}; +`;const vp=E.button` + background: none; + border: none; + color: ${ee.colors.text.muted}; + font-size: 18px; + padding: 0; + cursor: pointer; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s, color 0.2s; + + ${fu}:hover & { + opacity: 1; + } + + &:hover { + color: ${ee.colors.text.primary}; + } +`,_v=E(Dr)` + width: 40px; + height: 24px; + margin: 0 8px; +`,Tv=E.div` + font-size: 12px; + line-height: 13px; + color: ${ee.colors.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,wp=E.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; +`,$h=E.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,Oh=E.div` + background: ${ee.colors.background.primary}; + border-radius: 4px; + width: 440px; + max-width: 90%; +`,Lh=E.div` + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; +`,Dh=E.h2` + color: ${ee.colors.text.primary}; + font-size: 20px; + font-weight: 600; + margin: 0; +`,Ih=E.div` + padding: 0 16px 16px; +`,bh=E.form` + display: flex; + flex-direction: column; + gap: 16px; +`,No=E.div` + display: flex; + flex-direction: column; + gap: 8px; +`,$o=E.label` + color: ${ee.colors.text.primary}; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +`,zh=E.p` + color: ${ee.colors.text.muted}; + font-size: 14px; + margin: -4px 0 0; +`,Io=E.input` + padding: 10px; + background: ${ee.colors.background.tertiary}; + border: none; + border-radius: 3px; + color: ${ee.colors.text.primary}; + font-size: 16px; + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${ee.colors.status.online}; + } + + &::placeholder { + color: ${ee.colors.text.muted}; + } +`,Bh=E.button` + margin-top: 8px; + padding: 12px; + background: ${ee.colors.status.online}; + color: white; + border: none; + border-radius: 3px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #3ca374; + } +`,Fh=E.button` + background: none; + border: none; + color: ${ee.colors.text.muted}; + font-size: 24px; + cursor: pointer; + padding: 4px; + line-height: 1; + + &:hover { + color: ${ee.colors.text.primary}; + } +`,Nv=E(Io)` + margin-bottom: 8px; +`,$v=E.div` + max-height: 300px; + overflow-y: auto; + background: ${ee.colors.background.tertiary}; + border-radius: 4px; +`,Ov=E.div` + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: ${ee.colors.background.hover}; + } + + & + & { + border-top: 1px solid ${ee.colors.border.primary}; + } +`,Lv=E.input` + margin-right: 12px; + width: 16px; + height: 16px; + cursor: pointer; +`,Sp=E.img` + width: 32px; + height: 32px; + border-radius: 50%; + margin-right: 12px; +`,Dv=E.div` + flex: 1; + min-width: 0; +`,Iv=E.div` + color: ${ee.colors.text.primary}; + font-size: 14px; + font-weight: 500; +`,bv=E.div` + color: ${ee.colors.text.muted}; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,zv=E.div` + padding: 16px; + text-align: center; + color: ${ee.colors.text.muted}; +`,Uh=E.div` + color: ${ee.colors.status.error}; + font-size: 14px; + padding: 8px 0; + text-align: center; + background-color: ${({theme:n})=>n.colors.background.tertiary}; + border-radius: 4px; + margin-bottom: 8px; +`,Ya=E.div` + position: relative; + margin-left: auto; + z-index: 99999; +`,Va=E.button` + background: none; + border: none; + color: ${({theme:n})=>n.colors.text.muted}; + font-size: 16px; + cursor: pointer; + padding: 4px; + border-radius: 3px; + opacity: 0; + transition: opacity 0.2s, background 0.2s; + + &:hover { + background: ${({theme:n})=>n.colors.background.hover}; + color: ${({theme:n})=>n.colors.text.primary}; + } + + ${Au}:hover &, + ${pu}:hover & { + opacity: 1; + } +`,Wa=E.div` + position: absolute; + top: 100%; + right: 0; + background: ${({theme:n})=>n.colors.background.primary}; + border: 1px solid ${({theme:n})=>n.colors.border.primary}; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + min-width: 120px; + z-index: 100000; +`,os=E.div` + padding: 8px 12px; + color: ${({theme:n})=>n.colors.text.primary}; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + + &:hover { + background: ${({theme:n})=>n.colors.background.hover}; + } + + &:first-child { + border-radius: 4px 4px 0 0; + } + + &:last-child { + border-radius: 0 0 4px 4px; + } + + &:only-child { + border-radius: 4px; + } +`;function Bv(){return h.jsx(Pv,{children:"채널 목록"})}function Fv({isOpen:n,channel:i,onClose:s,onUpdateSuccess:l}){const[c,d]=Z.useState({name:"",description:""}),[f,m]=Z.useState(""),[x,y]=Z.useState(!1),{updatePublicChannel:S}=An();Z.useEffect(()=>{i&&n&&(d({name:i.name||"",description:i.description||""}),m(""))},[i,n]);const j=I=>{const{name:k,value:A}=I.target;d(_=>({..._,[k]:A}))},$=async I=>{var k,A;if(I.preventDefault(),!!i){m(""),y(!0);try{if(!c.name.trim()){m("채널 이름을 입력해주세요."),y(!1);return}const _={newName:c.name.trim(),newDescription:c.description.trim()},J=await S(i.id,_);l(J)}catch(_){console.error("채널 수정 실패:",_),m(((A=(k=_.response)==null?void 0:k.data)==null?void 0:A.message)||"채널 수정에 실패했습니다. 다시 시도해주세요.")}finally{y(!1)}}};return!n||!i||i.type!=="PUBLIC"?null:h.jsx($h,{onClick:s,children:h.jsxs(Oh,{onClick:I=>I.stopPropagation(),children:[h.jsxs(Lh,{children:[h.jsx(Dh,{children:"채널 수정"}),h.jsx(Fh,{onClick:s,children:"×"})]}),h.jsx(Ih,{children:h.jsxs(bh,{onSubmit:$,children:[f&&h.jsx(Uh,{children:f}),h.jsxs(No,{children:[h.jsx($o,{children:"채널 이름"}),h.jsx(Io,{name:"name",value:c.name,onChange:j,placeholder:"새로운-채널",required:!0,disabled:x})]}),h.jsxs(No,{children:[h.jsx($o,{children:"채널 설명"}),h.jsx(zh,{children:"이 채널의 주제를 설명해주세요."}),h.jsx(Io,{name:"description",value:c.description,onChange:j,placeholder:"채널 설명을 입력하세요",disabled:x})]}),h.jsx(Bh,{type:"submit",disabled:x,children:x?"수정 중...":"채널 수정"})]})})]})})}function kp({channel:n,isActive:i,onClick:s,hasUnread:l}){var G;const{currentUser:c}=et(),{binaryContents:d}=Rn(),{deleteChannel:f}=An(),[m,x]=Z.useState(null),[y,S]=Z.useState(!1),j=(c==null?void 0:c.role)===jn.ADMIN||(c==null?void 0:c.role)===jn.CHANNEL_MANAGER;Z.useEffect(()=>{const H=()=>{m&&x(null)};if(m)return document.addEventListener("click",H),()=>document.removeEventListener("click",H)},[m]);const $=H=>{x(m===H?null:H)},I=()=>{x(null),S(!0)},k=H=>{S(!1),console.log("Channel updated successfully:",H)},A=()=>{S(!1)},_=async H=>{var D;x(null);const X=n.type==="PUBLIC"?n.name:n.type==="PRIVATE"&&n.participants.length>2?`그룹 채팅 (멤버 ${n.participants.length}명)`:((D=n.participants.filter(N=>N.id!==(c==null?void 0:c.id))[0])==null?void 0:D.username)||"1:1 채팅";if(confirm(`"${X}" 채널을 삭제하시겠습니까?`))try{await f(H),console.log("Channel deleted successfully:",H)}catch(N){console.error("Channel delete failed:",N),alert("채널 삭제에 실패했습니다. 다시 시도해주세요.")}};let J;if(n.type==="PUBLIC")J=h.jsxs(Au,{$isActive:i,onClick:s,$hasUnread:l,children:["# ",n.name,j&&h.jsxs(Ya,{children:[h.jsx(Va,{onClick:H=>{H.stopPropagation(),$(n.id)},children:"⋯"}),m===n.id&&h.jsxs(Wa,{onClick:H=>H.stopPropagation(),children:[h.jsx(os,{onClick:()=>I(),children:"✏️ 수정"}),h.jsx(os,{onClick:()=>_(n.id),children:"🗑️ 삭제"})]})]})]});else{const H=n.participants;if(H.length>2){const X=H.filter(D=>D.id!==(c==null?void 0:c.id)).map(D=>D.username).join(", ");J=h.jsxs(pu,{$isActive:i,onClick:s,children:[h.jsx(_v,{children:H.filter(D=>D.id!==(c==null?void 0:c.id)).slice(0,2).map((D,N)=>{var Q;return h.jsx(rn,{src:D.profile?(Q=d[D.profile.id])==null?void 0:Q.url:Ct,style:{position:"absolute",left:N*16,zIndex:2-N,width:"24px",height:"24px",border:"2px solid #2a2a2a"}},D.id)})}),h.jsxs(wp,{children:[h.jsx(xp,{$hasUnread:l,children:X}),h.jsxs(Tv,{children:["멤버 ",H.length,"명"]})]}),j&&h.jsxs(Ya,{children:[h.jsx(Va,{onClick:D=>{D.stopPropagation(),$(n.id)},children:"⋯"}),m===n.id&&h.jsx(Wa,{onClick:D=>D.stopPropagation(),children:h.jsx(os,{onClick:()=>_(n.id),children:"🗑️ 삭제"})})]})]})}else{const X=H.filter(D=>D.id!==(c==null?void 0:c.id))[0];J=X?h.jsxs(pu,{$isActive:i,onClick:s,children:[h.jsxs(Mv,{children:[h.jsx(rn,{src:X.profile?(G=d[X.profile.id])==null?void 0:G.url:Ct,alt:"profile"}),h.jsx(Fo,{$online:X.online})]}),h.jsx(wp,{children:h.jsx(xp,{$hasUnread:l,children:X.username})}),j&&h.jsxs(Ya,{children:[h.jsx(Va,{onClick:D=>{D.stopPropagation(),$(n.id)},children:"⋯"}),m===n.id&&h.jsx(Wa,{onClick:D=>D.stopPropagation(),children:h.jsx(os,{onClick:()=>_(n.id),children:"🗑️ 삭제"})})]})]}):h.jsx("div",{})}}return h.jsxs(h.Fragment,{children:[J,h.jsx(Fv,{isOpen:y,channel:n,onClose:A,onUpdateSuccess:k})]})}function Uv({isOpen:n,type:i,onClose:s,onCreateSuccess:l}){const[c,d]=Z.useState({name:"",description:""}),[f,m]=Z.useState(""),[x,y]=Z.useState([]),[S,j]=Z.useState(""),$=Nr(D=>D.users),I=Rn(D=>D.binaryContents),{currentUser:k}=et(),A=Z.useMemo(()=>$.filter(D=>D.id!==(k==null?void 0:k.id)).filter(D=>D.username.toLowerCase().includes(f.toLowerCase())||D.email.toLowerCase().includes(f.toLowerCase())),[f,$,k]),_=An(D=>D.createPublicChannel),J=An(D=>D.createPrivateChannel),G=D=>{const{name:N,value:Q}=D.target;d(le=>({...le,[N]:Q}))},H=D=>{y(N=>N.includes(D)?N.filter(Q=>Q!==D):[...N,D])},X=async D=>{var N,Q;D.preventDefault(),j("");try{let le;if(i==="PUBLIC"){if(!c.name.trim()){j("채널 이름을 입력해주세요.");return}const Se={name:c.name,description:c.description};le=await _(Se)}else{if(x.length===0){j("대화 상대를 선택해주세요.");return}const Se=(k==null?void 0:k.id)&&[...x,k.id]||x;le=await J(Se)}l(le)}catch(le){console.error("채널 생성 실패:",le),j(((Q=(N=le.response)==null?void 0:N.data)==null?void 0:Q.message)||"채널 생성에 실패했습니다. 다시 시도해주세요.")}};return n?h.jsx($h,{onClick:s,children:h.jsxs(Oh,{onClick:D=>D.stopPropagation(),children:[h.jsxs(Lh,{children:[h.jsx(Dh,{children:i==="PUBLIC"?"채널 만들기":"개인 메시지 시작하기"}),h.jsx(Fh,{onClick:s,children:"×"})]}),h.jsx(Ih,{children:h.jsxs(bh,{onSubmit:X,children:[S&&h.jsx(Uh,{children:S}),i==="PUBLIC"?h.jsxs(h.Fragment,{children:[h.jsxs(No,{children:[h.jsx($o,{children:"채널 이름"}),h.jsx(Io,{name:"name",value:c.name,onChange:G,placeholder:"새로운-채널",required:!0})]}),h.jsxs(No,{children:[h.jsx($o,{children:"채널 설명"}),h.jsx(zh,{children:"이 채널의 주제를 설명해주세요."}),h.jsx(Io,{name:"description",value:c.description,onChange:G,placeholder:"채널 설명을 입력하세요"})]})]}):h.jsxs(No,{children:[h.jsx($o,{children:"사용자 검색"}),h.jsx(Nv,{type:"text",value:f,onChange:D=>m(D.target.value),placeholder:"사용자명 또는 이메일로 검색"}),h.jsx($v,{children:A.length>0?A.map(D=>h.jsxs(Ov,{children:[h.jsx(Lv,{type:"checkbox",checked:x.includes(D.id),onChange:()=>H(D.id)}),D.profile?h.jsx(Sp,{src:I[D.profile.id].url}):h.jsx(Sp,{src:Ct}),h.jsxs(Dv,{children:[h.jsx(Iv,{children:D.username}),h.jsx(bv,{children:D.email})]})]},D.id)):h.jsx(zv,{children:"검색 결과가 없습니다."})})]}),h.jsx(Bh,{type:"submit",children:i==="PUBLIC"?"채널 만들기":"대화 시작하기"})]})})]})}):null}function Hv({currentUser:n,activeChannel:i,onChannelSelect:s}){var X,D;const[l,c]=Z.useState({PUBLIC:!1,PRIVATE:!1}),[d,f]=Z.useState({isOpen:!1,type:null}),m=An(N=>N.channels),x=An(N=>N.fetchChannels),y=An(N=>N.startPolling),S=An(N=>N.stopPolling),j=Ar(N=>N.fetchReadStatuses),$=Ar(N=>N.updateReadStatus),I=Ar(N=>N.hasUnreadMessages);Z.useEffect(()=>{if(n)return x(n.id),j(),y(n.id),()=>{S()}},[n,x,j,y,S]);const k=N=>{c(Q=>({...Q,[N]:!Q[N]}))},A=(N,Q)=>{Q.stopPropagation(),f({isOpen:!0,type:N})},_=()=>{f({isOpen:!1,type:null})},J=async N=>{try{const le=(await x(n.id)).find(Se=>Se.id===N.id);le&&s(le),_()}catch(Q){console.error("채널 생성 실패:",Q)}},G=N=>{s(N),$(N.id)},H=m.reduce((N,Q)=>(N[Q.type]||(N[Q.type]=[]),N[Q.type].push(Q),N),{});return h.jsxs(Av,{children:[h.jsx(Bv,{}),h.jsxs(Rv,{children:[h.jsxs(mp,{children:[h.jsxs(fu,{onClick:()=>k("PUBLIC"),children:[h.jsx(gp,{$folded:l.PUBLIC,children:"▼"}),h.jsx("span",{children:"일반 채널"}),h.jsx(vp,{onClick:N=>A("PUBLIC",N),children:"+"})]}),h.jsx(yp,{$folded:l.PUBLIC,children:(X=H.PUBLIC)==null?void 0:X.map(N=>h.jsx(kp,{channel:N,isActive:(i==null?void 0:i.id)===N.id,hasUnread:I(N.id,N.lastMessageAt),onClick:()=>G(N)},N.id))})]}),h.jsxs(mp,{children:[h.jsxs(fu,{onClick:()=>k("PRIVATE"),children:[h.jsx(gp,{$folded:l.PRIVATE,children:"▼"}),h.jsx("span",{children:"개인 메시지"}),h.jsx(vp,{onClick:N=>A("PRIVATE",N),children:"+"})]}),h.jsx(yp,{$folded:l.PRIVATE,children:(D=H.PRIVATE)==null?void 0:D.map(N=>h.jsx(kp,{channel:N,isActive:(i==null?void 0:i.id)===N.id,hasUnread:I(N.id,N.lastMessageAt),onClick:()=>G(N)},N.id))})]})]}),h.jsx(Yv,{children:h.jsx(jv,{user:n})}),h.jsx(Uv,{isOpen:d.isOpen,type:d.type,onClose:_,onCreateSuccess:J})]})}const Yv=E.div` + margin-top: auto; + border-top: 1px solid ${({theme:n})=>n.colors.border.primary}; + background-color: ${({theme:n})=>n.colors.background.tertiary}; +`,Vv=E.div` + flex: 1; + display: flex; + flex-direction: column; + background: ${({theme:n})=>n.colors.background.primary}; +`,Wv=E.div` + display: flex; + flex-direction: column; + height: 100%; + background: ${({theme:n})=>n.colors.background.primary}; +`,qv=E(Wv)` + justify-content: center; + align-items: center; + flex: 1; + padding: 0 20px; +`,Qv=E.div` + text-align: center; + max-width: 400px; + padding: 20px; + margin-bottom: 80px; +`,Gv=E.div` + font-size: 48px; + margin-bottom: 16px; + animation: wave 2s infinite; + transform-origin: 70% 70%; + + @keyframes wave { + 0% { transform: rotate(0deg); } + 10% { transform: rotate(14deg); } + 20% { transform: rotate(-8deg); } + 30% { transform: rotate(14deg); } + 40% { transform: rotate(-4deg); } + 50% { transform: rotate(10deg); } + 60% { transform: rotate(0deg); } + 100% { transform: rotate(0deg); } + } +`,Kv=E.h2` + color: ${({theme:n})=>n.colors.text.primary}; + font-size: 28px; + font-weight: 700; + margin-bottom: 16px; +`,Xv=E.p` + color: ${({theme:n})=>n.colors.text.muted}; + font-size: 16px; + line-height: 1.6; + word-break: keep-all; +`,Cp=E.div` + height: 48px; + padding: 0 16px; + background: ${ee.colors.background.primary}; + border-bottom: 1px solid ${ee.colors.border.primary}; + display: flex; + align-items: center; +`,Ep=E.div` + display: flex; + align-items: center; + gap: 8px; + height: 100%; +`,Jv=E.div` + display: flex; + align-items: center; + gap: 12px; + height: 100%; +`,Zv=E(Dr)` + width: 24px; + height: 24px; +`;E.img` + width: 24px; + height: 24px; + border-radius: 50%; +`;const e1=E.div` + position: relative; + width: 40px; + height: 24px; + flex-shrink: 0; +`,t1=E(Fo)` + border-color: ${ee.colors.background.primary}; + bottom: -3px; + right: -3px; +`,n1=E.div` + font-size: 12px; + color: ${ee.colors.text.muted}; + line-height: 13px; +`,jp=E.div` + font-weight: bold; + color: ${ee.colors.text.primary}; + line-height: 20px; + font-size: 16px; +`,r1=E.div` + flex: 1; + display: flex; + flex-direction: column-reverse; + overflow-y: auto; + position: relative; +`,o1=E.div` + padding: 16px; + display: flex; + flex-direction: column; +`,Hh=E.div` + margin-bottom: 16px; + display: flex; + align-items: flex-start; + position: relative; + z-index: 1; +`,i1=E(Dr)` + margin-right: 16px; + width: 40px; + height: 40px; +`;E.img` + width: 40px; + height: 40px; + border-radius: 50%; +`;const s1=E.div` + display: flex; + align-items: center; + margin-bottom: 4px; + position: relative; +`,l1=E.span` + font-weight: bold; + color: ${ee.colors.text.primary}; + margin-right: 8px; +`,a1=E.span` + font-size: 0.75rem; + color: ${ee.colors.text.muted}; +`,u1=E.div` + color: ${ee.colors.text.secondary}; + margin-top: 4px; +`,c1=E.form` + display: flex; + align-items: center; + gap: 8px; + padding: 16px; + background: ${({theme:n})=>n.colors.background.secondary}; + position: relative; + z-index: 1; +`,d1=E.textarea` + flex: 1; + padding: 12px; + background: ${({theme:n})=>n.colors.background.tertiary}; + border: none; + border-radius: 4px; + color: ${({theme:n})=>n.colors.text.primary}; + font-size: 14px; + resize: none; + min-height: 44px; + max-height: 144px; + + &:focus { + outline: none; + } + + &::placeholder { + color: ${({theme:n})=>n.colors.text.muted}; + } +`,f1=E.button` + background: none; + border: none; + color: ${({theme:n})=>n.colors.text.muted}; + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${({theme:n})=>n.colors.text.primary}; + } +`;E.div` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: ${ee.colors.text.muted}; + font-size: 16px; + font-weight: 500; + padding: 20px; + text-align: center; +`;const is=E.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + width: 100%; +`,p1=E.a` + display: block; + border-radius: 4px; + overflow: hidden; + max-width: 300px; + + img { + width: 100%; + height: auto; + display: block; + } +`,qa=E.a` + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: ${({theme:n})=>n.colors.background.tertiary}; + border-radius: 8px; + text-decoration: none; + width: fit-content; + + &:hover { + background: ${({theme:n})=>n.colors.background.hover}; + } +`,Qa=E.div` + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: #0B93F6; +`,Ga=E.div` + display: flex; + flex-direction: column; + gap: 2px; +`,Ka=E.span` + font-size: 14px; + color: #0B93F6; + font-weight: 500; +`,Xa=E.span` + font-size: 13px; + color: ${({theme:n})=>n.colors.text.muted}; +`,h1=E.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 0; +`,Yh=E.div` + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: ${({theme:n})=>n.colors.background.tertiary}; + border-radius: 4px; + max-width: 300px; +`,m1=E(Yh)` + padding: 0; + overflow: hidden; + width: 200px; + height: 120px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`,g1=E.div` + color: #0B93F6; + font-size: 20px; +`,y1=E.div` + font-size: 13px; + color: ${({theme:n})=>n.colors.text.primary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`,Ap=E.button` + position: absolute; + top: -6px; + right: -6px; + width: 20px; + height: 20px; + border-radius: 50%; + background: ${({theme:n})=>n.colors.background.secondary}; + border: none; + color: ${({theme:n})=>n.colors.text.muted}; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + &:hover { + color: ${({theme:n})=>n.colors.text.primary}; + } +`,x1=E.div` + width: 16px; + height: 16px; + border: 2px solid ${({theme:n})=>n.colors.background.tertiary}; + border-top: 2px solid ${({theme:n})=>n.colors.brand.primary}; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-right: 8px; + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } +`,v1=E.div` + position: relative; + margin-left: auto; + z-index: 99999; +`,w1=E.button` + background: none; + border: none; + color: ${({theme:n})=>n.colors.text.muted}; + font-size: 16px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + + &:hover { + color: ${({theme:n})=>n.colors.text.primary}; + background: ${({theme:n})=>n.colors.background.hover}; + } + + ${Hh}:hover & { + opacity: 1; + } +`,S1=E.div` + position: absolute; + top: 0; + background: ${({theme:n})=>n.colors.background.primary}; + border: 1px solid ${({theme:n})=>n.colors.border.primary}; + border-radius: 6px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + width: 80px; + z-index: 99999; + overflow: hidden; +`,Rp=E.button` + display: flex; + align-items: center; + gap: 8px; + width: fit-content; + background: none; + border: none; + color: ${({theme:n})=>n.colors.text.primary}; + font-size: 14px; + cursor: pointer; + text-align: center ; + + &:hover { + background: ${({theme:n})=>n.colors.background.hover}; + } + + &:first-child { + border-radius: 6px 6px 0 0; + } + + &:last-child { + border-radius: 0 0 6px 6px; + } +`,k1=E.div` + margin-top: 4px; +`,C1=E.textarea` + width: 100%; + max-width: 600px; + min-height: 80px; + padding: 12px 16px; + background: ${({theme:n})=>n.colors.background.tertiary}; + border: 1px solid ${({theme:n})=>n.colors.border.primary}; + border-radius: 4px; + color: ${({theme:n})=>n.colors.text.primary}; + font-size: 14px; + font-family: inherit; + resize: vertical; + outline: none; + box-sizing: border-box; + + &:focus { + border-color: ${({theme:n})=>n.colors.primary}; + } + + &::placeholder { + color: ${({theme:n})=>n.colors.text.muted}; + } +`,E1=E.div` + display: flex; + gap: 8px; + margin-top: 8px; +`,Pp=E.button` + padding: 6px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background-color 0.2s ease; + + ${({variant:n,theme:i})=>n==="primary"?` + background: ${i.colors.primary}; + color: white; + + &:hover { + background: ${i.colors.primaryHover||i.colors.primary}; + } + `:` + background: ${i.colors.background.secondary}; + color: ${i.colors.text.secondary}; + + &:hover { + background: ${i.colors.background.hover}; + } + `} +`,Mp=E.button` + background: none; + border: none; + padding: 8px; + cursor: pointer; + color: ${({theme:n,$enabled:i})=>i?n.colors.brand.primary:n.colors.text.muted}; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + background: ${({theme:n})=>n.colors.background.hover}; + color: ${({theme:n})=>n.colors.brand.primary}; + } +`;function j1({channel:n}){var I;const{currentUser:i}=et(),s=Nr(k=>k.users),l=Rn(k=>k.binaryContents),{readStatuses:c,updateNotificationEnabled:d}=Ar(),[f,m]=Z.useState(!1);Z.useEffect(()=>{c[n==null?void 0:n.id]&&m(c[n.id].notificationEnabled)},[c,n]);const x=Z.useCallback(async()=>{if(!i||!n)return;const k=!f;m(k);try{await d(n.id,k)}catch(A){console.error("알림 설정 업데이트 실패:",A),m(f)}},[i,n,f,d]);if(!n)return null;if(n.type==="PUBLIC")return h.jsxs(Cp,{children:[h.jsx(Ep,{children:h.jsxs(jp,{children:["# ",n.name]})}),h.jsx(Mp,{onClick:x,$enabled:f,children:h.jsxs("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[h.jsx("path",{d:"M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"}),h.jsx("path",{d:"M13.73 21a2 2 0 0 1-3.46 0"})]})})]});const y=n.participants.map(k=>s.find(A=>A.id===k.id)).filter(Boolean),S=y.filter(k=>k.id!==(i==null?void 0:i.id)),j=y.length>2,$=y.filter(k=>k.id!==(i==null?void 0:i.id)).map(k=>k.username).join(", ");return h.jsxs(Cp,{children:[h.jsx(Ep,{children:h.jsxs(Jv,{children:[j?h.jsx(e1,{children:S.slice(0,2).map((k,A)=>{var _;return h.jsx(rn,{src:k.profile?(_=l[k.profile.id])==null?void 0:_.url:Ct,style:{position:"absolute",left:A*16,zIndex:2-A,width:"24px",height:"24px"}},k.id)})}):h.jsxs(Zv,{children:[h.jsx(rn,{src:S[0].profile?(I=l[S[0].profile.id])==null?void 0:I.url:Ct}),h.jsx(t1,{$online:S[0].online})]}),h.jsxs("div",{children:[h.jsx(jp,{children:$}),j&&h.jsxs(n1,{children:["멤버 ",y.length,"명"]})]})]})}),h.jsx(Mp,{onClick:x,$enabled:f,children:h.jsxs("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[h.jsx("path",{d:"M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"}),h.jsx("path",{d:"M13.73 21a2 2 0 0 1-3.46 0"})]})})]})}const A1=async(n,i,s)=>{var c;return(await Le.get("/messages",{params:{channelId:n,cursor:i,size:s.size,sort:(c=s.sort)==null?void 0:c.join(",")}})).data},R1=async(n,i)=>{const s=new FormData,l={content:n.content,channelId:n.channelId,authorId:n.authorId};return s.append("messageCreateRequest",new Blob([JSON.stringify(l)],{type:"application/json"})),i&&i.length>0&&i.forEach(d=>{s.append("attachments",d)}),(await Le.post("/messages",s,{headers:{"Content-Type":"multipart/form-data"}})).data},P1=async(n,i)=>(await Le.patch(`/messages/${n}`,i)).data,M1=async n=>{await Le.delete(`/messages/${n}`)},Ja={size:50,sort:["createdAt,desc"]},Vh=Kn((n,i)=>({messages:[],pollingIntervals:{},lastMessageId:null,pagination:{nextCursor:null,pageSize:50,hasNext:!1},isCreating:!1,fetchMessages:async(s,l,c=Ja)=>{try{if(i().isCreating)return Promise.resolve(!0);const d=await A1(s,l,c),f=d.content,m=f.length>0?f[0]:null,x=(m==null?void 0:m.id)!==i().lastMessageId;return n(y=>{var A;const S=!l,j=s!==((A=y.messages[0])==null?void 0:A.channelId),$=S&&(y.messages.length===0||j);let I=[],k={...y.pagination};if($)I=f,k={nextCursor:d.nextCursor,pageSize:d.size,hasNext:d.hasNext};else if(S){const _=new Set(y.messages.map(G=>G.id));I=[...f.filter(G=>!_.has(G.id)&&(y.messages.length===0||G.createdAt>y.messages[0].createdAt)),...y.messages]}else{const _=new Set(y.messages.map(G=>G.id)),J=f.filter(G=>!_.has(G.id));I=[...y.messages,...J],k={nextCursor:d.nextCursor,pageSize:d.size,hasNext:d.hasNext}}return{messages:I,lastMessageId:(m==null?void 0:m.id)||null,pagination:k}}),x}catch(d){return console.error("메시지 목록 조회 실패:",d),!1}},loadMoreMessages:async s=>{const{pagination:l}=i();l.hasNext&&await i().fetchMessages(s,l.nextCursor,{...Ja})},startPolling:s=>{const l=i();if(l.pollingIntervals[s]){const m=l.pollingIntervals[s];typeof m=="number"&&clearTimeout(m)}let c=300;const d=3e3;n(m=>({pollingIntervals:{...m.pollingIntervals,[s]:!0}}));const f=async()=>{const m=i();if(!m.pollingIntervals[s])return;const x=await m.fetchMessages(s,null,Ja);if(!(i().messages.length==0)&&x?c=300:c=Math.min(c*1.5,d),i().pollingIntervals[s]){const S=setTimeout(f,c);n(j=>({pollingIntervals:{...j.pollingIntervals,[s]:S}}))}};f()},stopPolling:s=>{const{pollingIntervals:l}=i();if(l[s]){const c=l[s];typeof c=="number"&&clearTimeout(c),n(d=>{const f={...d.pollingIntervals};return delete f[s],{pollingIntervals:f}})}},createMessage:async(s,l)=>{try{n({isCreating:!0});const c=await R1(s,l),d=Ar.getState().updateReadStatus;return await d(s.channelId),n(f=>f.messages.some(x=>x.id===c.id)?f:{messages:[c,...f.messages],lastMessageId:c.id}),c}catch(c){throw console.error("메시지 생성 실패:",c),c}finally{n({isCreating:!1})}},updateMessage:async(s,l)=>{try{const c=await P1(s,{newContent:l});return n(d=>({messages:d.messages.map(f=>f.id===s?{...f,content:l}:f)})),c}catch(c){throw console.error("메시지 업데이트 실패:",c),c}},deleteMessage:async s=>{try{await M1(s),n(l=>({messages:l.messages.filter(c=>c.id!==s)}))}catch(l){throw console.error("메시지 삭제 실패:",l),l}}}));function _1({channel:n}){const[i,s]=Z.useState(""),[l,c]=Z.useState([]),[d,f]=Z.useState(!1),m=Vh(k=>k.createMessage),{currentUser:x}=et(),y=async k=>{if(k.preventDefault(),!(!i.trim()&&l.length===0)&&!d){f(!0);try{await m({content:i.trim(),channelId:n.id,authorId:(x==null?void 0:x.id)??""},l),s(""),c([])}catch(A){console.error("메시지 전송 실패:",A)}finally{f(!1)}}},S=k=>{const A=Array.from(k.target.files||[]);c(_=>[..._,...A]),k.target.value=""},j=k=>{c(A=>A.filter((_,J)=>J!==k))},$=k=>{if(k.key==="Enter"&&!k.shiftKey){if(console.log("Enter key pressed"),k.preventDefault(),k.nativeEvent.isComposing)return;y(k)}},I=(k,A)=>k.type.startsWith("image/")?h.jsxs(m1,{children:[h.jsx("img",{src:URL.createObjectURL(k),alt:k.name}),h.jsx(Ap,{onClick:()=>j(A),children:"×"})]},A):h.jsxs(Yh,{children:[h.jsx(g1,{children:"📎"}),h.jsx(y1,{children:k.name}),h.jsx(Ap,{onClick:()=>j(A),children:"×"})]},A);return Z.useEffect(()=>()=>{l.forEach(k=>{k.type.startsWith("image/")&&URL.revokeObjectURL(URL.createObjectURL(k))})},[l]),n?h.jsxs(h.Fragment,{children:[l.length>0&&!d&&h.jsx(h1,{children:l.map((k,A)=>I(k,A))}),h.jsxs(c1,{onSubmit:y,children:[h.jsxs(f1,{as:"label",children:["+",h.jsx("input",{type:"file",multiple:!0,onChange:S,style:{display:"none"}})]}),h.jsx(d1,{value:i,onChange:k=>s(k.target.value),onKeyDown:$,disabled:d,placeholder:d?"메시지 전송 중...":n.type==="PUBLIC"?`#${n.name}에 메시지 보내기`:"메시지 보내기"}),d&&h.jsx(x1,{})]})]}):null}/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 + +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. + +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */var hu=function(n,i){return hu=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(s,l){s.__proto__=l}||function(s,l){for(var c in l)l.hasOwnProperty(c)&&(s[c]=l[c])},hu(n,i)};function T1(n,i){hu(n,i);function s(){this.constructor=n}n.prototype=i===null?Object.create(i):(s.prototype=i.prototype,new s)}var Oo=function(){return Oo=Object.assign||function(i){for(var s,l=1,c=arguments.length;ln?I():i!==!0&&(c=setTimeout(l?k:I,l===void 0?n-j:n))}return y.cancel=x,y}var Rr={Pixel:"Pixel",Percent:"Percent"},_p={unit:Rr.Percent,value:.8};function Tp(n){return typeof n=="number"?{unit:Rr.Percent,value:n*100}:typeof n=="string"?n.match(/^(\d*(\.\d+)?)px$/)?{unit:Rr.Pixel,value:parseFloat(n)}:n.match(/^(\d*(\.\d+)?)%$/)?{unit:Rr.Percent,value:parseFloat(n)}:(console.warn('scrollThreshold format is invalid. Valid formats: "120px", "50%"...'),_p):(console.warn("scrollThreshold should be string or number"),_p)}var $1=function(n){T1(i,n);function i(s){var l=n.call(this,s)||this;return l.lastScrollTop=0,l.actionTriggered=!1,l.startY=0,l.currentY=0,l.dragging=!1,l.maxPullDownDistance=0,l.getScrollableTarget=function(){return l.props.scrollableTarget instanceof HTMLElement?l.props.scrollableTarget:typeof l.props.scrollableTarget=="string"?document.getElementById(l.props.scrollableTarget):(l.props.scrollableTarget===null&&console.warn(`You are trying to pass scrollableTarget but it is null. This might + happen because the element may not have been added to DOM yet. + See https://github.com/ankeetmaini/react-infinite-scroll-component/issues/59 for more info. + `),null)},l.onStart=function(c){l.lastScrollTop||(l.dragging=!0,c instanceof MouseEvent?l.startY=c.pageY:c instanceof TouchEvent&&(l.startY=c.touches[0].pageY),l.currentY=l.startY,l._infScroll&&(l._infScroll.style.willChange="transform",l._infScroll.style.transition="transform 0.2s cubic-bezier(0,0,0.31,1)"))},l.onMove=function(c){l.dragging&&(c instanceof MouseEvent?l.currentY=c.pageY:c instanceof TouchEvent&&(l.currentY=c.touches[0].pageY),!(l.currentY=Number(l.props.pullDownToRefreshThreshold)&&l.setState({pullToRefreshThresholdBreached:!0}),!(l.currentY-l.startY>l.maxPullDownDistance*1.5)&&l._infScroll&&(l._infScroll.style.overflow="visible",l._infScroll.style.transform="translate3d(0px, "+(l.currentY-l.startY)+"px, 0px)")))},l.onEnd=function(){l.startY=0,l.currentY=0,l.dragging=!1,l.state.pullToRefreshThresholdBreached&&(l.props.refreshFunction&&l.props.refreshFunction(),l.setState({pullToRefreshThresholdBreached:!1})),requestAnimationFrame(function(){l._infScroll&&(l._infScroll.style.overflow="auto",l._infScroll.style.transform="none",l._infScroll.style.willChange="unset")})},l.onScrollListener=function(c){typeof l.props.onScroll=="function"&&setTimeout(function(){return l.props.onScroll&&l.props.onScroll(c)},0);var d=l.props.height||l._scrollableNode?c.target:document.documentElement.scrollTop?document.documentElement:document.body;if(!l.actionTriggered){var f=l.props.inverse?l.isElementAtTop(d,l.props.scrollThreshold):l.isElementAtBottom(d,l.props.scrollThreshold);f&&l.props.hasMore&&(l.actionTriggered=!0,l.setState({showLoader:!0}),l.props.next&&l.props.next()),l.lastScrollTop=d.scrollTop}},l.state={showLoader:!1,pullToRefreshThresholdBreached:!1,prevDataLength:s.dataLength},l.throttledOnScrollListener=N1(150,l.onScrollListener).bind(l),l.onStart=l.onStart.bind(l),l.onMove=l.onMove.bind(l),l.onEnd=l.onEnd.bind(l),l}return i.prototype.componentDidMount=function(){if(typeof this.props.dataLength>"u")throw new Error('mandatory prop "dataLength" is missing. The prop is needed when loading more content. Check README.md for usage');if(this._scrollableNode=this.getScrollableTarget(),this.el=this.props.height?this._infScroll:this._scrollableNode||window,this.el&&this.el.addEventListener("scroll",this.throttledOnScrollListener),typeof this.props.initialScrollY=="number"&&this.el&&this.el instanceof HTMLElement&&this.el.scrollHeight>this.props.initialScrollY&&this.el.scrollTo(0,this.props.initialScrollY),this.props.pullDownToRefresh&&this.el&&(this.el.addEventListener("touchstart",this.onStart),this.el.addEventListener("touchmove",this.onMove),this.el.addEventListener("touchend",this.onEnd),this.el.addEventListener("mousedown",this.onStart),this.el.addEventListener("mousemove",this.onMove),this.el.addEventListener("mouseup",this.onEnd),this.maxPullDownDistance=this._pullDown&&this._pullDown.firstChild&&this._pullDown.firstChild.getBoundingClientRect().height||0,this.forceUpdate(),typeof this.props.refreshFunction!="function"))throw new Error(`Mandatory prop "refreshFunction" missing. + Pull Down To Refresh functionality will not work + as expected. Check README.md for usage'`)},i.prototype.componentWillUnmount=function(){this.el&&(this.el.removeEventListener("scroll",this.throttledOnScrollListener),this.props.pullDownToRefresh&&(this.el.removeEventListener("touchstart",this.onStart),this.el.removeEventListener("touchmove",this.onMove),this.el.removeEventListener("touchend",this.onEnd),this.el.removeEventListener("mousedown",this.onStart),this.el.removeEventListener("mousemove",this.onMove),this.el.removeEventListener("mouseup",this.onEnd)))},i.prototype.componentDidUpdate=function(s){this.props.dataLength!==s.dataLength&&(this.actionTriggered=!1,this.setState({showLoader:!1}))},i.getDerivedStateFromProps=function(s,l){var c=s.dataLength!==l.prevDataLength;return c?Oo(Oo({},l),{prevDataLength:s.dataLength}):null},i.prototype.isElementAtTop=function(s,l){l===void 0&&(l=.8);var c=s===document.body||s===document.documentElement?window.screen.availHeight:s.clientHeight,d=Tp(l);return d.unit===Rr.Pixel?s.scrollTop<=d.value+c-s.scrollHeight+1:s.scrollTop<=d.value/100+c-s.scrollHeight+1},i.prototype.isElementAtBottom=function(s,l){l===void 0&&(l=.8);var c=s===document.body||s===document.documentElement?window.screen.availHeight:s.clientHeight,d=Tp(l);return d.unit===Rr.Pixel?s.scrollTop+c>=s.scrollHeight-d.value:s.scrollTop+c>=d.value/100*s.scrollHeight},i.prototype.render=function(){var s=this,l=Oo({height:this.props.height||"auto",overflow:"auto",WebkitOverflowScrolling:"touch"},this.props.style),c=this.props.hasChildren||!!(this.props.children&&this.props.children instanceof Array&&this.props.children.length),d=this.props.pullDownToRefresh&&this.props.height?{overflow:"auto"}:{};return St.createElement("div",{style:d,className:"infinite-scroll-component__outerdiv"},St.createElement("div",{className:"infinite-scroll-component "+(this.props.className||""),ref:function(f){return s._infScroll=f},style:l},this.props.pullDownToRefresh&&St.createElement("div",{style:{position:"relative"},ref:function(f){return s._pullDown=f}},St.createElement("div",{style:{position:"absolute",left:0,right:0,top:-1*this.maxPullDownDistance}},this.state.pullToRefreshThresholdBreached?this.props.releaseToRefreshContent:this.props.pullDownToRefreshContent)),this.props.children,!this.state.showLoader&&!c&&this.props.hasMore&&this.props.loader,this.state.showLoader&&this.props.hasMore&&this.props.loader,!this.props.hasMore&&this.props.endMessage))},i}(Z.Component);const O1=n=>n<1024?n+" B":n<1024*1024?(n/1024).toFixed(2)+" KB":n<1024*1024*1024?(n/(1024*1024)).toFixed(2)+" MB":(n/(1024*1024*1024)).toFixed(2)+" GB";function L1({channel:n}){const{messages:i,fetchMessages:s,loadMoreMessages:l,pagination:c,startPolling:d,stopPolling:f,updateMessage:m,deleteMessage:x}=Vh(),{binaryContents:y,fetchBinaryContent:S,clearBinaryContents:j,startPolling:$,clearAllPolling:I}=Rn(),{currentUser:k}=et(),[A,_]=Z.useState(null),[J,G]=Z.useState(null),[H,X]=Z.useState("");Z.useEffect(()=>{if(n!=null&&n.id)return s(n.id,null),d(n.id),()=>{f(n.id),I()}},[n==null?void 0:n.id,s,d,f,I]),Z.useEffect(()=>{i.forEach(V=>{var z;(z=V.attachments)==null||z.forEach(b=>{y[b.id]||S(b.id).then(W=>{W&&W.status==="PROCESSING"&&$(b.id)})})})},[i,S,$]),Z.useEffect(()=>()=>{const V=i.map(z=>{var b;return(b=z.attachments)==null?void 0:b.map(W=>W.id)}).flat();j(V),I()},[j,I]),Z.useEffect(()=>{const V=()=>{A&&_(null)};if(A)return document.addEventListener("click",V),()=>document.removeEventListener("click",V)},[A]);const D=async V=>{try{const{url:z,fileName:b}=V;if(z==null)return;const W=document.createElement("a");W.href=z,W.download=b,W.style.display="none",document.body.appendChild(W);try{const F=await(await window.showSaveFilePicker({suggestedName:V.fileName,types:[{description:"Files",accept:{"*/*":[".txt",".pdf",".doc",".docx",".xls",".xlsx",".jpg",".jpeg",".png",".gif"]}}]})).createWritable(),w=await(await fetch(z)).blob();await F.write(w),await F.close()}catch(P){P.name!=="AbortError"&&W.click()}document.body.removeChild(W),window.URL.revokeObjectURL(z)}catch(z){console.error("파일 다운로드 실패:",z)}},N=V=>V!=null&&V.length?(console.log("renderAttachments 호출됨",{attachments:V.map(z=>{var b;return{id:z.id,binaryContent:(b=y[z.id])==null?void 0:b.status}})}),V.map(z=>{const b=y[z.id];if(!b)return null;const W=b.contentType.startsWith("image/"),P=b.status;return P==="FAIL"?h.jsx(is,{children:h.jsxs(qa,{href:"#",style:{opacity:.5,backgroundColor:"#fff2f2"},onClick:F=>{F.preventDefault()},children:[h.jsx(Qa,{children:h.jsxs("svg",{width:"40",height:"40",viewBox:"0 0 40 40",fill:"none",children:[h.jsx("path",{d:"M8 3C8 1.89543 8.89543 1 10 1H22L32 11V37C32 38.1046 31.1046 39 30 39H10C8.89543 39 8 38.1046 8 37V3Z",fill:"#ef4444",fillOpacity:"0.1"}),h.jsx("path",{d:"M22 1L32 11H24C22.8954 11 22 10.1046 22 9V1Z",fill:"#ef4444",fillOpacity:"0.3"}),h.jsx("path",{d:"M13 19H27M13 25H27M13 31H27",stroke:"#ef4444",strokeWidth:"2",strokeLinecap:"round"})]})}),h.jsxs(Ga,{children:[h.jsx(Ka,{style:{color:"#ef4444"},children:z.fileName}),h.jsx(Xa,{style:{color:"#ef4444"},children:"업로드 실패"})]})]})},z.id):P==="PROCESSING"?h.jsx(is,{children:h.jsxs(qa,{href:"#",style:{opacity:.7,backgroundColor:"#fef3c7"},onClick:F=>{F.preventDefault()},children:[h.jsx(Qa,{children:h.jsxs("svg",{width:"40",height:"40",viewBox:"0 0 40 40",fill:"none",children:[h.jsx("path",{d:"M8 3C8 1.89543 8.89543 1 10 1H22L32 11V37C32 38.1046 31.1046 39 30 39H10C8.89543 39 8 38.1046 8 37V3Z",fill:"#f59e0b",fillOpacity:"0.1"}),h.jsx("path",{d:"M22 1L32 11H24C22.8954 11 22 10.1046 22 9V1Z",fill:"#f59e0b",fillOpacity:"0.3"}),h.jsx("path",{d:"M13 19H27M13 25H27M13 31H27",stroke:"#f59e0b",strokeWidth:"2",strokeLinecap:"round"})]})}),h.jsxs(Ga,{children:[h.jsx(Ka,{style:{color:"#f59e0b"},children:z.fileName}),h.jsx(Xa,{style:{color:"#f59e0b"},children:"업로드 중..."})]})]})},z.id):b.url?W?h.jsx(is,{children:h.jsx(p1,{href:"#",onClick:F=>{F.preventDefault(),D(b)},children:h.jsx("img",{src:b.url,alt:b.fileName})})},b.url):h.jsx(is,{children:h.jsxs(qa,{href:"#",onClick:F=>{F.preventDefault(),D(b)},children:[h.jsx(Qa,{children:h.jsxs("svg",{width:"40",height:"40",viewBox:"0 0 40 40",fill:"none",children:[h.jsx("path",{d:"M8 3C8 1.89543 8.89543 1 10 1H22L32 11V37C32 38.1046 31.1046 39 30 39H10C8.89543 39 8 38.1046 8 37V3Z",fill:"#0B93F6",fillOpacity:"0.1"}),h.jsx("path",{d:"M22 1L32 11H24C22.8954 11 22 10.1046 22 9V1Z",fill:"#0B93F6",fillOpacity:"0.3"}),h.jsx("path",{d:"M13 19H27M13 25H27M13 31H27",stroke:"#0B93F6",strokeWidth:"2",strokeLinecap:"round"})]})}),h.jsxs(Ga,{children:[h.jsx(Ka,{children:b.fileName}),h.jsx(Xa,{children:O1(b.size)})]})]})},b.url):null})):null,Q=V=>new Date(V).toLocaleTimeString(),le=()=>{n!=null&&n.id&&l(n.id)},Se=V=>{_(A===V?null:V)},ge=V=>{_(null);const z=i.find(b=>b.id===V);z&&(G(V),X(z.content))},pe=V=>{m(V,H).catch(z=>{console.error("메시지 수정 실패:",z),jr.emit("api-error",{error:z,alert:!0})}),G(null),X("")},Be=()=>{G(null),X("")},Fe=V=>{_(null),x(V)};return h.jsx(r1,{children:h.jsx("div",{id:"scrollableDiv",style:{height:"100%",overflow:"auto",display:"flex",flexDirection:"column-reverse"},children:h.jsx($1,{dataLength:i.length,next:le,hasMore:c.hasNext,loader:h.jsx("h4",{style:{textAlign:"center"},children:"메시지를 불러오는 중..."}),scrollableTarget:"scrollableDiv",style:{display:"flex",flexDirection:"column-reverse"},inverse:!0,endMessage:h.jsx("p",{style:{textAlign:"center"},children:h.jsx("b",{children:c.nextCursor!==null?"모든 메시지를 불러왔습니다":""})}),children:h.jsx(o1,{children:[...i].reverse().map(V=>{var W;const z=V.author,b=k&&z&&z.id===k.id;return h.jsxs(Hh,{children:[h.jsx(i1,{children:h.jsx(rn,{src:z&&z.profile?(W=y[z.profile.id])==null?void 0:W.url:Ct,alt:z&&z.username||"알 수 없음"})}),h.jsxs("div",{children:[h.jsxs(s1,{children:[h.jsx(l1,{children:z&&z.username||"알 수 없음"}),h.jsx(a1,{children:Q(V.createdAt)}),b&&h.jsxs(v1,{children:[h.jsx(w1,{onClick:P=>{P.stopPropagation(),Se(V.id)},children:"⋯"}),A===V.id&&h.jsxs(S1,{onClick:P=>P.stopPropagation(),children:[h.jsx(Rp,{onClick:()=>ge(V.id),children:"✏️ 수정"}),h.jsx(Rp,{onClick:()=>Fe(V.id),children:"🗑️ 삭제"})]})]})]}),J===V.id?h.jsxs(k1,{children:[h.jsx(C1,{value:H,onChange:P=>X(P.target.value),onKeyDown:P=>{P.key==="Escape"?Be():P.key==="Enter"&&(P.ctrlKey||P.metaKey)&&(P.preventDefault(),pe(V.id))},placeholder:"메시지를 입력하세요..."}),h.jsxs(E1,{children:[h.jsx(Pp,{variant:"secondary",onClick:Be,children:"취소"}),h.jsx(Pp,{variant:"primary",onClick:()=>pe(V.id),children:"저장"})]})]}):h.jsx(u1,{children:V.content}),N(V.attachments)]})]},V.id)})})})})})}function D1({channel:n}){return n?h.jsxs(Vv,{children:[h.jsx(j1,{channel:n}),h.jsx(L1,{channel:n}),h.jsx(_1,{channel:n})]}):h.jsx(qv,{children:h.jsxs(Qv,{children:[h.jsx(Gv,{children:"👋"}),h.jsx(Kv,{children:"채널을 선택해주세요"}),h.jsxs(Xv,{children:["왼쪽의 채널 목록에서 채널을 선택하여",h.jsx("br",{}),"대화를 시작하세요."]})]})})}function I1(n,i="yyyy-MM-dd HH:mm:ss"){if(!n||!(n instanceof Date)||isNaN(n.getTime()))return"";const s=n.getFullYear(),l=String(n.getMonth()+1).padStart(2,"0"),c=String(n.getDate()).padStart(2,"0"),d=String(n.getHours()).padStart(2,"0"),f=String(n.getMinutes()).padStart(2,"0"),m=String(n.getSeconds()).padStart(2,"0");return i.replace("yyyy",s.toString()).replace("MM",l).replace("dd",c).replace("HH",d).replace("mm",f).replace("ss",m)}const b1=E.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,z1=E.div` + background: ${({theme:n})=>n.colors.background.primary}; + border-radius: 8px; + width: 500px; + max-width: 90%; + padding: 24px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +`,B1=E.div` + display: flex; + align-items: center; + margin-bottom: 16px; +`,F1=E.div` + color: ${({theme:n})=>n.colors.status.error}; + font-size: 24px; + margin-right: 12px; +`,U1=E.h3` + color: ${({theme:n})=>n.colors.text.primary}; + margin: 0; + font-size: 18px; +`,H1=E.div` + background: ${({theme:n})=>n.colors.background.tertiary}; + color: ${({theme:n})=>n.colors.text.muted}; + padding: 2px 8px; + border-radius: 4px; + font-size: 14px; + margin-left: auto; +`,Y1=E.p` + color: ${({theme:n})=>n.colors.text.secondary}; + margin-bottom: 20px; + line-height: 1.5; + font-weight: 500; +`,V1=E.div` + margin-bottom: 20px; + background: ${({theme:n})=>n.colors.background.secondary}; + border-radius: 6px; + padding: 12px; +`,Ao=E.div` + display: flex; + margin-bottom: 8px; + font-size: 14px; +`,Ro=E.span` + color: ${({theme:n})=>n.colors.text.muted}; + min-width: 100px; +`,Po=E.span` + color: ${({theme:n})=>n.colors.text.secondary}; + word-break: break-word; +`,W1=E.button` + background: ${({theme:n})=>n.colors.brand.primary}; + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + width: 100%; + + &:hover { + background: ${({theme:n})=>n.colors.brand.hover}; + } +`;function q1({isOpen:n,onClose:i,error:s}){var $,I;if(!n)return null;console.log({error:s});const l=($=s==null?void 0:s.response)==null?void 0:$.data,c=(l==null?void 0:l.status)||((I=s==null?void 0:s.response)==null?void 0:I.status)||"오류",d=(l==null?void 0:l.code)||"",f=(l==null?void 0:l.message)||(s==null?void 0:s.message)||"알 수 없는 오류가 발생했습니다.",m=l!=null&&l.timestamp?new Date(l.timestamp):new Date,x=I1(m),y=(l==null?void 0:l.exceptionType)||"",S=(l==null?void 0:l.details)||{},j=(l==null?void 0:l.requestId)||"";return h.jsx(b1,{onClick:i,children:h.jsxs(z1,{onClick:k=>k.stopPropagation(),children:[h.jsxs(B1,{children:[h.jsx(F1,{children:"⚠️"}),h.jsx(U1,{children:"오류가 발생했습니다"}),h.jsxs(H1,{children:[c,d?` (${d})`:""]})]}),h.jsx(Y1,{children:f}),h.jsxs(V1,{children:[h.jsxs(Ao,{children:[h.jsx(Ro,{children:"시간:"}),h.jsx(Po,{children:x})]}),j&&h.jsxs(Ao,{children:[h.jsx(Ro,{children:"요청 ID:"}),h.jsx(Po,{children:j})]}),d&&h.jsxs(Ao,{children:[h.jsx(Ro,{children:"에러 코드:"}),h.jsx(Po,{children:d})]}),y&&h.jsxs(Ao,{children:[h.jsx(Ro,{children:"예외 유형:"}),h.jsx(Po,{children:y})]}),Object.keys(S).length>0&&h.jsxs(Ao,{children:[h.jsx(Ro,{children:"상세 정보:"}),h.jsx(Po,{children:Object.entries(S).map(([k,A])=>h.jsxs("div",{children:[k,": ",String(A)]},k))})]})]}),h.jsx(W1,{onClick:i,children:"확인"})]})})}const Q1=E.div` + width: 240px; + background: ${ee.colors.background.secondary}; + border-left: 1px solid ${ee.colors.border.primary}; + display: flex; + flex-direction: column; + height: 100%; +`,G1=E.div` + padding: 0px 16px; + height: 48px; + font-size: 14px; + font-weight: bold; + color: ${ee.colors.text.muted}; + text-transform: uppercase; + border-bottom: 1px solid ${ee.colors.border.primary}; +`,K1=E.div` + display: flex; + justify-content: space-between; + align-items: center; +`,X1=E.div` + padding: 8px 16px; + display: flex; + align-items: center; + color: ${ee.colors.text.muted}; + &:hover { + background: ${ee.colors.background.primary}; + cursor: pointer; + } +`,J1=E(Dr)` + margin-right: 12px; +`;E(rn)``;const Z1=E.div` + display: flex; + align-items: center; +`;function ew({member:n}){var l,c,d;const{binaryContents:i,fetchBinaryContent:s}=Rn();return Z.useEffect(()=>{var f;(f=n.profile)!=null&&f.id&&!i[n.profile.id]&&s(n.profile.id)},[(l=n.profile)==null?void 0:l.id,i,s]),h.jsxs(X1,{children:[h.jsxs(J1,{children:[h.jsx(rn,{src:(c=n.profile)!=null&&c.id&&((d=i[n.profile.id])==null?void 0:d.url)||Ct,alt:n.username}),h.jsx(Fo,{$online:n.online})]}),h.jsx(Z1,{children:n.username})]})}function tw({member:n,onClose:i}){var I,k,A;const{binaryContents:s,fetchBinaryContent:l}=Rn(),{currentUser:c,updateUserRole:d}=et(),[f,m]=Z.useState(n.role),[x,y]=Z.useState(!1);Z.useEffect(()=>{var _;(_=n.profile)!=null&&_.id&&!s[n.profile.id]&&l(n.profile.id)},[(I=n.profile)==null?void 0:I.id,s,l]);const S={[jn.USER]:{name:"사용자",color:"#2ed573"},[jn.CHANNEL_MANAGER]:{name:"채널 관리자",color:"#ff4757"},[jn.ADMIN]:{name:"어드민",color:"#0097e6"}},j=_=>{m(_),y(!0)},$=()=>{d(n.id,f),y(!1)};return h.jsx(ow,{onClick:i,children:h.jsxs(iw,{onClick:_=>_.stopPropagation(),children:[h.jsx("h2",{children:"사용자 정보"}),h.jsxs(sw,{children:[h.jsx(lw,{src:(k=n.profile)!=null&&k.id&&((A=s[n.profile.id])==null?void 0:A.url)||Ct,alt:n.username}),h.jsx(aw,{children:n.username}),h.jsx(uw,{children:n.email}),h.jsx(cw,{$online:n.online,children:n.online?"온라인":"오프라인"}),(c==null?void 0:c.role)===jn.ADMIN?h.jsx(rw,{value:f,onChange:_=>j(_.target.value),children:Object.entries(S).map(([_,J])=>h.jsx("option",{value:_,style:{marginTop:"8px",textAlign:"center"},children:J.name},_))}):h.jsx(nw,{style:{backgroundColor:S[n.role].color},children:S[n.role].name})]}),h.jsx(dw,{children:(c==null?void 0:c.role)===jn.ADMIN&&x&&h.jsx(fw,{onClick:$,disabled:!x,$secondary:!x,children:"저장"})})]})})}const nw=E.div` + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + color: white; + margin-top: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + letter-spacing: 0.3px; +`,rw=E.select` + padding: 10px 16px; + border-radius: 8px; + border: 1.5px solid ${ee.colors.border.primary}; + background: ${ee.colors.background.primary}; + color: ${ee.colors.text.primary}; + font-size: 14px; + width: 140px; + cursor: pointer; + transition: all 0.2s ease; + margin-top: 12px; + font-weight: 500; + + &:hover { + border-color: ${ee.colors.brand.primary}; + } + + &:focus { + outline: none; + border-color: ${ee.colors.brand.primary}; + box-shadow: 0 0 0 2px ${ee.colors.brand.primary}20; + } + + option { + background: ${ee.colors.background.primary}; + color: ${ee.colors.text.primary}; + padding: 12px; + } +`,ow=E.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +`,iw=E.div` + background: ${ee.colors.background.secondary}; + padding: 40px; + border-radius: 16px; + width: 100%; + max-width: 420px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + + h2 { + color: ${ee.colors.text.primary}; + margin-bottom: 32px; + text-align: center; + font-size: 26px; + font-weight: 600; + letter-spacing: -0.5px; + } +`,sw=E.div` + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 32px; + padding: 24px; + background: ${ee.colors.background.primary}; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +`,lw=E.img` + width: 140px; + height: 140px; + border-radius: 50%; + margin-bottom: 20px; + object-fit: cover; + border: 4px solid ${ee.colors.background.secondary}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +`,aw=E.div` + font-size: 22px; + font-weight: 600; + color: ${ee.colors.text.primary}; + margin-bottom: 8px; + letter-spacing: -0.3px; +`,uw=E.div` + font-size: 14px; + color: ${ee.colors.text.muted}; + margin-bottom: 16px; + font-weight: 500; +`,cw=E.div` + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + background-color: ${({$online:n,theme:i})=>n?i.colors.status.online:i.colors.status.offline}; + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + letter-spacing: 0.3px; +`,dw=E.div` + display: flex; + gap: 12px; + margin-top: 24px; +`,fw=E.button` + width: 100%; + padding: 12px; + border: none; + border-radius: 8px; + background: ${({$secondary:n,theme:i})=>n?"transparent":i.colors.brand.primary}; + color: ${({$secondary:n,theme:i})=>n?i.colors.text.primary:"white"}; + cursor: pointer; + font-weight: 600; + font-size: 15px; + transition: all 0.2s ease; + border: ${({$secondary:n,theme:i})=>n?`1.5px solid ${i.colors.border.primary}`:"none"}; + + &:hover { + background: ${({$secondary:n,theme:i})=>n?i.colors.background.hover:i.colors.brand.hover}; + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } +`,pw=async()=>(await Le.get("/notifications")).data,hw=async n=>{await Le.delete(`/notifications/${n}`)},Wh=Kn(n=>({notifications:[],fetchNotifications:async()=>{const i=await pw();n({notifications:i})},readNotification:async i=>{await hw(i),n(s=>({notifications:s.notifications.filter(l=>l.id!==i)}))}}));var hs={exports:{}},mw=hs.exports,Np;function qh(){return Np||(Np=1,function(n,i){(function(s,l){n.exports=l()})(mw,function(){var s=1e3,l=6e4,c=36e5,d="millisecond",f="second",m="minute",x="hour",y="day",S="week",j="month",$="quarter",I="year",k="date",A="Invalid Date",_=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,J=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,G={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),ordinal:function(V){var z=["th","st","nd","rd"],b=V%100;return"["+V+(z[(b-20)%10]||z[b]||z[0])+"]"}},H=function(V,z,b){var W=String(V);return!W||W.length>=z?V:""+Array(z+1-W.length).join(b)+V},X={s:H,z:function(V){var z=-V.utcOffset(),b=Math.abs(z),W=Math.floor(b/60),P=b%60;return(z<=0?"+":"-")+H(W,2,"0")+":"+H(P,2,"0")},m:function V(z,b){if(z.date()1)return V(B[0])}else{var w=z.name;N[w]=z,P=w}return!W&&P&&(D=P),P||!W&&D},ge=function(V,z){if(le(V))return V.clone();var b=typeof z=="object"?z:{};return b.date=V,b.args=arguments,new Be(b)},pe=X;pe.l=Se,pe.i=le,pe.w=function(V,z){return ge(V,{locale:z.$L,utc:z.$u,x:z.$x,$offset:z.$offset})};var Be=function(){function V(b){this.$L=Se(b.locale,null,!0),this.parse(b),this.$x=this.$x||b.x||{},this[Q]=!0}var z=V.prototype;return z.parse=function(b){this.$d=function(W){var P=W.date,F=W.utc;if(P===null)return new Date(NaN);if(pe.u(P))return new Date;if(P instanceof Date)return new Date(P);if(typeof P=="string"&&!/Z$/i.test(P)){var B=P.match(_);if(B){var w=B[2]-1||0,L=(B[7]||"0").substring(0,3);return F?new Date(Date.UTC(B[1],w,B[3]||1,B[4]||0,B[5]||0,B[6]||0,L)):new Date(B[1],w,B[3]||1,B[4]||0,B[5]||0,B[6]||0,L)}}return new Date(P)}(b),this.init()},z.init=function(){var b=this.$d;this.$y=b.getFullYear(),this.$M=b.getMonth(),this.$D=b.getDate(),this.$W=b.getDay(),this.$H=b.getHours(),this.$m=b.getMinutes(),this.$s=b.getSeconds(),this.$ms=b.getMilliseconds()},z.$utils=function(){return pe},z.isValid=function(){return this.$d.toString()!==A},z.isSame=function(b,W){var P=ge(b);return this.startOf(W)<=P&&P<=this.endOf(W)},z.isAfter=function(b,W){return ge(b)0,N<=D.r||!D.r){N<=1&&X>0&&(D=G[X-1]);var Q=J[D.l];I&&(N=I(""+N)),A=typeof Q=="string"?Q.replace("%d",N):Q(N,S,D.l,_);break}}if(S)return A;var le=_?J.future:J.past;return typeof le=="function"?le(A):le.replace("%s",A)},d.to=function(y,S){return m(y,S,this,!0)},d.from=function(y,S){return m(y,S,this)};var x=function(y){return y.$u?c.utc():c()};d.toNow=function(y){return this.to(x(this),y)},d.fromNow=function(y){return this.from(x(this),y)}}})}(ms)),ms.exports}var vw=xw();const ww=mu(vw);var gs={exports:{}},Sw=gs.exports,Op;function kw(){return Op||(Op=1,function(n,i){(function(s,l){n.exports=l(qh())})(Sw,function(s){function l(f){return f&&typeof f=="object"&&"default"in f?f:{default:f}}var c=l(s),d={name:"ko",weekdays:"일요일_월요일_화요일_수요일_목요일_금요일_토요일".split("_"),weekdaysShort:"일_월_화_수_목_금_토".split("_"),weekdaysMin:"일_월_화_수_목_금_토".split("_"),months:"1월_2월_3월_4월_5월_6월_7월_8월_9월_10월_11월_12월".split("_"),monthsShort:"1월_2월_3월_4월_5월_6월_7월_8월_9월_10월_11월_12월".split("_"),ordinal:function(f){return f+"일"},formats:{LT:"A h:mm",LTS:"A h:mm:ss",L:"YYYY.MM.DD.",LL:"YYYY년 MMMM D일",LLL:"YYYY년 MMMM D일 A h:mm",LLLL:"YYYY년 MMMM D일 dddd A h:mm",l:"YYYY.MM.DD.",ll:"YYYY년 MMMM D일",lll:"YYYY년 MMMM D일 A h:mm",llll:"YYYY년 MMMM D일 dddd A h:mm"},meridiem:function(f){return f<12?"오전":"오후"},relativeTime:{future:"%s 후",past:"%s 전",s:"몇 초",m:"1분",mm:"%d분",h:"한 시간",hh:"%d시간",d:"하루",dd:"%d일",M:"한 달",MM:"%d달",y:"일 년",yy:"%d년"}};return c.default.locale(d,null,!0),d})}(gs)),gs.exports}kw();Ru.extend(ww);Ru.locale("ko");const Cw=E.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + opacity: ${({$isOpen:n})=>n?1:0}; + visibility: ${({$isOpen:n})=>n?"visible":"hidden"}; + transition: all 0.3s ease; + z-index: 1000; +`,Ew=E.div` + position: fixed; + top: 0; + right: 0; + width: 360px; + height: 100vh; + background: ${({theme:n})=>n.colors.background.primary}; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); + transform: translateX(${({$isOpen:n})=>n?"0":"100%"}); + transition: transform 0.3s ease; + z-index: 1001; + display: flex; + flex-direction: column; +`,jw=E.div` + padding: 0px 16px; + height: 48px; + font-size: 14px; + font-weight: bold; + color: ${ee.colors.text.muted}; + text-transform: uppercase; + border-bottom: 1px solid ${({theme:n})=>n.colors.border.primary}; + display: flex; + justify-content: space-between; + align-items: center; +`,Aw=E.h2` + margin: 0; + font-size: 18px; + font-weight: 600; + color: ${({theme:n})=>n.colors.text.primary}; + text-transform: none; +`,Rw=E.button` + background: none; + border: none; + padding: 8px; + cursor: pointer; + color: ${({theme:n})=>n.colors.text.muted}; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + background: ${({theme:n})=>n.colors.background.hover}; + color: ${({theme:n})=>n.colors.text.primary}; + } +`,Pw=E.div` + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + box-sizing: border-box; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: ${({theme:n})=>n.colors.background.primary}; + } + + &::-webkit-scrollbar-thumb { + background: ${({theme:n})=>n.colors.border.primary}; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb:hover { + background: ${({theme:n})=>n.colors.text.muted}; + } +`,Mw=E.div` + position: relative; + flex: 1; +`,Qh=E.button` + position: absolute; + top: 0; + right: 0; + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: ${({theme:n})=>n.colors.text.muted}; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + opacity: 0; + + &:hover { + background: ${({theme:n})=>n.colors.background.hover}; + color: ${({theme:n})=>n.colors.text.primary}; + } +`,_w=E.div` + background: ${({theme:n})=>n.colors.background.primary}; + border-radius: 8px; + padding: 16px; + cursor: pointer; + transition: all 0.2s ease; + border-left: 4px solid ${({theme:n})=>n.colors.brand.primary}; + width: 100%; + box-sizing: border-box; + position: relative; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + + ${Qh} { + opacity: 1; + } + } +`,Tw=E.h4` + color: ${({theme:n})=>n.colors.text.primary}; + margin: 0 0 8px 0; + font-size: 15px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 300px; + text-transform: none; +`,Nw=E.p` + color: ${({theme:n})=>n.colors.text.secondary}; + margin: 0 0 8px 0; + font-size: 14px; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + max-width: 300px; + word-break: break-word; + text-transform: none; +`,$w=E.span` + color: ${({theme:n})=>n.colors.text.muted}; + font-size: 12px; +`,Ow=E.div` + text-align: center; + padding: 32px 16px; + color: ${({theme:n})=>n.colors.text.muted}; + font-size: 14px; +`,Lw=E.div` + position: absolute; + top: -30px; + right: 0; + background: ${({theme:n})=>n.colors.background.secondary}; + color: ${({theme:n})=>n.colors.text.primary}; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + opacity: ${({$show:n})=>n?1:0}; + transition: opacity 0.2s ease; + pointer-events: none; + white-space: nowrap; +`,Dw=({isOpen:n,onClose:i})=>{const{notifications:s,readNotification:l}=Wh(),[c,d]=Z.useState(null),f=async x=>{await l(x.id)},m=async(x,y,S)=>{x.stopPropagation();try{await navigator.clipboard.writeText(y),d(S),setTimeout(()=>d(null),2e3)}catch(j){console.error("클립보드 복사 실패:",j)}};return h.jsxs(h.Fragment,{children:[h.jsx(Cw,{$isOpen:n,onClick:i}),h.jsxs(Ew,{$isOpen:n,children:[h.jsxs(jw,{children:[h.jsxs(Aw,{children:["알림 ",s.length>0&&`(${s.length})`]}),h.jsx(Rw,{onClick:i,children:h.jsxs("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[h.jsx("line",{x1:"18",y1:"6",x2:"6",y2:"18"}),h.jsx("line",{x1:"6",y1:"6",x2:"18",y2:"18"})]})})]}),h.jsx(Pw,{children:s.length===0?h.jsx(Ow,{children:"새로운 알림이 없습니다"}):s.map(x=>h.jsx(_w,{onClick:()=>f(x),children:h.jsxs(Mw,{children:[h.jsx(Tw,{children:x.title}),h.jsx(Nw,{children:x.content}),h.jsx($w,{children:Ru(new Date(x.createdAt)).fromNow()}),h.jsx(Qh,{onClick:y=>m(y,x.content,x.id),title:"내용 복사",children:h.jsxs("svg",{width:"16",height:"16",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[h.jsx("rect",{x:"9",y:"9",width:"13",height:"13",rx:"2",ry:"2"}),h.jsx("path",{d:"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"})]})}),h.jsx(Lw,{$show:c===x.id,children:"복사되었습니다"})]})},x.id))})]})]})},Iw=E.div` + position: relative; + cursor: pointer; + padding: 8px; + border-radius: 50%; + transition: background-color 0.2s ease; + + &:hover { + background-color: ${({theme:n})=>n.colors.background.hover}; + } +`,bw=E.div` + position: absolute; + top: 5px; + right: 5px; + background-color: ${({theme:n})=>n.colors.status.error}; + color: white; + font-size: 12px; + font-weight: 600; + min-width: 18px; + height: 18px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + transform: translate(25%, -25%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +`,zw=()=>{const{notifications:n,fetchNotifications:i}=Wh(),[s,l]=Z.useState(!1);Z.useEffect(()=>{i();const d=setInterval(i,1e4);return()=>clearInterval(d)},[i]);const c=n.length;return h.jsxs(h.Fragment,{children:[h.jsxs(Iw,{onClick:()=>l(!0),children:[h.jsxs("svg",{width:"24",height:"24",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[h.jsx("path",{d:"M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"}),h.jsx("path",{d:"M13.73 21a2 2 0 0 1-3.46 0"})]}),c>0&&h.jsx(bw,{children:c>99?"99+":c})]}),h.jsx(Dw,{isOpen:s,onClose:()=>l(!1)})]})};function Bw(){const n=Nr(f=>f.users),i=Nr(f=>f.fetchUsers),{currentUser:s}=et(),[l,c]=Z.useState(null);Z.useEffect(()=>{i()},[i]);const d=[...n].sort((f,m)=>f.id===(s==null?void 0:s.id)?-1:m.id===(s==null?void 0:s.id)?1:f.online&&!m.online?-1:!f.online&&m.online?1:f.username.localeCompare(m.username));return h.jsxs(Q1,{children:[h.jsx(G1,{children:h.jsxs(K1,{children:["멤버 목록 - ",n.length,h.jsx(zw,{})]})}),d.map(f=>h.jsx("div",{onClick:()=>c(f),children:h.jsx(ew,{member:f},f.id)},f.id)),l&&h.jsx(tw,{member:l,onClose:()=>c(null)})]})}function Fw(){const{logout:n,fetchCsrfToken:i,refreshToken:s}=et(),{fetchUsers:l}=Nr(),[c,d]=Z.useState(null),[f,m]=Z.useState(null),[x,y]=Z.useState(!1),[S,j]=Z.useState(!0),{currentUser:$}=et();Z.useEffect(()=>{i(),s()},[]),Z.useEffect(()=>{(async()=>{try{if($)try{await l()}catch(A){console.warn("사용자 상태 업데이트 실패. 로그아웃합니다.",A),n()}}catch(A){console.error("초기화 오류:",A)}finally{j(!1)}})()},[$,l,n]),Z.useEffect(()=>{const k=G=>{G!=null&&G.error&&m(G.error),G!=null&&G.alert&&y(!0)},A=()=>{n()},_=jr.on("api-error",k),J=jr.on("auth-error",A);return()=>{_("api-error",k),J("auth-error",A)}},[n]),Z.useEffect(()=>{if($){const k=setInterval(()=>{l()},6e4);return()=>{clearInterval(k)}}},[$,l]);const I=()=>{y(!1),m(null)};return S?h.jsx(Vf,{theme:ee,children:h.jsx(Hw,{children:h.jsx(Yw,{})})}):h.jsxs(Vf,{theme:ee,children:[$?h.jsxs(Uw,{children:[h.jsx(Hv,{currentUser:$,activeChannel:c,onChannelSelect:d}),h.jsx(D1,{channel:c}),h.jsx(Bw,{})]}):h.jsx(ev,{isOpen:!0,onClose:()=>{}}),h.jsx(q1,{isOpen:x,onClose:I,error:f})]})}const Uw=E.div` + display: flex; + height: 100vh; + width: 100vw; + position: relative; +`,Hw=E.div` + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + background-color: ${({theme:n})=>n.colors.background.primary}; +`,Yw=E.div` + width: 40px; + height: 40px; + border: 4px solid ${({theme:n})=>n.colors.background.tertiary}; + border-top: 4px solid ${({theme:n})=>n.colors.brand.primary}; + border-radius: 50%; + animation: spin 1s linear infinite; + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } +`,Gh=document.getElementById("root");if(!Gh)throw new Error("Root element not found");ty.createRoot(Gh).render(h.jsx(Z.StrictMode,{children:h.jsx(Fw,{})})); diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index f4fcc0e9f..fd56b43a0 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -17,7 +17,7 @@ line-height: 1.4; } - + diff --git a/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java index c5a3a46a9..ee35ecb2d 100644 --- a/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java +++ b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java @@ -48,35 +48,6 @@ class AuthControllerTest { @MockitoBean private UserService userService; - @Test - @DisplayName("현재 사용자 정보 조회 - 성공") - void me_Success() throws Exception { - // Given - UUID userId = UUID.randomUUID(); - UserDto userDto = new UserDto( - userId, - "testuser", - "test@example.com", - null, - false, - Role.USER - ); - - DiscodeitUserDetails userDetails = new DiscodeitUserDetails(userDto, "encodedPassword"); - - given(userService.find(userId)).willReturn(userDto); - - // When & Then - mockMvc.perform(get("/api/auth/me") - .with(csrf()) - .with(user(userDetails))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(userId.toString())) - .andExpect(jsonPath("$.username").value("testuser")) - .andExpect(jsonPath("$.email").value("test@example.com")) - .andExpect(jsonPath("$.role").value("USER")); - } - @Test @DisplayName("현재 사용자 정보 조회 - 인증되지 않은 사용자") void me_Unauthorized() throws Exception { diff --git a/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java index b0263b009..d33bfdd78 100644 --- a/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java +++ b/src/test/java/com/sprint/mission/discodeit/controller/BinaryContentControllerTest.java @@ -19,6 +19,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -26,7 +28,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -@WebMvcTest(BinaryContentController.class) +@WebMvcTest(value = BinaryContentController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.REGEX, + pattern = ".*\\.security\\.jwt\\..*")) @AutoConfigureMockMvc(addFilters = false) class BinaryContentControllerTest { diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java index f968bf85b..6b8b4aa31 100644 --- a/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java +++ b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java @@ -33,11 +33,16 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -@WebMvcTest(ChannelController.class) +@WebMvcTest(value = ChannelController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.REGEX, + pattern = ".*\\.security\\.jwt\\..*")) @AutoConfigureMockMvc(addFilters = false) class ChannelControllerTest { diff --git a/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java index e51eca77b..de35f7a41 100644 --- a/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java +++ b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java @@ -32,6 +32,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -40,7 +42,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -@WebMvcTest(MessageController.class) +@WebMvcTest(value = MessageController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.REGEX, + pattern = ".*\\.security\\.jwt\\..*")) @AutoConfigureMockMvc(addFilters = false) class MessageControllerTest { diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java index f1124f2ca..a91a7e9a6 100644 --- a/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java +++ b/src/test/java/com/sprint/mission/discodeit/controller/ReadStatusControllerTest.java @@ -24,11 +24,16 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -@WebMvcTest(ReadStatusController.class) +@WebMvcTest(value = ReadStatusController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.REGEX, + pattern = ".*\\.security\\.jwt\\..*")) @AutoConfigureMockMvc(addFilters = false) class ReadStatusControllerTest { diff --git a/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java index 662cc950c..cf273a16f 100644 --- a/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java +++ b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java @@ -28,12 +28,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -@WebMvcTest(UserController.class) +@WebMvcTest(value = UserController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.REGEX, + pattern = ".*\\.security\\.jwt\\..*")) @AutoConfigureMockMvc(addFilters = false) class UserControllerTest { diff --git a/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java index e0313828f..16b05fa23 100644 --- a/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java +++ b/src/test/java/com/sprint/mission/discodeit/integration/AuthApiIntegrationTest.java @@ -66,9 +66,10 @@ void login_Success() throws Exception { "password", List.of(loginRequest.password()) )))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id", notNullValue())) - .andExpect(jsonPath("$.username", is("loginuser"))) - .andExpect(jsonPath("$.email", is("login@example.com"))); + .andExpect(jsonPath("$.userDto.id", notNullValue())) + .andExpect(jsonPath("$.userDto.username", is("loginuser"))) + .andExpect(jsonPath("$.userDto.email", is("login@example.com"))) + .andExpect(jsonPath("$.accessToken", notNullValue())); } @Test diff --git a/src/test/java/com/sprint/mission/discodeit/security/LoginTest.java b/src/test/java/com/sprint/mission/discodeit/security/LoginTest.java index 9c1befeb3..b6a2c025a 100644 --- a/src/test/java/com/sprint/mission/discodeit/security/LoginTest.java +++ b/src/test/java/com/sprint/mission/discodeit/security/LoginTest.java @@ -72,10 +72,11 @@ void login_Success() throws Exception { "password", List.of(loginRequest.password()) )))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(userId.toString())) - .andExpect(jsonPath("$.username").value("testuser")) - .andExpect(jsonPath("$.email").value("test@example.com")) - .andExpect(jsonPath("$.online").value(false)); + .andExpect(jsonPath("$.userDto.id").value(userId.toString())) + .andExpect(jsonPath("$.userDto.username").value("testuser")) + .andExpect(jsonPath("$.userDto.email").value("test@example.com")) + .andExpect(jsonPath("$.userDto.online").value(false)) + .andExpect(jsonPath("$.accessToken").exists()); } @Test diff --git a/src/test/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilterTest.java b/src/test/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilterTest.java new file mode 100644 index 000000000..82f938bf0 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilterTest.java @@ -0,0 +1,153 @@ +package com.sprint.mission.discodeit.security.jwt; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.security.DiscodeitUserDetailsService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.PrintWriter; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationFilterTest { + + @Mock + private JwtTokenProvider tokenProvider; + + @Mock + private DiscodeitUserDetailsService userDetailsService; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain filterChain; + + @Mock + private SecurityContext securityContext; + + @Mock + private PrintWriter printWriter; + + @Mock + private JwtRegistry jwtRegistry; + + private JwtAuthenticationFilter jwtAuthenticationFilter; + private DiscodeitUserDetails userDetails; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + jwtAuthenticationFilter = new JwtAuthenticationFilter(tokenProvider, userDetailsService, + objectMapper, jwtRegistry); + + UUID userId = UUID.randomUUID(); + UserDto userDto = new UserDto( + userId, + "testuser", + "test@example.com", + null, + false, + Role.USER + ); + + userDetails = new DiscodeitUserDetails(userDto, "encoded-password"); + + SecurityContextHolder.setContext(securityContext); + } + + @Test + @DisplayName("JWT 인증 필터 - 유효한 토큰으로 인증 성공") + void doFilterInternal_ValidToken_SetsAuthentication() throws Exception { + // Given + String token = "valid.jwt.token"; + String username = "testuser"; + + when(request.getHeader("Authorization")).thenReturn("Bearer " + token); + given(tokenProvider.validateAccessToken(token)).willReturn(true); + given(tokenProvider.getUsernameFromToken(token)).willReturn(username); + given(userDetailsService.loadUserByUsername(username)).willReturn(userDetails); + given(jwtRegistry.hasActiveJwtInformationByAccessToken(token)).willReturn(true); + + // When + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // Then + verify(securityContext).setAuthentication(any()); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("JWT 인증 필터 - 토큰 없음, 인증 설정하지 않음") + void doFilterInternal_NoToken_DoesNotSetAuthentication() throws Exception { + // Given + when(request.getHeader("Authorization")).thenReturn(null); + + // When + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // Then + verify(securityContext, never()).setAuthentication(any()); + verify(tokenProvider, never()).validateAccessToken(any()); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("JWT 인증 필터 - 잘못된 토큰, 인증 설정하지 않음") + void doFilterInternal_InvalidToken_DoesNotSetAuthentication() throws Exception { + // Given + String token = "invalid.jwt.token"; + + when(request.getHeader("Authorization")).thenReturn("Bearer " + token); + given(tokenProvider.validateAccessToken(token)).willReturn(false); + when(response.getWriter()).thenReturn(printWriter); + + // When + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // Then + verify(securityContext, never()).setAuthentication(any()); + verify(userDetailsService, never()).loadUserByUsername(any()); + verify(filterChain, never()).doFilter(request, response); + verify(response).setStatus(401); + } + + @Test + @DisplayName("JWT 인증 필터 - Bearer 없는 Authorization 헤더, 인증 설정하지 않음") + void doFilterInternal_NonBearerToken_DoesNotSetAuthentication() throws Exception { + // Given + when(request.getHeader("Authorization")).thenReturn("Basic dGVzdDp0ZXN0"); + + // When + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // Then + verify(securityContext, never()).setAuthentication(any()); + verify(tokenProvider, never()).validateAccessToken(any()); + verify(filterChain).doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandlerTest.java b/src/test/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandlerTest.java new file mode 100644 index 000000000..ab911bfa4 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandlerTest.java @@ -0,0 +1,130 @@ +package com.sprint.mission.discodeit.security.jwt; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.nimbusds.jose.JOSEException; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; + +@ExtendWith(MockitoExtension.class) +class JwtLoginSuccessHandlerTest { + + @Mock + private JwtTokenProvider tokenProvider; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private Authentication authentication; + + @Mock + private JwtRegistry jwtRegistry; + + private JwtLoginSuccessHandler jwtLoginSuccessHandler; + private ObjectMapper objectMapper; + private DiscodeitUserDetails userDetails; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + jwtLoginSuccessHandler = new JwtLoginSuccessHandler(objectMapper, tokenProvider, jwtRegistry); + + UUID userId = UUID.randomUUID(); + UserDto userDto = new UserDto( + userId, + "testuser", + "test@example.com", + null, + false, + Role.USER + ); + + userDetails = new DiscodeitUserDetails(userDto, "encoded-password"); + } + + @Test + @DisplayName("JWT 로그인 성공 핸들러 - 성공 테스트") + void onAuthenticationSuccess_Success() throws Exception { + // Given + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + + when(response.getWriter()).thenReturn(printWriter); + when(authentication.getPrincipal()).thenReturn(userDetails); + given(tokenProvider.generateAccessToken(any(DiscodeitUserDetails.class))) + .willReturn("test.jwt.token"); + + // When + jwtLoginSuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // Then + verify(response).setCharacterEncoding("UTF-8"); + verify(response).setContentType(MediaType.APPLICATION_JSON_VALUE); + verify(response).setStatus(HttpServletResponse.SC_OK); + verify(tokenProvider).generateAccessToken(userDetails); + + String responseBody = stringWriter.toString(); + assert responseBody.contains("\"accessToken\":\"test.jwt.token\""); + assert responseBody.contains("\"username\":\"testuser\""); + } + + @Test + @DisplayName("JWT 로그인 성공 핸들러 - 토큰 생성 실패 테스트") + void onAuthenticationSuccess_TokenGenerationFailure() throws Exception { + // Given + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + + when(response.getWriter()).thenReturn(printWriter); + when(authentication.getPrincipal()).thenReturn(userDetails); + given(tokenProvider.generateAccessToken(any(DiscodeitUserDetails.class))) + .willThrow(new JOSEException("Token generation failed")); + + // When + jwtLoginSuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // Then + verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + + @Test + @DisplayName("JWT 로그인 성공 핸들러 - 잘못된 사용자 정보 테스트") + void onAuthenticationSuccess_InvalidUserDetails() throws Exception { + // Given + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + + when(response.getWriter()).thenReturn(printWriter); + when(authentication.getPrincipal()).thenReturn("invalid-user-details"); + + // When + jwtLoginSuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // Then + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/security/jwt/JwtTokenProviderTest.java b/src/test/java/com/sprint/mission/discodeit/security/jwt/JwtTokenProviderTest.java new file mode 100644 index 000000000..d1d446411 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/security/jwt/JwtTokenProviderTest.java @@ -0,0 +1,208 @@ +package com.sprint.mission.discodeit.security.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.nimbusds.jose.JOSEException; +import com.sprint.mission.discodeit.dto.data.UserDto; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class JwtTokenProviderTest { + + private JwtTokenProvider jwtTokenProvider; + private DiscodeitUserDetails userDetails; + + @BeforeEach + void setUp() throws JOSEException { + String testAccessSecret = "test-access-secret-key-for-jwt-token-generation-and-validation-must-be-long-enough"; + String testRefreshSecret = "test-refresh-secret-key-for-jwt-token-generation-and-validation-must-be-long-enough"; + int testAccessExpirationMs = 1800000; // 30 minutes + int testRefreshExpirationMs = 604800000; // 7 days + + jwtTokenProvider = new JwtTokenProvider(testAccessSecret, testAccessExpirationMs, + testRefreshSecret, testRefreshExpirationMs); + + UUID userId = UUID.randomUUID(); + UserDto userDto = new UserDto( + userId, + "testuser", + "test@example.com", + null, + false, + Role.USER + ); + + userDetails = new DiscodeitUserDetails(userDto, "encoded-password"); + } + + @Test + @DisplayName("JWT 토큰 생성 테스트") + void generateAccessToken_Success() throws JOSEException { + // When + String token = jwtTokenProvider.generateAccessToken(userDetails); + + // Then + assertThat(token).isNotNull(); + assertThat(token).isNotEmpty(); + assertThat(token.split("\\.")).hasSize(3); // JWT should have 3 parts: header.payload.signature + } + + @Test + @DisplayName("유효한 JWT 토큰 검증 테스트") + void validateToken_ValidAccessToken_ReturnsTrue() throws JOSEException { + // Given + String token = jwtTokenProvider.generateAccessToken(userDetails); + + // When + boolean isValid = jwtTokenProvider.validateAccessToken(token); + + // Then + assertThat(isValid).isTrue(); + } + + @Test + @DisplayName("잘못된 JWT 토큰 검증 테스트") + void validateToken_InvalidAccessToken_ReturnsFalse() { + // Given + String invalidToken = "invalid.jwt.token"; + + // When + boolean isValid = jwtTokenProvider.validateAccessToken(invalidToken); + + // Then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("null 토큰 검증 테스트") + void validateToken_NullAccessToken_ReturnsFalse() { + // When + boolean isValid = jwtTokenProvider.validateAccessToken(null); + + // Then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("빈 토큰 검증 테스트") + void validateToken_EmptyAccessToken_ReturnsFalse() { + // When + boolean isValid = jwtTokenProvider.validateAccessToken(""); + + // Then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("JWT 토큰에서 사용자명 추출 테스트") + void getUsernameFromToken_ValidToken_ReturnsUsername() throws JOSEException { + // Given + String token = jwtTokenProvider.generateAccessToken(userDetails); + + // When + String username = jwtTokenProvider.getUsernameFromToken(token); + + // Then + assertThat(username).isEqualTo("testuser"); + } + + @Test + @DisplayName("잘못된 토큰에서 사용자명 추출 테스트 - 예외 발생") + void getUsernameFromToken_InvalidToken_ThrowsException() { + // Given + String invalidToken = "invalid.jwt.token"; + + // When & Then + assertThatThrownBy(() -> jwtTokenProvider.getUsernameFromToken(invalidToken)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid JWT token"); + } + + @Test + @DisplayName("JWT 토큰에서 토큰 ID 추출 테스트") + void getTokenId_ValidToken_ReturnsTokenId() throws JOSEException { + // Given + String token = jwtTokenProvider.generateAccessToken(userDetails); + + // When + String tokenId = jwtTokenProvider.getTokenId(token); + + // Then + assertThat(tokenId).isNotNull(); + assertThat(tokenId).isNotEmpty(); + // UUID format check + assertThat(UUID.fromString(tokenId)).isNotNull(); + } + + @Test + @DisplayName("잘못된 토큰에서 토큰 ID 추출 테스트 - 예외 발생") + void getTokenId_InvalidToken_ThrowsException() { + // Given + String invalidToken = "invalid.jwt.token"; + + // When & Then + assertThatThrownBy(() -> jwtTokenProvider.getTokenId(invalidToken)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid JWT token"); + } + + @Test + @DisplayName("만료된 토큰 검증 테스트") + void validateToken_ExpiredAccessToken_ReturnsFalse() throws JOSEException { + // Given - Create provider with very short expiration (1ms) + JwtTokenProvider shortExpirationProvider = new JwtTokenProvider( + "test-access-secret-key-for-jwt-token-generation-and-validation-must-be-long-enough", + 1, + "test-refresh-secret-key-for-jwt-token-generation-and-validation-must-be-long-enough", + 604800000 + ); + + String token = shortExpirationProvider.generateAccessToken(userDetails); + + // Wait for token to expire + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // When + boolean isValid = shortExpirationProvider.validateAccessToken(token); + + // Then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("다른 사용자의 토큰 생성 및 검증 테스트") + void generateAccessToken_DifferentUser_HasDifferentClaims() throws JOSEException { + // Given + UUID anotherUserId = UUID.randomUUID(); + UserDto anotherUserDto = new UserDto( + anotherUserId, + "anotheruser", + "another@example.com", + null, + true, + Role.ADMIN + ); + DiscodeitUserDetails anotherUserDetails = new DiscodeitUserDetails(anotherUserDto, + "another-password"); + + // When + String token1 = jwtTokenProvider.generateAccessToken(userDetails); + String token2 = jwtTokenProvider.generateAccessToken(anotherUserDetails); + + // Then + assertThat(token1).isNotEqualTo(token2); + assertThat(jwtTokenProvider.getUsernameFromToken(token1)).isEqualTo("testuser"); + assertThat(jwtTokenProvider.getUsernameFromToken(token2)).isEqualTo("anotheruser"); + assertThat(jwtTokenProvider.getTokenId(token1)).isNotEqualTo( + jwtTokenProvider.getTokenId(token2)); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 741eb8625..12bc7a717 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -15,6 +15,15 @@ spring: init: mode: never +discodeit: + jwt: + access-token: + secret: test-access-token-secret-key-for-jwt-token-generation-and-validation-must-be-long-enough-for-testing + expiration-ms: 1800000 # 30 minutes + refresh-token: + secret: test-refresh-token-secret-key-for-jwt-token-generation-and-validation-must-be-different-and-long-for-testing + expiration-ms: 604800000 # 7 days + logging: level: com.sprint.mission.discodeit: debug From 295167ccb5216d2a51f772d5fe979fcef47a0c71 Mon Sep 17 00:00:00 2001 From: Eunhye0k Date: Mon, 10 Nov 2025 09:11:52 +0900 Subject: [PATCH 28/28] =?UTF-8?q?=EC=98=A4=EB=A5=98=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mission/discodeit/repository/NotificationRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java index 3916077c8..3fe9e815a 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java @@ -8,3 +8,4 @@ public interface NotificationRepository extends JpaRepository { List findAllByReceiverIdOrderByCreatedAtDesc(UUID receiverId); } +