diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..f17c52ff8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +/src/test +.env +discodeit.env +/.logs +/.discodeit +/logs +docker-compose.yml +Dockerfile +.github +*.md \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..b6b7d2c14 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,101 @@ +on: + push: + branches: + - release +env: + ECR_REPOSITORY_NAME: public.ecr.aws/h3g7d6d9 +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + # 1) 소스 체크아웃 + - name: Checkout + uses: actions/checkout@v3 + + # 2) AWS 자격 증명 설정 (Secrets 사용) + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ${{ vars.AWS_REGION }} + + # 3) ECR 로그인 (계정 ID를 Secret에서 참조) + - name: ECR Login + run: | + aws ecr get-login-password --region us-east-1 \ + | docker login --username AWS --password-stdin ${{env.ECR_REPOSITORY_NAME}} + + # 5) 이미지 태그 구성 + - name: Prepare tags + id: prep + run: | + echo "IMAGE_TAG=${GITHUB_SHA::7}" >> $GITHUB_ENV + + - name: Docker 이미지 빌드 및 푸시 태그:커밋해시 + id: build-image + run: | + docker buildx build \ + -t "${{vars.ECR_REPOSITORY_URI}}:latest" \ + -t "${{vars.ECR_REPOSITORY_URI}}:$IMAGE_TAG" \ + --push . + + # 9) 작업 정의 템플릿 렌더링 + - name: Render task definition + run: | + sudo apt-get update -y && sudo apt-get install -y gettext-base + export IMAGE_TAG=${{ env.IMAGE_TAG }} + envsubst < ecs-task-def.json > task-def.rendered.json + echo "=== Rendered task def ===" + cat task-def.rendered.json + + # 🔧 10) jq 설치 (JSON 파싱용) + - name: Install jq + run: sudo apt-get install -y jq + + # ✅ 11) 작업 정의 등록 & ARN 추출 + - name: Register task definition and extract ARN + id: register_task + run: | + REVISION_JSON=$(aws ecs register-task-definition \ + --cli-input-json file://task-def.rendered.json) + TASK_DEF_ARN=$(echo $REVISION_JSON | jq -r '.taskDefinition.taskDefinitionArn') + echo "TASK_DEF_ARN=$TASK_DEF_ARN" >> $GITHUB_ENV + + - name: ECS 서비스 중단 + run: | + aws ecs update-service \ + --cluster ${{vars.ECS_CLUSTER}} \ + --service ${{vars.ECS_SERVICE}} \ + --desired-count 0 + aws ecs wait services-stable \ + --cluster ${{vars.ECS_CLUSTER}} \ + --service ${{vars.ECS_SERVICE}} + + # ✅ 12) ECS 서비스 업데이트 (정확한 리비전 사용) + - name: ECS 서비스 업데이트 + run: | + aws ecs update-service \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --service ${{ vars.ECS_SERVICE }} \ + --task-definition $TASK_DEF_ARN \ + --desired-count 1 \ + --force-new-deployment + + # 13) 배포 완료 대기 + - name: ECS 배포 완료 대기 + run: | + aws ecs wait services-stable \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --services ${{ vars.ECS_SERVICE }} + + # 14) 배포 상태 확인 + - name: ECS 배포 상태 확인 + run: | + aws ecs describe-services \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --services ${{ vars.ECS_SERVICE }} \ + --query "services[0].deployments" \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..7b731af49 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +on: + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: JDK 17 설정 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + + - name: gradlew 실행 권한 + run: chmod +x gradlew + + - name: Jacoco Test + run: ./gradlew clean test jacocoTestReport + + - name: CodeCov 커버리지 + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: build/reports/jacoco/test/jacocoTestReport. + fail_ci_if_error: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 975b2bb18..938402175 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +.env +discodeit.env +.logs ### IntelliJ IDEA ### .idea/modules.xml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..a50d1f822 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# ====== build args는 반드시 FROM보다 위에 선언 ====== +# 도커 빌드 시 사용할 빌드/런타임 이미지 이름을 변수로 지정 +# ARG는 선언된 이후부터만 유효하므로 반드시 FROM보다 위에 있어야 함 +ARG BUILDER_IMAGE=gradle:7.6.0-jdk17 +ARG RUNTIME_IMAGE=amazoncorretto:17.0.7-alpine + +# ============ (1) Builder ============ +# 빌더 스테이지 시작: 지정한 Gradle + JDK 환경을 사용 +FROM ${BUILDER_IMAGE} AS builder + +# 루트 권한으로 변경 (권한 설정/폴더 생성 작업을 위해) +USER root +# 애플리케이션 작업 디렉토리 설정 +WORKDIR /app +# Gradle 캐시 디렉토리 경로를 환경 변수로 설정 (빌드 속도 향상) +ENV GRADLE_USER_HOME=/home/gradle/.gradle +# Gradle 캐시 디렉토리와 앱 디렉토리 소유자를 gradle 유저로 변경 +RUN mkdir -p $GRADLE_USER_HOME && chown -R gradle:gradle /home/gradle /app +# gradle 유저로 변경 (보안 및 권한 문제 방지) +USER gradle + +# Gradle Wrapper 스크립트 복사 (빌드 실행에 필요) +COPY --chown=gradle:gradle gradlew ./ +# gradle 폴더 복사 (wrapper 설정 및 실행 환경) +COPY --chown=gradle:gradle gradle ./gradle +# Gradle 설정 파일 복사 (빌드 스크립트) +COPY --chown=gradle:gradle build.gradle settings.gradle ./ +# gradlew 실행 권한 부여 +RUN chmod +x ./gradlew +# 의존성만 먼저 다운로드하여 캐시 활용 (코드 변경 없이 재사용 가능) +RUN ./gradlew --no-daemon --refresh-dependencies dependencies || true + +# 실제 소스코드 복사 (이 시점 이후 변경 시 빌드 다시 수행됨) +COPY --chown=gradle:gradle src ./src +# 애플리케이션 빌드 (테스트 제외, 속도 향상) +RUN ./gradlew clean build --no-daemon --no-parallel -x test + +# ============ (2) Runtime ============ +# 런타임 스테이지: 빌드 결과 실행에 필요한 최소한의 경량 이미지 사용 +FROM ${RUNTIME_IMAGE} +# 앱 실행 디렉토리 지정 +WORKDIR /app + +# 애플리케이션이 사용하는 포트 노출 +EXPOSE 80 +# Spring Boot 프로필을 운영(prod)으로 설정 +ENV PROJECT_NAME="discodeit" +ENV PROJECT_VERSION="1.2-M8" +ENV JVM_OPTS="" + +# 빌드 스테이지에서 생성한 JAR 파일만 복사 +COPY --from=builder /app/build/libs/*.jar app.jar + +# 컨테이너 시작 시 JAR 실행 +ENTRYPOINT ["java", "-jar", "app.jar", "--server.port=80"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..50173e5ab --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +[![codecov](https://codecov.io/github/NanHangBok/4-sprint-mission/branch/sprint8/graph/badge.svg?token=KYTI3YEL3E)](https://codecov.io/github/NanHangBok/4-sprint-mission) diff --git a/build.gradle b/build.gradle index 6c2bb6283..831355d8e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,12 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.3.6' + id 'org.springframework.boot' version '3.4.0' id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' } group = 'com.sprint.mission' -version = '0.0.1-SNAPSHOT' +version = '2.2-M11' java { toolchain { @@ -28,7 +29,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-actuator' 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.springframework.boot:spring-boot-starter-data-jpa' @@ -38,8 +38,44 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'com.h2database:h2' + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.apache.httpcomponents.client5:httpclient5' + implementation 'software.amazon.awssdk:s3:2.31.7' + + implementation 'org.springframework.boot:spring-boot-starter-security' + + //JWT + implementation 'com.nimbusds:nimbus-jose-jwt:10.3' + + // Retry + implementation 'org.springframework.retry:spring-retry' + // Cache + implementation 'org.springframework.boot:spring-boot-starter-cache' +// implementation 'com.github.ben-manes.caffeine:caffeine' + // Kafka + implementation 'org.springframework.kafka:spring-kafka' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // test 소스 + 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-kafka.yaml b/docker-compose-kafka.yaml new file mode 100644 index 000000000..883b7ab9e --- /dev/null +++ b/docker-compose-kafka.yaml @@ -0,0 +1,25 @@ +# docker-compose-kafka.yaml +# https://developer.confluent.io/confluent-tutorials/kafka-on-docker/#the-docker-compose-file +services: + broker: + image: apache/kafka:latest + hostname: broker + container_name: broker + ports: + - 9092:9092 + environment: + KAFKA_BROKER_ID: 1 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_NODE_ID: 1 + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker:29093 + KAFKA_LISTENERS: PLAINTEXT://broker:29092,CONTROLLER://broker:29093,PLAINTEXT_HOST://0.0.0.0:9092 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_LOG_DIRS: /tmp/kraft-combined-logs + CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk diff --git a/docker-compose-redis.yml b/docker-compose-redis.yml new file mode 100644 index 000000000..954e3ea5a --- /dev/null +++ b/docker-compose-redis.yml @@ -0,0 +1,14 @@ +# docker-compose-redis.yml +# https://developer.confluent.io/confluent-tutorials/kafka-on-docker/#the-docker-compose-file +services: + redis: + image: redis:7.2-alpine + container_name: redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + +volumes: + redis-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..252f3e789 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + postgres: + image: postgres:15 + container_name: postgres-db + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "15432:5432" + volumes: + - discodeit-data:/var/lib/postgresql/data + - ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/init.sql:ro + networks: + - backend + + app: + build: . + container_name: spring-app + environment: + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILE} + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + 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} + PRESIGNED-URL-EXPIRATION: ${AWS_S3_PRESIGNED_URL_EXPIRATION} + ports: + - "8081:8080" + depends_on: + - postgres + volumes: + - ./${STORAGE_LOCAL_ROOT_PATH}:/${STORAGE_LOCAL_ROOT_PATH} + networks: + - backend + +networks: + backend: + +volumes: + discodeit-data: { } diff --git a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java index ba9c624b9..55483fc24 100644 --- a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java +++ b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java @@ -2,10 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.retry.annotation.EnableRetry; + -@EnableJpaAuditing @SpringBootApplication +@EnableRetry public class DiscodeitApplication { public static void main(String[] args) { SpringApplication.run(DiscodeitApplication.class, args); diff --git a/src/main/java/com/sprint/mission/discodeit/auth/DiscodeitUserDetails.java b/src/main/java/com/sprint/mission/discodeit/auth/DiscodeitUserDetails.java new file mode 100644 index 000000000..4836f702c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/DiscodeitUserDetails.java @@ -0,0 +1,65 @@ +package com.sprint.mission.discodeit.auth; + +import com.sprint.mission.discodeit.dto.UserDto; +import com.sprint.mission.discodeit.entity.Role; +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; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +@Getter +@RequiredArgsConstructor +public class DiscodeitUserDetails implements UserDetails { + private final UserDto userDto; + private final String password; + + @Override + public Collection getAuthorities() { + return createAuthorities(userDto.role()); + } + + @Override + public String getUsername() { + return userDto.username(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof DiscodeitUserDetails that)) return false; + return Objects.equals(getUserDto().id(), that.getUserDto().id()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getUserDto().id()); + } + + public List createAuthorities(Role role) { + return List.of(new SimpleGrantedAuthority("ROLE_" + role)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/auth/DiscodeitUserDetailsService.java b/src/main/java/com/sprint/mission/discodeit/auth/DiscodeitUserDetailsService.java new file mode 100644 index 000000000..8672d1e26 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/DiscodeitUserDetailsService.java @@ -0,0 +1,33 @@ +package com.sprint.mission.discodeit.auth; + +import com.sprint.mission.discodeit.dto.UserDto; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ErrorCode; +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; + +@RequiredArgsConstructor +public class DiscodeitUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + private final UserMapper userMapper; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = findUserByUsername(username); + UserDto userDto = userMapper.toDto(user); + String password = user.getPassword(); + + return new DiscodeitUserDetails(userDto, password); + } + + private User findUserByUsername(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> + new UsernameNotFoundException(ErrorCode.USER_NOT_FOUND.getMessage())); + return user; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/sprint/mission/discodeit/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 000000000..0ffc88719 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,58 @@ +package com.sprint.mission.discodeit.auth.filter; + +import com.sprint.mission.discodeit.auth.provider.JwtTokenProvider; +import com.sprint.mission.discodeit.auth.registry.JwtRegistry; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationServiceException; +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.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtTokenProvider jwtTokenProvider; + private final UserDetailsService userDetailsService; + private final JwtRegistry jwtRegistry; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + String token = request.getHeader("Authorization").replace("Bearer ", ""); + if (!jwtRegistry.hasActiveJwtInformationByAccessToken(token)) { + log.warn("Access Token이 유효하지 않음 token = {}", token); + throw new AuthenticationServiceException("Invalid access token"); + } + jwtTokenProvider.verifyJws(token); + String username = jwtTokenProvider.getUsername(token); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception e) { + request.setAttribute("exception", e); + } + + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String token = request.getHeader("Authorization"); + + return token == null || !token.startsWith("Bearer "); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/auth/handler/JwtLoginSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/auth/handler/JwtLoginSuccessHandler.java new file mode 100644 index 000000000..544d8e8b5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/handler/JwtLoginSuccessHandler.java @@ -0,0 +1,76 @@ +package com.sprint.mission.discodeit.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.auth.DiscodeitUserDetails; +import com.sprint.mission.discodeit.auth.provider.JwtTokenProvider; +import com.sprint.mission.discodeit.auth.registry.JwtInformation; +import com.sprint.mission.discodeit.auth.registry.JwtRegistry; +import com.sprint.mission.discodeit.dto.JwtDto; +import com.sprint.mission.discodeit.dto.UserDto; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtLoginSuccessHandler implements AuthenticationSuccessHandler { + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private final JwtRegistry jwtRegistry; + private final CacheManager cacheManager; + + @Value("${jwt.refresh-token-expiration-minutes}") + private int expiration; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + log.info("로그인 성공 username = {}", authentication.getName()); + // cache 무효화 + Objects.requireNonNull(cacheManager.getCache("users")).evict("users"); + + DiscodeitUserDetails userDetails = (DiscodeitUserDetails) authentication.getPrincipal(); + UserDto userDto = userDetails.getUserDto(); + Map claims = new HashMap<>(); + claims.put("roles", userDto.role()); + claims.put("username", userDto.username()); + String subject = userDto.email(); + String accessToken = jwtTokenProvider.generateAccessToken(claims, subject); + String refreshToken = jwtTokenProvider.generateRefreshToken(subject); + + JwtInformation jwtInformation = new JwtInformation(userDto, accessToken, refreshToken); + if (jwtRegistry.hasActiveJwtInformationByUserId(userDto.id())) { + log.info("중복 로그인 감지 username = {}", userDto.username()); + jwtRegistry.invalidateJwtInformationByUserId(userDto.id()); + } + jwtRegistry.registerJwtInformation(jwtInformation); + // accessToken 응답Body + JwtDto jwtDto = new JwtDto(userDto, accessToken); + + // RefreshToken Cookie + Cookie refreshTokenCookie = new Cookie("REFRESH_TOKEN", refreshToken); + refreshTokenCookie.setPath("/"); + refreshTokenCookie.setMaxAge(expiration * 60); + response.addCookie(refreshTokenCookie); + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), jwtDto); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/auth/handler/JwtLogoutHandler.java b/src/main/java/com/sprint/mission/discodeit/auth/handler/JwtLogoutHandler.java new file mode 100644 index 000000000..4bb6fafe1 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/handler/JwtLogoutHandler.java @@ -0,0 +1,43 @@ +package com.sprint.mission.discodeit.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.CacheManager; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Objects; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtLogoutHandler implements LogoutHandler { + private final ObjectMapper objectMapper; + private final CacheManager cacheManager; + + @Override + public void logout(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) { + // cache 무효화 + Objects.requireNonNull(cacheManager.getCache("users")).evict("users"); + + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + Arrays.stream(request.getCookies()) + .filter(cookie -> cookie.getName().equals("REFRESH_TOKEN")) + .findFirst() + .ifPresent(cookie -> { + cookie.setMaxAge(0); // 즉시 만료 + cookie.setPath("/"); + response.addCookie(cookie); + }); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/auth/handler/LoginFailureHandler.java b/src/main/java/com/sprint/mission/discodeit/auth/handler/LoginFailureHandler.java new file mode 100644 index 000000000..b177deb0b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/handler/LoginFailureHandler.java @@ -0,0 +1,40 @@ +package com.sprint.mission.discodeit.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +public class LoginFailureHandler implements AuthenticationFailureHandler { + private final ObjectMapper objectMapper; + + public LoginFailureHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + log.warn("접근 권한이 없음 msg = {}", exception.getMessage()); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + Cookie rememberCookie = new Cookie("remember-me", null); + rememberCookie.setMaxAge(0); // 즉시 만료 + rememberCookie.setPath("/"); + Cookie sessionIdCookie = new Cookie("JSESSIONID", null); + sessionIdCookie.setMaxAge(0); // 즉시 만료 + sessionIdCookie.setPath("/"); + response.addCookie(rememberCookie); + response.addCookie(sessionIdCookie); + objectMapper.writeValue(response.getWriter(), ErrorResponse.of(exception)); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/auth/handler/LoginSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/auth/handler/LoginSuccessHandler.java new file mode 100644 index 000000000..72583d9cd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/handler/LoginSuccessHandler.java @@ -0,0 +1,38 @@ +package com.sprint.mission.discodeit.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.auth.DiscodeitUserDetails; +import com.sprint.mission.discodeit.dto.UserDto; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@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 userDetails = (DiscodeitUserDetails) authentication.getPrincipal(); + UserDto userDto = userDetails.getUserDto(); + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json"); + log.debug("접근 허용 username = {}", userDto.username()); + + objectMapper.writeValue(response.getWriter(), userDto); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/auth/provider/JwtTokenProvider.java b/src/main/java/com/sprint/mission/discodeit/auth/provider/JwtTokenProvider.java new file mode 100644 index 000000000..9809fbf36 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/provider/JwtTokenProvider.java @@ -0,0 +1,141 @@ +package com.sprint.mission.discodeit.auth.provider; + +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.nimbusds.jwt.proc.ExpiredJWTException; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.util.Date; +import java.util.Map; + +@Slf4j +@Component +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; + + public String generateAccessToken(Map claims, String subject) { + log.info("AccessToken 발급 시도 subject = {}", subject); + try { + JWSSigner signer = new MACSigner(secretKey.getBytes(StandardCharsets.UTF_8)); + + Date expiration = new Date(System.currentTimeMillis() + accessTokenExpirationMinutes * 1000 * 60); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(subject) + .claim("roles", claims.get("roles")) + .claim("username", claims.get("username")) + .expirationTime(expiration) + .issueTime(new Date()) + .issuer("discodeit") + .build(); + + SignedJWT signedJWT = new SignedJWT( + new JWSHeader(JWSAlgorithm.HS256), + claimsSet + ); + signedJWT.sign(signer); + log.info("AccessToken 발급 성공 subject = {}", subject); + return signedJWT.serialize(); + } catch (Exception e) { + throw new RuntimeException("JWT 발급 실패", e); + } + } + + public String generateRefreshToken(String subject) { + log.info("RefreshToken 발급 시도 subject = {}", subject); + try { + JWSSigner signer = new MACSigner(secretKey.getBytes(StandardCharsets.UTF_8)); + + Date expiration = new Date(System.currentTimeMillis() + refreshTokenExpirationMinutes * 60 * 1000); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(subject) + .expirationTime(expiration) + .issueTime(new Date()) + .issuer("discodeit") + .build(); + + SignedJWT signedJWT = new SignedJWT( + new JWSHeader(JWSAlgorithm.HS256), + claimsSet + ); + signedJWT.sign(signer); + log.info("RefreshToken 발급 성공 subject = {}", subject); + return signedJWT.serialize(); + } catch (Exception e) { + throw new RuntimeException("JWT 발급 실패", e); + } + } + + public Map getClaims(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + JWSVerifier verifier = new MACVerifier(secretKey.getBytes(StandardCharsets.UTF_8)); + + if (!signedJWT.verify(verifier)) { + throw new RuntimeException("JWT 검증 실패"); + } + + JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet(); + return claimsSet.getClaims(); + } catch (Exception e) { + throw new RuntimeException("JWT 파싱 실패", e); + } + } + + public void verifyJws(String jws) { + log.info("JWS 검증 시도"); + try { + SignedJWT jwt = SignedJWT.parse(jws); + jwt.verify(new MACVerifier(secretKey.getBytes(StandardCharsets.UTF_8))); + if (jwt.getJWTClaimsSet().getExpirationTime().before(new Date())) { + throw new ExpiredJWTException("Access Token Expired"); + } + log.info("JWS 검증 성공"); + } catch (Exception e) { + throw new RuntimeException("JWT 검증 실패", e); + } + } + + public String getUsername(String token) throws ParseException { + SignedJWT jwt = SignedJWT.parse(token); + String username = jwt.getJWTClaimsSet() + .getClaim("username").toString(); + return username; + } + + public boolean isExpired(String token) { + log.info("Token 유효기간 검증 시도"); + try { + Date now = new Date(); + SignedJWT jwt = SignedJWT.parse(token); + if (jwt.getJWTClaimsSet().getExpirationTime().before(now)) { + log.info("Token의 유효기간 만료 subject = {}", jwt.getJWTClaimsSet().getSubject()); + return true; + } + log.info("Token의 유효기간 검증 성공 subject = {}", jwt.getJWTClaimsSet().getSubject()); + return false; + } catch (Exception e) { + throw new RuntimeException("Token의 유효기간 검증 중 오류 발생", e); + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/auth/registry/InMemoryJwtRegistry.java b/src/main/java/com/sprint/mission/discodeit/auth/registry/InMemoryJwtRegistry.java new file mode 100644 index 000000000..60ce764cb --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/registry/InMemoryJwtRegistry.java @@ -0,0 +1,107 @@ +package com.sprint.mission.discodeit.auth.registry; + +import com.sprint.mission.discodeit.auth.provider.JwtTokenProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Map; +import java.util.Queue; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; + +@Slf4j +@Component +public class InMemoryJwtRegistry implements JwtRegistry { + private final JwtTokenProvider jwtTokenProvider; + + private final Map> origin = new ConcurrentHashMap<>(); + private final int maxActiveJwtCount = 1; + + public InMemoryJwtRegistry(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + public void registerJwtInformation(JwtInformation jwtInformation) { + log.info("JwtInformation 등록 user = {}", jwtInformation.getUserDto().username()); + if (!origin.containsKey(jwtInformation.getUserDto().id())) { + origin.put(jwtInformation.getUserDto().id(), new ConcurrentLinkedQueue<>()); + } + Queue queue = origin.get(jwtInformation.getUserDto().id()); + if (queue.size() >= maxActiveJwtCount) { + queue.poll(); + } + queue.offer(jwtInformation); + } + + @Override + public void invalidateJwtInformationByUserId(UUID userId) { + log.debug("JwtInformation 삭제 userId = {}", userId); + origin.remove(userId); + } + + @Override + public boolean hasActiveJwtInformationByUserId(UUID userId) { + log.debug("JwtInformation 등록 유저 검증 userId = {}", userId); + Queue queue = origin.get(userId); + if (queue == null || queue.isEmpty()) { + return false; + } + return true; + } + + @Override + public boolean hasActiveJwtInformationByAccessToken(String accessToken) { + log.debug("JwtInformation AccessToken 검증 accessToken = {}", accessToken); + return origin.values() + .stream() + .flatMap(Collection::stream) + .anyMatch(jwtInformation -> jwtInformation.getAccessToken().equals(accessToken)); + } + + @Override + public boolean hasActiveJwtInformationByRefreshToken(String refreshToken) { + log.debug("JwtInformation Refresh Token 검증 refreshToken = {}", refreshToken); + return origin.values() + .stream() + .flatMap(Collection::stream) + .anyMatch(jwtInformation -> jwtInformation.getRefreshToken().equals(refreshToken)); + } + + @Override + public void rotateJwtInformation(String refreshToken, JwtInformation newJwtInformation) { + log.info("JwtInformation RefreshToken Rotate user = {}", newJwtInformation.getUserDto().username()); + UUID userId = origin.values() + .stream() + .flatMap(Collection::stream) + .filter(jwtInfo -> + jwtInfo.getRefreshToken().equals(refreshToken)) + .findFirst() + .orElseThrow(() -> { + log.warn("JwtInformation 내 찾을 수 없음 refreshToken = {}", refreshToken); + throw new IllegalArgumentException("Invalid refresh token"); + }) + .getUserDto().id(); + Queue queue = origin.get(userId); + if (queue.size() >= maxActiveJwtCount) { + queue.poll(); + } + queue.offer(newJwtInformation); + } + + @Scheduled(fixedRate = 1000 * 60 * 5) + @Override + public void clearExpiredJwtInformation() { + log.info("유효기간이 만료된 JwtInformation 정리"); + origin.forEach((userId, queue) -> { + queue.removeIf(jwtInformation -> + jwtTokenProvider.isExpired(jwtInformation.getRefreshToken())); + if (queue.isEmpty()) { + origin.remove(userId); + } + }); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/auth/registry/JwtInformation.java b/src/main/java/com/sprint/mission/discodeit/auth/registry/JwtInformation.java new file mode 100644 index 000000000..eed1e0e0e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/registry/JwtInformation.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.auth.registry; + +import com.sprint.mission.discodeit.dto.UserDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class JwtInformation { + private UserDto userDto; + private String accessToken; + private String refreshToken; + + public void rotate(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/auth/registry/JwtRegistry.java b/src/main/java/com/sprint/mission/discodeit/auth/registry/JwtRegistry.java new file mode 100644 index 000000000..ef342cb19 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/auth/registry/JwtRegistry.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.auth.registry; + +import java.util.UUID; + +public interface JwtRegistry { + public void registerJwtInformation(JwtInformation jwtInformation); + + public void invalidateJwtInformationByUserId(UUID userId); + + public boolean hasActiveJwtInformationByUserId(UUID userId); + + public boolean hasActiveJwtInformationByAccessToken(String accessToken); + + public boolean hasActiveJwtInformationByRefreshToken(String refreshToken); + + void rotateJwtInformation(String refreshToken, JwtInformation newJwtInformation); + + void clearExpiredJwtInformation(); +} 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..158d3b414 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.config; + +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 { +} 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..671fc676a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/AsyncConfig.java @@ -0,0 +1,34 @@ +package com.sprint.mission.discodeit.config; + +import com.sprint.mission.discodeit.config.decorator.CompositeTaskDecorator; +import com.sprint.mission.discodeit.config.decorator.MdcTaskDecorator; +import com.sprint.mission.discodeit.config.decorator.SecurityContextTaskDecorator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.Arrays; +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean + public Executor eventTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("eventTask-"); + executor.setTaskDecorator(new CompositeTaskDecorator( + Arrays.asList( + new MdcTaskDecorator(), + new SecurityContextTaskDecorator() + ) + )); + executor.initialize(); + return executor; + } +} 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..eb5daab6d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/CacheConfig.java @@ -0,0 +1,38 @@ +package com.sprint.mission.discodeit.config; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class CacheConfig { + @Bean + public RedisCacheConfiguration redisCacheConfiguration(ObjectMapper objectMapper) { + ObjectMapper redisObjectMapper = objectMapper.copy(); + redisObjectMapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.EVERYTHING, + JsonTypeInfo.As.PROPERTY + ); + + return RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer(redisObjectMapper) + ) + ) + .prefixCacheNameWith("discodeit:") + .entryTtl(Duration.ofSeconds(600)) + .disableCachingNullValues(); + } + +} 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..6a3dd17e3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +@Component +public class MDCLoggingInterceptor implements HandlerInterceptor { + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + MDC.put("requestId", UUID.randomUUID().toString()); + MDC.put("requestMethod", request.getMethod()); + MDC.put("requestUrl", request.getRequestURI()); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + MDC.clear(); + } +} 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..b95993b2d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -0,0 +1,190 @@ +package com.sprint.mission.discodeit.config; + +import com.sprint.mission.discodeit.auth.DiscodeitUserDetailsService; +import com.sprint.mission.discodeit.auth.filter.JwtAuthenticationFilter; +import com.sprint.mission.discodeit.auth.handler.JwtLoginSuccessHandler; +import com.sprint.mission.discodeit.auth.handler.JwtLogoutHandler; +import com.sprint.mission.discodeit.auth.handler.LoginFailureHandler; +import com.sprint.mission.discodeit.auth.provider.JwtTokenProvider; +import com.sprint.mission.discodeit.auth.registry.JwtRegistry; +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.entity.User; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.AuthService; +import com.sprint.mission.discodeit.service.UserService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +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.http.SessionCreationPolicy; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.core.userdetails.UserDetailsService; +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.access.AccessDeniedHandlerImpl; +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.*; +import org.springframework.security.web.session.HttpSessionEventPublisher; +import org.springframework.util.StringUtils; + +import java.util.function.Supplier; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + @Value("${discodeit.admin.username}") + private String username; + @Value("${discodeit.admin.password}") + private String password; + @Value("${discodeit.admin.email}") + private String email; + + private final JwtTokenProvider jwtTokenProvider; + private final JwtRegistry jwtRegistry; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, + JwtLoginSuccessHandler jwtLoginSuccessHandler, + LoginFailureHandler loginFailureHandler, + UserDetailsService discodeitUserDetailsService, + SessionRegistry sessionRegistry, + JwtLogoutHandler jwtLogoutHandler) throws Exception { + http + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) + ) + .cors(Customizer.withDefaults()) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, discodeitUserDetailsService, jwtRegistry), UsernamePasswordAuthenticationFilter.class) + .formLogin(Customizer.withDefaults()) + .formLogin(login -> login + .loginProcessingUrl("/api/auth/login") + .successHandler(jwtLoginSuccessHandler) + .failureHandler(loginFailureHandler) + ) + .logout(logout -> logout + .logoutUrl("/api/auth/logout") + .addLogoutHandler(jwtLogoutHandler) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.NO_CONTENT)) + .invalidateHttpSession(true) + .clearAuthentication(true) + ) + .sessionManagement(management -> management + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth +// .requestMatchers("**").permitAll() + .requestMatchers("/api/auth/csrf-token").permitAll() + .requestMatchers(HttpMethod.POST, "/api/users").permitAll() + .requestMatchers("/api/auth/login").permitAll() + .requestMatchers("/api/auth/refresh").permitAll() + .requestMatchers("*", "/actuator/**", "/swagger-resource/**" + , "/swagger-ui.html", "/swagger-ui/**", "/v3/**", + "/assets/**").permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(new Http403ForbiddenEntryPoint()) + .accessDeniedHandler(new AccessDeniedHandlerImpl()) + ); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public RoleHierarchy roleHierarchy() { + RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl(); + roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_CHANNEL_MANAGER > ROLE_USER"); + return roleHierarchy; + } + + @Bean + public MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) { + DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); + handler.setRoleHierarchy(roleHierarchy); + return handler; + } + + 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); + } + + } + + @Bean + public SessionRegistry sessionRegistry() { + + return new SessionRegistryImpl(); + } + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + + @Bean + public UserDetailsService discodeitUserDetailsService(UserRepository userRepository, UserMapper userMapper, UserService userService, AuthService authService) { + boolean isExistsAdmin = userService.existsByUsername(username); + if (!isExistsAdmin) { + UserCreateRequest userCreateRequest = new UserCreateRequest(username, password, email); + User user = userService.createUser(userCreateRequest, null); + RoleUpdateRequest roleUpdateRequest = new RoleUpdateRequest(user.getId(), Role.ADMIN); + authService.updateRole(roleUpdateRequest); + } + return new DiscodeitUserDetailsService(userRepository, userMapper); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/StorageConfig.java b/src/main/java/com/sprint/mission/discodeit/config/StorageConfig.java index f511ce25a..51c7dbe2c 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/StorageConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/StorageConfig.java @@ -1,16 +1,37 @@ -//package com.sprint.mission.discodeit.config; -// -//import com.sprint.mission.discodeit.storage.BinaryContentStorage; -//import com.sprint.mission.discodeit.storage.LocalBinaryContentStorage; -//import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -//import org.springframework.context.annotation.Bean; -//import org.springframework.context.annotation.Configuration; -// -//@Configuration -//public class StorageConfig { -// @Bean -// @ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local") -// public BinaryContentStorage binaryContentStorage() { -// return new LocalBinaryContentStorage(); -// } -//} +package com.sprint.mission.discodeit.config; + +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import com.sprint.mission.discodeit.storage.LocalBinaryContentStorage; +import com.sprint.mission.discodeit.storage.s3.S3BinaryContentStorage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class StorageConfig { + // 로컬 저장소 + @Bean + @ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local", matchIfMissing = true) + public BinaryContentStorage localBinaryContentStorage() { + return new LocalBinaryContentStorage(); + } + + // S3 저장소 + @Bean + @ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "s3") + public BinaryContentStorage 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, + BinaryContentService binaryContentService, + ApplicationEventPublisher applicationEventPublisher) { + return new S3BinaryContentStorage(accessKey, secretKey, region, bucket, binaryContentService, applicationEventPublisher); + } +} 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 bec8b4fe1..8205e8b3a 100644 --- a/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java +++ b/src/main/java/com/sprint/mission/discodeit/config/SwaggerConfig.java @@ -2,14 +2,14 @@ import com.sprint.mission.discodeit.exception.ErrorResponse; import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.oas.models.responses.ApiResponses; import org.springdoc.core.customizers.OpenApiCustomizer; import org.springframework.context.annotation.Bean; 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..d69b6069f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final MDCLoggingInterceptor mdcLoggingInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(mdcLoggingInterceptor) + .addPathPatterns("/**"); // 모든 요청에 적용 + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/decorator/CompositeTaskDecorator.java b/src/main/java/com/sprint/mission/discodeit/config/decorator/CompositeTaskDecorator.java new file mode 100644 index 000000000..2e020004b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/decorator/CompositeTaskDecorator.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.config.decorator; + +import org.springframework.core.task.TaskDecorator; + +import java.util.List; + +public class CompositeTaskDecorator implements TaskDecorator { + + private final List decorators; + + public CompositeTaskDecorator(List decorators) { + this.decorators = decorators; + } + + @Override + public Runnable decorate(Runnable runnable) { + for (int i = decorators.size() - 1; i >= 0; i--) { + runnable = decorators.get(i).decorate(runnable); + } + return runnable; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/decorator/MdcTaskDecorator.java b/src/main/java/com/sprint/mission/discodeit/config/decorator/MdcTaskDecorator.java new file mode 100644 index 000000000..9981199b9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/decorator/MdcTaskDecorator.java @@ -0,0 +1,25 @@ +package com.sprint.mission.discodeit.config.decorator; + +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; + +import java.util.Map; + +public class MdcTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + Map contextMap = MDC.getCopyOfContextMap(); + + return () -> { + try { + if (contextMap != null) { + MDC.setContextMap(contextMap); + } + runnable.run(); + } finally { + MDC.clear(); + } + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/config/decorator/SecurityContextTaskDecorator.java b/src/main/java/com/sprint/mission/discodeit/config/decorator/SecurityContextTaskDecorator.java new file mode 100644 index 000000000..fdbd2dc08 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/decorator/SecurityContextTaskDecorator.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.config.decorator; + +import org.springframework.core.task.TaskDecorator; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityContextTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + SecurityContext context = SecurityContextHolder.getContext(); + + return () -> { + try { + SecurityContextHolder.setContext(context); + runnable.run(); + } finally { + SecurityContextHolder.clearContext(); + } + }; + } +} 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 15076f581..d94513830 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -1,31 +1,82 @@ package com.sprint.mission.discodeit.controller; +import com.sprint.mission.discodeit.auth.provider.JwtTokenProvider; +import com.sprint.mission.discodeit.auth.registry.JwtInformation; +import com.sprint.mission.discodeit.auth.registry.JwtRegistry; import com.sprint.mission.discodeit.controller.api.AuthApi; -import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.JwtDto; import com.sprint.mission.discodeit.dto.UserDto; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; +import com.sprint.mission.discodeit.dto.response.TokenResponse; import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.auth.InvalidRefreshTokenException; import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.service.AuthService; +import com.sprint.mission.discodeit.service.UserService; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; @RestController @RequiredArgsConstructor +@RequestMapping("/api/auth") +@Slf4j @Tag(name = "Auth", description = "인증 API") public class AuthController implements AuthApi { private final AuthService authService; private final UserMapper userMapper; + private final JwtTokenProvider jwtTokenProvider; + private final UserService userService; + private final JwtRegistry jwtRegistry; - @RequestMapping(method = RequestMethod.POST, value = "/api/auth/login") - public ResponseEntity login(@RequestBody LoginRequest loginRequest) { - User user = authService.login(loginRequest); - UserDto response = userMapper.toDto(user); + @GetMapping("/csrf-token") + public ResponseEntity getCsrfToken(CsrfToken csrfToken) { + String tokenValue = csrfToken.getToken(); + log.debug("CSRF 토큰 요청 : {}", tokenValue); + return ResponseEntity.status(203).build(); + } + @PutMapping("/role") + public ResponseEntity updateRole(@RequestBody RoleUpdateRequest roleUpdateRequest) { + log.info("사용자 권한 수정 요청 userId = {}", roleUpdateRequest.userId()); + User user = authService.updateRole(roleUpdateRequest); + UserDto response = userMapper.toDto(user); + log.info("사용자 권한 수정 응답 userId = {}", user.getId()); + jwtRegistry.invalidateJwtInformationByUserId(response.id()); return ResponseEntity.ok(response); } + + @PostMapping("/refresh") + public ResponseEntity reissueAccessToken(@CookieValue("REFRESH_TOKEN") String refreshToken, HttpServletResponse response) { + log.info("AccessToken 재발급 요청"); + Map claims = jwtTokenProvider.getClaims(refreshToken); + User findUser = userService.findByEmail((String) claims.get("sub")); + UserDto userDto = userMapper.toDto(findUser); + + if (!jwtRegistry.hasActiveJwtInformationByRefreshToken(refreshToken)) { + log.warn("RefreshToken이 만료 됨 refreshToken = {}", refreshToken); + throw new InvalidRefreshTokenException(ErrorCode.INVALID_REFRESH_TOKEN, Map.of("refreshToken", refreshToken)); + } + TokenResponse tokenResponse = authService.reissueToken(claims); + + JwtDto responseDto = new JwtDto(userDto, tokenResponse.accessToken()); + + Cookie newRefreshToken = new Cookie("REFRESH_TOKEN", tokenResponse.refreshToken()); + newRefreshToken.setMaxAge(jwtTokenProvider.getRefreshTokenExpirationMinutes() * 60); + newRefreshToken.setPath("/"); + response.addCookie(newRefreshToken); + + JwtInformation jwtInformation = new JwtInformation(userDto, responseDto.accessToken(), tokenResponse.refreshToken()); + jwtRegistry.rotateJwtInformation(refreshToken, jwtInformation); + log.debug("AccessToken 재발급 성공 및 Rotate 응답 완료"); + return ResponseEntity.ok(responseDto); + } } 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 d7206c481..43a857c58 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java @@ -4,15 +4,13 @@ import com.sprint.mission.discodeit.dto.BinaryContentDto; import com.sprint.mission.discodeit.entity.BinaryContent; import com.sprint.mission.discodeit.exception.BusinessLogicException; -import com.sprint.mission.discodeit.exception.ExceptionCode; +import com.sprint.mission.discodeit.exception.ErrorCode; import com.sprint.mission.discodeit.mapper.BinaryContentMapper; import com.sprint.mission.discodeit.service.BinaryContentService; import com.sprint.mission.discodeit.storage.BinaryContentStorage; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -24,36 +22,41 @@ @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/binaryContents") @Tag(name = "BinaryContent", description = "첨부 파일 API") public class BinaryContentController implements BinaryContentApi { private final BinaryContentService binaryContentService; private final BinaryContentMapper binaryContentMapper; - @Autowired(required = false) - private BinaryContentStorage binaryContentStorage; + private final BinaryContentStorage binaryContentStorage; @RequestMapping(method = RequestMethod.GET) - public ResponseEntity findBinaryContents(@Parameter(description = "조회할 첨부 파일 ID 목록") @RequestParam("binaryContentIds") List binaryContentIds) { + public ResponseEntity findBinaryContents(@RequestParam("binaryContentIds") + List binaryContentIds) { List binaryContents = binaryContentService.findAllByIdIn(binaryContentIds); List response = binaryContents.stream().map(binaryContentMapper::toDto).collect(Collectors.toList()); return ResponseEntity.ok(response); } @RequestMapping(method = RequestMethod.GET, value = "/{binary-content-id}") - public ResponseEntity findBinaryContent(@Parameter(description = "조회할 첨부 파일 ID") @PathVariable("binary-content-id") UUID binaryContentId) { + public ResponseEntity findBinaryContent(@PathVariable("binary-content-id") UUID binaryContentId) { BinaryContent binaryContent = binaryContentService.find(binaryContentId); BinaryContentDto response = binaryContentMapper.toDto(binaryContent); return ResponseEntity.ok(response); } @GetMapping(value = "/{binaryContentId}/download") - public ResponseEntity downloadBinaryContent(@Parameter(schema = @Schema(format = "uuid")) @PathVariable("binaryContentId") UUID binaryContentId) throws IOException { - if (binaryContentStorage == null) - throw new BusinessLogicException(ExceptionCode.BINARY_CONTENT_STORAGE_NOT_FOUND); + public ResponseEntity downloadBinaryContent(@PathVariable("binaryContentId") UUID binaryContentId) throws IOException { + log.info("Download 호출 id = {}", binaryContentId); + if (binaryContentStorage == null) { + log.debug("스토리지를 사용 중이지 않음"); + throw new BusinessLogicException(ErrorCode.BINARY_CONTENT_STORAGE_NOT_FOUND); + } BinaryContent binaryContent = binaryContentService.find(binaryContentId); BinaryContentDto binaryContentDto = binaryContentMapper.toDto(binaryContent); ResponseEntity response = binaryContentStorage.download(binaryContentDto); + log.debug("Download 응답 = {}", response); 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 8d45c2bee..f29548a3f 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java @@ -1,16 +1,16 @@ package com.sprint.mission.discodeit.controller; import com.sprint.mission.discodeit.controller.api.ChannelApi; +import com.sprint.mission.discodeit.dto.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.ChannelDto; import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.mapper.ChannelMapper; import com.sprint.mission.discodeit.service.ChannelService; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -21,6 +21,7 @@ @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/channels") @Tag(name = "Channel", description = "Channel API") public class ChannelController implements ChannelApi { @@ -29,40 +30,46 @@ public class ChannelController implements ChannelApi { @RequestMapping(method = RequestMethod.POST, value = "/public") public ResponseEntity createPublicChannel(@RequestBody PublicChannelCreateRequest publicChannelCreateRequest) { + log.info("POST /api/channels/public 호출"); Channel channel = channelService.createPublicChannel(publicChannelCreateRequest); ChannelDto response = channelMapper.toChannelDto(channel); - + log.debug("공개 채널 생성 응답 = {}", response); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @RequestMapping(method = RequestMethod.POST, value = "/private") public ResponseEntity createPrivateChannel(@RequestBody PrivateChannelCreateRequest privateChannelCreateRequest) { + log.info("POST /api/channels/private 호출"); Channel channel = channelService.createPrivateChannel(privateChannelCreateRequest); ChannelDto response = channelMapper.toChannelDto(channel); - + log.debug("비공개 채널 생성 응답 = {}", response); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @RequestMapping(method = RequestMethod.PATCH, value = "/{channel-id}") - public ResponseEntity updatePublicChannel(@Parameter(description = "수정할 Channel ID") @PathVariable("channel-id") UUID channelId, @RequestBody PublicChannelUpdateRequest publicChannelUpdateRequest) { + public ResponseEntity updatePublicChannel(@PathVariable("channel-id") UUID channelId, + @RequestBody PublicChannelUpdateRequest publicChannelUpdateRequest) { + log.info("PATCH /api/channels/{channel-id} 호출 id = {}", channelId); Channel channel = channelService.updateChannel(channelId, publicChannelUpdateRequest); ChannelDto response = channelMapper.toChannelDto(channel); - + log.debug("공개 채널 수정 응답 = {}", response); return ResponseEntity.ok(response); } @RequestMapping(method = RequestMethod.DELETE, value = "/{channel-id}") - public ResponseEntity deletePublicChannel(@Parameter(description = "삭제할 Channel ID") @PathVariable("channel-id") UUID channelId) { + public ResponseEntity deleteChannel(@PathVariable("channel-id") UUID channelId) { + log.info("DELETE /api/channels/{channel-id} 호출 id = {}", channelId); channelService.deleteChannel(channelId); - + log.debug("채널 삭제 응답 status = {}", HttpStatus.NO_CONTENT); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } @RequestMapping(method = RequestMethod.GET) - public ResponseEntity findChannelByUserId(@Parameter(description = "조회할 User ID") @RequestParam("userId") UUID userId) { + public ResponseEntity findChannelByUserId(@RequestParam("userId") UUID userId) { List channels = channelService.findAllByUserId(userId); List responses = channels.stream().map(channelMapper::toChannelDto).collect(Collectors.toList()); return ResponseEntity.ok(responses); } + } 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 bd3f7db1e..127ede705 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java @@ -1,9 +1,9 @@ package com.sprint.mission.discodeit.controller; import com.sprint.mission.discodeit.controller.api.MessageApi; +import com.sprint.mission.discodeit.dto.MessageDto; import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; -import com.sprint.mission.discodeit.dto.MessageDto; import com.sprint.mission.discodeit.dto.response.PageResponse; import com.sprint.mission.discodeit.entity.BinaryContent; import com.sprint.mission.discodeit.entity.Message; @@ -12,12 +12,16 @@ import com.sprint.mission.discodeit.mapper.PageResponseMapper; import com.sprint.mission.discodeit.service.BinaryContentService; import com.sprint.mission.discodeit.service.MessageService; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.*; +import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Encoding; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -32,6 +36,7 @@ @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/messages") @Tag(name = "Message", description = "Message API") public class MessageController implements MessageApi { @@ -41,9 +46,12 @@ public class MessageController implements MessageApi { private final BinaryContentService binaryContentService; private final PageResponseMapper pageResponseMapper; + @Timed("message.create.async") @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(encoding = @Encoding(name = "messageCreateRequest", contentType = MediaType.APPLICATION_JSON_VALUE))) @RequestMapping(method = RequestMethod.POST, consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) - public ResponseEntity create(@RequestPart("messageCreateRequest") MessageCreateRequest messageCreateRequest, @Parameter(description = "Message 첨부 파일들") @RequestPart(value = "attachments", required = false) List attachments) { + public ResponseEntity create(@RequestPart("messageCreateRequest") MessageCreateRequest messageCreateRequest, + @RequestPart(value = "attachments", required = false) List attachments) { + log.info("POST /api/messages 호출"); List attachmentRequests = Optional.ofNullable(attachments) .map(files -> files.stream() .map(file -> { @@ -54,27 +62,37 @@ public ResponseEntity create(@RequestPart("messageCreateRequest") MessageCreateR .orElse(new ArrayList<>()); Message message = messageService.createMessage(messageCreateRequest, attachmentRequests); MessageDto response = messageMapper.toDto(message); - + log.debug("생성 응답 = {}", response); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @RequestMapping(method = RequestMethod.PATCH, value = "/{message-id}") - public ResponseEntity update(@Parameter(description = "수정할 Message ID") @PathVariable("message-id") UUID messageId, @RequestBody MessageUpdateRequest messageUpdateRequest) { + public ResponseEntity update(@PathVariable("message-id") UUID messageId, + @RequestBody MessageUpdateRequest messageUpdateRequest) { + log.info("PATCH /api/messages/{message-id} 호출 id = {}", messageId); Message message = messageService.updateMessage(messageId, messageUpdateRequest); MessageDto response = messageMapper.toDto(message); - + log.debug("수정 호출 = {}", response); return ResponseEntity.ok(response); } @RequestMapping(method = RequestMethod.DELETE, value = "/{message-id}") - public ResponseEntity delete(@Parameter(description = "삭제할 Message ID") @PathVariable("message-id") UUID messageId) { + public ResponseEntity delete(@PathVariable("message-id") UUID messageId) { + log.info("DELETE /api/messages/{message-id} 호출 id = {}", messageId); messageService.removeMessage(messageId); - + log.debug("삭제 응답 status = {}", HttpStatus.NO_CONTENT); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } @RequestMapping(method = RequestMethod.GET) - public ResponseEntity findAllByChannelId(@Parameter(description = "조회할 Channel ID") @RequestParam UUID channelId, @Parameter(description = "페이징 커서 정보", schema = @Schema(format = "date-time")) @RequestParam Instant cursor, @Parameter(description = "페이징 정보") Pageable pageable) { + public ResponseEntity findAllByChannelId(@RequestParam UUID channelId, + @RequestParam(required = false) Instant cursor, + @PageableDefault( + size = 50, + page = 0, + sort = "createdAt", + direction = Sort.Direction.DESC + ) Pageable pageable) { Page messages = messageService.findAllByChannelId(channelId, cursor, pageable); Page messageDtos = messages.map(messageMapper::toDto); PageResponse response = pageResponseMapper.fromPage(messageDtos); 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..d599e5647 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/NotificationController.java @@ -0,0 +1,49 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.dto.NotificationDto; +import com.sprint.mission.discodeit.entity.Notification; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.mapper.NotificationMapper; +import com.sprint.mission.discodeit.service.AuthService; +import com.sprint.mission.discodeit.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +public class NotificationController { + + private final NotificationService notificationService; + private final AuthService authService; + private final NotificationMapper notificationMapper; + + @GetMapping + public ResponseEntity getNotifications(@RequestHeader("authorization") String accessToken) { + log.info("GET /api/notification 호출"); + User user = authService.getUserByAccessToken(accessToken); + List notifications = notificationService.findAllByUserId(user.getId()); + List response = notifications.stream() + .map(notification -> notificationMapper.toDto(notification)) + .collect(Collectors.toList()); + log.debug("알림 조회 응답 / 총 알림 수 = {}", response.size()); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{notificationId}") + public ResponseEntity deleteNotification(@RequestHeader("authorization") String accessToken, + @PathVariable("notificationId") UUID notificationId) { + log.info("DELETE /api/notification/{notificationId} 호출 id = {}", notificationId); + User user = authService.getUserByAccessToken(accessToken); + notificationService.delete(user, notificationId); + log.debug("알림 삭제 응답 id = {}", notificationId); + return ResponseEntity.noContent().build(); + } +} 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 02fffc6dd..707da32ff 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java @@ -1,13 +1,12 @@ package com.sprint.mission.discodeit.controller; import com.sprint.mission.discodeit.controller.api.ReadStatusApi; -import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; import com.sprint.mission.discodeit.dto.ReadStatusDto; +import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; import com.sprint.mission.discodeit.entity.ReadStatus; import com.sprint.mission.discodeit.mapper.ReadStatusMapper; import com.sprint.mission.discodeit.service.ReadStatusService; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -27,7 +26,7 @@ public class ReadStatusController implements ReadStatusApi { private final ReadStatusMapper readStatusMapper; @RequestMapping(method = RequestMethod.GET) - public ResponseEntity findALlByUserId(@Parameter(description = "조회할 User ID") @RequestParam("userId") UUID userId) { + public ResponseEntity findALlByUserId(@RequestParam("userId") UUID userId) { List readStatuses = readStatusService.findAllByUserId(userId); List response = readStatuses.stream().map(readStatusMapper::toDto).collect(Collectors.toList()); @@ -43,7 +42,8 @@ public ResponseEntity createReadStatus(@RequestBody ReadStatusCreateRequest read } @RequestMapping(method = RequestMethod.PATCH, value = "/{read-status-id}") - public ResponseEntity updateReadStatus(@Parameter(description = "수정할 읽음 상태 ID") @PathVariable("read-status-id") UUID readStatusId, @RequestBody ReadStatusUpdateRequest readStatusUpdateRequest) { + public ResponseEntity updateReadStatus(@PathVariable("read-status-id") UUID readStatusId, + @RequestBody ReadStatusUpdateRequest readStatusUpdateRequest) { ReadStatus readStatus = readStatusService.update(readStatusId, readStatusUpdateRequest); ReadStatusDto response = readStatusMapper.toDto(readStatus); 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 507827894..da1769d10 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java @@ -1,24 +1,20 @@ package com.sprint.mission.discodeit.controller; import com.sprint.mission.discodeit.controller.api.UserApi; +import com.sprint.mission.discodeit.dto.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.UserDto; -import com.sprint.mission.discodeit.dto.UserStatusDto; 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.BinaryContentMapper; import com.sprint.mission.discodeit.mapper.UserMapper; -import com.sprint.mission.discodeit.mapper.UserStatusMapper; import com.sprint.mission.discodeit.service.BinaryContentService; import com.sprint.mission.discodeit.service.UserService; -import com.sprint.mission.discodeit.service.UserStatusService; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.*; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Encoding; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -32,21 +28,20 @@ @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/users") @Tag(name = "User", description = "User API") public class UserController implements UserApi { private final UserService userService; private final UserMapper userMapper; - private final UserStatusService userStatusService; private final BinaryContentMapper binaryContentMapper; - private final UserStatusMapper userStatusMapper; private final BinaryContentService binaryContentService; @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(encoding = @Encoding(name = "userCreateRequest", contentType = MediaType.APPLICATION_JSON_VALUE))) @RequestMapping(method = RequestMethod.POST, consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) public ResponseEntity create(@RequestPart("userCreateRequest") UserCreateRequest userCreateRequest, - @Parameter(description = "User 프로필 이미지") @RequestPart(value = "profile", required = false) MultipartFile profile) { - + @RequestPart(value = "profile", required = false) MultipartFile profile) { + log.info("POST /api/users 호출"); UUID binaryContentId = Optional.ofNullable(profile) .map(file -> { BinaryContent binaryContent = binaryContentService.create(profile); @@ -54,17 +49,17 @@ public ResponseEntity create(@RequestPart("userCreateRequest") UserCreateRequest }) .orElse(null); User user = userService.createUser(userCreateRequest, binaryContentId); - userStatusService.create(user); UserDto response = userMapper.toDto(user); + log.debug("생성 응답 = {}", response); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(encoding = @Encoding(name = "userUpdateRequest", contentType = MediaType.APPLICATION_JSON_VALUE))) @RequestMapping(method = RequestMethod.PATCH, value = "/{user-id}", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) - public ResponseEntity update(@Parameter(description = "수정할 User ID") - @PathVariable("user-id") UUID userId, + public ResponseEntity update(@PathVariable("user-id") UUID userId, @RequestPart("userUpdateRequest") UserUpdateRequest userUpdateRequest, - @Parameter(description = "수정할 User 프로필 이미지") @RequestPart(value = "profile", required = false) MultipartFile profile) { + @RequestPart(value = "profile", required = false) MultipartFile profile) { + log.info("PATCH /api/users/{user-id} 호출 id = {}", userId); UUID binaryContentId = Optional.ofNullable(profile) .map(file -> { BinaryContent binaryContent = binaryContentService.create(profile); @@ -73,31 +68,28 @@ public ResponseEntity update(@Parameter(description = "수정할 User ID") .orElse(null); User user = userService.updateUser(userId, userUpdateRequest, binaryContentId); UserDto response = userMapper.toDto(user); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + + log.debug("수정 응답 = {}", response); + + return ResponseEntity.status(HttpStatus.OK).body(response); } @RequestMapping(method = RequestMethod.DELETE, value = "/{user-id}") - public ResponseEntity delete(@Parameter(description = "삭제할 User ID") - @PathVariable("user-id") UUID userId) { + public ResponseEntity delete(@PathVariable("user-id") UUID userId) { + log.info("DELETE /api/users/{user-id} 호출 id = {}", userId); userService.deleteUser(userId); - + log.debug("삭제 응답 status = {}", HttpStatus.NO_CONTENT); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } @RequestMapping(method = RequestMethod.GET) public ResponseEntity findAllUsers() { + log.info("GET /api/users 호출"); List users = userService.findAll(); - List response = users.stream().map(userMapper::toDto).collect(Collectors.toList()); - return ResponseEntity.ok(response); - } - - @RequestMapping(method = RequestMethod.PATCH, value = "/{user-id}/userStatus") - public ResponseEntity updateUserStatus(@Parameter(description = "상태를 변경할 User ID") - @PathVariable("user-id") UUID userId, - @RequestBody UserStatusUpdateRequest userStatusUpdateRequest) { - UserStatus userStatus = userStatusService.updateByUserId(userId, userStatusUpdateRequest); - UserStatusDto response = userStatusMapper.toDto(userStatus); - + List response = users.stream() + .map(userMapper::toDto) + .collect(Collectors.toList()); + log.info("전체 조회 응답 {}건 발견", response.size()); return ResponseEntity.ok(response); } } 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 84c15962d..2e2697d64 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,19 +1,16 @@ package com.sprint.mission.discodeit.controller.api; -import com.sprint.mission.discodeit.dto.UserDto; -import com.sprint.mission.discodeit.dto.request.LoginRequest; -import io.swagger.v3.oas.annotations.Operation; -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 com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.ResponseEntity; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.RequestBody; public interface AuthApi { - @Operation(summary = "로그인", operationId = "login", responses = { - @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = UserDto.class))), - @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content(schema = @Schema(ref = "#/components/schemas/ErrorResponse"))), - @ApiResponse(responseCode = "400", description = "비밀번호가 일치하지 않음", content = @Content(schema = @Schema(ref = "#/components/schemas/ErrorResponse"))) - }) - ResponseEntity login(@RequestBody LoginRequest loginRequest); + ResponseEntity getCsrfToken(CsrfToken csrfToken); + + ResponseEntity reissueAccessToken(@CookieValue("REFRESH_TOKEN") String refreshToken, HttpServletResponse response); + + ResponseEntity updateRole(@RequestBody RoleUpdateRequest roleUpdateRequest); } 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 index 9c45b49fc..a74a9a543 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java @@ -8,29 +8,38 @@ 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 jakarta.validation.constraints.NotNull; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import java.io.IOException; import java.util.List; import java.util.UUID; +@Validated public interface BinaryContentApi { @Operation(summary = "여러 첨부 파일 조회", operationId = "findAllByIdIn", responses = { @ApiResponse(responseCode = "200", description = "첨부 파일 목록 조회 성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = BinaryContentDto.class)))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) - ResponseEntity findBinaryContents(@Parameter(description = "조회할 첨부 파일 ID 목록") @RequestParam("binaryContentIds") List binaryContentIds); + ResponseEntity findBinaryContents(@Parameter(description = "조회할 첨부 파일 ID 목록") + @RequestParam("binaryContentIds") List<@NotNull(message = "잘못된 ID 입니다.") UUID> binaryContentIds); @Operation(summary = "첨부 파일 조회", operationId = "find", responses = { @ApiResponse(responseCode = "200", description = "첨부 파일 조회 성공", content = @Content(schema = @Schema(implementation = BinaryContentDto.class))), @ApiResponse(responseCode = "404", description = "첨부 파일을 찾을 수 없음", content = @Content(examples = @ExampleObject(value = "BinaryContent with id not found"))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) - ResponseEntity findBinaryContent(@Parameter(description = "조회할 첨부 파일 ID") @PathVariable("binary-content-id") UUID binaryContentId); + ResponseEntity findBinaryContent(@Parameter(description = "조회할 첨부 파일 ID") + @NotNull(message = "잘못된 ID 입니다.") + @PathVariable("binary-content-id") UUID binaryContentId); @Operation(summary = "파일 다운로드", operationId = "download", responses = { @ApiResponse(responseCode = "200", description = "파일 다운로드 성공", content = @Content(schema = @Schema(format = "binary", type = "string"))), }) - ResponseEntity downloadBinaryContent(@Parameter(schema = @Schema(format = "uuid")) @PathVariable("binaryContentId") UUID binaryContentId) throws IOException; + ResponseEntity downloadBinaryContent(@Parameter(schema = @Schema(format = "uuid")) + @NotNull(message = "잘못된 ID 입니다.") + @PathVariable("binaryContentId") UUID binaryContentId) throws IOException; } 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 index 143e51d65..0e807a825 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java @@ -11,41 +11,54 @@ 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 jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import java.util.UUID; +@Validated public interface ChannelApi { @Operation(summary = "Public Channel 생성", operationId = "create_3", responses = { @ApiResponse(responseCode = "201", description = "Public Channel이 성공적으로 생성됨", content = @Content(schema = @Schema(implementation = ChannelDto.class))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) - ResponseEntity createPublicChannel(@RequestBody PublicChannelCreateRequest publicChannelCreateRequest); + ResponseEntity createPublicChannel(@Valid @RequestBody PublicChannelCreateRequest publicChannelCreateRequest); @Operation(summary = "Private Channel 생성", operationId = "create_4", responses = { @ApiResponse(responseCode = "201", description = "Private Channel이 성공적으로 생성됨", content = @Content(schema = @Schema(implementation = ChannelDto.class))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) - ResponseEntity createPrivateChannel(@RequestBody PrivateChannelCreateRequest privateChannelCreateRequest); + ResponseEntity createPrivateChannel(@Valid @RequestBody PrivateChannelCreateRequest privateChannelCreateRequest); @Operation(summary = "Channel 정보 수정", operationId = "update_3", responses = { @ApiResponse(responseCode = "404", description = "Channel을 찾을 수 없음", content = @Content(examples = @ExampleObject(value = "Channel with id not found"))), @ApiResponse(responseCode = "400", description = "Private Channel은 수정할 수 없음", content = @Content(examples = @ExampleObject(value = "Private channel cannot be updated"))), @ApiResponse(responseCode = "200", description = "Channel 정보가 성공적으로 수정됨", content = @Content(schema = @Schema(implementation = ChannelDto.class))) }) - ResponseEntity updatePublicChannel(@Parameter(description = "수정할 Channel ID") @PathVariable("channel-id") UUID channelId, @RequestBody PublicChannelUpdateRequest publicChannelUpdateRequest); + ResponseEntity updatePublicChannel(@Parameter(description = "수정할 Channel ID") + @NotNull(message = "잘못된 ID 입니다.") + @PathVariable("channel-id") UUID channelId, + @Valid @RequestBody PublicChannelUpdateRequest publicChannelUpdateRequest); @Operation(summary = "Channel 삭제", operationId = "delete_2", responses = { @ApiResponse(responseCode = "404", description = "Channel을 찾을 수 없음", content = @Content(examples = @ExampleObject(value = "Channel with id not found"))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))), @ApiResponse(responseCode = "204", description = "Channel이 성공적으로 삭제 됨", content = @Content()) }) - ResponseEntity deletePublicChannel(@Parameter(description = "삭제할 Channel ID") @PathVariable("channel-id") UUID channelId); + ResponseEntity deleteChannel(@Parameter(description = "삭제할 Channel ID") + @NotNull(message = "잘못된 ID 입니다.") + @PathVariable("channel-id") UUID channelId); @Operation(summary = "User가 참여 중인 Channel 목록 조회", operationId = "finnAll_1", responses = { @ApiResponse(responseCode = "200", description = "Channel 목록 조회 성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class)))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) - ResponseEntity findChannelByUserId(@Parameter(description = "조회할 User ID") @RequestParam("userId") UUID userId); + ResponseEntity findChannelByUserId(@Parameter(description = "조회할 User ID") + @NotNull(message = "잘못된 ID 입니다.") + @RequestParam("userId") UUID userId); } 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 index 953914460..b7fe3c792 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java @@ -6,42 +6,64 @@ 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.*; +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 jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; import java.time.Instant; import java.util.List; import java.util.UUID; +@Validated public interface MessageApi { @Operation(summary = "Message 생성", operationId = "create_2", responses = { @ApiResponse(responseCode = "404", description = "Channel 또는 User를 찾을 수 없음", content = @Content(examples = @ExampleObject(value = "Channel | Author with id not found"))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))), @ApiResponse(responseCode = "201", description = "Message가 성공적으로 생성됨", content = @Content(schema = @Schema(implementation = MessageDto.class))), }) - ResponseEntity create(@RequestPart("messageCreateRequest") MessageCreateRequest messageCreateRequest, @Parameter(description = "Message 첨부 파일들") @RequestPart(value = "attachments", required = false) List attachments); + ResponseEntity create(@Valid @RequestPart("messageCreateRequest") MessageCreateRequest messageCreateRequest, + @Parameter(description = "Message 첨부 파일들") + @RequestPart(value = "attachments", required = false) List attachments); @Operation(summary = "Message 내용 수정", operationId = "update_2", responses = { @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 not found"))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) - ResponseEntity update(@Parameter(description = "수정할 Message ID") @PathVariable("message-id") UUID messageId, @RequestBody MessageUpdateRequest messageUpdateRequest); + ResponseEntity update(@Parameter(description = "수정할 Message ID") + @NotNull(message = "잘못된 ID 입니다.") + @PathVariable("message-id") UUID messageId, + @Valid @RequestBody MessageUpdateRequest messageUpdateRequest); @Operation(summary = "Message 삭제", operationId = "delete_1", responses = { @ApiResponse(responseCode = "204", description = "Message가 성공적으로 삭제됨", content = @Content()), @ApiResponse(responseCode = "404", description = "Message를 찾을 수 없음", content = @Content(examples = @ExampleObject(value = "Message with id not found"))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) - ResponseEntity delete(@Parameter(description = "삭제할 Message ID") @PathVariable("message-id") UUID messageId); + ResponseEntity delete(@Parameter(description = "삭제할 Message ID") + @NotNull(message = "잘못된 ID 입니다.") + @PathVariable("message-id") UUID messageId); @Operation(summary = "Channel의 Message 목록 조회", operationId = "findAllByChannelId", responses = { @ApiResponse(responseCode = "200", description = "Message 목록 조회 성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = PageResponse.class)))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) - ResponseEntity findAllByChannelId(@Parameter(description = "조회할 Channel ID") @RequestParam UUID channelId, @Parameter(description = "페이징 커서 정보", schema = @Schema(format = "date-time")) @RequestParam Instant cursor, @Parameter(description = "페이징 정보") Pageable pageable); + ResponseEntity findAllByChannelId(@Parameter(description = "조회할 Channel ID") + @NotNull(message = "잘못된 ID 입니다.") + @RequestParam UUID channelId, + @Parameter(description = "페이징 커서 정보", schema = @Schema(format = "date-time")) + @RequestParam Instant cursor, + @Parameter(description = "페이징 정보") Pageable pageable); } 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 index adf13606e..b1f848728 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java @@ -10,29 +10,39 @@ 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 jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import java.util.UUID; +@Validated public interface ReadStatusApi { @Operation(summary = "User의 Message 읽음 상태 목록 조회", operationId = "findAllByUserId", responses = { @ApiResponse(responseCode = "200", description = "Message 읽음 상태 목록 조회 성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ReadStatusDto.class)))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) - ResponseEntity findALlByUserId(@Parameter(description = "조회할 User ID") @RequestParam("userId") UUID userId); + ResponseEntity findALlByUserId(@Parameter(description = "조회할 User ID") + @NotNull(message = "잘못된 ID 입니다.") + @RequestParam("userId") UUID userId); @Operation(summary = "Message 읽은 상태 생성", operationId = "carete_1", responses = { @ApiResponse(responseCode = "404", description = "Channel 또는 User를 찾을 수 없음", content = @Content(examples = @ExampleObject(value = "Channel | User with id not found"))), @ApiResponse(responseCode = "400", description = "이미 읽음 상태가 존재함", content = @Content(examples = @ExampleObject(value = "ReadStatus with userId and channelId already exists"))), @ApiResponse(responseCode = "201", description = "Message 읽음 상태가 성공적으로 생성됨", content = @Content(schema = @Schema(implementation = ReadStatusDto.class))), }) - ResponseEntity createReadStatus(@RequestBody ReadStatusCreateRequest readStatusCreateRequest); + ResponseEntity createReadStatus(@Valid @RequestBody ReadStatusCreateRequest readStatusCreateRequest); @Operation(summary = "Message 읽음 상태 수정", operationId = "update_1", responses = { @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 not found"))), @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) - ResponseEntity updateReadStatus(@Parameter(description = "수정할 읽음 상태 ID") @PathVariable("read-status-id") UUID readStatusId, @RequestBody ReadStatusUpdateRequest readStatusUpdateRequest); + ResponseEntity updateReadStatus(@Parameter(description = "수정할 읽음 상태 ID") + @NotNull(message = "잘못된 ID 입니다.") + @PathVariable("read-status-id") UUID readStatusId, @RequestBody ReadStatusUpdateRequest readStatusUpdateRequest); } 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 index 3f14f1c12..6f6bdf34c 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java @@ -1,21 +1,25 @@ package com.sprint.mission.discodeit.controller.api; import com.sprint.mission.discodeit.dto.UserDto; -import com.sprint.mission.discodeit.dto.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.*; +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 org.springframework.http.MediaType; +import jakarta.validation.constraints.NotNull; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; import java.util.UUID; +@Validated public interface UserApi { @Operation(summary = "User 등록", operationId = "create", responses = { @ApiResponse(responseCode = "201", description = "User가 성공적으로 생성됨", content = @Content(schema = @Schema(implementation = UserDto.class))), @@ -30,6 +34,7 @@ ResponseEntity create(@RequestPart("userCreateRequest") UserCreateRequest userCr @ApiResponse(responseCode = "200", description = "User 정보가 성공적으로 수정됨", content = @Content(schema = @Schema(implementation = UserDto.class))), }) ResponseEntity update(@Parameter(description = "수정할 User ID") + @NotNull(message = "잘못된 ID 입니다.") @PathVariable("user-id") UUID userId, @RequestPart("userUpdateRequest") UserUpdateRequest userUpdateRequest, @Parameter(description = "수정할 User 프로필 이미지") @RequestPart(value = "profile", required = false) MultipartFile profile); @@ -40,6 +45,7 @@ ResponseEntity update(@Parameter(description = "수정할 User ID") @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))) }) ResponseEntity delete(@Parameter(description = "삭제할 User ID") + @NotNull(message = "잘못된 ID 입니다.") @PathVariable("user-id") UUID userId); @Operation(summary = "전체 User 목록 조회", operationId = "findAll", responses = { @@ -50,12 +56,13 @@ ResponseEntity delete(@Parameter(description = "삭제할 User ID") ResponseEntity findAllUsers(); - @Operation(summary = "User 온라인 상태 업데이트", operationId = "updateUserStatusByUserId", responses = { - @ApiResponse(responseCode = "404", description = "해당 User의 UserStatus를 찾을 수 없음", content = @Content(examples = @ExampleObject(value = "UserStatus with userId not found"))), - @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))), - @ApiResponse(responseCode = "200", description = "User 온라인 상태가 성공적으로 업데이트 됨", content = @Content(schema = @Schema(implementation = UserStatusDto.class))) - }) - ResponseEntity updateUserStatus(@Parameter(description = "상태를 변경할 User ID") - @PathVariable("user-id") UUID userId, - @RequestBody UserStatusUpdateRequest userStatusUpdateRequest); +// @Operation(summary = "User 온라인 상태 업데이트", operationId = "updateUserStatusByUserId", responses = { +// @ApiResponse(responseCode = "404", description = "해당 User의 UserStatus를 찾을 수 없음", content = @Content(examples = @ExampleObject(value = "UserStatus with userId not found"))), +// @ApiResponse(responseCode = "400", description = "유효하지 않은 입력 및 검증 실패", content = @Content(examples = @ExampleObject(value = "Invalid request body | Constraint violation"))), +// @ApiResponse(responseCode = "200", description = "User 온라인 상태가 성공적으로 업데이트 됨", content = @Content(schema = @Schema(implementation = UserStatusDto.class))) +// }) +// ResponseEntity updateUserStatus(@Parameter(description = "상태를 변경할 User ID") +// @NotNull(message = "잘못된 ID 입니다.") +// @PathVariable("user-id") UUID userId, +// @RequestBody UserStatusUpdateRequest userStatusUpdateRequest); } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/BinaryContentDto.java b/src/main/java/com/sprint/mission/discodeit/dto/BinaryContentDto.java index 83b4edfe0..8c7e820da 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/BinaryContentDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/BinaryContentDto.java @@ -1,5 +1,6 @@ package com.sprint.mission.discodeit.dto; +import com.sprint.mission.discodeit.entity.BinaryContentStatus; import io.swagger.v3.oas.annotations.media.Schema; import java.util.UUID; @@ -13,6 +14,8 @@ public record BinaryContentDto( @Schema(description = "첨부파일 이름", example = "file1") String filename, @Schema(description = "첨부파일 종류", example = "image/jpeg") - String contentType + String contentType, + @Schema(description = "첨부파일 상태", allowableValues = {"SUCCESS", "FAIL", "PROCESSING"}) + BinaryContentStatus status ) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/JwtDto.java b/src/main/java/com/sprint/mission/discodeit/dto/JwtDto.java new file mode 100644 index 000000000..0268f6bc0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/JwtDto.java @@ -0,0 +1,7 @@ +package com.sprint.mission.discodeit.dto; + +public record JwtDto( + UserDto userDto, + String accessToken +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/NotificationDto.java b/src/main/java/com/sprint/mission/discodeit/dto/NotificationDto.java new file mode 100644 index 000000000..6334f0945 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/NotificationDto.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.dto; + +import java.time.Instant; +import java.util.UUID; + +public record NotificationDto( + UUID id, + Instant createdAt, + UUID receiverId, + String title, + String content +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/ReadStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/ReadStatusDto.java index 10390b88e..a46b05b22 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/ReadStatusDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/ReadStatusDto.java @@ -13,6 +13,7 @@ public record ReadStatusDto( @Schema(description = "채널 ID", format = "uuid") UUID channelId, @Schema(description = "메시지를 읽은 최근 시간", format = "date-time") - Instant lastReadAt + Instant lastReadAt, + boolean notificationEnabled ) { -} +} \ No newline at end of file diff --git a/src/main/java/com/sprint/mission/discodeit/dto/UserDto.java b/src/main/java/com/sprint/mission/discodeit/dto/UserDto.java index 5aee0bfbc..e65792813 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/UserDto.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/UserDto.java @@ -1,5 +1,6 @@ package com.sprint.mission.discodeit.dto; +import com.sprint.mission.discodeit.entity.Role; import io.swagger.v3.oas.annotations.media.Schema; import java.util.UUID; @@ -15,7 +16,9 @@ public record UserDto( @Schema(description = "유저 프로필 이미지 ID") BinaryContentDto profile, @Schema(description = "온라인 상태", example = "true") - Boolean online + Boolean online, + @Schema(description = "유저 권한", example = "USER") + Role role ) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/UserStatusDto.java b/src/main/java/com/sprint/mission/discodeit/dto/UserStatusDto.java deleted file mode 100644 index a440f3071..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/UserStatusDto.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.sprint.mission.discodeit.dto; - -import io.swagger.v3.oas.annotations.media.Schema; - -import java.time.Instant; -import java.util.UUID; - -@Schema(description = "유저 상태 정보") -public record UserStatusDto( - @Schema(description = "유저 상태 정보 ID", format = "uuid") - UUID id, - @Schema(description = "유저 ID", format = "uuid") - UUID userId, - @Schema(description = "최근 활동 시각", format = "date-time") - Instant lastActiveAt -) { -} 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 deleted file mode 100644 index 726a86723..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/LoginRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.sprint.mission.discodeit.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(type = "object", description = "로그인 정보") -public record LoginRequest( - @Schema(description = "유저 이름", example = "Woody") - String username, - @Schema(description = "패스워드", example = "woody1234") - 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 f8afa1769..31fae3e45 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,14 +1,17 @@ package com.sprint.mission.discodeit.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; import java.util.UUID; @Schema(type = "object", description = "Message 생성 정보") public record MessageCreateRequest( @Schema(description = "메시지 작성 유저 ID", format = "uuid") + @NotNull(message = "잘못된 ID 입니다.") UUID authorId, @Schema(description = "메시지가 작성된 채널 ID", format = "uuid") + @NotNull(message = "잘못된 ID 입니다.") UUID channelId, @Schema(description = "메시지 내용(본문)", example = "Hello, World!") String content 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 462869ff7..e23d1fc54 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,6 +1,8 @@ package com.sprint.mission.discodeit.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.util.List; import java.util.UUID; @@ -8,6 +10,7 @@ @Schema(description = "Private Channel 생성 정보") public record PrivateChannelCreateRequest( @Schema(description = "비공개 채널 참가자들의 ID", format = "uuid", example = "[\"uuid-1\",\"uuid-2\"]") - List participantIds + @Size(min = 2, message = "최소 2명 이상이 대화에 참여해야 합니다.") + List<@NotNull(message = "잘못된 ID 입니다.") UUID> 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 7bae4c0f0..703dff092 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 @@ -1,12 +1,15 @@ package com.sprint.mission.discodeit.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; @Schema(description = "Public Channel 생성 정보") public record PublicChannelCreateRequest( @Schema(description = "채널 이름", example = "Notification Channel") + @NotBlank(message = "채널 이름은 필수입니다.") String name, @Schema(description = "채널 정보", example = "공지 채널입니다.") + @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 index 220ec352d..1d9b7617c 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,12 +1,15 @@ package com.sprint.mission.discodeit.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; @Schema(description = "수정할 Channel 정보") public record PublicChannelUpdateRequest( @Schema(description = "새로운 채널 이름", example = "Information Channel") + @NotBlank(message = "변경할 채널이름이 올바르지 않습니다.") String newName, @Schema(description = "새로운 채널 정보", example = "정보 채널입니다.") + @NotBlank(message = "변경할 채널 정보가 올바르지 않습니다.") 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 7239e73d9..d755ccb4d 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,6 +1,7 @@ package com.sprint.mission.discodeit.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; import java.time.Instant; import java.util.UUID; @@ -8,8 +9,10 @@ @Schema(type = "object", description = "Message 읽음 상태 생성 정보") public record ReadStatusCreateRequest( @Schema(description = "채널 ID", format = "uuid") + @NotNull(message = "잘못된 ID 입니다.") UUID channelId, @Schema(description = "유저 ID", format = "uuid") + @NotNull(message = "잘못된 ID 입니다.") UUID userId, @Schema(description = "메세지를 읽은 최근 시간", format = "date-time") 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 1a9f0ece3..60809379c 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,12 +1,16 @@ package com.sprint.mission.discodeit.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.PastOrPresent; import java.time.Instant; @Schema(description = "수정할 읽음 상태 정보") public record ReadStatusUpdateRequest( @Schema(description = "새로운(최근) 읽음 상태 시각", format = "date-time") - Instant newLastReadAt + @PastOrPresent(message = "최근 읽음 상태는 과거이거나 현재여야 합니다.") + Instant newLastReadAt, + @Schema(description = "알림 수신 여부") + Boolean newNotificationEnabled ) { } 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..60bbf6849 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/request/RoleUpdateRequest.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto.request; + +import com.sprint.mission.discodeit.entity.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +@Schema(type = "object", description = "User 권한 변경 정보") +public record RoleUpdateRequest( + @Schema(description = "권한을 변경할 유저ID", format = "uuid") + @NotNull + UUID userId, + @Schema(description = "새로운 권한", allowableValues = {"ADMIN", "CHANNEL_MANAGER", "USER"}, example = "ADMIN") + 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 index 89b111655..ae301b90a 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 @@ -1,14 +1,20 @@ package com.sprint.mission.discodeit.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; @Schema(type = "object", description = "User 생성 정보") public record UserCreateRequest( @Schema(description = "유저이름 | 아이디의 역할 수행", example = "Woody") + @NotBlank(message = "올바르지 않은 유저이름입니다.") String username, @Schema(description = "패스워드", example = "woody1234") + @NotBlank(message = "올바르지 않은 패스워드입니다.") String password, @Schema(description = "이메일 | 동시에 하나만 존재 가능", example = "woody@codeit.com", format = "email") + @Email(message = "이메일 형식이 아닙니다.") + @NotBlank(message = "이메일은 필수항목입니다.") String email ) { } 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 deleted file mode 100644 index 7b948a8ce..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/request/UserStatusUpdateRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sprint.mission.discodeit.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -import java.time.Instant; - -@Schema(description = "변경할 User 온라인 상태 정보") -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 index 1e26603b2..f2039c887 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,14 +1,20 @@ package com.sprint.mission.discodeit.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; @Schema(description = "수정할 User 정보") public record UserUpdateRequest( @Schema(description = "새로운 유저 이름", example = "Buzz") + @NotBlank(message = "유저 이름은 필수 항목입니다.") String newUsername, @Schema(description = "새로운 유저 이메일 | 중복 불가", example = "buzz@codeit.com", format = "email") + @NotBlank(message = "이메일은 필수 항목입니다.") + @Email(message = "이메일의 형식이 아닙니다.") String newEmail, @Schema(description = "새로운 비밀번호", example = "buzz1234") + @NotBlank(message = "비밀번호는 필수 항목입니다.") String newPassword ) { } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/TokenResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/response/TokenResponse.java new file mode 100644 index 000000000..de6e52c70 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/response/TokenResponse.java @@ -0,0 +1,7 @@ +package com.sprint.mission.discodeit.dto.response; + +public record TokenResponse( + String accessToken, + String refreshToken +) { +} 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 207201aa0..2b0fa1a03 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java @@ -1,30 +1,36 @@ package com.sprint.mission.discodeit.entity; -import com.sprint.mission.discodeit.entity.base.BaseEntity; +import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; import jakarta.persistence.*; -import lombok.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import org.springframework.web.multipart.MultipartFile; -import java.io.Serializable; +import java.util.UUID; @Getter @Setter @NoArgsConstructor @Entity @Table(name = "binary_contents") -public class BinaryContent extends BaseEntity implements Serializable { +public class BinaryContent extends BaseUpdatableEntity { @Column(nullable = false) private String contentType; @Column(nullable = false) private Long size; @Column(nullable = false) private String fileName; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private BinaryContentStatus status; public BinaryContent(String contentType, Long size, String fileName) { super(); this.contentType = contentType; this.size = size; this.fileName = fileName; + this.status = BinaryContentStatus.PROCESSING; } public BinaryContent(MultipartFile file) { @@ -32,6 +38,12 @@ public BinaryContent(MultipartFile file) { this.contentType = file.getContentType(); this.size = file.getSize(); this.fileName = file.getOriginalFilename(); + this.status = BinaryContentStatus.PROCESSING; + } + + private BinaryContent(UUID id, MultipartFile file) { + this(file); + setId(id); } @Override @@ -42,6 +54,14 @@ public String toString() { ", contentType=" + contentType + '}'; } + + public static BinaryContent of(UUID id, MultipartFile file) { + return new BinaryContent(id, file); + } + + 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..bf0b6e192 --- /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/Channel.java b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java index a93cfc933..6eafde21e 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/Channel.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java @@ -5,12 +5,14 @@ import lombok.Getter; import lombok.Setter; +import java.util.UUID; + @Getter @Setter @Entity @Table(name = "channels") public class Channel extends BaseUpdatableEntity { - @Column + @Column() private String name; @Column(nullable = false) @Enumerated(EnumType.STRING) @@ -30,6 +32,14 @@ public Channel() { this.type = ChannelType.PRIVATE; } + private Channel(UUID uuid, String name, String description, ChannelType type) { + super(); + setId(uuid); + this.name = name; + this.description = description; + this.type = type; + } + @Override public String toString() { if (type == ChannelType.PUBLIC) { @@ -48,4 +58,8 @@ public String toString() { } return "Invalid type provided"; } + + public static Channel of(UUID uuid, String name, String description, ChannelType type) { + return new Channel(uuid, name, description, type); + } } 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 0b157ab6a..bce115c4f 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/Message.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/Message.java @@ -39,6 +39,11 @@ public Message(String content, Channel channel, User author) { this.author = author; } + private Message(UUID id, Channel channel, User author, String content) { + this(content, channel, author); + setId(id); + } + @Override public String toString() { return "Message{" + @@ -59,8 +64,11 @@ public UUID getChannelId() { return channel.getId(); } - public void setContent(String content) { this.content = content; } + + public static Message of(UUID id, Channel channel, User author, String content) { + return new Message(id, channel, author, content); + } } 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..dcc59fd47 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Notification.java @@ -0,0 +1,30 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.entity.base.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "notifications") +public class Notification extends BaseEntity { + @ManyToOne + @JoinColumn(name = "receiver_id") + private User receiver; + + @Column + private String title; + + @Column + private String content; + + public Notification(User receiver, String title, String content) { + this.receiver = receiver; + 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 a4f79fe6a..093ef127b 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java @@ -23,26 +23,31 @@ public class ReadStatus extends BaseUpdatableEntity { @Column(nullable = false) private Instant lastReadAt; - @ManyToOne + @Column(nullable = false) + private boolean notificationEnabled; + + @ManyToOne(cascade = CascadeType.ALL) @JoinColumn(name = "user_id", nullable = false) private User user; - @ManyToOne - @JoinColumn(name = "channel_id", nullable= false) + @ManyToOne(cascade = {CascadeType.ALL}) + @JoinColumn(name = "channel_id", nullable = false) private Channel channel; public ReadStatus(User user, Channel channel) { - super(); - this.user = user; - this.channel = channel; - this.lastReadAt = Instant.now(); + this(user, channel, Instant.now()); } public ReadStatus(User user, Channel channel, Instant lastReadAt) { - super(); this.user = user; this.channel = channel; this.lastReadAt = lastReadAt; + this.notificationEnabled = channel.getType().equals(ChannelType.PUBLIC); + } + + private ReadStatus(UUID id, User user, Channel channel) { + this(user, channel); + setId(id); } @Override @@ -61,10 +66,8 @@ public UUID getUserId() { public UUID getChannelId() { return channel.getId(); } -// public ReadStatus(UUID userId, UUID channelId, Instant lastReadAt) { -// super(); -// this.userId = userId; -// this.channelId = channelId; -// this.lastReadAt = lastReadAt; -// } + + public static ReadStatus of(UUID id, User user, Channel channel) { + return new ReadStatus(id, user, channel); + } } 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..8bbd189a2 --- /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 index cf4b3c69a..6810c073b 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/User.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/User.java @@ -1,11 +1,13 @@ package com.sprint.mission.discodeit.entity; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import java.util.Objects; import java.util.UUID; @Getter @@ -20,19 +22,42 @@ public class User extends BaseUpdatableEntity { private String email; @Column(nullable = false) private String password; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Role role; - @OneToOne(cascade = {CascadeType.PERSIST,CascadeType.REMOVE}, orphanRemoval = true) + @OneToOne(cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true) @JoinColumn(name = "profile_id") private BinaryContent profile; - @OneToOne(orphanRemoval = true, cascade = {CascadeType.ALL}, mappedBy = "user") - private UserStatus status; + public void updateRole(Role role) { + this.role = role; + } public User(String username, String password, String email, BinaryContent profile) { this.username = username; this.email = email; this.password = password; this.profile = profile; + this.role = Role.USER; + } + + public User(UserCreateRequest request, BinaryContent profile) { + super(); + this.username = request.username(); + this.email = request.email(); + this.password = request.password(); + this.profile = profile; + this.role = Role.USER; + } + + private User(UUID uuid, String username, String email, String password, BinaryContent profile) { + setId(uuid); + this.username = username; + this.email = email; + this.password = password; + this.profile = profile; + this.role = Role.USER; } @Override @@ -47,7 +72,20 @@ public String toString() { '}'; } - public UUID getProfileId() { - return profile.getId(); + public static User of(UUID uuid, String username, String email, String password, BinaryContent profile) { + return new User(uuid, username, email, password, profile); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof User user)) return false; + if (!super.equals(o)) return false; + return Objects.equals(getId(), user.getId()); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), getId()); } } + diff --git a/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java deleted file mode 100644 index 5d29d6db0..000000000 --- a/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.sprint.mission.discodeit.entity; - -import com.sprint.mission.discodeit.entity.base.BaseUpdatableEntity; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.time.Instant; -import java.util.UUID; - -@Getter -@Setter -@AllArgsConstructor -@NoArgsConstructor -@Entity -@Table(name = "user_statuses") -public class UserStatus extends BaseUpdatableEntity { - @Column(nullable = false) - private Instant lastActiveAt; - @OneToOne - @JoinColumn(name = "user_id", nullable = false, unique = true) - private User user; - - public UserStatus(User user, Instant lastActiveAt) { - super(); - this.user = user; - this.lastActiveAt = lastActiveAt; - } - - public boolean isRecentlyActive() { - return lastActiveAt.isAfter(Instant.now().minusSeconds(300)); - } - - @Override - public String toString() { - return "UserStatus{" + - "userId=" + user.getId() + - ", lastActiveAt=" + lastActiveAt + - ", online=" + this.isOnline() + - '}'; - } - - public UUID getUserId() { - return user.getId(); - } - - public void update(Instant newLastActiveAt) { - if (lastActiveAt.isAfter(newLastActiveAt)) { - System.out.println("가지고 있는 데이터가 더 최신입니다."); - return; - } - this.lastActiveAt = newLastActiveAt; - } - - public boolean isOnline() { - return isRecentlyActive(); - } -} 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 7f9dadcf4..f234f371b 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 @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.Getter; +import lombok.Setter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -11,6 +12,7 @@ import java.util.UUID; @Getter +@Setter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseEntity implements Serializable { 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..3c10b3077 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEvent.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.event; + +import java.util.UUID; + +public record BinaryContentCreatedEvent( + UUID binaryContentId, + byte[] bytes +) { +} \ No newline at end of file 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..1172eefcb --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEvent.java @@ -0,0 +1,8 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.entity.Message; + +public record MessageCreatedEvent( + Message message +) { +} 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..0ac727b51 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEvent.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.entity.User; + +public record RoleUpdatedEvent( + User user, + Role oldRole, + Role newRole +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/S3UploadFailedEvent.java b/src/main/java/com/sprint/mission/discodeit/event/S3UploadFailedEvent.java new file mode 100644 index 000000000..d9cd8365f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/S3UploadFailedEvent.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.exception.DiscodeitException; + +import java.util.UUID; + +public record S3UploadFailedEvent( + DiscodeitException exception, + UUID binaryContentId +) { +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/kafka/event_listener/KafkaProduceRequiredEventListener.java b/src/main/java/com/sprint/mission/discodeit/event/kafka/event_listener/KafkaProduceRequiredEventListener.java new file mode 100644 index 000000000..3ca316072 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/kafka/event_listener/KafkaProduceRequiredEventListener.java @@ -0,0 +1,48 @@ +package com.sprint.mission.discodeit.event.kafka.event_listener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.event.MessageCreatedEvent; +import com.sprint.mission.discodeit.event.RoleUpdatedEvent; +import com.sprint.mission.discodeit.event.S3UploadFailedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@RequiredArgsConstructor +@Component +public class KafkaProduceRequiredEventListener { + + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @Async("eventTaskExecutor") + @TransactionalEventListener + public void on(MessageCreatedEvent event) throws JsonProcessingException { + log.info("kafka 이벤트 호출 메시지 생성 알림"); + String payload = objectMapper.writeValueAsString(event); + kafkaTemplate.send("discodeit.MessageCreatedEvent", payload); + } + + @Async("eventTaskExecutor") + @TransactionalEventListener + public void on(RoleUpdatedEvent event) throws JsonProcessingException { + log.info("kafka 이벤트 호출 유저 상태 변경 알림"); + String payload = objectMapper.writeValueAsString(event); + kafkaTemplate.send("discodeit.RoleUpdatedEvent", payload); + } + + @Async("eventTaskExecutor") + @EventListener + public void on(S3UploadFailedEvent event) throws JsonProcessingException { + log.info("kafka 이벤트 호출 S3 파일 업로드 실패 알림"); + String payload = objectMapper.writeValueAsString(event); + kafkaTemplate.send("discodeit.S3UploadFailedEvent", payload); + } +} + diff --git a/src/main/java/com/sprint/mission/discodeit/event/kafka/topic_listener/NotificationRequiredTopicListener.java b/src/main/java/com/sprint/mission/discodeit/event/kafka/topic_listener/NotificationRequiredTopicListener.java new file mode 100644 index 000000000..c959bdd80 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/kafka/topic_listener/NotificationRequiredTopicListener.java @@ -0,0 +1,61 @@ +package com.sprint.mission.discodeit.event.kafka.topic_listener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.entity.BinaryContentStatus; +import com.sprint.mission.discodeit.event.MessageCreatedEvent; +import com.sprint.mission.discodeit.event.RoleUpdatedEvent; +import com.sprint.mission.discodeit.event.S3UploadFailedEvent; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class NotificationRequiredTopicListener { + private final NotificationService notificationService; + private final ObjectMapper objectMapper; + private final BinaryContentService binaryContentService; + + @KafkaListener(topics = "discodeit.MessageCreatedEvent") + public void onMessageCreatedEvent(String kafkaEvent) { + try { + log.info("kafka 이벤트 수신 메시지 생성 알림"); + MessageCreatedEvent event = objectMapper.readValue(kafkaEvent, + MessageCreatedEvent.class); + notificationService.create(event.message()); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + @KafkaListener(topics = "discodeit.RoleUpdatedEvent") + public void onRoleUpdatedEvent(String kafkaEvent) { + try { + log.info("kafka 이벤트 수신 유저 권한 수정 알림"); + RoleUpdatedEvent event = objectMapper.readValue(kafkaEvent, + RoleUpdatedEvent.class); + notificationService.create(event.user(), event.oldRole(), event.newRole()); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + @KafkaListener(topics = "discodeit.S3UploadFailedEvent") + public void onS3UploadFailedEvent(String kafkaEvent) { + try { + log.info("kafka 이벤트 수신 S3 업로드 실패 알림"); + S3UploadFailedEvent event = objectMapper.readValue(kafkaEvent, + S3UploadFailedEvent.class); + binaryContentService.updateStatus(event.binaryContentId(), BinaryContentStatus.FAIL); + notificationService.create(event.exception()); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} + diff --git a/src/main/java/com/sprint/mission/discodeit/event/listener/BinaryContentEventListener.java b/src/main/java/com/sprint/mission/discodeit/event/listener/BinaryContentEventListener.java new file mode 100644 index 000000000..4c8ffa74d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/listener/BinaryContentEventListener.java @@ -0,0 +1,24 @@ +package com.sprint.mission.discodeit.event.listener; + +import com.sprint.mission.discodeit.event.BinaryContentCreatedEvent; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BinaryContentEventListener { + private final BinaryContentStorage s3BinaryContentStorage; + + @Async("eventTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(BinaryContentCreatedEvent event) { + log.info("kafka 파일 업로드 이벤트 수행"); + s3BinaryContentStorage.put(event.binaryContentId(), event.bytes()); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/listener/NotificationRequiredEventListener.java b/src/main/java/com/sprint/mission/discodeit/event/listener/NotificationRequiredEventListener.java new file mode 100644 index 000000000..7c79feaa7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/listener/NotificationRequiredEventListener.java @@ -0,0 +1,28 @@ +//package com.sprint.mission.discodeit.event.listener; +// +//import com.sprint.mission.discodeit.event.MessageCreatedEvent; +//import com.sprint.mission.discodeit.event.RoleUpdatedEvent; +//import com.sprint.mission.discodeit.service.NotificationService; +//import lombok.RequiredArgsConstructor; +//import org.springframework.scheduling.annotation.Async; +//import org.springframework.stereotype.Component; +//import org.springframework.transaction.event.TransactionalEventListener; +// +//@Component +//@RequiredArgsConstructor +//public class NotificationRequiredEventListener { +// +// private final NotificationService notificationService; +// +// @Async +// @TransactionalEventListener +// public void on(MessageCreatedEvent event) { +// notificationService.create(event.message()); +// } +// +// @Async +// @TransactionalEventListener +// public void on(RoleUpdatedEvent event) { +// notificationService.create(event.user(), event.oldRole(), event.newRole()); +// } +//} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/BusinessLogicException.java b/src/main/java/com/sprint/mission/discodeit/exception/BusinessLogicException.java index 8a057665f..465a13c2d 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/BusinessLogicException.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/BusinessLogicException.java @@ -4,10 +4,10 @@ public class BusinessLogicException extends RuntimeException { @Getter - private final ExceptionCode exceptionCode; + private final ErrorCode errorCode; - public BusinessLogicException(ExceptionCode exceptionCode) { - super(exceptionCode.getMessage()); - this.exceptionCode = exceptionCode; + public BusinessLogicException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; } } 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..ab59bce37 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.exception; + +import lombok.Getter; + +import java.time.Instant; +import java.util.Map; + +@Getter +public class DiscodeitException extends RuntimeException { + private final ErrorCode errorCode; + private final Instant timestamp; + private final Map details; + + public DiscodeitException(ErrorCode errorCode, Map details) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.timestamp = Instant.now(); + this.details = details; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ExceptionCode.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java similarity index 68% rename from src/main/java/com/sprint/mission/discodeit/exception/ExceptionCode.java rename to src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java index 0af65c510..13af829cb 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/ExceptionCode.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorCode.java @@ -2,9 +2,9 @@ import lombok.Getter; -public enum ExceptionCode { +public enum ErrorCode { USER_NOT_FOUND(404, "User Not Found"), - EMAIL_OR_USERNAME_ALREADY_EXISTS(400, "User With Email Already Exists"), + EMAIL_OR_USERNAME_ALREADY_EXISTS(400, "Username Or Email Already Exists"), CHANNEL_OR_USER_NOT_FOUND(404, "Channel | User With ID Not Found"), READSTATUS_ALREADY_EXISTS(400, "ReadStatus With UserId and ChannelId Already Exists"), WRONG_PASSWORD(400, "Wrong Password"), @@ -16,9 +16,15 @@ public enum ExceptionCode { BINARY_CONTENT_NOT_FOUND(404, "BinaryContent Not Found"), BINARY_CONTENT_EXISTS(400, "BinaryContent Exists"), + BINARY_CONTENT_UPLOAD_FAIL(400, "BinaryContent Upload Fail"), USER_ALREADY_EXISTS_USERSTATUS(400, "User Already Exists UserStatus"), INVALID_PAST_TIME(400, "Time Is Earlier Than Saved Time"), - BINARY_CONTENT_STORAGE_NOT_FOUND(404,"BinaryContent Storage Not Found"); + BINARY_CONTENT_STORAGE_NOT_FOUND(404, "BinaryContent Storage Not Found"), + AlREADY_EXISTS_CHANNEL_NAME(400, "Channel Name Already Exists"), + + INVALID_REFRESH_TOKEN(401, "Invalid Refresh Token"), + + NOTIFICATION_NOT_FOUND(404, "Notification Not Found"); @Getter private final int status; @@ -26,7 +32,7 @@ public enum ExceptionCode { @Getter private final String message; - ExceptionCode(int status, String message) { + ErrorCode(int status, String message) { this.status = status; this.message = message; } 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 3e31ce1fa..77617beb9 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java @@ -1,16 +1,23 @@ package com.sprint.mission.discodeit.exception; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.ConstraintViolation; import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import org.springframework.validation.BindingResult; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @Getter +@NoArgsConstructor @Schema(description = "전역 예외 처리") public class ErrorResponse { private List fieldErrors; @@ -21,16 +28,43 @@ public class ErrorResponse { private Instant timestamp; @Schema(description = "에러 내용", example = "Bad Request!") private String message; + private String code; + private String exceptionType; + private Map details; + private ErrorResponse(List fieldErrors, List violationErrors) { this.fieldErrors = fieldErrors; this.violationErrors = violationErrors; } - private ErrorResponse(ExceptionCode exceptionCode, Instant timestamp, String message) { - this.status = exceptionCode.getStatus(); + private ErrorResponse(ErrorCode errorCode, Instant timestamp, String message) { + this.status = errorCode.getStatus(); + this.timestamp = timestamp; + this.message = message; + } + + public ErrorResponse(int status, Instant timestamp, String message, String code, String exceptionType, Map details) { + this.status = status; this.timestamp = timestamp; this.message = message; + this.code = code; + this.exceptionType = exceptionType; + this.details = details; + } + + public ErrorResponse(AuthenticationException exception) { + this.status = HttpServletResponse.SC_UNAUTHORIZED; + this.timestamp = Instant.now(); + this.message = exception.getMessage(); + this.details = Map.of("authorized", "권한이 없습니다."); + } + + public ErrorResponse(AccessDeniedException exception) { + this.status = HttpStatus.FORBIDDEN.value(); + this.timestamp = Instant.now(); + this.message = "권한이 없습니다."; + this.details = Map.of("authorized", "권한이 없습니다."); } public static ErrorResponse of(BindingResult bindingResult) { @@ -42,7 +76,19 @@ public static ErrorResponse of(Set> violations) { } public static ErrorResponse of(BusinessLogicException businessLogicException) { - return new ErrorResponse(businessLogicException.getExceptionCode(), Instant.now(), businessLogicException.getMessage()); + return new ErrorResponse(businessLogicException.getErrorCode(), Instant.now(), businessLogicException.getMessage()); + } + + public static ErrorResponse of(DiscodeitException discodeitException) { + return new ErrorResponse(discodeitException.getErrorCode().getStatus(), Instant.now(), discodeitException.getMessage(), discodeitException.getErrorCode().name(), discodeitException.getClass().getSimpleName(), discodeitException.getDetails()); + } + + public static ErrorResponse of(AuthenticationException exception) { + return new ErrorResponse(exception); + } + + public static ErrorResponse of(AccessDeniedException exception) { + return new ErrorResponse(exception); } @Getter 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 8f9192473..20420bfbd 100644 --- a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import jakarta.validation.ConstraintViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; @@ -10,13 +11,19 @@ @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler - @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException(AccessDeniedException ex) { + final ErrorResponse errorResponse = ErrorResponse.of(ex); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValidException( MethodArgumentNotValidException e) { final ErrorResponse response = ErrorResponse.of(e.getBindingResult()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); } @ExceptionHandler @@ -28,6 +35,13 @@ public ResponseEntity handleConstraintViolationException( return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } + @ExceptionHandler(DiscodeitException.class) + public ResponseEntity handleDiscodeitException(DiscodeitException discodeitException) { + final ErrorResponse response = ErrorResponse.of(discodeitException); + + return ResponseEntity.status(response.getStatus()).body(response); + } + @ExceptionHandler(BusinessLogicException.class) public ResponseEntity handleBusinessLogicException(BusinessLogicException businessLogicException) { final ErrorResponse response = ErrorResponse.of(businessLogicException); diff --git a/src/main/java/com/sprint/mission/discodeit/exception/auth/AuthException.java b/src/main/java/com/sprint/mission/discodeit/exception/auth/AuthException.java new file mode 100644 index 000000000..fd503d479 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/auth/AuthException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.auth; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class AuthException extends DiscodeitException { + public AuthException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/auth/InvalidAccessTokenException.java b/src/main/java/com/sprint/mission/discodeit/exception/auth/InvalidAccessTokenException.java new file mode 100644 index 000000000..0d6a1e387 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/auth/InvalidAccessTokenException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.auth; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class InvalidAccessTokenException extends AuthException { + public InvalidAccessTokenException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/auth/InvalidRefreshTokenException.java b/src/main/java/com/sprint/mission/discodeit/exception/auth/InvalidRefreshTokenException.java new file mode 100644 index 000000000..7fab63c1e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/auth/InvalidRefreshTokenException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.auth; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class InvalidRefreshTokenException extends AuthException { + public InvalidRefreshTokenException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binary_content/BinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/binary_content/BinaryContentException.java new file mode 100644 index 000000000..22585090e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binary_content/BinaryContentException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.binary_content; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class BinaryContentException extends DiscodeitException { + public BinaryContentException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binary_content/BinaryContentNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/binary_content/BinaryContentNotFoundException.java new file mode 100644 index 000000000..525e5dfb9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binary_content/BinaryContentNotFoundException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.binary_content; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class BinaryContentNotFoundException extends BinaryContentException { + public BinaryContentNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binary_content/BinaryContentUploadFailException.java b/src/main/java/com/sprint/mission/discodeit/exception/binary_content/BinaryContentUploadFailException.java new file mode 100644 index 000000000..4da00dd0b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/binary_content/BinaryContentUploadFailException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.binary_content; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class BinaryContentUploadFailException extends BinaryContentException { + public BinaryContentUploadFailException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelAlreadyExistsException.java new file mode 100644 index 000000000..deb0b39b9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelAlreadyExistsException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class ChannelAlreadyExistsException extends ChannelException { + public ChannelAlreadyExistsException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} 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..1792ffee9 --- /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..33a8e390a --- /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; + +public class ChannelNotFoundException extends ChannelException { + public ChannelNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} 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..0b25633d9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.channel; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class PrivateChannelUpdateException extends ChannelException { + public PrivateChannelUpdateException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} 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..7cc5f4f40 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java @@ -0,0 +1,12 @@ +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..5258cc423 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageNotFoundException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.message; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class MessageNotFoundException extends MessageException { + public MessageNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notification/NotificationException.java b/src/main/java/com/sprint/mission/discodeit/exception/notification/NotificationException.java new file mode 100644 index 000000000..d696ea48c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/notification/NotificationException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.notification; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class NotificationException extends DiscodeitException { + public NotificationException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} \ No newline at end of file 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..6e6b861e6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/notification/NotificationNotFoundException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.notification; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class NotificationNotFoundException extends NotificationException { + public NotificationNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/read_status/ReadStatusAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/read_status/ReadStatusAlreadyExistsException.java new file mode 100644 index 000000000..8addc3aba --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/read_status/ReadStatusAlreadyExistsException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.read_status; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class ReadStatusAlreadyExistsException extends ReadStatusException { + public ReadStatusAlreadyExistsException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/read_status/ReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/read_status/ReadStatusException.java new file mode 100644 index 000000000..cdec3b819 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/read_status/ReadStatusException.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.exception.read_status; + +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class ReadStatusException extends DiscodeitException { + public ReadStatusException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/read_status/ReadStatusNotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/read_status/ReadStatusNotFoundException.java new file mode 100644 index 000000000..2a95d3784 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/read_status/ReadStatusNotFoundException.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.exception.read_status; + +import com.sprint.mission.discodeit.exception.ErrorCode; + +import java.util.Map; + +public class ReadStatusNotFoundException extends ReadStatusException { + public ReadStatusNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} 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..1a49201fe --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAlreadyExistsException.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 UserAlreadyExistsException extends UserException { + public UserAlreadyExistsException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserAuthException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAuthException.java new file mode 100644 index 000000000..5c6c8af92 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserAuthException.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 UserAuthException extends UserException { + public UserAuthException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} 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..a427b53d7 --- /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..3714ea75b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserNotFoundException.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 UserNotFoundException extends UserException { + public UserNotFoundException(ErrorCode errorCode, Map details) { + super(errorCode, details); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/NotificationMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/NotificationMapper.java new file mode 100644 index 000000000..8c9050851 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/NotificationMapper.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.NotificationDto; +import com.sprint.mission.discodeit.entity.Notification; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface NotificationMapper { + NotificationDto toDto(Notification notification); +} 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 aeee386ea..1b4b9156f 100644 --- a/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java @@ -1,12 +1,35 @@ package com.sprint.mission.discodeit.mapper; +import com.sprint.mission.discodeit.auth.DiscodeitUserDetails; import com.sprint.mission.discodeit.dto.UserDto; import com.sprint.mission.discodeit.entity.User; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.session.SessionRegistry; + +import java.util.UUID; @Mapper(componentModel = "spring", uses = {BinaryContentMapper.class}) -public interface UserMapper { - @Mapping(target = "online", expression = "java(user.getStatus().isOnline())") - UserDto toDto(User user); +public abstract class UserMapper { + @Autowired + SessionRegistry sessionRegistry; + + @Mapping(target = "online", expression = "java(isOnline(user.getId()))") + public abstract UserDto toDto(User user); + + public boolean isOnline(UUID userId) { + var principals = sessionRegistry.getAllPrincipals(); + for (Object principal : principals) { + if (principal instanceof DiscodeitUserDetails) { + DiscodeitUserDetails discodeitUserDetails = (DiscodeitUserDetails) principal; + if (discodeitUserDetails.getUserDto().id().equals(userId)) { + return true; + } + } + } + return false; + } } + + diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java deleted file mode 100644 index 86b77f79d..000000000 --- a/src/main/java/com/sprint/mission/discodeit/mapper/UserStatusMapper.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.sprint.mission.discodeit.mapper; - -import com.sprint.mission.discodeit.dto.UserStatusDto; -import com.sprint.mission.discodeit.entity.UserStatus; -import org.mapstruct.Mapper; - -@Mapper(componentModel = "spring") -public interface UserStatusMapper { - UserStatusDto toDto(UserStatus userStatus); -} 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 a356102ea..215a18562 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java @@ -18,4 +18,6 @@ public interface ChannelRepository extends JpaRepository { Channel save(Channel channel); List findAllByType(ChannelType type); + + boolean existsByName(String channelName); } 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 22b7ba21c..6e2aaa45b 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java @@ -15,14 +15,19 @@ public interface MessageRepository extends JpaRepository { List findAll(); + @EntityGraph(attributePaths = {"attachments", "author"}) Optional findById(UUID id); void delete(Message message); Message save(Message message); - @EntityGraph(attributePaths = {"attachments", "author"}) + @EntityGraph(attributePaths = {"attachments", "author", "author.profile"}) Page findAllByChannel_Id(UUID channelId, Pageable pageable); Optional findTopByChannelOrderByUpdatedAtDesc(Channel channel); + + Page findAllByOrderByIdDescCreatedAtDesc(Pageable pageable); + + Page findByIdLessThanOrderByIdDescCreatedAtDesc(Instant cursor, Pageable pageable); } 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..16f0634ec --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Notification; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface NotificationRepository extends JpaRepository { + List findAllByReceiver_Id(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 14367e6b6..c6ac384f2 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java @@ -24,4 +24,6 @@ public interface ReadStatusRepository extends JpaRepository { boolean existsByUser_IdAndChannel_Id(UUID userId, UUID channelId); List findAllByUser_Id(UUID userId); + + List findAllByChannel_Id(UUID channelId); } \ No newline at end of file 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 a442c11ac..bc4854e53 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java @@ -1,10 +1,12 @@ package com.sprint.mission.discodeit.repository; +import com.sprint.mission.discodeit.entity.Role; import com.sprint.mission.discodeit.entity.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -17,9 +19,13 @@ public interface UserRepository extends JpaRepository { User save(User user); - User findByUsername(String username); + Optional findByUsername(String username); boolean existsByEmail(String email); boolean existsByUsername(String name); + + Optional findByEmail(String email); + + List findAllByRole(Role role); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java deleted file mode 100644 index 343bc7188..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.sprint.mission.discodeit.repository; - -import com.sprint.mission.discodeit.entity.UserStatus; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface UserStatusRepository extends JpaRepository { - void deleteById(UUID id); - - @Modifying - @Query(nativeQuery = true, value = "DELETE FROM user_statuses WHERE user_id = :userId") - void deleteByUserId(@Param("userId") UUID userId); - - Optional findById(UUID userId); - - @Query(nativeQuery = true, value = "SELECT * FROM user_statuses WHERE user_id = :userId") - UserStatus findByUserId(@Param("userId") UUID userId); - - UserStatus save(UserStatus userStatus); - - List findAll(); - - boolean existsByUser_Id(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 index 72b54f9c6..199be6c14 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java @@ -1,9 +1,15 @@ package com.sprint.mission.discodeit.service; -import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; +import com.sprint.mission.discodeit.dto.response.TokenResponse; import com.sprint.mission.discodeit.entity.User; +import java.util.Map; public interface AuthService { - User login(LoginRequest loginRequest); + User updateRole(RoleUpdateRequest roleUpdateRequest); + + TokenResponse reissueToken(Map claims); + + User getUserByAccessToken(String accessToken); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java index ade087d75..c7ea1ee98 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java @@ -1,6 +1,7 @@ package com.sprint.mission.discodeit.service; import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.BinaryContentStatus; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -14,4 +15,6 @@ public interface BinaryContentService { List findAllByIdIn(List ids); void delete(UUID id); + + BinaryContent updateStatus(UUID binaryContentId, BinaryContentStatus status); } 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 3082a7bb8..66c98acd4 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java @@ -18,4 +18,6 @@ public interface MessageService { Message updateMessage(UUID messageId, MessageUpdateRequest messageUpdateRequest); void removeMessage(UUID messageId); + + UUID findUserIdByMessageId(UUID messageId); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/NotificationService.java b/src/main/java/com/sprint/mission/discodeit/service/NotificationService.java new file mode 100644 index 000000000..54b599bff --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/NotificationService.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.Notification; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.DiscodeitException; + +import java.util.List; +import java.util.UUID; + +public interface NotificationService { + List create(Message message); + + Notification create(User user, Role oldRole, Role newRole); + + void create(DiscodeitException e); + + List findAllByUserId(UUID userId); + + void delete(User user, UUID id); + +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java index c0afac1d0..d4a98c239 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/ReadStatusService.java @@ -2,6 +2,7 @@ import com.sprint.mission.discodeit.dto.request.ReadStatusCreateRequest; import com.sprint.mission.discodeit.dto.request.ReadStatusUpdateRequest; +import com.sprint.mission.discodeit.entity.Message; import com.sprint.mission.discodeit.entity.ReadStatus; import java.util.List; @@ -15,4 +16,6 @@ public interface ReadStatusService { ReadStatus update(UUID id, ReadStatusUpdateRequest readStatusUpdateRequest); void delete(UUID id); + + List findAllByMessage(Message message); } 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 e4c521b54..bdd7eec7f 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/UserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/UserService.java @@ -2,7 +2,6 @@ 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 java.util.List; @@ -16,4 +15,10 @@ public interface UserService { void deleteUser(UUID userId); List findAll(); + + User findById(UUID userId); + + boolean existsByUsername(String username); + + User findByEmail(String email); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java deleted file mode 100644 index d61985065..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.sprint.mission.discodeit.service; - -import com.sprint.mission.discodeit.dto.request.UserStatusUpdateRequest; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.entity.UserStatus; - -import java.util.UUID; - -public interface UserStatusService { - UserStatus create(User user); - - UserStatus updateByUserId(UUID userId, UserStatusUpdateRequest userStatusUpdateRequest); - - void delete(UUID id); -} 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 a424262db..8389b9e13 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,30 +1,114 @@ package com.sprint.mission.discodeit.service.basic; -import com.sprint.mission.discodeit.dto.request.LoginRequest; +import com.sprint.mission.discodeit.auth.DiscodeitUserDetails; +import com.sprint.mission.discodeit.auth.provider.JwtTokenProvider; +import com.sprint.mission.discodeit.dto.request.RoleUpdateRequest; +import com.sprint.mission.discodeit.dto.response.TokenResponse; +import com.sprint.mission.discodeit.entity.Role; import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.exception.BusinessLogicException; -import com.sprint.mission.discodeit.exception.ExceptionCode; +import com.sprint.mission.discodeit.event.RoleUpdatedEvent; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.auth.InvalidRefreshTokenException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.service.AuthService; 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.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; @Service @RequiredArgsConstructor +@Slf4j public class BasicAuthService implements AuthService { private final UserRepository userRepository; + private final SessionRegistry sessionRegistry; + private final JwtTokenProvider jwtTokenProvider; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + @Override + @PreAuthorize("hasRole=('ADMIN')") + public User updateRole(RoleUpdateRequest roleUpdateRequest) { + User user = getValidUser(roleUpdateRequest.userId()); + Role oldRole = user.getRole(); + log.info("인가 권한 변경 id = {}, oldRole = {}, newRole = {}", roleUpdateRequest.userId(), oldRole, roleUpdateRequest.newRole()); + user.updateRole(roleUpdateRequest.newRole()); + sessionRegistry.getAllPrincipals() + .forEach(principal -> { + if (principal instanceof DiscodeitUserDetails) { + DiscodeitUserDetails discodeitUserDetails = (DiscodeitUserDetails) principal; + if (discodeitUserDetails.getUserDto().id().equals(user.getId())) { + List info = sessionRegistry.getAllSessions(principal, false); + info.forEach(sessionInformation -> { + log.debug("세션 만료 sessionId = {}", sessionInformation.getSessionId()); + sessionInformation.expireNow(); +// sessionRegistry.removeSessionInformation(sessionInformation.getSessionId()); + }); + log.debug("세션 {}개 무효화", info.size()); + } + } + }); + log.info("권한 변경 알림 생성 userId = {}", user.getId()); + eventPublisher.publishEvent(new RoleUpdatedEvent(user, oldRole, roleUpdateRequest.newRole())); + return userRepository.save(user); + } + + @Override + public TokenResponse reissueToken(Map claims) { + log.info("AccessToken 재발급 시도"); + Date expiredAt = (Date) claims.get("exp"); + String userEmail = (String) claims.get("sub"); + User user = getValidUserByEmail(userEmail); + if (expiredAt.before(new Date())) { + log.warn("RefreshToken 유효기간이 만료 됨"); + throw new InvalidRefreshTokenException(ErrorCode.INVALID_REFRESH_TOKEN, Map.of("expiredAt", expiredAt)); + } + + Map accessClaims = new HashMap<>(); + accessClaims.put("roles", user.getRole()); + accessClaims.put("username", user.getUsername()); + String newAccessToken = jwtTokenProvider.generateAccessToken(accessClaims, userEmail); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(userEmail); + + TokenResponse tokenResponse = new TokenResponse(newAccessToken, newRefreshToken); + log.info("AccessToken 및 RefreshToken 재발급 완료"); + return tokenResponse; + } @Override - public User login(LoginRequest loginRequest) { - if (loginRequest.password() == null || loginRequest.username() == null) - throw new IllegalArgumentException("Username or Password is null"); + public User getUserByAccessToken(String accessToken) { + String token = accessToken.replace("Bearer ", ""); + jwtTokenProvider.verifyJws(token); + Map claims = jwtTokenProvider.getClaims(token); + Date expiredAt = (Date) claims.get("exp"); + String userEmail = (String) claims.get("sub"); + if (expiredAt.before(new Date())) { + log.warn("RefreshToken 유효기간이 만료 됨"); + throw new InvalidRefreshTokenException(ErrorCode.INVALID_REFRESH_TOKEN, Map.of("expiredAt", expiredAt)); + } - if (!userRepository.existsByUsername(loginRequest.username())) - throw new BusinessLogicException(ExceptionCode.USER_NOT_FOUND); - User findUser = userRepository.findByUsername(loginRequest.username()); - if (!findUser.getPassword().equals(loginRequest.password())) - throw new BusinessLogicException(ExceptionCode.WRONG_PASSWORD); + User user = getValidUserByEmail(userEmail); + return user; + } + + private User getValidUser(UUID userId) { + return userRepository.findById(userId).orElseThrow(() -> { + log.warn("해당 유저를 찾을 수 없음 id = {}", userId); + throw new UserNotFoundException(ErrorCode.USER_NOT_FOUND, Map.of("userId", userId)); + }); + } - return findUser; + private User getValidUserByEmail(String email) { + return userRepository.findByEmail(email).orElseThrow(() -> { + log.warn("해당 유저를 찾을 수 없음 email = {}", email); + throw new UserNotFoundException(ErrorCode.USER_NOT_FOUND, Map.of("email", email)); + }); } } 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 3e8619197..c30ea84d8 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 @@ -1,49 +1,63 @@ package com.sprint.mission.discodeit.service.basic; import com.sprint.mission.discodeit.entity.BinaryContent; -import com.sprint.mission.discodeit.exception.BusinessLogicException; -import com.sprint.mission.discodeit.exception.ExceptionCode; +import com.sprint.mission.discodeit.entity.BinaryContentStatus; +import com.sprint.mission.discodeit.event.BinaryContentCreatedEvent; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.binary_content.BinaryContentNotFoundException; import com.sprint.mission.discodeit.repository.BinaryContentRepository; import com.sprint.mission.discodeit.service.BinaryContentService; -import com.sprint.mission.discodeit.storage.BinaryContentStorage; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.UUID; @Service +@Slf4j @RequiredArgsConstructor public class BasicBinaryContentService implements BinaryContentService { private final BinaryContentRepository binaryContentRepository; - @Autowired(required = false) - private BinaryContentStorage binaryContentStorage; + // private final BinaryContentStorage binaryContentStorage; + private final ApplicationEventPublisher eventPublisher; - @Transactional(rollbackFor = Exception.class) + @Transactional @Override public BinaryContent create(MultipartFile file) { + log.info("파일 업로드 호출"); + log.debug("파일 업로드 file = {}", file); BinaryContent binaryContent = new BinaryContent(file); binaryContentRepository.save(binaryContent); - if (binaryContentStorage != null) { - try { - binaryContentStorage.put(binaryContent.getId(), file.getBytes()); - } catch (IOException e) { - throw new RuntimeException(e); - } - } else { - System.out.println("NOT LOCAL"); + try { + eventPublisher.publishEvent(new BinaryContentCreatedEvent(binaryContent.getId(), file.getBytes())); + } catch (Exception e) { + throw new RuntimeException(e.getMessage(), e); } +// if (binaryContentStorage != null) { +// try { +// eventPublisher.publishEvent(new BinaryContentCreatedEvent(binaryContent.getId(), file.getBytes())); +//// binaryContentStorage.put(binaryContent.getId(), file.getBytes()); +// } catch (Exception e) { +// log.error("파일 입출력 실패 msg = {}", e.getMessage()); +// updateStatus(binaryContent.getId(), BinaryContentStatus.FAIL); +// throw new RuntimeException(e); +// } +// } else { +// log.warn("스토리지가 존재하지 않음"); +// } + log.info("파일 업로드 완료 id = {}", binaryContent.getId()); return binaryContent; } @Override public BinaryContent find(UUID id) { return binaryContentRepository.findById(id) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.BINARY_CONTENT_NOT_FOUND)); + .orElseThrow(() -> new BinaryContentNotFoundException(ErrorCode.BINARY_CONTENT_NOT_FOUND, Map.of("binaryContentId", id))); } @Override @@ -56,4 +70,12 @@ public List findAllByIdIn(List ids) { public void delete(UUID id) { binaryContentRepository.deleteById(id); } + + @Override + @Transactional + public BinaryContent updateStatus(UUID binaryContentId, BinaryContentStatus status) { + BinaryContent binaryContent = find(binaryContentId); + binaryContent.updateStatus(status); + return binaryContentRepository.save(binaryContent); + } } 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 b22a2afa1..9b7e5cdc1 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 @@ -3,57 +3,77 @@ 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.exception.BusinessLogicException; -import com.sprint.mission.discodeit.exception.ExceptionCode; -import com.sprint.mission.discodeit.repository.*; +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.ErrorCode; +import com.sprint.mission.discodeit.exception.channel.ChannelAlreadyExistsException; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +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 org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.CacheManager; +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; import java.util.*; @Service +@Slf4j @RequiredArgsConstructor public class BasicChannelService implements ChannelService { private final ChannelRepository channelRepository; private final MessageRepository messageRepository; private final ReadStatusRepository readStatusRepository; private final UserRepository userRepository; + private final CacheManager cacheManager; @Transactional + @PreAuthorize("hasRole('CHANNEL_MANAGER')") + @CacheEvict(value = "channels", allEntries = true) @Override public Channel createPublicChannel(PublicChannelCreateRequest publicChannelCreateRequest) { + log.debug("공개 채널 생성 호출"); + validateDuplicatedChannelName(publicChannelCreateRequest.name()); Channel channel = new Channel(publicChannelCreateRequest.name(), publicChannelCreateRequest.description()); channelRepository.save(channel); - + log.info("공개 채널 생성 완료 id = {}", channel.getId()); return channel; } @Transactional @Override public Channel createPrivateChannel(PrivateChannelCreateRequest privateChannelCreateRequest) { - for (UUID id : privateChannelCreateRequest.participantIds()) { - validateUser(id); - } + log.debug("비공개 채널 생성 호출"); Channel channel = new Channel(); channelRepository.save(channel); for (UUID id : privateChannelCreateRequest.participantIds()) { - User user = userRepository.findById(id) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.USER_NOT_FOUND)); + User user = getValidUser(id); + cacheManager.getCache("channels").evict(id); ReadStatus readStatus = new ReadStatus(user, channel); readStatusRepository.save(readStatus); } + log.info("비공개 채널 생성 완료 id = {}", channel.getId()); return channel; } @Override @Transactional(readOnly = true) + @Cacheable(value = "channels", key = "#userId") public List findAllByUserId(UUID userId) { - validateUser(userId); + getValidUser(userId); List readStatuses = readStatusRepository.findAllByUser_Id(userId); Set channels = new LinkedHashSet<>(); readStatuses.forEach(readStatus -> { @@ -64,44 +84,61 @@ public List findAllByUserId(UUID userId) { } @Transactional + @PreAuthorize("hasRole('CHANNEL_MANAGER')") + @CacheEvict(value = "channels", allEntries = true) @Override public Channel updateChannel(UUID channelId, PublicChannelUpdateRequest publicChannelUpdateRequest) { - validateActiveChannel(channelId); - validatePublicChannel(channelId); + log.debug("공개 채널 수정 호출 id = {}", channelId); + Channel findChannel = getValidChannel(channelId); + validatePublicChannel(findChannel); - Channel findChannel = channelRepository.findById(channelId) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.CHANNEL_NOT_FOUND)); Optional.ofNullable(publicChannelUpdateRequest.newName()) .ifPresent(findChannel::setName); Optional.ofNullable(publicChannelUpdateRequest.newDescription()) .ifPresent(findChannel::setDescription); channelRepository.save(findChannel); + + log.info("공개 채널 수정 완료 id = {}", channelId); return findChannel; } + @PreAuthorize("hasRole('CHANNEL_MANAGER')") + @CacheEvict(value = "channels", allEntries = true) @Override public void deleteChannel(UUID id) { - validateActiveChannel(id); - Channel channel = channelRepository.findById(id) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.CHANNEL_NOT_FOUND)); + log.debug("채널 삭제 호출 id = {}", id); + Channel channel = getValidChannel(id); + log.info("채널 삭제 완료 id = {}", id); channelRepository.delete(channel); } - private void validateActiveChannel(UUID id) { - if (!channelRepository.existsById(id)) { - throw new BusinessLogicException(ExceptionCode.CHANNEL_NOT_FOUND); + private Channel getValidChannel(UUID id) { + return channelRepository.findById(id) + .orElseThrow(() -> { + log.warn("해당 채널을 찾을 수 없음 id = {}", id); + throw new ChannelNotFoundException(ErrorCode.CHANNEL_NOT_FOUND, Map.of("channelId", id)); + }); + } + + private void validatePublicChannel(Channel channel) { + if (channel.getType().equals(ChannelType.PRIVATE)) { + log.warn("비공개 채널은 수정할 수 없음 id = {}, type = {}", channel.getId(), channel.getType()); + throw new PrivateChannelUpdateException(ErrorCode.PRIVATE_CHANNEL_CANNOT_UPDATE, Map.of("channelId", channel.getId())); } } - private void validatePublicChannel(UUID id) { - if (channelRepository.findById(id) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.CHANNEL_NOT_FOUND)) - .getType().equals(ChannelType.PRIVATE)) - throw new BusinessLogicException(ExceptionCode.PRIVATE_CHANNEL_CANNOT_UPDATE); + private User getValidUser(UUID userId) { + return userRepository.findById(userId).orElseThrow(() -> { + log.warn("해당 유저가 존재하지 않음 id = {}", userId); + throw new UserNotFoundException(ErrorCode.USER_NOT_FOUND, Map.of("userId", userId)); + }); } - private void validateUser(UUID userId) { - if (!userRepository.existsById(userId)) throw new BusinessLogicException(ExceptionCode.USER_NOT_FOUND); + private void validateDuplicatedChannelName(String channelName) { + if (channelRepository.existsByName(channelName)) { + log.warn("채널이름 중복 name = {}", channelName); + throw new ChannelAlreadyExistsException(ErrorCode.AlREADY_EXISTS_CHANNEL_NAME, Map.of("channelName", channelName)); + } } } 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 b662c610c..6f2a6659e 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 @@ -2,47 +2,52 @@ import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; -import com.sprint.mission.discodeit.entity.*; -import com.sprint.mission.discodeit.exception.BusinessLogicException; -import com.sprint.mission.discodeit.exception.ExceptionCode; +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.event.MessageCreatedEvent; +import com.sprint.mission.discodeit.exception.ErrorCode; +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.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 org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @Service +@Slf4j @RequiredArgsConstructor public class BasicMessageService implements MessageService { private final MessageRepository messageRepository; private final UserRepository userRepository; private final ChannelRepository channelRepository; private final BinaryContentRepository binaryContentRepository; - @Autowired(required = false) - private BinaryContentStorage binaryContentStorage; + private final ApplicationEventPublisher eventPublisher; @Transactional @Override public Message createMessage(MessageCreateRequest messageCreateRequest, List attachments) { - validateChannel(messageCreateRequest.channelId()); - Channel channel = channelRepository.findById(messageCreateRequest.channelId()) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.CHANNEL_NOT_FOUND)); - validateUser(messageCreateRequest.authorId()); - User user = userRepository.findById(messageCreateRequest.authorId()) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.USER_NOT_FOUND)); + log.debug("메시지 생성 호출"); + Channel channel = getValidChannel(messageCreateRequest.channelId()); + User user = getValidUser(messageCreateRequest.authorId()); + Message message = new Message(messageCreateRequest.content(), channel, user); if (attachments != null && !attachments.isEmpty()) { @@ -54,61 +59,80 @@ public Message createMessage(MessageCreateRequest messageCreateRequest, List findAllByChannelId(UUID channelId, Instant cursor, Pageable pageable) { + getValidChannel(channelId); Page messages = messageRepository.findAllByChannel_Id(channelId, pageable); return messages; } @Transactional + @PreAuthorize("@basicMessageService.findUserIdByMessageId(#messageId) == authentication.principal.userDto.id") @Override public Message updateMessage(UUID messageId, MessageUpdateRequest messageUpdateRequest) { - Message findMessage = messageRepository.findById(messageId) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.MESSAGE_NOT_FOUND)); - validateActiveMessage(findMessage); + log.debug("메시지 수정 호출 id = {}", messageId); + Message findMessage = getValidMessage(messageId); Optional.ofNullable(messageUpdateRequest.newContent()) .ifPresent(findMessage::setContent); messageRepository.save(findMessage); - + log.info("메시지 수정 완료 id = {}", messageId); return findMessage; } @Transactional + @PreAuthorize("@basicMessageService.findUserIdByMessageId(#messageId) == authentication.principal.userDto.id") @Override public void removeMessage(UUID messageId) { - Message findMessage = messageRepository.findById(messageId) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.MESSAGE_NOT_FOUND)); - validateActiveMessage(findMessage); + log.debug("메시지 삭제 호출 id = {}", messageId); + Message findMessage = getValidMessage(messageId); messageRepository.delete(findMessage); + log.info("메시지 삭제 완료 id = {}", messageId); if (findMessage.getAttachments() != null && !findMessage.getAttachments().isEmpty()) { findMessage.getAttachments().stream() .forEach(bc -> { binaryContentRepository.delete(bc); + log.debug("첨푸파일 삭제 완료 id = {}", messageId); }); } - Channel channel = channelRepository.findById(findMessage.getChannelId()) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.CHANNEL_NOT_FOUND)); - channelRepository.save(channel); } - private void validateActiveMessage(Message message) { - if (!messageRepository.existsById(message.getId())) - throw new BusinessLogicException(ExceptionCode.MESSAGE_NOT_FOUND); + private Message getValidMessage(UUID messageId) { + return messageRepository.findById(messageId) + .orElseThrow(() -> { + log.warn("해당 메시지를 찾을 수 없음 id = {}", messageId); + throw new MessageNotFoundException(ErrorCode.MESSAGE_NOT_FOUND, Map.of("messageId", messageId)); + }); } - private void validateUser(UUID authorId) { - if (!userRepository.existsById(authorId)) - throw new BusinessLogicException(ExceptionCode.CHANNEL_OR_USER_NOT_FOUND); + private User getValidUser(UUID authorId) { + return userRepository.findById(authorId) + .orElseThrow(() -> { + log.warn("해당 유저를 찾을 수 없음 id = {}", authorId); + throw new UserNotFoundException(ErrorCode.USER_NOT_FOUND, Map.of("userId", authorId)); + }); } - private void validateChannel(UUID channelId) { - if (!channelRepository.existsById(channelId)) - throw new BusinessLogicException(ExceptionCode.CHANNEL_OR_USER_NOT_FOUND); + private Channel getValidChannel(UUID channelId) { + return channelRepository.findById(channelId) + .orElseThrow(() -> { + log.warn("해당 채널을 찾을 수 없음 id = {}", channelId); + throw new ChannelNotFoundException(ErrorCode.CHANNEL_NOT_FOUND, Map.of("channelId", channelId)); + }); + } + + @Override + public UUID findUserIdByMessageId(UUID messageId) { + Message message = getValidMessage(messageId); + return message.getAuthor().getId(); } } 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..f44d10aa8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicNotificationService.java @@ -0,0 +1,126 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.entity.*; +import com.sprint.mission.discodeit.exception.DiscodeitException; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.notification.NotificationNotFoundException; +import com.sprint.mission.discodeit.repository.NotificationRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.CacheManager; +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; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BasicNotificationService implements NotificationService { + private final ReadStatusRepository readStatusRepository; + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + private final CacheManager cacheManager; + + @Override + @Transactional + public List create(Message message) { + log.info("메시지 생성 알림 생성 호출"); + List readStatuses = readStatusRepository.findAllByChannel_Id(message.getChannelId()); + List notifications = readStatuses.stream() + .filter(readStatus -> + !readStatus.getUserId().equals(message.getAuthorId()) && + readStatus.isNotificationEnabled() + ) + .map(readStatus -> { + Notification notification = new Notification( + readStatus.getUser(), + String.format("%s (#%s)", + message.getAuthor().getUsername(), + message.getChannel().getName()), + message.getContent()); + return notificationRepository.save(notification); + }) + .map(notification -> { + cacheManager.getCache("notifications").evict(notification.getReceiver().getId()); + return notification; + }) + .collect(Collectors.toList()); + log.info("알림 생성 완료 총 알림 수 = {}", notifications.size()); + return notifications; + } + + @Override + @CacheEvict(value = "notifications", key = "#user.getId()") + public Notification create(User user, Role oldRole, Role newRole) { + log.info("권한 변경 알림 생성 호출"); + Notification notification = new Notification( + user, + "권한이 변경되었습니다.", + String.format("%s -> %s", oldRole, newRole)); + log.info("권한 변경 알림 생성 완료"); + log.debug("{} 권한 변경 {} -> {}", user.getUsername(), oldRole, newRole); + return notificationRepository.save(notification); + } + + @Override + public void create(DiscodeitException e) { + log.info("S3 업로드 실패 알림 생성 호출"); + String errorMessage = e.getMessage(); + String content = String.format( + """ + RequestId: %s\s + BinaryContentId: %s\s + Error: %s + """, + e.getDetails().get("RequestId"), + e.getDetails().get("BinaryContentId"), + errorMessage); + + List admins = userRepository.findAllByRole(Role.ADMIN); + + admins.forEach(admin -> { + Notification notification = new Notification( + admin, + "S3 파일 업로드 실패", + content + ); + notificationRepository.save(notification); + cacheManager.getCache("notifications").evict(admin.getId()); + }); + log.info("S3 업로드 실패 알림 생성 완료"); + } + + @Override + @Cacheable(value = "notifications", key = "#userId") + public List findAllByUserId(UUID userId) { + log.info("알림 조회 호출"); + List notifications = + notificationRepository.findAllByReceiver_Id(userId); + log.info("총 알림 수 = {}", notifications.size()); + return notifications; + } + + @PreAuthorize("#user.id == authentication.principal.userDto.id") + @CacheEvict(value = "notifications", key = "#user.getId()") + @Override + public void delete(User user, UUID id) { + log.info("알림 삭제 호출"); + Notification notification = notificationRepository.findById(id) + .orElseThrow(() -> { + log.warn("알림이 존재하지 않습니다. notificationId = {}", id); + throw new NotificationNotFoundException(ErrorCode.NOTIFICATION_NOT_FOUND, Map.of("notificationId", id)); + }); + notificationRepository.delete(notification); + log.debug("알림 삭제 id = {}", notification.getId()); + } +} 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 f8df24cb3..d5d1861bf 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 @@ -3,19 +3,24 @@ 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.Message; import com.sprint.mission.discodeit.entity.ReadStatus; import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.exception.BusinessLogicException; -import com.sprint.mission.discodeit.exception.ExceptionCode; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.read_status.ReadStatusAlreadyExistsException; +import com.sprint.mission.discodeit.exception.read_status.ReadStatusNotFoundException; 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 org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -27,18 +32,16 @@ public class BasicReadStatusService implements ReadStatusService { @Transactional @Override public ReadStatus create(ReadStatusCreateRequest readStatusCreateRequest) { - validateUser(readStatusCreateRequest); - validateChannel(readStatusCreateRequest); + User user = getValidUser(readStatusCreateRequest); + Channel channel = getValidChannel(readStatusCreateRequest); existsByUserAndChannel(readStatusCreateRequest); - User user = userRepository.findById(readStatusCreateRequest.userId()) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.USER_NOT_FOUND)); - Channel channel = channelRepository.findById(readStatusCreateRequest.channelId()) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.CHANNEL_NOT_FOUND)); + ReadStatus readStatus = new ReadStatus(user, channel, readStatusCreateRequest.lastReadAt()); readStatusRepository.save(readStatus); return readStatus; } + @Transactional(readOnly = true) @Override public List findAllByUserId(UUID userId) { List readStatuses = readStatusRepository.findAllByUserId(userId); @@ -48,32 +51,43 @@ public List findAllByUserId(UUID userId) { @Transactional @Override public ReadStatus update(UUID id, ReadStatusUpdateRequest readStatusUpdateRequest) { - ReadStatus findReadStatus = readStatusRepository.findById(id).orElseThrow(() -> new BusinessLogicException(ExceptionCode.READSTATUS_NOT_FOUND)); + ReadStatus findReadStatus = readStatusRepository.findById(id).orElseThrow(() -> new ReadStatusNotFoundException(ErrorCode.READSTATUS_NOT_FOUND, Map.of("readStatusId", id))); Optional.ofNullable(readStatusUpdateRequest.newLastReadAt()).ifPresent(findReadStatus::setLastReadAt); + Optional.ofNullable(readStatusUpdateRequest.newNotificationEnabled()).ifPresent(findReadStatus::setNotificationEnabled); readStatusRepository.save(findReadStatus); return findReadStatus; } + @Transactional(readOnly = true) + @Override + public List findAllByMessage(Message message) { + List readStatuses = getAllByChannelId(message.getChannelId()); + return readStatuses; + } + @Override public void delete(UUID id) { readStatusRepository.deleteById(id); } - private void validateUser(ReadStatusCreateRequest readStatusCreateDto) { - if (!userRepository.existsById(readStatusCreateDto.userId())) - throw new BusinessLogicException(ExceptionCode.CHANNEL_OR_USER_NOT_FOUND); + private User getValidUser(ReadStatusCreateRequest readStatusCreateRequest) { + return userRepository.findById(readStatusCreateRequest.userId()) + .orElseThrow(() -> new ReadStatusNotFoundException(ErrorCode.CHANNEL_OR_USER_NOT_FOUND, Map.of("userId", readStatusCreateRequest.userId()))); } - private void validateChannel(ReadStatusCreateRequest readStatusCreateDto) { - if (!channelRepository.existsById(readStatusCreateDto.channelId())) - throw new BusinessLogicException(ExceptionCode.CHANNEL_OR_USER_NOT_FOUND); + private Channel getValidChannel(ReadStatusCreateRequest readStatusCreateRequest) { + return channelRepository.findById(readStatusCreateRequest.channelId()) + .orElseThrow(() -> new ReadStatusNotFoundException(ErrorCode.CHANNEL_OR_USER_NOT_FOUND, Map.of("channelId", readStatusCreateRequest.channelId()))); } private void existsByUserAndChannel(ReadStatusCreateRequest readStatusCreateDto) { if (readStatusRepository.existsByUser_IdAndChannel_Id(readStatusCreateDto.userId(), readStatusCreateDto.channelId())) - throw new BusinessLogicException(ExceptionCode.READSTATUS_ALREADY_EXISTS); + throw new ReadStatusAlreadyExistsException(ErrorCode.READSTATUS_ALREADY_EXISTS, Map.of("userId", readStatusCreateDto.userId(), "channelId", readStatusCreateDto.channelId())); } + private List getAllByChannelId(UUID channelId) { + return readStatusRepository.findAllByChannel_Id((channelId)); + } } 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 c40e3ec74..d2165a946 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 @@ -2,36 +2,49 @@ import com.sprint.mission.discodeit.dto.request.UserCreateRequest; import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; -import com.sprint.mission.discodeit.entity.*; -import com.sprint.mission.discodeit.exception.BusinessLogicException; -import com.sprint.mission.discodeit.exception.ExceptionCode; -import com.sprint.mission.discodeit.repository.*; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.binary_content.BinaryContentNotFoundException; +import com.sprint.mission.discodeit.exception.user.UserAlreadyExistsException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +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 org.springframework.beans.factory.annotation.Autowired; -import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +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 java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @Service +@Slf4j @RequiredArgsConstructor public class BasicUserService implements UserService { private final UserRepository userRepository; private final BinaryContentRepository binaryContentRepository; - private final UserStatusRepository userStatusRepository; + private final PasswordEncoder passwordEncoder; + @Autowired(required = false) private BinaryContentStorage binaryContentStorage; @Transactional + @CacheEvict(value = "users", key = "'users'") @Override public User createUser(UserCreateRequest userCreateRequest, UUID profileId) { - isDuplicateEmail(userCreateRequest.email()); - isDuplicateName(userCreateRequest.username()); + log.debug("유저 생성 호출"); + validateEmail(userCreateRequest.email()); + validateUsername(userCreateRequest.username()); if (profileId == null) { System.out.println("이미지가 포함되지 않아 기본 프로필로 설정됩니다."); @@ -39,52 +52,62 @@ public User createUser(UserCreateRequest userCreateRequest, UUID profileId) { BinaryContent profile = Optional.ofNullable(profileId) .flatMap(binaryContentRepository::findById) .orElse(null); - User user = new User(userCreateRequest.username(), userCreateRequest.password(), userCreateRequest.email(), profile); + String password = passwordEncoder.encode(userCreateRequest.password()); + User user = new User(userCreateRequest.username(), password, userCreateRequest.email(), profile); userRepository.save(user); + log.info("유저 생성 완료 id = {}", user.getId()); return user; } @Transactional(readOnly = true) + @Cacheable(value = "users", key = "'users'") @Override public List findAll() { return userRepository.findAll(); } @Transactional + @PreAuthorize("#userId == authentication.principal.userDto.id") + @CacheEvict(value = "users", key = "'users'") @Override public User updateUser(UUID userId, UserUpdateRequest userUpdateRequest, UUID newProfileId) { - User findUser = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException(" .")); + log.debug("유저 수정 호출"); + User findUser = getValidUser(userId); - isDuplicateName(userUpdateRequest.newUsername()); - isDuplicateEmail(userUpdateRequest.newEmail()); + validateUpdateName(findUser.getUsername(), userUpdateRequest.newUsername()); + validateUpdateEmail(findUser.getEmail(), userUpdateRequest.newEmail()); Optional.ofNullable(userUpdateRequest.newUsername()) // username 업데이트 .ifPresent(findUser::setUsername); Optional.ofNullable(userUpdateRequest.newPassword()) // password 업데이트 - .ifPresent(findUser::setPassword); + .ifPresent(password -> { + String encodePassword = passwordEncoder.encode(password); + findUser.setPassword(encodePassword); + }); Optional.ofNullable(userUpdateRequest.newEmail()) // email 업데이트 .ifPresent(findUser::setEmail); Optional.ofNullable(newProfileId).ifPresent(binaryContentId -> { // 변경할 프로필이 있으면 삭제 후 등록 Optional.ofNullable(findUser.getProfile()).ifPresent(binaryContentRepository::delete); BinaryContent binaryContent = binaryContentRepository.findById(newProfileId) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.BINARY_CONTENT_NOT_FOUND)); + .orElseThrow(() -> { + log.warn("프로필 이미지를 찾을 수 없음 id = {}", newProfileId); + throw new BinaryContentNotFoundException(ErrorCode.BINARY_CONTENT_NOT_FOUND, Map.of("newProfileId", newProfileId)); + }); binaryContentRepository.save(binaryContent); findUser.setProfile(binaryContent); }); - UserStatus userStatus = userStatusRepository.findByUserId(findUser.getId()); - userStatus.update(Instant.now()); userRepository.save(findUser); - userStatusRepository.save(userStatus); - + log.info("유저 수정 완료 id = {}", findUser.getId()); return findUser; } @Transactional + @PreAuthorize("#userId == authentication.principal.userDto.id") + @CacheEvict(value = "users", key = "'users'") @Override public void deleteUser(UUID userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BusinessLogicException(ExceptionCode.USER_NOT_FOUND)); - validateActiveUser(user); + log.debug("유저 삭제 호출 id = {}", userId); + User user = getValidUser(userId); // 프로필 이미지가 있는 경우에만 제거한다. if (user.getProfile() != null) { @@ -94,22 +117,67 @@ public void deleteUser(UUID userId) { userRepository.delete(user); - userStatusRepository.deleteByUserId(user.getId()); + log.info("유저 삭제 완료 id = {}", userId); + } + + @Override + public User findById(UUID userId) { + return getValidUser(userId); } - private void validateActiveUser(User user) { - if (!userRepository.existsById(user.getId())) throw new BusinessLogicException(ExceptionCode.USER_NOT_FOUND); + @Transactional(readOnly = true) + @Override + public boolean existsByUsername(String username) { + return userRepository.existsByUsername(username); + } + + @Override + public User findByEmail(String email) { + return getValidUser(email); + } + + private User getValidUser(UUID userId) { + return userRepository.findById(userId).orElseThrow(() -> { + log.warn("해당 유저를 찾을 수 없음 id = {}", userId); + throw new UserNotFoundException(ErrorCode.USER_NOT_FOUND, Map.of("userId", userId)); + }); + } + + private User getValidUser(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> { + log.warn("해당 유저를 찾을 수 없음 email = {}", email); + throw new UserNotFoundException(ErrorCode.USER_NOT_FOUND, Map.of("email", email)); + }); } // 동일한 이메일이 존재하는지 확인 - private void isDuplicateEmail(String email) { - if (userRepository.existsByEmail(email)) - throw new BusinessLogicException(ExceptionCode.EMAIL_OR_USERNAME_ALREADY_EXISTS); + private void validateUpdateEmail(String oldEmail, String newEmail) { + if (userRepository.existsByEmail(newEmail) && !oldEmail.equals(newEmail)) { + log.warn("이메일 검증 실패 , 해당 이메일은 이미 존재합니다. email = {}", newEmail); + throw new UserAlreadyExistsException(ErrorCode.EMAIL_OR_USERNAME_ALREADY_EXISTS, Map.of("email", newEmail)); + } } - private void isDuplicateName(String name) { - if (userRepository.existsByUsername(name)) - throw new BusinessLogicException(ExceptionCode.EMAIL_OR_USERNAME_ALREADY_EXISTS); + private void validateUpdateName(String oldName, String newName) { + if (userRepository.existsByUsername(newName) && !oldName.equals(newName)) { + log.warn("이름 검증 실패 name , 해당 username은 이미 존재합니다 = {}", newName); + throw new UserAlreadyExistsException(ErrorCode.EMAIL_OR_USERNAME_ALREADY_EXISTS, Map.of("name", newName)); + } } + + private void validateEmail(String email) { + if (userRepository.existsByEmail(email)) { + log.warn("이메일 검증 실패, 해당 이메일은 이미 존재합니다. email = {}", email); + throw new UserAlreadyExistsException(ErrorCode.EMAIL_OR_USERNAME_ALREADY_EXISTS, Map.of("email", email)); + } + } + + private void validateUsername(String username) { + if (userRepository.existsByUsername(username)) { + log.warn("이름 검증 실패, 해당 username은 이미 존재합니다. username = {}", username); + throw new UserAlreadyExistsException(ErrorCode.EMAIL_OR_USERNAME_ALREADY_EXISTS, Map.of("username", username)); + } + } } 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 deleted file mode 100644 index f7fba68d9..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.sprint.mission.discodeit.service.basic; - -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.BusinessLogicException; -import com.sprint.mission.discodeit.exception.ExceptionCode; -import com.sprint.mission.discodeit.repository.UserRepository; -import com.sprint.mission.discodeit.repository.UserStatusRepository; -import com.sprint.mission.discodeit.service.UserStatusService; -import org.springframework.transaction.annotation.Transactional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.*; - -@Service -@RequiredArgsConstructor -public class BasicUserStatusService implements UserStatusService { - private final UserStatusRepository userStatusRepository; - private final UserRepository userRepository; - - @Transactional - @Override - public UserStatus create(User user) { - validateUser(user.getId()); - existsUserStatus(user.getId()); - - UserStatus userStatus = new UserStatus(user, Instant.now()); - userStatusRepository.save(userStatus); - user.setStatus(userStatus); - return userStatus; - } - - @Transactional - @Override - public UserStatus updateByUserId(UUID userId, UserStatusUpdateRequest userStatusUpdateRequest) { - Instant newLastActiveAt = userStatusUpdateRequest.newLastActiveAt(); - UserStatus findUserStatus = userStatusRepository.findByUserId(userId); - if (findUserStatus.getLastActiveAt().isAfter(newLastActiveAt)) - throw new BusinessLogicException(ExceptionCode.INVALID_PAST_TIME); - - findUserStatus.setLastActiveAt(newLastActiveAt); - - userStatusRepository.save(findUserStatus); - return findUserStatus; - } - - @Override - public void delete(UUID id) { - userStatusRepository.deleteById(id); - } - - private void validateUser(UUID userId) { - if (!userRepository.existsById(userId)) throw new BusinessLogicException(ExceptionCode.USER_NOT_FOUND); - } - - private void existsUserStatus(UUID userId) { - if (userStatusRepository.existsByUser_Id(userId)) - throw new BusinessLogicException(ExceptionCode.USER_ALREADY_EXISTS_USERSTATUS); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java index 4ce07289f..b68ec9552 100644 --- a/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java +++ b/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java @@ -2,24 +2,22 @@ import com.sprint.mission.discodeit.dto.BinaryContentDto; import com.sprint.mission.discodeit.exception.BusinessLogicException; -import com.sprint.mission.discodeit.exception.ExceptionCode; +import com.sprint.mission.discodeit.exception.ErrorCode; import jakarta.annotation.PostConstruct; 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.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import java.io.*; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; -@Component -@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local") public class LocalBinaryContentStorage implements BinaryContentStorage { @Value(" ${discodeit.storage.local.root-path}") private Path root; @@ -64,7 +62,7 @@ public InputStream get(UUID binaryContentId) { InputStream inputStream = null; Path path = resolve(binaryContentId); if (Files.notExists(path)) { - throw new BusinessLogicException(ExceptionCode.BINARY_CONTENT_NOT_FOUND); + throw new BusinessLogicException(ErrorCode.BINARY_CONTENT_NOT_FOUND); } else { try { inputStream = Files.newInputStream(path); diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java new file mode 100644 index 000000000..462a63a69 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java @@ -0,0 +1,118 @@ +//package com.sprint.mission.discodeit.storage.s3; +// +//import jakarta.annotation.PostConstruct; +//import lombok.RequiredArgsConstructor; +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.*; +//import org.springframework.web.multipart.MultipartFile; +//import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +//import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +//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.presigner.S3Presigner; +//import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +// +//import java.io.FileInputStream; +//import java.io.IOException; +//import java.net.URI; +//import java.time.Duration; +//import java.util.Properties; +// +//@RestController +//@RequestMapping("/api/s3") +//@RequiredArgsConstructor +//public class AWSS3Test { +// private Properties props = new Properties(); +// private S3Client s3Client; +// private S3Presigner presigner; +// +// @PostConstruct +// public void init() { +// try (FileInputStream fis = new FileInputStream(".env")) { +// props.load(fis); +// } catch (IOException e) { +// e.printStackTrace(); +// } +// if (!props.isEmpty() && props.size() > 0) { +// if (props.getProperty("AWS_S3_ACCESS_KEY") != null && !props.getProperty("AWS_S3_ACCESS_KEY").isBlank()) { +// s3Client = S3Client.builder() +// .region(Region.of(props.getProperty("AWS_S3_REGION"))) +// .credentialsProvider( +// StaticCredentialsProvider.create( +// AwsBasicCredentials.create( +// props.getProperty("AWS_S3_ACCESS_KEY"), +// props.getProperty("AWS_S3_SECRET_KEY") +// ) +// ) +// ) +// .build(); +// } else { +// s3Client = S3Client.builder() +// .region(Region.of(props.getProperty("AWS_S3_REGION"))) +// .credentialsProvider(DefaultCredentialsProvider.create()) +// .build(); +// } +// +// presigner = S3Presigner.builder() +// .region(Region.of(props.getProperty("AWS_S3_REGION"))) +// .credentialsProvider( +// (props.getProperty("AWS_S3_ACCESS_KEY") != null && !props.getProperty("AWS_S3_ACCESS_KEY").isBlank()) +// ? StaticCredentialsProvider.create(AwsBasicCredentials.create( +// props.getProperty("AWS_S3_ACCESS_KEY"), +// props.getProperty("AWS_S3_SECRET_KEY"))) +// : DefaultCredentialsProvider.create() +// ) +// .build(); +// } +// } +// +// @PostMapping +// public ResponseEntity upload(@RequestParam("file") MultipartFile file) { +// String key = file.getOriginalFilename(); +// key = key.replace(" ", "_"); +// +// // 2) PutObjectRequest 생성 (버킷 정책이 퍼블릭 읽기면 acl 생략해도 됨) +// PutObjectRequest putReq = PutObjectRequest.builder() +// .bucket(props.getProperty("AWS_S3_BUCKET")) +// .key(key) +// .contentType(file.getContentType()) +// // .acl(ObjectCannedACL.PUBLIC_READ) // 필요 시 주석 해제 +// .build(); +// +// // 3) 업로드 (임시파일 없이 InputStream으로) +// try { +// s3Client.putObject(putReq, +// RequestBody.fromBytes(file.getBytes())); +// } catch (IOException e) { +// e.printStackTrace(); +// } +// return ResponseEntity.ok(key); +// } +// +// @GetMapping +// public ResponseEntity download(@RequestParam("url") String url) { +// return ResponseEntity.status(302).location(URI.create(url)).build(); +// } +// +// @PostMapping(value = "/url") +// public ResponseEntity createPresignedUrl(@RequestParam("filename") String filename) { +// GetObjectRequest getReq = GetObjectRequest.builder() +// .bucket(props.getProperty("AWS_S3_BUCKET")) +// .key(filename) +// .build(); +// +// GetObjectPresignRequest preReq = GetObjectPresignRequest.builder() +// .getObjectRequest(getReq) +// .signatureDuration(Duration.ofMinutes(5)) // 유효기간 +// .build(); +// +// String signed = presigner.presignGetObject(preReq).url().toString(); +// +// return ResponseEntity.ok(signed); +// } +// +//} 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..f0181fcec --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java @@ -0,0 +1,153 @@ +package com.sprint.mission.discodeit.storage.s3; + +import com.sprint.mission.discodeit.dto.BinaryContentDto; +import com.sprint.mission.discodeit.entity.BinaryContentStatus; +import com.sprint.mission.discodeit.event.S3UploadFailedEvent; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.binary_content.BinaryContentException; +import com.sprint.mission.discodeit.exception.binary_content.BinaryContentUploadFailException; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; +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.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@AllArgsConstructor +public class S3BinaryContentStorage implements BinaryContentStorage { + private final BinaryContentService binaryContentService; + private final ApplicationEventPublisher eventPublisher; + private final S3Client s3Client; + private final S3Presigner presigner; + private String accessKey; + private String secretKey; + private String region; + private String bucket; + + public S3BinaryContentStorage(String accessKey, String secretKey, String region, String bucket, BinaryContentService binaryContentService, ApplicationEventPublisher eventPublisher) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.bucket = bucket; + if (accessKey != null && !accessKey.isBlank()) { + s3Client = S3Client.builder().region(Region.of(region)).credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))).build(); + } else { + s3Client = S3Client.builder().region(Region.of(region)).credentialsProvider(DefaultCredentialsProvider.create()).build(); + } + presigner = S3Presigner.builder().region(Region.of(region)).credentialsProvider((accessKey != null && !accessKey.isBlank()) ? StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)) : DefaultCredentialsProvider.create()).build(); + this.binaryContentService = binaryContentService; + this.eventPublisher = eventPublisher; + } + + @Retryable( + retryFor = {BinaryContentUploadFailException.class, Throwable.class, AwsServiceException.class, SdkClientException.class}, + recover = "recover", + backoff = @Backoff(delay = 1000) + ) + @Override + 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); + } + + String key = binaryContentId.toString(); + + // 2) PutObjectRequest 생성 (버킷 정책이 퍼블릭 읽기면 acl 생략해도 됨) + PutObjectRequest putReq = PutObjectRequest.builder().bucket(bucket).key(key) + // .acl(ObjectCannedACL.PUBLIC_READ) // 필요 시 주석 해제 + .build(); + + // 3) 업로드 (임시파일 없이 InputStream으로) + try { + s3Client.putObject(putReq, RequestBody.fromBytes(bytes)); + binaryContentService.updateStatus(binaryContentId, BinaryContentStatus.SUCCESS); + log.info("S3 업로드 성공"); + } catch (AwsServiceException e) { + throw new BinaryContentUploadFailException(ErrorCode.BINARY_CONTENT_UPLOAD_FAIL, Map.of("RequestId", MDC.get("requestId"), + "BinaryContentId", binaryContentId.toString())); + } catch (SdkClientException e) { + throw new BinaryContentUploadFailException(ErrorCode.BINARY_CONTENT_UPLOAD_FAIL, Map.of("RequestId", MDC.get("requestId"), "BinaryContentId", binaryContentId.toString())); + } catch (Exception e) { + throw new BinaryContentUploadFailException(ErrorCode.BINARY_CONTENT_UPLOAD_FAIL, Map.of("RequestId", MDC.get("requestId"), "BinaryContentId", binaryContentId.toString())); + } + + // 4) 퍼블릭 URL 생성 후 반환 + return binaryContentId; + // ⚠ 주입받은 s3Client는 닫지 말 것 (Bean 공용) + } + + @Override + public InputStream get(UUID binaryContentId) { + // 응답 헤더(Content-Disposition)를 presign 시점에 주입 + GetObjectRequest getReq = GetObjectRequest.builder().bucket(bucket).key(binaryContentId.toString()).responseContentDisposition("attachment; filename=\"" + binaryContentId.toString() + "\"").build(); + + GetObjectPresignRequest preReq = GetObjectPresignRequest.builder().getObjectRequest(getReq).signatureDuration(Duration.ofMinutes(5)) // 유효기간 + .build(); + + URL signed = presigner.presignGetObject(preReq).url(); + try { + return signed.openStream(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public ResponseEntity download(BinaryContentDto binaryContentDto) { + String presignedUrl = generatePresignedUrl(binaryContentDto.id().toString(), binaryContentDto.contentType()); + return ResponseEntity.status(302).location(URI.create(presignedUrl)).build(); + } + + private S3Client getS3Client() { + return s3Client; + } + + private String generatePresignedUrl(String key, String contentType) { + GetObjectRequest getReq = GetObjectRequest.builder().bucket(bucket).key(key).responseContentType(contentType).build(); + + GetObjectPresignRequest preReq = GetObjectPresignRequest.builder() + .getObjectRequest(getReq) + .signatureDuration(Duration.ofMinutes(5)) // 유효기간 + .build(); + + String signed = presigner.presignGetObject(preReq).url().toString(); + + return signed; + } + + @Recover + public UUID recover(BinaryContentException e, UUID binaryContentId, byte[] bytes) { + log.info("S3 업로드 실패"); + eventPublisher.publishEvent(new S3UploadFailedEvent(e, binaryContentId)); + throw new BinaryContentUploadFailException(ErrorCode.BINARY_CONTENT_UPLOAD_FAIL, Map.of("RequestId", e.getMessage())); + } + +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 000000000..46b632d36 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,40 @@ +server: + port: 8081 +spring: + config: + import: optional:file:.env[.properties] + application: + name: discodeit + servlet: + multipart: + maxFileSize: 10MB # 파일 하나의 최대 크기 + maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 + + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/discodeit_dev + username: discodeit_user_dev + password: discodeitdev1234 + # datasource: + # url: jdbc:h2:mem:e2e;DB_CLOSE_DELAY=-1 + # driver-class-name: org.h2.Driver + # username: sa + # password: + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + # default_batch_fetch_size: 100 + open-in-view: false + sql: + init: + mode: never + schema-locations: classpath:schema.sql + +logging: + level: + org.springframework.security: trace + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: debug diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 000000000..51d4805c3 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,28 @@ +server: + port: 8080 +spring: + application: + name: discodeit + servlet: + multipart: + maxFileSize: 10MB # 파일 하나의 최대 크기 + maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + jpa: + hibernate: + ddl-auto: update + show-sql: false + open-in-view: false + +logging: + level: + org.hibernate.SQL: info + org.hibernate.orm.jdbc.bind: info + diff --git a/src/main/resources/application-test.yaml b/src/main/resources/application-test.yaml new file mode 100644 index 000000000..d58c298fa --- /dev/null +++ b/src/main/resources/application-test.yaml @@ -0,0 +1,41 @@ +server: + port: 8082 +spring: + application: + name: discodeit + h2: + console: + enabled: true + path: /h2 + servlet: + multipart: + maxFileSize: 10MB # 파일 하나의 최대 크기 + maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 + datasource: + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + # default_batch_fetch_size: 100 + open-in-view: false + sql: + init: + mode: never +logging: + level: + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: debug + +discodeit: + storage: + s3: + access-key: access-key + secret-key: secret-key + region: region + bucket: bucket diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f94e0ff44..d5de866c6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,50 +1,110 @@ spring: application: name: discodeit + profiles: + active: dev + servlet: + multipart: + maxFileSize: 10MB # 파일 하나의 최대 크기 + maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량 + session: + timeout: 30m datasource: + driver-class-name: org.postgresql.Driver url: jdbc:postgresql://localhost:5432/discodeit username: discodeit_user password: discodeit1234 - driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: none - show-sql: true + ddl-auto: validate properties: hibernate: format_sql: true + # default_batch_fetch_size: 100 open-in-view: false - output: - ansi: - enabled: always - sql: - init: - mode: always + cache: + type: redis + cache-names: users, channels, notifications + caffeine: + spec: maximumSize=1000,expireAfterWrite=10m,recordStats + redis: + enable-statistics: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + kafka: + bootstrap-servers: localhost:9092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + consumer: + group-id: discodeit-group + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer -# jcf / file -discodeit: - repository: - type: file - file-directory: .discodeit - storage: - type: local - local: - root-path: .storage - -springdoc: - swagger-ui: - path: /docs - enabled: true - api-docs: - enabled: true - default-produces-media-type: application/json +management: info: - title: "Discodeit API 문서" - description: "Discodeit 프로젝트의 Swagger API 문서입니다." - version: "v1" + env: + enabled: true + endpoint: + health: + show-details: always + endpoints: + web: + exposure: + include: '*' + exclude: '' + health: + db: + enabled: true + observations: + annotations: + enabled: true +info: + app: + name: Discodeit + version: 1.7.0 + java: + version: 17 + spring-boot: + version: 3.4.0 + 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} logging: level: - org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace \ No newline at end of file + org.hibernate.SQL: info + org.hibernate.orm.jdbc.bind: info + +discodeit: + storage: + type: ${STORAGE_TYPE: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: ${ADMIN_USERNAME} + password: ${ADMIN_PASSWORD} + email: ${ADMIN_EMAIL} + remember-key: ${REMEMBER_KEY} +jwt: + key: dicodeit-jwt-key-abcdabcdabcdabcdabcdabcdabcd + access-token-expiration-minutes: 10 + refresh-token-expiration-minutes: 600 \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..1ddf8ac51 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,26 @@ + + + + + + .logs/myapp.log + + .logs/myapp.%d{yy-MM-dd}.log + 30 + + + %d{yy}-%d{MM}-%d{dd} %d{HH}:%d{mm}:%d{ss} [%X{requestId} | %X{requestMethod} | %X{requestUrl}] - + %msg%n + + + + + + + + + + + + + diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index f97c6cbb9..16177d5ea 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,15 +1,18 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; create table binary_contents ( id uuid default uuid_generate_v4() not null primary key, created_at timestamp with time zone default CURRENT_TIMESTAMP 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(255) not null, + status varchar(20) not null ); alter table binary_contents - owner to discodeit_user; + owner to discodeit_user_dev; create table users ( @@ -17,34 +20,19 @@ create table users primary key, created_at timestamp with time zone default CURRENT_TIMESTAMP not null, updated_at timestamp with time zone default CURRENT_TIMESTAMP, - username varchar(50) not null + username varchar(255) not null unique, - email varchar(100) not null + email varchar(255) not null unique, - password varchar(60) not null, + password varchar(255) not null, profile_id uuid references binary_contents - on delete set null + on delete set null, + role varchar(255) not null ); alter table users - owner to discodeit_user; - -create table user_statuses -( - id uuid default uuid_generate_v4() not null - primary key, - created_at timestamp with time zone default CURRENT_TIMESTAMP not null, - updated_at timestamp with time zone default CURRENT_TIMESTAMP, - user_id uuid not null - unique - references users - on delete cascade, - last_active_at timestamp with time zone default CURRENT_TIMESTAMP not null -); - -alter table user_statuses - owner to discodeit_user; + owner to discodeit_user_dev; create table channels ( @@ -52,22 +40,23 @@ create table channels primary key, created_at timestamp with time zone default CURRENT_TIMESTAMP not null, updated_at timestamp with time zone default CURRENT_TIMESTAMP, - name varchar(100), - description varchar(500), - type varchar(10) not null + name varchar(255), + description varchar(255), + type varchar(255) not null constraint channels_type_check - check ((type)::text = ANY ((ARRAY ['PUBLIC'::character varying, 'PRIVATE'::character varying])::text[])) -); + check ((type)::text = ANY + (ARRAY [('PUBLIC'::character varying)::text, ('PRIVATE'::character varying)::text])) + ); alter table channels - owner to discodeit_user; + owner to discodeit_user_dev; create table read_statuses ( id uuid default uuid_generate_v4() not null primary key, created_at timestamp with time zone default CURRENT_TIMESTAMP not null, - updated_at timestamp default CURRENT_TIMESTAMP, + updated_at timestamp with time zone default CURRENT_TIMESTAMP, user_id uuid not null references users on delete cascade, @@ -75,12 +64,15 @@ create table read_statuses references channels on delete cascade, last_read_at timestamp with time zone default CURRENT_TIMESTAMP not null, + notification_enabled boolean not null, constraint read_statuses_user_id_channel_id_unique + unique (user_id, channel_id), + constraint ukqttel343c4eq691kcxipoixr7 unique (user_id, channel_id) ); alter table read_statuses - owner to discodeit_user; + owner to discodeit_user_dev; create table messages ( @@ -88,7 +80,7 @@ create table messages primary key, created_at timestamp with time zone default CURRENT_TIMESTAMP not null, updated_at timestamp with time zone default CURRENT_TIMESTAMP, - content text, + content varchar(255), channel_id uuid not null references channels on delete cascade, @@ -98,7 +90,7 @@ create table messages ); alter table messages - owner to discodeit_user; + owner to discodeit_user_dev; create table message_attachments ( @@ -111,6 +103,4 @@ create table message_attachments ); alter table message_attachments - owner to discodeit_user; - - + owner to discodeit_user_dev; \ No newline at end of file diff --git a/src/main/resources/static/assets/index-D8OMG6Bz.js b/src/main/resources/static/assets/index-D8OMG6Bz.js deleted file mode 100644 index 84f4ce135..000000000 --- a/src/main/resources/static/assets/index-D8OMG6Bz.js +++ /dev/null @@ -1,1015 +0,0 @@ -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 R(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(){}},_=Object.assign,C={};function O(E,D,se){this.props=E,this.context=D,this.refs=C,this.updater=se||I}O.prototype.isReactComponent={},O.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")},O.prototype.forceUpdate=function(E){this.updater.enqueueForceUpdate(this,E,"forceUpdate")};function F(){}F.prototype=O.prototype;function B(E,D,se){this.props=E,this.context=D,this.refs=C,this.updater=se||I}var V=B.prototype=new F;V.constructor=B,_(V,O.prototype),V.isPureReactComponent=!0;var Q=Array.isArray,H=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)H.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,R=3,I=!1,_=!1,C=!1,O=typeof setTimeout=="function"?setTimeout:null,F=typeof clearTimeout=="function"?clearTimeout:null,B=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),!_)if(s(x)!==null)_=!0,We(H);else{var Z=s(v);Z!==null&&Se(Q,Z.startTime-W)}}function H(W,Z){_=!1,C&&(C=!1,F(re),re=-1),I=!0;var Y=R;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,R=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,R=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?(F(re),re=-1):C=!0,Se(Q,Y-E))):(W.sortIndex=D,i(x,W),_||I||(_=!0,We(H))),W},r.unstable_shouldYield=at,r.unstable_wrapCallback=function(W){var Z=R;return function(){var Y=R;R=Z;try{return W.apply(this,arguments)}finally{R=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 R(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 _(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 O={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){O[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];O[t]=new C(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){O[e]=new C(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){O[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){O[e]=new C(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){O[e]=new C(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){O[e]=new C(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){O[e]=new C(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){O[e]=new C(e,5,!1,e.toLowerCase(),null,!1,!1)});var F=/[\-:]([a-z])/g;function B(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(F,B);O[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(F,B);O[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(F,B);O[t]=new C(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){O[e]=new C(e,1,!1,e.toLowerCase(),null,!1,!1)}),O.xlinkHref=new C("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){O[e]=new C(e,1,!1,e.toLowerCase(),null,!0,!0)});function V(e,t,n,o){var u=O.hasOwnProperty(t)?O[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],$);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,$);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=U(k,ge.value,$),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,$),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,$){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 H: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,$,j.key),w.return=k,k=w):($=Oi(j.type,j.key,j.props,null,k.mode,$),$.ref=no(k,w,j),$.return=k,k=$)}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,$),w.return=k,k=w}return d(k);case We:return te=j._init,Ie(k,w,te(j._payload),$)}if(Ir(j))return K(k,w,j,$);if(Z(j))return X(k,w,j,$);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,$),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 U=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"){U=K.call(q,U,M);break e}U=K;break e;case 3:K.flags=K.flags&-65537|128;case 0:if(K=X.payload,M=typeof K=="function"?K.call(q,U,M):K,M==null)break e;U=Y({},U,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=U):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=U),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=U}}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,U=e,M=null;t:for(;;){for(var q;U!==n||u!==0&&U.nodeType!==3||(m=d+u),U!==a||o!==0&&U.nodeType!==3||(y=d+o),U.nodeType===3&&(d+=U.nodeValue.length),(q=U.firstChild)!==null;)M=U,U=q;for(;;){if(U===e)break t;if(M===n&&++P===u&&(m=d),M===a&&++z===o&&(y=d),(q=U.nextSibling)!==null)break;U=M,M=U.parentNode}U=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($){_e(t,t.return,$)}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,R=0,I=0,_=0,C=1,O=1,F=1,B=0,V="",Q=c,H=f,L=l,b=V;O;)switch(_=B,B=Mt()){case 40:if(_!=108&&$e(b,A-1)==58){Qi(b+=ae(xu(B),"&","&\f"),"&\f",up(v?g[v-1]:0))!=-1&&(F=-1);break}case 34:case 39:case 91:b+=xu(B);break;case 9:case 10:case 13:case 32:b+=Cg(_);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)*F;case 125*C:case 59:case 0:switch(B){case 0:case 125:O=0;case 59+S:F==-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=[],H=[],A,f),f),B===123)if(S===0)Ki(b,i,L,L,Q,f,A,g,H);else switch(R===99&&$e(b,3)===110?100:R){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,H),H),c,H,A,g,l?Q:H);break;default:Ki(b,L,L,L,[""],H,0,g,H)}}v=S=I=0,C=F=1,V=b="",A=p;break;case 58:A=1+Wt(b),I=_;default:if(C<1){if(B==123)--C;else if(B==125&&C++==0&&xg()==125)continue}switch(b+=Ju(B),B*C){case 38:F=S>0?1:(b+="\f",-1);break;case 44:g[v++]=(Wt(b)-1)*F,F=1;break;case 64:Bn()===45&&(b+=xu(Mt())),R=Bn(),S=A=Wt(V=b+=Ag(Gi())),B++;break;case 45:_===45&&Wt(b)==2&&(C=0)}}return f}function fd(r,i,s,l,c,f,p,g,x,v,S,A){for(var R=c-1,I=c===0?f:[""],_=cp(I),C=0,O=0,F=0;C0?I[B]+" "+V:ae(V,/&\f/g,I[B])))&&(x[F++]=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&&(O+="".concat(F,","))}),x+="".concat(_).concat(C,'{content:"').concat(O,'"}').concat(Zu)},S=0;S0?".".concat(i):R},S=x.slice();S.push(function(R){R.type===cs&&R.value.includes("&")&&(R.props[0]=R.props[0].replace(sy,s).replace(l,v))}),p.prefix&&S.push(Ng),S.push(_g);var A=function(R,I,_,C){I===void 0&&(I=""),_===void 0&&(_=""),C===void 0&&(C="&"),i=C,s=I,l=new RegExp("\\".concat(s,"\\b"),"g");var O=R.replace(ly,""),F=Rg(_||I?"".concat(_," ").concat(I," { ").concat(O," }"):O);p.namespace&&(F=kp(F,p.namespace));var B=[];return rs(F,Tg(S.concat(Ig(function(V){return B.push(V)})))),B};return A.hash=x.length?x.reduce(function(R,I){return I.name||Vn(15),wr(R,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,H){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 H?"".concat(H,"-").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,R=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,_=i.shouldForwardProp;if(l&&c.shouldForwardProp){var C=c.shouldForwardProp;if(i.shouldForwardProp){var O=i.shouldForwardProp;_=function(Q,H){return C(Q,H)&&O(Q,H)}}else _=C}var F=new hy(s,R,l?c.componentStyle:void 0);function B(Q,H){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,N={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)}N.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:N.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 N.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 N.isPlainObject(r)||N.isArray(r)}function Fp(r){return N.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 N.isArray(r)&&!r.some($u)}const ov=N.toFlatObject(N,{},null,function(i){return/^is[A-Z]/.test(i)});function ws(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(C,O){return!N.isUndefined(O[C])});const l=s.metaTokens,c=s.visitor||S,f=s.dots,p=s.indexes,x=(s.Blob||typeof Blob<"u"&&Blob)&&N.isSpecCompliantForm(i);if(!N.isFunction(c))throw new TypeError("visitor must be a function");function v(_){if(_===null)return"";if(N.isDate(_))return _.toISOString();if(!x&&N.isBlob(_))throw new le("Blob is not supported. Use a Buffer instead.");return N.isArrayBuffer(_)||N.isTypedArray(_)?x&&typeof Blob=="function"?new Blob([_]):Buffer.from(_):_}function S(_,C,O){let F=_;if(_&&!O&&typeof _=="object"){if(N.endsWith(C,"{}"))C=l?C:C.slice(0,-2),_=JSON.stringify(_);else if(N.isArray(_)&&rv(_)||(N.isFileList(_)||N.endsWith(C,"[]"))&&(F=N.toArray(_)))return C=Fp(C),F.forEach(function(V,Q){!(N.isUndefined(V)||V===null)&&i.append(p===!0?Pd([C],Q,f):p===null?C:C+"[]",v(V))}),!1}return $u(_)?!0:(i.append(Pd(O,C,f),v(_)),!1)}const A=[],R=Object.assign(ov,{defaultVisitor:S,convertValue:v,isVisitable:$u});function I(_,C){if(!N.isUndefined(_)){if(A.indexOf(_)!==-1)throw Error("Circular reference detected in "+C.join("."));A.push(_),N.forEach(_,function(F,B){(!(N.isUndefined(F)||F===null)&&c.call(i,F,N.isString(B)?B.trim():B,C,R))===!0&&I(F,C?C.concat(B):[B])}),A.pop()}}if(!N.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;N.isFunction(s)&&(s={serialize:s});const c=s&&s.serialize;let f;if(c?f=c(i,s):f=N.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){N.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&&N.isBuffer(s)?(this.append(l,s.toString("base64")),!1):f.defaultVisitor.apply(this,arguments)}},i))}function mv(r){return N.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&&N.isArray(c)?c.length:p,x?(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],f)&&N.isArray(c[p])&&(c[p]=gv(c[p])),!g)}if(N.isFormData(r)&&N.isFunction(r.entries)){const s={};return N.forEachEntry(r,(l,c)=>{i(mv(l),c,s,0)}),s}return null}function yv(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 To={transitional:Hp,adapter:["xhr","http","fetch"],transformRequest:[function(i,s){const l=s.getContentType()||"",c=l.indexOf("application/json")>-1,f=N.isObject(i);if(f&&N.isHTMLForm(i)&&(i=new FormData(i)),N.isFormData(i))return c?JSON.stringify(bp(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(f){if(l.indexOf("application/x-www-form-urlencoded")>-1)return hv(i,this.formSerializer).toString();if((g=N.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(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"?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}}};N.forEach(["delete","get","head","post","put","patch"],r=>{To.headers[r]={}});const vv=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"]),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:N.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(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 Ev(r){return r.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(i,s,l)=>s.toUpperCase()+l)}function Cv(r,i){const s=N.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=N.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)=>N.forEach(g,(v,S)=>f(v,S,x));if(N.isPlainObject(i)||i instanceof this.constructor)p(i,s);else if(N.isString(i)&&(i=i.trim())&&!Sv(i))p(wv(i),s);else if(N.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=N.findKey(this,i);if(l){const c=this[l];if(!s)return c;if(s===!0)return xv(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=vo(i),i){const l=N.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=N.findKey(l,p);g&&(!s||ju(l,l[g],g,s))&&(delete l[g],c=!0)}}return N.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 N.forEach(this,(c,f)=>{const p=N.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 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[Id]=this[Id]={accessors:{}}).accessors,c=this.prototype;function f(p){const g=vo(p);l[g]||(Cv(c,p),l[g]=!0)}return N.isArray(i)?i.forEach(f):f(i),this}}ut.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);N.reduceDescriptors(ut.prototype,({value:r},i)=>{let s=i[0].toUpperCase()+i.slice(1);return{get:()=>r,set(l){this[s]=l}}});N.freezeMethods(ut);function Au(r,i){const s=this||To,l=i||s,c=ut.from(l.headers);let f=l.data;return N.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"}N.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,R=0;for(;A!==c;)R+=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)=>N.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)];N.isNumber(s)&&p.push("expires="+new Date(s).toGMTString()),N.isString(l)&&p.push("path="+l),N.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,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,A,R){if(N.isUndefined(S)){if(!N.isUndefined(v))return l(void 0,v,A,R)}else return l(v,S,A,R)}function f(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,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 N.forEach(Object.keys(Object.assign({},r,i)),function(S){const A=x[S]||c,R=A(r[S],i[S],S);N.isUndefined(R)&&A!==g||(s[S]=R)}),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(N.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&&N.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,R,I,_;function C(){I&&I(),_&&_(),c.cancelToken&&c.cancelToken.unsubscribe(S),c.signal&&c.signal.removeEventListener("abort",S)}let O=new XMLHttpRequest;O.open(c.method.toUpperCase(),c.url,!0),O.timeout=c.timeout;function F(){if(!O)return;const V=ut.from("getAllResponseHeaders"in O&&O.getAllResponseHeaders()),H={data:!g||g==="text"||g==="json"?O.responseText:O.response,status:O.status,statusText:O.statusText,headers:V,config:r,request:O};Wp(function(b){s(b),C()},function(b){l(b),C()},H),O=null}"onloadend"in O?O.onloadend=F:O.onreadystatechange=function(){!O||O.readyState!==4||O.status===0&&!(O.responseURL&&O.responseURL.indexOf("file:")===0)||setTimeout(F)},O.onabort=function(){O&&(l(new le("Request aborted",le.ECONNABORTED,r,O)),O=null)},O.onerror=function(){l(new le("Network Error",le.ERR_NETWORK,r,O)),O=null},O.ontimeout=function(){let Q=c.timeout?"timeout of "+c.timeout+"ms exceeded":"timeout exceeded";const H=c.transitional||Hp;c.timeoutErrorMessage&&(Q=c.timeoutErrorMessage),l(new le(Q,H.clarifyTimeoutError?le.ETIMEDOUT:le.ECONNABORTED,r,O)),O=null},f===void 0&&p.setContentType(null),"setRequestHeader"in O&&N.forEach(p.toJSON(),function(Q,H){O.setRequestHeader(H,Q)}),N.isUndefined(c.withCredentials)||(O.withCredentials=!!c.withCredentials),g&&g!=="json"&&(O.responseType=c.responseType),v&&([R,_]=ls(v,!0),O.addEventListener("progress",R)),x&&O.upload&&([A,I]=ls(x),O.upload.addEventListener("progress",A),O.upload.addEventListener("loadend",I)),(c.cancelToken||c.signal)&&(S=V=>{O&&(l(!V||V.type?new Pr(null,r,O):V),O.abort(),O=null)},c.cancelToken&&c.cancelToken.subscribe(S),c.signal&&(c.signal.aborted?S():c.signal.addEventListener("abort",S)));const B=kv(c.url);if(B&&Ke.protocols.indexOf(B)===-1){l(new le("Unsupported protocol "+B+":",le.ERR_BAD_REQUEST,r));return}O.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=()=>N.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 R=f+=A;s(R)}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(()=>N.isReadableStream(new Response("").body)),us={stream:bu&&(r=>r.body)};xs&&(r=>{["text","arrayBuffer","blob","formData","stream"].forEach(i=>{!us[i]&&(us[i]=N.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(N.isBlob(r))return r.size;if(N.isSpecCompliantForm(r))return(await new Request(Ke.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 zv(r)).byteLength},Bv=async(r,i)=>{const s=N.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:R}=qp(r);v=v?(v+"").toLowerCase():"text";let I=Ov([c,f&&f.toAbortSignal()],p),_;const C=I&&I.unsubscribe&&(()=>{I.unsubscribe()});let O;try{if(x&&Uv&&s!=="get"&&s!=="head"&&(O=await Bv(S,l))!==0){let H=new Request(i,{method:"POST",body:l,duplex:"half"}),L;if(N.isFormData(l)&&(L=H.headers.get("content-type"))&&S.setContentType(L),H.body){const[b,re]=Nd(O,ls(Od(x)));l=Dd(H.body,Md,b,re)}}N.isString(A)||(A=A?"include":"omit");const F="credentials"in Request.prototype;_=new Request(i,{...R,signal:I,method:s.toUpperCase(),headers:S.normalize().toJSON(),body:l,duplex:"half",credentials:F?A:void 0});let B=await fetch(_);const V=bu&&(v==="stream"||v==="response");if(bu&&(g||V&&C)){const H={};["status","statusText","headers"].forEach(ye=>{H[ye]=B[ye]});const L=N.toFiniteNumber(B.headers.get("content-length")),[b,re]=g&&Nd(L,ls(Od(g),!0))||[];B=new Response(Dd(B.body,Md,b,()=>{re&&re(),C&&C()}),H)}v=v||"text";let Q=await us[N.findKey(us,v)||"text"](B,r);return!V&&C&&C(),await new Promise((H,L)=>{Wp(H,L,{data:Q,headers:ut.from(B.headers),status:B.status,statusText:B.statusText,config:r,request:_})})}catch(F){throw C&&C(),F&&F.name==="TypeError"&&/fetch/i.test(F.message)?Object.assign(new le("Network Error",le.ERR_NETWORK,r,_),{cause:F.cause||F}):le.from(F,F&&F.code,r,_)}}),Vu={http:nv,xhr:Nv,fetch:$v};N.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=>N.isFunction(r)||r===null||r===!1,Kp={getAdapter:r=>{r=N.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&&(N.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&&N.merge(f.common,f[s.method]);f&&N.forEach(["delete","get","head","post","put","patch","common"],_=>{delete f[_]}),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,R;if(!x){const _=[Ud.bind(this),void 0];for(_.unshift.apply(_,g),_.push.apply(_,v),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 N.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 N.extend(s,Hn.prototype,i,{allOwnKeys:!0}),N.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(N.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 R=i;i=S??(typeof A!="object"||A===null)?A:Object.assign({},i,A),s.forEach(I=>I(i,R))}},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,O)=>({...O,...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,O)=>{A(C,O),S()};const R=r((...C)=>{s(...C),S()},l,c);c.getInitialState=()=>R;let I;const _=()=>{var C,O;if(!v)return;p=!1,g.forEach(B=>{var V;return B((V=l())!=null?V:R)});const F=((O=f.onRehydrateStorage)==null?void 0:O.call(f,(C=l())!=null?C:R))||void 0;return Yu(v.getItem.bind(v))(f.name).then(B=>{if(B)if(typeof B.version=="number"&&B.version!==f.version){if(f.migrate){const V=f.migrate(B.state,B.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,B.state];return[!1,void 0]}).then(B=>{var V;const[Q,H]=B;if(I=f.merge(H,(V=l())!=null?V:R),s(I,!0),Q)return S()}).then(()=>{F==null||F(I,void 0),I=l(),p=!0,x.forEach(B=>B(I))}).catch(B=>{F==null||F(void 0,B)})};return c.persist={setOptions:C=>{f={...f,...C},C.storage&&(v=C.storage)},clearStorage:()=>{v==null||v.removeItem(f.name)},getOptions:()=>f,rehydrate:()=>_(),hasHydrated:()=>p,onHydrate:C=>(g.add(C),()=>{g.delete(C)}),onFinishHydration:C=>(x.add(C),()=>{x.delete(C)})},f.skipHydration||_(),I||R},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=T.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=T.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=T.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=T.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=T.div` - color: ${ee.colors.status.error}; - font-size: 14px; - text-align: center; -`,i0=T.p` - text-align: center; - margin-top: 16px; - color: ${({theme:r})=>r.colors.text.muted}; - font-size: 14px; -`,s0=T.span` - color: ${({theme:r})=>r.colors.brand.primary}; - cursor: pointer; - - &:hover { - text-decoration: underline; - } -`,Vi=T.div` - margin-bottom: 20px; -`,Wi=T.label` - display: block; - color: ${({theme:r})=>r.colors.text.muted}; - font-size: 12px; - font-weight: 700; - margin-bottom: 8px; -`,Pu=T.span` - color: ${({theme:r})=>r.colors.status.error}; -`,l0=T.div` - display: flex; - flex-direction: column; - align-items: center; - margin: 10px 0; -`,u0=T.img` - width: 80px; - height: 80px; - border-radius: 50%; - margin-bottom: 10px; - object-fit: cover; -`,a0=T.input` - display: none; -`,c0=T.label` - color: ${({theme:r})=>r.colors.brand.primary}; - cursor: pointer; - font-size: 14px; - - &:hover { - text-decoration: underline; - } -`,f0=T.span` - color: ${({theme:r})=>r.colors.brand.primary}; - cursor: pointer; - - &:hover { - text-decoration: underline; - } -`,d0=T(f0)` - display: block; - text-align: center; - margin-top: 16px; -`,zt="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAADwCAYAAAA+VemSAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAw2SURBVHgB7d3PT1XpHcfxBy5g6hipSMolGViACThxJDbVRZ2FXejKlf9h/4GmC1fTRdkwC8fE0JgyJuICFkCjEA04GeZe6P0cPC0698I95zzPc57v5f1K6DSto3A8n/v9nufXGfrr338+dgBMGnYAzCLAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwbcTDvyuWh//33w1/1dexwMRBgYxTW5vVh9/vxYTcxPpR9jY0OffZrdt8fu82ttlvfbLv9j4R5kBHgxCmcE1eH3NfTDTc7PfxZte3lJNgjbmlxxK3+1HKrr1oOg4kAJ0pVdnG+4ZqTw7+psEUoxF91Qv/Di1+db/q+ZpvD7g+T6gb04XLyv6mF3//osuqvTmDn3RGdQCAEOCG6+W/ONdzNTnCrhPZLN2Yb2T99hVhdwOLcSOf37f7hknUN4yedgLoGeb3Rdv/qdAIE2S8CnIDzAuGDQrzXeTZee1OtndaHy9LCSOHvU3++vv693nLPX9LS+0KAa6QQLC2o4sb5a1A7rYGtMqPU+l7v3hpx85+qeVnfdH7W2c7z/Pcrh1RjD5gHromq2JOHY9HCK2Ojzk1dL1fhH90fqxzenDoO/X79DMjhbAQ4Mg1OPXl4KauGodrls6j6FaXKq+dZn/IQ13ENBgkBjiRvQR99V2/lmZos9lc+PxOuxdd1uL3gp6pfVDwDR6Ab9cG9Me9VLAZ1CiHpmXhz6yibakJxVODAZpoN9/iBzfCq+sboFkJ/SAwyrlxAujE1WJWSIiO/sYKlxSpTnbEBqnBxVOBA9LybWnjloM8An6ysitc1NCe5FcvgqgVw/85o1OmhItY32n39uqnJuC3/FAEuhavmmcLra77UN7XP2322qRNX494aqvgojqvmUcrhFa1+6tdXkae6tMiEhR3FEWBPNOCTcni1rZCli4OHAHuQ4mjzaewJHlxMI1Wked5Uw7v99ijbwqd/FnVQQ7WmQyiOAFegZ7a736ZzCU820h+7nbfHbnO7XSq4p3+vmHbfMwdcBgGuoO4dNQrZxtaR+08nqNueT73Y2D7qTIW5aLRXGcUR4JL03FtHeBXa9Y2jyhX2PHudiqg/K9ZuoY3t/uan8TkCXIKCG/u5V2Fae9N2a+vtKO2tjqfVnxfj5zw5O4sWugwCXIJa51hiB/e0tfVWdkZX6CrMCHl5BLigWDt0RCc6rrxo1XZQu6rw6qt2tq47FD0G9Lu8E79FgAvIWucIO3QU2B9ftpK4sVWFZ5rDQTYbqHUOcdztRcJCjgLUToauvrqpny4fJlWVlp/5P4BOH1IcbFcdAe6Tght6h5FeiaLwpnZTq5VW2HzN1eYfUoS3OgLcp9sL4cOrkKT6YrI8dFUHnDQYR3j94Rm4D9kLxQLuV009vKdpXbXae00vFdm8UWVZJ3ojwH3QcS+hnn1VifSMaemVoPqeVzqDT6rG2oivQS5dH33l70ZS262w7n04yhae8MrTMAhwH0KNPFsfyNH3vd+pxkwD1Ydn4HOodQ5VfTXHyrMgqiDA55ibCbNJX1VLc6xAFQT4HCEGr9Q6s3wQPhDgM4RqnzWVQusMHwjwGTS66puCS/WFLwT4DCHOKia88IkA96BjTkOcVbzDQgZ4RIB7CBFejTzz7AufCHAPWn3lGwse4BsB7uGa5wqcLS3k7XvwjAD3cOWy84pnX4RAgHvw/QzMLhyEQIC7CLF4Y4+DyxEAAe4iRIB3PzD6DP8IcBejnncPagCL/bAIgQB34fsc5P2PtM8IgwBHcMjJqQiEAHfBm+JhBQGO4IDlkwiEAHdx2PIbuFhv+MPFQ4C7ODx0Xo2OOiAIAhwBz9QIhQB34XvOlhYaoRDgLg5+dl7pcACqMEIgwF2EWDV1bZwAwz8C3IVOzfAd4omrXGr4x13Vg++jb6YmudTwj7uqh733fgOsM6YZzIJvBLiH3Q/+NyDMB3pNCy4u3k7Yw+57/wNZM9PDbu2NGwjqJiauDrmvpxufXiv6+f+v63fw8SjrZDgLLBwC3INO0NBAls+2V220jurZNXw6h8K6ODfibsye/UjQnNR/nnQcGk/IX/DNsbp+EeAetAVQVaQ56fe5dXGu4X54YTPASwsj7uZ8o/CHmkJ/Y7aRfb3eaBNkj3gGPsNOgNZPN7G1RR36fh8/uJS96LxqR6Kf/9H9MRa2eEKAz7C5FaZS3l6w0/goaArchMeFKPkHwrVxbr+quIJn0LNqiFZPVSjEmx98U7UNVS016PWXe6NU4ooI8DnWN8O8DuX+H0eTnxdeWgjb7uv3/vMd9lpWQYDPEep9Rrp5by+kOy+s7+/mfPhWXyPzFrqRVHHlzpFPgYTwTScg87NphjhmZdTgGMohwH1YexPupdx3b40mN5ij6tuMuHabKlweV60PGo0OdTB7ioM5WjEWW5PNHqVw1fq09ibcu33zqZpUQjzTjN/Ws1urHK5an9bWW0Ffj5JSiOv4HiaYEy6Fq9YnLa1cfRWuCku+wOHmXL2DOnUEmGOHyiHABagKh17Dqxv57rcj7k+3RpKfJ0b9CHBBKy/ivOhIU0yPH4xdqD3EV37HB1ZRBLignc6c8MZW2FY6p5ZSK7b0bNyMOM3CTiE7CHAJz1+2or7vV1Msj74by4IcoyKHOMygH4fhptsHFgEuQRXqx5fx7zYFWRX5ycNL2UqpUFV5512cDuNLvAS9ONawlaQ10jpSJsZ64S+d3iCvm3777XGntW9nx9fsfqh+JK5+Nq0Qi43WvTgCXMHqq5abma53g75Gqmen9fX/alz1CBtNmenfj7k6yvIxQ3Wiha5AN/r3K4fJtX55hVarvVTy8AB9OMV0GGdwf+AQ4IpU4f75LN27Tzt9HtwbKzynrNF2zXvHsvOWClwGAfZAN18dg1r9UnuthSFF6WeK1doS4HIIsCeqVrHbziLUUpdZornc6S5iDC5p8A3FEWCPVn9KO8RlTpVUeJ8u/xLsUAPR780UUjkE2LOUQ6x11jPN4n/l+WDdaqDznEOdO3YREOAAFOJUn4mrTA3p51KQNU/sM8g8/5bHPHAgeibWAND9O2mdtlF147yCm2/o0IeBXlyuAwDKfjDotBMWcJRHBQ5IlUUVa1Bv0O1squnkVSllvd5kAXQVBDiwfBAo5pyqFbo2od5+cVEQ4Ag0CKRnYrWedVfjlLqBlEfsrSDAEWnwJx8Eqsve+zQCrA+SOq/DoCDAkeWDQE+X63k23txKIzRUXz8IcE00Qv23f/wSta3Odim9q/+Zc6Pz3Ev19YNppJrpRtaXXrGinUMhp5zUvqfg+Uu2HvlCgBORB1nzqYtzDTc77ffoHC3CSGEAS4N5zPv6Q4ATo7lVfV253MoWXegMrKob6xWaFKax9PzNdJpfBDhRqlL7n6qy2mqFWeuY9QaDfttsfRCoXd1NYOS5rnPEBh0BNuB0mGVifOgk1Ncb2VJGbVLIdxnp12qqaHO7HXQHURH6ngZ5RVqdCLBBqqj62jCwiknbBJefEd5QCDCCUWgV3hRa+EFFgBEEbXMcBBjeabR55UWLUzYiIMDwRoHVK1iZKoqHAMMLqm49CDAqyxefID42MwCGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhv0XZkN9IbEGbp4AAAAASUVORK5CYII=",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),[R,I]=ie.useState(""),_=vt(F=>F.setCurrentUser),C=F=>{var V;const B=(V=F.target.files)==null?void 0:V[0];if(B){v(B);const Q=new FileReader;Q.onloadend=()=>{A(Q.result)},Q.readAsDataURL(B)}},O=async F=>{F.preventDefault(),I("");try{const B=new FormData;B.append("userCreateRequest",new Blob([JSON.stringify({email:s,username:c,password:p})],{type:"application/json"})),x&&B.append("profile",x);const V=await Kv(B);_(V),i()}catch{I("회원가입에 실패했습니다.")}};return r?h.jsx(eh,{children:h.jsxs(th,{children:[h.jsx("h2",{children:"계정 만들기"}),h.jsxs("form",{onSubmit:O,children:[h.jsxs(Vi,{children:[h.jsxs(Wi,{children:["이메일 ",h.jsx(Pu,{children:"*"})]}),h.jsx(ko,{type:"email",value:s,onChange:F=>l(F.target.value),required:!0})]}),h.jsxs(Vi,{children:[h.jsxs(Wi,{children:["사용자명 ",h.jsx(Pu,{children:"*"})]}),h.jsx(ko,{type:"text",value:c,onChange:F=>f(F.target.value),required:!0})]}),h.jsxs(Vi,{children:[h.jsxs(Wi,{children:["비밀번호 ",h.jsx(Pu,{children:"*"})]}),h.jsx(ko,{type:"password",value:p,onChange:F=>g(F.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:"이미지 변경"})]})]}),R&&h.jsx(rh,{children:R}),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(),R=async()=>{var I;try{const _=await Gv(s,c);await A(),S(_),g(""),i()}catch(_){console.error("로그인 에러:",_),((I=_.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(),R()},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=T.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}; -`;T.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=T.div` - position: relative; - width: ${r=>r.$size||"32px"}; - height: ${r=>r.$size||"32px"}; - flex-shrink: 0; - margin: ${r=>r.$margin||"0"}; -`,rn=T.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,R]=ie.useState(""),[I,_]=ie.useState(null),{binaryContents:C,fetchBinaryContent:O}=Yn(),{logout:F,updateUser:B}=vt();ie.useEffect(()=>{var re;(re=s.profile)!=null&&re.id&&!C[s.profile.id]&&O(s.profile.id)},[s.profile,C,O]);const V=()=>{c(s.username),p(s.email),x(""),S(null),_(null),R(""),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=()=>{_(at.result)},at.readAsDataURL(ye)}},H=async re=>{re.preventDefault(),R("");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 B(s.id,ye)),i()}catch{R("사용자 정보 수정에 실패했습니다.")}};return r?h.jsx(k0,{children:h.jsxs(j0,{children:[h.jsx("h2",{children:"프로필 수정"}),h.jsxs("form",{onSubmit:H,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:F,children:"로그아웃"})]})}):null}const k0=T.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=T.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=T.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=T.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=T.div` - color: ${({theme:r})=>r.colors.status.error}; - font-size: 14px; - margin-bottom: 10px; -`,R0=T.div` - display: flex; - flex-direction: column; - align-items: center; - margin-bottom: 20px; -`,P0=T.img` - width: 100px; - height: 100px; - border-radius: 50%; - margin-bottom: 10px; - object-fit: cover; -`,_0=T.input` - display: none; -`,T0=T.label` - color: ${({theme:r})=>r.colors.brand.primary}; - cursor: pointer; - font-size: 14px; - - &:hover { - text-decoration: underline; - } -`,I0=T.div` - display: flex; - gap: 10px; - margin-top: 20px; -`,N0=T.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=T.div` - margin-bottom: 20px; -`,qi=T.label` - display: block; - color: ${({theme:r})=>r.colors.text.muted}; - font-size: 12px; - font-weight: 700; - margin-bottom: 8px; -`,bd=T.span` - color: ${({theme:r})=>r.colors.status.error}; -`,O0=T.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=T(Tr)``;T(rn)``;const D0=T.div` - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - justify-content: center; -`,M0=T.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=T.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=T.div` - display: flex; - align-items: center; - flex-shrink: 0; -`,F0=T.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=T.div` - width: 240px; - background: ${ee.colors.background.secondary}; - border-right: 1px solid ${ee.colors.border.primary}; - display: flex; - flex-direction: column; -`,H0=T.div` - flex: 1; - overflow-y: auto; -`,b0=T.div` - padding: 16px; - font-size: 16px; - font-weight: bold; - color: ${ee.colors.text.primary}; -`,oh=T.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=T.div` - margin-bottom: 8px; -`,qu=T.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=T.span` - margin-right: 4px; - font-size: 10px; - transition: transform 0.2s; - transform: rotate(${r=>r.$folded?"-90deg":"0deg"}); -`,Yd=T.div` - display: ${r=>r.$folded?"none":"block"}; -`,qd=T(oh)` - height: ${r=>r.hasSubtext?"42px":"34px"}; -`,V0=T(Tr)` - width: 32px; - height: 32px; - margin: 0 8px; -`,Qd=T.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"}; -`;T(Io)` - border-color: ${ee.colors.background.primary}; -`;const Gd=T.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=T(Tr)` - width: 40px; - height: 24px; - margin: 0 8px; -`,Y0=T.div` - font-size: 12px; - line-height: 13px; - color: ${ee.colors.text.muted}; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`,Kd=T.div` - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - justify-content: center; - gap: 2px; -`,q0=T.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=T.div` - background: ${ee.colors.background.primary}; - border-radius: 4px; - width: 440px; - max-width: 90%; -`,G0=T.div` - padding: 16px; - display: flex; - justify-content: space-between; - align-items: center; -`,K0=T.h2` - color: ${ee.colors.text.primary}; - font-size: 20px; - font-weight: 600; - margin: 0; -`,X0=T.div` - padding: 0 16px 16px; -`,J0=T.form` - display: flex; - flex-direction: column; - gap: 16px; -`,Tu=T.div` - display: flex; - flex-direction: column; - gap: 8px; -`,Iu=T.label` - color: ${ee.colors.text.primary}; - font-size: 12px; - font-weight: 600; - text-transform: uppercase; -`,Z0=T.p` - color: ${ee.colors.text.muted}; - font-size: 14px; - margin: -4px 0 0; -`,Qu=T.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=T.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=T.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=T(Qu)` - margin-bottom: 8px; -`,r1=T.div` - max-height: 300px; - overflow-y: auto; - background: ${ee.colors.background.tertiary}; - border-radius: 4px; -`,o1=T.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=T.input` - margin-right: 12px; - width: 16px; - height: 16px; - cursor: pointer; -`,Xd=T.img` - width: 32px; - height: 32px; - border-radius: 50%; - margin-right: 12px; -`,s1=T.div` - flex: 1; - min-width: 0; -`,l1=T.div` - color: ${ee.colors.text.primary}; - font-size: 14px; - font-weight: 500; -`,u1=T.div` - color: ${ee.colors.text.muted}; - font-size: 12px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`,a1=T.div` - padding: 16px; - text-align: center; - color: ${ee.colors.text.muted}; -`,c1=T.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 R;return h.jsx(rn,{src:S.profile?(R=f[S.profile.id])==null?void 0:R.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(""),R=nn(H=>H.users),I=Yn(H=>H.binaryContents),_=vt(H=>H.currentUserId),C=ie.useMemo(()=>R.filter(H=>H.id!==_).filter(H=>H.username.toLowerCase().includes(p.toLowerCase())||H.email.toLowerCase().includes(p.toLowerCase())),[p,R,_]),O=xr(H=>H.createPublicChannel),F=xr(H=>H.createPrivateChannel),B=H=>{const{name:L,value:b}=H.target;f(re=>({...re,[L]:b}))},V=H=>{v(L=>L.includes(H)?L.filter(b=>b!==H):[...L,H])},Q=async H=>{var L,b;H.preventDefault(),A("");try{let re;if(i==="PUBLIC"){if(!c.name.trim()){A("채널 이름을 입력해주세요.");return}const ye={name:c.name,description:c.description};re=await O(ye)}else{if(x.length===0){A("대화 상대를 선택해주세요.");return}const ye=_&&[...x,_]||x;re=await F(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:H=>H.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:B,placeholder:"새로운-채널",required:!0})]}),h.jsxs(Tu,{children:[h.jsx(Iu,{children:"채널 설명"}),h.jsx(Z0,{children:"이 채널의 주제를 설명해주세요."}),h.jsx(Qu,{name:"description",value:c.description,onChange:B,placeholder:"채널 설명을 입력하세요"})]})]}):h.jsxs(Tu,{children:[h.jsx(Iu,{children:"사용자 검색"}),h.jsx(n1,{type:"text",value:p,onChange:H=>g(H.target.value),placeholder:"사용자명 또는 이메일로 검색"}),h.jsx(r1,{children:C.length>0?C.map(H=>h.jsxs(o1,{children:[h.jsx(i1,{type:"checkbox",checked:x.includes(H.id),onChange:()=>V(H.id)}),H.profile?h.jsx(Xd,{src:I[H.profile.id].url}):h.jsx(Xd,{src:zt}),h.jsxs(s1,{children:[h.jsx(l1,{children:H.username}),h.jsx(u1,{children:H.email})]})]},H.id)):h.jsx(a1,{children:"검색 결과가 없습니다."})})]}),h.jsx(e1,{type:"submit",children:i==="PUBLIC"?"채널 만들기":"대화 시작하기"})]})})]})}):null}function p1({currentUser:r,activeChannel:i,onChannelSelect:s}){var Q,H;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),R=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 _=L=>{c(b=>({...b,[L]:!b[L]}))},C=(L,b)=>{b.stopPropagation(),p({isOpen:!0,type:L})},O=()=>{p({isOpen:!1,type:null})},F=async L=>{try{const re=(await x(r.id)).find(ye=>ye.id===L.id);re&&s(re),O()}catch(b){console.error("채널 생성 실패:",b)}},B=L=>{s(L),R(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:()=>_("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:()=>B(L)},L.id))})]}),h.jsxs(Vd,{children:[h.jsxs(qu,{onClick:()=>_("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:(H=V.PRIVATE)==null?void 0:H.map(L=>h.jsx(Jd,{channel:L,isActive:(i==null?void 0:i.id)===L.id,hasUnread:I(L.id,L.lastMessageAt),onClick:()=>B(L)},L.id))})]})]}),h.jsx(h1,{children:h.jsx(B0,{user:r})}),h.jsx(d1,{isOpen:f.isOpen,type:f.type,onClose:O,onCreateSuccess:F})]})}const h1=T.div` - margin-top: auto; - border-top: 1px solid ${({theme:r})=>r.colors.border.primary}; - background-color: ${({theme:r})=>r.colors.background.tertiary}; -`,m1=T.div` - flex: 1; - display: flex; - flex-direction: column; - background: ${({theme:r})=>r.colors.background.primary}; -`,g1=T.div` - display: flex; - flex-direction: column; - height: 100%; - background: ${({theme:r})=>r.colors.background.primary}; -`,y1=T(g1)` - justify-content: center; - align-items: center; - flex: 1; - padding: 0 20px; -`,v1=T.div` - text-align: center; - max-width: 400px; - padding: 20px; - margin-bottom: 80px; -`,w1=T.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=T.h2` - color: ${({theme:r})=>r.colors.text.primary}; - font-size: 28px; - font-weight: 700; - margin-bottom: 16px; -`,S1=T.p` - color: ${({theme:r})=>r.colors.text.muted}; - font-size: 16px; - line-height: 1.6; - word-break: keep-all; -`,Zd=T.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=T.div` - display: flex; - align-items: center; - gap: 8px; - height: 100%; -`,E1=T.div` - display: flex; - align-items: center; - gap: 12px; - height: 100%; -`,C1=T(Tr)` - width: 24px; - height: 24px; -`;T.img` - width: 24px; - height: 24px; - border-radius: 50%; -`;const k1=T.div` - position: relative; - width: 40px; - height: 24px; - flex-shrink: 0; -`,j1=T(Io)` - border-color: ${ee.colors.background.primary}; - bottom: -3px; - right: -3px; -`,A1=T.div` - font-size: 12px; - color: ${ee.colors.text.muted}; - line-height: 13px; -`,tp=T.div` - font-weight: bold; - color: ${ee.colors.text.primary}; - line-height: 20px; - font-size: 16px; -`,R1=T.div` - flex: 1; - display: flex; - flex-direction: column-reverse; - overflow-y: auto; -`,P1=T.div` - padding: 16px; - display: flex; - flex-direction: column; -`,_1=T.div` - margin-bottom: 16px; - display: flex; - align-items: flex-start; -`,T1=T(Tr)` - margin-right: 16px; - width: 40px; - height: 40px; -`;T.img` - width: 40px; - height: 40px; - border-radius: 50%; -`;const I1=T.div` - display: flex; - align-items: center; - margin-bottom: 4px; -`,N1=T.span` - font-weight: bold; - color: ${ee.colors.text.primary}; - margin-right: 8px; -`,O1=T.span` - font-size: 0.75rem; - color: ${ee.colors.text.muted}; -`,L1=T.div` - color: ${ee.colors.text.secondary}; - margin-top: 4px; -`,D1=T.form` - display: flex; - align-items: center; - gap: 8px; - padding: 16px; - background: ${({theme:r})=>r.colors.background.secondary}; -`,M1=T.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=T.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}; - } -`;T.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=T.div` - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 8px; - width: 100%; -`,U1=T.a` - display: block; - border-radius: 4px; - overflow: hidden; - max-width: 300px; - - img { - width: 100%; - height: auto; - display: block; - } -`,F1=T.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=T.div` - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - font-size: 40px; - color: #0B93F6; -`,$1=T.div` - display: flex; - flex-direction: column; - gap: 2px; -`,H1=T.span` - font-size: 14px; - color: #0B93F6; - font-weight: 500; -`,b1=T.span` - font-size: 13px; - color: ${({theme:r})=>r.colors.text.muted}; -`,V1=T.div` - display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 8px 0; -`,ih=T.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=T(ih)` - padding: 0; - overflow: hidden; - width: 200px; - height: 120px; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } -`,Y1=T.div` - color: #0B93F6; - font-size: 20px; -`,q1=T.div` - font-size: 13px; - color: ${({theme:r})=>r.colors.text.primary}; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`,rp=T.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)=>{var l;return(await Je.get("/messages",{params:{channelId:r,page:i==null?void 0:i.page,size:i==null?void 0:i.size,sort:(l=i==null?void 0:i.sort)==null?void 0:l.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={page:0,size:50,sort:["createdAt,desc"]},sh=_r((r,i)=>({messages:[],pollingIntervals:{},lastMessageId:null,pagination:{currentPage:0,pageSize:50,hasNext:!1},fetchMessages:async(s,l=Nu)=>{try{const c=await G1(s,l),f=c.content,p=f.length>0?f[0]:null,g=(p==null?void 0:p.id)!==i().lastMessageId;return r(x=>{var _;const v=l.page===0,S=s!==((_=x.messages[0])==null?void 0:_.channelId),A=v&&(x.messages.length===0||S);let R=[],I={...x.pagination};if(A)R=f,I={currentPage:c.number,pageSize:c.size,hasNext:c.hasNext};else if(v){const C=new Set(x.messages.map(F=>F.id));R=[...f.filter(F=>!C.has(F.id)&&(x.messages.length===0||F.createdAt>x.messages[0].createdAt)),...x.messages]}else{if(x.messages.length>0){const C=new Set(x.messages.map(F=>F.id)),O=f.filter(F=>!C.has(F.id)&&F.createdAt{const{pagination:l}=i();if(!l.hasNext)return;const c={...Nu,page:l.currentPage+1};await i().fetchMessages(s,c)},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,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(R=>R.createMessage),p=vt(R=>R.currentUserId),g=async R=>{if(R.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=R=>{const I=Array.from(R.target.files||[]);c(_=>[..._,...I]),R.target.value=""},v=R=>{c(I=>I.filter((_,C)=>C!==R))},S=R=>{if(R.key==="Enter"&&!R.shiftKey){if(console.log("Enter key pressed"),R.preventDefault(),R.nativeEvent.isComposing)return;g(R)}},A=(R,I)=>R.type.startsWith("image/")?h.jsxs(W1,{children:[h.jsx("img",{src:URL.createObjectURL(R),alt:R.name}),h.jsx(rp,{onClick:()=>v(I),children:"×"})]},I):h.jsxs(ih,{children:[h.jsx(Y1,{children:"📎"}),h.jsx(q1,{children:R.name}),h.jsx(rp,{onClick:()=>v(I),children:"×"})]},I);return ie.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(V1,{children:l.map((R,I)=>A(R,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: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 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?_: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),f(r.id),()=>{p(r.id)}},[r==null?void 0:r.id,s,f,p]),ie.useEffect(()=>{i.forEach(I=>{var _;(_=I.attachments)==null||_.forEach(C=>{g[C.id]||x(C.id)})})},[i,g,x]);const v=async I=>{try{const{url:_,fileName:C}=I,O=document.createElement("a");O.href=_,O.download=C,O.style.display="none",document.body.appendChild(O);try{const B=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(_)).blob();await B.write(Q),await B.close()}catch(F){F.name!=="AbortError"&&O.click()}document.body.removeChild(O),window.URL.revokeObjectURL(_)}catch(_){console.error("파일 다운로드 실패:",_)}},S=I=>I!=null&&I.length?I.map(_=>{const C=g[_.id];return C?C.contentType.startsWith("image/")?h.jsx(np,{children:h.jsx(U1,{href:"#",onClick:F=>{F.preventDefault(),v(C)},children:h.jsx("img",{src:C.url,alt:C.fileName})})},C.url):h.jsx(np,{children:h.jsxs(F1,{href:"#",onClick:F=>{F.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(),R=()=>{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:R,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.currentPage>0?"모든 메시지를 불러왔습니다":""})}),children:h.jsx(P1,{children:[...i].reverse().map(I=>{var C;const _=I.author;return h.jsxs(_1,{children:[h.jsx(T1,{children:h.jsx(rn,{src:_&&_.profile?(C=g[_.profile.id])==null?void 0:C.url:zt,alt:_&&_.username||"알 수 없음"})}),h.jsxs("div",{children:[h.jsxs(I1,{children:[h.jsx(N1,{children:_&&_.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=T.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=T.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=T.div` - display: flex; - align-items: center; - margin-bottom: 16px; -`,uw=T.div` - color: ${({theme:r})=>r.colors.status.error}; - font-size: 24px; - margin-right: 12px; -`,aw=T.h3` - color: ${({theme:r})=>r.colors.text.primary}; - margin: 0; - font-size: 18px; -`,cw=T.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=T.p` - color: ${({theme:r})=>r.colors.text.secondary}; - margin-bottom: 20px; - line-height: 1.5; - font-weight: 500; -`,dw=T.div` - margin-bottom: 20px; - background: ${({theme:r})=>r.colors.background.secondary}; - border-radius: 6px; - padding: 12px; -`,wo=T.div` - display: flex; - margin-bottom: 8px; - font-size: 14px; -`,xo=T.span` - color: ${({theme:r})=>r.colors.text.muted}; - min-width: 100px; -`,So=T.span` - color: ${({theme:r})=>r.colors.text.secondary}; - word-break: break-word; -`,pw=T.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 R,I;if(!r)return null;const l=(R=s==null?void 0:s.response)==null?void 0:R.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:_=>_.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(([_,C])=>h.jsxs("div",{children:[_,": ",String(C)]},_))})]})]}),h.jsx(pw,{onClick:i,children:"확인"})]})})}const mw=T.div` - width: 240px; - background: ${ee.colors.background.secondary}; - border-left: 1px solid ${ee.colors.border.primary}; -`,gw=T.div` - padding: 16px; - font-size: 14px; - font-weight: bold; - color: ${ee.colors.text.muted}; - text-transform: uppercase; -`,yw=T.div` - padding: 8px 16px; - display: flex; - align-items: center; - color: ${ee.colors.text.muted}; -`,vw=T(Tr)` - margin-right: 12px; -`;T(rn)``;const ww=T.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,R]=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(O){console.warn("사용자 상태 업데이트 실패. 로그아웃합니다.",O),i()}}catch(O){console.error("초기화 오류:",O)}finally{R(!1)}})()},[r,c,l,i]),ie.useEffect(()=>{const C=V=>{x(V),S(!0)},O=()=>{i()},F=as.on("api-error",C),B=as.on("auth-error",O);return()=>{F("api-error",C),B("auth-error",O)}},[i]),ie.useEffect(()=>{let C;if(r){c(r),C=setInterval(()=>{c(r)},3e4);const O=setInterval(()=>{l()},6e4);return()=>{clearInterval(C),clearInterval(O)}}},[r,l,c]);const _=()=>{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:_,error:g})]})}const Cw=T.div` - display: flex; - height: 100vh; - width: 100vw; - position: relative; -`,kw=T.div` - display: flex; - justify-content: center; - align-items: center; - height: 100vh; - width: 100vw; - background-color: ${({theme:r})=>r.colors.background.primary}; -`,jw=T.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-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,iVBORw0KGgoAAAANSUhEUgAAAPAAAADwCAYAAAA+VemSAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAw2SURBVHgB7d3PT1XpHcfxBy5g6hipSMolGViACThxJDbVRZ2FXejKlf9h/4GmC1fTRdkwC8fE0JgyJuICFkCjEA04GeZe6P0cPC0698I95zzPc57v5f1K6DSto3A8n/v9nufXGfrr338+dgBMGnYAzCLAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwbcTDvyuWh//33w1/1dexwMRBgYxTW5vVh9/vxYTcxPpR9jY0OffZrdt8fu82ttlvfbLv9j4R5kBHgxCmcE1eH3NfTDTc7PfxZte3lJNgjbmlxxK3+1HKrr1oOg4kAJ0pVdnG+4ZqTw7+psEUoxF91Qv/Di1+db/q+ZpvD7g+T6gb04XLyv6mF3//osuqvTmDn3RGdQCAEOCG6+W/ONdzNTnCrhPZLN2Yb2T99hVhdwOLcSOf37f7hknUN4yedgLoGeb3Rdv/qdAIE2S8CnIDzAuGDQrzXeTZee1OtndaHy9LCSOHvU3++vv693nLPX9LS+0KAa6QQLC2o4sb5a1A7rYGtMqPU+l7v3hpx85+qeVnfdH7W2c7z/Pcrh1RjD5gHromq2JOHY9HCK2Ojzk1dL1fhH90fqxzenDoO/X79DMjhbAQ4Mg1OPXl4KauGodrls6j6FaXKq+dZn/IQ13ENBgkBjiRvQR99V2/lmZos9lc+PxOuxdd1uL3gp6pfVDwDR6Ab9cG9Me9VLAZ1CiHpmXhz6yibakJxVODAZpoN9/iBzfCq+sboFkJ/SAwyrlxAujE1WJWSIiO/sYKlxSpTnbEBqnBxVOBA9LybWnjloM8An6ysitc1NCe5FcvgqgVw/85o1OmhItY32n39uqnJuC3/FAEuhavmmcLra77UN7XP2322qRNX494aqvgojqvmUcrhFa1+6tdXkae6tMiEhR3FEWBPNOCTcni1rZCli4OHAHuQ4mjzaewJHlxMI1Wked5Uw7v99ijbwqd/FnVQQ7WmQyiOAFegZ7a736ZzCU820h+7nbfHbnO7XSq4p3+vmHbfMwdcBgGuoO4dNQrZxtaR+08nqNueT73Y2D7qTIW5aLRXGcUR4JL03FtHeBXa9Y2jyhX2PHudiqg/K9ZuoY3t/uan8TkCXIKCG/u5V2Fae9N2a+vtKO2tjqfVnxfj5zw5O4sWugwCXIJa51hiB/e0tfVWdkZX6CrMCHl5BLigWDt0RCc6rrxo1XZQu6rw6qt2tq47FD0G9Lu8E79FgAvIWucIO3QU2B9ftpK4sVWFZ5rDQTYbqHUOcdztRcJCjgLUToauvrqpny4fJlWVlp/5P4BOH1IcbFcdAe6Tght6h5FeiaLwpnZTq5VW2HzN1eYfUoS3OgLcp9sL4cOrkKT6YrI8dFUHnDQYR3j94Rm4D9kLxQLuV009vKdpXbXae00vFdm8UWVZJ3ojwH3QcS+hnn1VifSMaemVoPqeVzqDT6rG2oivQS5dH33l70ZS262w7n04yhae8MrTMAhwH0KNPFsfyNH3vd+pxkwD1Ydn4HOodQ5VfTXHyrMgqiDA55ibCbNJX1VLc6xAFQT4HCEGr9Q6s3wQPhDgM4RqnzWVQusMHwjwGTS66puCS/WFLwT4DCHOKia88IkA96BjTkOcVbzDQgZ4RIB7CBFejTzz7AufCHAPWn3lGwse4BsB7uGa5wqcLS3k7XvwjAD3cOWy84pnX4RAgHvw/QzMLhyEQIC7CLF4Y4+DyxEAAe4iRIB3PzD6DP8IcBejnncPagCL/bAIgQB34fsc5P2PtM8IgwBHcMjJqQiEAHfBm+JhBQGO4IDlkwiEAHdx2PIbuFhv+MPFQ4C7ODx0Xo2OOiAIAhwBz9QIhQB34XvOlhYaoRDgLg5+dl7pcACqMEIgwF2EWDV1bZwAwz8C3IVOzfAd4omrXGr4x13Vg++jb6YmudTwj7uqh733fgOsM6YZzIJvBLiH3Q/+NyDMB3pNCy4u3k7Yw+57/wNZM9PDbu2NGwjqJiauDrmvpxufXiv6+f+v63fw8SjrZDgLLBwC3INO0NBAls+2V220jurZNXw6h8K6ODfibsye/UjQnNR/nnQcGk/IX/DNsbp+EeAetAVQVaQ56fe5dXGu4X54YTPASwsj7uZ8o/CHmkJ/Y7aRfb3eaBNkj3gGPsNOgNZPN7G1RR36fh8/uJS96LxqR6Kf/9H9MRa2eEKAz7C5FaZS3l6w0/goaArchMeFKPkHwrVxbr+quIJn0LNqiFZPVSjEmx98U7UNVS016PWXe6NU4ooI8DnWN8O8DuX+H0eTnxdeWgjb7uv3/vMd9lpWQYDPEep9Rrp5by+kOy+s7+/mfPhWXyPzFrqRVHHlzpFPgYTwTScg87NphjhmZdTgGMohwH1YexPupdx3b40mN5ij6tuMuHabKlweV60PGo0OdTB7ioM5WjEWW5PNHqVw1fq09ibcu33zqZpUQjzTjN/Ws1urHK5an9bWW0Ffj5JSiOv4HiaYEy6Fq9YnLa1cfRWuCku+wOHmXL2DOnUEmGOHyiHABagKh17Dqxv57rcj7k+3RpKfJ0b9CHBBKy/ivOhIU0yPH4xdqD3EV37HB1ZRBLignc6c8MZW2FY6p5ZSK7b0bNyMOM3CTiE7CHAJz1+2or7vV1Msj74by4IcoyKHOMygH4fhptsHFgEuQRXqx5fx7zYFWRX5ycNL2UqpUFV5512cDuNLvAS9ONawlaQ10jpSJsZ64S+d3iCvm3777XGntW9nx9fsfqh+JK5+Nq0Qi43WvTgCXMHqq5abma53g75Gqmen9fX/alz1CBtNmenfj7k6yvIxQ3Wiha5AN/r3K4fJtX55hVarvVTy8AB9OMV0GGdwf+AQ4IpU4f75LN27Tzt9HtwbKzynrNF2zXvHsvOWClwGAfZAN18dg1r9UnuthSFF6WeK1doS4HIIsCeqVrHbziLUUpdZornc6S5iDC5p8A3FEWCPVn9KO8RlTpVUeJ8u/xLsUAPR780UUjkE2LOUQ6x11jPN4n/l+WDdaqDznEOdO3YREOAAFOJUn4mrTA3p51KQNU/sM8g8/5bHPHAgeibWAND9O2mdtlF147yCm2/o0IeBXlyuAwDKfjDotBMWcJRHBQ5IlUUVa1Bv0O1squnkVSllvd5kAXQVBDiwfBAo5pyqFbo2od5+cVEQ4Ag0CKRnYrWedVfjlLqBlEfsrSDAEWnwJx8Eqsve+zQCrA+SOq/DoCDAkeWDQE+X63k23txKIzRUXz8IcE00Qv23f/wSta3Odim9q/+Zc6Pz3Ev19YNppJrpRtaXXrGinUMhp5zUvqfg+Uu2HvlCgBORB1nzqYtzDTc77ffoHC3CSGEAS4N5zPv6Q4ATo7lVfV253MoWXegMrKob6xWaFKax9PzNdJpfBDhRqlL7n6qy2mqFWeuY9QaDfttsfRCoXd1NYOS5rnPEBh0BNuB0mGVifOgk1Ncb2VJGbVLIdxnp12qqaHO7HXQHURH6ngZ5RVqdCLBBqqj62jCwiknbBJefEd5QCDCCUWgV3hRa+EFFgBEEbXMcBBjeabR55UWLUzYiIMDwRoHVK1iZKoqHAMMLqm49CDAqyxefID42MwCGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhhFgwDACDBhGgAHDCDBgGAEGDCPAgGEEGDCMAAOGEWDAMAIMGEaAAcMIMGAYAQYMI8CAYQQYMIwAA4YRYMAwAgwYRoABwwgwYBgBBgwjwIBhBBgwjAADhv0XZkN9IbEGbp4AAAAASUVORK5CYII=",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 94d9533b4..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/DiscodeitApplicationTests.java b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java index 3a987a214..b0817ca00 100644 --- a/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java +++ b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java @@ -2,12 +2,15 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class DiscodeitApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + + } } 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..760c31f90 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java @@ -0,0 +1,131 @@ +package com.sprint.mission.discodeit.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.ChannelDto; +import com.sprint.mission.discodeit.dto.UserDto; +import com.sprint.mission.discodeit.dto.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.request.PublicChannelCreateRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.GlobalExceptionHandler; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.fixture.ChannelFixture; +import com.sprint.mission.discodeit.repository.fixture.UserFixture; +import com.sprint.mission.discodeit.service.ChannelService; +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.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.mockito.BDDMockito.given; +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; + +@WebMvcTest(ChannelController.class) +@Import({GlobalExceptionHandler.class}) +public class ChannelControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper om; + + @MockitoBean + private ChannelService channelService; + @MockitoBean + private ChannelMapper channelMapper; + + @DisplayName("공개 채널을 생성합니다. 공개 채널은 name과 description을 가질 수 있지만 선택 사항입니다.") + @Test + void createPublicChannel() throws Exception { + // 본문 생성 + PublicChannelCreateRequest request = new PublicChannelCreateRequest("name", "description"); + String content = om.writeValueAsString(request); + + // given + Channel channel = Channel.of(UUID.randomUUID(), request.name(), request.description(), ChannelType.PUBLIC); + given(channelService.createPublicChannel(request)).willReturn(channel); + ChannelDto channelDto = new ChannelDto(channel.getId(), ChannelType.PUBLIC, "name", "description", null, Instant.now()); + given(channelMapper.toChannelDto(channel)).willReturn(channelDto); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(content)); + + // then + resultActions.andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("name")) + .andExpect(jsonPath("$.description").value("description")) + .andExpect(jsonPath("$.type").value(ChannelType.PUBLIC.name())); + } + + @DisplayName("비공개 채널을 생성합니다. 비공개 채널은 name과 description을 가지지 않습니다.") + @Test + void createPrivateChannel() throws Exception { + // 본문 생성 + User hong = UserFixture.createUserHong(); + hong.setId(UUID.randomUUID()); + User kim = UserFixture.createUserKim(); + kim.setId(UUID.randomUUID()); + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(List.of(hong.getId(), kim.getId())); + String content = om.writeValueAsString(request); + + // given + Channel channel = ChannelFixture.createPrivateChannel(); + given(channelService.createPrivateChannel(request)).willReturn(channel); + UserDto hongDto = new UserDto(hong.getId(), hong.getUsername(), hong.getEmail(), null, true, Role.USER); + UserDto kimDto = new UserDto(kim.getId(), kim.getUsername(), kim.getEmail(), null, true, Role.USER); + List dtos = List.of(hongDto, kimDto); + ChannelDto channelDto = new ChannelDto(channel.getId(), ChannelType.PRIVATE, null, null, dtos, Instant.now()); + given(channelMapper.toChannelDto(channel)).willReturn(channelDto); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(content) + ); + + // then + resultActions.andExpect(status().isCreated()) + .andExpect(jsonPath("$.type").value(ChannelType.PRIVATE.name())) + .andExpect(jsonPath("$.participants").isNotEmpty()); + } + + @DisplayName("비공개 채널을 만들기 위해 유저는 최소 2명 이상이 필요합니다.") + @Test + void createPrivateChannelShouldFailedWhenUserLessThanTwo() throws Exception { + // 본문 생성 + User hong = UserFixture.createUserHong(); + hong.setId(UUID.randomUUID()); + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(List.of(hong.getId())); + String content = om.writeValueAsString(request); + + // when + ResultActions resultActions = mockMvc.perform( + post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(content) + ); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.fieldErrors[*].reason").value("최소 2명 이상이 대화에 참여해야 합니다.")); + } +} 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..6418eb42c --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java @@ -0,0 +1,115 @@ +package com.sprint.mission.discodeit.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.MessageDto; +import com.sprint.mission.discodeit.dto.UserDto; +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.entity.Channel; +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.GlobalExceptionHandler; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.PageResponseMapper; +import com.sprint.mission.discodeit.repository.fixture.ChannelFixture; +import com.sprint.mission.discodeit.repository.fixture.UserFixture; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.service.MessageService; +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.context.annotation.Import; +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; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.UUID; + +import static org.mockito.BDDMockito.given; +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; + +@WebMvcTest(MessageController.class) +@Import({GlobalExceptionHandler.class}) +public class MessageControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper om; + + @MockitoBean + private MessageService messageService; + @MockitoBean + private MessageMapper messageMapper; + @MockitoBean + private BinaryContentMapper binaryContentMapper; + @MockitoBean + private BinaryContentService binaryContentService; + @MockitoBean + private PageResponseMapper pageResponseMapper; + + @DisplayName("메시지를 생성합니다. 작성자와 채널은 필수 항목이며 메시지 내용과 첨부파일은 선택사항입니다." + + "첨부 파일이 있으면 내용이 없어도 되고 내용이 있으면 첨부파일은 없어도 동작합니다." + + "웹에서 메시지와 첨부파일이 없는 메시지는 보내지 않습니다.") + @Test + void createMessageNoAttachments() throws Exception { + // 본문 생성 + User user = UserFixture.createUserHong(); + user.setId(UUID.randomUUID()); + Channel channel = ChannelFixture.createPublicChannel(); + channel.setId(UUID.randomUUID()); + + MessageCreateRequest request = new MessageCreateRequest(user.getId(), channel.getId(), "content"); + String content = om.writeValueAsString(request); + MockMultipartFile messageCreateRequest = new MockMultipartFile("messageCreateRequest", "jsondata", MediaType.APPLICATION_JSON_VALUE, content.getBytes()); + + // given + Message message = Message.of(UUID.randomUUID(), channel, user, "content"); + given(messageService.createMessage(request, new ArrayList<>())).willReturn(message); + UserDto userDto = new UserDto(user.getId(), user.getUsername(), user.getEmail(), null, true, Role.USER); + MessageDto messageDto = new MessageDto(message.getId(), userDto, channel.getId(), "content", null, Instant.now(), Instant.now()); + given(messageMapper.toDto(message)).willReturn(messageDto); + + // when + ResultActions resultActions = mockMvc.perform( + multipart("/api/messages") + .file(messageCreateRequest) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .accept(MediaType.APPLICATION_JSON_VALUE) + ); + + // then + resultActions.andExpect(status().isCreated()) + .andExpect(jsonPath("$.content").value("content")) + .andExpect(jsonPath("$.author.id").value(user.getId().toString())) + .andExpect(jsonPath("$.channelId").value(channel.getId().toString())); + } + + @DisplayName("유저 또는 채널의 정보 없이는 메시지를 생성할 수 없습니다.") + @Test + void createMessageShouldFailedWhenUserOrChannelIsNull() throws Exception { + // 본문 생성 given + MessageCreateRequest request = new MessageCreateRequest(null, null, null); + String content = om.writeValueAsString(request); + MockMultipartFile messageCreateRequest = new MockMultipartFile("messageCreateRequest", null, "application/json", content.getBytes()); + + // when + ResultActions resultActions = mockMvc.perform( + multipart("/api/messages") + .file(messageCreateRequest) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .accept(MediaType.APPLICATION_JSON_VALUE) + ); + + // then + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.fieldErrors[*].reason", "잘못된 ID 입니다.").exists()); + } +} 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..dd5aef1c5 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java @@ -0,0 +1,131 @@ +package com.sprint.mission.discodeit.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.BinaryContentDto; +import com.sprint.mission.discodeit.dto.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +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.exception.GlobalExceptionHandler; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.service.UserService; +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.context.annotation.Import; +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; +import org.springframework.test.web.servlet.ResultActions; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.mockito.BDDMockito.given; +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; + +@WebMvcTest(UserController.class) +@Import({GlobalExceptionHandler.class}) +public class UserControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper om; + + @MockitoBean + private UserService userService; + @MockitoBean + private UserMapper userMapper; + @MockitoBean + private BinaryContentMapper binaryContentMapper; + @MockitoBean + private BinaryContentService binaryContentService; + + @DisplayName("유저를 생성할 때 profile은 선택사항입니다. profile이 존재하지 않아도 정상적으로 유저를 생성합니다.") + @Test + void createUser() throws Exception { + // 본문 생성 + UserCreateRequest request = new UserCreateRequest("username", "password", "test@email.com"); + String content = om.writeValueAsString(request); + MockMultipartFile userCreateRequest = new MockMultipartFile("userCreateRequest", "userCreateRequest", MediaType.APPLICATION_JSON_VALUE, content.getBytes(StandardCharsets.UTF_8)); + + // given + User user = new User(request, null); + given(userService.createUser(request, null)).willReturn(user); + UserDto userDto = new UserDto(UUID.randomUUID(), "username", "test@email.com", null, true, Role.USER); + given(userMapper.toDto(user)).willReturn(userDto); + + // when + ResultActions resultActions = mockMvc.perform( + multipart("/api/users") + .file(userCreateRequest) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .accept(MediaType.APPLICATION_JSON_VALUE) + ); + + // then + resultActions.andExpect(jsonPath("$.username").value("username")) + .andExpect(jsonPath("$.email").value("test@email.com")) + .andExpect(jsonPath("$.online").value(true)) + .andExpect(status().isCreated()); + } + + @DisplayName("profile이 포함된 경우 BinaryContent를 생성하여 유저기 추가 연관관계를 가집니다.") + @Test + void createUserWithProfile() throws Exception { + // 본문 생성 + UserCreateRequest request = new UserCreateRequest("username", "password", "test@email.com"); + MockMultipartFile profile = new MockMultipartFile("profile", "profileImg", MediaType.IMAGE_JPEG_VALUE, "test".getBytes()); + String content = om.writeValueAsString(request); + MockMultipartFile userCreateRequest = new MockMultipartFile("userCreateRequest", "userCreateRequest", MediaType.APPLICATION_JSON_VALUE, content.getBytes(StandardCharsets.UTF_8)); + + // given + User user = new User(request, null); + BinaryContent binaryContent = BinaryContent.of(UUID.randomUUID(), profile); + given(userService.createUser(request, binaryContent.getId())).willReturn(user); + BinaryContentDto binaryContentDto = new BinaryContentDto(binaryContent.getId(), binaryContent.getSize(), binaryContent.getFileName(), binaryContent.getContentType()); + UserDto userDto = new UserDto(UUID.randomUUID(), "username", "test@email.com", binaryContentDto, true, Role.USER); + given(userMapper.toDto(user)).willReturn(userDto); + given(binaryContentService.create(profile)).willReturn(binaryContent); + + // when + ResultActions resultActions = mockMvc.perform( + multipart("/api/users") + .file(profile) + .file(userCreateRequest) + .contentType(MediaType.MULTIPART_FORM_DATA) + .accept(MediaType.APPLICATION_JSON) + ); + + // then + resultActions.andExpect(status().isCreated()) + .andExpect(jsonPath("$.username").value("username")) + .andExpect(jsonPath("$.email").value("test@email.com")) + .andExpect(jsonPath("$.online").value(true)); + } + + @DisplayName("DTO 검증 통과 실패. username은 필수 항목입니다. null이나 공백이 들어갈 수 없습니다.") + @Test + void createUserShouldFailedWhenUsernameIsNull() throws Exception { + // 본문 생성 + UserCreateRequest request = new UserCreateRequest("", "password", "test@email.com"); + String content = om.writeValueAsString(request); + + // when + ResultActions resultActions = mockMvc.perform( + multipart("/api/users") + .param("userCreateRequest", content) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .accept(MediaType.APPLICATION_JSON)); + + // then + resultActions.andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/e2e/ChannelE2ETest.java b/src/test/java/com/sprint/mission/discodeit/e2e/ChannelE2ETest.java new file mode 100644 index 000000000..77d90f5a9 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/e2e/ChannelE2ETest.java @@ -0,0 +1,360 @@ +package com.sprint.mission.discodeit.e2e; + +import com.sprint.mission.discodeit.dto.ChannelDto; +import com.sprint.mission.discodeit.dto.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.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.transaction.AfterTransaction; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Transactional +public class ChannelE2ETest { + @Autowired + private TestRestTemplate rest; + + @Autowired + private ChannelRepository channelRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private ReadStatusRepository readStatusRepository; + + @BeforeEach + void setUp() { + var httpClient = org.apache.hc.client5.http.impl.classic.HttpClients.createDefault(); + var factory = new org.springframework.http.client.HttpComponentsClientHttpRequestFactory(httpClient); + rest.getRestTemplate().setRequestFactory(factory); + } + + @AfterTransaction + void cleanUp() { + readStatusRepository.deleteAll(); + userRepository.deleteAll(); + channelRepository.deleteAll(); + } + + @DisplayName("공개 채널 생성") + @Test + void createPublic() { + // given + PublicChannelCreateRequest channelCreateRequest = new PublicChannelCreateRequest("channelName", "description"); + + // when - POST /api/channel/public + ResponseEntity response = rest.postForEntity("/api/channels/public", channelCreateRequest, ChannelDto.class); + + // then - 201 + 채널 정보 확인 + type Public 확인 + id 추가 확인 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().description()).isEqualTo("description"); + assertThat(response.getBody().name()).isEqualTo("channelName"); + assertThat(response.getBody().type()).isEqualTo(ChannelType.PUBLIC); + UUID channelId = response.getBody().id(); + assertThat(channelId).isNotNull(); + } + + @DisplayName("비공개 채널 생성") + @Test + void createPrivate() { + // given - 비공개 채널에 참가할 유저 생성 정보 + UserCreateRequest hongCreateRequest = new UserCreateRequest("hong", "hong1234", "hong@example.com"); + UserCreateRequest leeCreateRequest = new UserCreateRequest("lee", "lee1234", "lee@example.com"); + MultiValueMap hongRequest = new LinkedMultiValueMap<>(); + hongRequest.add("userCreateRequest", hongCreateRequest); + hongRequest.add("profile", null); + MultiValueMap leeRequest = new LinkedMultiValueMap<>(); + leeRequest.add("userCreateRequest", leeCreateRequest); + leeRequest.add("profile", null); + + // given & when - POST /api/users + UserDto hong = rest.postForObject("/api/users", hongRequest, UserDto.class); + UserDto lee = rest.postForObject("/api/users", leeRequest, UserDto.class); + + // and given - 비공개 채널에 참가할 유저 + List participants = Arrays.asList(hong.id(), lee.id()); + PrivateChannelCreateRequest channelCreateRequest = new PrivateChannelCreateRequest(participants); + + // and when - POST /api/channels/private + ResponseEntity response = rest.postForEntity("/api/channels/private", channelCreateRequest, ChannelDto.class); + + // and then - 201 + 채널 이름과 설명이 없음 확인 + 채널 타입 Private 확인 + id 추가 확인 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().description()).isNullOrEmpty(); + assertThat(response.getBody().name()).isNullOrEmpty(); + assertThat(response.getBody().type()).isEqualTo(ChannelType.PRIVATE); + UUID channelId = response.getBody().id(); + assertThat(channelId).isNotNull(); + } + + @DisplayName("채널 수정") + @Test + void update() { + // given + PublicChannelCreateRequest channelCreateRequest = new PublicChannelCreateRequest("channelName", "description"); + + // when - POST /api/channel/public + ResponseEntity response = rest.postForEntity("/api/channels/public", channelCreateRequest, ChannelDto.class); + + // then - 201 + 채널 정보 확인 + type Public 확인 + id 추가 확인 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().description()).isEqualTo("description"); + assertThat(response.getBody().name()).isEqualTo("channelName"); + assertThat(response.getBody().type()).isEqualTo(ChannelType.PUBLIC); + UUID channelId = response.getBody().id(); + assertThat(channelId).isNotNull(); + + // and given - Update 정보 + PublicChannelUpdateRequest channelUpdateRequest = new PublicChannelUpdateRequest("newChannelName", "newDescription"); + HttpEntity request = new HttpEntity<>(channelUpdateRequest); + + // when - PATCH /api/channels/{channel-id} + ResponseEntity updated = rest.exchange("/api/channels/" + channelId, HttpMethod.PATCH, request, ChannelDto.class); + + // then - 200 응답 + 내용 확인 + 타입 public 확인 + assertThat(updated.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(updated.getBody()).isNotNull(); + assertThat(updated.getBody().description()).isEqualTo("newDescription"); + assertThat(updated.getBody().name()).isEqualTo("newChannelName"); + assertThat(updated.getBody().type()).isEqualTo(ChannelType.PUBLIC); + } + + @DisplayName("채널 삭제") + @Test + void delete() { + // given + PublicChannelCreateRequest channelCreateRequest = new PublicChannelCreateRequest("channelName", "description"); + + // when - POST /api/channel/public + ResponseEntity publicCreated = rest.postForEntity("/api/channels/public", channelCreateRequest, ChannelDto.class); + + // then - 201 + 채널 정보 확인 + type Public 확인 + id 추가 확인 + assertThat(publicCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(publicCreated.getBody()).isNotNull(); + assertThat(publicCreated.getBody().description()).isEqualTo("description"); + assertThat(publicCreated.getBody().name()).isEqualTo("channelName"); + assertThat(publicCreated.getBody().type()).isEqualTo(ChannelType.PUBLIC); + UUID publicChannelId = publicCreated.getBody().id(); + assertThat(publicChannelId).isNotNull(); + + // and given - 비공개 채널에 참가할 유저 생성 정보 + UserCreateRequest hongCreateRequest = new UserCreateRequest("hong", "hong1234", "hong@example.com"); + UserCreateRequest leeCreateRequest = new UserCreateRequest("lee", "lee1234", "lee@example.com"); + MultiValueMap hongRequest = new LinkedMultiValueMap<>(); + hongRequest.add("userCreateRequest", hongCreateRequest); + hongRequest.add("profile", null); + MultiValueMap leeRequest = new LinkedMultiValueMap<>(); + leeRequest.add("userCreateRequest", leeCreateRequest); + leeRequest.add("profile", null); + + // and given & when - POST /api/users + UserDto hong = rest.postForObject("/api/users", hongRequest, UserDto.class); + UserDto lee = rest.postForObject("/api/users", leeRequest, UserDto.class); + + // and given - 비공개 채널에 참가할 유저 + List participants = Arrays.asList(hong.id(), lee.id()); + PrivateChannelCreateRequest privateChannelCreateRequest = new PrivateChannelCreateRequest(participants); + + // and when - POST /api/channels/private + ResponseEntity privateCreated = rest.postForEntity("/api/channels/private", privateChannelCreateRequest, ChannelDto.class); + + // and then - 201 + 채널 이름과 설명이 없음 확인 + 채널 타입 Private 확인 + id 추가 확인 + assertThat(privateCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(privateCreated.getBody()).isNotNull(); + assertThat(privateCreated.getBody().description()).isNullOrEmpty(); + assertThat(privateCreated.getBody().name()).isNullOrEmpty(); + assertThat(privateCreated.getBody().type()).isEqualTo(ChannelType.PRIVATE); + UUID privateChannelId = privateCreated.getBody().id(); + assertThat(privateChannelId).isNotNull(); + + // and when - GET /api/channels?userId={user-id} 쿼리 파라미터로 유저 아이디를 받음 + ResponseEntity findChannels = rest.getForEntity("/api/channels?userId=" + hong.id(), List.class); + + // and then - 200 응답 + 채널 개수 확인 + assertThat(findChannels.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(findChannels.getBody()).isNotNull(); + assertThat(findChannels.getBody().size()).isEqualTo(2); + + // and when & then - DELETE /api/channels/{channel-id} + // 203 응답 + 바디가 비어 있음 확인 + ResponseEntity deleted = rest.exchange("/api/channels/" + publicChannelId, HttpMethod.DELETE, HttpEntity.EMPTY, ChannelDto.class); + assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(deleted.getBody()).isNull(); + + // and when & then - GET /api/channels?userId={user-id} 다시 한번 조회로 변화 확인 + // 200 응답 + 채널의 수가 하나 줄어듦 확인 + findChannels = rest.getForEntity("/api/channels?userId=" + hong.id(), List.class); + assertThat(findChannels.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(findChannels.getBody()).isNotNull(); + assertThat(findChannels.getBody().size()).isEqualTo(1); + } + + @DisplayName("유저ID로 채널 조회") + @Test + void findByUserId() { + // given + PublicChannelCreateRequest channelCreateRequest = new PublicChannelCreateRequest("channelName", "description"); + + // when - POST /api/channel/public + ResponseEntity publicCreated = rest.postForEntity("/api/channels/public", channelCreateRequest, ChannelDto.class); + + // then - 201 + 채널 정보 확인 + type Public 확인 + id 추가 확인 + assertThat(publicCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(publicCreated.getBody()).isNotNull(); + assertThat(publicCreated.getBody().description()).isEqualTo("description"); + assertThat(publicCreated.getBody().name()).isEqualTo("channelName"); + assertThat(publicCreated.getBody().type()).isEqualTo(ChannelType.PUBLIC); + UUID publicChannelId = publicCreated.getBody().id(); + assertThat(publicChannelId).isNotNull(); + + // and given - 비공개 채널에 참가할 유저 생성 정보 + UserCreateRequest hongCreateRequest = new UserCreateRequest("hong", "hong1234", "hong@example.com"); + UserCreateRequest leeCreateRequest = new UserCreateRequest("lee", "lee1234", "lee@example.com"); + MultiValueMap hongRequest = new LinkedMultiValueMap<>(); + hongRequest.add("userCreateRequest", hongCreateRequest); + hongRequest.add("profile", null); + MultiValueMap leeRequest = new LinkedMultiValueMap<>(); + leeRequest.add("userCreateRequest", leeCreateRequest); + leeRequest.add("profile", null); + + // and given & when - POST /api/users + UserDto hong = rest.postForObject("/api/users", hongRequest, UserDto.class); + UserDto lee = rest.postForObject("/api/users", leeRequest, UserDto.class); + + // and given - 비공개 채널에 참가할 유저 + List participants = Arrays.asList(hong.id(), lee.id()); + PrivateChannelCreateRequest privateChannelCreateRequest = new PrivateChannelCreateRequest(participants); + + // and when - POST /api/channels/private + ResponseEntity privateCreated = rest.postForEntity("/api/channels/private", privateChannelCreateRequest, ChannelDto.class); + + // and then - 201 + 채널 이름과 설명이 없음 확인 + 채널 타입 Private 확인 + id 추가 확인 + assertThat(privateCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(privateCreated.getBody()).isNotNull(); + assertThat(privateCreated.getBody().description()).isNullOrEmpty(); + assertThat(privateCreated.getBody().name()).isNullOrEmpty(); + assertThat(privateCreated.getBody().type()).isEqualTo(ChannelType.PRIVATE); + UUID privateChannelId = privateCreated.getBody().id(); + assertThat(privateChannelId).isNotNull(); + + // and when - GET /api/channels?userId={user-id} 쿼리파라미터로 받음 + ResponseEntity findChannels = rest.getForEntity("/api/channels?userId=" + hong.id(), List.class); + + // and then - 200 응답 + 채널의 수 확인 + assertThat(findChannels.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(findChannels.getBody()).isNotNull(); + assertThat(findChannels.getBody().size()).isEqualTo(2); + } + + @DisplayName("채널 수정 실패 / 비공개 채널은 수정할 수 없음") + @Test + void updateShouldFailedWhenPrivateChannelUpdate() { + // given - 비공개 채널에 참가할 유저 생성 정보 + UserCreateRequest hongCreateRequest = new UserCreateRequest("hong", "hong1234", "hong@example.com"); + UserCreateRequest leeCreateRequest = new UserCreateRequest("lee", "lee1234", "lee@example.com"); + MultiValueMap hongRequest = new LinkedMultiValueMap<>(); + hongRequest.add("userCreateRequest", hongCreateRequest); + MultiValueMap leeRequest = new LinkedMultiValueMap<>(); + leeRequest.add("userCreateRequest", leeCreateRequest); + + // given & when - POST /api/users + UserDto hong = rest.postForObject("/api/users", hongRequest, UserDto.class); + UserDto lee = rest.postForObject("/api/users", leeRequest, UserDto.class); + + // and given - 비공개 채널에 참가할 유저 + List participants = Arrays.asList(hong.id(), lee.id()); + PrivateChannelCreateRequest channelCreateRequest = new PrivateChannelCreateRequest(participants); + + // and when - POST /api/channels/private + ResponseEntity response = rest.postForEntity("/api/channels/private", channelCreateRequest, ChannelDto.class); + + // and then - 201 + 채널 이름과 설명이 없음 확인 + 채널 타입 Private 확인 + id 추가 확인 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().description()).isNullOrEmpty(); + assertThat(response.getBody().name()).isNullOrEmpty(); + assertThat(response.getBody().type()).isEqualTo(ChannelType.PRIVATE); + UUID channelId = response.getBody().id(); + assertThat(channelId).isNotNull(); + + // and given - Update 정보 + PublicChannelUpdateRequest channelUpdateRequest = new PublicChannelUpdateRequest("newChannelName", "newDescription"); + HttpEntity request = new HttpEntity<>(channelUpdateRequest); + + // and when - PATCH /api/channels/{channel-id} + ResponseEntity errorResponse = rest.exchange("/api/channels/" + channelId, HttpMethod.PATCH, request, ErrorResponse.class); + + // and then - 400 응답 + Exception 종류 확인 + assertThat(errorResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getBody()).isNotNull(); + assertThat(errorResponse.getBody()).isInstanceOf(ErrorResponse.class); + assertThat(errorResponse.getBody().getCode()).isEqualTo(ErrorCode.PRIVATE_CHANNEL_CANNOT_UPDATE.name()); + } + + @DisplayName("PathVariable로 받은 Id에 해당하는 채널이 존재하지 않는 경우 실패한다.") + @Test + void shouldFailedWhenChannelNotFound() { + // update의 경우 + // given - Update 정보 + PublicChannelUpdateRequest channelUpdateRequest = new PublicChannelUpdateRequest("newChannelName", "newDescription"); + HttpEntity request = new HttpEntity<>(channelUpdateRequest); + + // when - PATCH /api/channels/{channel-id} + ResponseEntity failUpdated = rest.exchange("/api/channels/" + UUID.randomUUID(), HttpMethod.PATCH, request, ErrorResponse.class); + + // then - 404 응답 + Exception 종류 확인 + assertThat(failUpdated.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(failUpdated.getBody()).isNotNull(); + assertThat(failUpdated.getBody().getCode()).isEqualTo(ErrorCode.CHANNEL_NOT_FOUND.name()); + + // delete의 경우 + // and when - DELETE /api/channels/{channel-id} + ResponseEntity failDeleted = rest.exchange("/api/channels/" + UUID.randomUUID(), HttpMethod.DELETE, request, ErrorResponse.class); + + // and then - 404 응답 + Exception 종류 확인 + assertThat(failDeleted.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(failDeleted.getBody()).isNotNull(); + assertThat(failDeleted.getBody().getCode()).isEqualTo(ErrorCode.CHANNEL_NOT_FOUND.name()); + } + + @DisplayName("UserId로 채널 조회 시 UserId에 해당하는 유저가 존재하지 않으면 실패한다.") + @Test + void shouldFailedWhenUserNotFound() { + // when - GET /api/channels?userId={user-id} 쿼리파라미터로 받음 + ResponseEntity failed = rest.getForEntity("/api/channels?userId=" + UUID.randomUUID(), ErrorResponse.class); + + // then - 404 응답 + Exception 종류 확인 + assertThat(failed.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(failed.getBody()).isNotNull(); + assertThat(failed.getBody().getCode()).isEqualTo(ErrorCode.USER_NOT_FOUND.name()); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/e2e/MessageE2ETest.java b/src/test/java/com/sprint/mission/discodeit/e2e/MessageE2ETest.java new file mode 100644 index 000000000..ccd7914f5 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/e2e/MessageE2ETest.java @@ -0,0 +1,401 @@ +package com.sprint.mission.discodeit.e2e; + +import com.sprint.mission.discodeit.dto.ChannelDto; +import com.sprint.mission.discodeit.dto.MessageDto; +import com.sprint.mission.discodeit.dto.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.dto.response.PageResponse; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.ErrorResponse; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.transaction.AfterTransaction; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Transactional +public class MessageE2ETest { + @Autowired + private TestRestTemplate rest; + + @Autowired + private MessageRepository messageRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private ChannelRepository channelRepository; + @Autowired + private ReadStatusRepository readStatusRepository; + + @BeforeEach + void setUp() { + var httpClient = org.apache.hc.client5.http.impl.classic.HttpClients.createDefault(); + var factory = new org.springframework.http.client.HttpComponentsClientHttpRequestFactory(httpClient); + rest.getRestTemplate().setRequestFactory(factory); + } + + @AfterTransaction + void cleanUp() { + readStatusRepository.deleteAll(); + messageRepository.deleteAll(); + userRepository.deleteAll(); + channelRepository.deleteAll(); + } + + @DisplayName("메시지 생성") + @Test + void create() { + // given 유저 + UserCreateRequest userCreateRequest = new UserCreateRequest("username", "password", "test@email.com"); + MultiValueMap UserRequest = new LinkedMultiValueMap<>(); + UserRequest.add("userCreateRequest", userCreateRequest); + UserRequest.add("profile", null); + + // when POST /api/users + ResponseEntity UserCreated = rest.postForEntity("/api/users", UserRequest, UserDto.class); + + // then - 201 응답 + id 추가 확인 + assertThat(UserCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(UserCreated.getBody()).isNotNull(); + UUID userId = UserCreated.getBody().id(); + assertThat(userId).isNotNull(); + + // given 채널 + PublicChannelCreateRequest channelCreateRequest = new PublicChannelCreateRequest("channelName", "description"); + + // when - POST /api/channel/public + ResponseEntity channelCreated = rest.postForEntity("/api/channels/public", channelCreateRequest, ChannelDto.class); + + // then - 201 + 채널 정보 확인 + type Public 확인 + id 추가 확인 + assertThat(channelCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(channelCreated.getBody()).isNotNull(); + assertThat(channelCreated.getBody().description()).isEqualTo("description"); + assertThat(channelCreated.getBody().name()).isEqualTo("channelName"); + assertThat(channelCreated.getBody().type()).isEqualTo(ChannelType.PUBLIC); + UUID channelId = channelCreated.getBody().id(); + assertThat(channelId).isNotNull(); + + // given + MessageCreateRequest messageCreateRequest = new MessageCreateRequest(userId, channelId, "content"); + List attachments = null; + MultiValueMap request = new LinkedMultiValueMap<>(); + request.add("messageCreateRequest", messageCreateRequest); + request.add("attachments", attachments); + + // when - POST /api/messages + ResponseEntity created = rest.postForEntity("/api/messages", request, MessageDto.class); + + // then - 201 응답 + id 추가 확인 + 내용 확인 + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(created.getBody()).isNotNull(); + assertThat(created.getBody().id()).isNotNull(); + assertThat(created.getBody().channelId()).isEqualTo(channelId); + assertThat(created.getBody().author().id()).isEqualTo(userId); + assertThat(created.getBody().content()).isEqualTo("content"); + } + + @DisplayName("메시지 수정") + @Test + void update() { + // given 유저 + UserCreateRequest userCreateRequest = new UserCreateRequest("username", "password", "test@email.com"); + MultiValueMap UserRequest = new LinkedMultiValueMap<>(); + UserRequest.add("userCreateRequest", userCreateRequest); + UserRequest.add("profile", null); + + // when - POST /api/users + ResponseEntity UserCreated = rest.postForEntity("/api/users", UserRequest, UserDto.class); + + // then - 201 + id 추가 확인 + assertThat(UserCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(UserCreated.getBody()).isNotNull(); + UUID userId = UserCreated.getBody().id(); + assertThat(userId).isNotNull(); + + // and given 채널 + PublicChannelCreateRequest channelCreateRequest = new PublicChannelCreateRequest("channelName", "description"); + + // and when - POST /api/channel/public + ResponseEntity channelCreated = rest.postForEntity("/api/channels/public", channelCreateRequest, ChannelDto.class); + + // and then - 201 + 채널 정보 확인 + type Public 확인 + id 추가 확인 + assertThat(channelCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(channelCreated.getBody()).isNotNull(); + assertThat(channelCreated.getBody().description()).isEqualTo("description"); + assertThat(channelCreated.getBody().name()).isEqualTo("channelName"); + assertThat(channelCreated.getBody().type()).isEqualTo(ChannelType.PUBLIC); + UUID channelId = channelCreated.getBody().id(); + assertThat(channelId).isNotNull(); + + // and given + MessageCreateRequest messageCreateRequest = new MessageCreateRequest(userId, channelId, "content"); + List attachments = null; + MultiValueMap createRequest = new LinkedMultiValueMap<>(); + createRequest.add("messageCreateRequest", messageCreateRequest); + createRequest.add("attachments", attachments); + + // and when - POST /api/messages + ResponseEntity created = rest.postForEntity("/api/messages", createRequest, MessageDto.class); + + // and then - 201 응답 + id 추가 확인 + 내용 확인 + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(created.getBody()).isNotNull(); + UUID messageId = created.getBody().id(); + assertThat(messageId).isNotNull(); + assertThat(created.getBody().channelId()).isEqualTo(channelId); + assertThat(created.getBody().author().id()).isEqualTo(userId); + assertThat(created.getBody().content()).isEqualTo("content"); + + // and given 수정 정보 + MessageUpdateRequest messageUpdateRequest = new MessageUpdateRequest("newContent"); + HttpEntity updateRequest = new HttpEntity<>(messageUpdateRequest); + + // and when - PATCH /api/messages/{message-id} + ResponseEntity updated = rest.exchange("/api/messages/" + messageId, HttpMethod.PATCH, updateRequest, MessageDto.class); + + // and then - 200 응답 + id 확인 + 내용 확인 + assertThat(updated.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(updated.getBody()).isNotNull(); + assertThat(updated.getBody().id()).isEqualTo(messageId); + assertThat(updated.getBody().channelId()).isEqualTo(channelId); + assertThat(updated.getBody().author().id()).isEqualTo(userId); + assertThat(updated.getBody().content()).isEqualTo("newContent"); + } + + @DisplayName("메시지 삭제") + @Test + void delete() { + // given 유저 + UserCreateRequest userCreateRequest = new UserCreateRequest("username", "password", "test@email.com"); + MultiValueMap UserRequest = new LinkedMultiValueMap<>(); + UserRequest.add("userCreateRequest", userCreateRequest); + UserRequest.add("profile", null); + + // when - POST /api/users + ResponseEntity UserCreated = rest.postForEntity("/api/users", UserRequest, UserDto.class); + + // then - 201 + id 추가 확인 + assertThat(UserCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(UserCreated.getBody()).isNotNull(); + UUID userId = UserCreated.getBody().id(); + assertThat(userId).isNotNull(); + + // and given 채널 + PublicChannelCreateRequest channelCreateRequest = new PublicChannelCreateRequest("channelName", "description"); + + // and when - POST /api/channel/public + ResponseEntity channelCreated = rest.postForEntity("/api/channels/public", channelCreateRequest, ChannelDto.class); + + // and then - 201 + 채널 정보 확인 + type Public 확인 + id 추가 확인 + assertThat(channelCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(channelCreated.getBody()).isNotNull(); + assertThat(channelCreated.getBody().description()).isEqualTo("description"); + assertThat(channelCreated.getBody().name()).isEqualTo("channelName"); + assertThat(channelCreated.getBody().type()).isEqualTo(ChannelType.PUBLIC); + UUID channelId = channelCreated.getBody().id(); + assertThat(channelId).isNotNull(); + + // and given + MessageCreateRequest messageCreateRequest = new MessageCreateRequest(userId, channelId, "content"); + List attachments = null; + MultiValueMap createRequest = new LinkedMultiValueMap<>(); + createRequest.add("messageCreateRequest", messageCreateRequest); + createRequest.add("attachments", attachments); + + // when - POST /api/messages + ResponseEntity created = rest.postForEntity("/api/messages", createRequest, MessageDto.class); + + // then - 201 응답 + id 추가 확인 + 내용 확인 + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(created.getBody()).isNotNull(); + UUID messageId = created.getBody().id(); + assertThat(messageId).isNotNull(); + assertThat(created.getBody().channelId()).isEqualTo(channelId); + assertThat(created.getBody().author().id()).isEqualTo(userId); + assertThat(created.getBody().content()).isEqualTo("content"); + + // and when & then - 203 응답 확인 + 바디 없음 확인 + ResponseEntity deleted = rest.exchange("/api/messages/" + messageId, HttpMethod.DELETE, HttpEntity.EMPTY, MessageDto.class); + assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(deleted.getBody()).isNull(); + } + + @DisplayName("채널에 해당하는 메시지 목록 조회") + @Test + void findAllMessage() { + // given 유저 + UserCreateRequest userCreateRequest = new UserCreateRequest("username", "password", "test@email.com"); + MultiValueMap UserRequest = new LinkedMultiValueMap<>(); + UserRequest.add("userCreateRequest", userCreateRequest); + UserRequest.add("profile", null); + + // when - POST /api/users + ResponseEntity UserCreated = rest.postForEntity("/api/users", UserRequest, UserDto.class); + + // then - 201 + id 추가 확인 + assertThat(UserCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(UserCreated.getBody()).isNotNull(); + UUID userId = UserCreated.getBody().id(); + assertThat(userId).isNotNull(); + + // and given 채널 + PublicChannelCreateRequest channelCreateRequest = new PublicChannelCreateRequest("channelName", "description"); + + // and when - POST /api/channel/public + ResponseEntity channelCreated = rest.postForEntity("/api/channels/public", channelCreateRequest, ChannelDto.class); + + // and then - 201 + 채널 정보 확인 + type Public 확인 + id 추가 확인 + assertThat(channelCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(channelCreated.getBody()).isNotNull(); + assertThat(channelCreated.getBody().description()).isEqualTo("description"); + assertThat(channelCreated.getBody().name()).isEqualTo("channelName"); + assertThat(channelCreated.getBody().type()).isEqualTo(ChannelType.PUBLIC); + UUID channelId = channelCreated.getBody().id(); + assertThat(channelId).isNotNull(); + + // and given + MessageCreateRequest messageCreateRequest = new MessageCreateRequest(userId, channelId, "content"); + List attachments = null; + MultiValueMap createRequest = new LinkedMultiValueMap<>(); + createRequest.add("messageCreateRequest", messageCreateRequest); + createRequest.add("attachments", attachments); + + // when - POST /api/messages + ResponseEntity created = rest.postForEntity("/api/messages", createRequest, MessageDto.class); + + // then - 201 응답 + id 추가 확인 + 내용 확인 + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(created.getBody()).isNotNull(); + UUID messageId = created.getBody().id(); + assertThat(messageId).isNotNull(); + assertThat(created.getBody().channelId()).isEqualTo(channelId); + assertThat(created.getBody().author().id()).isEqualTo(userId); + assertThat(created.getBody().content()).isEqualTo("content"); + + // when - GET /api/messages?channelId={channel-id} 채널 아이디로 해당 채널에 모든 메시지를 페이지네이션하여 PageResponse로 반환 + ResponseEntity response = rest.getForEntity("/api/messages?channelId=" + channelId, PageResponse.class); + + // then - 200 응답 + 전체 메시지 개수 확인 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().totalElements()).isEqualTo(1); + } + + @DisplayName("메시지는 알 수 없는 유저나 알 수 없는 채널에서 발생한 경우 실패한다.") + @Test + void createShouldFailedWhenUnknownUserOrUnknownChannel() { + // given 유저 + UserCreateRequest userCreateRequest = new UserCreateRequest("username", "password", "test@email.com"); + MultiValueMap UserRequest = new LinkedMultiValueMap<>(); + UserRequest.add("userCreateRequest", userCreateRequest); + UserRequest.add("profile", null); + + // when - POST /api/users + ResponseEntity UserCreated = rest.postForEntity("/api/users", UserRequest, UserDto.class); + + // then - 201 + id 추가 확인 + assertThat(UserCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(UserCreated.getBody()).isNotNull(); + UUID userId = UserCreated.getBody().id(); + assertThat(userId).isNotNull(); + + // and given 채널 + PublicChannelCreateRequest channelCreateRequest = new PublicChannelCreateRequest("channelName", "description"); + + // and when - POST /api/channel/public + ResponseEntity channelCreated = rest.postForEntity("/api/channels/public", channelCreateRequest, ChannelDto.class); + + // and then - 201 + 채널 정보 확인 + type Public 확인 + id 추가 확인 + assertThat(channelCreated.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(channelCreated.getBody()).isNotNull(); + assertThat(channelCreated.getBody().description()).isEqualTo("description"); + assertThat(channelCreated.getBody().name()).isEqualTo("channelName"); + assertThat(channelCreated.getBody().type()).isEqualTo(ChannelType.PUBLIC); + UUID channelId = channelCreated.getBody().id(); + assertThat(channelId).isNotNull(); + + // and given 알 수 없는 유저 & 알 수 없는 채널 + MessageCreateRequest unknownUserMessageCreateRequest = new MessageCreateRequest(UUID.randomUUID(), channelId, "content"); + MultiValueMap badRequestUnknownUser = new LinkedMultiValueMap<>(); + badRequestUnknownUser.add("messageCreateRequest", unknownUserMessageCreateRequest); + + MessageCreateRequest unknownChannelMessageCreateRequest = new MessageCreateRequest(userId, UUID.randomUUID(), "content"); + MultiValueMap badRequestUnknownChannel = new LinkedMultiValueMap<>(); + badRequestUnknownChannel.add("messageCreateRequest", unknownChannelMessageCreateRequest); + + // when - POST /api/messages + ResponseEntity failCreatedUnknownUser = rest.postForEntity("/api/messages", badRequestUnknownUser, ErrorResponse.class); + ResponseEntity failCreatedUnknownChannel = rest.postForEntity("/api/messages", badRequestUnknownChannel, ErrorResponse.class); + + // then - 404 응답 + Exception 종류 확인 + assertThat(failCreatedUnknownUser.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(failCreatedUnknownUser.getBody()).isNotNull(); + assertThat(failCreatedUnknownUser.getBody().getCode()).isEqualTo(ErrorCode.USER_NOT_FOUND.name()); + + // and then - 404 응답 + Exception 종류 확인 + assertThat(failCreatedUnknownChannel.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(failCreatedUnknownChannel.getBody()).isNotNull(); + assertThat(failCreatedUnknownChannel.getBody().getCode()).isEqualTo(ErrorCode.CHANNEL_NOT_FOUND.name()); + + // 알 수 없는 채널로 메시지 조회 + // and when - GET /api/messages?channelId={channel-id} + ResponseEntity failFindAllMessages = rest.getForEntity("/api/messages?channelId=" + UUID.randomUUID(), ErrorResponse.class); + + // and then - 404 응답 + Exception 종류 확인 + assertThat(failFindAllMessages.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(failFindAllMessages.getBody()).isNotNull(); + assertThat(failFindAllMessages.getBody().getCode()).isEqualTo(ErrorCode.CHANNEL_NOT_FOUND.name()); + } + + @DisplayName("PathVariable로 받은 Id에 해당하는 메시지가 존재하지 않으면 실패한다.") + @Test + void shouldFailedWhenMessageNotFound() { + // update의 경우 + // and given 수정 정보 + MessageUpdateRequest messageUpdateRequest = new MessageUpdateRequest("newContent"); + HttpEntity updateRequest = new HttpEntity<>(messageUpdateRequest); + + // when - PATCH /api/messages/{message-id} + ResponseEntity failUpdated = rest.exchange("/api/messages/" + UUID.randomUUID(), HttpMethod.PATCH, updateRequest, ErrorResponse.class); + + // then - 404 응답 + Exception 종류 확인 + assertThat(failUpdated.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(failUpdated.getBody()).isNotNull(); + assertThat(failUpdated.getBody().getCode()).isEqualTo(ErrorCode.MESSAGE_NOT_FOUND.name()); + + // and when - DELETE /api/messages/{message-id} + ResponseEntity failDeleted = rest.exchange("/api/messages/" + UUID.randomUUID(), HttpMethod.DELETE, updateRequest, ErrorResponse.class); + + // and then - 404 응답 + Exception 종류 확인 + assertThat(failDeleted.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(failDeleted.getBody()).isNotNull(); + assertThat(failDeleted.getBody().getCode()).isEqualTo(ErrorCode.MESSAGE_NOT_FOUND.name()); + } + +} diff --git a/src/test/java/com/sprint/mission/discodeit/e2e/UserE2ETest.java b/src/test/java/com/sprint/mission/discodeit/e2e/UserE2ETest.java new file mode 100644 index 000000000..46384f169 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/e2e/UserE2ETest.java @@ -0,0 +1,236 @@ +package com.sprint.mission.discodeit.e2e; + +import com.sprint.mission.discodeit.dto.UserDto; +import com.sprint.mission.discodeit.dto.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.request.UserUpdateRequest; +import com.sprint.mission.discodeit.exception.ErrorCode; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import com.sprint.mission.discodeit.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.transaction.AfterTransaction; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Transactional +public class UserE2ETest { + + @Autowired + private TestRestTemplate rest; + + @Autowired + private UserRepository userRepository; + + @BeforeEach + void setUp() { + var httpClient = org.apache.hc.client5.http.impl.classic.HttpClients.createDefault(); + var factory = new org.springframework.http.client.HttpComponentsClientHttpRequestFactory(httpClient); + rest.getRestTemplate().setRequestFactory(factory); + } + + @AfterTransaction + void cleanUp() { + userRepository.deleteAll(); + } + + @DisplayName("유저 생성") + @Test + void createUser() { + // given + UserCreateRequest userCreateRequest = new UserCreateRequest("username", "password", "test@email.com"); + MultiValueMap request = new LinkedMultiValueMap<>(); + request.add("userCreateRequest", userCreateRequest); + + // when - POST /api/users + ResponseEntity created = rest.postForEntity("/api/users", request, UserDto.class); + + // then - 201 + id 추가 확인 + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(created.getBody()).isNotNull(); + UUID id = created.getBody().id(); + assertThat(id).isNotNull(); + } + + @DisplayName("유저 수정") + @Test + void updateUser() { + // 유저 생성 & given 생성 정보 + UserCreateRequest userCreateRequest = new UserCreateRequest("username", "password", "test@email.com"); + MultiValueMap CreateRequest = new LinkedMultiValueMap<>(); + CreateRequest.add("userCreateRequest", userCreateRequest); + CreateRequest.add("profile", null); + + // when - POST /api/users + ResponseEntity created = rest.postForEntity("/api/users", CreateRequest, UserDto.class); + + // then - 201 + 생성 정보 확인 + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(created.getBody()).isNotNull(); + UUID userId = created.getBody().id(); + assertThat(userId).isNotNull(); + assertThat(created.getBody().username()).isEqualTo(userCreateRequest.username()); + assertThat(created.getBody().email()).isEqualTo(userCreateRequest.email()); + + // given - 수정할 정보 + UserUpdateRequest userUpdateRequest = new UserUpdateRequest("newUsername", "new@email.com", "newPassword"); + MultiValueMap updateRequest = new LinkedMultiValueMap<>(); + updateRequest.add("userUpdateRequest", userUpdateRequest); + updateRequest.add("profile", null); + HttpEntity> request = new HttpEntity<>(updateRequest); + + // when PATCH /api/users/{user-id} + ResponseEntity updated = rest.exchange("/api/users/" + userId, HttpMethod.PATCH, request, UserDto.class); + + // then 수정된 유저 정보 - 200 + id 및 수정 정보 확인 + assertThat(updated.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(updated.getBody()).isNotNull(); + assertThat(updated.getBody().id()).isEqualTo(userId); + assertThat(updated.getBody().username()).isEqualTo(userUpdateRequest.newUsername()); + assertThat(updated.getBody().email()).isEqualTo(userUpdateRequest.newEmail()); + } + + @DisplayName("유저 삭제") + @Test + void deleteUser() { + // 유저 생성 & given 생성 정보 + UserCreateRequest userCreateRequest = new UserCreateRequest("username", "password", "test@email.com"); + MultiValueMap CreateRequest = new LinkedMultiValueMap<>(); + CreateRequest.add("userCreateRequest", userCreateRequest); + CreateRequest.add("profile", null); + + // when - POST /api/users + ResponseEntity created = rest.postForEntity("/api/users", CreateRequest, UserDto.class); + + // then - 201 + 생성 정보 확인 + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(created.getBody()).isNotNull(); + UUID userId = created.getBody().id(); + assertThat(userId).isNotNull(); + assertThat(created.getBody().username()).isEqualTo(userCreateRequest.username()); + assertThat(created.getBody().email()).isEqualTo(userCreateRequest.email()); + + // and when - GET /api/users , response = ResponseEntity> + ResponseEntity allUsers = rest.getForEntity("/api/users", List.class); + + // and then - 200 + 유저 수 확인 + assertThat(allUsers.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(allUsers.getBody()).isNotNull(); + assertThat(allUsers.getBody().size()).isEqualTo(1); + + // and when - DELETE /api/users/{user-id}, response = ResponseEntity , body 없음 + ResponseEntity response = rest.exchange("/api/users/" + userId, HttpMethod.DELETE, HttpEntity.EMPTY, ResponseEntity.class); + allUsers = rest.getForEntity("/api/users", List.class); // 유저 조회 + + // and then - 203 + body 없음 및 유저 수 확인 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(allUsers.getBody()).isEmpty(); + assertThat(allUsers.getBody().size()).isEqualTo(0); + } + + @DisplayName("유저 조회") + @Test + void findAllUser() { + // 유저 생성 & given 생성 정보 + UserCreateRequest userCreateRequest = new UserCreateRequest("username", "password", "test@email.com"); + MultiValueMap CreateRequest = new LinkedMultiValueMap<>(); + CreateRequest.add("userCreateRequest", userCreateRequest); + CreateRequest.add("profile", null); + + // when - POST /api/users + ResponseEntity created = rest.postForEntity("/api/users", CreateRequest, UserDto.class); + + // then - 201 + 생성 정보 확인 + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(created.getBody()).isNotNull(); + UUID userId = created.getBody().id(); + assertThat(userId).isNotNull(); + assertThat(created.getBody().username()).isEqualTo(userCreateRequest.username()); + assertThat(created.getBody().email()).isEqualTo(userCreateRequest.email()); + + // and when - 유저 조회 GET /api/users , response = ResponseEntity> + ResponseEntity allUsers = rest.getForEntity("/api/users", List.class); + + // and then - 200 + 유저 수 확인 + assertThat(allUsers.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(allUsers.getBody()).isNotNull(); + assertThat(allUsers.getBody().size()).isEqualTo(1); + } + + @DisplayName("동일한 유저네임이나 동일한 이메일로 유저 생성을 요청하면 생성에 실패한다.") + @Test + void createShouldFailedWhenUsernameOrEmailAlreadyExists() { + // given + UserCreateRequest userCreateRequest = new UserCreateRequest("username", "password", "test@email.com"); + MultiValueMap request = new LinkedMultiValueMap<>(); + request.add("userCreateRequest", userCreateRequest); + + UserCreateRequest userCreateRequestDuplicateEmail = new UserCreateRequest("newname", "password", "test@email.com"); + MultiValueMap requestDuplicateEmail = new LinkedMultiValueMap<>(); + requestDuplicateEmail.add("userCreateRequest", userCreateRequestDuplicateEmail); + + UserCreateRequest userCreateRequestDuplicateUsername = new UserCreateRequest("username", "password", "new@email.com"); + MultiValueMap requestDuplicateUsername = new LinkedMultiValueMap<>(); + requestDuplicateUsername.add("userCreateRequest", userCreateRequestDuplicateUsername); + + // when - POST /api/users + ResponseEntity created = rest.postForEntity("/api/users", request, UserDto.class); + ResponseEntity duplicateEmailFailed = rest.postForEntity("/api/users", requestDuplicateEmail, ErrorResponse.class); + ResponseEntity duplicateUsernameFailed = rest.postForEntity("/api/users", requestDuplicateUsername, ErrorResponse.class); + + // then - 400 응답 + Exception 종류 확인 + assertThat(duplicateEmailFailed.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(duplicateEmailFailed.getBody()).isNotNull(); + assertThat(duplicateEmailFailed.getBody().getCode()).isEqualTo(ErrorCode.EMAIL_OR_USERNAME_ALREADY_EXISTS.name()); + + // and then - 400 응답 + Exception 종류 확인 + assertThat(duplicateUsernameFailed.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(duplicateUsernameFailed.getBody()).isNotNull(); + assertThat(duplicateUsernameFailed.getBody().getCode()).isEqualTo(ErrorCode.EMAIL_OR_USERNAME_ALREADY_EXISTS.name()); + } + + @DisplayName("PathVariable로 받는 Id에 해당하는 유저가 존재하지 않으면 실패한다.") + @Test + void shouldFailedWhenUserNotFound() { + // update의 경우 + // given - 수정할 정보 + UserUpdateRequest userUpdateRequest = new UserUpdateRequest("newUsername", "new@email.com", "newPassword"); + MultiValueMap updateRequest = new LinkedMultiValueMap<>(); + updateRequest.add("userUpdateRequest", userUpdateRequest); + updateRequest.add("profile", null); + HttpEntity> request = new HttpEntity<>(updateRequest); + + // when - PATCH /api/users/{user-id} + ResponseEntity updated = rest.exchange("/api/users/" + UUID.randomUUID(), HttpMethod.PATCH, request, ErrorResponse.class); + + // then - 404 응답 + Exception 종류 확인 + assertThat(updated.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(updated.getBody()).isNotNull(); + assertThat(updated.getBody().getCode()).isEqualTo(ErrorCode.USER_NOT_FOUND.name()); + + // delete의 경우 + // and when DELETE /api/users/{user-id} + ResponseEntity deleted = rest.exchange("/api/users/" + UUID.randomUUID(), HttpMethod.DELETE, request, ErrorResponse.class); + + // and then - 404 응답 + Exception 종류 확인 + assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(deleted.getBody()).isNotNull(); + assertThat(deleted.getBody().getCode()).isEqualTo(ErrorCode.USER_NOT_FOUND.name()); + } +} 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..3f00c12fc --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java @@ -0,0 +1,60 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.repository.fixture.ChannelFixture; +import org.junit.jupiter.api.BeforeEach; +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.dao.DataIntegrityViolationException; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +public class ChannelRepositoryTest { + @Autowired + private ChannelRepository channelRepository; + + @BeforeEach + void setup() { + Channel publicChannel = ChannelFixture.createPublicChannel(); + channelRepository.save(publicChannel); + Channel privateChannel = ChannelFixture.createPrivateChannel(); + channelRepository.save(privateChannel); + } + + @DisplayName("공개 채널만 전체 조회합니다.") + @Test + void findAllByTypeShouldReturnAllPublicChannel() { + // given + Channel channel = new Channel("public2", "description"); + channelRepository.save(channel); + + // when + List publicChannels = channelRepository.findAllByType(ChannelType.PUBLIC); + + // then + assertThat(publicChannels).hasSize(2); + assertThat(publicChannels).contains(channel); + assertThat(publicChannels.get(0).getType()).isEqualTo(ChannelType.PUBLIC); + } + + @DisplayName("타입이 정해지지 않은 채널은 DB에 저장될 수 없습니다.") + @Test + void saveChannelShouldFailedWhenUnknownType() { + // given + Channel channel = new Channel("test", "description"); + channel.setType(null); + + // when & then + assertThatThrownBy(() -> { + channelRepository.save(channel); + channelRepository.flush(); + }).isInstanceOf(DataIntegrityViolationException.class); + } +} 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..2eae7ef61 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java @@ -0,0 +1,65 @@ +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 com.sprint.mission.discodeit.repository.fixture.ChannelFixture; +import com.sprint.mission.discodeit.repository.fixture.UserFixture; +import org.junit.jupiter.api.BeforeEach; +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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +public class MessageRepositoryTest { + @Autowired + private MessageRepository messageRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private ChannelRepository channelRepository; + + private Channel channel; + + @BeforeEach + void setUp() { + messageRepository.deleteAll(); + User user = UserFixture.createUserHong(); + channel = ChannelFixture.createPublicChannel(); + + userRepository.save(user); + channelRepository.save(channel); + + Message message = new Message("content", channel, user); + messageRepository.save(message); + Message message2 = new Message("content2", channel, user); + messageRepository.save(message2); + } + + @DisplayName("채널 ID를 통해 해당 채널에 존재하는 모든 메시지를 페이지네이션하여 가져옵니다.") + @Test + void findAllByChannelIdShouldReturnAllMessagesPage() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page messages = messageRepository.findAllByChannel_Id(channel.getId(), pageable); + + // then + assertThat(messages.getTotalElements()).isEqualTo(2); + } + + @DisplayName("페이지네이션을 위한 Pageable의 size에는 음수가 들어갈 수 없습니다.") + @Test + void findAllByChannelIdShouldFailedPageSizeLessThanZero() { + // given & when & then + assertThatThrownBy(() -> PageRequest.of(0, -1)).isInstanceOf(IllegalArgumentException.class); + } +} 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..cf3500408 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java @@ -0,0 +1,56 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.repository.fixture.UserFixture; +import org.junit.jupiter.api.BeforeEach; +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.dao.DataIntegrityViolationException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +public class UserRepositoryTest { + @Autowired + private UserRepository userRepository; + + @BeforeEach + void setUp() { + userRepository.deleteAll(); + } + + @DisplayName("username, password, email은 필수 항목이며 유저가 정상적으로 저장되면 id를 가집니다.") + @Test + void saveUserShouldReturnUser() { + // given + User user = UserFixture.createUserHong(); + + // when + User savedUser = userRepository.save(user); + + // then + assertThat(savedUser.getId()).isNotNull(); + assertThat(savedUser.getUsername()).isEqualTo(user.getUsername()); + assertThat(savedUser.getPassword()).isEqualTo(user.getPassword()); + assertThat(savedUser.getEmail()).isEqualTo(user.getEmail()); + } + + @DisplayName("이미 동일한 email이 존재하면 DB에 저장할 수 없습니다.") + @Test + void saveUserShouldFailedWhenDuplicateEmail() { + // given + User user = UserFixture.createUserHong(); + User user2 = UserFixture.createUserHong_DuplicateEmail(); + userRepository.save(user); + userRepository.flush(); + + // when & then + assertThatThrownBy(() -> { + userRepository.save(user2); + userRepository.flush(); + }).isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/repository/fixture/ChannelFixture.java b/src/test/java/com/sprint/mission/discodeit/repository/fixture/ChannelFixture.java new file mode 100644 index 000000000..3fbb42bf7 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/fixture/ChannelFixture.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.repository.fixture; + +import com.sprint.mission.discodeit.entity.Channel; + +public class ChannelFixture { + public static Channel createPublicChannel() { + return new Channel("public", "description"); + } + + public static Channel createPrivateChannel() { + return new Channel(); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/repository/fixture/UserFixture.java b/src/test/java/com/sprint/mission/discodeit/repository/fixture/UserFixture.java new file mode 100644 index 000000000..2fac07418 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/fixture/UserFixture.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.repository.fixture; + +import com.sprint.mission.discodeit.entity.User; + +public class UserFixture { + public static User createUserHong() { + return new User("hong", "hong@example.com", "hong1234", null); + } + + public static User createUserHong_DuplicateEmail() { + return new User("hong2", "hong@example.com", "hong1234", null); + } + + public static User createUserKim() { + return new User("kim", "kim@example.org", "kim1234", null); + } + + public static User createUserLee() { + return new User("lee", "lee@example.com", "lee1234", null); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/service/ChannelServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/ChannelServiceTest.java new file mode 100644 index 000000000..02879af4e --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/ChannelServiceTest.java @@ -0,0 +1,223 @@ +package com.sprint.mission.discodeit.service; + +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.ChannelAlreadyExistsException; +import com.sprint.mission.discodeit.exception.channel.ChannelNotFoundException; +import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException; +import com.sprint.mission.discodeit.exception.user.UserNotFoundException; +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.basic.BasicChannelService; +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 java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +public class ChannelServiceTest { + @Mock + private ChannelRepository channelRepository; + @Mock + private ReadStatusRepository readStatusRepository; + @Mock + private UserRepository userRepository; + + @InjectMocks + private BasicChannelService channelService; + + @DisplayName("공개 채널을 성공적으로 완료") + @Test + void createPublicChannel() { + // given + PublicChannelCreateRequest request = new PublicChannelCreateRequest("channelId", "channelName"); + Channel channel = new Channel(request.name(), request.description()); + given(channelRepository.save(channel)).willReturn(channel); + given(channelRepository.existsByName(anyString())).willReturn(false); + + // when + Channel createdChannel = channelService.createPublicChannel(request); + + // then + assertEquals(request.name(), createdChannel.getName()); + assertEquals(request.description(), createdChannel.getDescription()); + assertEquals(ChannelType.PUBLIC, createdChannel.getType()); + } + + @DisplayName("중복된 채널 이름으로 생성 요청 시 ChannelAlreadyExistsException 발생") + @Test + void createPublicChannelShouldFailedWhenDuplicateName() { + // given + PublicChannelCreateRequest request = new PublicChannelCreateRequest("channelId", "channelName"); + given(channelRepository.existsByName(anyString())).willReturn(true); + + // when & then + Exception exception = assertThrows(ChannelAlreadyExistsException.class, () -> channelService.createPublicChannel(request)); + assertEquals("Channel Name Already Exists", exception.getMessage()); + assertInstanceOf(ChannelAlreadyExistsException.class, exception); + } + + @DisplayName("비공개 채널 성공적으로 생성") + @Test + void createPrivateChannelOk() { + // given + List participantIds = new ArrayList<>(Arrays.asList(UUID.randomUUID(), UUID.randomUUID())); + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(participantIds); + Channel channel = new Channel(); + given(channelRepository.save(channel)).willReturn(channel); + given(userRepository.findById(notNull(UUID.class))).willReturn(Optional.of(new User())); + given(readStatusRepository.save(new ReadStatus(notNull(User.class), channel))).willReturn(new ReadStatus(notNull(User.class), channel)); + + // when + Channel createdChannel = channelService.createPrivateChannel(request); + + // then + assertEquals(ChannelType.PRIVATE, createdChannel.getType()); + then(userRepository).should(times(2)).findById(notNull(UUID.class)); + then(readStatusRepository).should(times(2)).save(notNull(ReadStatus.class)); + } + + @DisplayName("비공개 채널 생성 시 참여하는 유저를 찾을수 없음") + @Test + void createPrivateShouldFailedWhenUserNotFound() { + // given + List participantIds = new ArrayList<>(Arrays.asList(UUID.randomUUID(), UUID.randomUUID())); + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(participantIds); + given(userRepository.findById(notNull(UUID.class))).willReturn(Optional.empty()); + + // when & then + Exception exception = assertThrows(UserNotFoundException.class, () -> channelService.createPrivateChannel(request)); + assertEquals("User Not Found", exception.getMessage()); + assertInstanceOf(UserNotFoundException.class, exception); + } + + @DisplayName("정상적으로 채널 수정 완료") + @Test + void updatePublicChannelShouldReturnUpdatedChannel() { + // given + UUID uuid = UUID.randomUUID(); + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName", "newDescription"); + Channel channel = Channel.of(uuid, "oldName", "oldDescription", ChannelType.PUBLIC); + given(channelRepository.save(channel)).willReturn(channel); + given(channelRepository.findById(uuid)).willReturn(Optional.of(channel)); + + // when + Channel createdChannel = channelService.updateChannel(uuid, request); + + // then + assertEquals(ChannelType.PUBLIC, createdChannel.getType()); + assertEquals(request.newName(), createdChannel.getName()); + assertEquals(request.newDescription(), createdChannel.getDescription()); + then(channelRepository).should(times(1)).findById(uuid); + then(channelRepository).should(times(1)).save(channel); + } + + @DisplayName("비공개 채널은 수정될 수 없음, 비공개 채널 수정 요청 시 PrivateChannelUpdateException 발생") + @Test + void updatePrivateUpdateShouldFailedWhenTypeIsPrivate() { + // given + UUID uuid = UUID.randomUUID(); + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName", "newDescription"); + Channel channel = Channel.of(uuid, "oldName", "oldDescription", ChannelType.PRIVATE); + given(channelRepository.findById(uuid)).willReturn(Optional.of(channel)); + + // when & then + Exception exception = assertThrows(PrivateChannelUpdateException.class, () -> channelService.updateChannel(uuid, request)); + assertEquals("Private Channel Cannot Be Updated", exception.getMessage()); + assertInstanceOf(PrivateChannelUpdateException.class, exception); + } + + @DisplayName("수정할 채널을 찾을 수 없음 ChannelNotFoundException 발생") + @Test + void updateChannelShouldFailedWhenChannelNotFound() { + // given + UUID uuid = UUID.randomUUID(); + PublicChannelUpdateRequest request = new PublicChannelUpdateRequest("newName", "newDescription"); + given(channelRepository.findById(uuid)).willReturn(Optional.empty()); + // when & then + Exception exception = assertThrows(ChannelNotFoundException.class, () -> channelService.updateChannel(uuid, request)); + assertEquals("Channel Not Found", exception.getMessage()); + assertInstanceOf(ChannelNotFoundException.class, exception); + } + + @DisplayName("정상적으로 채널 삭제 동작") + @Test + void deleteChannelOk() { + // given + UUID uuid = UUID.randomUUID(); + given(channelRepository.findById(uuid)).willReturn(Optional.of(new Channel())); + + // when + channelService.deleteChannel(uuid); + + // then + then(channelRepository).should(times(1)).delete(any(Channel.class)); + } + + @DisplayName("존재하지 않는 채널 삭제 요청 시 ChannelNotFoundException 발생") + @Test + void deleteChannelShouldFailedWhenChannelNotFound() { + // given + UUID uuid = UUID.randomUUID(); + given(channelRepository.findById(uuid)).willReturn(Optional.empty()); + + // when & then + Exception exception = assertThrows(ChannelNotFoundException.class, () -> channelService.deleteChannel(uuid)); + assertEquals("Channel Not Found", exception.getMessage()); + assertInstanceOf(ChannelNotFoundException.class, exception); + } + + @DisplayName("유저Id로 정상적으로 채널을 찾은 경우") + @Test + void channelFindByUserIdOkShouldReturnAllChannel() { + // given + UUID userId = UUID.randomUUID(); + Channel publicChannel = Channel.of(UUID.randomUUID(), "name", "description", ChannelType.PUBLIC); + Channel privateChannel = Channel.of(UUID.randomUUID(), null, null, ChannelType.PRIVATE); + User user = User.of(userId, "username", "test@email.com", "password", null); + ReadStatus readStatus = ReadStatus.of(UUID.randomUUID(), user, publicChannel); + ReadStatus readStatus2 = ReadStatus.of(UUID.randomUUID(), user, privateChannel); + List readStatusList = new ArrayList<>(Arrays.asList(readStatus, readStatus2)); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(readStatusRepository.findAllByUser_Id(userId)).willReturn(readStatusList); + given(channelRepository.findAllByType(ChannelType.PUBLIC)).willReturn(List.of(publicChannel)); + + // when + List list = channelService.findAllByUserId(userId); + + // then + then(userRepository).should(times(1)).findById(userId); + then(channelRepository).should(times(1)).findAllByType(ChannelType.PUBLIC); + then(readStatusRepository).should(times(1)).findAllByUser_Id(userId); + assertEquals(2, list.size()); + } + + @DisplayName("존재하지 않는 유저의 채널을 찾을 경우 UserNotFoundException 발생") + @Test + void channelFindByUserIdShouldFailedWhenUserNotFound() { + // given + UUID userId = UUID.randomUUID(); + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // when & then + Exception exception = assertThrows(UserNotFoundException.class, () -> channelService.findAllByUserId(userId)); + assertEquals("User Not Found", exception.getMessage()); + assertInstanceOf(UserNotFoundException.class, exception); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/service/MessageServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/MessageServiceTest.java new file mode 100644 index 000000000..b56f36066 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/MessageServiceTest.java @@ -0,0 +1,207 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.request.MessageUpdateRequest; +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.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.basic.BasicMessageService; +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.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +public class MessageServiceTest { + @Mock + private MessageRepository messageRepository; + @Mock + private UserRepository userRepository; + @Mock + private ChannelRepository channelRepository; + + @InjectMocks + private BasicMessageService messageService; + + @DisplayName("정상적으로 메시지 생성 완료") + @Test + void messageCreateOk() { + // given + UUID authorId = UUID.randomUUID(); + User user = User.of(authorId, "username", "test@email.com", "password", null); + UUID channelId = UUID.randomUUID(); + Channel channel = Channel.of(channelId, "name", "description", ChannelType.PUBLIC); + MessageCreateRequest request = new MessageCreateRequest(authorId, channelId, "content"); + Message message = new Message(request.content(), channel, user); + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + given(userRepository.findById(authorId)).willReturn(Optional.of(user)); + given(messageRepository.save(message)).willReturn(message); + + // when + Message createdMessage = messageService.createMessage(request, null); + + // then + assertEquals(user, createdMessage.getAuthor()); + assertEquals(channel, createdMessage.getChannel()); + assertEquals(request.content(), createdMessage.getContent()); + then(userRepository).should(times(1)).findById(authorId); + then(channelRepository).should(times(1)).findById(channelId); + then(messageRepository).should(times(1)).save(message); + } + + @DisplayName("유저를 찾을 수 없는 경우 UserNotFoundException 발생") + @Test + void messageCreateShouldFailedWhenUserNotFound() { + // given + UUID authorId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + MessageCreateRequest request = new MessageCreateRequest(authorId, channelId, "content"); + given(channelRepository.findById(channelId)).willReturn(Optional.of(new Channel())); + given(userRepository.findById(authorId)).willReturn(Optional.empty()); + + // when & then + Exception exception = assertThrows(UserNotFoundException.class, () -> messageService.createMessage(request, null)); + assertEquals("User Not Found", exception.getMessage()); + assertInstanceOf(UserNotFoundException.class, exception); + } + + @DisplayName("채널을 찾을 수 없는 경우 ChannelNotFoundException 발생") + @Test + void messageCreateShouldFailedWhenChannelNotFound() { + // given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + MessageCreateRequest request = new MessageCreateRequest(authorId, channelId, "content"); + given(channelRepository.findById(channelId)).willReturn(Optional.empty()); + + // when & then + Exception exception = assertThrows(ChannelNotFoundException.class, () -> messageService.createMessage(request, null)); + assertEquals("Channel Not Found", exception.getMessage()); + assertInstanceOf(ChannelNotFoundException.class, exception); + } + + @DisplayName("정상적으로 메시지 수정 완료") + @Test + void messageUpdateOk() { + // given + UUID messageId = UUID.randomUUID(); + MessageUpdateRequest request = new MessageUpdateRequest("newContent"); + Message message = Message.of(messageId, new Channel(), new User(), "oldContent"); + given(messageRepository.findById(messageId)).willReturn(Optional.of(message)); + given(messageRepository.save(message)).willReturn(message); + + // when + Message updatedMessage = messageService.updateMessage(messageId, request); + + // then + assertEquals(request.newContent(), updatedMessage.getContent()); + then(messageRepository).should(times(1)).findById(messageId); + then(messageRepository).should(times(1)).save(message); + assertEquals(message.getId(), updatedMessage.getId()); + } + + @DisplayName("해당하는 메시지를 찾을 수 없는 경우 MessageNotFoundException 발생") + @Test + void messageUpdateShouldFailedMessageNotFound() { + // given + UUID messageId = UUID.randomUUID(); + MessageUpdateRequest request = new MessageUpdateRequest("newContent"); + given(messageRepository.findById(messageId)).willReturn(Optional.empty()); + + // when & then + Exception exception = assertThrows(MessageNotFoundException.class, () -> messageService.updateMessage(messageId, request)); + assertEquals("Message Not Found", exception.getMessage()); + assertInstanceOf(MessageNotFoundException.class, exception); + } + + @DisplayName("정상적으로 메시지 삭제 완료") + @Test + void messageDeleteOk() { + // given + UUID messageId = UUID.randomUUID(); + Message message = Message.of(messageId, new Channel(), new User(), "oldContent"); + given(messageRepository.findById(messageId)).willReturn(Optional.of(message)); + + // when + messageService.removeMessage(messageId); + + // when + then(messageRepository).should(times(1)).delete(message); + } + + @DisplayName("해당하는 메시지가 존재하지 않을 경우 MessageNotFoundException 발생") + @Test + void messageDeleteShouldFailedWhenMessageNotFound() { + // given + UUID messageId = UUID.randomUUID(); + Message message = Message.of(messageId, new Channel(), new User(), "oldContent"); + given(messageRepository.findById(messageId)).willReturn(Optional.empty()); + + // when & then + Exception exception = assertThrows(MessageNotFoundException.class, () -> messageService.removeMessage(messageId)); + assertEquals("Message Not Found", exception.getMessage()); + assertInstanceOf(MessageNotFoundException.class, exception); + } + + @DisplayName("채널 Id로 정상적으로 Message 조회 완료") + @Test + void messageFindAllByChannelIdOkShouldReturnAllMessage() { + // given + UUID messageId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + Message message = Message.of(messageId, new Channel(), new User(), "oldContent"); + Message message2 = Message.of(messageId, new Channel(), new User(), "newContent"); + Pageable pageable = PageRequest.of(0, 10); + Instant cursor = Instant.now(); + Page page = new PageImpl(List.of(message, message2), pageable, 2); + given(channelRepository.findById(channelId)).willReturn(Optional.of(new Channel())); + given(messageRepository.findAllByChannel_Id(channelId, pageable)).willReturn(page); + + // when + Page messages = messageService.findAllByChannelId(channelId, cursor, pageable); + + // then + assertEquals(message, messages.getContent().get(0)); + assertEquals(message2, messages.getContent().get(1)); + assertEquals(2, messages.getTotalElements()); + assertInstanceOf(Page.class, messages); + } + + @DisplayName("채널이 존재하지 않는 경우 ChannelNotFoundException 발생") + @Test + void messageFindAllByChannelIdShouldFailedWhenChannelNotFound() { + // given + UUID channelId = UUID.randomUUID(); + Pageable pageable = PageRequest.of(0, 10); + Instant cursor = Instant.now(); + given(channelRepository.findById(channelId)).willReturn(Optional.empty()); + + // when & then + Exception exception = assertThrows(ChannelNotFoundException.class, () -> messageService.findAllByChannelId(channelId, cursor, pageable)); + assertEquals("Channel Not Found", exception.getMessage()); + assertInstanceOf(ChannelNotFoundException.class, exception); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/service/UserServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/UserServiceTest.java new file mode 100644 index 000000000..bfc600d90 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/UserServiceTest.java @@ -0,0 +1,169 @@ +package com.sprint.mission.discodeit.service; + +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.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.basic.BasicUserService; +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 java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + + +@ExtendWith(MockitoExtension.class) +public class UserServiceTest { + @Mock + private UserRepository userRepository; + @Mock + private BinaryContentRepository binaryContentRepository; + + @InjectMocks + private BasicUserService userService; + + @DisplayName("올바른 User 생성 시") + @Test + void createUserOk() { + // given + UserCreateRequest request = new UserCreateRequest("test", "test", "test@test.com"); + User user = new User(request, null); + given(userRepository.save(user)).willReturn(user); + given(userRepository.existsByEmail(request.email())).willReturn(false); + given(userRepository.existsByUsername(request.username())).willReturn(false); + + // when + User createdUser = userService.createUser(request, null); + + // then + assertEquals(request.username(), createdUser.getUsername()); + assertEquals(request.password(), createdUser.getPassword()); + assertEquals(request.email(), createdUser.getEmail()); + then(userRepository).should(times(1)).save(user); + then(userRepository).should(times(1)).existsByEmail(request.email()); + then(userRepository).should(times(1)).existsByUsername(request.username()); + } + + @DisplayName("이메일이 중복이면 UserAlreadyExistsException 발생") + @Test + void createUserShouldFailedWhenDuplicateEmail() { + // given + UserCreateRequest request = new UserCreateRequest("test", "test", "test@test.com"); + given(userRepository.existsByEmail(request.email())).willReturn(true); + + // when & then + Exception exception = assertThrows(UserAlreadyExistsException.class, () -> userService.createUser(request, null)); + assertEquals("Username Or Email Already Exists", exception.getMessage()); + assertInstanceOf(UserAlreadyExistsException.class, exception); + } + + @DisplayName("유저이름이 중복이면 UserAlreadyExistsException 발생") + @Test + void createUserShouldFailedWhenDuplicateUsername() { + // given + UserCreateRequest request = new UserCreateRequest("test", "test", "test@test.com"); + given(userRepository.existsByUsername(request.username())).willReturn(true); + + // when & then + Exception exception = assertThrows(UserAlreadyExistsException.class, () -> userService.createUser(request, null)); + assertEquals("Username Or Email Already Exists", exception.getMessage()); + assertInstanceOf(UserAlreadyExistsException.class, exception); + } + + @DisplayName("유저 수정 성공") + @Test + void updateUserOk() { + // given + UserUpdateRequest request = new UserUpdateRequest("newUsername", "new@email.com", "newPassword"); + UUID uuid = UUID.randomUUID(); + User testUser = User.of(uuid, "oldUsername", "old@email.com", "oldPassword", null); + given(userRepository.findById(uuid)).willReturn(Optional.of(testUser)); + given(userRepository.existsByEmail(request.newEmail())).willReturn(false); + given(userRepository.existsByUsername(request.newUsername())).willReturn(false); + given(userRepository.save(testUser)).willReturn(testUser); + + // when + User user = userService.updateUser(uuid, request, null); + + // then + then(userRepository).should(times(1)).save(user); + assertEquals(request.newUsername(), user.getUsername()); + assertEquals(request.newEmail(), user.getEmail()); + assertEquals(request.newPassword(), user.getPassword()); + then(userRepository).should(times(1)).existsByEmail(anyString()); + then(userRepository).should(times(1)).existsByUsername(anyString()); + } + + @DisplayName("id에 해당하는 유저를 찾지 못하는 경우 UserNotFoundException 발생") + @Test + void updateUserShouldFailedWhenUserNotFound() { + // given + UserUpdateRequest request = new UserUpdateRequest("newUsername", "new@email.com", "newPassword"); + UUID uuid = UUID.randomUUID(); + given(userRepository.findById(uuid)).willReturn(Optional.empty()); + + // when & then + Exception exception = assertThrows(UserNotFoundException.class, () -> userService.updateUser(uuid, request, null)); + assertEquals("User Not Found", exception.getMessage()); + assertInstanceOf(UserNotFoundException.class, exception); + } + + @DisplayName("변경할 Email이 중복일 경우 UserAlreadyExistsException 발생") + @Test + void updateUserShouldFailedWhenNewEmailAlreadyExists() { + // given + UserUpdateRequest request = new UserUpdateRequest("newUsername", "new@email.com", "newPassword"); + UUID uuid = UUID.randomUUID(); + User user = new User(); + given(userRepository.findById(uuid)).willReturn(Optional.of(user)); + given(userRepository.existsByEmail(anyString())).willReturn(true); + + // when & then + Exception exception = assertThrows(UserAlreadyExistsException.class, () -> userService.updateUser(uuid, request, null)); + assertEquals("Username Or Email Already Exists", exception.getMessage()); + assertInstanceOf(UserAlreadyExistsException.class, exception); + } + + @DisplayName("유저 삭제 성공") + @Test + void deleteUserOk() { + // given + UUID uuid = UUID.randomUUID(); + User user = User.of(uuid, "username", "test@email.com", "password", null); + given(userRepository.findById(uuid)).willReturn(Optional.of(user)); + + // when + userService.deleteUser(uuid); + + // then + then(userRepository).should(times(1)).delete(user); + } + + @DisplayName("삭제할 유저가 존재하지 않는 경우") + @Test + void deleteUserShouldFailedWhenUserNotFound() { + // given + UUID uuid = UUID.randomUUID(); + given(userRepository.findById(notNull(UUID.class))).willReturn(Optional.empty()); + + // when & then + Exception exception = assertThrows(UserNotFoundException.class, () -> userService.deleteUser(uuid)); + assertEquals("User Not Found", exception.getMessage()); + assertInstanceOf(UserNotFoundException.class, exception); + } + +} 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..e23fc8797 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java @@ -0,0 +1,176 @@ +package com.sprint.mission.discodeit.storage.s3; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +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.NoSuchBucketException; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +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; + +import java.io.FileInputStream; +import java.io.IOException; +import java.time.Duration; +import java.util.Properties; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +public class AWSS3Test { + private static Properties props = new Properties(); + private static S3Client s3Client; + private static S3Presigner presigner; + + @BeforeAll + public static void setup() { + try (FileInputStream fis = new FileInputStream(".env")) { + props.load(fis); + } catch (IOException e) { + e.printStackTrace(); + } + if (!props.isEmpty() && props.size() > 0) { + if (props.getProperty("AWS_S3_ACCESS_KEY") != null && !props.getProperty("AWS_S3_ACCESS_KEY").isBlank()) { + s3Client = S3Client.builder() + .region(Region.of(props.getProperty("AWS_S3_REGION"))) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create( + props.getProperty("AWS_S3_ACCESS_KEY"), + props.getProperty("AWS_S3_SECRET_KEY") + ) + ) + ) + .build(); + } else { + s3Client = S3Client.builder() + .region(Region.of(props.getProperty("AWS_S3_REGION"))) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } + + presigner = S3Presigner.builder() + .region(Region.of(props.getProperty("AWS_S3_REGION"))) + .credentialsProvider( + (props.getProperty("AWS_S3_ACCESS_KEY") != null && !props.getProperty("AWS_S3_ACCESS_KEY").isBlank()) + ? StaticCredentialsProvider.create(AwsBasicCredentials.create( + props.getProperty("AWS_S3_ACCESS_KEY"), + props.getProperty("AWS_S3_SECRET_KEY"))) + : DefaultCredentialsProvider.create() + ) + .build(); + } + } + + + @Test + void upload() throws IOException { + // 본문 생성 + MockMultipartFile file = new MockMultipartFile("file.txt", "test_content".getBytes()); + // given + String key = "test-" + UUID.randomUUID(); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(props.getProperty("AWS_S3_BUCKET")) + .key(key) + .contentType(file.getContentType()) + .build(); + + // when + s3Client.putObject(putObjectRequest, + RequestBody.fromBytes(file.getBytes())); + + var result = s3Client.getObject( + GetObjectRequest.builder() + .bucket(props.getProperty("AWS_S3_BUCKET")) + .key(key) + .build() + ); + // then + assertArrayEquals("test_content".getBytes(), result.readAllBytes()); + } + + @Test + void download() throws IOException { + // given + String url = "test-715e1e52-81e8-4434-a64e-f2798f3970a0"; + + // when + var result = s3Client.getObject( + GetObjectRequest.builder() + .bucket(props.getProperty("AWS_S3_BUCKET")) + .key(url) + .build() + ); + + // then + assertArrayEquals("test_content".getBytes(), result.readAllBytes()); + } + + @Test + void createPresignedUrl() { + // 본문생성 + String filename = "file.txt"; + + // given + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(props.getProperty("AWS_S3_BUCKET")) + .key(filename) + .build(); + + GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder() + .getObjectRequest(getObjectRequest) + .signatureDuration(Duration.ofMinutes(1)) + .build(); + + // when + String signed = presigner.presignGetObject(getObjectPresignRequest).url().toString(); + + // then + assertNotNull(signed); + assertNotEquals(filename, signed); + } + + @DisplayName("download 시 사용되는 key값이 존재하지 않을 시 예외를 발생시킨다.") + @Test + void downloadShouldFailedWhenUnknownKeyValue() { + // given + String url = "failed"; + + // when & then + Exception exception = assertThrows(Exception.class, () -> s3Client.getObject( + GetObjectRequest.builder() + .bucket(props.getProperty("AWS_S3_BUCKET")) + .key(url) + .build() + )); + assertInstanceOf(NoSuchKeyException.class, exception); + } + + @DisplayName("Bucket이 올바르지 않으면 예외가 발생한다.") + @Test + void shouldFailedWhenInvalidBucket() { + // given + String bucket = "invalidBucket"; + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key("key") + .contentType(MediaType.IMAGE_PNG_VALUE) + .build(); + + // when & then + Exception exception = assertThrows(Exception.class, + () -> s3Client.putObject(putObjectRequest, + RequestBody.fromBytes("failed".getBytes()))); + assertInstanceOf(NoSuchBucketException.class, exception); + } +} 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..dd321fcb8 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java @@ -0,0 +1,89 @@ +package com.sprint.mission.discodeit.storage.s3; + +import com.sprint.mission.discodeit.dto.BinaryContentDto; +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.HttpStatus; +import org.springframework.http.ResponseEntity; +import software.amazon.awssdk.services.s3.S3Client; +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; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +public class S3BinaryContentStorageTest { + @Mock + private S3Client s3Client; + @Mock + private S3Presigner presigner; + @Mock + private PresignedGetObjectRequest presignedGetObjectRequest; + + private S3BinaryContentStorage s3BinaryContentStorage; + + @BeforeEach + public void setup() { + s3BinaryContentStorage = new S3BinaryContentStorage(s3Client, presigner, "accessKey", "secretKey", "region", "bucket"); + } + + @DisplayName("AWS S3에 파일을 업로드합니다. binaryContentController에서 파일을 받고 binaryContent를 생성하고 해당 ID와 bytes로 처리합니다.") + @Test + void put() { + // given + UUID uuid = UUID.randomUUID(); + byte[] bytes = "test".getBytes(); + + // when + UUID binaryContentId = s3BinaryContentStorage.put(uuid, bytes); + + // then + assertEquals(binaryContentId, uuid); + } + + @DisplayName("파일의 Id를 통해 InputStream을 얻습니다.") + @Test + void get() throws IOException { + // given + UUID uuid = UUID.randomUUID(); + URL mock = mock(URL.class); + given(mock.openStream()).willReturn(InputStream.nullInputStream()); + given(presignedGetObjectRequest.url()).willReturn(mock); + given(presigner.presignGetObject(any(GetObjectPresignRequest.class))).willReturn(presignedGetObjectRequest); + + // when + InputStream is = s3BinaryContentStorage.get(uuid); + + // then + assertNotNull(is); + } + + @DisplayName("파일을 다운로드 할 수 있도록 PresignerURL을 생성해 리다이렉션 합니다.") + @Test + void download() throws Exception { + // givne + UUID uuid = UUID.randomUUID(); + BinaryContentDto binaryContentDto = new BinaryContentDto(uuid, 1L, "filename", "image/jpeg"); + given(presignedGetObjectRequest.url()).willReturn(new URL("https://amazon.com/test-url")); + given(presigner.presignGetObject(any(GetObjectPresignRequest.class))).willReturn(presignedGetObjectRequest); + // when + ResponseEntity response = s3BinaryContentStorage.download(binaryContentDto); + + // then + assertEquals(response.getStatusCode(), HttpStatus.FOUND); + } +}