diff --git a/README.md b/README.md index d140f507..7b812145 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,34 @@ Bounded Context(BC)는 동일한 문맥으로 효율적으로 업무 용어(도 --- -## 2. 레거시 코드의 양방향 연관관계를 단방향으로! +## 2. 헥사고날 아키택처로의 전환 + +**문제 상황** + +- 기존 아키택처의 한계점 ([데이터 중심의 설계가 불러온 한계](https://blogshine.tistory.com/688)) +- 정확한 기준 없이 그때 그때 달라지는 페키지 구조와 네이밍 방식 +- 확장에 대한 고려가 없는 기존의 설계 + +**문제 해결** + +- 기존의 아키텍처를 헥사고날 아키텍처로 전환하면서 이를 극복 + +`유연성, 유지보수성` : 외부 시스템이나 인프라와의 의존성을 낮추어, 구성 요소를 쉽게 교체하거나 업데이트할 수 있게 되었습니다. + 그도 그럴 것이 application의 service들은 모두 인터페이스에 해당되는 port에 의존하게 되었습니다. + 더 이상 실 구현체가 아니기 때문에 중간에 다른 구현채로 변경되어도 유연하게 대응할 수 있는 장점을 갖게 되었죠! 이는 곧 유지보수성과도 직결된다 생각되더라고요! + +`테스트 용이성` : 비즈니스 로직을 독립적으로 테스트할 수 있어 품질 향상과 개발 속도 향상에 도움이 됩니다! + 인터페이스를 적절하게 사용하였기에 해당 로직의 독립성을 유지할 수 있던 점이 매우 장점이 돼준 것 같습니다. + +`팀원과의 협업` : 책임이 분리되어 있어, 코드의 이해와 수정이 용이하며, 변화에 빠르게 대응할 수 있습니다. + 즉, 흔하게 말하는 SOLID가 모두 지켜지고 있는 좋은 아키텍처 구조입니다. + 또한 제2의 멤버가 들어와서 유지보수를 하거나 개편해야 해도 HexagonalArchitecture 자체의 이해도만 있다면 충분히 빠른 적응이 가능하다 생각됩니다. + +**상세 내용 링크 : ([글 링크](https://blogshine.tistory.com/689))** + +--- + +## 3. 레거시 코드의 양방향 연관관계를 단방향으로! JPA에 대해서는 서로 어느 정도 이해하고 있어, 적절한 fetch join을 사용하여 코딩했었기에 N+1 문제는 발생하지 않았습니다. 하지만 연관관계에 대해서 문제가 있었습니다. 가장 좋은 연관관계 설계는 단방향을 기초로 하되 필요하면 양방향 설계를 하는 것입니다. @@ -177,7 +204,7 @@ https://blogshine.tistory.com/345 --- -## 3. 공지 Scrap작업 multi-threading 처리로 시간 개선하기 +## 4. 공지 Scrap작업 multi-threading 처리로 시간 개선하기 **문제 상황** @@ -192,7 +219,8 @@ https://blogshine.tistory.com/345 **상세 내용 링크 : ([글 링크](https://blogshine.tistory.com/660))** --- -## 4. Full-Text-Index도입을 통한 **검색 성능개선** + +## 5. Full-Text-Index도입을 통한 **검색 성능개선** **문제 상황** @@ -208,7 +236,8 @@ https://blogshine.tistory.com/345 **상세 내용 링크 : ([글 링크](https://blogshine.tistory.com/664))** --- -## 5. HeapDump를 통해 메모리 누수 원인 찾기 **검색 성능개선** + +## 6. HeapDump를 통해 메모리 누수 원인 찾기 **검색 성능개선** **문제 상황** @@ -224,7 +253,8 @@ https://blogshine.tistory.com/345 **상세 내용 링크 : ([글 링크](https://blogshine.tistory.com/687))** --- -# 6. Bulk Query를 통한 성능 개선 + +## 7. Bulk Query를 통한 성능 개선 **문제 상황** @@ -243,7 +273,7 @@ https://blogshine.tistory.com/345
-### 6-1) Insert 해결책 +### 7-1) Insert 해결책 해결책은 2가지가 존재했습니다. 1. Table Id strategy를 SEQUENCE로 변경하고 Batch 작업 @@ -258,14 +288,14 @@ MySQL과 MariaDB의 Table Id 전략은 대부분이 IDENTITY 전략을 사용하
-### 6-2) Delete 해결책 +### 7-2) Delete 해결책 이미 프로젝트에서 queryDsl를 사용하고 있어 이를 이용하는 것이 가장 간단했기 때문에 queryDsl의 delete in 쿼리를 사용하여 해결했습니다. **상세 내용 링크 : ([글 링크](https://blogshine.tistory.com/686))** --- -## 7. 인증, 인가를 비즈니스 로직으로부터 분리하기 +## 8. 인증, 인가를 비즈니스 로직으로부터 분리하기 **문제 상황** @@ -283,7 +313,7 @@ MySQL과 MariaDB의 Table Id 전략은 대부분이 IDENTITY 전략을 사용하 --- -## 8. 흔하디 흔한 N+1 쿼리 개선기 +## 9. 흔하디 흔한 N+1 쿼리 개선기 원래 로직에서는 사용자의 Category 이름 목록을 가져오기 위해서 다음과 같이 처리가 되고 잇었습니다! @@ -341,7 +371,7 @@ public List getCategoryNamesFromCategories(List categories) { 쿼리가 총 1 + 2N 만큼 발생중이다. -### 8 - 1) 변경 전 쿼리 +### 9 - 1) 변경 전 쿼리 ```bash Hibernate: @@ -398,7 +428,7 @@ Connection: keep-alive N+1 문제로 User한번 조회하는데 위와 같이 쿼리가 3번 나가게 됨 -### 8 - 2) 변경 후 +### 9 - 2) 변경 후 변경 후 한방 쿼리로 조회 끝 ```java @@ -415,7 +445,7 @@ public List getUserCategoryNamesByToken(String token) { ___ -## 9. Test Container를 통한 테스트의 멱등성 보장하기 +## 10. Test Container를 통한 테스트의 멱등성 보장하기 테스트와, 실제 운영 DB를 둘다 MariaDB 환경으로 사용하여 문제가 발생할 일이 없다 생각했었습니다. 하지만, utf8과 같은 인코딩 방식이 로컬과 프로덕션이 달라 문제가 발생하였으며, 이또한 테스트 환경에서 걸러내지 못한 것이 문제라 생각하였습니다. @@ -424,7 +454,7 @@ ___ --- -## 10. CI / 정적분석기(SonarCloud, jacoco)를 사용한 코드 컨벤션에 대한 코드리뷰 자동화 +## 11. CI / 정적분석기(SonarCloud, jacoco)를 사용한 코드 컨벤션에 대한 코드리뷰 자동화 **문제 상황** @@ -441,7 +471,7 @@ ___ --- -## 11. 서버 모니터링 +## 12. 서버 모니터링 **문제 상황** diff --git a/build.gradle b/build.gradle index f3aa57b3..cef7a5c7 100644 --- a/build.gradle +++ b/build.gradle @@ -5,18 +5,17 @@ buildscript { } plugins { - id 'org.springframework.boot' version '2.5.5' + id 'org.springframework.boot' version '2.7.18' id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'com.ewerk.gradle.plugins.querydsl' version "1.0.10" id 'java' id 'org.asciidoctor.jvm.convert' version "3.3.2" - id 'org.flywaydb.flyway' version '9.16.1' // flyway gradle plugin 의존성 id 'org.sonarqube' version '3.5.0.2730' // sonarqube gradle plugin 의존성 id 'jacoco' // jacoco gradle plugin 의존성 } group = 'com.kustacks' -version = '1.1.2' +version = '2.5.0' sourceCompatibility = '17' configurations { @@ -41,12 +40,9 @@ sonarqube { dependencies { // Web implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework:spring-aspects' - implementation 'org.springframework.session:spring-session:1.3.5.RELEASE' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' // DB @@ -69,7 +65,7 @@ dependencies { implementation 'io.micrometer:micrometer-registry-prometheus' // flyway - implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' // DevTool compileOnly 'org.projectlombok:lombok' @@ -85,30 +81,30 @@ dependencies { // Firebase implementation 'com.google.firebase:firebase-admin:8.1.0' - // API Docs + // RestDocs asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + // Swagger + implementation 'org.springdoc:springdoc-openapi-ui:1.6.11' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.rest-assured:rest-assured:4.2.0' + // ArchUnit + testImplementation 'com.tngtech.archunit:archunit-junit5:1.0.1' + // Test Container testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1' - testImplementation 'org.testcontainers:testcontainers:1.17.6' - testImplementation 'org.testcontainers:junit-jupiter:1.17.6' - testImplementation 'org.testcontainers:mariadb:1.17.6' -} - -ext { - snippetsDir = file 'build/generated-snippets' + testImplementation 'org.testcontainers:testcontainers:1.19.3' + testImplementation 'org.testcontainers:junit-jupiter:1.19.3' + testImplementation 'org.testcontainers:mariadb:1.19.3' } test.onlyIf { System.getenv('DEPLOY_ENV') == 'dev' } test { - outputs.dir snippetsDir - jacoco { destinationFile = file("$buildDir/jacoco/jacoco.exec") } @@ -126,50 +122,6 @@ test { finalizedBy 'jacocoTestReport' } -//Querydsl 추가, 자동 생성된 Q클래스 gradle clean으로 제거 -clean { - delete file('src/main/generated') -} - -asciidoctor.onlyIf { System.getenv('DEPLOY_ENV') == 'dev' } - -asciidoctor { - inputs.dir snippetsDir - dependsOn test - attributes "snippets": snippetsDir, - "version": version, - "stylesheet": "asciitheme/clean.css" - - doFirst { - println "=====Start asciidoctor" - //asciidoctor 실행전 기존에 생성된 API 문서 삭제 - delete file('src/main/resources/static/docs/api-docs.html') - } - - doLast { - println "=====Finish asciidoctor" - } -} - -task copyDocument(type: Copy) { - dependsOn asciidoctor - from file("build/asciidoc/html5") - // resources/static/docs 로 복사하여 서버가 돌아가고 있을때 /docs/index.html 로 접속하면 볼수 있음 - into file("src/main/resources/static/docs") -} - -build { - dependsOn copyDocument -} - -bootJar { - enabled = true - dependsOn asciidoctor - from("${asciidoctor.outputDir}/html5") { - into "static/docs" - } -} - jar { enabled = false manifest { @@ -196,6 +148,11 @@ compileQuerydsl.doFirst { if(file(querydslDir).exists()) delete(file(querydslDir)) } + +//Querydsl 추가, 자동 생성된 Q클래스 gradle clean으로 제거 +clean { + delete file('src/main/generated') +} // -- Jacoco 설정 ------------------------------------------------------- jacoco { // jacoco version diff --git a/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.java b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.java new file mode 100644 index 00000000..57b45c4e --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.java @@ -0,0 +1,69 @@ +package com.kustacks.kuring.admin.adapter.in.web; + +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.ADMIN_REAL_NOTICE_CREATE_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.ADMIN_TEST_NOTICE_CREATE_SUCCESS; + +import com.kustacks.kuring.admin.adapter.in.web.dto.RealNotificationRequest; +import com.kustacks.kuring.admin.adapter.in.web.dto.TestNotificationRequest; +import com.kustacks.kuring.admin.application.port.in.AdminCommandUseCase; +import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand; +import com.kustacks.kuring.admin.domain.AdminRole; +import com.kustacks.kuring.auth.authorization.AuthenticationPrincipal; +import com.kustacks.kuring.auth.context.Authentication; +import com.kustacks.kuring.auth.secured.Secured; +import com.kustacks.kuring.common.dto.BaseResponse; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin-Command", description = "관리자가 주체가 되는 정보 수정") +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping(value = "/api/v2/admin", produces = MediaType.APPLICATION_JSON_VALUE) +public class AdminCommandApiV2 { + + private final AdminCommandUseCase adminCommandUseCase; + + @Operation(summary = "테스트 공지 전송", description = "테스트 공지를 전송합니다, 실제 운영시 사용하지 않습니다") + @SecurityRequirement(name = "JWT") + @Secured(AdminRole.ROLE_ROOT) + @PostMapping("/notices/dev") + public ResponseEntity> createTestNotice( + @RequestBody TestNotificationRequest request + ) { + adminCommandUseCase.createTestNotice(request.toCommand()); + return ResponseEntity.ok().body(new BaseResponse<>(ADMIN_TEST_NOTICE_CREATE_SUCCESS, null)); + } + + @Operation(summary = "전체 공지 전송", description = "전체 공지를 전송합니다, 실제 운영시 사용합니다") + @SecurityRequirement(name = "JWT") + @Secured(AdminRole.ROLE_ROOT) + @PostMapping("/notices/prod") + public ResponseEntity> createRealNotice( + @RequestBody RealNotificationRequest request, + @AuthenticationPrincipal Authentication authentication + ) { + RealNotificationCommand command = request.toCommandWithAuthentication(authentication); + adminCommandUseCase.createRealNoticeForAllUser(command); + return ResponseEntity.ok().body(new BaseResponse<>(ADMIN_REAL_NOTICE_CREATE_SUCCESS, null)); + } + + @Hidden + @Secured(AdminRole.ROLE_ROOT) + @GetMapping("/subscribe/all") + public ResponseEntity subscribe() { + adminCommandUseCase.subscribeAllUserSameTopic(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/kustacks/kuring/admin/presentation/AdminQueryApiV2.java b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminQueryApiV2.java similarity index 52% rename from src/main/java/com/kustacks/kuring/admin/presentation/AdminQueryApiV2.java rename to src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminQueryApiV2.java index e986ace9..bf92bcbd 100644 --- a/src/main/java/com/kustacks/kuring/admin/presentation/AdminQueryApiV2.java +++ b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminQueryApiV2.java @@ -1,12 +1,23 @@ -package com.kustacks.kuring.admin.presentation; +package com.kustacks.kuring.admin.adapter.in.web; -import com.kustacks.kuring.admin.common.dto.FeedbackDto; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.AUTH_AUTHENTICATION_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.FEEDBACK_SEARCH_SUCCESS; + +import com.kustacks.kuring.admin.application.port.in.AdminQueryUseCase; import com.kustacks.kuring.admin.domain.AdminRole; import com.kustacks.kuring.auth.authorization.AuthenticationPrincipal; import com.kustacks.kuring.auth.context.Authentication; import com.kustacks.kuring.auth.secured.Secured; import com.kustacks.kuring.common.dto.BaseResponse; -import com.kustacks.kuring.user.facade.UserQueryFacade; +import com.kustacks.kuring.user.application.port.in.dto.AdminFeedbacksResult; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -16,48 +27,53 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import javax.validation.constraints.Max; -import javax.validation.constraints.Min; -import java.util.List; - -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.AUTH_AUTHENTICATION_SUCCESS; -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.FEEDBACK_SEARCH_SUCCESS; - +@Tag(name = "Admin-Query", description = "관리자가 주체가 되는 정보 조회") @Validated @RestController @RequiredArgsConstructor @RequestMapping(value = "/api/v2/admin", produces = MediaType.APPLICATION_JSON_VALUE) public class AdminQueryApiV2 { - private final UserQueryFacade userQueryFacade; + private final AdminQueryUseCase adminQueryUseCase; + @Operation(summary = "피드백 조회", description = "사용자의 모든 피드백을 조회합니다") + @SecurityRequirement(name = "JWT") @Secured(AdminRole.ROLE_ROOT) @GetMapping("/feedbacks") - public ResponseEntity>> getFeedbacks( - @RequestParam(name = "page") @Min(0) int page, - @RequestParam(name = "size") @Min(1) @Max(30) int size) - { - List feedbacks = userQueryFacade.lookupFeedbacks(page, size); + public ResponseEntity>> getFeedbacks( + @Parameter(description = "페이지") @RequestParam(name = "page") @Min(0) int page, + @Parameter(description = "단일 페이지의 사이즈, 1 ~ 30까지 허용") @RequestParam(name = "size") @Min(1) @Max(30) int size) { + List feedbacks = adminQueryUseCase.lookupFeedbacks(page, size); return ResponseEntity.ok().body(new BaseResponse<>(FEEDBACK_SEARCH_SUCCESS, feedbacks)); } /** * Root 이상만 호출 가능한 테스트 API + * * @return OK */ + @Hidden @Secured(AdminRole.ROLE_ROOT) @GetMapping("/root") - public ResponseEntity>> roleAdminRoot(@AuthenticationPrincipal Authentication authentication) { - return ResponseEntity.ok().body(new BaseResponse<>(AUTH_AUTHENTICATION_SUCCESS, authentication.getAuthorities())); + public ResponseEntity>> roleAdminRoot( + @AuthenticationPrincipal Authentication authentication + ) { + return ResponseEntity.ok() + .body(new BaseResponse<>(AUTH_AUTHENTICATION_SUCCESS, authentication.getAuthorities())); } /** * Client 이상만 호출 가능한 테스트 API + * * @return OK */ + @Hidden @Secured(AdminRole.ROLE_CLIENT) @GetMapping("/client") - public ResponseEntity>> roleAdminClient(@AuthenticationPrincipal Authentication authentication) { - return ResponseEntity.ok().body(new BaseResponse<>(AUTH_AUTHENTICATION_SUCCESS, authentication.getAuthorities())); + public ResponseEntity>> roleAdminClient( + @AuthenticationPrincipal Authentication authentication + ) { + return ResponseEntity.ok() + .body(new BaseResponse<>(AUTH_AUTHENTICATION_SUCCESS, authentication.getAuthorities())); } } diff --git a/src/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/RealNotificationRequest.java b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/RealNotificationRequest.java new file mode 100644 index 00000000..d42538bb --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/RealNotificationRequest.java @@ -0,0 +1,15 @@ +package com.kustacks.kuring.admin.adapter.in.web.dto; + +import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand; +import com.kustacks.kuring.auth.context.Authentication; + +public record RealNotificationRequest( + String title, + String body, + String url, + String adminPassword +) { + public RealNotificationCommand toCommandWithAuthentication(Authentication authentication) { + return new RealNotificationCommand(title, body, url, adminPassword, authentication); + } +} diff --git a/src/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/TestNotificationRequest.java b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/TestNotificationRequest.java new file mode 100644 index 00000000..cf859fb3 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/TestNotificationRequest.java @@ -0,0 +1,14 @@ +package com.kustacks.kuring.admin.adapter.in.web.dto; + +import com.kustacks.kuring.admin.application.port.in.dto.TestNotificationCommand; + +public record TestNotificationRequest( + String category, + String subject, + String articleId +) { + + public TestNotificationCommand toCommand() { + return new TestNotificationCommand(category, subject, articleId); + } +} diff --git a/src/main/java/com/kustacks/kuring/admin/adapter/out/event/AdminFirebaseMessageAdapter.java b/src/main/java/com/kustacks/kuring/admin/adapter/out/event/AdminFirebaseMessageAdapter.java new file mode 100644 index 00000000..80b04e49 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/adapter/out/event/AdminFirebaseMessageAdapter.java @@ -0,0 +1,40 @@ +package com.kustacks.kuring.admin.adapter.out.event; + +import com.kustacks.kuring.admin.application.port.out.AdminEventPort; +import com.kustacks.kuring.common.domain.Events; +import com.kustacks.kuring.message.adapter.in.event.dto.AdminNotificationEvent; +import com.kustacks.kuring.message.adapter.in.event.dto.AdminTestNotificationEvent; +import com.kustacks.kuring.message.application.service.exception.FirebaseMessageSendException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminFirebaseMessageAdapter implements AdminEventPort { + + @Override + public void sendNotificationByAdmin(String title, String body, String url) { + Events.raise(new AdminNotificationEvent(title, body, url)); + } + + @Override + public void sendTestNotificationByAdmin( + String articleId, + String postedDate, + String categoryName, + String subject, + String korName, + String url + ) throws FirebaseMessageSendException { + Events.raise( + AdminTestNotificationEvent.builder() + .articleId(articleId) + .postedDate(postedDate) + .category(categoryName) + .subject(subject) + .categoryKorName(korName) + .baseUrl(url) + .build() + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/admin/adapter/out/persistence/AdminPersistenceAdapter.java b/src/main/java/com/kustacks/kuring/admin/adapter/out/persistence/AdminPersistenceAdapter.java new file mode 100644 index 00000000..921e9e0f --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/adapter/out/persistence/AdminPersistenceAdapter.java @@ -0,0 +1,26 @@ +package com.kustacks.kuring.admin.adapter.out.persistence; + +import com.kustacks.kuring.admin.application.port.out.AdminCommandPort; +import com.kustacks.kuring.admin.domain.Admin; +import com.kustacks.kuring.admin.application.port.out.AdminQueryPort; +import com.kustacks.kuring.common.annotation.PersistenceAdapter; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +@PersistenceAdapter +@RequiredArgsConstructor +public class AdminPersistenceAdapter implements AdminQueryPort, AdminCommandPort { + + private final AdminRepository adminRepository; + + @Override + public Optional findByLoginId(String loginId) { + return adminRepository.findByLoginId(loginId); + } + + @Override + public void save(Admin admin) { + this.adminRepository.save(admin); + } +} diff --git a/src/main/java/com/kustacks/kuring/admin/domain/AdminRepository.java b/src/main/java/com/kustacks/kuring/admin/adapter/out/persistence/AdminRepository.java similarity index 66% rename from src/main/java/com/kustacks/kuring/admin/domain/AdminRepository.java rename to src/main/java/com/kustacks/kuring/admin/adapter/out/persistence/AdminRepository.java index 66dc69cd..6cc76cd0 100644 --- a/src/main/java/com/kustacks/kuring/admin/domain/AdminRepository.java +++ b/src/main/java/com/kustacks/kuring/admin/adapter/out/persistence/AdminRepository.java @@ -1,5 +1,6 @@ -package com.kustacks.kuring.admin.domain; +package com.kustacks.kuring.admin.adapter.out.persistence; +import com.kustacks.kuring.admin.domain.Admin; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminCommandUseCase.java b/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminCommandUseCase.java new file mode 100644 index 00000000..1e78a5ad --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminCommandUseCase.java @@ -0,0 +1,10 @@ +package com.kustacks.kuring.admin.application.port.in; + +import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand; +import com.kustacks.kuring.admin.application.port.in.dto.TestNotificationCommand; + +public interface AdminCommandUseCase { + void createTestNotice(TestNotificationCommand command); + void createRealNoticeForAllUser(RealNotificationCommand command); + void subscribeAllUserSameTopic(); +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminQueryUseCase.java b/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminQueryUseCase.java new file mode 100644 index 00000000..9bd93965 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/port/in/AdminQueryUseCase.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.admin.application.port.in; + +import com.kustacks.kuring.user.application.port.in.dto.AdminFeedbacksResult; + +import java.util.List; + +public interface AdminQueryUseCase { + List lookupFeedbacks(int page, int size); +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/in/dto/RealNotificationCommand.java b/src/main/java/com/kustacks/kuring/admin/application/port/in/dto/RealNotificationCommand.java new file mode 100644 index 00000000..d579497f --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/port/in/dto/RealNotificationCommand.java @@ -0,0 +1,15 @@ +package com.kustacks.kuring.admin.application.port.in.dto; + +import com.kustacks.kuring.auth.context.Authentication; + +public record RealNotificationCommand( + String title, + String body, + String url, + String adminPassword, + Authentication authentication +) { + public String getStringPrincipal() { + return String.valueOf(this.authentication.getPrincipal()); + } +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/in/dto/TestNotificationCommand.java b/src/main/java/com/kustacks/kuring/admin/application/port/in/dto/TestNotificationCommand.java new file mode 100644 index 00000000..d5f38bc3 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/port/in/dto/TestNotificationCommand.java @@ -0,0 +1,8 @@ +package com.kustacks.kuring.admin.application.port.in.dto; + +public record TestNotificationCommand( + String category, + String subject, + String articleId +) { +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminCommandPort.java b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminCommandPort.java new file mode 100644 index 00000000..484a19c5 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminCommandPort.java @@ -0,0 +1,7 @@ +package com.kustacks.kuring.admin.application.port.out; + +import com.kustacks.kuring.admin.domain.Admin; + +public interface AdminCommandPort { + void save(Admin admin); +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminEventPort.java b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminEventPort.java new file mode 100644 index 00000000..3eeb8a35 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminEventPort.java @@ -0,0 +1,16 @@ +package com.kustacks.kuring.admin.application.port.out; + +import com.kustacks.kuring.message.application.service.exception.FirebaseMessageSendException; + +public interface AdminEventPort { + void sendNotificationByAdmin(String title, String body, String url); + + void sendTestNotificationByAdmin( + String articleId, + String postedDate, + String categoryName, + String subject, + String korName, + String url + ) throws FirebaseMessageSendException; +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminQueryPort.java b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminQueryPort.java new file mode 100644 index 00000000..05cc7674 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminQueryPort.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.admin.application.port.out; + +import com.kustacks.kuring.admin.domain.Admin; + +import java.util.Optional; + +public interface AdminQueryPort { + Optional findByLoginId(String loginId); +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminUserFeedbackPort.java b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminUserFeedbackPort.java new file mode 100644 index 00000000..a8da3ef3 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/port/out/AdminUserFeedbackPort.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.admin.application.port.out; + +import com.kustacks.kuring.user.application.port.out.dto.FeedbackDto; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface AdminUserFeedbackPort { + List findAllToken(); + List findAllFeedbackByPageRequest(Pageable pageable); +} diff --git a/src/main/java/com/kustacks/kuring/admin/application/service/AdminCommandService.java b/src/main/java/com/kustacks/kuring/admin/application/service/AdminCommandService.java new file mode 100644 index 00000000..df9d92b1 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/service/AdminCommandService.java @@ -0,0 +1,86 @@ +package com.kustacks.kuring.admin.application.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.kustacks.kuring.admin.application.port.in.AdminCommandUseCase; +import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand; +import com.kustacks.kuring.admin.application.port.in.dto.TestNotificationCommand; +import com.kustacks.kuring.admin.application.port.out.AdminEventPort; +import com.kustacks.kuring.admin.application.port.out.AdminUserFeedbackPort; +import com.kustacks.kuring.admin.domain.Admin; +import com.kustacks.kuring.auth.userdetails.UserDetailsServicePort; +import com.kustacks.kuring.common.annotation.UseCase; +import com.kustacks.kuring.common.properties.ServerProperties; +import com.kustacks.kuring.notice.domain.CategoryName; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static com.kustacks.kuring.message.application.service.FirebaseSubscribeService.ALL_DEVICE_SUBSCRIBED_TOPIC; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class AdminCommandService implements AdminCommandUseCase { + + private final UserDetailsServicePort userDetailsServicePort; + private final AdminUserFeedbackPort adminUserFeedbackPort; + private final AdminEventPort adminEventPort; + private final NoticeProperties noticeProperties; + private final ServerProperties serverProperties; + private final PasswordEncoder passwordEncoder; + + @Override + public void createTestNotice(TestNotificationCommand command) { + String testNoticePostedDate = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + + CategoryName testCategoryName = CategoryName.fromStringName(command.category()); + + adminEventPort.sendTestNotificationByAdmin( + command.articleId(), + testNoticePostedDate, + testCategoryName.getName(), + command.subject(), + testCategoryName.getKorName(), + CategoryName.LIBRARY.equals(testCategoryName) + ? noticeProperties.getLibraryBaseUrl() + : noticeProperties.getNormalBaseUrl() + ); + } + + @Transactional + @Override + public void createRealNoticeForAllUser(RealNotificationCommand command) { + Admin admin = (Admin) userDetailsServicePort + .loadUserByUsername(command.getStringPrincipal()); + + if (!passwordEncoder.matches(command.adminPassword(), admin.getPassword())) { + throw new IllegalArgumentException("관리자 비밀번호가 일치하지 않습니다."); + } + + adminEventPort.sendNotificationByAdmin(command.title(), command.body(), command.url()); + } + + /** + * TODO : 1회성 API - client v2 배포 후, 단 한번 모든 사용자를 공통 topic에 구독시킨 후 제거 예정 + */ + @Transactional + @Override + public void subscribeAllUserSameTopic() { + String topic = serverProperties.ifDevThenAddSuffix(ALL_DEVICE_SUBSCRIBED_TOPIC); + + FirebaseMessaging instance = FirebaseMessaging.getInstance(); + List allToken = adminUserFeedbackPort.findAllToken(); + + int size = allToken.size(); + for (int i = 0; i < size; i += 500) { + List subList = allToken.subList(i, Math.min(i + 500, size)); + instance.subscribeToTopicAsync(subList, topic); + } + } +} diff --git a/src/main/java/com/kustacks/kuring/admin/business/AdminDetailsService.java b/src/main/java/com/kustacks/kuring/admin/application/service/AdminDetailsService.java similarity index 56% rename from src/main/java/com/kustacks/kuring/admin/business/AdminDetailsService.java rename to src/main/java/com/kustacks/kuring/admin/application/service/AdminDetailsService.java index 252f66f8..c53c6381 100644 --- a/src/main/java/com/kustacks/kuring/admin/business/AdminDetailsService.java +++ b/src/main/java/com/kustacks/kuring/admin/application/service/AdminDetailsService.java @@ -1,8 +1,8 @@ -package com.kustacks.kuring.admin.business; +package com.kustacks.kuring.admin.application.service; -import com.kustacks.kuring.admin.domain.AdminRepository; +import com.kustacks.kuring.admin.application.port.out.AdminQueryPort; import com.kustacks.kuring.auth.userdetails.UserDetails; -import com.kustacks.kuring.auth.userdetails.UserDetailsService; +import com.kustacks.kuring.auth.userdetails.UserDetailsServicePort; import com.kustacks.kuring.common.exception.NotFoundException; import com.kustacks.kuring.common.exception.code.ErrorCode; import lombok.RequiredArgsConstructor; @@ -10,13 +10,13 @@ @Service @RequiredArgsConstructor -public class AdminDetailsService implements UserDetailsService { +public class AdminDetailsService implements UserDetailsServicePort { - private final AdminRepository adminRepository; + private final AdminQueryPort adminQueryPort; @Override public UserDetails loadUserByUsername(String loginId) { - return adminRepository.findByLoginId(loginId) + return adminQueryPort.findByLoginId(loginId) .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); } } diff --git a/src/main/java/com/kustacks/kuring/admin/common/AdminProperties.java b/src/main/java/com/kustacks/kuring/admin/application/service/AdminProperties.java similarity index 88% rename from src/main/java/com/kustacks/kuring/admin/common/AdminProperties.java rename to src/main/java/com/kustacks/kuring/admin/application/service/AdminProperties.java index 8b5bcb07..a54d8bc3 100644 --- a/src/main/java/com/kustacks/kuring/admin/common/AdminProperties.java +++ b/src/main/java/com/kustacks/kuring/admin/application/service/AdminProperties.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.admin.common; +package com.kustacks.kuring.admin.application.service; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/kustacks/kuring/admin/application/service/AdminQueryService.java b/src/main/java/com/kustacks/kuring/admin/application/service/AdminQueryService.java new file mode 100644 index 00000000..7d4b62f7 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/admin/application/service/AdminQueryService.java @@ -0,0 +1,34 @@ +package com.kustacks.kuring.admin.application.service; + +import com.kustacks.kuring.admin.application.port.in.AdminQueryUseCase; +import com.kustacks.kuring.admin.application.port.out.AdminUserFeedbackPort; +import com.kustacks.kuring.common.annotation.UseCase; +import com.kustacks.kuring.user.application.port.in.dto.AdminFeedbacksResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class AdminQueryService implements AdminQueryUseCase { + + private final AdminUserFeedbackPort adminUserFeedbackPort; + + @Transactional(readOnly = true) + @Override + public List lookupFeedbacks(int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size); + return adminUserFeedbackPort.findAllFeedbackByPageRequest(pageRequest) + .stream() + .map(dto -> new AdminFeedbacksResult( + dto.getContents(), + dto.getUserId(), + dto.getCreatedAt() + )) + .toList(); + } +} diff --git a/src/main/java/com/kustacks/kuring/admin/common/InitAdmin.java b/src/main/java/com/kustacks/kuring/admin/application/service/InitAdmin.java similarity index 63% rename from src/main/java/com/kustacks/kuring/admin/common/InitAdmin.java rename to src/main/java/com/kustacks/kuring/admin/application/service/InitAdmin.java index b827066c..81d41ed5 100644 --- a/src/main/java/com/kustacks/kuring/admin/common/InitAdmin.java +++ b/src/main/java/com/kustacks/kuring/admin/application/service/InitAdmin.java @@ -1,7 +1,8 @@ -package com.kustacks.kuring.admin.common; +package com.kustacks.kuring.admin.application.service; +import com.kustacks.kuring.admin.application.port.out.AdminCommandPort; +import com.kustacks.kuring.admin.application.port.out.AdminQueryPort; import com.kustacks.kuring.admin.domain.Admin; -import com.kustacks.kuring.admin.domain.AdminRepository; import com.kustacks.kuring.admin.domain.AdminRole; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.InitializingBean; @@ -14,19 +15,20 @@ @RequiredArgsConstructor public class InitAdmin implements InitializingBean { - private final AdminRepository adminRepository; + private final AdminQueryPort adminQueryPort; + private final AdminCommandPort adminCommandPort; private final PasswordEncoder passwordEncoder; private final AdminProperties adminProperties; @Override - public void afterPropertiesSet() throws Exception { - Optional optionalAdmin = adminRepository.findByLoginId(adminProperties.getId()); + public void afterPropertiesSet() { + Optional optionalAdmin = adminQueryPort.findByLoginId(adminProperties.getId()); if(optionalAdmin.isEmpty()) { String encodedPassword = passwordEncoder.encode(adminProperties.getPassword()); Admin admin = new Admin(adminProperties.getId(), encodedPassword); admin.addRole(AdminRole.ROLE_ROOT); - adminRepository.save(admin); + adminCommandPort.save(admin); } } } diff --git a/src/main/java/com/kustacks/kuring/admin/presentation/NoticeProperties.java b/src/main/java/com/kustacks/kuring/admin/application/service/NoticeProperties.java similarity index 88% rename from src/main/java/com/kustacks/kuring/admin/presentation/NoticeProperties.java rename to src/main/java/com/kustacks/kuring/admin/application/service/NoticeProperties.java index ec181386..4fcd557e 100644 --- a/src/main/java/com/kustacks/kuring/admin/presentation/NoticeProperties.java +++ b/src/main/java/com/kustacks/kuring/admin/application/service/NoticeProperties.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.admin.presentation; +package com.kustacks.kuring.admin.application.service; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/kustacks/kuring/admin/common/dto/AdminNotificationDto.java b/src/main/java/com/kustacks/kuring/admin/common/dto/AdminNotificationDto.java deleted file mode 100644 index fc91cb64..00000000 --- a/src/main/java/com/kustacks/kuring/admin/common/dto/AdminNotificationDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.kustacks.kuring.admin.common.dto; - -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class AdminNotificationDto { - - private String type; - private String title; - private String body; - private String url; - - public AdminNotificationDto(String title, String body, String url) { - this.type = "admin"; - this.title = title; - this.body = body; - this.url = url; - } - - public static AdminNotificationDto from(RealNotificationRequest request) { - return new AdminNotificationDto(request.getTitle(), request.getBody(), request.getUrl()); - } -} diff --git a/src/main/java/com/kustacks/kuring/admin/common/dto/RealNotificationRequest.java b/src/main/java/com/kustacks/kuring/admin/common/dto/RealNotificationRequest.java deleted file mode 100644 index a0caffc5..00000000 --- a/src/main/java/com/kustacks/kuring/admin/common/dto/RealNotificationRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.kustacks.kuring.admin.common.dto; - -import lombok.*; - -@Getter -@AllArgsConstructor -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class RealNotificationRequest { - - private String title; - private String body; - private String url; - private String adminPassword; -} diff --git a/src/main/java/com/kustacks/kuring/admin/common/dto/TestNotificationRequest.java b/src/main/java/com/kustacks/kuring/admin/common/dto/TestNotificationRequest.java deleted file mode 100644 index 07afe90a..00000000 --- a/src/main/java/com/kustacks/kuring/admin/common/dto/TestNotificationRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.kustacks.kuring.admin.common.dto; - -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class TestNotificationRequest { - - private String category; - private String subject; - private String articleId; -} diff --git a/src/main/java/com/kustacks/kuring/admin/domain/Admin.java b/src/main/java/com/kustacks/kuring/admin/domain/Admin.java index 3764e85b..fc9037fc 100644 --- a/src/main/java/com/kustacks/kuring/admin/domain/Admin.java +++ b/src/main/java/com/kustacks/kuring/admin/domain/Admin.java @@ -5,9 +5,14 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import javax.persistence.*; +import javax.persistence.Column; +import javax.persistence.Embedded; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; import java.util.List; -import java.util.stream.Collectors; @Entity @Table(name = "admin") diff --git a/src/main/java/com/kustacks/kuring/admin/facade/AdminCommandFacade.java b/src/main/java/com/kustacks/kuring/admin/facade/AdminCommandFacade.java deleted file mode 100644 index ac4cfb4e..00000000 --- a/src/main/java/com/kustacks/kuring/admin/facade/AdminCommandFacade.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.kustacks.kuring.admin.facade; - -import com.google.firebase.messaging.FirebaseMessaging; -import com.kustacks.kuring.admin.business.AdminDetailsService; -import com.kustacks.kuring.admin.common.dto.AdminNotificationDto; -import com.kustacks.kuring.admin.common.dto.RealNotificationRequest; -import com.kustacks.kuring.admin.common.dto.TestNotificationRequest; -import com.kustacks.kuring.admin.domain.Admin; -import com.kustacks.kuring.admin.presentation.NoticeProperties; -import com.kustacks.kuring.auth.context.Authentication; -import com.kustacks.kuring.common.dto.NoticeMessageDto; -import com.kustacks.kuring.message.firebase.FirebaseService; -import com.kustacks.kuring.message.firebase.ServerProperties; -import com.kustacks.kuring.notice.domain.CategoryName; -import com.kustacks.kuring.user.domain.UserRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.List; - -import static com.kustacks.kuring.message.firebase.FirebaseService.ALL_DEVICE_SUBSCRIBED_TOPIC; - -@Slf4j -@Service -@RequiredArgsConstructor -public class AdminCommandFacade { - - private final FirebaseService firebaseService; - private final NoticeProperties noticeProperties; - private final AdminDetailsService adminDetailsService; - private final PasswordEncoder passwordEncoder; - private final UserRepository userRepository; - private final ServerProperties serverProperties; - - public void createTestNotice(TestNotificationRequest request) { - String testNoticePostedDate = LocalDateTime.now() - .format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); - - CategoryName testCategoryName = CategoryName.fromStringName(request.getCategory()); - - firebaseService.sendTestNotification(NoticeMessageDto.builder() - .articleId(request.getArticleId()) - .postedDate(testNoticePostedDate) - .category(testCategoryName.getName()) - .subject(request.getSubject()) - .categoryKorName(testCategoryName.getKorName()) - .baseUrl(CategoryName.LIBRARY.equals(testCategoryName) ? noticeProperties.getLibraryBaseUrl() : noticeProperties.getNormalBaseUrl()) - .build()); - } - - @Transactional(readOnly = true) - public void createRealNoticeForAllUser(RealNotificationRequest request, Authentication authentication) { - Admin admin = (Admin) adminDetailsService - .loadUserByUsername(authentication.getPrincipal().toString()); - - if(!passwordEncoder.matches(request.getAdminPassword(), admin.getPassword())) { - throw new IllegalArgumentException("관리자 비밀번호가 일치하지 않습니다."); - } - - firebaseService.sendNotificationByAdmin(AdminNotificationDto.from(request)); - } - - /** - * 1회성 API : 단 한번 모든 사용자를 공통 topic에 구독시킨 후 제거 예정 - */ - @Transactional(readOnly = true) - public void subscribeAllUserSameTopic() { - String topic = serverProperties.ifDevThenAddSuffix(ALL_DEVICE_SUBSCRIBED_TOPIC); - - FirebaseMessaging instance = FirebaseMessaging.getInstance(); - List allToken = userRepository.findAllToken(); - - int size = allToken.size(); - for(int i = 0; i < size; i += 500) { - List subList = allToken.subList(i, Math.min(i + 500, size)); - instance.subscribeToTopicAsync(subList, topic); - } - } -} diff --git a/src/main/java/com/kustacks/kuring/admin/presentation/AdminCommandApiV2.java b/src/main/java/com/kustacks/kuring/admin/presentation/AdminCommandApiV2.java deleted file mode 100644 index 9e40b95b..00000000 --- a/src/main/java/com/kustacks/kuring/admin/presentation/AdminCommandApiV2.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.kustacks.kuring.admin.presentation; - -import com.kustacks.kuring.admin.common.dto.RealNotificationRequest; -import com.kustacks.kuring.admin.common.dto.TestNotificationRequest; -import com.kustacks.kuring.admin.domain.AdminRole; -import com.kustacks.kuring.admin.facade.AdminCommandFacade; -import com.kustacks.kuring.auth.authorization.AuthenticationPrincipal; -import com.kustacks.kuring.auth.context.Authentication; -import com.kustacks.kuring.auth.secured.Secured; -import com.kustacks.kuring.common.dto.BaseResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.ADMIN_REAL_NOTICE_CREATE_SUCCESS; -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.ADMIN_TEST_NOTICE_CREATE_SUCCESS; - -@Validated -@RestController -@RequiredArgsConstructor -@RequestMapping(value = "/api/v2/admin", produces = MediaType.APPLICATION_JSON_VALUE) -public class AdminCommandApiV2 { - - private final AdminCommandFacade adminCommandFacade; - - @Secured(AdminRole.ROLE_ROOT) - @PostMapping("/notices/dev") - public ResponseEntity> createTestNotice( - @RequestBody TestNotificationRequest request) - { - adminCommandFacade.createTestNotice(request); - return ResponseEntity.ok().body(new BaseResponse<>(ADMIN_TEST_NOTICE_CREATE_SUCCESS, null)); - } - - @Secured(AdminRole.ROLE_ROOT) - @PostMapping("/notices/prod") - public ResponseEntity> createRealNotice( - @RequestBody RealNotificationRequest request, - @AuthenticationPrincipal Authentication authentication) - { - adminCommandFacade.createRealNoticeForAllUser(request, authentication); - return ResponseEntity.ok().body(new BaseResponse<>(ADMIN_REAL_NOTICE_CREATE_SUCCESS, null)); - } - - @Secured(AdminRole.ROLE_ROOT) - @GetMapping("/subscribe/all") - public ResponseEntity subscribe() { - adminCommandFacade.subscribeAllUserSameTopic(); - return ResponseEntity.ok().build(); - } -} diff --git a/src/main/java/com/kustacks/kuring/auth/AuthConfig.java b/src/main/java/com/kustacks/kuring/auth/AuthConfig.java index adbcb71b..9b4b2a22 100644 --- a/src/main/java/com/kustacks/kuring/auth/AuthConfig.java +++ b/src/main/java/com/kustacks/kuring/auth/AuthConfig.java @@ -1,17 +1,18 @@ package com.kustacks.kuring.auth; import com.fasterxml.jackson.databind.ObjectMapper; -import com.kustacks.kuring.admin.business.AdminDetailsService; +import com.kustacks.kuring.admin.application.service.AdminDetailsService; import com.kustacks.kuring.auth.authorization.AuthenticationPrincipalArgumentResolver; import com.kustacks.kuring.auth.context.SecurityContextPersistenceFilter; import com.kustacks.kuring.auth.handler.*; import com.kustacks.kuring.auth.interceptor.AdminTokenAuthenticationFilter; import com.kustacks.kuring.auth.interceptor.BearerTokenAuthenticationFilter; +import com.kustacks.kuring.auth.interceptor.FirebaseTokenAuthenticationFilter; import com.kustacks.kuring.auth.interceptor.UserRegisterNonChainingFilter; import com.kustacks.kuring.auth.token.JwtTokenProvider; -import com.kustacks.kuring.message.firebase.FirebaseService; -import com.kustacks.kuring.message.firebase.ServerProperties; -import com.kustacks.kuring.user.domain.UserRepository; +import com.kustacks.kuring.message.application.port.in.FirebaseWithUserUseCase; +import com.kustacks.kuring.common.properties.ServerProperties; +import com.kustacks.kuring.user.adapter.out.persistence.UserPersistenceAdapter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -30,8 +31,8 @@ public class AuthConfig implements WebMvcConfigurer { private final JwtTokenProvider jwtTokenProvider; private final ObjectMapper objectMapper; private final ServerProperties serverProperties; - private final FirebaseService firebaseService; - private final UserRepository userRepository; + private final FirebaseWithUserUseCase firebaseService; + private final UserPersistenceAdapter userPersistenceAdapter; @Override public void addInterceptors(InterceptorRegistry registry) { @@ -43,11 +44,15 @@ adminDetailsService, passwordEncoder(), objectMapper, .addPathPatterns("/api/v2/admin/login"); registry.addInterceptor(new UserRegisterNonChainingFilter( - serverProperties, firebaseService, userRepository, objectMapper, + serverProperties, firebaseService, userPersistenceAdapter, objectMapper, userRegisterSuccessHandler(), userRegisterFailureHandler())) .addPathPatterns("/api/v2/users"); - registry.addInterceptor(new BearerTokenAuthenticationFilter(jwtTokenProvider)).addPathPatterns("/api/v2/admin/**"); + registry.addInterceptor(new BearerTokenAuthenticationFilter(jwtTokenProvider)) + .addPathPatterns("/api/v2/admin/**"); + + registry.addInterceptor(new FirebaseTokenAuthenticationFilter(firebaseService, objectMapper)) + .addPathPatterns("/api/v2/users/**"); } @Override diff --git a/src/main/java/com/kustacks/kuring/auth/interceptor/AdminTokenAuthenticationFilter.java b/src/main/java/com/kustacks/kuring/auth/interceptor/AdminTokenAuthenticationFilter.java index 996420a5..b042610d 100644 --- a/src/main/java/com/kustacks/kuring/auth/interceptor/AdminTokenAuthenticationFilter.java +++ b/src/main/java/com/kustacks/kuring/auth/interceptor/AdminTokenAuthenticationFilter.java @@ -5,7 +5,7 @@ import com.kustacks.kuring.auth.handler.AuthenticationFailureHandler; import com.kustacks.kuring.auth.handler.AuthenticationSuccessHandler; import com.kustacks.kuring.auth.token.AdminLoginTokenRequest; -import com.kustacks.kuring.auth.userdetails.UserDetailsService; +import com.kustacks.kuring.auth.userdetails.UserDetailsServicePort; import org.springframework.security.crypto.password.PasswordEncoder; import javax.servlet.http.HttpServletRequest; @@ -14,13 +14,13 @@ public class AdminTokenAuthenticationFilter extends AuthenticationNonChainingFilter { - public AdminTokenAuthenticationFilter(UserDetailsService userDetailsService, + public AdminTokenAuthenticationFilter(UserDetailsServicePort userDetailsServicePort, PasswordEncoder passwordEncoder, ObjectMapper objectMapper, AuthenticationSuccessHandler successHandler, AuthenticationFailureHandler failureHandler) { - super(userDetailsService, passwordEncoder, objectMapper, successHandler, failureHandler); + super(userDetailsServicePort, passwordEncoder, objectMapper, successHandler, failureHandler); } @Override diff --git a/src/main/java/com/kustacks/kuring/auth/interceptor/AuthenticationNonChainingFilter.java b/src/main/java/com/kustacks/kuring/auth/interceptor/AuthenticationNonChainingFilter.java index 673efe5b..e3cee429 100644 --- a/src/main/java/com/kustacks/kuring/auth/interceptor/AuthenticationNonChainingFilter.java +++ b/src/main/java/com/kustacks/kuring/auth/interceptor/AuthenticationNonChainingFilter.java @@ -7,7 +7,7 @@ import com.kustacks.kuring.auth.handler.AuthenticationFailureHandler; import com.kustacks.kuring.auth.handler.AuthenticationSuccessHandler; import com.kustacks.kuring.auth.userdetails.UserDetails; -import com.kustacks.kuring.auth.userdetails.UserDetailsService; +import com.kustacks.kuring.auth.userdetails.UserDetailsServicePort; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.servlet.HandlerInterceptor; @@ -19,7 +19,7 @@ @RequiredArgsConstructor public abstract class AuthenticationNonChainingFilter implements HandlerInterceptor { - protected final UserDetailsService userDetailsService; + protected final UserDetailsServicePort userDetailsServicePort; protected final PasswordEncoder passwordEncoder; protected final ObjectMapper objectMapper; protected final AuthenticationSuccessHandler successHandler; @@ -42,7 +42,7 @@ public Authentication authenticate(AuthenticationToken tokenRequest) { String principal = tokenRequest.getPrincipal(); String credentials = tokenRequest.getCredentials(); - UserDetails userDetails = userDetailsService.loadUserByUsername(principal); + UserDetails userDetails = userDetailsServicePort.loadUserByUsername(principal); if (userDetails == null) { throw new AuthenticationException(); } diff --git a/src/main/java/com/kustacks/kuring/auth/interceptor/FirebaseTokenAuthenticationFilter.java b/src/main/java/com/kustacks/kuring/auth/interceptor/FirebaseTokenAuthenticationFilter.java new file mode 100644 index 00000000..c4f74acf --- /dev/null +++ b/src/main/java/com/kustacks/kuring/auth/interceptor/FirebaseTokenAuthenticationFilter.java @@ -0,0 +1,57 @@ +package com.kustacks.kuring.auth.interceptor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kustacks.kuring.auth.authentication.AuthenticationException; +import com.kustacks.kuring.auth.exception.UnauthorizedException; +import com.kustacks.kuring.common.dto.ErrorResponse; +import com.kustacks.kuring.common.exception.code.ErrorCode; +import com.kustacks.kuring.message.application.port.in.FirebaseWithUserUseCase; +import com.kustacks.kuring.message.application.service.exception.FirebaseInvalidTokenException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@RequiredArgsConstructor +public class FirebaseTokenAuthenticationFilter implements HandlerInterceptor { + + private static final String FIREBASE_HEADER = "User-Token"; + private final FirebaseWithUserUseCase firebaseService; + private final ObjectMapper objectMapper; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { + try { + String authorization = request.getHeader(FIREBASE_HEADER); + + if (authorization == null) { + throw new AuthenticationException(); + } + + if (authorization.isBlank()) { + throw new UnauthorizedException(); + } + + firebaseService.validationToken(authorization); + + return true; + } catch (UnauthorizedException | FirebaseInvalidTokenException e) { + setErrorResponse(response); + return false; + } catch (AuthenticationException e) { + return true; + } + } + + private void setErrorResponse(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + String result = objectMapper.writeValueAsString(new ErrorResponse(ErrorCode.API_FB_INVALID_TOKEN)); + response.getWriter().write(result); + } +} diff --git a/src/main/java/com/kustacks/kuring/auth/interceptor/UserRegisterNonChainingFilter.java b/src/main/java/com/kustacks/kuring/auth/interceptor/UserRegisterNonChainingFilter.java index 4799b8bb..c0bb7608 100644 --- a/src/main/java/com/kustacks/kuring/auth/interceptor/UserRegisterNonChainingFilter.java +++ b/src/main/java/com/kustacks/kuring/auth/interceptor/UserRegisterNonChainingFilter.java @@ -5,10 +5,11 @@ import com.kustacks.kuring.auth.exception.RegisterException; import com.kustacks.kuring.auth.handler.AuthenticationFailureHandler; import com.kustacks.kuring.auth.handler.AuthenticationSuccessHandler; -import com.kustacks.kuring.message.firebase.FirebaseService; -import com.kustacks.kuring.message.firebase.ServerProperties; +import com.kustacks.kuring.message.application.port.in.FirebaseWithUserUseCase; +import com.kustacks.kuring.message.application.port.in.dto.UserSubscribeCommand; +import com.kustacks.kuring.common.properties.ServerProperties; +import com.kustacks.kuring.user.application.port.out.UserCommandPort; import com.kustacks.kuring.user.domain.User; -import com.kustacks.kuring.user.domain.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.web.servlet.HandlerInterceptor; @@ -17,7 +18,7 @@ import java.io.IOException; import java.util.stream.Collectors; -import static com.kustacks.kuring.message.firebase.FirebaseService.ALL_DEVICE_SUBSCRIBED_TOPIC; +import static com.kustacks.kuring.message.application.service.FirebaseSubscribeService.ALL_DEVICE_SUBSCRIBED_TOPIC; @RequiredArgsConstructor public class UserRegisterNonChainingFilter implements HandlerInterceptor { @@ -25,8 +26,8 @@ public class UserRegisterNonChainingFilter implements HandlerInterceptor { private static final String REGISTER_HTTP_METHOD = "POST"; private final ServerProperties serverProperties; - private final FirebaseService firebaseService; - private final UserRepository userRepository; + private final FirebaseWithUserUseCase firebaseService; + private final UserCommandPort userCommandPort; private final ObjectMapper objectMapper; private final AuthenticationSuccessHandler successHandler; private final AuthenticationFailureHandler failureHandler; @@ -49,8 +50,13 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons } private void register(String userFcmToken) { - userRepository.save(new User(userFcmToken)); - firebaseService.subscribe(userFcmToken, serverProperties.ifDevThenAddSuffix(ALL_DEVICE_SUBSCRIBED_TOPIC)); + userCommandPort.save(new User(userFcmToken)); + UserSubscribeCommand command = + new UserSubscribeCommand( + userFcmToken, + serverProperties.ifDevThenAddSuffix(ALL_DEVICE_SUBSCRIBED_TOPIC) + ); + firebaseService.subscribe(command); } public String convert(HttpServletRequest request) throws IOException { diff --git a/src/main/java/com/kustacks/kuring/auth/secured/SecuredAnnotationChecker.java b/src/main/java/com/kustacks/kuring/auth/secured/SecuredAnnotationChecker.java index 3a49d024..d7f56495 100644 --- a/src/main/java/com/kustacks/kuring/auth/secured/SecuredAnnotationChecker.java +++ b/src/main/java/com/kustacks/kuring/auth/secured/SecuredAnnotationChecker.java @@ -12,7 +12,6 @@ import java.lang.reflect.Method; import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; @Aspect @Component diff --git a/src/main/java/com/kustacks/kuring/auth/userdetails/UserDetailsService.java b/src/main/java/com/kustacks/kuring/auth/userdetails/UserDetailsServicePort.java similarity index 74% rename from src/main/java/com/kustacks/kuring/auth/userdetails/UserDetailsService.java rename to src/main/java/com/kustacks/kuring/auth/userdetails/UserDetailsServicePort.java index acf05e00..6b356fcc 100644 --- a/src/main/java/com/kustacks/kuring/auth/userdetails/UserDetailsService.java +++ b/src/main/java/com/kustacks/kuring/auth/userdetails/UserDetailsServicePort.java @@ -1,6 +1,6 @@ package com.kustacks.kuring.auth.userdetails; @FunctionalInterface -public interface UserDetailsService { +public interface UserDetailsServicePort { UserDetails loadUserByUsername(String email); } diff --git a/src/main/java/com/kustacks/kuring/common/annotation/PersistenceAdapter.java b/src/main/java/com/kustacks/kuring/common/annotation/PersistenceAdapter.java new file mode 100644 index 00000000..6739a60c --- /dev/null +++ b/src/main/java/com/kustacks/kuring/common/annotation/PersistenceAdapter.java @@ -0,0 +1,16 @@ +package com.kustacks.kuring.common.annotation; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Repository; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Repository +public @interface PersistenceAdapter { + + @AliasFor(annotation = Repository.class) + String value() default ""; +} diff --git a/src/main/java/com/kustacks/kuring/common/annotation/RestWebAdapter.java b/src/main/java/com/kustacks/kuring/common/annotation/RestWebAdapter.java new file mode 100644 index 00000000..bad6ae81 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/common/annotation/RestWebAdapter.java @@ -0,0 +1,22 @@ +package com.kustacks.kuring.common.annotation; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@RestController +@RequestMapping +public @interface RestWebAdapter { + + @AliasFor(annotation = RestController.class) + String value() default ""; + + @AliasFor(annotation = RequestMapping.class, attribute = "path") + String path() default ""; +} + diff --git a/src/main/java/com/kustacks/kuring/common/annotation/UseCase.java b/src/main/java/com/kustacks/kuring/common/annotation/UseCase.java new file mode 100644 index 00000000..185adf54 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/common/annotation/UseCase.java @@ -0,0 +1,16 @@ +package com.kustacks.kuring.common.annotation; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Service; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Service +public @interface UseCase { + + @AliasFor(annotation = Service.class) + String value() default ""; +} diff --git a/src/main/java/com/kustacks/kuring/worker/event/Events.java b/src/main/java/com/kustacks/kuring/common/domain/Events.java similarity index 92% rename from src/main/java/com/kustacks/kuring/worker/event/Events.java rename to src/main/java/com/kustacks/kuring/common/domain/Events.java index dab29f7f..42af63dd 100644 --- a/src/main/java/com/kustacks/kuring/worker/event/Events.java +++ b/src/main/java/com/kustacks/kuring/common/domain/Events.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.worker.event; +package com.kustacks.kuring.common.domain; import lombok.NoArgsConstructor; import org.springframework.context.ApplicationEventPublisher; diff --git a/src/main/java/com/kustacks/kuring/common/dto/ResponseDto.java b/src/main/java/com/kustacks/kuring/common/dto/ResponseDto.java deleted file mode 100644 index a918bd08..00000000 --- a/src/main/java/com/kustacks/kuring/common/dto/ResponseDto.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.kustacks.kuring.common.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class ResponseDto { - - @JsonProperty("isSuccess") - private boolean success; - - private String resultMsg; - - private int resultCode; -} diff --git a/src/main/java/com/kustacks/kuring/common/exception/handler/CommonExceptionHandler.java b/src/main/java/com/kustacks/kuring/common/exception/handler/CommonExceptionHandler.java index 325c83eb..fdcbd37f 100644 --- a/src/main/java/com/kustacks/kuring/common/exception/handler/CommonExceptionHandler.java +++ b/src/main/java/com/kustacks/kuring/common/exception/handler/CommonExceptionHandler.java @@ -1,8 +1,11 @@ package com.kustacks.kuring.common.exception.handler; import com.kustacks.kuring.common.dto.ErrorResponse; -import com.kustacks.kuring.common.exception.*; +import com.kustacks.kuring.common.exception.AdminException; +import com.kustacks.kuring.common.exception.InternalLogicException; +import com.kustacks.kuring.common.exception.NotFoundException; import com.kustacks.kuring.common.exception.code.ErrorCode; +import com.kustacks.kuring.message.application.service.exception.FirebaseSubscribeException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.HttpMediaTypeNotAcceptableException; @@ -59,6 +62,13 @@ public ResponseEntity HttpMediaTypeNotAcceptableExceptionHandler( .body(new ErrorResponse(ErrorCode.API_NOT_ACCEPTABLE)); } + @ExceptionHandler + public ResponseEntity FirebaseSubscribeExceptionHandler(FirebaseSubscribeException exception) { + log.error("[FirebaseSubscribeException] {}", exception.getMessage()); + return ResponseEntity.status(ErrorCode.API_FB_SERVER_ERROR.getHttpStatus()) + .body(new ErrorResponse(ErrorCode.API_FB_SERVER_ERROR)); + } + @ExceptionHandler public void InternalLogicExceptionHandler(InternalLogicException e) { log.error("[InternalLogicException] {}", e.getErrorCode().getMessage(), e); diff --git a/src/main/java/com/kustacks/kuring/message/firebase/ServerProperties.java b/src/main/java/com/kustacks/kuring/common/properties/ServerProperties.java similarity index 95% rename from src/main/java/com/kustacks/kuring/message/firebase/ServerProperties.java rename to src/main/java/com/kustacks/kuring/common/properties/ServerProperties.java index f9717f23..dbbf7a6b 100644 --- a/src/main/java/com/kustacks/kuring/message/firebase/ServerProperties.java +++ b/src/main/java/com/kustacks/kuring/common/properties/ServerProperties.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.message.firebase; +package com.kustacks.kuring.common.properties; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/kustacks/kuring/config/EventsConfiguration.java b/src/main/java/com/kustacks/kuring/config/EventsConfiguration.java index 887f85fa..94f6ffaf 100644 --- a/src/main/java/com/kustacks/kuring/config/EventsConfiguration.java +++ b/src/main/java/com/kustacks/kuring/config/EventsConfiguration.java @@ -1,6 +1,6 @@ package com.kustacks.kuring.config; -import com.kustacks.kuring.worker.event.Events; +import com.kustacks.kuring.common.domain.Events; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; diff --git a/src/main/java/com/kustacks/kuring/config/FirebaseConfig.java b/src/main/java/com/kustacks/kuring/config/FirebaseConfig.java index dbc7ec19..127b580f 100644 --- a/src/main/java/com/kustacks/kuring/config/FirebaseConfig.java +++ b/src/main/java/com/kustacks/kuring/config/FirebaseConfig.java @@ -3,6 +3,7 @@ import com.google.auth.oauth2.GoogleCredentials; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; +import com.google.firebase.auth.FirebaseAuth; import com.google.firebase.messaging.FirebaseMessaging; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -31,4 +32,9 @@ FirebaseApp firebaseApp(@Value("${firebase.file-path}") String filePath) throws return FirebaseApp.getInstance(); } + + @Bean + FirebaseAuth firebaseAuth(FirebaseApp firebaseApp) { + return FirebaseAuth.getInstance(firebaseApp); + } } diff --git a/src/main/java/com/kustacks/kuring/config/HttpSessionConfig.java b/src/main/java/com/kustacks/kuring/config/HttpSessionConfig.java deleted file mode 100644 index eb4e0dfd..00000000 --- a/src/main/java/com/kustacks/kuring/config/HttpSessionConfig.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.kustacks.kuring.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.session.MapSessionRepository; -import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession; -import org.springframework.session.web.http.CookieSerializer; -import org.springframework.session.web.http.DefaultCookieSerializer; - -import java.util.concurrent.ConcurrentHashMap; - -@EnableSpringHttpSession -public class HttpSessionConfig { - - @Bean - public MapSessionRepository sessionRepository() { - return new MapSessionRepository(new ConcurrentHashMap<>()); - } - - @Bean - public CookieSerializer cookieSerializer() { - DefaultCookieSerializer serializer = new DefaultCookieSerializer(); - serializer.setCookieName("KURING"); - serializer.setCookiePath("/admin"); - serializer.setCookieMaxAge(60 * 120); - return serializer; - } -} diff --git a/src/main/java/com/kustacks/kuring/config/SwaggerConfiguration.java b/src/main/java/com/kustacks/kuring/config/SwaggerConfiguration.java new file mode 100644 index 00000000..33ad0dbe --- /dev/null +++ b/src/main/java/com/kustacks/kuring/config/SwaggerConfiguration.java @@ -0,0 +1,52 @@ +package com.kustacks.kuring.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.security.SecurityScheme.In; +import io.swagger.v3.oas.models.security.SecurityScheme.Type; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; + +@Configuration +public class SwaggerConfiguration { + + private static final String FIREBASE_HEADER = "User-Token"; + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .components(new Components() + .addSecuritySchemes("JWT", JwtAuth()) + .addSecuritySchemes("User-Token", firebaseAuth()) + ).info(getInfo()); + } + + private static Info getInfo() { + return new Info() + .title("Kuring Backend API Docs") + .description("Kuring의 Swagger API 문서입니다.") + .version("2.5.0") + .contact(new Contact().name("Kuring Contact") + .url("https://kuring.notion.site/kuring/a69fdf7ff06848c2aedef1fdcf13ca57")); + } + + private static SecurityScheme firebaseAuth() { + return new SecurityScheme() + .type(Type.APIKEY) + .in(In.HEADER) + .name(FIREBASE_HEADER); + } + + private static SecurityScheme JwtAuth() { + return new SecurityScheme() + .type(Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(In.HEADER) + .name(HttpHeaders.AUTHORIZATION); + } +} diff --git a/src/main/java/com/kustacks/kuring/config/WorkerThreadConfig.java b/src/main/java/com/kustacks/kuring/config/WorkerThreadConfig.java index eee51fbd..f3decff8 100644 --- a/src/main/java/com/kustacks/kuring/config/WorkerThreadConfig.java +++ b/src/main/java/com/kustacks/kuring/config/WorkerThreadConfig.java @@ -1,7 +1,9 @@ package com.kustacks.kuring.config; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @@ -9,7 +11,7 @@ @EnableAsync @Configuration -public class WorkerThreadConfig { +public class WorkerThreadConfig implements AsyncConfigurer { @Bean public ThreadPoolTaskExecutor noticeUpdaterThreadTaskExecutor() { @@ -24,4 +26,9 @@ public ThreadPoolTaskExecutor noticeUpdaterThreadTaskExecutor() { taskExecutor.initialize(); return taskExecutor; } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return AsyncConfigurer.super.getAsyncUncaughtExceptionHandler(); + } } diff --git a/src/main/java/com/kustacks/kuring/message/adapter/in/event/MessageAdminEventListener.java b/src/main/java/com/kustacks/kuring/message/adapter/in/event/MessageAdminEventListener.java new file mode 100644 index 00000000..6f502bbd --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/adapter/in/event/MessageAdminEventListener.java @@ -0,0 +1,32 @@ +package com.kustacks.kuring.message.adapter.in.event; + +import com.kustacks.kuring.message.adapter.in.event.dto.AdminNotificationEvent; +import com.kustacks.kuring.message.adapter.in.event.dto.AdminTestNotificationEvent; +import com.kustacks.kuring.message.application.port.in.FirebaseWithAdminUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MessageAdminEventListener { + + private final FirebaseWithAdminUseCase firebaseWithAdminUseCase; + + @Async + @EventListener + public void sendNotificationEvent( + AdminNotificationEvent event + ) { + firebaseWithAdminUseCase.sendNotificationByAdmin(event.toCommand()); + } + + @Async + @EventListener + public void sendTestNotificationEvent( + AdminTestNotificationEvent event + ) { + firebaseWithAdminUseCase.sendTestNotificationByAdmin(event.toCommand()); + } +} diff --git a/src/main/java/com/kustacks/kuring/message/adapter/in/event/MessageUserEventListener.java b/src/main/java/com/kustacks/kuring/message/adapter/in/event/MessageUserEventListener.java new file mode 100644 index 00000000..aee659b9 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/adapter/in/event/MessageUserEventListener.java @@ -0,0 +1,33 @@ +package com.kustacks.kuring.message.adapter.in.event; + +import com.kustacks.kuring.message.adapter.in.event.dto.UserSubscribeEvent; +import com.kustacks.kuring.message.adapter.in.event.dto.UserUnsubscribeEvent; +import com.kustacks.kuring.message.application.port.in.FirebaseWithUserUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class MessageUserEventListener { + + private final FirebaseWithUserUseCase firebaseWithUserUseCase; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void subscribeEvent( + UserSubscribeEvent event + ) { + firebaseWithUserUseCase.subscribe(event.toCommand()); + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void unSubscribeEvent( + UserUnsubscribeEvent event + ) { + firebaseWithUserUseCase.unsubscribe(event.toCommand()); + } +} diff --git a/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/AdminNotificationEvent.java b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/AdminNotificationEvent.java new file mode 100644 index 00000000..7a1f72fd --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/AdminNotificationEvent.java @@ -0,0 +1,18 @@ +package com.kustacks.kuring.message.adapter.in.event.dto; + +import com.kustacks.kuring.message.application.port.in.dto.AdminNotificationCommand; + +public record AdminNotificationEvent( + String type, + String title, + String body, + String url +) { + public AdminNotificationEvent(String title, String body, String url) { + this("admin", title, body, url); + } + + public AdminNotificationCommand toCommand() { + return new AdminNotificationCommand(title, body, url); + } +} diff --git a/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/AdminTestNotificationEvent.java b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/AdminTestNotificationEvent.java new file mode 100644 index 00000000..60ba7a4a --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/AdminTestNotificationEvent.java @@ -0,0 +1,26 @@ +package com.kustacks.kuring.message.adapter.in.event.dto; + +import com.kustacks.kuring.message.application.port.in.dto.AdminTestNotificationCommand; +import lombok.Builder; + +@Builder +public record AdminTestNotificationEvent( + String type, + String articleId, + String postedDate, + String subject, + String category, + String categoryKorName, + String baseUrl +) { + public AdminTestNotificationCommand toCommand() { + return new AdminTestNotificationCommand( + articleId, + postedDate, + category, + subject, + categoryKorName, + baseUrl + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/UserSubscribeEvent.java b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/UserSubscribeEvent.java new file mode 100644 index 00000000..6b2579bb --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/UserSubscribeEvent.java @@ -0,0 +1,12 @@ +package com.kustacks.kuring.message.adapter.in.event.dto; + +import com.kustacks.kuring.message.application.port.in.dto.UserSubscribeCommand; + +public record UserSubscribeEvent( + String token, + String topic +) { + public UserSubscribeCommand toCommand() { + return new UserSubscribeCommand(token, topic); + } +} diff --git a/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/UserTokenValidationEvent.java b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/UserTokenValidationEvent.java new file mode 100644 index 00000000..97341039 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/UserTokenValidationEvent.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.message.adapter.in.event.dto; + +import com.kustacks.kuring.message.application.port.in.dto.UserTokenValidationCommand; + +public record UserTokenValidationEvent( + String token +) { + public UserTokenValidationCommand toCommand() { + return new UserTokenValidationCommand(token); + } +} diff --git a/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/UserUnsubscribeEvent.java b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/UserUnsubscribeEvent.java new file mode 100644 index 00000000..ed14aa56 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/adapter/in/event/dto/UserUnsubscribeEvent.java @@ -0,0 +1,12 @@ +package com.kustacks.kuring.message.adapter.in.event.dto; + +import com.kustacks.kuring.message.application.port.in.dto.UserUnsubscribeCommand; + +public record UserUnsubscribeEvent( + String token, + String topic +) { + public UserUnsubscribeCommand toCommand() { + return new UserUnsubscribeCommand(token, topic); + } +} diff --git a/src/main/java/com/kustacks/kuring/message/firebase/exception/handler/FirebaseExceptionHandler.java b/src/main/java/com/kustacks/kuring/message/adapter/out/exception/FirebaseExceptionHandler.java similarity index 82% rename from src/main/java/com/kustacks/kuring/message/firebase/exception/handler/FirebaseExceptionHandler.java rename to src/main/java/com/kustacks/kuring/message/adapter/out/exception/FirebaseExceptionHandler.java index 63fb0308..3b93c933 100644 --- a/src/main/java/com/kustacks/kuring/message/firebase/exception/handler/FirebaseExceptionHandler.java +++ b/src/main/java/com/kustacks/kuring/message/adapter/out/exception/FirebaseExceptionHandler.java @@ -1,7 +1,7 @@ -package com.kustacks.kuring.message.firebase.exception.handler; +package com.kustacks.kuring.message.adapter.out.exception; import com.kustacks.kuring.common.dto.ErrorResponse; -import com.kustacks.kuring.message.firebase.exception.FirebaseBusinessException; +import com.kustacks.kuring.message.application.service.exception.FirebaseBusinessException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; diff --git a/src/main/java/com/kustacks/kuring/message/adapter/out/firebase/FirebaseAdapter.java b/src/main/java/com/kustacks/kuring/message/adapter/out/firebase/FirebaseAdapter.java new file mode 100644 index 00000000..457f2152 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/adapter/out/firebase/FirebaseAdapter.java @@ -0,0 +1,50 @@ +package com.kustacks.kuring.message.adapter.out.firebase; + +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.TopicManagementResponse; +import com.kustacks.kuring.message.application.port.out.FirebaseAuthPort; +import com.kustacks.kuring.message.application.port.out.FirebaseMessagingPort; +import com.kustacks.kuring.message.application.port.out.FirebaseSubscribePort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class FirebaseAdapter implements FirebaseSubscribePort, FirebaseAuthPort, FirebaseMessagingPort { + + private final FirebaseMessaging firebaseMessaging; + private final FirebaseAuth firebaseAuth; + + @Override + public FirebaseToken verifyIdToken(String idToken) throws FirebaseAuthException { + return firebaseAuth.verifyIdToken(idToken); + } + + @Override + public TopicManagementResponse subscribeToTopic( + List tokens, + String topic + ) throws FirebaseMessagingException { + return firebaseMessaging.subscribeToTopic(tokens, topic); + } + + @Override + public TopicManagementResponse unsubscribeFromTopic( + List tokens, + String topic + ) throws FirebaseMessagingException { + return firebaseMessaging.unsubscribeFromTopic(tokens, topic); + } + + @Override + public String send(Message message) throws FirebaseMessagingException { + return firebaseMessaging.send(message); + } +} diff --git a/src/main/java/com/kustacks/kuring/message/application/port/in/FirebaseWithAdminUseCase.java b/src/main/java/com/kustacks/kuring/message/application/port/in/FirebaseWithAdminUseCase.java new file mode 100644 index 00000000..76f9ed30 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/application/port/in/FirebaseWithAdminUseCase.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.message.application.port.in; + +import com.kustacks.kuring.message.application.port.in.dto.AdminNotificationCommand; +import com.kustacks.kuring.message.application.port.in.dto.AdminTestNotificationCommand; + +public interface FirebaseWithAdminUseCase { + void sendNotificationByAdmin(AdminNotificationCommand command); + void sendTestNotificationByAdmin(AdminTestNotificationCommand command); +} diff --git a/src/main/java/com/kustacks/kuring/message/application/port/in/FirebaseWithUserUseCase.java b/src/main/java/com/kustacks/kuring/message/application/port/in/FirebaseWithUserUseCase.java new file mode 100644 index 00000000..d42be43d --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/application/port/in/FirebaseWithUserUseCase.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.message.application.port.in; + +import com.kustacks.kuring.message.application.port.in.dto.UserSubscribeCommand; +import com.kustacks.kuring.message.application.port.in.dto.UserUnsubscribeCommand; +import com.kustacks.kuring.message.application.service.exception.FirebaseInvalidTokenException; +import com.kustacks.kuring.message.application.service.exception.FirebaseSubscribeException; +import com.kustacks.kuring.message.application.service.exception.FirebaseUnSubscribeException; + +public interface FirebaseWithUserUseCase { + void validationToken(String token) throws FirebaseInvalidTokenException; + void subscribe(UserSubscribeCommand command) throws FirebaseSubscribeException; + void unsubscribe(UserUnsubscribeCommand command) throws FirebaseUnSubscribeException; +} diff --git a/src/main/java/com/kustacks/kuring/message/application/port/in/dto/AdminNotificationCommand.java b/src/main/java/com/kustacks/kuring/message/application/port/in/dto/AdminNotificationCommand.java new file mode 100644 index 00000000..bd8590a3 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/application/port/in/dto/AdminNotificationCommand.java @@ -0,0 +1,12 @@ +package com.kustacks.kuring.message.application.port.in.dto; + +public record AdminNotificationCommand( + String type, + String title, + String body, + String url +) { + public AdminNotificationCommand(String title, String body, String url) { + this("admin", title, body, url); + } +} diff --git a/src/main/java/com/kustacks/kuring/message/application/port/in/dto/AdminTestNotificationCommand.java b/src/main/java/com/kustacks/kuring/message/application/port/in/dto/AdminTestNotificationCommand.java new file mode 100644 index 00000000..e19f1ffc --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/application/port/in/dto/AdminTestNotificationCommand.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.message.application.port.in.dto; + +public record AdminTestNotificationCommand( + String articleId, + String postedDate, + String categoryName, + String subject, + String korName, + String url +) { +} diff --git a/src/main/java/com/kustacks/kuring/message/application/port/in/dto/UserSubscribeCommand.java b/src/main/java/com/kustacks/kuring/message/application/port/in/dto/UserSubscribeCommand.java new file mode 100644 index 00000000..034197bb --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/application/port/in/dto/UserSubscribeCommand.java @@ -0,0 +1,7 @@ +package com.kustacks.kuring.message.application.port.in.dto; + +public record UserSubscribeCommand( + String token, + String topic +) { +} diff --git a/src/main/java/com/kustacks/kuring/message/application/port/in/dto/UserTokenValidationCommand.java b/src/main/java/com/kustacks/kuring/message/application/port/in/dto/UserTokenValidationCommand.java new file mode 100644 index 00000000..f9fb83ec --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/application/port/in/dto/UserTokenValidationCommand.java @@ -0,0 +1,6 @@ +package com.kustacks.kuring.message.application.port.in.dto; + +public record UserTokenValidationCommand( + String token +) { +} diff --git a/src/main/java/com/kustacks/kuring/message/application/port/in/dto/UserUnsubscribeCommand.java b/src/main/java/com/kustacks/kuring/message/application/port/in/dto/UserUnsubscribeCommand.java new file mode 100644 index 00000000..a8184f38 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/application/port/in/dto/UserUnsubscribeCommand.java @@ -0,0 +1,7 @@ +package com.kustacks.kuring.message.application.port.in.dto; + +public record UserUnsubscribeCommand( + String token, + String topic +) { +} diff --git a/src/main/java/com/kustacks/kuring/message/application/port/out/FirebaseAuthPort.java b/src/main/java/com/kustacks/kuring/message/application/port/out/FirebaseAuthPort.java new file mode 100644 index 00000000..158aaa73 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/application/port/out/FirebaseAuthPort.java @@ -0,0 +1,8 @@ +package com.kustacks.kuring.message.application.port.out; + +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; + +public interface FirebaseAuthPort { + FirebaseToken verifyIdToken(String idToken) throws FirebaseAuthException; +} diff --git a/src/main/java/com/kustacks/kuring/message/application/port/out/FirebaseMessagingPort.java b/src/main/java/com/kustacks/kuring/message/application/port/out/FirebaseMessagingPort.java new file mode 100644 index 00000000..5599df91 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/application/port/out/FirebaseMessagingPort.java @@ -0,0 +1,8 @@ +package com.kustacks.kuring.message.application.port.out; + +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; + +public interface FirebaseMessagingPort { + String send(Message message) throws FirebaseMessagingException; +} diff --git a/src/main/java/com/kustacks/kuring/message/application/port/out/FirebaseSubscribePort.java b/src/main/java/com/kustacks/kuring/message/application/port/out/FirebaseSubscribePort.java new file mode 100644 index 00000000..3b499d13 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/message/application/port/out/FirebaseSubscribePort.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.message.application.port.out; + +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.TopicManagementResponse; + +import java.util.List; + +public interface FirebaseSubscribePort { + TopicManagementResponse subscribeToTopic(List tokens, String topic) throws FirebaseMessagingException; + TopicManagementResponse unsubscribeFromTopic(List tokens, String topic) throws FirebaseMessagingException; +} diff --git a/src/main/java/com/kustacks/kuring/common/dto/NoticeMessageDto.java b/src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java similarity index 97% rename from src/main/java/com/kustacks/kuring/common/dto/NoticeMessageDto.java rename to src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java index 89668245..dec19e26 100644 --- a/src/main/java/com/kustacks/kuring/common/dto/NoticeMessageDto.java +++ b/src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.common.dto; +package com.kustacks.kuring.message.application.port.out.dto; import com.kustacks.kuring.notice.domain.DepartmentNotice; import com.kustacks.kuring.notice.domain.Notice; diff --git a/src/main/java/com/kustacks/kuring/message/firebase/FirebaseService.java b/src/main/java/com/kustacks/kuring/message/application/service/FirebaseNotificationService.java similarity index 59% rename from src/main/java/com/kustacks/kuring/message/firebase/FirebaseService.java rename to src/main/java/com/kustacks/kuring/message/application/service/FirebaseNotificationService.java index 4bf79493..df6cb18a 100644 --- a/src/main/java/com/kustacks/kuring/message/firebase/FirebaseService.java +++ b/src/main/java/com/kustacks/kuring/message/application/service/FirebaseNotificationService.java @@ -1,107 +1,68 @@ -package com.kustacks.kuring.message.firebase; +package com.kustacks.kuring.message.application.service; import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.firebase.messaging.*; -import com.kustacks.kuring.admin.common.dto.AdminNotificationDto; -import com.kustacks.kuring.common.dto.NoticeMessageDto; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import com.kustacks.kuring.common.annotation.UseCase; import com.kustacks.kuring.common.exception.InternalLogicException; import com.kustacks.kuring.common.exception.code.ErrorCode; -import com.kustacks.kuring.message.firebase.exception.FirebaseInvalidTokenException; -import com.kustacks.kuring.message.firebase.exception.FirebaseMessageSendException; -import com.kustacks.kuring.message.firebase.exception.FirebaseSubscribeException; -import com.kustacks.kuring.message.firebase.exception.FirebaseUnSubscribeException; +import com.kustacks.kuring.common.properties.ServerProperties; +import com.kustacks.kuring.message.application.port.in.FirebaseWithAdminUseCase; +import com.kustacks.kuring.message.application.port.in.dto.AdminNotificationCommand; +import com.kustacks.kuring.message.application.port.in.dto.AdminTestNotificationCommand; +import com.kustacks.kuring.message.application.port.out.FirebaseMessagingPort; +import com.kustacks.kuring.message.application.port.out.dto.NoticeMessageDto; +import com.kustacks.kuring.message.application.service.exception.FirebaseMessageSendException; import com.kustacks.kuring.notice.domain.Notice; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; +import java.util.function.UnaryOperator; + +import static com.kustacks.kuring.message.application.service.FirebaseSubscribeService.ALL_DEVICE_SUBSCRIBED_TOPIC; @Slf4j -@Service +@UseCase @RequiredArgsConstructor -public class FirebaseService { +public class FirebaseNotificationService implements FirebaseWithAdminUseCase { private static final String NOTIFICATION_TITLE = "새로운 공지가 왔어요!"; - public static final String ALL_DEVICE_SUBSCRIBED_TOPIC = "allDevice"; - private final FirebaseMessaging firebaseMessaging; - private final ObjectMapper objectMapper; + private final FirebaseMessagingPort firebaseMessagingPort; private final ServerProperties serverProperties; + private final ObjectMapper objectMapper; - public void validationToken(String token) throws FirebaseInvalidTokenException { - try { - Message message = Message.builder().setToken(token).build(); - - firebaseMessaging.send(message); - } catch (FirebaseMessagingException exception) { - throw new FirebaseInvalidTokenException(); - } - } - - public void subscribe(String token, String topic) throws FirebaseSubscribeException { - try { - TopicManagementResponse response = firebaseMessaging - .subscribeToTopic(List.of(token), serverProperties.ifDevThenAddSuffix(topic)); - - if (response.getFailureCount() > 0) { - throw new FirebaseSubscribeException(); - } - } catch (FirebaseMessagingException | FirebaseSubscribeException exception) { - throw new FirebaseSubscribeException(); - } - } - - public void unsubscribe(String token, String topic) throws FirebaseUnSubscribeException { - try { - TopicManagementResponse response = firebaseMessaging - .unsubscribeFromTopic(List.of(token), serverProperties.ifDevThenAddSuffix(topic)); + @Override + public void sendTestNotificationByAdmin(AdminTestNotificationCommand command) { + NoticeMessageDto messageDto = NoticeMessageDto.builder() + .articleId(command.articleId()) + .postedDate(command.postedDate()) + .category(command.categoryName()) + .subject(command.subject()) + .categoryKorName(command.korName()) + .baseUrl(command.url()) + .build(); - if (response.getFailureCount() > 0) { - throw new FirebaseUnSubscribeException(); - } - } catch (FirebaseMessagingException | FirebaseUnSubscribeException exception) { - throw new FirebaseUnSubscribeException(); - } - } - - /** - * Firebase message에는 두 가지 paylaad가 존재한다. - * 1. notification - * 2. data - *

- * notification을 Message로 만들어 보내면 여기서 설정한 title, body가 직접 앱 noti로 뜬다. - * data로 Message를 만들어 보내면 이것을 앱 클라이언트(Andriod)가 받아서, 가공한 뒤 푸쉬 알람으로 만들 수 있다. - *

- * 따라서 여기선 putData를 사용하여 보내고, 클라이언트가 푸쉬 알람을 만들어 띄운다. - * - * @param messageDto - * @throws FirebaseMessageSendException - */ - public void sendNotification(NoticeMessageDto messageDto) throws FirebaseMessageSendException { - sendBaseNotification(messageDto, serverProperties::ifDevThenAddSuffix); - } - - public void sendTestNotification(NoticeMessageDto messageDto) throws FirebaseMessageSendException { sendBaseNotification(messageDto, serverProperties::addDevSuffix); } - public void sendNotificationByAdmin(AdminNotificationDto messageDto) { + @Override + public void sendNotificationByAdmin(AdminNotificationCommand command) { try { Message newMessage = Message.builder() .setNotification(Notification .builder() - .setTitle(messageDto.getTitle()) - .setBody(messageDto.getBody()) + .setTitle(command.title()) + .setBody(command.body()) .build()) - .putAllData(objectMapper.convertValue(messageDto, Map.class)) + .putAllData(objectMapper.convertValue(command, Map.class)) .setTopic(serverProperties.ifDevThenAddSuffix(ALL_DEVICE_SUBSCRIBED_TOPIC)) .build(); - firebaseMessaging.send(newMessage); + firebaseMessagingPort.send(newMessage); } catch (FirebaseMessagingException exception) { throw new FirebaseMessageSendException(); } @@ -126,7 +87,24 @@ public void sendNotificationList(List noticeList) { } } - private void sendBaseNotification(NoticeMessageDto messageDto, Function suffixUtil) throws FirebaseMessageSendException { + /** + * Firebase message에는 두 가지 paylaad가 존재한다. + * 1. notification + * 2. data + *

+ * notification을 Message로 만들어 보내면 여기서 설정한 title, body가 직접 앱 noti로 뜬다. + * data로 Message를 만들어 보내면 이것을 앱 클라이언트(Andriod)가 받아서, 가공한 뒤 푸쉬 알람으로 만들 수 있다. + *

+ * 따라서 여기선 putData를 사용하여 보내고, 클라이언트가 푸쉬 알람을 만들어 띄운다. + * + * @param messageDto + * @throws FirebaseMessageSendException + */ + private void sendNotification(NoticeMessageDto messageDto) throws FirebaseMessageSendException { + sendBaseNotification(messageDto, serverProperties::ifDevThenAddSuffix); + } + + private void sendBaseNotification(NoticeMessageDto messageDto, UnaryOperator suffixUtil) throws FirebaseMessageSendException { try { Message newMessage = Message.builder() .setNotification(Notification @@ -138,7 +116,7 @@ private void sendBaseNotification(NoticeMessageDto messageDto, Function 0) { + throw new FirebaseSubscribeException(); + } + } catch (FirebaseMessagingException | FirebaseSubscribeException exception) { + throw new FirebaseSubscribeException(); + } + } + + @Override + public void unsubscribe(UserUnsubscribeCommand command) throws FirebaseUnSubscribeException { + try { + TopicManagementResponse response = firebaseSubscribePort + .unsubscribeFromTopic(List.of(command.token()), serverProperties.ifDevThenAddSuffix(command.topic())); + + if (response.getFailureCount() > 0) { + throw new FirebaseUnSubscribeException(); + } + } catch (FirebaseMessagingException | FirebaseUnSubscribeException exception) { + throw new FirebaseUnSubscribeException(); + } + } +} diff --git a/src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseBusinessException.java b/src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseBusinessException.java similarity index 82% rename from src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseBusinessException.java rename to src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseBusinessException.java index fa623cc7..9748bb7e 100644 --- a/src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseBusinessException.java +++ b/src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseBusinessException.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.message.firebase.exception; +package com.kustacks.kuring.message.application.service.exception; import com.kustacks.kuring.common.exception.BusinessException; import com.kustacks.kuring.common.exception.code.ErrorCode; diff --git a/src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseInvalidTokenException.java b/src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseInvalidTokenException.java similarity index 78% rename from src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseInvalidTokenException.java rename to src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseInvalidTokenException.java index b50d79e1..29946da3 100644 --- a/src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseInvalidTokenException.java +++ b/src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseInvalidTokenException.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.message.firebase.exception; +package com.kustacks.kuring.message.application.service.exception; import com.kustacks.kuring.common.exception.code.ErrorCode; diff --git a/src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseMessageSendException.java b/src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseMessageSendException.java similarity index 77% rename from src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseMessageSendException.java rename to src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseMessageSendException.java index 6af9fe76..32ff5718 100644 --- a/src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseMessageSendException.java +++ b/src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseMessageSendException.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.message.firebase.exception; +package com.kustacks.kuring.message.application.service.exception; import com.kustacks.kuring.common.exception.code.ErrorCode; diff --git a/src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseSubscribeException.java b/src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseSubscribeException.java similarity index 77% rename from src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseSubscribeException.java rename to src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseSubscribeException.java index b12ff83c..4ecccc8f 100644 --- a/src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseSubscribeException.java +++ b/src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseSubscribeException.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.message.firebase.exception; +package com.kustacks.kuring.message.application.service.exception; import com.kustacks.kuring.common.exception.code.ErrorCode; diff --git a/src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseUnSubscribeException.java b/src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseUnSubscribeException.java similarity index 78% rename from src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseUnSubscribeException.java rename to src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseUnSubscribeException.java index 88450b4e..885865ad 100644 --- a/src/main/java/com/kustacks/kuring/message/firebase/exception/FirebaseUnSubscribeException.java +++ b/src/main/java/com/kustacks/kuring/message/application/service/exception/FirebaseUnSubscribeException.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.message.firebase.exception; +package com.kustacks.kuring.message.application.service.exception; import com.kustacks.kuring.common.exception.code.ErrorCode; diff --git a/src/main/java/com/kustacks/kuring/notice/adapter/in/web/NoticeQueryApiV2.java b/src/main/java/com/kustacks/kuring/notice/adapter/in/web/NoticeQueryApiV2.java new file mode 100644 index 00000000..a00f0223 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/adapter/in/web/NoticeQueryApiV2.java @@ -0,0 +1,86 @@ +package com.kustacks.kuring.notice.adapter.in.web; + +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CATEGORY_SEARCH_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.DEPARTMENTS_SEARCH_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.NOTICE_SEARCH_SUCCESS; + +import com.kustacks.kuring.common.annotation.RestWebAdapter; +import com.kustacks.kuring.common.dto.BaseResponse; +import com.kustacks.kuring.notice.adapter.in.web.dto.NoticeCategoryNameResponse; +import com.kustacks.kuring.notice.adapter.in.web.dto.NoticeContentSearchResponse; +import com.kustacks.kuring.notice.adapter.in.web.dto.NoticeDepartmentNameResponse; +import com.kustacks.kuring.notice.adapter.in.web.dto.NoticeRangeLookupResponse; +import com.kustacks.kuring.notice.application.port.in.NoticeQueryUseCase; +import com.kustacks.kuring.notice.application.port.in.dto.NoticeContentSearchResult; +import com.kustacks.kuring.notice.application.port.in.dto.NoticeRangeLookupCommand; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Notice-Query", description = "공지 정보 조회") +@Validated +@RequiredArgsConstructor +@RestWebAdapter(path = "/api/v2/notices") +public class NoticeQueryApiV2 { + + private final NoticeQueryUseCase noticeQueryUseCase; + + @Operation(summary = "공지 조회", description = "일반 공지 조회와 학과별 공지 조회를 지원합니다") + @GetMapping + public ResponseEntity>> getNotices( + @Parameter(description = "공지 타입") @RequestParam(name = "type") String type, + @Parameter(description = "학과는 hostPrefix 로 전달") @RequestParam(name = "department", required = false) String department, + @Parameter(description = "중요도") @RequestParam(name = "important", defaultValue = "false") Boolean important, + @Parameter(description = "페이지") @RequestParam(name = "page") @Min(0) int page, + @Parameter(description = "단일 페이지의 사이즈, 1 ~ 30까지 허용") @RequestParam(name = "size") @Min(1) @Max(30) int size + ) { + NoticeRangeLookupCommand command = new NoticeRangeLookupCommand(type, department, important, page, size); + List searchResults = noticeQueryUseCase.getNotices(command) + .stream() + .map(NoticeRangeLookupResponse::from) + .toList(); + + return ResponseEntity.ok().body(new BaseResponse<>(NOTICE_SEARCH_SUCCESS, searchResults)); + } + + @Operation(summary = "키워드 공지 조회", description = "일반 공지 조회와 학과별 공지 검색을 지원하며, 2글자 이상의 키워드를 입력하길 권장합니다") + @GetMapping("/search") + public ResponseEntity> searchNotice( + @NotBlank @RequestParam String content + ) { + List response = noticeQueryUseCase.findAllNoticeByContent(content); + NoticeContentSearchResponse noticeContentSearchResponse = new NoticeContentSearchResponse(response); + return ResponseEntity.ok().body(new BaseResponse<>(NOTICE_SEARCH_SUCCESS, noticeContentSearchResponse)); + } + + @Operation(summary = "일반 공지 카테고리", description = "서버가 지원하는 일반공지 카테고리 목록을 조회합니다") + @GetMapping("/categories") + public ResponseEntity>> getSupportedCategories() { + List categoryNames = noticeQueryUseCase.lookupSupportedCategories() + .stream() + .map(NoticeCategoryNameResponse::from) + .toList(); + + return ResponseEntity.ok().body(new BaseResponse<>(CATEGORY_SEARCH_SUCCESS, categoryNames)); + } + + @Operation(summary = "학과별 공지 카테고리", description = "서버가 지원하는 학과별 공지 카테고리 목록을 조회합니다") + @GetMapping("/departments") + public ResponseEntity>> getSupportedDepartments() { + List departmentNames = noticeQueryUseCase.lookupSupportedDepartments() + .stream() + .map(NoticeDepartmentNameResponse::from) + .toList(); + + return ResponseEntity.ok().body(new BaseResponse<>(DEPARTMENTS_SEARCH_SUCCESS, departmentNames)); + } +} diff --git a/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeCategoryNameResponse.java b/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeCategoryNameResponse.java new file mode 100644 index 00000000..9b244f19 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeCategoryNameResponse.java @@ -0,0 +1,17 @@ +package com.kustacks.kuring.notice.adapter.in.web.dto; + +import com.kustacks.kuring.notice.application.port.in.dto.NoticeCategoryNameResult; + +public record NoticeCategoryNameResponse( + String name, + String hostPrefix, + String korName +) { + public static NoticeCategoryNameResponse from(NoticeCategoryNameResult result) { + return new NoticeCategoryNameResponse( + result.name(), + result.hostPrefix(), + result.korName() + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeContentSearchResponse.java b/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeContentSearchResponse.java new file mode 100644 index 00000000..6556b6c4 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeContentSearchResponse.java @@ -0,0 +1,10 @@ +package com.kustacks.kuring.notice.adapter.in.web.dto; + +import com.kustacks.kuring.notice.application.port.in.dto.NoticeContentSearchResult; + +import java.util.List; + +public record NoticeContentSearchResponse( + List noticeList +) { +} diff --git a/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeDepartmentNameResponse.java b/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeDepartmentNameResponse.java new file mode 100644 index 00000000..05cd4e6d --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeDepartmentNameResponse.java @@ -0,0 +1,17 @@ +package com.kustacks.kuring.notice.adapter.in.web.dto; + +import com.kustacks.kuring.notice.application.port.in.dto.NoticeDepartmentNameResult; + +public record NoticeDepartmentNameResponse( + String name, + String hostPrefix, + String korName +) { + public static NoticeDepartmentNameResponse from(NoticeDepartmentNameResult result) { + return new NoticeDepartmentNameResponse( + result.name(), + result.hostPrefix(), + result.korName() + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeRangeLookupResponse.java b/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeRangeLookupResponse.java new file mode 100644 index 00000000..6fa80737 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/adapter/in/web/dto/NoticeRangeLookupResponse.java @@ -0,0 +1,23 @@ +package com.kustacks.kuring.notice.adapter.in.web.dto; + +import com.kustacks.kuring.notice.application.port.in.dto.NoticeRangeLookupResult; + +public record NoticeRangeLookupResponse( + String articleId, + String postedDate, + String url, + String subject, + String category, + Boolean important +){ + public static NoticeRangeLookupResponse from(NoticeRangeLookupResult result) { + return new NoticeRangeLookupResponse( + result.articleId(), + result.postedDate(), + result.url(), + result.subject(), + result.category(), + result.important() + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/notice/domain/NoticeJdbcRepository.java b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeJdbcRepository.java similarity index 92% rename from src/main/java/com/kustacks/kuring/notice/domain/NoticeJdbcRepository.java rename to src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeJdbcRepository.java index 3430dadb..229c5e44 100644 --- a/src/main/java/com/kustacks/kuring/notice/domain/NoticeJdbcRepository.java +++ b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeJdbcRepository.java @@ -1,5 +1,8 @@ -package com.kustacks.kuring.notice.domain; +package com.kustacks.kuring.notice.adapter.out.persistence; +import com.kustacks.kuring.notice.domain.DepartmentName; +import com.kustacks.kuring.notice.domain.DepartmentNotice; +import com.kustacks.kuring.notice.domain.Notice; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; @@ -12,7 +15,7 @@ @Repository @RequiredArgsConstructor -public class NoticeJdbcRepository { +class NoticeJdbcRepository { private final JdbcTemplate jdbcTemplate; diff --git a/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticePersistenceAdapter.java b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticePersistenceAdapter.java new file mode 100644 index 00000000..281105b9 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticePersistenceAdapter.java @@ -0,0 +1,89 @@ +package com.kustacks.kuring.notice.adapter.out.persistence; + +import com.kustacks.kuring.common.annotation.PersistenceAdapter; +import com.kustacks.kuring.notice.application.port.out.NoticeCommandPort; +import com.kustacks.kuring.notice.application.port.out.NoticeQueryPort; +import com.kustacks.kuring.notice.application.port.out.dto.NoticeDto; +import com.kustacks.kuring.notice.application.port.out.dto.NoticeSearchDto; +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.notice.domain.DepartmentName; +import com.kustacks.kuring.notice.domain.DepartmentNotice; +import com.kustacks.kuring.notice.domain.Notice; +import com.kustacks.kuring.user.application.port.out.dto.BookmarkDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@PersistenceAdapter +@RequiredArgsConstructor +public class NoticePersistenceAdapter implements NoticeCommandPort, NoticeQueryPort { + + private final NoticeRepository noticeRepository; + private final NoticeJdbcRepository noticeJdbcRepository; + + @Override + public void saveAllCategoryNotices(List notices) { + this.noticeJdbcRepository.saveAllCategoryNotices(notices); + } + + @Override + public void saveAllDepartmentNotices(List departmentNotices) { + this.noticeJdbcRepository.saveAllDepartmentNotices(departmentNotices); + } + + @Override + public void deleteAllByIdsAndCategory(CategoryName categoryName, List articleIds) { + this.noticeRepository.deleteAllByIdsAndCategory(categoryName, articleIds); + } + + @Override + public void deleteAllByIdsAndDepartment(DepartmentName departmentName, List articleIds) { + this.noticeRepository.deleteAllByIdsAndDepartment(departmentName, articleIds); + } + + @Override + public List findNoticesByCategoryWithOffset(CategoryName categoryName, Pageable pageable) { + return this.noticeRepository.findNoticesByCategoryWithOffset(categoryName, pageable); + } + + @Override + public List findAllByKeywords(List containedNames) { + return this.noticeRepository.findAllByKeywords(containedNames); + } + + @Override + public List findNormalArticleIdsByCategory(CategoryName categoryName) { + return this.noticeRepository.findNormalArticleIdsByCategory(categoryName); + } + + @Override + public List findImportantNoticesByDepartment(DepartmentName departmentName) { + return this.noticeRepository.findImportantNoticesByDepartment(departmentName); + } + + @Override + public List findNormalNoticesByDepartmentWithOffset(DepartmentName departmentName, Pageable pageable) { + return this.noticeRepository.findNormalNoticesByDepartmentWithOffset(departmentName, pageable); + } + + @Override + public List findImportantArticleIdsByDepartment(DepartmentName departmentNameEnum) { + return this.noticeRepository.findImportantArticleIdsByDepartment(departmentNameEnum); + } + + @Override + public List findNormalArticleIdsByDepartment(DepartmentName departmentNameEnum) { + return this.noticeRepository.findNormalArticleIdsByDepartment(departmentNameEnum); + } + + @Override + public List findAllByBookmarkIds(List ids) { + return this.noticeRepository.findAllByBookmarkIds(ids); + } + + @Override + public Long count() { + return this.noticeRepository.count(); + } +} diff --git a/src/main/java/com/kustacks/kuring/notice/domain/NoticeQueryRepository.java b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeQueryRepository.java similarity index 69% rename from src/main/java/com/kustacks/kuring/notice/domain/NoticeQueryRepository.java rename to src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeQueryRepository.java index 11b0dc31..1f7be949 100644 --- a/src/main/java/com/kustacks/kuring/notice/domain/NoticeQueryRepository.java +++ b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeQueryRepository.java @@ -1,13 +1,15 @@ -package com.kustacks.kuring.notice.domain; +package com.kustacks.kuring.notice.adapter.out.persistence; -import com.kustacks.kuring.notice.common.dto.NoticeDto; -import com.kustacks.kuring.notice.common.dto.NoticeSearchDto; -import com.kustacks.kuring.user.common.dto.BookmarkDto; +import com.kustacks.kuring.notice.application.port.out.dto.NoticeDto; +import com.kustacks.kuring.notice.application.port.out.dto.NoticeSearchDto; +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.notice.domain.DepartmentName; +import com.kustacks.kuring.user.application.port.out.dto.BookmarkDto; import org.springframework.data.domain.Pageable; import java.util.List; -public interface NoticeQueryRepository { +interface NoticeQueryRepository { List findNoticesByCategoryWithOffset(CategoryName categoryName, Pageable pageable); diff --git a/src/main/java/com/kustacks/kuring/notice/domain/NoticeQueryRepositoryImpl.java b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeQueryRepositoryImpl.java similarity index 91% rename from src/main/java/com/kustacks/kuring/notice/domain/NoticeQueryRepositoryImpl.java rename to src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeQueryRepositoryImpl.java index c3754d52..511341c0 100644 --- a/src/main/java/com/kustacks/kuring/notice/domain/NoticeQueryRepositoryImpl.java +++ b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeQueryRepositoryImpl.java @@ -1,11 +1,13 @@ -package com.kustacks.kuring.notice.domain; - -import com.kustacks.kuring.notice.common.dto.NoticeDto; -import com.kustacks.kuring.notice.common.dto.NoticeSearchDto; -import com.kustacks.kuring.notice.common.dto.QNoticeDto; -import com.kustacks.kuring.notice.common.dto.QNoticeSearchDto; -import com.kustacks.kuring.user.common.dto.BookmarkDto; -import com.kustacks.kuring.user.common.dto.QBookmarkDto; +package com.kustacks.kuring.notice.adapter.out.persistence; + +import com.kustacks.kuring.notice.application.port.out.dto.NoticeDto; +import com.kustacks.kuring.notice.application.port.out.dto.NoticeSearchDto; +import com.kustacks.kuring.notice.application.port.out.dto.QNoticeDto; +import com.kustacks.kuring.notice.application.port.out.dto.QNoticeSearchDto; +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.notice.domain.DepartmentName; +import com.kustacks.kuring.user.application.port.out.dto.BookmarkDto; +import com.kustacks.kuring.user.application.port.out.dto.QBookmarkDto; import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.NumberTemplate; @@ -20,7 +22,7 @@ import static com.kustacks.kuring.notice.domain.QNotice.notice; @RequiredArgsConstructor -public class NoticeQueryRepositoryImpl implements NoticeQueryRepository { +class NoticeQueryRepositoryImpl implements NoticeQueryRepository { private final JPAQueryFactory queryFactory; diff --git a/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeRepository.java b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeRepository.java new file mode 100644 index 00000000..0d912191 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeRepository.java @@ -0,0 +1,7 @@ +package com.kustacks.kuring.notice.adapter.out.persistence; + +import com.kustacks.kuring.notice.domain.Notice; +import org.springframework.data.jpa.repository.JpaRepository; + +interface NoticeRepository extends JpaRepository, NoticeQueryRepository { +} diff --git a/src/main/java/com/kustacks/kuring/notice/application/port/in/NoticeQueryUseCase.java b/src/main/java/com/kustacks/kuring/notice/application/port/in/NoticeQueryUseCase.java new file mode 100644 index 00000000..45df89aa --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/application/port/in/NoticeQueryUseCase.java @@ -0,0 +1,12 @@ +package com.kustacks.kuring.notice.application.port.in; + +import com.kustacks.kuring.notice.application.port.in.dto.*; + +import java.util.List; + +public interface NoticeQueryUseCase { + List getNotices(NoticeRangeLookupCommand command); + List findAllNoticeByContent(String content); + List lookupSupportedCategories(); + List lookupSupportedDepartments(); +} diff --git a/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeCategoryNameResult.java b/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeCategoryNameResult.java new file mode 100644 index 00000000..9ac18f73 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeCategoryNameResult.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.notice.application.port.in.dto; + +import com.kustacks.kuring.notice.domain.CategoryName; + +public record NoticeCategoryNameResult( + String name, + String hostPrefix, + String korName +) { + public static NoticeCategoryNameResult from(CategoryName name) { + return new NoticeCategoryNameResult(name.getName(), name.getShortName(), name.getKorName()); + } +} diff --git a/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeContentSearchResult.java b/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeContentSearchResult.java new file mode 100644 index 00000000..98cb86d6 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeContentSearchResult.java @@ -0,0 +1,10 @@ +package com.kustacks.kuring.notice.application.port.in.dto; + +public record NoticeContentSearchResult( + String articleId, + String postedDate, + String subject, + String category, + String baseUrl +) { +} diff --git a/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeDepartmentNameResult.java b/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeDepartmentNameResult.java new file mode 100644 index 00000000..9dbc7bcd --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeDepartmentNameResult.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.notice.application.port.in.dto; + +import com.kustacks.kuring.notice.domain.DepartmentName; + +public record NoticeDepartmentNameResult( + String name, + String hostPrefix, + String korName +) { + public static NoticeDepartmentNameResult from(DepartmentName name) { + return new NoticeDepartmentNameResult(name.getName(), name.getHostPrefix(), name.getKorName()); + } +} diff --git a/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeRangeLookupCommand.java b/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeRangeLookupCommand.java new file mode 100644 index 00000000..8f804020 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeRangeLookupCommand.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.notice.application.port.in.dto; + +public record NoticeRangeLookupCommand( + String type, + String department, + Boolean important, + int page, + int size +) { + public boolean isImportant() { + return this.important; + } +} diff --git a/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeRangeLookupResult.java b/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeRangeLookupResult.java new file mode 100644 index 00000000..8a43c71a --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/application/port/in/dto/NoticeRangeLookupResult.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.notice.application.port.in.dto; + +public record NoticeRangeLookupResult( + String articleId, + String postedDate, + String url, + String subject, + String category, + Boolean important +){ +} diff --git a/src/main/java/com/kustacks/kuring/notice/application/port/out/NoticeCommandPort.java b/src/main/java/com/kustacks/kuring/notice/application/port/out/NoticeCommandPort.java new file mode 100644 index 00000000..df836350 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/application/port/out/NoticeCommandPort.java @@ -0,0 +1,16 @@ +package com.kustacks.kuring.notice.application.port.out; + +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.notice.domain.DepartmentName; +import com.kustacks.kuring.notice.domain.DepartmentNotice; +import com.kustacks.kuring.notice.domain.Notice; + +import java.util.List; + +public interface NoticeCommandPort { + + void saveAllCategoryNotices(List notices); + void saveAllDepartmentNotices(List departmentNotices); + void deleteAllByIdsAndCategory(CategoryName categoryName, List articleIds); + void deleteAllByIdsAndDepartment(DepartmentName departmentName, List articleIds); +} diff --git a/src/main/java/com/kustacks/kuring/notice/application/port/out/NoticeQueryPort.java b/src/main/java/com/kustacks/kuring/notice/application/port/out/NoticeQueryPort.java new file mode 100644 index 00000000..925393f5 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/application/port/out/NoticeQueryPort.java @@ -0,0 +1,31 @@ +package com.kustacks.kuring.notice.application.port.out; + +import com.kustacks.kuring.notice.application.port.out.dto.NoticeDto; +import com.kustacks.kuring.notice.application.port.out.dto.NoticeSearchDto; +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.notice.domain.DepartmentName; +import com.kustacks.kuring.user.application.port.out.dto.BookmarkDto; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface NoticeQueryPort { + + List findNoticesByCategoryWithOffset(CategoryName categoryName, Pageable pageable); + + List findAllByKeywords(List containedNames); + + List findNormalArticleIdsByCategory(CategoryName categoryName); + + List findImportantNoticesByDepartment(DepartmentName departmentName); + + List findNormalNoticesByDepartmentWithOffset(DepartmentName departmentName, Pageable pageable); + + List findImportantArticleIdsByDepartment(DepartmentName departmentNameEnum); + + List findNormalArticleIdsByDepartment(DepartmentName departmentNameEnum); + + List findAllByBookmarkIds(List ids); + + Long count(); +} diff --git a/src/main/java/com/kustacks/kuring/notice/common/dto/NoticeDto.java b/src/main/java/com/kustacks/kuring/notice/application/port/out/dto/NoticeDto.java similarity index 95% rename from src/main/java/com/kustacks/kuring/notice/common/dto/NoticeDto.java rename to src/main/java/com/kustacks/kuring/notice/application/port/out/dto/NoticeDto.java index f85b8591..28fd5a39 100644 --- a/src/main/java/com/kustacks/kuring/notice/common/dto/NoticeDto.java +++ b/src/main/java/com/kustacks/kuring/notice/application/port/out/dto/NoticeDto.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.notice.common.dto; +package com.kustacks.kuring.notice.application.port.out.dto; import com.querydsl.core.annotations.QueryProjection; import lombok.AccessLevel; diff --git a/src/main/java/com/kustacks/kuring/notice/common/dto/NoticeSearchDto.java b/src/main/java/com/kustacks/kuring/notice/application/port/out/dto/NoticeSearchDto.java similarity index 94% rename from src/main/java/com/kustacks/kuring/notice/common/dto/NoticeSearchDto.java rename to src/main/java/com/kustacks/kuring/notice/application/port/out/dto/NoticeSearchDto.java index cdb8ff2f..d986752a 100644 --- a/src/main/java/com/kustacks/kuring/notice/common/dto/NoticeSearchDto.java +++ b/src/main/java/com/kustacks/kuring/notice/application/port/out/dto/NoticeSearchDto.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.notice.common.dto; +package com.kustacks.kuring.notice.application.port.out.dto; import com.fasterxml.jackson.annotation.JsonProperty; import com.querydsl.core.annotations.QueryProjection; diff --git a/src/main/java/com/kustacks/kuring/notice/application/service/NoticeQueryService.java b/src/main/java/com/kustacks/kuring/notice/application/service/NoticeQueryService.java new file mode 100644 index 00000000..a6544415 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/notice/application/service/NoticeQueryService.java @@ -0,0 +1,163 @@ +package com.kustacks.kuring.notice.application.service; + +import com.kustacks.kuring.common.annotation.UseCase; +import com.kustacks.kuring.common.exception.InternalLogicException; +import com.kustacks.kuring.common.exception.NotFoundException; +import com.kustacks.kuring.common.exception.code.ErrorCode; +import com.kustacks.kuring.notice.application.port.in.NoticeQueryUseCase; +import com.kustacks.kuring.notice.application.port.in.dto.*; +import com.kustacks.kuring.notice.application.port.out.NoticeQueryPort; +import com.kustacks.kuring.notice.application.port.out.dto.NoticeDto; +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.notice.domain.DepartmentName; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; + +import static com.kustacks.kuring.notice.domain.CategoryName.DEPARTMENT; + +@UseCase +@Transactional(readOnly = true) +public class NoticeQueryService implements NoticeQueryUseCase { + + private static final String SPACE_REGEX = "[\\s+]"; + private final NoticeQueryPort noticeQueryPort; + private final List supportedCategoryNameList; + private final List supportedDepartmentNameList; + + public NoticeQueryService(NoticeQueryPort noticeQueryPort) { + this.noticeQueryPort = noticeQueryPort; + this.supportedCategoryNameList = Arrays.asList(CategoryName.values()); + this.supportedDepartmentNameList = Arrays.asList(DepartmentName.values()); + } + + @Override + public List getNotices(NoticeRangeLookupCommand command) { + if (isDepartmentSearchRequest(command.type(), command.department())) { + return getDepartmentNoticeRangeLookup(command); + } + + return getNoticeRangeLookup(command); + } + + @Override + public List findAllNoticeByContent(String content) { + String[] splitedKeywords = splitBySpace(content); + + List keywords = noticeCategoryNameConvertEnglish(splitedKeywords); + + return noticeQueryPort.findAllByKeywords(keywords) + .stream() + .map(dto -> new NoticeContentSearchResult( + dto.getArticleId(), + dto.getPostedDate(), + dto.getSubject(), + dto.getCategoryName(), + dto.getBaseUrl() + )) + .toList(); + } + + @Override + public List lookupSupportedCategories() { + return supportedCategoryNameList.stream() + .map(NoticeCategoryNameResult::from) + .toList(); + } + + @Override + public List lookupSupportedDepartments() { + return convertDepartmentNameDtos(supportedDepartmentNameList); + } + + private List getNoticeRangeLookup(NoticeRangeLookupCommand command) { + String categoryName = convertShortNameIntoLongName(command.type()); + if (isDepartment(categoryName)) { + throw new InternalLogicException(ErrorCode.API_INVALID_PARAM); + } + + return noticeQueryPort + .findNoticesByCategoryWithOffset( + CategoryName.fromStringName(categoryName), + PageRequest.of(command.page(), command.size()) + ).stream() + .map(NoticeQueryService::convertPortResult) + .toList(); + } + + private List getDepartmentNoticeRangeLookup(NoticeRangeLookupCommand command) { + DepartmentName departmentName = DepartmentName.fromHostPrefix(command.department()); + + if (command.isImportant()) { + return noticeQueryPort + .findImportantNoticesByDepartment(departmentName) + .stream() + .map(NoticeQueryService::convertPortResult) + .toList(); + } + + return noticeQueryPort + .findNormalNoticesByDepartmentWithOffset( + departmentName, + PageRequest.of(command.page(), command.size()) + ).stream() + .map(NoticeQueryService::convertPortResult) + .toList(); + } + + private List convertDepartmentNameDtos(List departmentNames) { + return departmentNames.stream() + .filter(dn -> !dn.equals(DepartmentName.BIO_SCIENCE)) + .filter(dn -> !dn.equals(DepartmentName.COMM_DESIGN)) + .map(NoticeDepartmentNameResult::from) + .toList(); + } + + private boolean isDepartmentSearchRequest(String type, String department) { + return type.equals("dep") && !department.isEmpty(); + } + + private boolean isDepartment(String categoryName) { + return DEPARTMENT.isSameName(categoryName); + } + + private String[] splitBySpace(String content) { + return content.trim().split(SPACE_REGEX); + } + + private List noticeCategoryNameConvertEnglish(String[] splitedKeywords) { + return Arrays.stream(splitedKeywords) + .map(this::convertEnglish) + .toList(); + } + + private String convertEnglish(String keyword) { + for (CategoryName categoryName : supportedCategoryNameList) { + if (categoryName.isSameKorName(keyword)) { + return categoryName.getName(); + } + } + return keyword; + } + + private String convertShortNameIntoLongName(String typeShortName) { + return supportedCategoryNameList.stream() + .filter(categoryName -> categoryName.isSameShortName(typeShortName)) + .findFirst() + .map(CategoryName::getName) + .orElseThrow(() -> new NotFoundException(ErrorCode.API_NOTICE_NOT_EXIST_CATEGORY)); + } + + public static NoticeRangeLookupResult convertPortResult(NoticeDto dto) { + return new NoticeRangeLookupResult( + dto.getArticleId(), + dto.getPostedDate(), + dto.getUrl(), + dto.getSubject(), + dto.getCategory(), + dto.getImportant() + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/notice/business/NoticeService.java b/src/main/java/com/kustacks/kuring/notice/business/NoticeService.java deleted file mode 100644 index 59095dd7..00000000 --- a/src/main/java/com/kustacks/kuring/notice/business/NoticeService.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.kustacks.kuring.notice.business; - -import com.kustacks.kuring.common.exception.InternalLogicException; -import com.kustacks.kuring.common.exception.NotFoundException; -import com.kustacks.kuring.common.exception.code.ErrorCode; -import com.kustacks.kuring.notice.common.dto.NoticeDto; -import com.kustacks.kuring.notice.common.dto.NoticeSearchDto; -import com.kustacks.kuring.notice.domain.CategoryName; -import com.kustacks.kuring.notice.domain.DepartmentName; -import com.kustacks.kuring.notice.domain.NoticeRepository; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Arrays; -import java.util.List; - -@Service -@Transactional(readOnly = true) -public class NoticeService { - - private final NoticeRepository noticeRepository; - private final CategoryName[] supportedCategoryNameList; - private final DepartmentName[] supportedDepartmentNameList; - private final String SPACE_REGEX = "[\\s+]"; - - @Value("${notice.normal-base-url}") - private String normalBaseUrl; - - @Value("${notice.library-base-url}") - private String libraryBaseUrl; - - public NoticeService(NoticeRepository noticeRepository) { - this.noticeRepository = noticeRepository; - this.supportedCategoryNameList = CategoryName.values(); - this.supportedDepartmentNameList = DepartmentName.values(); - } - - public List lookupSupportedDepartments() { - return List.of(supportedDepartmentNameList); - } - - public List getNotices(String type, String department, Boolean important, int page, int size) { - if (isDepartmentSearchRequest(type, department)) { - DepartmentName departmentName = DepartmentName.fromHostPrefix(department); - - if (Boolean.TRUE.equals(important)) { - return noticeRepository.findImportantNoticesByDepartment(departmentName); - } else { - return noticeRepository.findNormalNoticesByDepartmentWithOffset(departmentName, PageRequest.of(page, size)); - } - } - - String categoryName = convertShortNameIntoLongName(type); - if (isDepartment(categoryName)) { - throw new InternalLogicException(ErrorCode.API_INVALID_PARAM); - } - - return noticeRepository.findNoticesByCategoryWithOffset(CategoryName.fromStringName(categoryName), PageRequest.of(page, size)); - } - - public List findAllNoticeByContent(String content) { - String[] splitedKeywords = splitBySpace(content); - - List keywords = noticeCategoryNameConvertEnglish(splitedKeywords); - - return noticeRepository.findAllByKeywords(keywords); - } - - private boolean isDepartmentSearchRequest(String type, String department) { - return type.equals("dep") && !department.isEmpty(); - } - - private boolean isDepartment(String categoryName) { - return CategoryName.DEPARTMENT.isSameName(categoryName); - } - - private String[] splitBySpace(String content) { - return content.trim().split(SPACE_REGEX); - } - - private List noticeCategoryNameConvertEnglish(String[] splitedKeywords) { - return Arrays.stream(splitedKeywords) - .map(this::convertEnglish) - .toList(); - } - - private String convertEnglish(String keyword) { - for (CategoryName categoryName : supportedCategoryNameList) { - if (categoryName.isSameKorName(keyword)) { - return categoryName.getName(); - } - } - return keyword; - } - - private String convertShortNameIntoLongName(String typeShortName) { - return Arrays.stream(supportedCategoryNameList) - .filter(categoryName -> categoryName.isSameShortName(typeShortName)) - .findFirst() - .map(CategoryName::getName) - .orElseThrow(() -> new NotFoundException(ErrorCode.API_NOTICE_NOT_EXIST_CATEGORY)); - } - - private String convertBaseUrl(String categoryName) { - return CategoryName.LIBRARY.isSameName(categoryName) ? libraryBaseUrl : normalBaseUrl; - } -} diff --git a/src/main/java/com/kustacks/kuring/notice/common/OffsetBasedPageRequest.java b/src/main/java/com/kustacks/kuring/notice/common/OffsetBasedPageRequest.java deleted file mode 100644 index 790e2f7f..00000000 --- a/src/main/java/com/kustacks/kuring/notice/common/OffsetBasedPageRequest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.kustacks.kuring.notice.common; - -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; - -public class OffsetBasedPageRequest implements Pageable { - private int limit; - private int offset; - - public OffsetBasedPageRequest(int offset, int limit) { - if (offset < 0) { - throw new IllegalArgumentException("Offset must not be less than zero!"); - } - - if (limit < 1) { - throw new IllegalArgumentException("Limit must not be less than zero!"); - } - - this.offset = offset; - this.limit = limit; - } - - @Override - public int getPageNumber() { - return 0; - } - - @Override - public int getPageSize() { - return this.limit; - } - - @Override - public long getOffset() { - return this.offset; - } - - @Override - public Sort getSort() { - return null; - } - - @Override - public Pageable next() { - return null; - } - - @Override - public Pageable previousOrFirst() { - return this; - } - - @Override - public Pageable first() { - return this; - } - - @Override - public Pageable withPage(int pageNumber) { - return null; - } - - @Override - public boolean hasPrevious() { - return false; - } -} diff --git a/src/main/java/com/kustacks/kuring/notice/common/dto/CategoryNameDto.java b/src/main/java/com/kustacks/kuring/notice/common/dto/CategoryNameDto.java deleted file mode 100644 index 2d7caccf..00000000 --- a/src/main/java/com/kustacks/kuring/notice/common/dto/CategoryNameDto.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.kustacks.kuring.notice.common.dto; - -import com.kustacks.kuring.notice.domain.CategoryName; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class CategoryNameDto { - - private String name; - private String hostPrefix; - private String korName; - - public CategoryNameDto(String name, String hostPrefix, String korName) { - this.name = name; - this.hostPrefix = hostPrefix; - this.korName = korName; - } - - public static CategoryNameDto from(CategoryName name) { - return new CategoryNameDto(name.getName(), name.getShortName(), name.getKorName()); - } -} diff --git a/src/main/java/com/kustacks/kuring/notice/common/dto/DepartmentNameDto.java b/src/main/java/com/kustacks/kuring/notice/common/dto/DepartmentNameDto.java deleted file mode 100644 index d0e912c5..00000000 --- a/src/main/java/com/kustacks/kuring/notice/common/dto/DepartmentNameDto.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.kustacks.kuring.notice.common.dto; - -import com.kustacks.kuring.notice.domain.DepartmentName; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class DepartmentNameDto { - - private String name; - private String hostPrefix; - private String korName; - - private DepartmentNameDto(String name, String hostPrefix, String korName) { - this.name = name; - this.hostPrefix = hostPrefix; - this.korName = korName; - } - - public static DepartmentNameDto from(DepartmentName name) { - return new DepartmentNameDto(name.getName(), name.getHostPrefix(), name.getKorName()); - } -} diff --git a/src/main/java/com/kustacks/kuring/notice/common/dto/NoticeListResponse.java b/src/main/java/com/kustacks/kuring/notice/common/dto/NoticeListResponse.java deleted file mode 100644 index 2fb92261..00000000 --- a/src/main/java/com/kustacks/kuring/notice/common/dto/NoticeListResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.kustacks.kuring.notice.common.dto; - -import com.kustacks.kuring.common.dto.ResponseDto; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class NoticeListResponse extends ResponseDto { - - private String baseUrl; - - private List noticeList; - - public NoticeListResponse(String baseUrl, List noticeList) { - super(true, "성공", 200); - this.baseUrl = baseUrl; - this.noticeList = noticeList; - } -} diff --git a/src/main/java/com/kustacks/kuring/notice/common/dto/NoticeLookupResponse.java b/src/main/java/com/kustacks/kuring/notice/common/dto/NoticeLookupResponse.java deleted file mode 100644 index b73f9620..00000000 --- a/src/main/java/com/kustacks/kuring/notice/common/dto/NoticeLookupResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.kustacks.kuring.notice.common.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.List; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class NoticeLookupResponse { - - private List noticeList; -} diff --git a/src/main/java/com/kustacks/kuring/notice/domain/NoticeRepository.java b/src/main/java/com/kustacks/kuring/notice/domain/NoticeRepository.java deleted file mode 100644 index a15d69ca..00000000 --- a/src/main/java/com/kustacks/kuring/notice/domain/NoticeRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.kustacks.kuring.notice.domain; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface NoticeRepository extends JpaRepository, NoticeQueryRepository { -} diff --git a/src/main/java/com/kustacks/kuring/notice/facade/NoticeQueryFacade.java b/src/main/java/com/kustacks/kuring/notice/facade/NoticeQueryFacade.java deleted file mode 100644 index 6f4617d2..00000000 --- a/src/main/java/com/kustacks/kuring/notice/facade/NoticeQueryFacade.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.kustacks.kuring.notice.facade; - -import com.kustacks.kuring.notice.business.NoticeService; -import com.kustacks.kuring.notice.common.dto.*; -import com.kustacks.kuring.notice.domain.CategoryName; -import com.kustacks.kuring.notice.domain.DepartmentName; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.stream.Stream; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class NoticeQueryFacade { - - private final NoticeService noticeService; - - public List getNotices(String type, String department, Boolean important, int page, int size) { - return noticeService.getNotices(type, department, important, page, size); - } - - public NoticeLookupResponse searchNoticeByContent(String content) { - List noticeDtoList = noticeService.findAllNoticeByContent(content); - return new NoticeLookupResponse(noticeDtoList); - } - - public List getSupportedCategories() { - return Stream.of(CategoryName.values()) - .map(CategoryNameDto::from) - .toList(); - } - - public List getSupportedDepartments() { - List departmentNames = noticeService.lookupSupportedDepartments(); - return convertDepartmentNameDtos(departmentNames); - } - - private List convertDepartmentNameDtos(List departmentNames) { - return departmentNames.stream() - .filter(dn -> !dn.equals(DepartmentName.BIO_SCIENCE)) - .filter(dn -> !dn.equals(DepartmentName.COMM_DESIGN)) - .map(DepartmentNameDto::from) - .toList(); - } -} diff --git a/src/main/java/com/kustacks/kuring/notice/presentation/NoticeQueryApiV2.java b/src/main/java/com/kustacks/kuring/notice/presentation/NoticeQueryApiV2.java deleted file mode 100644 index 6a3626ec..00000000 --- a/src/main/java/com/kustacks/kuring/notice/presentation/NoticeQueryApiV2.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.kustacks.kuring.notice.presentation; - -import com.kustacks.kuring.common.dto.BaseResponse; -import com.kustacks.kuring.notice.common.dto.CategoryNameDto; -import com.kustacks.kuring.notice.common.dto.DepartmentNameDto; -import com.kustacks.kuring.notice.common.dto.NoticeDto; -import com.kustacks.kuring.notice.common.dto.NoticeLookupResponse; -import com.kustacks.kuring.notice.facade.NoticeQueryFacade; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import javax.validation.constraints.Max; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotBlank; -import java.util.List; - -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CATEGORY_SEARCH_SUCCESS; -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.DEPARTMENTS_SEARCH_SUCCESS; -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.NOTICE_SEARCH_SUCCESS; - -@Validated -@RestController -@RequiredArgsConstructor -@RequestMapping(value = "/api/v2/notices", produces = MediaType.APPLICATION_JSON_VALUE) -public class NoticeQueryApiV2 { - - private final NoticeQueryFacade noticeQueryFacade; - - @GetMapping - public ResponseEntity>> getNotices( - @RequestParam(name = "type") String type, - @RequestParam(name = "department", required = false) String department, - @RequestParam(name = "important", required = false) Boolean important, - @RequestParam(name = "page") @Min(0) int page, - @RequestParam(name = "size") @Min(1) @Max(30) int size) { - List searchResults = noticeQueryFacade.getNotices(type, department, important, page, size); - return ResponseEntity.ok().body(new BaseResponse<>(NOTICE_SEARCH_SUCCESS, searchResults)); - } - - @GetMapping("/search") - public ResponseEntity> searchNotice(@NotBlank @RequestParam String content) { - NoticeLookupResponse response = noticeQueryFacade.searchNoticeByContent(content); - return ResponseEntity.ok().body(new BaseResponse<>(NOTICE_SEARCH_SUCCESS, response)); - } - - @GetMapping("/categories") - public ResponseEntity>> getSupportedCategories() { - List categoryNames = noticeQueryFacade.getSupportedCategories(); - return ResponseEntity.ok().body(new BaseResponse<>(CATEGORY_SEARCH_SUCCESS, categoryNames)); - } - - @GetMapping("/departments") - public ResponseEntity>> getSupportedDepartments() { - List departmentNames = noticeQueryFacade.getSupportedDepartments(); - return ResponseEntity.ok().body(new BaseResponse<>(DEPARTMENTS_SEARCH_SUCCESS, departmentNames)); - } -} diff --git a/src/main/java/com/kustacks/kuring/staff/adapter/in/web/StaffQueryApiV2.java b/src/main/java/com/kustacks/kuring/staff/adapter/in/web/StaffQueryApiV2.java new file mode 100644 index 00000000..ef838391 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/staff/adapter/in/web/StaffQueryApiV2.java @@ -0,0 +1,39 @@ +package com.kustacks.kuring.staff.adapter.in.web; + +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.STAFF_SEARCH_SUCCESS; + +import com.kustacks.kuring.common.annotation.RestWebAdapter; +import com.kustacks.kuring.common.dto.BaseResponse; +import com.kustacks.kuring.staff.adapter.in.web.dto.StaffSearchListResponse; +import com.kustacks.kuring.staff.adapter.in.web.dto.StaffSearchResponse; +import com.kustacks.kuring.staff.application.port.in.StaffQueryUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import javax.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Staff-Query", description = "교직원 정보 조회") +@Validated +@RequiredArgsConstructor +@RestWebAdapter(path = "/api/v2/staffs") +public class StaffQueryApiV2 { + + private final StaffQueryUseCase staffQueryUseCase; + + @Operation(summary = "교직원 검색", description = "교직원 이름을 통하여 검색합니다") + @GetMapping("/search") + public ResponseEntity> searchStaff(@NotBlank @RequestParam String content) { + List staffSearchResults = staffQueryUseCase.findAllStaffByContent(content) + .stream() + .map(StaffSearchResponse::from) + .toList(); + + StaffSearchListResponse response = new StaffSearchListResponse(staffSearchResults); + return ResponseEntity.ok().body(new BaseResponse<>(STAFF_SEARCH_SUCCESS, response)); + } +} diff --git a/src/main/java/com/kustacks/kuring/staff/adapter/in/web/dto/StaffSearchListResponse.java b/src/main/java/com/kustacks/kuring/staff/adapter/in/web/dto/StaffSearchListResponse.java new file mode 100644 index 00000000..48e38e28 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/staff/adapter/in/web/dto/StaffSearchListResponse.java @@ -0,0 +1,8 @@ +package com.kustacks.kuring.staff.adapter.in.web.dto; + +import java.util.List; + +public record StaffSearchListResponse( + List staffList +) { +} diff --git a/src/main/java/com/kustacks/kuring/staff/adapter/in/web/dto/StaffSearchResponse.java b/src/main/java/com/kustacks/kuring/staff/adapter/in/web/dto/StaffSearchResponse.java new file mode 100644 index 00000000..930aded1 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/staff/adapter/in/web/dto/StaffSearchResponse.java @@ -0,0 +1,26 @@ +package com.kustacks.kuring.staff.adapter.in.web.dto; + +import com.kustacks.kuring.staff.application.port.in.dto.StaffSearchResult; + +public record StaffSearchResponse( + String name, + String major, + String lab, + String phone, + String email, + String deptName, + String collegeName +) { + + public static StaffSearchResponse from(StaffSearchResult result) { + return new StaffSearchResponse( + result.name(), + result.major(), + result.lab(), + result.phone(), + result.email(), + result.deptName(), + result.collegeName() + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffPersistenceAdapter.java b/src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffPersistenceAdapter.java new file mode 100644 index 00000000..0edfbc8f --- /dev/null +++ b/src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffPersistenceAdapter.java @@ -0,0 +1,20 @@ +package com.kustacks.kuring.staff.adapter.out.persistence; + +import com.kustacks.kuring.common.annotation.PersistenceAdapter; +import com.kustacks.kuring.staff.application.port.out.StaffQueryPort; +import com.kustacks.kuring.staff.application.port.out.dto.StaffSearchDto; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@PersistenceAdapter +@RequiredArgsConstructor +public class StaffPersistenceAdapter implements StaffQueryPort { + + private final StaffRepository staffRepository; + + @Override + public List findAllByKeywords(List keywords) { + return this.staffRepository.findAllByKeywords(keywords); + } +} diff --git a/src/main/java/com/kustacks/kuring/staff/domain/StaffQueryRepository.java b/src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffQueryRepository.java similarity index 50% rename from src/main/java/com/kustacks/kuring/staff/domain/StaffQueryRepository.java rename to src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffQueryRepository.java index 447f8d45..a4098c72 100644 --- a/src/main/java/com/kustacks/kuring/staff/domain/StaffQueryRepository.java +++ b/src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffQueryRepository.java @@ -1,6 +1,6 @@ -package com.kustacks.kuring.staff.domain; +package com.kustacks.kuring.staff.adapter.out.persistence; -import com.kustacks.kuring.staff.common.dto.StaffSearchDto; +import com.kustacks.kuring.staff.application.port.out.dto.StaffSearchDto; import java.util.List; diff --git a/src/main/java/com/kustacks/kuring/staff/domain/StaffQueryRepositoryImpl.java b/src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffQueryRepositoryImpl.java similarity index 89% rename from src/main/java/com/kustacks/kuring/staff/domain/StaffQueryRepositoryImpl.java rename to src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffQueryRepositoryImpl.java index 7a8271d9..04b0e821 100644 --- a/src/main/java/com/kustacks/kuring/staff/domain/StaffQueryRepositoryImpl.java +++ b/src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffQueryRepositoryImpl.java @@ -1,7 +1,7 @@ -package com.kustacks.kuring.staff.domain; +package com.kustacks.kuring.staff.adapter.out.persistence; -import com.kustacks.kuring.staff.common.dto.QStaffSearchDto; -import com.kustacks.kuring.staff.common.dto.StaffSearchDto; +import com.kustacks.kuring.staff.application.port.out.dto.QStaffSearchDto; +import com.kustacks.kuring.staff.application.port.out.dto.StaffSearchDto; import com.querydsl.core.BooleanBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/kustacks/kuring/staff/domain/StaffRepository.java b/src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffRepository.java similarity index 71% rename from src/main/java/com/kustacks/kuring/staff/domain/StaffRepository.java rename to src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffRepository.java index 0fc85e56..1db6ca84 100644 --- a/src/main/java/com/kustacks/kuring/staff/domain/StaffRepository.java +++ b/src/main/java/com/kustacks/kuring/staff/adapter/out/persistence/StaffRepository.java @@ -1,16 +1,13 @@ -package com.kustacks.kuring.staff.domain; +package com.kustacks.kuring.staff.adapter.out.persistence; +import com.kustacks.kuring.staff.domain.Staff; import org.springframework.data.jpa.repository.JpaRepository; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; public interface StaffRepository extends JpaRepository, StaffQueryRepository { - default Map findAllMap() { - return findAll().stream().collect(Collectors.toMap(Staff::getEmail, v -> v)); - } default Map findByDeptContainingMap(List deptNames) { @@ -29,6 +26,4 @@ default Map findByDeptContainingMap(List deptNames) { } List findByDeptContaining(String deptName); - - List findByNameContainingOrDeptContainingOrCollegeContaining(String name, String dept, String college); } diff --git a/src/main/java/com/kustacks/kuring/staff/application/port/in/StaffQueryUseCase.java b/src/main/java/com/kustacks/kuring/staff/application/port/in/StaffQueryUseCase.java new file mode 100644 index 00000000..e669acea --- /dev/null +++ b/src/main/java/com/kustacks/kuring/staff/application/port/in/StaffQueryUseCase.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.staff.application.port.in; + +import com.kustacks.kuring.staff.application.port.in.dto.StaffSearchResult; + +import java.util.List; + +public interface StaffQueryUseCase { + List findAllStaffByContent(String content); +} diff --git a/src/main/java/com/kustacks/kuring/staff/application/port/in/dto/StaffSearchResult.java b/src/main/java/com/kustacks/kuring/staff/application/port/in/dto/StaffSearchResult.java new file mode 100644 index 00000000..4d831e16 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/staff/application/port/in/dto/StaffSearchResult.java @@ -0,0 +1,12 @@ +package com.kustacks.kuring.staff.application.port.in.dto; + +public record StaffSearchResult( + String name, + String major, + String lab, + String phone, + String email, + String deptName, + String collegeName +) { +} diff --git a/src/main/java/com/kustacks/kuring/staff/application/port/out/StaffQueryPort.java b/src/main/java/com/kustacks/kuring/staff/application/port/out/StaffQueryPort.java new file mode 100644 index 00000000..fae29c59 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/staff/application/port/out/StaffQueryPort.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.staff.application.port.out; + +import com.kustacks.kuring.staff.application.port.out.dto.StaffSearchDto; + +import java.util.List; + +public interface StaffQueryPort { + List findAllByKeywords(List keywords); +} diff --git a/src/main/java/com/kustacks/kuring/staff/common/dto/StaffSearchDto.java b/src/main/java/com/kustacks/kuring/staff/application/port/out/dto/StaffSearchDto.java similarity index 92% rename from src/main/java/com/kustacks/kuring/staff/common/dto/StaffSearchDto.java rename to src/main/java/com/kustacks/kuring/staff/application/port/out/dto/StaffSearchDto.java index fef6df3b..7460673f 100644 --- a/src/main/java/com/kustacks/kuring/staff/common/dto/StaffSearchDto.java +++ b/src/main/java/com/kustacks/kuring/staff/application/port/out/dto/StaffSearchDto.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.staff.common.dto; +package com.kustacks.kuring.staff.application.port.out.dto; import com.querydsl.core.annotations.QueryProjection; import lombok.AccessLevel; diff --git a/src/main/java/com/kustacks/kuring/staff/application/service/StaffQueryService.java b/src/main/java/com/kustacks/kuring/staff/application/service/StaffQueryService.java new file mode 100644 index 00000000..ec39bb7a --- /dev/null +++ b/src/main/java/com/kustacks/kuring/staff/application/service/StaffQueryService.java @@ -0,0 +1,37 @@ +package com.kustacks.kuring.staff.application.service; + +import com.kustacks.kuring.staff.application.port.in.StaffQueryUseCase; +import com.kustacks.kuring.staff.application.port.in.dto.StaffSearchResult; +import com.kustacks.kuring.staff.application.port.out.StaffQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class StaffQueryService implements StaffQueryUseCase { + + private static final String SPACE_REGEX = "[\\s+]"; + private final StaffQueryPort staffQueryPort; + + public List findAllStaffByContent(String content) { + List splitedKeywords = Arrays.asList(splitBySpace(content)); + return staffQueryPort.findAllByKeywords(splitedKeywords) + .stream() + .map(dto -> new StaffSearchResult( + dto.getName(), + dto.getMajor(), + dto.getLab(), + dto.getPhone(), + dto.getEmail(), + dto.getDeptName(), + dto.getCollegeName() + )).toList(); + } + + private String[] splitBySpace(String content) { + return content.trim().split(SPACE_REGEX); + } +} diff --git a/src/main/java/com/kustacks/kuring/staff/business/StaffService.java b/src/main/java/com/kustacks/kuring/staff/business/StaffService.java deleted file mode 100644 index 8394dce4..00000000 --- a/src/main/java/com/kustacks/kuring/staff/business/StaffService.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.kustacks.kuring.staff.business; - -import com.kustacks.kuring.staff.common.dto.StaffSearchDto; -import com.kustacks.kuring.staff.domain.Staff; -import com.kustacks.kuring.staff.domain.StaffRepository; -import org.springframework.stereotype.Service; - -import java.util.Arrays; -import java.util.Iterator; -import java.util.List; - -@Service -public class StaffService { - - private final String SPACE_REGEX = "[\\s+]"; - - private final StaffRepository staffRepository; - - public StaffService(StaffRepository staffRepository) { - this.staffRepository = staffRepository; - } - - - public List handleSearchRequest(String keywords) { - - keywords = keywords.trim(); - String[] splitedKeywords = keywords.split("[\\s+]"); - - return getStaffsByNameOrDeptOrCollege(splitedKeywords); - } - - public List findAllStaffByContent(String content) { - List splitedKeywords = Arrays.asList(splitBySpace(content)); - - return staffRepository.findAllByKeywords(splitedKeywords); - } - - private List getStaffsByNameOrDeptOrCollege(String[] keywords) { - - List staffs = staffRepository.findByNameContainingOrDeptContainingOrCollegeContaining(keywords[0], keywords[0], keywords[0]); - Iterator iterator = staffs.iterator(); - - for(int i=1; i staffList; -} diff --git a/src/main/java/com/kustacks/kuring/staff/presentation/StaffQueryApiV2.java b/src/main/java/com/kustacks/kuring/staff/presentation/StaffQueryApiV2.java deleted file mode 100644 index 77d3293f..00000000 --- a/src/main/java/com/kustacks/kuring/staff/presentation/StaffQueryApiV2.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.kustacks.kuring.staff.presentation; - -import com.kustacks.kuring.common.dto.BaseResponse; -import com.kustacks.kuring.staff.business.StaffService; -import com.kustacks.kuring.staff.common.dto.StaffLookupResponse; -import com.kustacks.kuring.staff.common.dto.StaffSearchDto; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import javax.validation.constraints.NotBlank; -import java.util.List; - -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.STAFF_SEARCH_SUCCESS; - -@Validated -@RestController -@RequiredArgsConstructor -@RequestMapping(value = "/api/v2", produces = MediaType.APPLICATION_JSON_VALUE) -public class StaffQueryApiV2 { - - private final StaffService staffService; - - @GetMapping("/staffs/search") - public ResponseEntity> searchStaff(@NotBlank @RequestParam String content) { - List staffDtoList = staffService.findAllStaffByContent(content); - StaffLookupResponse response = new StaffLookupResponse(staffDtoList); - return ResponseEntity.ok().body(new BaseResponse<>(STAFF_SEARCH_SUCCESS, response)); - } -} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/UserCommandApiV2.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/UserCommandApiV2.java new file mode 100644 index 00000000..febff789 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/UserCommandApiV2.java @@ -0,0 +1,86 @@ +package com.kustacks.kuring.user.adapter.in.web; + +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.BOOKMAKR_SAVE_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CATEGORY_SUBSCRIBE_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.DEPARTMENTS_SUBSCRIBE_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.FEEDBACK_SAVE_SUCCESS; + +import com.kustacks.kuring.common.annotation.RestWebAdapter; +import com.kustacks.kuring.common.dto.BaseResponse; +import com.kustacks.kuring.user.adapter.in.web.dto.UserBookmarkRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserCategoriesSubscribeRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserDepartmentsSubscribeRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserFeedbackRequest; +import com.kustacks.kuring.user.application.port.in.UserCommandUseCase; +import com.kustacks.kuring.user.application.port.in.dto.UserBookmarkCommand; +import com.kustacks.kuring.user.application.port.in.dto.UserCategoriesSubscribeCommand; +import com.kustacks.kuring.user.application.port.in.dto.UserDepartmentsSubscribeCommand; +import com.kustacks.kuring.user.application.port.in.dto.UserFeedbackCommand; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import javax.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "User-Command", description = "사용자가 주체가 되는 정보 수정") +@Slf4j +@Validated +@RequiredArgsConstructor +@RestWebAdapter(path = "/api/v2/users") +class UserCommandApiV2 { + + private static final String USER_TOKEN_HEADER_KEY = "User-Token"; + + private final UserCommandUseCase userCommandUseCase; + + @Operation(summary = "사용자 카테고리 수정", description = "사용자가 구독한 카테고리 목록을 추가, 삭제 합니다") + @SecurityRequirement(name = "User-Token") + @PostMapping(value = "/subscriptions/categories", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> editUserSubscribeCategories( + @Valid @RequestBody UserCategoriesSubscribeRequest request, + @RequestHeader(USER_TOKEN_HEADER_KEY) String id + ) { + userCommandUseCase.editSubscribeCategories(new UserCategoriesSubscribeCommand(id, request.categories())); + return ResponseEntity.ok().body(new BaseResponse<>(CATEGORY_SUBSCRIBE_SUCCESS, null)); + } + + @Operation(summary = "사용자 학과 수정", description = "사용자가 구독한 학과 목록을 추가, 삭제 합니다") + @SecurityRequirement(name = "User-Token") + @PostMapping(value = "/subscriptions/departments", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> editUserSubscribeDepartments( + @Valid @RequestBody UserDepartmentsSubscribeRequest request, + @RequestHeader(USER_TOKEN_HEADER_KEY) String id + ) { + userCommandUseCase.editSubscribeDepartments(new UserDepartmentsSubscribeCommand(id, request.departments())); + return ResponseEntity.ok().body(new BaseResponse<>(DEPARTMENTS_SUBSCRIBE_SUCCESS, null)); + } + + @Operation(summary = "사용자 피드백 작성", description = "사용자가 피드백을 작성하여 저장합니다") + @SecurityRequirement(name = "User-Token") + @PostMapping("/feedbacks") + public ResponseEntity> saveFeedback( + @Valid @RequestBody UserFeedbackRequest request, + @RequestHeader(USER_TOKEN_HEADER_KEY) String id + ) { + userCommandUseCase.saveFeedback(new UserFeedbackCommand(id, request.content())); + return ResponseEntity.ok().body(new BaseResponse<>(FEEDBACK_SAVE_SUCCESS, null)); + } + + @Operation(summary = "사용자 북마크 작성", description = "사용자가 원하는 공지를 북마크 하여 저장합니다") + @SecurityRequirement(name = "User-Token") + @PostMapping("/bookmarks") + public ResponseEntity> saveBookmark( + @Valid @RequestBody UserBookmarkRequest request, + @RequestHeader(USER_TOKEN_HEADER_KEY) String id + ) { + userCommandUseCase.saveBookmark(new UserBookmarkCommand(id, request.articleId())); + return ResponseEntity.ok().body(new BaseResponse<>(BOOKMAKR_SAVE_SUCCESS, null)); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/UserQueryApiV2.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/UserQueryApiV2.java new file mode 100644 index 00000000..e7993daf --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/UserQueryApiV2.java @@ -0,0 +1,76 @@ +package com.kustacks.kuring.user.adapter.in.web; + +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.BOOKMARK_LOOKUP_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.CATEGORY_USER_SUBSCRIBES_LOOKUP_SUCCESS; +import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.DEPARTMENTS_USER_SUBSCRIBES_LOOKUP_SUCCESS; + +import com.kustacks.kuring.common.annotation.RestWebAdapter; +import com.kustacks.kuring.common.dto.BaseResponse; +import com.kustacks.kuring.user.adapter.in.web.dto.UserBookmarkResponse; +import com.kustacks.kuring.user.adapter.in.web.dto.UserCategoryNameResponse; +import com.kustacks.kuring.user.adapter.in.web.dto.UserDepartmentNameResponse; +import com.kustacks.kuring.user.application.port.in.UserQueryUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "User-Query", description = "사용자가 주체가 되는 정보 조회") +@Slf4j +@Validated +@RequiredArgsConstructor +@RestWebAdapter(path = "/api/v2/users") +class UserQueryApiV2 { + + private static final String USER_TOKEN_HEADER_KEY = "User-Token"; + + private final UserQueryUseCase userQueryUseCase; + + @Operation(summary = "사용자 카테고리 조회", description = "사용자가 구독한 카테고리 목록을 조회합니다") + @SecurityRequirement(name = "User-Token") + @GetMapping("/subscriptions/categories") + public ResponseEntity>> lookupUserSubscribeCategories( + @RequestHeader(USER_TOKEN_HEADER_KEY) String userToken + ) { + List responses = userQueryUseCase.lookupSubscribeCategories(userToken) + .stream() + .map(UserCategoryNameResponse::from) + .toList(); + + return ResponseEntity.ok().body(new BaseResponse<>(CATEGORY_USER_SUBSCRIBES_LOOKUP_SUCCESS, responses)); + } + + @Operation(summary = "사용자 학과 조회", description = "사용자가 구독한 학과의 목록을 조회합니다") + @SecurityRequirement(name = "User-Token") + @GetMapping("/subscriptions/departments") + public ResponseEntity>> lookupUserSubscribeDepartments( + @RequestHeader(USER_TOKEN_HEADER_KEY) String userToken + ) { + List responses = userQueryUseCase.lookupSubscribeDepartments(userToken) + .stream() + .map(UserDepartmentNameResponse::from) + .toList(); + + return ResponseEntity.ok().body(new BaseResponse<>(DEPARTMENTS_USER_SUBSCRIBES_LOOKUP_SUCCESS, responses)); + } + + @Operation(summary = "사용자 북마크 조회", description = "사용자가 북마크한 공지의 목록을 조회합니다") + @SecurityRequirement(name = "User-Token") + @GetMapping("/bookmarks") + public ResponseEntity>> lookupUserBookmarks( + @RequestHeader(USER_TOKEN_HEADER_KEY) String userToken + ) { + List responses = userQueryUseCase.lookupUserBookmarkedNotices(userToken) + .stream() + .map(UserBookmarkResponse::from) + .toList(); + + return ResponseEntity.ok().body(new BaseResponse<>(BOOKMARK_LOOKUP_SUCCESS, responses)); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserBookmarkRequest.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserBookmarkRequest.java new file mode 100644 index 00000000..e8cdc56c --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserBookmarkRequest.java @@ -0,0 +1,8 @@ +package com.kustacks.kuring.user.adapter.in.web.dto; + +import javax.validation.constraints.NotBlank; + +public record UserBookmarkRequest( + @NotBlank String articleId +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserBookmarkResponse.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserBookmarkResponse.java new file mode 100644 index 00000000..bebfde56 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserBookmarkResponse.java @@ -0,0 +1,22 @@ +package com.kustacks.kuring.user.adapter.in.web.dto; + +import com.kustacks.kuring.user.application.port.in.dto.UserBookmarkResult; + +public record UserBookmarkResponse( + String articleId, + String postedDate, + String subject, + String category, + String baseUrl +) { + + public static UserBookmarkResponse from(UserBookmarkResult result) { + return new UserBookmarkResponse( + result.articleId(), + result.postedDate(), + result.subject(), + result.category(), + result.baseUrl() + ); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserCategoriesSubscribeRequest.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserCategoriesSubscribeRequest.java new file mode 100644 index 00000000..e6e211fb --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserCategoriesSubscribeRequest.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.user.adapter.in.web.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public record UserCategoriesSubscribeRequest( + @NotNull List categories +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserCategoryNameResponse.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserCategoryNameResponse.java new file mode 100644 index 00000000..15c4aa95 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserCategoryNameResponse.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.user.adapter.in.web.dto; + +import com.kustacks.kuring.user.application.port.in.dto.UserCategoryNameResult; + +public record UserCategoryNameResponse( + String name, + String hostPrefix, + String korName +) { + public static UserCategoryNameResponse from(UserCategoryNameResult name) { + return new UserCategoryNameResponse(name.name(), name.hostPrefix(), name.korName()); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserDepartmentNameResponse.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserDepartmentNameResponse.java new file mode 100644 index 00000000..66b228f0 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserDepartmentNameResponse.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.user.adapter.in.web.dto; + +import com.kustacks.kuring.user.application.port.in.dto.UserDepartmentNameResult; + +public record UserDepartmentNameResponse( + String name, + String hostPrefix, + String korName +) { + public static UserDepartmentNameResponse from(UserDepartmentNameResult name) { + return new UserDepartmentNameResponse(name.name(), name.hostPrefix(), name.korName()); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserDepartmentsSubscribeRequest.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserDepartmentsSubscribeRequest.java new file mode 100644 index 00000000..ebf6ae53 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserDepartmentsSubscribeRequest.java @@ -0,0 +1,10 @@ +package com.kustacks.kuring.user.adapter.in.web.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + + +public record UserDepartmentsSubscribeRequest( + @NotNull List departments +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserFeedbackRequest.java b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserFeedbackRequest.java new file mode 100644 index 00000000..52cf16e6 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserFeedbackRequest.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.user.adapter.in.web.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +public record UserFeedbackRequest( + @NotBlank @Size(min = 5, max = 256) String content +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/out/event/UserFirebaseMessageAdapter.java b/src/main/java/com/kustacks/kuring/user/adapter/out/event/UserFirebaseMessageAdapter.java new file mode 100644 index 00000000..ba8b2195 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/out/event/UserFirebaseMessageAdapter.java @@ -0,0 +1,25 @@ +package com.kustacks.kuring.user.adapter.out.event; + +import com.kustacks.kuring.common.domain.Events; +import com.kustacks.kuring.message.adapter.in.event.dto.UserSubscribeEvent; +import com.kustacks.kuring.message.adapter.in.event.dto.UserUnsubscribeEvent; +import com.kustacks.kuring.message.application.service.exception.FirebaseSubscribeException; +import com.kustacks.kuring.message.application.service.exception.FirebaseUnSubscribeException; +import com.kustacks.kuring.user.application.port.out.UserEventPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UserFirebaseMessageAdapter implements UserEventPort { + + @Override + public void subscribeEvent(String token, String topic) throws FirebaseSubscribeException { + Events.raise(new UserSubscribeEvent(token, topic)); + } + + @Override + public void unsubscribeEvent(String token, String topic) throws FirebaseUnSubscribeException { + Events.raise(new UserUnsubscribeEvent(token, topic)); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserPersistenceAdapter.java b/src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserPersistenceAdapter.java new file mode 100644 index 00000000..b9d85669 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserPersistenceAdapter.java @@ -0,0 +1,50 @@ +package com.kustacks.kuring.user.adapter.out.persistence; + +import com.kustacks.kuring.user.application.port.out.dto.FeedbackDto; +import com.kustacks.kuring.admin.application.port.out.AdminUserFeedbackPort; +import com.kustacks.kuring.common.annotation.PersistenceAdapter; +import com.kustacks.kuring.user.application.port.out.UserCommandPort; +import com.kustacks.kuring.user.application.port.out.UserQueryPort; +import com.kustacks.kuring.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +@PersistenceAdapter +@RequiredArgsConstructor +public class UserPersistenceAdapter implements UserCommandPort, UserQueryPort, AdminUserFeedbackPort { + + private final UserRepository userRepository; + + @Override + public List findAllFeedbackByPageRequest(Pageable pageable) { + return userRepository.findAllFeedbackByPageRequest(pageable); + } + + @Override + public List findAllToken() { + return userRepository.findAllToken(); + } + + @Override + public Optional findByToken(String token) { + return userRepository.findByToken(token); + } + + @Override + public List findAll() { + return userRepository.findAll(); + } + + @Override + public User save(User user) { + return userRepository.save(user); + } + + @Override + public void delete(User user) { + userRepository.delete(user); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserQueryRepository.java b/src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserQueryRepository.java new file mode 100644 index 00000000..8c450fea --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserQueryRepository.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.user.adapter.out.persistence; + +import com.kustacks.kuring.user.application.port.out.dto.FeedbackDto; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +interface UserQueryRepository { + + List findAllFeedbackByPageRequest(Pageable pageable); +} diff --git a/src/main/java/com/kustacks/kuring/user/domain/UserQueryRepositoryImpl.java b/src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserQueryRepositoryImpl.java similarity index 73% rename from src/main/java/com/kustacks/kuring/user/domain/UserQueryRepositoryImpl.java rename to src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserQueryRepositoryImpl.java index 2a69c011..23761b23 100644 --- a/src/main/java/com/kustacks/kuring/user/domain/UserQueryRepositoryImpl.java +++ b/src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserQueryRepositoryImpl.java @@ -1,7 +1,7 @@ -package com.kustacks.kuring.user.domain; +package com.kustacks.kuring.user.adapter.out.persistence; -import com.kustacks.kuring.admin.common.dto.FeedbackDto; -import com.kustacks.kuring.admin.common.dto.QFeedbackDto; +import com.kustacks.kuring.user.application.port.out.dto.FeedbackDto; +import com.kustacks.kuring.user.application.port.out.dto.QFeedbackDto; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; @@ -11,7 +11,7 @@ import static com.kustacks.kuring.user.domain.QFeedback.feedback; @RequiredArgsConstructor -public class UserQueryRepositoryImpl implements UserQueryRepository { +class UserQueryRepositoryImpl implements UserQueryRepository { private final JPAQueryFactory queryFactory; diff --git a/src/main/java/com/kustacks/kuring/user/domain/UserRepository.java b/src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserRepository.java similarity index 61% rename from src/main/java/com/kustacks/kuring/user/domain/UserRepository.java rename to src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserRepository.java index f3c04123..72df8984 100644 --- a/src/main/java/com/kustacks/kuring/user/domain/UserRepository.java +++ b/src/main/java/com/kustacks/kuring/user/adapter/out/persistence/UserRepository.java @@ -1,12 +1,13 @@ -package com.kustacks.kuring.user.domain; +package com.kustacks.kuring.user.adapter.out.persistence; +import com.kustacks.kuring.user.domain.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import java.util.List; import java.util.Optional; -public interface UserRepository extends JpaRepository, UserQueryRepository { +interface UserRepository extends JpaRepository, UserQueryRepository { Optional findByToken(String token); @Query("SELECT u.token FROM User u") diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/UserCommandUseCase.java b/src/main/java/com/kustacks/kuring/user/application/port/in/UserCommandUseCase.java new file mode 100644 index 00000000..686d6427 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/UserCommandUseCase.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.user.application.port.in; + +import com.kustacks.kuring.user.application.port.in.dto.UserBookmarkCommand; +import com.kustacks.kuring.user.application.port.in.dto.UserFeedbackCommand; +import com.kustacks.kuring.user.application.port.in.dto.UserCategoriesSubscribeCommand; +import com.kustacks.kuring.user.application.port.in.dto.UserDepartmentsSubscribeCommand; + +public interface UserCommandUseCase { + void editSubscribeCategories(UserCategoriesSubscribeCommand command); + void editSubscribeDepartments(UserDepartmentsSubscribeCommand command); + void saveFeedback(UserFeedbackCommand command); + void saveBookmark(UserBookmarkCommand command); +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/UserQueryUseCase.java b/src/main/java/com/kustacks/kuring/user/application/port/in/UserQueryUseCase.java new file mode 100644 index 00000000..338e951e --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/UserQueryUseCase.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.user.application.port.in; + +import com.kustacks.kuring.user.application.port.in.dto.UserBookmarkResult; +import com.kustacks.kuring.user.application.port.in.dto.UserCategoryNameResult; +import com.kustacks.kuring.user.application.port.in.dto.UserDepartmentNameResult; + +import java.util.List; + +public interface UserQueryUseCase { + List lookupSubscribeCategories(String userToken); + List lookupSubscribeDepartments(String userToken); + List lookupUserBookmarkedNotices(String userToken); +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/dto/AdminFeedbacksResult.java b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/AdminFeedbacksResult.java new file mode 100644 index 00000000..27979bb6 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/AdminFeedbacksResult.java @@ -0,0 +1,10 @@ +package com.kustacks.kuring.user.application.port.in.dto; + +import java.time.LocalDateTime; + +public record AdminFeedbacksResult( + String contents, + Long userId, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserBookmarkCommand.java b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserBookmarkCommand.java new file mode 100644 index 00000000..dd514a7a --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserBookmarkCommand.java @@ -0,0 +1,7 @@ +package com.kustacks.kuring.user.application.port.in.dto; + +public record UserBookmarkCommand( + String userToken, + String articleId +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserBookmarkResult.java b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserBookmarkResult.java new file mode 100644 index 00000000..422d9e03 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserBookmarkResult.java @@ -0,0 +1,10 @@ +package com.kustacks.kuring.user.application.port.in.dto; + +public record UserBookmarkResult( + String articleId, + String postedDate, + String subject, + String category, + String baseUrl +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserCategoriesSubscribeCommand.java b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserCategoriesSubscribeCommand.java new file mode 100644 index 00000000..eb55f5a2 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserCategoriesSubscribeCommand.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.user.application.port.in.dto; + +import java.util.List; + +public record UserCategoriesSubscribeCommand( + String userToken, + List categories +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserCategoryNameResult.java b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserCategoryNameResult.java new file mode 100644 index 00000000..50d8e737 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserCategoryNameResult.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.user.application.port.in.dto; + +import com.kustacks.kuring.notice.domain.CategoryName; + +public record UserCategoryNameResult( + String name, + String hostPrefix, + String korName +) { + public static UserCategoryNameResult from(CategoryName name) { + return new UserCategoryNameResult(name.getName(), name.getShortName(), name.getKorName()); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserDepartmentNameResult.java b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserDepartmentNameResult.java new file mode 100644 index 00000000..ec8cf45b --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserDepartmentNameResult.java @@ -0,0 +1,13 @@ +package com.kustacks.kuring.user.application.port.in.dto; + +import com.kustacks.kuring.notice.domain.DepartmentName; + +public record UserDepartmentNameResult( + String name, + String hostPrefix, + String korName +) { + public static UserDepartmentNameResult from(DepartmentName name) { + return new UserDepartmentNameResult(name.getName(), name.getHostPrefix(), name.getKorName()); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserDepartmentsSubscribeCommand.java b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserDepartmentsSubscribeCommand.java new file mode 100644 index 00000000..34ec8a71 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserDepartmentsSubscribeCommand.java @@ -0,0 +1,10 @@ +package com.kustacks.kuring.user.application.port.in.dto; + +import java.util.List; + + +public record UserDepartmentsSubscribeCommand( + String userToken, + List departments +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserFeedbackCommand.java b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserFeedbackCommand.java new file mode 100644 index 00000000..0bab3b0c --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserFeedbackCommand.java @@ -0,0 +1,7 @@ +package com.kustacks.kuring.user.application.port.in.dto; + +public record UserFeedbackCommand( + String userToken, + String content +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserSubscribeCompareResult.java b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserSubscribeCompareResult.java new file mode 100644 index 00000000..6087b9b5 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/in/dto/UserSubscribeCompareResult.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.user.application.port.in.dto; + +import java.util.List; + +public record UserSubscribeCompareResult( + List savedNameList, + List deletedNameList +) { +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/out/UserCommandPort.java b/src/main/java/com/kustacks/kuring/user/application/port/out/UserCommandPort.java new file mode 100644 index 00000000..8a24f4f4 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/out/UserCommandPort.java @@ -0,0 +1,8 @@ +package com.kustacks.kuring.user.application.port.out; + +import com.kustacks.kuring.user.domain.User; + +public interface UserCommandPort { + User save(User user); + void delete(User user); +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/out/UserEventPort.java b/src/main/java/com/kustacks/kuring/user/application/port/out/UserEventPort.java new file mode 100644 index 00000000..e7dc56f5 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/out/UserEventPort.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.user.application.port.out; + +import com.kustacks.kuring.message.application.service.exception.FirebaseSubscribeException; +import com.kustacks.kuring.message.application.service.exception.FirebaseUnSubscribeException; + +public interface UserEventPort { + + void subscribeEvent(String token, String topic) throws FirebaseSubscribeException; + + void unsubscribeEvent(String token, String topic) throws FirebaseUnSubscribeException; +} diff --git a/src/main/java/com/kustacks/kuring/user/application/port/out/UserQueryPort.java b/src/main/java/com/kustacks/kuring/user/application/port/out/UserQueryPort.java new file mode 100644 index 00000000..36502d43 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/port/out/UserQueryPort.java @@ -0,0 +1,12 @@ +package com.kustacks.kuring.user.application.port.out; + +import com.kustacks.kuring.user.domain.User; + +import java.util.List; +import java.util.Optional; + +public interface UserQueryPort { + + Optional findByToken(String token); + List findAll(); +} diff --git a/src/main/java/com/kustacks/kuring/user/common/dto/BookmarkDto.java b/src/main/java/com/kustacks/kuring/user/application/port/out/dto/BookmarkDto.java similarity index 92% rename from src/main/java/com/kustacks/kuring/user/common/dto/BookmarkDto.java rename to src/main/java/com/kustacks/kuring/user/application/port/out/dto/BookmarkDto.java index b8fdb95a..1347b585 100644 --- a/src/main/java/com/kustacks/kuring/user/common/dto/BookmarkDto.java +++ b/src/main/java/com/kustacks/kuring/user/application/port/out/dto/BookmarkDto.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.user.common.dto; +package com.kustacks.kuring.user.application.port.out.dto; import com.querydsl.core.annotations.QueryProjection; import lombok.AccessLevel; diff --git a/src/main/java/com/kustacks/kuring/admin/common/dto/FeedbackDto.java b/src/main/java/com/kustacks/kuring/user/application/port/out/dto/FeedbackDto.java similarity index 90% rename from src/main/java/com/kustacks/kuring/admin/common/dto/FeedbackDto.java rename to src/main/java/com/kustacks/kuring/user/application/port/out/dto/FeedbackDto.java index 623030f8..0e13b046 100644 --- a/src/main/java/com/kustacks/kuring/admin/common/dto/FeedbackDto.java +++ b/src/main/java/com/kustacks/kuring/user/application/port/out/dto/FeedbackDto.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.admin.common.dto; +package com.kustacks.kuring.user.application.port.out.dto; import com.querydsl.core.annotations.QueryProjection; import lombok.AccessLevel; diff --git a/src/main/java/com/kustacks/kuring/user/application/service/UserCommandService.java b/src/main/java/com/kustacks/kuring/user/application/service/UserCommandService.java new file mode 100644 index 00000000..e7805aa6 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/service/UserCommandService.java @@ -0,0 +1,203 @@ +package com.kustacks.kuring.user.application.service; + +import com.kustacks.kuring.common.annotation.UseCase; +import com.kustacks.kuring.common.exception.NotFoundException; +import com.kustacks.kuring.common.exception.code.ErrorCode; +import com.kustacks.kuring.common.properties.ServerProperties; +import com.kustacks.kuring.message.application.service.exception.FirebaseSubscribeException; +import com.kustacks.kuring.message.application.service.exception.FirebaseUnSubscribeException; +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.notice.domain.DepartmentName; +import com.kustacks.kuring.user.application.port.in.UserCommandUseCase; +import com.kustacks.kuring.user.application.port.in.dto.*; +import com.kustacks.kuring.user.application.port.out.UserCommandPort; +import com.kustacks.kuring.user.application.port.out.UserEventPort; +import com.kustacks.kuring.user.application.port.out.UserQueryPort; +import com.kustacks.kuring.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +import static com.kustacks.kuring.message.application.service.FirebaseSubscribeService.ALL_DEVICE_SUBSCRIBED_TOPIC; + +@Slf4j +@UseCase +@Transactional +@RequiredArgsConstructor +class UserCommandService implements UserCommandUseCase { + + private final UserCommandPort userCommandPort; + private final UserQueryPort userQueryPort; + private final UserEventPort userEventPort; + private final ServerProperties serverProperties; + + @Override + public void editSubscribeCategories(UserCategoriesSubscribeCommand command) { + UserSubscribeCompareResult compareResults = + this.editSubscribeCategoryList(command.userToken(), command.categories()); + + editUserCategoryList( + command.userToken(), + compareResults.savedNameList(), + compareResults.deletedNameList() + ); + } + + @Override + public void editSubscribeDepartments(UserDepartmentsSubscribeCommand command) { + UserSubscribeCompareResult compareResults + = this.editSubscribeDepartmentList(command.userToken(), command.departments()); + + editDepartmentNameList( + command.userToken(), + compareResults.savedNameList(), + compareResults.deletedNameList() + ); + } + + @Override + public void saveFeedback(UserFeedbackCommand command) { + User findUser = findUserByToken(command.userToken()); + findUser.addFeedback(command.content()); + } + + @Override + public void saveBookmark(UserBookmarkCommand command) { + User user = findUserByToken(command.userToken()); + user.addBookmark(command.articleId()); + } + + private UserSubscribeCompareResult editSubscribeCategoryList( + String userToken, + List newCategoryStringNames + ) { + User user = findUserByToken(userToken); + + List newCategoryNames = convertToEnumList(newCategoryStringNames); + + List savedCategoryNames = user.filteringNewCategoryName(newCategoryNames); + List deletedCategoryNames = user.filteringOldCategoryName(newCategoryNames); + + return new UserSubscribeCompareResult<>(savedCategoryNames, deletedCategoryNames); + } + + private void editUserCategoryList( + String userToken, + List savedCategoryNames, + List deletedCategoryNames + ) throws FirebaseSubscribeException, FirebaseUnSubscribeException { + subscribeUserCategory(userToken, savedCategoryNames); + unsubscribeUserCategory(userToken, deletedCategoryNames); + } + + private UserSubscribeCompareResult editSubscribeDepartmentList( + String userToken, + List departments + ) { + User user = findUserByToken(userToken); + + List newDepartmentNames = convertHostPrefixToEnum(departments); + + List savedDepartmentNames = user.filteringNewDepartmentName(newDepartmentNames); + List deletedDepartmentNames = user.filteringOldDepartmentName(newDepartmentNames); + return new UserSubscribeCompareResult<>(savedDepartmentNames, deletedDepartmentNames); + } + + private void subscribeUserCategory( + String token, + List newCategoryNames + ) throws FirebaseSubscribeException { + for (CategoryName newCategoryName : newCategoryNames) { + userEventPort.subscribeEvent(token, newCategoryName.getName()); + this.subscribeCategory(token, newCategoryName); + log.debug("구독 성공 = {}", newCategoryName.getName()); + } + } + + private void unsubscribeUserCategory( + String token, + List removeCategoryNames + ) throws FirebaseUnSubscribeException { + for (CategoryName removeCategoryName : removeCategoryNames) { + userEventPort.unsubscribeEvent(token, removeCategoryName.getName()); + this.unsubscribeCategory(token, removeCategoryName); + log.debug("구독 취소 = {}", removeCategoryName.getName()); + } + } + + private void editDepartmentNameList( + String userToken, + List savedDepartmentNames, + List deletedDepartmentNames + ) throws FirebaseSubscribeException, FirebaseUnSubscribeException { + subscribeDepartment(userToken, savedDepartmentNames); + unsubscribeDepartment(userToken, deletedDepartmentNames); + } + + private void subscribeCategory(String userToken, CategoryName categoryName) { + User user = findUserByToken(userToken); + user.subscribeCategory(categoryName); + } + + private void unsubscribeCategory(String userToken, CategoryName categoryName) { + User user = findUserByToken(userToken); + user.unsubscribeCategory(categoryName); + } + + private void subscribeDepartment( + String userToken, + List newDepartmentNames + ) { + for (DepartmentName newDepartmentName : newDepartmentNames) { + userEventPort.subscribeEvent(userToken, newDepartmentName.getName()); + this.subscribeDepartment(userToken, newDepartmentName); + log.debug("구독 성공 = {}", newDepartmentName.getName()); + } + } + + private void unsubscribeDepartment( + String userToken, + List removeDepartmentNames + ) { + for (DepartmentName removeDepartmentName : removeDepartmentNames) { + userEventPort.unsubscribeEvent(userToken, removeDepartmentName.getName()); + this.unsubscribeDepartment(userToken, removeDepartmentName); + log.debug("구독 취소 = {}", removeDepartmentName.getName()); + } + } + + private void subscribeDepartment(String userToken, DepartmentName newDepartmentName) { + User user = findUserByToken(userToken); + user.subscribeDepartment(newDepartmentName); + } + + private void unsubscribeDepartment(String userToken, DepartmentName removeDepartmentName) { + User user = findUserByToken(userToken); + user.unsubscribeDepartment(removeDepartmentName); + } + + private User findUserByToken(String token) { + Optional optionalUser = userQueryPort.findByToken(token); + if (optionalUser.isEmpty()) { + optionalUser = Optional.of(userCommandPort.save(new User(token))); + userEventPort.subscribeEvent(token, serverProperties.ifDevThenAddSuffix(ALL_DEVICE_SUBSCRIBED_TOPIC)); + } + + return optionalUser.orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + } + + private List convertToEnumList(List categories) { + return categories.stream() + .map(CategoryName::fromStringName) + .toList(); + } + + private List convertHostPrefixToEnum(List departments) { + return departments.stream() + .map(DepartmentName::fromHostPrefix) + .toList(); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/application/service/UserQueryService.java b/src/main/java/com/kustacks/kuring/user/application/service/UserQueryService.java new file mode 100644 index 00000000..ea9faf8b --- /dev/null +++ b/src/main/java/com/kustacks/kuring/user/application/service/UserQueryService.java @@ -0,0 +1,88 @@ +package com.kustacks.kuring.user.application.service; + +import com.kustacks.kuring.common.annotation.UseCase; +import com.kustacks.kuring.common.exception.NotFoundException; +import com.kustacks.kuring.common.exception.code.ErrorCode; +import com.kustacks.kuring.common.properties.ServerProperties; +import com.kustacks.kuring.notice.application.port.out.NoticeQueryPort; +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.notice.domain.DepartmentName; +import com.kustacks.kuring.user.application.port.in.UserQueryUseCase; +import com.kustacks.kuring.user.application.port.in.dto.UserBookmarkResult; +import com.kustacks.kuring.user.application.port.in.dto.UserCategoryNameResult; +import com.kustacks.kuring.user.application.port.in.dto.UserDepartmentNameResult; +import com.kustacks.kuring.user.application.port.out.UserCommandPort; +import com.kustacks.kuring.user.application.port.out.UserEventPort; +import com.kustacks.kuring.user.application.port.out.UserQueryPort; +import com.kustacks.kuring.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +import static com.kustacks.kuring.message.application.service.FirebaseSubscribeService.ALL_DEVICE_SUBSCRIBED_TOPIC; + +@Slf4j +@UseCase +@Transactional(readOnly = true) +@RequiredArgsConstructor +class UserQueryService implements UserQueryUseCase { + + private final UserCommandPort userCommandPort; + private final UserQueryPort userQueryPort; + private final NoticeQueryPort noticeQueryPort; + private final UserEventPort userEventPort; + private final ServerProperties serverProperties; + + @Override + public List lookupSubscribeCategories(String userToken) { + User findUser = findUserByToken(userToken); + return convertCategoryNameDtoList(findUser.getSubscribedCategoryList()); + } + + @Override + public List lookupSubscribeDepartments(String userToken) { + User findUser = findUserByToken(userToken); + return convertDepartmentDtoList(findUser.getSubscribedDepartmentList()); + } + + @Override + public List lookupUserBookmarkedNotices(String userToken) { + User user = findUserByToken(userToken); + List bookmarkIds = user.lookupAllBookmarkIds(); + + return noticeQueryPort.findAllByBookmarkIds(bookmarkIds) + .stream() + .map(dto -> new UserBookmarkResult( + dto.getArticleId(), + dto.getPostedDate(), + dto.getSubject(), + dto.getCategory(), + dto.getBaseUrl()) + ).toList(); + } + + private User findUserByToken(String token) { + Optional optionalUser = userQueryPort.findByToken(token); + if (optionalUser.isEmpty()) { + optionalUser = Optional.of(userCommandPort.save(new User(token))); + userEventPort.subscribeEvent(token, serverProperties.ifDevThenAddSuffix(ALL_DEVICE_SUBSCRIBED_TOPIC)); + } + + return optionalUser.orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + } + + private List convertCategoryNameDtoList(List categoryNamesList) { + return categoryNamesList.stream() + .map(UserCategoryNameResult::from) + .toList(); + } + + private List convertDepartmentDtoList(List departmentNames) { + return departmentNames.stream() + .map(UserDepartmentNameResult::from) + .toList(); + } +} diff --git a/src/main/java/com/kustacks/kuring/user/business/UserService.java b/src/main/java/com/kustacks/kuring/user/business/UserService.java deleted file mode 100644 index 9d0e1601..00000000 --- a/src/main/java/com/kustacks/kuring/user/business/UserService.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.kustacks.kuring.user.business; - -import com.kustacks.kuring.admin.common.dto.FeedbackDto; -import com.kustacks.kuring.common.exception.NotFoundException; -import com.kustacks.kuring.common.exception.code.ErrorCode; -import com.kustacks.kuring.message.firebase.FirebaseService; -import com.kustacks.kuring.message.firebase.ServerProperties; -import com.kustacks.kuring.notice.domain.CategoryName; -import com.kustacks.kuring.notice.domain.DepartmentName; -import com.kustacks.kuring.notice.domain.NoticeRepository; -import com.kustacks.kuring.user.common.dto.BookmarkDto; -import com.kustacks.kuring.user.common.dto.SubscribeCompareResultDto; -import com.kustacks.kuring.user.domain.User; -import com.kustacks.kuring.user.domain.UserRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Optional; - -import static com.kustacks.kuring.message.firebase.FirebaseService.ALL_DEVICE_SUBSCRIBED_TOPIC; - -@Slf4j -@Service -@Transactional -@RequiredArgsConstructor -public class UserService { - - private final UserRepository userRepository; - private final NoticeRepository noticeRepository; - private final FirebaseService firebaseService; - private final ServerProperties serverProperties; - - @Transactional(readOnly = true) - public List lookupSubscribeDepartmentList(String id) { - User findUser = findUserByToken(id); - return findUser.getSubscribedDepartmentList(); - } - - @Transactional(readOnly = true) - public List lookUpUserCategories(String token) { - User findUser = findUserByToken(token); - return findUser.getSubscribedCategoryList(); - } - - @Transactional(readOnly = true) - public List lookupFeedbacks(int page, int size) { - PageRequest pageRequest = PageRequest.of(page, size); - return userRepository.findAllFeedbackByPageRequest(pageRequest); - } - - @Transactional(readOnly = true) - public List lookupUserBookmarkedNotices(String userToken) { - User user = findUserByToken(userToken); - List bookmarkIds = user.lookupAllBookmarkIds(); - return noticeRepository.findAllByBookmarkIds(bookmarkIds); - } - - public void saveFeedback(String token, String content) { - User findUser = findUserByToken(token); - findUser.addFeedback(content); - } - - public SubscribeCompareResultDto editSubscribeCategoryList( - String userToken, List newCategoryStringNames) { - User user = findUserByToken(userToken); - - List newCategoryNames = convertToEnumList(newCategoryStringNames); - - List savedCategoryNames = user.filteringNewCategoryName(newCategoryNames); - List deletedCategoryNames = user.filteringOldCategoryName(newCategoryNames); - - return new SubscribeCompareResultDto<>(savedCategoryNames, deletedCategoryNames); - } - - public void subscribeCategory(String userToken, CategoryName categoryName) { - User user = findUserByToken(userToken); - user.subscribeCategory(categoryName); - } - - public void unsubscribeCategory(String userToken, CategoryName categoryName) { - User user = findUserByToken(userToken); - user.unsubscribeCategory(categoryName); - } - - public SubscribeCompareResultDto editSubscribeDepartmentList(String userToken, List departments) { - User user = findUserByToken(userToken); - - List newDepartmentNames = convertHostPrefixToEnum(departments); - - List savedDepartmentNames = user.filteringNewDepartmentName(newDepartmentNames); - List deletedDepartmentNames = user.filteringOldDepartmentName(newDepartmentNames); - return new SubscribeCompareResultDto<>(savedDepartmentNames, deletedDepartmentNames); - } - - public void subscribeDepartment(String userToken, DepartmentName newDepartmentName) { - User user = findUserByToken(userToken); - user.subscribeDepartment(newDepartmentName); - } - - public void unsubscribeDepartment(String userToken, DepartmentName removeDepartmentName) { - User user = findUserByToken(userToken); - user.unsubscribeDepartment(removeDepartmentName); - } - - public void saveBookmark(String userToken, String articleId) { - User user = findUserByToken(userToken); - user.addBookmark(articleId); - } - - private User findUserByToken(String token) { - Optional optionalUser = userRepository.findByToken(token); - if (optionalUser.isEmpty()) { - optionalUser = Optional.of(userRepository.save(new User(token))); - firebaseService.subscribe(token, serverProperties.ifDevThenAddSuffix(ALL_DEVICE_SUBSCRIBED_TOPIC)); - } - - return optionalUser.orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); - } - - private List convertToEnumList(List categories) { - return categories.stream() - .map(CategoryName::fromStringName) - .toList(); - } - - private List convertHostPrefixToEnum(List departments) { - return departments.stream() - .map(DepartmentName::fromHostPrefix) - .toList(); - } -} diff --git a/src/main/java/com/kustacks/kuring/user/common/dto/SaveBookmarkRequest.java b/src/main/java/com/kustacks/kuring/user/common/dto/SaveBookmarkRequest.java deleted file mode 100644 index e6cdfe50..00000000 --- a/src/main/java/com/kustacks/kuring/user/common/dto/SaveBookmarkRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.kustacks.kuring.user.common.dto; - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import javax.validation.constraints.NotBlank; - -@Getter -@AllArgsConstructor -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class SaveBookmarkRequest { - - @NotBlank - private String articleId; -} diff --git a/src/main/java/com/kustacks/kuring/user/common/dto/SaveFeedbackRequest.java b/src/main/java/com/kustacks/kuring/user/common/dto/SaveFeedbackRequest.java deleted file mode 100644 index a37efe76..00000000 --- a/src/main/java/com/kustacks/kuring/user/common/dto/SaveFeedbackRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.kustacks.kuring.user.common.dto; - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.Size; - -@Getter -@AllArgsConstructor -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class SaveFeedbackRequest { - - @NotBlank - @Size(min = 5, max = 256) - private String content; -} diff --git a/src/main/java/com/kustacks/kuring/user/common/dto/SaveFeedbackResponse.java b/src/main/java/com/kustacks/kuring/user/common/dto/SaveFeedbackResponse.java deleted file mode 100644 index b61db4c0..00000000 --- a/src/main/java/com/kustacks/kuring/user/common/dto/SaveFeedbackResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.kustacks.kuring.user.common.dto; - -import com.kustacks.kuring.common.dto.ResponseDto; -import lombok.Getter; - -@Getter -public class SaveFeedbackResponse extends ResponseDto { - - public SaveFeedbackResponse() { - super(true, "성공", 201); - } -} diff --git a/src/main/java/com/kustacks/kuring/user/common/dto/SubscribeCategoriesRequest.java b/src/main/java/com/kustacks/kuring/user/common/dto/SubscribeCategoriesRequest.java deleted file mode 100644 index ec25e72a..00000000 --- a/src/main/java/com/kustacks/kuring/user/common/dto/SubscribeCategoriesRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.kustacks.kuring.user.common.dto; - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import javax.validation.constraints.NotNull; -import java.util.List; - -@Getter -@AllArgsConstructor -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class SubscribeCategoriesRequest { - - @NotNull - private List categories; -} diff --git a/src/main/java/com/kustacks/kuring/user/common/dto/SubscribeCompareResultDto.java b/src/main/java/com/kustacks/kuring/user/common/dto/SubscribeCompareResultDto.java deleted file mode 100644 index a976dc26..00000000 --- a/src/main/java/com/kustacks/kuring/user/common/dto/SubscribeCompareResultDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.kustacks.kuring.user.common.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.util.List; - -@Getter -@AllArgsConstructor -public class SubscribeCompareResultDto { - - private List savedNameList; - private List deletedNameList; -} diff --git a/src/main/java/com/kustacks/kuring/user/common/dto/SubscribeDepartmentsRequest.java b/src/main/java/com/kustacks/kuring/user/common/dto/SubscribeDepartmentsRequest.java deleted file mode 100644 index 964c4114..00000000 --- a/src/main/java/com/kustacks/kuring/user/common/dto/SubscribeDepartmentsRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.kustacks.kuring.user.common.dto; - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import javax.validation.constraints.NotNull; -import java.util.List; - - -@Getter -@AllArgsConstructor -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class SubscribeDepartmentsRequest { - - @NotNull - private List departments; -} diff --git a/src/main/java/com/kustacks/kuring/user/domain/UserQueryRepository.java b/src/main/java/com/kustacks/kuring/user/domain/UserQueryRepository.java deleted file mode 100644 index 5a493589..00000000 --- a/src/main/java/com/kustacks/kuring/user/domain/UserQueryRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.kustacks.kuring.user.domain; - -import com.kustacks.kuring.admin.common.dto.FeedbackDto; -import org.springframework.data.domain.Pageable; - -import java.util.List; - -public interface UserQueryRepository { - - List findAllFeedbackByPageRequest(Pageable pageable); -} diff --git a/src/main/java/com/kustacks/kuring/user/facade/UserCommandFacade.java b/src/main/java/com/kustacks/kuring/user/facade/UserCommandFacade.java deleted file mode 100644 index 72377368..00000000 --- a/src/main/java/com/kustacks/kuring/user/facade/UserCommandFacade.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.kustacks.kuring.user.facade; - -import com.kustacks.kuring.message.firebase.FirebaseService; -import com.kustacks.kuring.message.firebase.exception.FirebaseSubscribeException; -import com.kustacks.kuring.message.firebase.exception.FirebaseUnSubscribeException; -import com.kustacks.kuring.notice.domain.CategoryName; -import com.kustacks.kuring.notice.domain.DepartmentName; -import com.kustacks.kuring.user.business.UserService; -import com.kustacks.kuring.user.common.dto.SubscribeCompareResultDto; -import com.kustacks.kuring.worker.event.Events; -import com.kustacks.kuring.worker.event.SubscribedRollbackEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import javax.transaction.Transactional; -import java.util.List; - -@Slf4j -@Service -@Transactional -@RequiredArgsConstructor -public class UserCommandFacade { - - private final UserService userService; - private final FirebaseService firebaseService; - - public void editSubscribeCategories(String userToken, List newCategoryNames) { - firebaseService.validationToken(userToken); - SubscribeCompareResultDto compareResults = userService.editSubscribeCategoryList(userToken, newCategoryNames); - editUserCategoryList(userToken, compareResults.getSavedNameList(), compareResults.getDeletedNameList()); - } - - public void editSubscribeDepartments(String userToken, List departments) { - firebaseService.validationToken(userToken); - SubscribeCompareResultDto compareResults = userService.editSubscribeDepartmentList(userToken, departments); - editDepartmentNameList(userToken, compareResults.getSavedNameList(), compareResults.getDeletedNameList()); - } - - public void saveFeedback(String userToken, String feedback) { - firebaseService.validationToken(userToken); - userService.saveFeedback(userToken, feedback); - } - - public void saveBookmark(String userToken, String articleId) { - firebaseService.validationToken(userToken); - userService.saveBookmark(userToken, articleId); - } - - private void editUserCategoryList( - String userToken, List savedCategoryNames, List deletedCategoryNames) - throws FirebaseSubscribeException, FirebaseUnSubscribeException - { - SubscribedRollbackEvent subscribedRollbackEvent = new SubscribedRollbackEvent(userToken); - Events.raise(subscribedRollbackEvent); - - subscribeUserCategory(userToken, savedCategoryNames, subscribedRollbackEvent); - unsubscribeUserCategory(userToken, deletedCategoryNames, subscribedRollbackEvent); - } - - private void subscribeUserCategory( - String token, - List newCategoryNames, - SubscribedRollbackEvent subscribedRollbackEvent) throws FirebaseSubscribeException - { - for (CategoryName newCategoryName : newCategoryNames) { - firebaseService.subscribe(token, newCategoryName.getName()); - userService.subscribeCategory(token, newCategoryName); - subscribedRollbackEvent.addNewCategoryName(newCategoryName.getName()); - log.info("구독 성공 = {}", newCategoryName.getName()); - } - } - - private void unsubscribeUserCategory( - String token, - List removeCategoryNames, - SubscribedRollbackEvent subscribedRollbackEvent) throws FirebaseUnSubscribeException - { - for (CategoryName removeCategoryName : removeCategoryNames) { - firebaseService.unsubscribe(token, removeCategoryName.getName()); - userService.unsubscribeCategory(token, removeCategoryName); - subscribedRollbackEvent.deleteNewCategoryName(removeCategoryName.getName()); - log.info("구독 취소 = {}", removeCategoryName.getName()); - } - } - - private void editDepartmentNameList( - String userToken, List savedDepartmentNames, List deletedDepartmentNames) - throws FirebaseSubscribeException, FirebaseUnSubscribeException - { - SubscribedRollbackEvent subscribedRollbackEvent = new SubscribedRollbackEvent(userToken); - Events.raise(subscribedRollbackEvent); - - subscribeDepartment(userToken, savedDepartmentNames, subscribedRollbackEvent); - unsubscribeDepartment(userToken, deletedDepartmentNames, subscribedRollbackEvent); - } - - private void subscribeDepartment(String userToken, List newDepartmentNames, SubscribedRollbackEvent subscribedRollbackEvent) { - for (DepartmentName newDepartmentName : newDepartmentNames) { - firebaseService.subscribe(userToken, newDepartmentName.getName()); - userService.subscribeDepartment(userToken, newDepartmentName); - subscribedRollbackEvent.addNewCategoryName(newDepartmentName.getName()); - log.info("구독 성공 = {}", newDepartmentName.getName()); - } - } - - private void unsubscribeDepartment(String userToken, List removeDepartmentNames, SubscribedRollbackEvent subscribedRollbackEvent) { - for (DepartmentName removeDepartmentName : removeDepartmentNames) { - firebaseService.unsubscribe(userToken, removeDepartmentName.getName()); - userService.unsubscribeDepartment(userToken, removeDepartmentName); - subscribedRollbackEvent.deleteNewCategoryName(removeDepartmentName.getName()); - log.info("구독 취소 = {}", removeDepartmentName.getName()); - } - } -} diff --git a/src/main/java/com/kustacks/kuring/user/facade/UserQueryFacade.java b/src/main/java/com/kustacks/kuring/user/facade/UserQueryFacade.java deleted file mode 100644 index 60089a0d..00000000 --- a/src/main/java/com/kustacks/kuring/user/facade/UserQueryFacade.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.kustacks.kuring.user.facade; - -import com.kustacks.kuring.admin.common.dto.FeedbackDto; -import com.kustacks.kuring.message.firebase.FirebaseService; -import com.kustacks.kuring.notice.common.dto.CategoryNameDto; -import com.kustacks.kuring.notice.common.dto.DepartmentNameDto; -import com.kustacks.kuring.notice.domain.CategoryName; -import com.kustacks.kuring.notice.domain.DepartmentName; -import com.kustacks.kuring.user.business.UserService; -import com.kustacks.kuring.user.common.dto.BookmarkDto; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class UserQueryFacade { - - private final UserService userService; - private final FirebaseService firebaseService; - - public List lookupSubscribeCategories(String userToken) { - firebaseService.validationToken(userToken); - return convertCategoryNameDtoList(userService.lookUpUserCategories(userToken)); - } - - public List lookupSubscribeDepartments(String userToken) { - firebaseService.validationToken(userToken); - return convertDepartmentDtoList(userService.lookupSubscribeDepartmentList(userToken)); - } - - public List lookupFeedbacks(int page, int size) { - return userService.lookupFeedbacks(page, size); - } - - public List lookupUserBookmarkedNotices(String userToken) { - return userService.lookupUserBookmarkedNotices(userToken); - } - - private List convertCategoryNameDtoList(List categoryNamesList) { - return categoryNamesList.stream() - .map(CategoryNameDto::from) - .toList(); - } - - private List convertDepartmentDtoList(List departmentNames) { - return departmentNames.stream() - .map(DepartmentNameDto::from) - .toList(); - } -} diff --git a/src/main/java/com/kustacks/kuring/user/presentation/UserCommandApiV2.java b/src/main/java/com/kustacks/kuring/user/presentation/UserCommandApiV2.java deleted file mode 100644 index fb0cdc66..00000000 --- a/src/main/java/com/kustacks/kuring/user/presentation/UserCommandApiV2.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.kustacks.kuring.user.presentation; - -import com.kustacks.kuring.common.dto.BaseResponse; -import com.kustacks.kuring.user.common.dto.SaveBookmarkRequest; -import com.kustacks.kuring.user.common.dto.SaveFeedbackRequest; -import com.kustacks.kuring.user.common.dto.SubscribeCategoriesRequest; -import com.kustacks.kuring.user.common.dto.SubscribeDepartmentsRequest; -import com.kustacks.kuring.user.facade.UserCommandFacade; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import javax.validation.Valid; - -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.*; - -@Slf4j -@Validated -@RestController -@RequiredArgsConstructor -@RequestMapping(value = "/api/v2/users", produces = MediaType.APPLICATION_JSON_VALUE) -public class UserCommandApiV2 { - - private static final String USER_TOKEN_HEADER_KEY = "User-Token"; - - private final UserCommandFacade userCommandFacade; - - @PostMapping(value = "/subscriptions/categories", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity> editUserSubscribeCategories( - @Valid @RequestBody SubscribeCategoriesRequest request, - @RequestHeader(USER_TOKEN_HEADER_KEY) String id - ) { - userCommandFacade.editSubscribeCategories(id, request.getCategories()); - return ResponseEntity.ok().body(new BaseResponse<>(CATEGORY_SUBSCRIBE_SUCCESS, null)); - } - - @PostMapping(value = "/subscriptions/departments", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity> editUserSubscribeDepartments( - @Valid @RequestBody SubscribeDepartmentsRequest request, - @RequestHeader(USER_TOKEN_HEADER_KEY) String id - ) { - userCommandFacade.editSubscribeDepartments(id, request.getDepartments()); - return ResponseEntity.ok().body(new BaseResponse<>(DEPARTMENTS_SUBSCRIBE_SUCCESS, null)); - } - - @PostMapping("/feedbacks") - public ResponseEntity> saveFeedback( - @Valid @RequestBody SaveFeedbackRequest request, - @RequestHeader(USER_TOKEN_HEADER_KEY) String id - ) { - userCommandFacade.saveFeedback(id, request.getContent()); - return ResponseEntity.ok().body(new BaseResponse<>(FEEDBACK_SAVE_SUCCESS, null)); - } - - @PostMapping("/bookmarks") - public ResponseEntity> saveBookmark( - @Valid @RequestBody SaveBookmarkRequest request, - @RequestHeader(USER_TOKEN_HEADER_KEY) String id - ) { - userCommandFacade.saveBookmark(id, request.getArticleId()); - return ResponseEntity.ok().body(new BaseResponse<>(BOOKMAKR_SAVE_SUCCESS, null)); - } -} diff --git a/src/main/java/com/kustacks/kuring/user/presentation/UserQueryApiV2.java b/src/main/java/com/kustacks/kuring/user/presentation/UserQueryApiV2.java deleted file mode 100644 index d451f5f4..00000000 --- a/src/main/java/com/kustacks/kuring/user/presentation/UserQueryApiV2.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.kustacks.kuring.user.presentation; - -import com.kustacks.kuring.common.dto.BaseResponse; -import com.kustacks.kuring.notice.common.dto.CategoryNameDto; -import com.kustacks.kuring.notice.common.dto.DepartmentNameDto; -import com.kustacks.kuring.user.common.dto.BookmarkDto; -import com.kustacks.kuring.user.facade.UserQueryFacade; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -import static com.kustacks.kuring.common.dto.ResponseCodeAndMessages.*; - -@Slf4j -@Validated -@RestController -@RequiredArgsConstructor -@RequestMapping(value = "/api/v2/users", produces = MediaType.APPLICATION_JSON_VALUE) -public class UserQueryApiV2 { - - private static final String USER_TOKEN_HEADER_KEY = "User-Token"; - - private final UserQueryFacade userQueryFacade; - - @GetMapping("/subscriptions/categories") - public ResponseEntity>> lookupUserSubscribeCategories(@RequestHeader(USER_TOKEN_HEADER_KEY) String id) { - List categoryNameDtos = userQueryFacade.lookupSubscribeCategories(id); - return ResponseEntity.ok().body(new BaseResponse<>(CATEGORY_USER_SUBSCRIBES_LOOKUP_SUCCESS, categoryNameDtos)); - } - - @GetMapping("/subscriptions/departments") - public ResponseEntity>> lookupUserSubscribeDepartments(@RequestHeader(USER_TOKEN_HEADER_KEY) String id) { - List departmentNameDtos = userQueryFacade.lookupSubscribeDepartments(id); - return ResponseEntity.ok().body(new BaseResponse<>(DEPARTMENTS_USER_SUBSCRIBES_LOOKUP_SUCCESS, departmentNameDtos)); - } - - @GetMapping("/bookmarks") - public ResponseEntity>> lookupUserBookmarks(@RequestHeader(USER_TOKEN_HEADER_KEY) String id) { - List bookmarkedDtos = userQueryFacade.lookupUserBookmarkedNotices(id); - return ResponseEntity.ok().body(new BaseResponse<>(BOOKMARK_LOOKUP_SUCCESS, bookmarkedDtos)); - } -} diff --git a/src/main/java/com/kustacks/kuring/worker/event/SubscribedRollbackEvent.java b/src/main/java/com/kustacks/kuring/worker/event/SubscribedRollbackEvent.java deleted file mode 100644 index d6b4ecfa..00000000 --- a/src/main/java/com/kustacks/kuring/worker/event/SubscribedRollbackEvent.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.kustacks.kuring.worker.event; - -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; - -public class SubscribedRollbackEvent { - - private static final String NEW_CATEGORY_FLAG = "new"; - private static final String REMOVE_CATEGORY_FLAG = "remove"; - - private final Map> transactionHistory; - private final String token; - - public SubscribedRollbackEvent(String token) { - this.token = token; - this.transactionHistory = new HashMap<>(); - historyInit(); - } - - public void addNewCategoryName(String categoryName) { - this.transactionHistory.get(NEW_CATEGORY_FLAG).add(categoryName); - } - - public void deleteNewCategoryName(String categoryName) { - this.transactionHistory.get(REMOVE_CATEGORY_FLAG).add(categoryName); - } - - private void historyInit() { - transactionHistory.put(NEW_CATEGORY_FLAG, new LinkedList<>()); - transactionHistory.put(REMOVE_CATEGORY_FLAG, new LinkedList<>()); - } - - public List getNewUserCategoryNames() { - return this.transactionHistory.get(NEW_CATEGORY_FLAG); - } - - public List getRemovedUserCategoryNames() { - return this.transactionHistory.get(REMOVE_CATEGORY_FLAG); - } - - public String getToken() { - return this.token; - } -} diff --git a/src/main/java/com/kustacks/kuring/worker/event/SubscribedRollbackEventHandler.java b/src/main/java/com/kustacks/kuring/worker/event/SubscribedRollbackEventHandler.java deleted file mode 100644 index cbafc00a..00000000 --- a/src/main/java/com/kustacks/kuring/worker/event/SubscribedRollbackEventHandler.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.kustacks.kuring.worker.event; - -import com.kustacks.kuring.common.exception.code.ErrorCode; -import com.kustacks.kuring.common.exception.InternalLogicException; -import com.kustacks.kuring.message.firebase.FirebaseService; -import com.kustacks.kuring.message.firebase.exception.FirebaseSubscribeException; -import com.kustacks.kuring.message.firebase.exception.FirebaseUnSubscribeException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Slf4j -@Component -@RequiredArgsConstructor -public class SubscribedRollbackEventHandler { - - private final FirebaseService firebaseService; - - @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) - public void rollbackFirebaseRequest(SubscribedRollbackEvent subscribedRollbackEvent) { - try { - String token = subscribedRollbackEvent.getToken(); - - log.info("=== 신청한 구독 롤백 ==="); - for (String newUserCategoryName : subscribedRollbackEvent.getNewUserCategoryNames()) { - firebaseService.unsubscribe(token, newUserCategoryName); - log.info(newUserCategoryName); - } - - log.info("=== 취소한 구독 롤백 ==="); - for (String removeUserCategoryName : subscribedRollbackEvent.getRemovedUserCategoryNames()) { - firebaseService.subscribe(token, removeUserCategoryName); - log.info(removeUserCategoryName); - } - } catch (FirebaseSubscribeException | FirebaseUnSubscribeException e) { - throw new InternalLogicException(ErrorCode.FB_FAIL_ROLLBACK, e); - } - } - -} diff --git a/src/main/java/com/kustacks/kuring/worker/scrap/StaffScraper.java b/src/main/java/com/kustacks/kuring/worker/scrap/StaffScraper.java index 2757d4ab..89434890 100644 --- a/src/main/java/com/kustacks/kuring/worker/scrap/StaffScraper.java +++ b/src/main/java/com/kustacks/kuring/worker/scrap/StaffScraper.java @@ -1,6 +1,6 @@ package com.kustacks.kuring.worker.scrap; -import com.kustacks.kuring.common.dto.StaffDto; +import com.kustacks.kuring.worker.update.staff.dto.StaffDto; import com.kustacks.kuring.common.exception.code.ErrorCode; import com.kustacks.kuring.common.exception.InternalLogicException; import com.kustacks.kuring.worker.scrap.client.staff.StaffApiClient; diff --git a/src/main/java/com/kustacks/kuring/worker/scrap/client/notice/LibraryNoticeApiClient.java b/src/main/java/com/kustacks/kuring/worker/scrap/client/notice/LibraryNoticeApiClient.java index 090d95ea..7a5e5374 100644 --- a/src/main/java/com/kustacks/kuring/worker/scrap/client/notice/LibraryNoticeApiClient.java +++ b/src/main/java/com/kustacks/kuring/worker/scrap/client/notice/LibraryNoticeApiClient.java @@ -17,7 +17,6 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; -import java.util.stream.Collectors; @Slf4j @Component diff --git a/src/main/java/com/kustacks/kuring/worker/update/notice/CategoryNoticeUpdater.java b/src/main/java/com/kustacks/kuring/worker/update/notice/CategoryNoticeUpdater.java index 383bb523..f9a6a0b0 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/notice/CategoryNoticeUpdater.java +++ b/src/main/java/com/kustacks/kuring/worker/update/notice/CategoryNoticeUpdater.java @@ -1,10 +1,10 @@ package com.kustacks.kuring.worker.update.notice; -import com.kustacks.kuring.message.firebase.FirebaseService; +import com.kustacks.kuring.message.application.service.FirebaseNotificationService; +import com.kustacks.kuring.notice.application.port.out.NoticeCommandPort; +import com.kustacks.kuring.notice.application.port.out.NoticeQueryPort; import com.kustacks.kuring.notice.domain.CategoryName; import com.kustacks.kuring.notice.domain.Notice; -import com.kustacks.kuring.notice.domain.NoticeJdbcRepository; -import com.kustacks.kuring.notice.domain.NoticeRepository; import com.kustacks.kuring.worker.scrap.KuisNoticeScraperTemplate; import com.kustacks.kuring.worker.scrap.client.notice.LibraryNoticeApiClient; import com.kustacks.kuring.worker.update.notice.dto.request.KuisNoticeInfo; @@ -26,9 +26,9 @@ public class CategoryNoticeUpdater { private final List kuisNoticeInfoList; private final KuisNoticeScraperTemplate scrapperTemplate; - private final NoticeRepository noticeRepository; - private final NoticeJdbcRepository noticeJdbcRepository; - private final FirebaseService firebaseService; + private final NoticeQueryPort noticeQueryPort; + private final NoticeCommandPort noticeCommandPort; + private final FirebaseNotificationService notificationService; private final LibraryNoticeApiClient libraryNoticeApiClient; private final ThreadPoolTaskExecutor noticeUpdaterThreadTaskExecutor; private final NoticeUpdateSupport noticeUpdateSupport; @@ -49,14 +49,14 @@ public void update() { CompletableFuture .supplyAsync(() -> updateKuisNoticeAsync(kuisNoticeInfo, KuisNoticeInfo::scrapLatestPageHtml), noticeUpdaterThreadTaskExecutor) .thenApply(scrapResults -> compareLatestAndUpdateDB(scrapResults, kuisNoticeInfo.getCategoryName())) - .thenAccept(firebaseService::sendNotificationList); + .thenAccept(notificationService::sendNotificationList); } } private void updateLibrary() { List scrapResults = updateLibraryNotice(CategoryName.LIBRARY); List notices = compareLatestAndUpdateDB(scrapResults, CategoryName.LIBRARY); - firebaseService.sendNotificationList(notices); + notificationService.sendNotificationList(notices); } private List updateLibraryNotice(CategoryName categoryName) { @@ -69,7 +69,7 @@ private List updateKuisNoticeAsync(KuisNoticeInfo deptInf private List compareLatestAndUpdateDB(List scrapResults, CategoryName categoryName) { // DB에서 모든 일반 공지 id를 가져와서 - List savedArticleIds = noticeRepository.findNormalArticleIdsByCategory(categoryName); + List savedArticleIds = noticeQueryPort.findNormalArticleIdsByCategory(categoryName); // db와 싱크를 맞춘다 List newNotices = synchronizationWithDb(scrapResults, savedArticleIds, categoryName); @@ -87,10 +87,10 @@ private List synchronizationWithDb(List scrapResu List deletedNoticesArticleIds = noticeUpdateSupport.filteringSoonDeleteNoticeIds(savedArticleIds, scrapNoticeIds); - noticeJdbcRepository.saveAllCategoryNotices(newNotices); + noticeCommandPort.saveAllCategoryNotices(newNotices); if (!deletedNoticesArticleIds.isEmpty()) { - noticeRepository.deleteAllByIdsAndCategory(categoryName, deletedNoticesArticleIds); + noticeCommandPort.deleteAllByIdsAndCategory(categoryName, deletedNoticesArticleIds); } return newNotices; diff --git a/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentNoticeUpdater.java b/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentNoticeUpdater.java index 8a8b421b..637fb2bc 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentNoticeUpdater.java +++ b/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentNoticeUpdater.java @@ -1,11 +1,11 @@ package com.kustacks.kuring.worker.update.notice; -import com.kustacks.kuring.message.firebase.FirebaseService; +import com.kustacks.kuring.message.application.service.FirebaseNotificationService; +import com.kustacks.kuring.notice.application.port.out.NoticeCommandPort; +import com.kustacks.kuring.notice.application.port.out.NoticeQueryPort; import com.kustacks.kuring.notice.domain.DepartmentName; import com.kustacks.kuring.notice.domain.DepartmentNotice; -import com.kustacks.kuring.notice.domain.NoticeJdbcRepository; -import com.kustacks.kuring.notice.domain.NoticeRepository; import com.kustacks.kuring.worker.scrap.DepartmentNoticeScraperTemplate; import com.kustacks.kuring.worker.scrap.deptinfo.DeptInfo; import com.kustacks.kuring.worker.scrap.dto.ComplexNoticeFormatDto; @@ -31,10 +31,10 @@ public class DepartmentNoticeUpdater { private final List deptInfoList; private final DepartmentNoticeScraperTemplate scrapperTemplate; - private final NoticeRepository noticeRepository; - private final NoticeJdbcRepository noticeJdbcRepository; + private final NoticeQueryPort noticeQueryPort; + private final NoticeCommandPort noticeCommandPort; private final ThreadPoolTaskExecutor noticeUpdaterThreadTaskExecutor; - private final FirebaseService firebaseService; + private final FirebaseNotificationService notificationService; private final NoticeUpdateSupport noticeUpdateSupport; private static long startTime = 0L; @@ -48,7 +48,7 @@ public void update() { CompletableFuture .supplyAsync(() -> updateDepartmentAsync(deptInfo, DeptInfo::scrapLatestPageHtml), noticeUpdaterThreadTaskExecutor) .thenApply(scrapResults -> compareLatestAndUpdateDB(scrapResults, deptInfo.getDeptName())) - .thenAccept(firebaseService::sendNotificationList); + .thenAccept(notificationService::sendNotificationList); } } @@ -84,14 +84,14 @@ private List compareLatestAndUpdateDB(List newNoticeList = new ArrayList<>(); for (ComplexNoticeFormatDto scrapResult : scrapResults) { // DB에서 모든 중요 공지를 가져와서 - List savedImportantArticleIds = noticeRepository.findImportantArticleIdsByDepartment(departmentNameEnum); + List savedImportantArticleIds = noticeQueryPort.findImportantArticleIdsByDepartment(departmentNameEnum); // db와 싱크를 맞춘다 List newImportantNotices = saveNewNotices(scrapResult.getImportantNoticeList(), savedImportantArticleIds, departmentNameEnum, true); newNoticeList.addAll(newImportantNotices); // DB에서 모든 일반 공지 id를 가져와서 - List savedNormalArticleIds = noticeRepository.findNormalArticleIdsByDepartment(departmentNameEnum); + List savedNormalArticleIds = noticeQueryPort.findNormalArticleIdsByDepartment(departmentNameEnum); // db와 싱크를 맞춘다 List newNormalNotices = saveNewNotices(scrapResult.getNormalNoticeList(), savedNormalArticleIds, departmentNameEnum, false); @@ -106,7 +106,7 @@ private List compareLatestAndUpdateDB(List saveNewNotices(List scrapResults, List savedArticleIds, DepartmentName departmentNameEnum, boolean important) { List newNotices = noticeUpdateSupport.filteringSoonSaveDepartmentNotices(scrapResults, savedArticleIds, departmentNameEnum, important); - noticeJdbcRepository.saveAllDepartmentNotices(newNotices); + noticeCommandPort.saveAllDepartmentNotices(newNotices); return newNotices; } @@ -119,13 +119,13 @@ private void compareAllAndUpdateDB(List scrapResults, St for (ComplexNoticeFormatDto scrapResult : scrapResults) { // DB에서 최신 중요 공지를 가져와서 - List savedImportantArticleIds = noticeRepository.findImportantArticleIdsByDepartment(departmentNameEnum); + List savedImportantArticleIds = noticeQueryPort.findImportantArticleIdsByDepartment(departmentNameEnum); // db와 싱크를 맞춘다 synchronizationWithDb(scrapResult.getImportantNoticeList(), savedImportantArticleIds, departmentNameEnum, true); // DB에서 모든 일반 공지의 id를 가져와서 - List savedNormalArticleIds = noticeRepository.findNormalArticleIdsByDepartment(departmentNameEnum); + List savedNormalArticleIds = noticeQueryPort.findNormalArticleIdsByDepartment(departmentNameEnum); // db와 싱크를 맞춘다 synchronizationWithDb(scrapResult.getNormalNoticeList(), savedNormalArticleIds, departmentNameEnum, false); @@ -142,10 +142,10 @@ private void synchronizationWithDb(List scrapResults, Lis List deletedNoticesArticleIds = noticeUpdateSupport.filteringSoonDeleteDepartmentNoticeIds(savedArticleIds, latestNoticeIds); - noticeJdbcRepository.saveAllDepartmentNotices(newNotices); + noticeCommandPort.saveAllDepartmentNotices(newNotices); if (!deletedNoticesArticleIds.isEmpty()) { - noticeRepository.deleteAllByIdsAndDepartment(departmentNameEnum, deletedNoticesArticleIds); + noticeCommandPort.deleteAllByIdsAndDepartment(departmentNameEnum, deletedNoticesArticleIds); } } } diff --git a/src/main/java/com/kustacks/kuring/worker/update/notice/NoticeUpdateSupport.java b/src/main/java/com/kustacks/kuring/worker/update/notice/NoticeUpdateSupport.java index 4097b3f6..56b942ca 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/notice/NoticeUpdateSupport.java +++ b/src/main/java/com/kustacks/kuring/worker/update/notice/NoticeUpdateSupport.java @@ -12,13 +12,16 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; -import java.util.stream.Collectors; @Slf4j @Component public class NoticeUpdateSupport { - public List filteringSoonSaveNotices(List scrapResults, List savedArticleIds, CategoryName categoryName) { + public List filteringSoonSaveNotices( + List scrapResults, + List savedArticleIds, + CategoryName categoryName + ) { List newNotices = new LinkedList<>(); // 뒤에 추가만 계속 하기 때문에 arrayList가 아닌 Linked List 사용 O(1) for (CommonNoticeFormatDto notice : scrapResults) { try { @@ -37,12 +40,17 @@ public List filteringSoonSaveNotices(List scrapRe return newNotices; } - public List filteringSoonSaveDepartmentNotices(List scrapResults, List savedArticleIds, DepartmentName departmentNameEnum, boolean important) { + public List filteringSoonSaveDepartmentNotices( + List scrapResults, + List savedArticleIds, + DepartmentName departmentNameEnum, + boolean important + ) { List newNotices = new LinkedList<>(); // 뒤에 추가만 계속 하기 때문에 arrayList가 아닌 Linked List 사용 O(1) for (CommonNoticeFormatDto notice : scrapResults) { try { if (Collections.binarySearch(savedArticleIds, Integer.valueOf(notice.getArticleId())) < 0) { // 정렬되어있다, 이진탐색으로 O(logN)안에 수행 - DepartmentNotice newDepartmentNotice = convert(notice, departmentNameEnum, CategoryName.DEPARTMENT, important); + DepartmentNotice newDepartmentNotice = convert(notice, departmentNameEnum, important); newNotices.add(newDepartmentNotice); } } catch (IncorrectResultSizeDataAccessException e) { @@ -71,14 +79,20 @@ public List extractDepartmentNoticeIds(List scra .toList(); } - public List filteringSoonDeleteNoticeIds(List savedArticleIds, List latestNoticeIds) { + public List filteringSoonDeleteNoticeIds( + List savedArticleIds, + List latestNoticeIds + ) { return savedArticleIds.stream() .filter(savedArticleId -> Collections.binarySearch(latestNoticeIds, savedArticleId) < 0) .map(Object::toString) .toList(); } - public List filteringSoonDeleteDepartmentNoticeIds(List savedArticleIds, List latestNoticeIds) { + public List filteringSoonDeleteDepartmentNoticeIds( + List savedArticleIds, + List latestNoticeIds + ) { return savedArticleIds.stream() .filter(savedArticleId -> Collections.binarySearch(latestNoticeIds, savedArticleId) < 0) .map(Object::toString) @@ -95,7 +109,7 @@ private Notice convert(CommonNoticeFormatDto dto, CategoryName CategoryName) { dto.getFullUrl()); } - private DepartmentNotice convert(CommonNoticeFormatDto dto, DepartmentName departmentNameEnum, CategoryName categoryName, boolean important) { + private DepartmentNotice convert(CommonNoticeFormatDto dto, DepartmentName departmentNameEnum, boolean important) { return DepartmentNotice.builder() .articleId(dto.getArticleId()) .postedDate(dto.getPostedDate()) @@ -103,7 +117,7 @@ private DepartmentNotice convert(CommonNoticeFormatDto dto, DepartmentName depar .subject(dto.getSubject()) .fullUrl(dto.getFullUrl()) .important(important) - .categoryName(categoryName) + .categoryName(CategoryName.DEPARTMENT) .departmentName(departmentNameEnum) .build(); } diff --git a/src/main/java/com/kustacks/kuring/worker/update/staff/StaffUpdater.java b/src/main/java/com/kustacks/kuring/worker/update/staff/StaffUpdater.java index 811aac2f..d2fdc7af 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/staff/StaffUpdater.java +++ b/src/main/java/com/kustacks/kuring/worker/update/staff/StaffUpdater.java @@ -1,9 +1,9 @@ package com.kustacks.kuring.worker.update.staff; -import com.kustacks.kuring.common.dto.StaffDto; +import com.kustacks.kuring.worker.update.staff.dto.StaffDto; import com.kustacks.kuring.common.exception.InternalLogicException; import com.kustacks.kuring.staff.domain.Staff; -import com.kustacks.kuring.staff.domain.StaffRepository; +import com.kustacks.kuring.staff.adapter.out.persistence.StaffRepository; import com.kustacks.kuring.worker.scrap.StaffScraper; import com.kustacks.kuring.worker.scrap.deptinfo.DeptInfo; import lombok.extern.slf4j.Slf4j; @@ -16,7 +16,6 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; @Slf4j @Component diff --git a/src/main/java/com/kustacks/kuring/common/dto/StaffDto.java b/src/main/java/com/kustacks/kuring/worker/update/staff/dto/StaffDto.java similarity index 97% rename from src/main/java/com/kustacks/kuring/common/dto/StaffDto.java rename to src/main/java/com/kustacks/kuring/worker/update/staff/dto/StaffDto.java index d1e96ef0..2c4b4377 100644 --- a/src/main/java/com/kustacks/kuring/common/dto/StaffDto.java +++ b/src/main/java/com/kustacks/kuring/worker/update/staff/dto/StaffDto.java @@ -1,4 +1,4 @@ -package com.kustacks.kuring.common.dto; +package com.kustacks.kuring.worker.update.staff.dto; import com.kustacks.kuring.staff.domain.Staff; import lombok.AccessLevel; diff --git a/src/main/java/com/kustacks/kuring/worker/update/user/UserUpdater.java b/src/main/java/com/kustacks/kuring/worker/update/user/UserUpdater.java index 4bc149f3..d8a13f5e 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/user/UserUpdater.java +++ b/src/main/java/com/kustacks/kuring/worker/update/user/UserUpdater.java @@ -1,9 +1,9 @@ package com.kustacks.kuring.worker.update.user; -import com.kustacks.kuring.message.firebase.FirebaseService; -import com.kustacks.kuring.message.firebase.exception.FirebaseInvalidTokenException; +import com.kustacks.kuring.message.application.port.in.FirebaseWithUserUseCase; +import com.kustacks.kuring.message.application.service.exception.FirebaseInvalidTokenException; +import com.kustacks.kuring.user.adapter.out.persistence.UserPersistenceAdapter; import com.kustacks.kuring.user.domain.User; -import com.kustacks.kuring.user.domain.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -18,8 +18,8 @@ @RequiredArgsConstructor public class UserUpdater { - private final FirebaseService firebaseService; - private final UserRepository userRepository; + private final FirebaseWithUserUseCase firebaseService; + private final UserPersistenceAdapter userPersistenceAdapter; @Transactional @Scheduled(fixedRate = 30, timeUnit = TimeUnit.DAYS) @@ -27,14 +27,14 @@ public void update() { log.info("========== 토큰 유효성 필터링 시작 =========="); - List users = userRepository.findAll(); + List users = userPersistenceAdapter.findAll(); for (User user : users) { String token = user.getToken(); try { firebaseService.validationToken(token); } catch (FirebaseInvalidTokenException e) { - userRepository.delete(user); + userPersistenceAdapter.delete(user); log.info("삭제한 토큰 = {}", user.getToken()); } } diff --git a/src/main/resources/static/assets/img/error-404-monochrome.svg b/src/main/resources/static/assets/img/error-404-monochrome.svg deleted file mode 100644 index f0d345f9..00000000 --- a/src/main/resources/static/assets/img/error-404-monochrome.svg +++ /dev/null @@ -1 +0,0 @@ -error-404-monochrome \ No newline at end of file diff --git a/src/main/resources/static/css/loader-dot.css b/src/main/resources/static/css/loader-dot.css deleted file mode 100644 index cd8dc824..00000000 --- a/src/main/resources/static/css/loader-dot.css +++ /dev/null @@ -1,57 +0,0 @@ -.loader-dot, -.loader-dot:before, -.loader-dot:after { - border-radius: 50%; - width: 2.5em; - height: 2.5em; - -webkit-animation-fill-mode: both; - animation-fill-mode: both; - -webkit-animation: load7 1.8s infinite ease-in-out; - animation: load7 1.8s infinite ease-in-out; -} -.loader-dot { - color: #D3D3D3; - font-size: 8px; - margin: 80px auto; - position: relative; - text-indent: -9999em; - -webkit-transform: translateZ(0); - -ms-transform: translateZ(0); - transform: translateZ(0); - -webkit-animation-delay: -0.16s; - animation-delay: -0.16s; -} -.loader-dot:before, -.loader-dot:after { - content: ''; - position: absolute; - top: 0; -} -.loader-dot:before { - left: -3.5em; - -webkit-animation-delay: -0.32s; - animation-delay: -0.32s; -} -.loader-dot:after { - left: 3.5em; -} -@-webkit-keyframes load7 { - 0%, - 80%, - 100% { - box-shadow: 0 2.5em 0 -1.3em; - } - 40% { - box-shadow: 0 2.5em 0 0; - } -} -@keyframes load7 { - 0%, - 80%, - 100% { - box-shadow: 0 2.5em 0 -1.3em; - } - 40% { - box-shadow: 0 2.5em 0 0; - } -} diff --git a/src/main/resources/static/css/loader.css b/src/main/resources/static/css/loader.css deleted file mode 100644 index 4576fe06..00000000 --- a/src/main/resources/static/css/loader.css +++ /dev/null @@ -1,63 +0,0 @@ -.loader { - font-size: 10px; - margin: 50px auto; - text-indent: -9999em; - width: 11em; - height: 11em; - border-radius: 50%; - background: #D3D3D3; - background: -moz-linear-gradient(left, #D3D3D3 10%, rgba(203,195,195, 0) 42%); - background: -webkit-linear-gradient(left, #D3D3D3 10%, rgba(203,195,195, 0) 42%); - background: -o-linear-gradient(left, #D3D3D3 10%, rgba(203,195,195, 0) 42%); - background: -ms-linear-gradient(left, #D3D3D3 10%, rgba(203,195,195, 0) 42%); - background: linear-gradient(to right, #D3D3D3 10%, rgba(203,195,195, 0) 42%); - position: relative; - -webkit-animation: load3 1.4s infinite linear; - animation: load3 1.4s infinite linear; - -webkit-transform: translateZ(0); - -ms-transform: translateZ(0); - transform: translateZ(0); -} -.loader:before { - width: 50%; - height: 50%; - background: #D3D3D3; - border-radius: 100% 0 0 0; - position: absolute; - top: 0; - left: 0; - content: ''; -} -.loader:after { - background: #ffffff; - width: 75%; - height: 75%; - border-radius: 50%; - content: ''; - margin: auto; - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; -} -@-webkit-keyframes load3 { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } -} -@keyframes load3 { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } -} \ No newline at end of file diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css deleted file mode 100644 index f27be542..00000000 --- a/src/main/resources/static/css/styles.css +++ /dev/null @@ -1,11701 +0,0 @@ -@charset "UTF-8"; -/*! -* Start Bootstrap - SB Admin v7.0.4 (https://startbootstrap.com/template/sb-admin) -* Copyright 2013-2021 Start Bootstrap -* Licensed under MIT (https://github.com/StartBootstrap/startbootstrap-sb-admin/blob/master/LICENSE) -*/ -/*! - * Bootstrap v5.1.3 (https://getbootstrap.com/) - * Copyright 2011-2021 The Bootstrap Authors - * Copyright 2011-2021 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */ -:root { - --bs-blue: #0d6efd; - --bs-indigo: #6610f2; - --bs-purple: #6f42c1; - --bs-pink: #d63384; - --bs-red: #dc3545; - --bs-orange: #fd7e14; - --bs-yellow: #ffc107; - --bs-green: #198754; - --bs-teal: #20c997; - --bs-cyan: #0dcaf0; - --bs-white: #fff; - --bs-gray: #6c757d; - --bs-gray-dark: #343a40; - --bs-gray-100: #f8f9fa; - --bs-gray-200: #e9ecef; - --bs-gray-300: #dee2e6; - --bs-gray-400: #ced4da; - --bs-gray-500: #adb5bd; - --bs-gray-600: #6c757d; - --bs-gray-700: #495057; - --bs-gray-800: #343a40; - --bs-gray-900: #212529; - --bs-primary: #0d6efd; - --bs-secondary: #6c757d; - --bs-success: #198754; - --bs-info: #0dcaf0; - --bs-warning: #ffc107; - --bs-danger: #dc3545; - --bs-light: #f8f9fa; - --bs-dark: #212529; - --bs-primary-rgb: 13, 110, 253; - --bs-secondary-rgb: 108, 117, 125; - --bs-success-rgb: 25, 135, 84; - --bs-info-rgb: 13, 202, 240; - --bs-warning-rgb: 255, 193, 7; - --bs-danger-rgb: 220, 53, 69; - --bs-light-rgb: 248, 249, 250; - --bs-dark-rgb: 33, 37, 41; - --bs-white-rgb: 255, 255, 255; - --bs-black-rgb: 0, 0, 0; - --bs-body-color-rgb: 33, 37, 41; - --bs-body-bg-rgb: 255, 255, 255; - --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); - --bs-body-font-family: var(--bs-font-sans-serif); - --bs-body-font-size: 1rem; - --bs-body-font-weight: 400; - --bs-body-line-height: 1.5; - --bs-body-color: #212529; - --bs-body-bg: #fff; -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -@media (prefers-reduced-motion: no-preference) { - :root { - scroll-behavior: smooth; - } -} - -body { - margin: 0; - font-family: var(--bs-body-font-family); - font-size: var(--bs-body-font-size); - font-weight: var(--bs-body-font-weight); - line-height: var(--bs-body-line-height); - color: var(--bs-body-color); - text-align: var(--bs-body-text-align); - background-color: var(--bs-body-bg); - -webkit-text-size-adjust: 100%; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -} - -hr { - margin: 1rem 0; - color: inherit; - background-color: currentColor; - border: 0; - opacity: 0.25; -} - -hr:not([size]) { - height: 1px; -} - -h6, .h6, h5, .h5, h4, .h4, h3, .h3, h2, .h2, h1, .h1 { - margin-top: 0; - margin-bottom: 0.5rem; - font-weight: 500; - line-height: 1.2; -} - -h1, .h1 { - font-size: calc(1.375rem + 1.5vw); -} -@media (min-width: 1200px) { - h1, .h1 { - font-size: 2.5rem; - } -} - -h2, .h2 { - font-size: calc(1.325rem + 0.9vw); -} -@media (min-width: 1200px) { - h2, .h2 { - font-size: 2rem; - } -} - -h3, .h3 { - font-size: calc(1.3rem + 0.6vw); -} -@media (min-width: 1200px) { - h3, .h3 { - font-size: 1.75rem; - } -} - -h4, .h4 { - font-size: calc(1.275rem + 0.3vw); -} -@media (min-width: 1200px) { - h4, .h4 { - font-size: 1.5rem; - } -} - -h5, .h5 { - font-size: 1.25rem; -} - -h6, .h6 { - font-size: 1rem; -} - -p { - margin-top: 0; - margin-bottom: 1rem; -} - -abbr[title], -abbr[data-bs-original-title] { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; - cursor: help; - -webkit-text-decoration-skip-ink: none; - text-decoration-skip-ink: none; -} - -address { - margin-bottom: 1rem; - font-style: normal; - line-height: inherit; -} - -ol, -ul { - padding-left: 2rem; -} - -ol, -ul, -dl { - margin-top: 0; - margin-bottom: 1rem; -} - -ol ol, -ul ul, -ol ul, -ul ol { - margin-bottom: 0; -} - -dt { - font-weight: 700; -} - -dd { - margin-bottom: 0.5rem; - margin-left: 0; -} - -blockquote { - margin: 0 0 1rem; -} - -b, -strong { - font-weight: bolder; -} - -small, .small { - font-size: 0.875em; -} - -mark, .mark { - padding: 0.2em; - background-color: #fcf8e3; -} - -sub, -sup { - position: relative; - font-size: 0.75em; - line-height: 0; - vertical-align: baseline; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -a { - color: #0d6efd; - text-decoration: underline; -} -a:hover { - color: #0a58ca; -} - -a:not([href]):not([class]), a:not([href]):not([class]):hover { - color: inherit; - text-decoration: none; -} - -pre, -code, -kbd, -samp { - font-family: var(--bs-font-monospace); - font-size: 1em; - direction: ltr /* rtl:ignore */; - unicode-bidi: bidi-override; -} - -pre { - display: block; - margin-top: 0; - margin-bottom: 1rem; - overflow: auto; - font-size: 0.875em; -} -pre code { - font-size: inherit; - color: inherit; - word-break: normal; -} - -code { - font-size: 0.875em; - color: #d63384; - word-wrap: break-word; -} -a > code { - color: inherit; -} - -kbd { - padding: 0.2rem 0.4rem; - font-size: 0.875em; - color: #fff; - background-color: #212529; - border-radius: 0.2rem; -} -kbd kbd { - padding: 0; - font-size: 1em; - font-weight: 700; -} - -figure { - margin: 0 0 1rem; -} - -img, -svg { - vertical-align: middle; -} - -table { - caption-side: bottom; - border-collapse: collapse; -} - -caption { - padding-top: 0.5rem; - padding-bottom: 0.5rem; - color: #6c757d; - text-align: left; -} - -th { - text-align: inherit; - text-align: -webkit-match-parent; -} - -thead, -tbody, -tfoot, -tr, -td, -th { - border-color: inherit; - border-style: solid; - border-width: 0; -} - -label { - display: inline-block; -} - -button { - border-radius: 0; -} - -button:focus:not(:focus-visible) { - outline: 0; -} - -input, -button, -select, -optgroup, -textarea { - margin: 0; - font-family: inherit; - font-size: inherit; - line-height: inherit; -} - -button, -select { - text-transform: none; -} - -[role=button] { - cursor: pointer; -} - -select { - word-wrap: normal; -} -select:disabled { - opacity: 1; -} - -[list]::-webkit-calendar-picker-indicator { - display: none; -} - -button, -[type=button], -[type=reset], -[type=submit] { - -webkit-appearance: button; -} -button:not(:disabled), -[type=button]:not(:disabled), -[type=reset]:not(:disabled), -[type=submit]:not(:disabled) { - cursor: pointer; -} - -::-moz-focus-inner { - padding: 0; - border-style: none; -} - -textarea { - resize: vertical; -} - -fieldset { - min-width: 0; - padding: 0; - margin: 0; - border: 0; -} - -legend { - float: left; - width: 100%; - padding: 0; - margin-bottom: 0.5rem; - font-size: calc(1.275rem + 0.3vw); - line-height: inherit; -} -@media (min-width: 1200px) { - legend { - font-size: 1.5rem; - } -} -legend + * { - clear: left; -} - -::-webkit-datetime-edit-fields-wrapper, -::-webkit-datetime-edit-text, -::-webkit-datetime-edit-minute, -::-webkit-datetime-edit-hour-field, -::-webkit-datetime-edit-day-field, -::-webkit-datetime-edit-month-field, -::-webkit-datetime-edit-year-field { - padding: 0; -} - -::-webkit-inner-spin-button { - height: auto; -} - -[type=search] { - outline-offset: -2px; - -webkit-appearance: textfield; -} - -/* rtl:raw: -[type="tel"], -[type="url"], -[type="email"], -[type="number"] { - direction: ltr; -} -*/ -::-webkit-search-decoration { - -webkit-appearance: none; -} - -::-webkit-color-swatch-wrapper { - padding: 0; -} - -::-webkit-file-upload-button { - font: inherit; -} - -::file-selector-button { - font: inherit; -} - -::-webkit-file-upload-button { - font: inherit; - -webkit-appearance: button; -} - -output { - display: inline-block; -} - -iframe { - border: 0; -} - -summary { - display: list-item; - cursor: pointer; -} - -progress { - vertical-align: baseline; -} - -[hidden] { - display: none !important; -} - -.lead { - font-size: 1.25rem; - font-weight: 300; -} - -.display-1 { - font-size: calc(1.625rem + 4.5vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-1 { - font-size: 5rem; - } -} - -.display-2 { - font-size: calc(1.575rem + 3.9vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-2 { - font-size: 4.5rem; - } -} - -.display-3 { - font-size: calc(1.525rem + 3.3vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-3 { - font-size: 4rem; - } -} - -.display-4 { - font-size: calc(1.475rem + 2.7vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-4 { - font-size: 3.5rem; - } -} - -.display-5 { - font-size: calc(1.425rem + 2.1vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-5 { - font-size: 3rem; - } -} - -.display-6 { - font-size: calc(1.375rem + 1.5vw); - font-weight: 300; - line-height: 1.2; -} -@media (min-width: 1200px) { - .display-6 { - font-size: 2.5rem; - } -} - -.list-unstyled { - padding-left: 0; - list-style: none; -} - -.list-inline { - padding-left: 0; - list-style: none; -} - -.list-inline-item { - display: inline-block; -} -.list-inline-item:not(:last-child) { - margin-right: 0.5rem; -} - -.initialism { - font-size: 0.875em; - text-transform: uppercase; -} - -.blockquote { - margin-bottom: 1rem; - font-size: 1.25rem; -} -.blockquote > :last-child { - margin-bottom: 0; -} - -.blockquote-footer { - margin-top: -1rem; - margin-bottom: 1rem; - font-size: 0.875em; - color: #6c757d; -} -.blockquote-footer::before { - content: "— "; -} - -.img-fluid { - max-width: 100%; - height: auto; -} - -.img-thumbnail { - padding: 0.25rem; - background-color: #fff; - border: 1px solid #dee2e6; - border-radius: 0.25rem; - max-width: 100%; - height: auto; -} - -.figure { - display: inline-block; -} - -.figure-img { - margin-bottom: 0.5rem; - line-height: 1; -} - -.figure-caption { - font-size: 0.875em; - color: #6c757d; -} - -.container, -.container-fluid, -.container-xxl, -.container-xl, -.container-lg, -.container-md, -.container-sm { - width: 100%; - padding-right: var(--bs-gutter-x, 0.75rem); - padding-left: var(--bs-gutter-x, 0.75rem); - margin-right: auto; - margin-left: auto; -} - -@media (min-width: 576px) { - .container-sm, .container { - max-width: 540px; - } -} -@media (min-width: 768px) { - .container-md, .container-sm, .container { - max-width: 720px; - } -} -@media (min-width: 992px) { - .container-lg, .container-md, .container-sm, .container { - max-width: 960px; - } -} -@media (min-width: 1200px) { - .container-xl, .container-lg, .container-md, .container-sm, .container { - max-width: 1140px; - } -} -@media (min-width: 1400px) { - .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container { - max-width: 1320px; - } -} -.row { - --bs-gutter-x: 1.5rem; - --bs-gutter-y: 0; - display: flex; - flex-wrap: wrap; - margin-top: calc(-1 * var(--bs-gutter-y)); - margin-right: calc(-0.5 * var(--bs-gutter-x)); - margin-left: calc(-0.5 * var(--bs-gutter-x)); -} -.row > * { - flex-shrink: 0; - width: 100%; - max-width: 100%; - padding-right: calc(var(--bs-gutter-x) * 0.5); - padding-left: calc(var(--bs-gutter-x) * 0.5); - margin-top: var(--bs-gutter-y); -} - -.col { - flex: 1 0 0%; -} - -.row-cols-auto > * { - flex: 0 0 auto; - width: auto; -} - -.row-cols-1 > * { - flex: 0 0 auto; - width: 100%; -} - -.row-cols-2 > * { - flex: 0 0 auto; - width: 50%; -} - -.row-cols-3 > * { - flex: 0 0 auto; - width: 33.3333333333%; -} - -.row-cols-4 > * { - flex: 0 0 auto; - width: 25%; -} - -.row-cols-5 > * { - flex: 0 0 auto; - width: 20%; -} - -.row-cols-6 > * { - flex: 0 0 auto; - width: 16.6666666667%; -} - -.col-auto { - flex: 0 0 auto; - width: auto; -} - -.col-1 { - flex: 0 0 auto; - width: 8.33333333%; -} - -.col-2 { - flex: 0 0 auto; - width: 16.66666667%; -} - -.col-3 { - flex: 0 0 auto; - width: 25%; -} - -.col-4 { - flex: 0 0 auto; - width: 33.33333333%; -} - -.col-5 { - flex: 0 0 auto; - width: 41.66666667%; -} - -.col-6 { - flex: 0 0 auto; - width: 50%; -} - -.col-7 { - flex: 0 0 auto; - width: 58.33333333%; -} - -.col-8 { - flex: 0 0 auto; - width: 66.66666667%; -} - -.col-9 { - flex: 0 0 auto; - width: 75%; -} - -.col-10 { - flex: 0 0 auto; - width: 83.33333333%; -} - -.col-11 { - flex: 0 0 auto; - width: 91.66666667%; -} - -.col-12 { - flex: 0 0 auto; - width: 100%; -} - -.offset-1 { - margin-left: 8.33333333%; -} - -.offset-2 { - margin-left: 16.66666667%; -} - -.offset-3 { - margin-left: 25%; -} - -.offset-4 { - margin-left: 33.33333333%; -} - -.offset-5 { - margin-left: 41.66666667%; -} - -.offset-6 { - margin-left: 50%; -} - -.offset-7 { - margin-left: 58.33333333%; -} - -.offset-8 { - margin-left: 66.66666667%; -} - -.offset-9 { - margin-left: 75%; -} - -.offset-10 { - margin-left: 83.33333333%; -} - -.offset-11 { - margin-left: 91.66666667%; -} - -.g-0, -.gx-0 { - --bs-gutter-x: 0; -} - -.g-0, -.gy-0 { - --bs-gutter-y: 0; -} - -.g-1, -.gx-1 { - --bs-gutter-x: 0.25rem; -} - -.g-1, -.gy-1 { - --bs-gutter-y: 0.25rem; -} - -.g-2, -.gx-2 { - --bs-gutter-x: 0.5rem; -} - -.g-2, -.gy-2 { - --bs-gutter-y: 0.5rem; -} - -.g-3, -.gx-3 { - --bs-gutter-x: 1rem; -} - -.g-3, -.gy-3 { - --bs-gutter-y: 1rem; -} - -.g-4, -.gx-4 { - --bs-gutter-x: 1.5rem; -} - -.g-4, -.gy-4 { - --bs-gutter-y: 1.5rem; -} - -.g-5, -.gx-5 { - --bs-gutter-x: 3rem; -} - -.g-5, -.gy-5 { - --bs-gutter-y: 3rem; -} - -@media (min-width: 576px) { - .col-sm { - flex: 1 0 0%; - } - - .row-cols-sm-auto > * { - flex: 0 0 auto; - width: auto; - } - - .row-cols-sm-1 > * { - flex: 0 0 auto; - width: 100%; - } - - .row-cols-sm-2 > * { - flex: 0 0 auto; - width: 50%; - } - - .row-cols-sm-3 > * { - flex: 0 0 auto; - width: 33.3333333333%; - } - - .row-cols-sm-4 > * { - flex: 0 0 auto; - width: 25%; - } - - .row-cols-sm-5 > * { - flex: 0 0 auto; - width: 20%; - } - - .row-cols-sm-6 > * { - flex: 0 0 auto; - width: 16.6666666667%; - } - - .col-sm-auto { - flex: 0 0 auto; - width: auto; - } - - .col-sm-1 { - flex: 0 0 auto; - width: 8.33333333%; - } - - .col-sm-2 { - flex: 0 0 auto; - width: 16.66666667%; - } - - .col-sm-3 { - flex: 0 0 auto; - width: 25%; - } - - .col-sm-4 { - flex: 0 0 auto; - width: 33.33333333%; - } - - .col-sm-5 { - flex: 0 0 auto; - width: 41.66666667%; - } - - .col-sm-6 { - flex: 0 0 auto; - width: 50%; - } - - .col-sm-7 { - flex: 0 0 auto; - width: 58.33333333%; - } - - .col-sm-8 { - flex: 0 0 auto; - width: 66.66666667%; - } - - .col-sm-9 { - flex: 0 0 auto; - width: 75%; - } - - .col-sm-10 { - flex: 0 0 auto; - width: 83.33333333%; - } - - .col-sm-11 { - flex: 0 0 auto; - width: 91.66666667%; - } - - .col-sm-12 { - flex: 0 0 auto; - width: 100%; - } - - .offset-sm-0 { - margin-left: 0; - } - - .offset-sm-1 { - margin-left: 8.33333333%; - } - - .offset-sm-2 { - margin-left: 16.66666667%; - } - - .offset-sm-3 { - margin-left: 25%; - } - - .offset-sm-4 { - margin-left: 33.33333333%; - } - - .offset-sm-5 { - margin-left: 41.66666667%; - } - - .offset-sm-6 { - margin-left: 50%; - } - - .offset-sm-7 { - margin-left: 58.33333333%; - } - - .offset-sm-8 { - margin-left: 66.66666667%; - } - - .offset-sm-9 { - margin-left: 75%; - } - - .offset-sm-10 { - margin-left: 83.33333333%; - } - - .offset-sm-11 { - margin-left: 91.66666667%; - } - - .g-sm-0, -.gx-sm-0 { - --bs-gutter-x: 0; - } - - .g-sm-0, -.gy-sm-0 { - --bs-gutter-y: 0; - } - - .g-sm-1, -.gx-sm-1 { - --bs-gutter-x: 0.25rem; - } - - .g-sm-1, -.gy-sm-1 { - --bs-gutter-y: 0.25rem; - } - - .g-sm-2, -.gx-sm-2 { - --bs-gutter-x: 0.5rem; - } - - .g-sm-2, -.gy-sm-2 { - --bs-gutter-y: 0.5rem; - } - - .g-sm-3, -.gx-sm-3 { - --bs-gutter-x: 1rem; - } - - .g-sm-3, -.gy-sm-3 { - --bs-gutter-y: 1rem; - } - - .g-sm-4, -.gx-sm-4 { - --bs-gutter-x: 1.5rem; - } - - .g-sm-4, -.gy-sm-4 { - --bs-gutter-y: 1.5rem; - } - - .g-sm-5, -.gx-sm-5 { - --bs-gutter-x: 3rem; - } - - .g-sm-5, -.gy-sm-5 { - --bs-gutter-y: 3rem; - } -} -@media (min-width: 768px) { - .col-md { - flex: 1 0 0%; - } - - .row-cols-md-auto > * { - flex: 0 0 auto; - width: auto; - } - - .row-cols-md-1 > * { - flex: 0 0 auto; - width: 100%; - } - - .row-cols-md-2 > * { - flex: 0 0 auto; - width: 50%; - } - - .row-cols-md-3 > * { - flex: 0 0 auto; - width: 33.3333333333%; - } - - .row-cols-md-4 > * { - flex: 0 0 auto; - width: 25%; - } - - .row-cols-md-5 > * { - flex: 0 0 auto; - width: 20%; - } - - .row-cols-md-6 > * { - flex: 0 0 auto; - width: 16.6666666667%; - } - - .col-md-auto { - flex: 0 0 auto; - width: auto; - } - - .col-md-1 { - flex: 0 0 auto; - width: 8.33333333%; - } - - .col-md-2 { - flex: 0 0 auto; - width: 16.66666667%; - } - - .col-md-3 { - flex: 0 0 auto; - width: 25%; - } - - .col-md-4 { - flex: 0 0 auto; - width: 33.33333333%; - } - - .col-md-5 { - flex: 0 0 auto; - width: 41.66666667%; - } - - .col-md-6 { - flex: 0 0 auto; - width: 50%; - } - - .col-md-7 { - flex: 0 0 auto; - width: 58.33333333%; - } - - .col-md-8 { - flex: 0 0 auto; - width: 66.66666667%; - } - - .col-md-9 { - flex: 0 0 auto; - width: 75%; - } - - .col-md-10 { - flex: 0 0 auto; - width: 83.33333333%; - } - - .col-md-11 { - flex: 0 0 auto; - width: 91.66666667%; - } - - .col-md-12 { - flex: 0 0 auto; - width: 100%; - } - - .offset-md-0 { - margin-left: 0; - } - - .offset-md-1 { - margin-left: 8.33333333%; - } - - .offset-md-2 { - margin-left: 16.66666667%; - } - - .offset-md-3 { - margin-left: 25%; - } - - .offset-md-4 { - margin-left: 33.33333333%; - } - - .offset-md-5 { - margin-left: 41.66666667%; - } - - .offset-md-6 { - margin-left: 50%; - } - - .offset-md-7 { - margin-left: 58.33333333%; - } - - .offset-md-8 { - margin-left: 66.66666667%; - } - - .offset-md-9 { - margin-left: 75%; - } - - .offset-md-10 { - margin-left: 83.33333333%; - } - - .offset-md-11 { - margin-left: 91.66666667%; - } - - .g-md-0, -.gx-md-0 { - --bs-gutter-x: 0; - } - - .g-md-0, -.gy-md-0 { - --bs-gutter-y: 0; - } - - .g-md-1, -.gx-md-1 { - --bs-gutter-x: 0.25rem; - } - - .g-md-1, -.gy-md-1 { - --bs-gutter-y: 0.25rem; - } - - .g-md-2, -.gx-md-2 { - --bs-gutter-x: 0.5rem; - } - - .g-md-2, -.gy-md-2 { - --bs-gutter-y: 0.5rem; - } - - .g-md-3, -.gx-md-3 { - --bs-gutter-x: 1rem; - } - - .g-md-3, -.gy-md-3 { - --bs-gutter-y: 1rem; - } - - .g-md-4, -.gx-md-4 { - --bs-gutter-x: 1.5rem; - } - - .g-md-4, -.gy-md-4 { - --bs-gutter-y: 1.5rem; - } - - .g-md-5, -.gx-md-5 { - --bs-gutter-x: 3rem; - } - - .g-md-5, -.gy-md-5 { - --bs-gutter-y: 3rem; - } -} -@media (min-width: 992px) { - .col-lg { - flex: 1 0 0%; - } - - .row-cols-lg-auto > * { - flex: 0 0 auto; - width: auto; - } - - .row-cols-lg-1 > * { - flex: 0 0 auto; - width: 100%; - } - - .row-cols-lg-2 > * { - flex: 0 0 auto; - width: 50%; - } - - .row-cols-lg-3 > * { - flex: 0 0 auto; - width: 33.3333333333%; - } - - .row-cols-lg-4 > * { - flex: 0 0 auto; - width: 25%; - } - - .row-cols-lg-5 > * { - flex: 0 0 auto; - width: 20%; - } - - .row-cols-lg-6 > * { - flex: 0 0 auto; - width: 16.6666666667%; - } - - .col-lg-auto { - flex: 0 0 auto; - width: auto; - } - - .col-lg-1 { - flex: 0 0 auto; - width: 8.33333333%; - } - - .col-lg-2 { - flex: 0 0 auto; - width: 16.66666667%; - } - - .col-lg-3 { - flex: 0 0 auto; - width: 25%; - } - - .col-lg-4 { - flex: 0 0 auto; - width: 33.33333333%; - } - - .col-lg-5 { - flex: 0 0 auto; - width: 41.66666667%; - } - - .col-lg-6 { - flex: 0 0 auto; - width: 50%; - } - - .col-lg-7 { - flex: 0 0 auto; - width: 58.33333333%; - } - - .col-lg-8 { - flex: 0 0 auto; - width: 66.66666667%; - } - - .col-lg-9 { - flex: 0 0 auto; - width: 75%; - } - - .col-lg-10 { - flex: 0 0 auto; - width: 83.33333333%; - } - - .col-lg-11 { - flex: 0 0 auto; - width: 91.66666667%; - } - - .col-lg-12 { - flex: 0 0 auto; - width: 100%; - } - - .offset-lg-0 { - margin-left: 0; - } - - .offset-lg-1 { - margin-left: 8.33333333%; - } - - .offset-lg-2 { - margin-left: 16.66666667%; - } - - .offset-lg-3 { - margin-left: 25%; - } - - .offset-lg-4 { - margin-left: 33.33333333%; - } - - .offset-lg-5 { - margin-left: 41.66666667%; - } - - .offset-lg-6 { - margin-left: 50%; - } - - .offset-lg-7 { - margin-left: 58.33333333%; - } - - .offset-lg-8 { - margin-left: 66.66666667%; - } - - .offset-lg-9 { - margin-left: 75%; - } - - .offset-lg-10 { - margin-left: 83.33333333%; - } - - .offset-lg-11 { - margin-left: 91.66666667%; - } - - .g-lg-0, -.gx-lg-0 { - --bs-gutter-x: 0; - } - - .g-lg-0, -.gy-lg-0 { - --bs-gutter-y: 0; - } - - .g-lg-1, -.gx-lg-1 { - --bs-gutter-x: 0.25rem; - } - - .g-lg-1, -.gy-lg-1 { - --bs-gutter-y: 0.25rem; - } - - .g-lg-2, -.gx-lg-2 { - --bs-gutter-x: 0.5rem; - } - - .g-lg-2, -.gy-lg-2 { - --bs-gutter-y: 0.5rem; - } - - .g-lg-3, -.gx-lg-3 { - --bs-gutter-x: 1rem; - } - - .g-lg-3, -.gy-lg-3 { - --bs-gutter-y: 1rem; - } - - .g-lg-4, -.gx-lg-4 { - --bs-gutter-x: 1.5rem; - } - - .g-lg-4, -.gy-lg-4 { - --bs-gutter-y: 1.5rem; - } - - .g-lg-5, -.gx-lg-5 { - --bs-gutter-x: 3rem; - } - - .g-lg-5, -.gy-lg-5 { - --bs-gutter-y: 3rem; - } -} -@media (min-width: 1200px) { - .col-xl { - flex: 1 0 0%; - } - - .row-cols-xl-auto > * { - flex: 0 0 auto; - width: auto; - } - - .row-cols-xl-1 > * { - flex: 0 0 auto; - width: 100%; - } - - .row-cols-xl-2 > * { - flex: 0 0 auto; - width: 50%; - } - - .row-cols-xl-3 > * { - flex: 0 0 auto; - width: 33.3333333333%; - } - - .row-cols-xl-4 > * { - flex: 0 0 auto; - width: 25%; - } - - .row-cols-xl-5 > * { - flex: 0 0 auto; - width: 20%; - } - - .row-cols-xl-6 > * { - flex: 0 0 auto; - width: 16.6666666667%; - } - - .col-xl-auto { - flex: 0 0 auto; - width: auto; - } - - .col-xl-1 { - flex: 0 0 auto; - width: 8.33333333%; - } - - .col-xl-2 { - flex: 0 0 auto; - width: 16.66666667%; - } - - .col-xl-3 { - flex: 0 0 auto; - width: 25%; - } - - .col-xl-4 { - flex: 0 0 auto; - width: 33.33333333%; - } - - .col-xl-5 { - flex: 0 0 auto; - width: 41.66666667%; - } - - .col-xl-6 { - flex: 0 0 auto; - width: 50%; - } - - .col-xl-7 { - flex: 0 0 auto; - width: 58.33333333%; - } - - .col-xl-8 { - flex: 0 0 auto; - width: 66.66666667%; - } - - .col-xl-9 { - flex: 0 0 auto; - width: 75%; - } - - .col-xl-10 { - flex: 0 0 auto; - width: 83.33333333%; - } - - .col-xl-11 { - flex: 0 0 auto; - width: 91.66666667%; - } - - .col-xl-12 { - flex: 0 0 auto; - width: 100%; - } - - .offset-xl-0 { - margin-left: 0; - } - - .offset-xl-1 { - margin-left: 8.33333333%; - } - - .offset-xl-2 { - margin-left: 16.66666667%; - } - - .offset-xl-3 { - margin-left: 25%; - } - - .offset-xl-4 { - margin-left: 33.33333333%; - } - - .offset-xl-5 { - margin-left: 41.66666667%; - } - - .offset-xl-6 { - margin-left: 50%; - } - - .offset-xl-7 { - margin-left: 58.33333333%; - } - - .offset-xl-8 { - margin-left: 66.66666667%; - } - - .offset-xl-9 { - margin-left: 75%; - } - - .offset-xl-10 { - margin-left: 83.33333333%; - } - - .offset-xl-11 { - margin-left: 91.66666667%; - } - - .g-xl-0, -.gx-xl-0 { - --bs-gutter-x: 0; - } - - .g-xl-0, -.gy-xl-0 { - --bs-gutter-y: 0; - } - - .g-xl-1, -.gx-xl-1 { - --bs-gutter-x: 0.25rem; - } - - .g-xl-1, -.gy-xl-1 { - --bs-gutter-y: 0.25rem; - } - - .g-xl-2, -.gx-xl-2 { - --bs-gutter-x: 0.5rem; - } - - .g-xl-2, -.gy-xl-2 { - --bs-gutter-y: 0.5rem; - } - - .g-xl-3, -.gx-xl-3 { - --bs-gutter-x: 1rem; - } - - .g-xl-3, -.gy-xl-3 { - --bs-gutter-y: 1rem; - } - - .g-xl-4, -.gx-xl-4 { - --bs-gutter-x: 1.5rem; - } - - .g-xl-4, -.gy-xl-4 { - --bs-gutter-y: 1.5rem; - } - - .g-xl-5, -.gx-xl-5 { - --bs-gutter-x: 3rem; - } - - .g-xl-5, -.gy-xl-5 { - --bs-gutter-y: 3rem; - } -} -@media (min-width: 1400px) { - .col-xxl { - flex: 1 0 0%; - } - - .row-cols-xxl-auto > * { - flex: 0 0 auto; - width: auto; - } - - .row-cols-xxl-1 > * { - flex: 0 0 auto; - width: 100%; - } - - .row-cols-xxl-2 > * { - flex: 0 0 auto; - width: 50%; - } - - .row-cols-xxl-3 > * { - flex: 0 0 auto; - width: 33.3333333333%; - } - - .row-cols-xxl-4 > * { - flex: 0 0 auto; - width: 25%; - } - - .row-cols-xxl-5 > * { - flex: 0 0 auto; - width: 20%; - } - - .row-cols-xxl-6 > * { - flex: 0 0 auto; - width: 16.6666666667%; - } - - .col-xxl-auto { - flex: 0 0 auto; - width: auto; - } - - .col-xxl-1 { - flex: 0 0 auto; - width: 8.33333333%; - } - - .col-xxl-2 { - flex: 0 0 auto; - width: 16.66666667%; - } - - .col-xxl-3 { - flex: 0 0 auto; - width: 25%; - } - - .col-xxl-4 { - flex: 0 0 auto; - width: 33.33333333%; - } - - .col-xxl-5 { - flex: 0 0 auto; - width: 41.66666667%; - } - - .col-xxl-6 { - flex: 0 0 auto; - width: 50%; - } - - .col-xxl-7 { - flex: 0 0 auto; - width: 58.33333333%; - } - - .col-xxl-8 { - flex: 0 0 auto; - width: 66.66666667%; - } - - .col-xxl-9 { - flex: 0 0 auto; - width: 75%; - } - - .col-xxl-10 { - flex: 0 0 auto; - width: 83.33333333%; - } - - .col-xxl-11 { - flex: 0 0 auto; - width: 91.66666667%; - } - - .col-xxl-12 { - flex: 0 0 auto; - width: 100%; - } - - .offset-xxl-0 { - margin-left: 0; - } - - .offset-xxl-1 { - margin-left: 8.33333333%; - } - - .offset-xxl-2 { - margin-left: 16.66666667%; - } - - .offset-xxl-3 { - margin-left: 25%; - } - - .offset-xxl-4 { - margin-left: 33.33333333%; - } - - .offset-xxl-5 { - margin-left: 41.66666667%; - } - - .offset-xxl-6 { - margin-left: 50%; - } - - .offset-xxl-7 { - margin-left: 58.33333333%; - } - - .offset-xxl-8 { - margin-left: 66.66666667%; - } - - .offset-xxl-9 { - margin-left: 75%; - } - - .offset-xxl-10 { - margin-left: 83.33333333%; - } - - .offset-xxl-11 { - margin-left: 91.66666667%; - } - - .g-xxl-0, -.gx-xxl-0 { - --bs-gutter-x: 0; - } - - .g-xxl-0, -.gy-xxl-0 { - --bs-gutter-y: 0; - } - - .g-xxl-1, -.gx-xxl-1 { - --bs-gutter-x: 0.25rem; - } - - .g-xxl-1, -.gy-xxl-1 { - --bs-gutter-y: 0.25rem; - } - - .g-xxl-2, -.gx-xxl-2 { - --bs-gutter-x: 0.5rem; - } - - .g-xxl-2, -.gy-xxl-2 { - --bs-gutter-y: 0.5rem; - } - - .g-xxl-3, -.gx-xxl-3 { - --bs-gutter-x: 1rem; - } - - .g-xxl-3, -.gy-xxl-3 { - --bs-gutter-y: 1rem; - } - - .g-xxl-4, -.gx-xxl-4 { - --bs-gutter-x: 1.5rem; - } - - .g-xxl-4, -.gy-xxl-4 { - --bs-gutter-y: 1.5rem; - } - - .g-xxl-5, -.gx-xxl-5 { - --bs-gutter-x: 3rem; - } - - .g-xxl-5, -.gy-xxl-5 { - --bs-gutter-y: 3rem; - } -} -.table, .dataTable-table { - --bs-table-bg: transparent; - --bs-table-accent-bg: transparent; - --bs-table-striped-color: #212529; - --bs-table-striped-bg: rgba(0, 0, 0, 0.05); - --bs-table-active-color: #212529; - --bs-table-active-bg: rgba(0, 0, 0, 0.1); - --bs-table-hover-color: #212529; - --bs-table-hover-bg: rgba(0, 0, 0, 0.075); - width: 100%; - margin-bottom: 1rem; - color: #212529; - vertical-align: top; - border-color: #dee2e6; -} -.table > :not(caption) > * > *, .dataTable-table > :not(caption) > * > * { - padding: 0.5rem 0.5rem; - background-color: var(--bs-table-bg); - border-bottom-width: 1px; - box-shadow: inset 0 0 0 9999px var(--bs-table-accent-bg); -} -.table > tbody, .dataTable-table > tbody { - vertical-align: inherit; -} -.table > thead, .dataTable-table > thead { - vertical-align: bottom; -} -.table > :not(:first-child), .dataTable-table > :not(:first-child) { - border-top: 2px solid currentColor; -} - -.caption-top { - caption-side: top; -} - -.table-sm > :not(caption) > * > * { - padding: 0.25rem 0.25rem; -} - -.table-bordered > :not(caption) > *, .dataTable-table > :not(caption) > * { - border-width: 1px 0; -} -.table-bordered > :not(caption) > * > *, .dataTable-table > :not(caption) > * > * { - border-width: 0 1px; -} - -.table-borderless > :not(caption) > * > * { - border-bottom-width: 0; -} -.table-borderless > :not(:first-child) { - border-top-width: 0; -} - -.table-striped > tbody > tr:nth-of-type(odd) > * { - --bs-table-accent-bg: var(--bs-table-striped-bg); - color: var(--bs-table-striped-color); -} - -.table-active { - --bs-table-accent-bg: var(--bs-table-active-bg); - color: var(--bs-table-active-color); -} - -.table-hover > tbody > tr:hover > *, .dataTable-table > tbody > tr:hover > * { - --bs-table-accent-bg: var(--bs-table-hover-bg); - color: var(--bs-table-hover-color); -} - -.table-primary { - --bs-table-bg: #cfe2ff; - --bs-table-striped-bg: #c5d7f2; - --bs-table-striped-color: #000; - --bs-table-active-bg: #bacbe6; - --bs-table-active-color: #000; - --bs-table-hover-bg: #bfd1ec; - --bs-table-hover-color: #000; - color: #000; - border-color: #bacbe6; -} - -.table-secondary { - --bs-table-bg: #e2e3e5; - --bs-table-striped-bg: #d7d8da; - --bs-table-striped-color: #000; - --bs-table-active-bg: #cbccce; - --bs-table-active-color: #000; - --bs-table-hover-bg: #d1d2d4; - --bs-table-hover-color: #000; - color: #000; - border-color: #cbccce; -} - -.table-success { - --bs-table-bg: #d1e7dd; - --bs-table-striped-bg: #c7dbd2; - --bs-table-striped-color: #000; - --bs-table-active-bg: #bcd0c7; - --bs-table-active-color: #000; - --bs-table-hover-bg: #c1d6cc; - --bs-table-hover-color: #000; - color: #000; - border-color: #bcd0c7; -} - -.table-info { - --bs-table-bg: #cff4fc; - --bs-table-striped-bg: #c5e8ef; - --bs-table-striped-color: #000; - --bs-table-active-bg: #badce3; - --bs-table-active-color: #000; - --bs-table-hover-bg: #bfe2e9; - --bs-table-hover-color: #000; - color: #000; - border-color: #badce3; -} - -.table-warning { - --bs-table-bg: #fff3cd; - --bs-table-striped-bg: #f2e7c3; - --bs-table-striped-color: #000; - --bs-table-active-bg: #e6dbb9; - --bs-table-active-color: #000; - --bs-table-hover-bg: #ece1be; - --bs-table-hover-color: #000; - color: #000; - border-color: #e6dbb9; -} - -.table-danger { - --bs-table-bg: #f8d7da; - --bs-table-striped-bg: #eccccf; - --bs-table-striped-color: #000; - --bs-table-active-bg: #dfc2c4; - --bs-table-active-color: #000; - --bs-table-hover-bg: #e5c7ca; - --bs-table-hover-color: #000; - color: #000; - border-color: #dfc2c4; -} - -.table-light { - --bs-table-bg: #f8f9fa; - --bs-table-striped-bg: #ecedee; - --bs-table-striped-color: #000; - --bs-table-active-bg: #dfe0e1; - --bs-table-active-color: #000; - --bs-table-hover-bg: #e5e6e7; - --bs-table-hover-color: #000; - color: #000; - border-color: #dfe0e1; -} - -.table-dark { - --bs-table-bg: #212529; - --bs-table-striped-bg: #2c3034; - --bs-table-striped-color: #fff; - --bs-table-active-bg: #373b3e; - --bs-table-active-color: #fff; - --bs-table-hover-bg: #323539; - --bs-table-hover-color: #fff; - color: #fff; - border-color: #373b3e; -} - -.table-responsive, .dataTable-wrapper .dataTable-container { - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -@media (max-width: 575.98px) { - .table-responsive-sm { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } -} -@media (max-width: 767.98px) { - .table-responsive-md { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } -} -@media (max-width: 991.98px) { - .table-responsive-lg { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } -} -@media (max-width: 1199.98px) { - .table-responsive-xl { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } -} -@media (max-width: 1399.98px) { - .table-responsive-xxl { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } -} -.form-label { - margin-bottom: 0.5rem; -} - -.col-form-label { - padding-top: calc(0.375rem + 1px); - padding-bottom: calc(0.375rem + 1px); - margin-bottom: 0; - font-size: inherit; - line-height: 1.5; -} - -.col-form-label-lg { - padding-top: calc(0.5rem + 1px); - padding-bottom: calc(0.5rem + 1px); - font-size: 1.25rem; -} - -.col-form-label-sm { - padding-top: calc(0.25rem + 1px); - padding-bottom: calc(0.25rem + 1px); - font-size: 0.875rem; -} - -.form-text { - margin-top: 0.25rem; - font-size: 0.875em; - color: #6c757d; -} - -.form-control, .dataTable-input { - display: block; - width: 100%; - padding: 0.375rem 0.75rem; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: #212529; - background-color: #fff; - background-clip: padding-box; - border: 1px solid #ced4da; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - border-radius: 0.25rem; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-control, .dataTable-input { - transition: none; - } -} -.form-control[type=file], [type=file].dataTable-input { - overflow: hidden; -} -.form-control[type=file]:not(:disabled):not([readonly]), [type=file].dataTable-input:not(:disabled):not([readonly]) { - cursor: pointer; -} -.form-control:focus, .dataTable-input:focus { - color: #212529; - background-color: #fff; - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.form-control::-webkit-date-and-time-value, .dataTable-input::-webkit-date-and-time-value { - height: 1.5em; -} -.form-control::-moz-placeholder, .dataTable-input::-moz-placeholder { - color: #6c757d; - opacity: 1; -} -.form-control:-ms-input-placeholder, .dataTable-input:-ms-input-placeholder { - color: #6c757d; - opacity: 1; -} -.form-control::placeholder, .dataTable-input::placeholder { - color: #6c757d; - opacity: 1; -} -.form-control:disabled, .dataTable-input:disabled, .form-control[readonly], [readonly].dataTable-input { - background-color: #e9ecef; - opacity: 1; -} -.form-control::-webkit-file-upload-button, .dataTable-input::-webkit-file-upload-button { - padding: 0.375rem 0.75rem; - margin: -0.375rem -0.75rem; - -webkit-margin-end: 0.75rem; - margin-inline-end: 0.75rem; - color: #212529; - background-color: #e9ecef; - pointer-events: none; - border-color: inherit; - border-style: solid; - border-width: 0; - border-inline-end-width: 1px; - border-radius: 0; - -webkit-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -.form-control::file-selector-button, .dataTable-input::file-selector-button { - padding: 0.375rem 0.75rem; - margin: -0.375rem -0.75rem; - -webkit-margin-end: 0.75rem; - margin-inline-end: 0.75rem; - color: #212529; - background-color: #e9ecef; - pointer-events: none; - border-color: inherit; - border-style: solid; - border-width: 0; - border-inline-end-width: 1px; - border-radius: 0; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-control::-webkit-file-upload-button, .dataTable-input::-webkit-file-upload-button { - -webkit-transition: none; - transition: none; - } - .form-control::file-selector-button, .dataTable-input::file-selector-button { - transition: none; - } -} -.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button, .dataTable-input:hover:not(:disabled):not([readonly])::-webkit-file-upload-button { - background-color: #dde0e3; -} -.form-control:hover:not(:disabled):not([readonly])::file-selector-button, .dataTable-input:hover:not(:disabled):not([readonly])::file-selector-button { - background-color: #dde0e3; -} -.form-control::-webkit-file-upload-button, .dataTable-input::-webkit-file-upload-button { - padding: 0.375rem 0.75rem; - margin: -0.375rem -0.75rem; - -webkit-margin-end: 0.75rem; - margin-inline-end: 0.75rem; - color: #212529; - background-color: #e9ecef; - pointer-events: none; - border-color: inherit; - border-style: solid; - border-width: 0; - border-inline-end-width: 1px; - border-radius: 0; - -webkit-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-control::-webkit-file-upload-button, .dataTable-input::-webkit-file-upload-button { - -webkit-transition: none; - transition: none; - } -} -.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button, .dataTable-input:hover:not(:disabled):not([readonly])::-webkit-file-upload-button { - background-color: #dde0e3; -} - -.form-control-plaintext { - display: block; - width: 100%; - padding: 0.375rem 0; - margin-bottom: 0; - line-height: 1.5; - color: #212529; - background-color: transparent; - border: solid transparent; - border-width: 1px 0; -} -.form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg { - padding-right: 0; - padding-left: 0; -} - -.form-control-sm { - min-height: calc(1.5em + 0.5rem + 2px); - padding: 0.25rem 0.5rem; - font-size: 0.875rem; - border-radius: 0.2rem; -} -.form-control-sm::-webkit-file-upload-button { - padding: 0.25rem 0.5rem; - margin: -0.25rem -0.5rem; - -webkit-margin-end: 0.5rem; - margin-inline-end: 0.5rem; -} -.form-control-sm::file-selector-button { - padding: 0.25rem 0.5rem; - margin: -0.25rem -0.5rem; - -webkit-margin-end: 0.5rem; - margin-inline-end: 0.5rem; -} -.form-control-sm::-webkit-file-upload-button { - padding: 0.25rem 0.5rem; - margin: -0.25rem -0.5rem; - -webkit-margin-end: 0.5rem; - margin-inline-end: 0.5rem; -} - -.form-control-lg { - min-height: calc(1.5em + 1rem + 2px); - padding: 0.5rem 1rem; - font-size: 1.25rem; - border-radius: 0.3rem; -} -.form-control-lg::-webkit-file-upload-button { - padding: 0.5rem 1rem; - margin: -0.5rem -1rem; - -webkit-margin-end: 1rem; - margin-inline-end: 1rem; -} -.form-control-lg::file-selector-button { - padding: 0.5rem 1rem; - margin: -0.5rem -1rem; - -webkit-margin-end: 1rem; - margin-inline-end: 1rem; -} -.form-control-lg::-webkit-file-upload-button { - padding: 0.5rem 1rem; - margin: -0.5rem -1rem; - -webkit-margin-end: 1rem; - margin-inline-end: 1rem; -} - -textarea.form-control, textarea.dataTable-input { - min-height: calc(1.5em + 0.75rem + 2px); -} -textarea.form-control-sm { - min-height: calc(1.5em + 0.5rem + 2px); -} -textarea.form-control-lg { - min-height: calc(1.5em + 1rem + 2px); -} - -.form-control-color { - width: 3rem; - height: auto; - padding: 0.375rem; -} -.form-control-color:not(:disabled):not([readonly]) { - cursor: pointer; -} -.form-control-color::-moz-color-swatch { - height: 1.5em; - border-radius: 0.25rem; -} -.form-control-color::-webkit-color-swatch { - height: 1.5em; - border-radius: 0.25rem; -} - -.form-select, .dataTable-selector { - display: block; - width: 100%; - padding: 0.375rem 2.25rem 0.375rem 0.75rem; - -moz-padding-start: calc(0.75rem - 3px); - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: #212529; - background-color: #fff; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right 0.75rem center; - background-size: 16px 12px; - border: 1px solid #ced4da; - border-radius: 0.25rem; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; -} -@media (prefers-reduced-motion: reduce) { - .form-select, .dataTable-selector { - transition: none; - } -} -.form-select:focus, .dataTable-selector:focus { - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.form-select[multiple], [multiple].dataTable-selector, .form-select[size]:not([size="1"]), [size].dataTable-selector:not([size="1"]) { - padding-right: 0.75rem; - background-image: none; -} -.form-select:disabled, .dataTable-selector:disabled { - background-color: #e9ecef; -} -.form-select:-moz-focusring, .dataTable-selector:-moz-focusring { - color: transparent; - text-shadow: 0 0 0 #212529; -} - -.form-select-sm { - padding-top: 0.25rem; - padding-bottom: 0.25rem; - padding-left: 0.5rem; - font-size: 0.875rem; - border-radius: 0.2rem; -} - -.form-select-lg { - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 1rem; - font-size: 1.25rem; - border-radius: 0.3rem; -} - -.form-check { - display: block; - min-height: 1.5rem; - padding-left: 1.5em; - margin-bottom: 0.125rem; -} -.form-check .form-check-input { - float: left; - margin-left: -1.5em; -} - -.form-check-input { - width: 1em; - height: 1em; - margin-top: 0.25em; - vertical-align: top; - background-color: #fff; - background-repeat: no-repeat; - background-position: center; - background-size: contain; - border: 1px solid rgba(0, 0, 0, 0.25); - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - -webkit-print-color-adjust: exact; - color-adjust: exact; -} -.form-check-input[type=checkbox] { - border-radius: 0.25em; -} -.form-check-input[type=radio] { - border-radius: 50%; -} -.form-check-input:active { - filter: brightness(90%); -} -.form-check-input:focus { - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.form-check-input:checked { - background-color: #0d6efd; - border-color: #0d6efd; -} -.form-check-input:checked[type=checkbox] { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e"); -} -.form-check-input:checked[type=radio] { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e"); -} -.form-check-input[type=checkbox]:indeterminate { - background-color: #0d6efd; - border-color: #0d6efd; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e"); -} -.form-check-input:disabled { - pointer-events: none; - filter: none; - opacity: 0.5; -} -.form-check-input[disabled] ~ .form-check-label, .form-check-input:disabled ~ .form-check-label { - opacity: 0.5; -} - -.form-switch { - padding-left: 2.5em; -} -.form-switch .form-check-input { - width: 2em; - margin-left: -2.5em; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e"); - background-position: left center; - border-radius: 2em; - transition: background-position 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-switch .form-check-input { - transition: none; - } -} -.form-switch .form-check-input:focus { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e"); -} -.form-switch .form-check-input:checked { - background-position: right center; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); -} - -.form-check-inline { - display: inline-block; - margin-right: 1rem; -} - -.btn-check { - position: absolute; - clip: rect(0, 0, 0, 0); - pointer-events: none; -} -.btn-check[disabled] + .btn, .btn-check:disabled + .btn { - pointer-events: none; - filter: none; - opacity: 0.65; -} - -.form-range { - width: 100%; - height: 1.5rem; - padding: 0; - background-color: transparent; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; -} -.form-range:focus { - outline: 0; -} -.form-range:focus::-webkit-slider-thumb { - box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.form-range:focus::-moz-range-thumb { - box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.form-range::-moz-focus-outer { - border: 0; -} -.form-range::-webkit-slider-thumb { - width: 1rem; - height: 1rem; - margin-top: -0.25rem; - background-color: #0d6efd; - border: 0; - border-radius: 1rem; - -webkit-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - -webkit-appearance: none; - appearance: none; -} -@media (prefers-reduced-motion: reduce) { - .form-range::-webkit-slider-thumb { - -webkit-transition: none; - transition: none; - } -} -.form-range::-webkit-slider-thumb:active { - background-color: #b6d4fe; -} -.form-range::-webkit-slider-runnable-track { - width: 100%; - height: 0.5rem; - color: transparent; - cursor: pointer; - background-color: #dee2e6; - border-color: transparent; - border-radius: 1rem; -} -.form-range::-moz-range-thumb { - width: 1rem; - height: 1rem; - background-color: #0d6efd; - border: 0; - border-radius: 1rem; - -moz-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - -moz-appearance: none; - appearance: none; -} -@media (prefers-reduced-motion: reduce) { - .form-range::-moz-range-thumb { - -moz-transition: none; - transition: none; - } -} -.form-range::-moz-range-thumb:active { - background-color: #b6d4fe; -} -.form-range::-moz-range-track { - width: 100%; - height: 0.5rem; - color: transparent; - cursor: pointer; - background-color: #dee2e6; - border-color: transparent; - border-radius: 1rem; -} -.form-range:disabled { - pointer-events: none; -} -.form-range:disabled::-webkit-slider-thumb { - background-color: #adb5bd; -} -.form-range:disabled::-moz-range-thumb { - background-color: #adb5bd; -} - -.form-floating { - position: relative; -} -.form-floating > .form-control, .form-floating > .dataTable-input, -.form-floating > .form-select, -.form-floating > .dataTable-selector { - height: calc(3.5rem + 2px); - line-height: 1.25; -} -.form-floating > label { - position: absolute; - top: 0; - left: 0; - height: 100%; - padding: 1rem 0.75rem; - pointer-events: none; - border: 1px solid transparent; - transform-origin: 0 0; - transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .form-floating > label { - transition: none; - } -} -.form-floating > .form-control, .form-floating > .dataTable-input { - padding: 1rem 0.75rem; -} -.form-floating > .form-control::-moz-placeholder, .form-floating > .dataTable-input::-moz-placeholder { - color: transparent; -} -.form-floating > .form-control:-ms-input-placeholder, .form-floating > .dataTable-input:-ms-input-placeholder { - color: transparent; -} -.form-floating > .form-control::placeholder, .form-floating > .dataTable-input::placeholder { - color: transparent; -} -.form-floating > .form-control:not(:-moz-placeholder-shown), .form-floating > .dataTable-input:not(:-moz-placeholder-shown) { - padding-top: 1.625rem; - padding-bottom: 0.625rem; -} -.form-floating > .form-control:not(:-ms-input-placeholder), .form-floating > .dataTable-input:not(:-ms-input-placeholder) { - padding-top: 1.625rem; - padding-bottom: 0.625rem; -} -.form-floating > .form-control:focus, .form-floating > .dataTable-input:focus, .form-floating > .form-control:not(:placeholder-shown), .form-floating > .dataTable-input:not(:placeholder-shown) { - padding-top: 1.625rem; - padding-bottom: 0.625rem; -} -.form-floating > .form-control:-webkit-autofill, .form-floating > .dataTable-input:-webkit-autofill { - padding-top: 1.625rem; - padding-bottom: 0.625rem; -} -.form-floating > .form-select, .form-floating > .dataTable-selector { - padding-top: 1.625rem; - padding-bottom: 0.625rem; -} -.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label, .form-floating > .dataTable-input:not(:-moz-placeholder-shown) ~ label { - opacity: 0.65; - transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); -} -.form-floating > .form-control:not(:-ms-input-placeholder) ~ label, .form-floating > .dataTable-input:not(:-ms-input-placeholder) ~ label { - opacity: 0.65; - transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); -} -.form-floating > .form-control:focus ~ label, .form-floating > .dataTable-input:focus ~ label, -.form-floating > .form-control:not(:placeholder-shown) ~ label, -.form-floating > .dataTable-input:not(:placeholder-shown) ~ label, -.form-floating > .form-select ~ label, -.form-floating > .dataTable-selector ~ label { - opacity: 0.65; - transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); -} -.form-floating > .form-control:-webkit-autofill ~ label, .form-floating > .dataTable-input:-webkit-autofill ~ label { - opacity: 0.65; - transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); -} - -.input-group { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: stretch; - width: 100%; -} -.input-group > .form-control, .input-group > .dataTable-input, -.input-group > .form-select, -.input-group > .dataTable-selector { - position: relative; - flex: 1 1 auto; - width: 1%; - min-width: 0; -} -.input-group > .form-control:focus, .input-group > .dataTable-input:focus, -.input-group > .form-select:focus, -.input-group > .dataTable-selector:focus { - z-index: 3; -} -.input-group .btn { - position: relative; - z-index: 2; -} -.input-group .btn:focus { - z-index: 3; -} - -.input-group-text { - display: flex; - align-items: center; - padding: 0.375rem 0.75rem; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: #212529; - text-align: center; - white-space: nowrap; - background-color: #e9ecef; - border: 1px solid #ced4da; - border-radius: 0.25rem; -} - -.input-group-lg > .form-control, .input-group-lg > .dataTable-input, -.input-group-lg > .form-select, -.input-group-lg > .dataTable-selector, -.input-group-lg > .input-group-text, -.input-group-lg > .btn { - padding: 0.5rem 1rem; - font-size: 1.25rem; - border-radius: 0.3rem; -} - -.input-group-sm > .form-control, .input-group-sm > .dataTable-input, -.input-group-sm > .form-select, -.input-group-sm > .dataTable-selector, -.input-group-sm > .input-group-text, -.input-group-sm > .btn { - padding: 0.25rem 0.5rem; - font-size: 0.875rem; - border-radius: 0.2rem; -} - -.input-group-lg > .form-select, .input-group-lg > .dataTable-selector, -.input-group-sm > .form-select, -.input-group-sm > .dataTable-selector { - padding-right: 3rem; -} - -.input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu), -.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n+3) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} -.input-group.has-validation > :nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu), -.input-group.has-validation > .dropdown-toggle:nth-last-child(n+4) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} -.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) { - margin-left: -1px; - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -.valid-feedback { - display: none; - width: 100%; - margin-top: 0.25rem; - font-size: 0.875em; - color: #198754; -} - -.valid-tooltip { - position: absolute; - top: 100%; - z-index: 5; - display: none; - max-width: 100%; - padding: 0.25rem 0.5rem; - margin-top: 0.1rem; - font-size: 0.875rem; - color: #fff; - background-color: rgba(25, 135, 84, 0.9); - border-radius: 0.25rem; -} - -.was-validated :valid ~ .valid-feedback, -.was-validated :valid ~ .valid-tooltip, -.is-valid ~ .valid-feedback, -.is-valid ~ .valid-tooltip { - display: block; -} - -.was-validated .form-control:valid, .was-validated .dataTable-input:valid, .form-control.is-valid, .is-valid.dataTable-input { - border-color: #198754; - padding-right: calc(1.5em + 0.75rem); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} -.was-validated .form-control:valid:focus, .was-validated .dataTable-input:valid:focus, .form-control.is-valid:focus, .is-valid.dataTable-input:focus { - border-color: #198754; - box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25); -} - -.was-validated textarea.form-control:valid, .was-validated textarea.dataTable-input:valid, textarea.form-control.is-valid, textarea.is-valid.dataTable-input { - padding-right: calc(1.5em + 0.75rem); - background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); -} - -.was-validated .form-select:valid, .was-validated .dataTable-selector:valid, .form-select.is-valid, .is-valid.dataTable-selector { - border-color: #198754; -} -.was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .dataTable-selector:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .was-validated .dataTable-selector:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .is-valid.dataTable-selector:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size="1"], .is-valid.dataTable-selector:not([multiple])[size="1"] { - padding-right: 4.125rem; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"), url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); - background-position: right 0.75rem center, center right 2.25rem; - background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} -.was-validated .form-select:valid:focus, .was-validated .dataTable-selector:valid:focus, .form-select.is-valid:focus, .is-valid.dataTable-selector:focus { - border-color: #198754; - box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25); -} - -.was-validated .form-check-input:valid, .form-check-input.is-valid { - border-color: #198754; -} -.was-validated .form-check-input:valid:checked, .form-check-input.is-valid:checked { - background-color: #198754; -} -.was-validated .form-check-input:valid:focus, .form-check-input.is-valid:focus { - box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25); -} -.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label { - color: #198754; -} - -.form-check-inline .form-check-input ~ .valid-feedback { - margin-left: 0.5em; -} - -.was-validated .input-group .form-control:valid, .was-validated .input-group .dataTable-input:valid, .input-group .form-control.is-valid, .input-group .is-valid.dataTable-input, -.was-validated .input-group .form-select:valid, -.was-validated .input-group .dataTable-selector:valid, -.input-group .form-select.is-valid, -.input-group .is-valid.dataTable-selector { - z-index: 1; -} -.was-validated .input-group .form-control:valid:focus, .was-validated .input-group .dataTable-input:valid:focus, .input-group .form-control.is-valid:focus, .input-group .is-valid.dataTable-input:focus, -.was-validated .input-group .form-select:valid:focus, -.was-validated .input-group .dataTable-selector:valid:focus, -.input-group .form-select.is-valid:focus, -.input-group .is-valid.dataTable-selector:focus { - z-index: 3; -} - -.invalid-feedback { - display: none; - width: 100%; - margin-top: 0.25rem; - font-size: 0.875em; - color: #dc3545; -} - -.invalid-tooltip { - position: absolute; - top: 100%; - z-index: 5; - display: none; - max-width: 100%; - padding: 0.25rem 0.5rem; - margin-top: 0.1rem; - font-size: 0.875rem; - color: #fff; - background-color: rgba(220, 53, 69, 0.9); - border-radius: 0.25rem; -} - -.was-validated :invalid ~ .invalid-feedback, -.was-validated :invalid ~ .invalid-tooltip, -.is-invalid ~ .invalid-feedback, -.is-invalid ~ .invalid-tooltip { - display: block; -} - -.was-validated .form-control:invalid, .was-validated .dataTable-input:invalid, .form-control.is-invalid, .is-invalid.dataTable-input { - border-color: #dc3545; - padding-right: calc(1.5em + 0.75rem); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} -.was-validated .form-control:invalid:focus, .was-validated .dataTable-input:invalid:focus, .form-control.is-invalid:focus, .is-invalid.dataTable-input:focus { - border-color: #dc3545; - box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25); -} - -.was-validated textarea.form-control:invalid, .was-validated textarea.dataTable-input:invalid, textarea.form-control.is-invalid, textarea.is-invalid.dataTable-input { - padding-right: calc(1.5em + 0.75rem); - background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); -} - -.was-validated .form-select:invalid, .was-validated .dataTable-selector:invalid, .form-select.is-invalid, .is-invalid.dataTable-selector { - border-color: #dc3545; -} -.was-validated .form-select:invalid:not([multiple]):not([size]), .was-validated .dataTable-selector:invalid:not([multiple]):not([size]), .was-validated .form-select:invalid:not([multiple])[size="1"], .was-validated .dataTable-selector:invalid:not([multiple])[size="1"], .form-select.is-invalid:not([multiple]):not([size]), .is-invalid.dataTable-selector:not([multiple]):not([size]), .form-select.is-invalid:not([multiple])[size="1"], .is-invalid.dataTable-selector:not([multiple])[size="1"] { - padding-right: 4.125rem; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"), url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); - background-position: right 0.75rem center, center right 2.25rem; - background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} -.was-validated .form-select:invalid:focus, .was-validated .dataTable-selector:invalid:focus, .form-select.is-invalid:focus, .is-invalid.dataTable-selector:focus { - border-color: #dc3545; - box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25); -} - -.was-validated .form-check-input:invalid, .form-check-input.is-invalid { - border-color: #dc3545; -} -.was-validated .form-check-input:invalid:checked, .form-check-input.is-invalid:checked { - background-color: #dc3545; -} -.was-validated .form-check-input:invalid:focus, .form-check-input.is-invalid:focus { - box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25); -} -.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label { - color: #dc3545; -} - -.form-check-inline .form-check-input ~ .invalid-feedback { - margin-left: 0.5em; -} - -.was-validated .input-group .form-control:invalid, .was-validated .input-group .dataTable-input:invalid, .input-group .form-control.is-invalid, .input-group .is-invalid.dataTable-input, -.was-validated .input-group .form-select:invalid, -.was-validated .input-group .dataTable-selector:invalid, -.input-group .form-select.is-invalid, -.input-group .is-invalid.dataTable-selector { - z-index: 2; -} -.was-validated .input-group .form-control:invalid:focus, .was-validated .input-group .dataTable-input:invalid:focus, .input-group .form-control.is-invalid:focus, .input-group .is-invalid.dataTable-input:focus, -.was-validated .input-group .form-select:invalid:focus, -.was-validated .input-group .dataTable-selector:invalid:focus, -.input-group .form-select.is-invalid:focus, -.input-group .is-invalid.dataTable-selector:focus { - z-index: 3; -} - -.btn { - display: inline-block; - font-weight: 400; - line-height: 1.5; - color: #212529; - text-align: center; - text-decoration: none; - vertical-align: middle; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - background-color: transparent; - border: 1px solid transparent; - padding: 0.375rem 0.75rem; - font-size: 1rem; - border-radius: 0.25rem; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .btn { - transition: none; - } -} -.btn:hover { - color: #212529; -} -.btn-check:focus + .btn, .btn:focus { - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} -.btn:disabled, .btn.disabled, fieldset:disabled .btn { - pointer-events: none; - opacity: 0.65; -} - -.btn-primary { - color: #fff; - background-color: #0d6efd; - border-color: #0d6efd; -} -.btn-primary:hover { - color: #fff; - background-color: #0b5ed7; - border-color: #0a58ca; -} -.btn-check:focus + .btn-primary, .btn-primary:focus { - color: #fff; - background-color: #0b5ed7; - border-color: #0a58ca; - box-shadow: 0 0 0 0.25rem rgba(49, 132, 253, 0.5); -} -.btn-check:checked + .btn-primary, .btn-check:active + .btn-primary, .btn-primary:active, .btn-primary.active, .show > .btn-primary.dropdown-toggle { - color: #fff; - background-color: #0a58ca; - border-color: #0a53be; -} -.btn-check:checked + .btn-primary:focus, .btn-check:active + .btn-primary:focus, .btn-primary:active:focus, .btn-primary.active:focus, .show > .btn-primary.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(49, 132, 253, 0.5); -} -.btn-primary:disabled, .btn-primary.disabled { - color: #fff; - background-color: #0d6efd; - border-color: #0d6efd; -} - -.btn-secondary { - color: #fff; - background-color: #6c757d; - border-color: #6c757d; -} -.btn-secondary:hover { - color: #fff; - background-color: #5c636a; - border-color: #565e64; -} -.btn-check:focus + .btn-secondary, .btn-secondary:focus { - color: #fff; - background-color: #5c636a; - border-color: #565e64; - box-shadow: 0 0 0 0.25rem rgba(130, 138, 145, 0.5); -} -.btn-check:checked + .btn-secondary, .btn-check:active + .btn-secondary, .btn-secondary:active, .btn-secondary.active, .show > .btn-secondary.dropdown-toggle { - color: #fff; - background-color: #565e64; - border-color: #51585e; -} -.btn-check:checked + .btn-secondary:focus, .btn-check:active + .btn-secondary:focus, .btn-secondary:active:focus, .btn-secondary.active:focus, .show > .btn-secondary.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(130, 138, 145, 0.5); -} -.btn-secondary:disabled, .btn-secondary.disabled { - color: #fff; - background-color: #6c757d; - border-color: #6c757d; -} - -.btn-success { - color: #fff; - background-color: #198754; - border-color: #198754; -} -.btn-success:hover { - color: #fff; - background-color: #157347; - border-color: #146c43; -} -.btn-check:focus + .btn-success, .btn-success:focus { - color: #fff; - background-color: #157347; - border-color: #146c43; - box-shadow: 0 0 0 0.25rem rgba(60, 153, 110, 0.5); -} -.btn-check:checked + .btn-success, .btn-check:active + .btn-success, .btn-success:active, .btn-success.active, .show > .btn-success.dropdown-toggle { - color: #fff; - background-color: #146c43; - border-color: #13653f; -} -.btn-check:checked + .btn-success:focus, .btn-check:active + .btn-success:focus, .btn-success:active:focus, .btn-success.active:focus, .show > .btn-success.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(60, 153, 110, 0.5); -} -.btn-success:disabled, .btn-success.disabled { - color: #fff; - background-color: #198754; - border-color: #198754; -} - -.btn-info { - color: #000; - background-color: #0dcaf0; - border-color: #0dcaf0; -} -.btn-info:hover { - color: #000; - background-color: #31d2f2; - border-color: #25cff2; -} -.btn-check:focus + .btn-info, .btn-info:focus { - color: #000; - background-color: #31d2f2; - border-color: #25cff2; - box-shadow: 0 0 0 0.25rem rgba(11, 172, 204, 0.5); -} -.btn-check:checked + .btn-info, .btn-check:active + .btn-info, .btn-info:active, .btn-info.active, .show > .btn-info.dropdown-toggle { - color: #000; - background-color: #3dd5f3; - border-color: #25cff2; -} -.btn-check:checked + .btn-info:focus, .btn-check:active + .btn-info:focus, .btn-info:active:focus, .btn-info.active:focus, .show > .btn-info.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(11, 172, 204, 0.5); -} -.btn-info:disabled, .btn-info.disabled { - color: #000; - background-color: #0dcaf0; - border-color: #0dcaf0; -} - -.btn-warning { - color: #000; - background-color: #ffc107; - border-color: #ffc107; -} -.btn-warning:hover { - color: #000; - background-color: #ffca2c; - border-color: #ffc720; -} -.btn-check:focus + .btn-warning, .btn-warning:focus { - color: #000; - background-color: #ffca2c; - border-color: #ffc720; - box-shadow: 0 0 0 0.25rem rgba(217, 164, 6, 0.5); -} -.btn-check:checked + .btn-warning, .btn-check:active + .btn-warning, .btn-warning:active, .btn-warning.active, .show > .btn-warning.dropdown-toggle { - color: #000; - background-color: #ffcd39; - border-color: #ffc720; -} -.btn-check:checked + .btn-warning:focus, .btn-check:active + .btn-warning:focus, .btn-warning:active:focus, .btn-warning.active:focus, .show > .btn-warning.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(217, 164, 6, 0.5); -} -.btn-warning:disabled, .btn-warning.disabled { - color: #000; - background-color: #ffc107; - border-color: #ffc107; -} - -.btn-danger { - color: #fff; - background-color: #dc3545; - border-color: #dc3545; -} -.btn-danger:hover { - color: #fff; - background-color: #bb2d3b; - border-color: #b02a37; -} -.btn-check:focus + .btn-danger, .btn-danger:focus { - color: #fff; - background-color: #bb2d3b; - border-color: #b02a37; - box-shadow: 0 0 0 0.25rem rgba(225, 83, 97, 0.5); -} -.btn-check:checked + .btn-danger, .btn-check:active + .btn-danger, .btn-danger:active, .btn-danger.active, .show > .btn-danger.dropdown-toggle { - color: #fff; - background-color: #b02a37; - border-color: #a52834; -} -.btn-check:checked + .btn-danger:focus, .btn-check:active + .btn-danger:focus, .btn-danger:active:focus, .btn-danger.active:focus, .show > .btn-danger.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(225, 83, 97, 0.5); -} -.btn-danger:disabled, .btn-danger.disabled { - color: #fff; - background-color: #dc3545; - border-color: #dc3545; -} - -.btn-light { - color: #000; - background-color: #f8f9fa; - border-color: #f8f9fa; -} -.btn-light:hover { - color: #000; - background-color: #f9fafb; - border-color: #f9fafb; -} -.btn-check:focus + .btn-light, .btn-light:focus { - color: #000; - background-color: #f9fafb; - border-color: #f9fafb; - box-shadow: 0 0 0 0.25rem rgba(211, 212, 213, 0.5); -} -.btn-check:checked + .btn-light, .btn-check:active + .btn-light, .btn-light:active, .btn-light.active, .show > .btn-light.dropdown-toggle { - color: #000; - background-color: #f9fafb; - border-color: #f9fafb; -} -.btn-check:checked + .btn-light:focus, .btn-check:active + .btn-light:focus, .btn-light:active:focus, .btn-light.active:focus, .show > .btn-light.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(211, 212, 213, 0.5); -} -.btn-light:disabled, .btn-light.disabled { - color: #000; - background-color: #f8f9fa; - border-color: #f8f9fa; -} - -.btn-dark { - color: #fff; - background-color: #212529; - border-color: #212529; -} -.btn-dark:hover { - color: #fff; - background-color: #1c1f23; - border-color: #1a1e21; -} -.btn-check:focus + .btn-dark, .btn-dark:focus { - color: #fff; - background-color: #1c1f23; - border-color: #1a1e21; - box-shadow: 0 0 0 0.25rem rgba(66, 70, 73, 0.5); -} -.btn-check:checked + .btn-dark, .btn-check:active + .btn-dark, .btn-dark:active, .btn-dark.active, .show > .btn-dark.dropdown-toggle { - color: #fff; - background-color: #1a1e21; - border-color: #191c1f; -} -.btn-check:checked + .btn-dark:focus, .btn-check:active + .btn-dark:focus, .btn-dark:active:focus, .btn-dark.active:focus, .show > .btn-dark.dropdown-toggle:focus { - box-shadow: 0 0 0 0.25rem rgba(66, 70, 73, 0.5); -} -.btn-dark:disabled, .btn-dark.disabled { - color: #fff; - background-color: #212529; - border-color: #212529; -} - -.btn-outline-primary { - color: #0d6efd; - border-color: #0d6efd; -} -.btn-outline-primary:hover { - color: #fff; - background-color: #0d6efd; - border-color: #0d6efd; -} -.btn-check:focus + .btn-outline-primary, .btn-outline-primary:focus { - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.5); -} -.btn-check:checked + .btn-outline-primary, .btn-check:active + .btn-outline-primary, .btn-outline-primary:active, .btn-outline-primary.active, .btn-outline-primary.dropdown-toggle.show { - color: #fff; - background-color: #0d6efd; - border-color: #0d6efd; -} -.btn-check:checked + .btn-outline-primary:focus, .btn-check:active + .btn-outline-primary:focus, .btn-outline-primary:active:focus, .btn-outline-primary.active:focus, .btn-outline-primary.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.5); -} -.btn-outline-primary:disabled, .btn-outline-primary.disabled { - color: #0d6efd; - background-color: transparent; -} - -.btn-outline-secondary { - color: #6c757d; - border-color: #6c757d; -} -.btn-outline-secondary:hover { - color: #fff; - background-color: #6c757d; - border-color: #6c757d; -} -.btn-check:focus + .btn-outline-secondary, .btn-outline-secondary:focus { - box-shadow: 0 0 0 0.25rem rgba(108, 117, 125, 0.5); -} -.btn-check:checked + .btn-outline-secondary, .btn-check:active + .btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show { - color: #fff; - background-color: #6c757d; - border-color: #6c757d; -} -.btn-check:checked + .btn-outline-secondary:focus, .btn-check:active + .btn-outline-secondary:focus, .btn-outline-secondary:active:focus, .btn-outline-secondary.active:focus, .btn-outline-secondary.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(108, 117, 125, 0.5); -} -.btn-outline-secondary:disabled, .btn-outline-secondary.disabled { - color: #6c757d; - background-color: transparent; -} - -.btn-outline-success { - color: #198754; - border-color: #198754; -} -.btn-outline-success:hover { - color: #fff; - background-color: #198754; - border-color: #198754; -} -.btn-check:focus + .btn-outline-success, .btn-outline-success:focus { - box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.5); -} -.btn-check:checked + .btn-outline-success, .btn-check:active + .btn-outline-success, .btn-outline-success:active, .btn-outline-success.active, .btn-outline-success.dropdown-toggle.show { - color: #fff; - background-color: #198754; - border-color: #198754; -} -.btn-check:checked + .btn-outline-success:focus, .btn-check:active + .btn-outline-success:focus, .btn-outline-success:active:focus, .btn-outline-success.active:focus, .btn-outline-success.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.5); -} -.btn-outline-success:disabled, .btn-outline-success.disabled { - color: #198754; - background-color: transparent; -} - -.btn-outline-info { - color: #0dcaf0; - border-color: #0dcaf0; -} -.btn-outline-info:hover { - color: #000; - background-color: #0dcaf0; - border-color: #0dcaf0; -} -.btn-check:focus + .btn-outline-info, .btn-outline-info:focus { - box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.5); -} -.btn-check:checked + .btn-outline-info, .btn-check:active + .btn-outline-info, .btn-outline-info:active, .btn-outline-info.active, .btn-outline-info.dropdown-toggle.show { - color: #000; - background-color: #0dcaf0; - border-color: #0dcaf0; -} -.btn-check:checked + .btn-outline-info:focus, .btn-check:active + .btn-outline-info:focus, .btn-outline-info:active:focus, .btn-outline-info.active:focus, .btn-outline-info.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.5); -} -.btn-outline-info:disabled, .btn-outline-info.disabled { - color: #0dcaf0; - background-color: transparent; -} - -.btn-outline-warning { - color: #ffc107; - border-color: #ffc107; -} -.btn-outline-warning:hover { - color: #000; - background-color: #ffc107; - border-color: #ffc107; -} -.btn-check:focus + .btn-outline-warning, .btn-outline-warning:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.5); -} -.btn-check:checked + .btn-outline-warning, .btn-check:active + .btn-outline-warning, .btn-outline-warning:active, .btn-outline-warning.active, .btn-outline-warning.dropdown-toggle.show { - color: #000; - background-color: #ffc107; - border-color: #ffc107; -} -.btn-check:checked + .btn-outline-warning:focus, .btn-check:active + .btn-outline-warning:focus, .btn-outline-warning:active:focus, .btn-outline-warning.active:focus, .btn-outline-warning.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(255, 193, 7, 0.5); -} -.btn-outline-warning:disabled, .btn-outline-warning.disabled { - color: #ffc107; - background-color: transparent; -} - -.btn-outline-danger { - color: #dc3545; - border-color: #dc3545; -} -.btn-outline-danger:hover { - color: #fff; - background-color: #dc3545; - border-color: #dc3545; -} -.btn-check:focus + .btn-outline-danger, .btn-outline-danger:focus { - box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.5); -} -.btn-check:checked + .btn-outline-danger, .btn-check:active + .btn-outline-danger, .btn-outline-danger:active, .btn-outline-danger.active, .btn-outline-danger.dropdown-toggle.show { - color: #fff; - background-color: #dc3545; - border-color: #dc3545; -} -.btn-check:checked + .btn-outline-danger:focus, .btn-check:active + .btn-outline-danger:focus, .btn-outline-danger:active:focus, .btn-outline-danger.active:focus, .btn-outline-danger.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.5); -} -.btn-outline-danger:disabled, .btn-outline-danger.disabled { - color: #dc3545; - background-color: transparent; -} - -.btn-outline-light { - color: #f8f9fa; - border-color: #f8f9fa; -} -.btn-outline-light:hover { - color: #000; - background-color: #f8f9fa; - border-color: #f8f9fa; -} -.btn-check:focus + .btn-outline-light, .btn-outline-light:focus { - box-shadow: 0 0 0 0.25rem rgba(248, 249, 250, 0.5); -} -.btn-check:checked + .btn-outline-light, .btn-check:active + .btn-outline-light, .btn-outline-light:active, .btn-outline-light.active, .btn-outline-light.dropdown-toggle.show { - color: #000; - background-color: #f8f9fa; - border-color: #f8f9fa; -} -.btn-check:checked + .btn-outline-light:focus, .btn-check:active + .btn-outline-light:focus, .btn-outline-light:active:focus, .btn-outline-light.active:focus, .btn-outline-light.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(248, 249, 250, 0.5); -} -.btn-outline-light:disabled, .btn-outline-light.disabled { - color: #f8f9fa; - background-color: transparent; -} - -.btn-outline-dark { - color: #212529; - border-color: #212529; -} -.btn-outline-dark:hover { - color: #fff; - background-color: #212529; - border-color: #212529; -} -.btn-check:focus + .btn-outline-dark, .btn-outline-dark:focus { - box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5); -} -.btn-check:checked + .btn-outline-dark, .btn-check:active + .btn-outline-dark, .btn-outline-dark:active, .btn-outline-dark.active, .btn-outline-dark.dropdown-toggle.show { - color: #fff; - background-color: #212529; - border-color: #212529; -} -.btn-check:checked + .btn-outline-dark:focus, .btn-check:active + .btn-outline-dark:focus, .btn-outline-dark:active:focus, .btn-outline-dark.active:focus, .btn-outline-dark.dropdown-toggle.show:focus { - box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5); -} -.btn-outline-dark:disabled, .btn-outline-dark.disabled { - color: #212529; - background-color: transparent; -} - -.btn-link { - font-weight: 400; - color: #0d6efd; - text-decoration: underline; -} -.btn-link:hover { - color: #0a58ca; -} -.btn-link:disabled, .btn-link.disabled { - color: #6c757d; -} - -.btn-lg, .btn-group-lg > .btn { - padding: 0.5rem 1rem; - font-size: 1.25rem; - border-radius: 0.3rem; -} - -.btn-sm, .btn-group-sm > .btn { - padding: 0.25rem 0.5rem; - font-size: 0.875rem; - border-radius: 0.2rem; -} - -.fade { - transition: opacity 0.15s linear; -} -@media (prefers-reduced-motion: reduce) { - .fade { - transition: none; - } -} -.fade:not(.show) { - opacity: 0; -} - -.collapse:not(.show) { - display: none; -} - -.collapsing { - height: 0; - overflow: hidden; - transition: height 0.35s ease; -} -@media (prefers-reduced-motion: reduce) { - .collapsing { - transition: none; - } -} -.collapsing.collapse-horizontal { - width: 0; - height: auto; - transition: width 0.35s ease; -} -@media (prefers-reduced-motion: reduce) { - .collapsing.collapse-horizontal { - transition: none; - } -} - -.dropup, -.dropend, -.dropdown, -.dropstart { - position: relative; -} - -.dropdown-toggle { - white-space: nowrap; -} -.dropdown-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0.3em solid; - border-right: 0.3em solid transparent; - border-bottom: 0; - border-left: 0.3em solid transparent; -} -.dropdown-toggle:empty::after { - margin-left: 0; -} - -.dropdown-menu { - position: absolute; - z-index: 1000; - display: none; - min-width: 10rem; - padding: 0.5rem 0; - margin: 0; - font-size: 1rem; - color: #212529; - text-align: left; - list-style: none; - background-color: #fff; - background-clip: padding-box; - border: 1px solid rgba(0, 0, 0, 0.15); - border-radius: 0.25rem; -} -.dropdown-menu[data-bs-popper] { - top: 100%; - left: 0; - margin-top: 0.125rem; -} - -.dropdown-menu-start { - --bs-position: start; -} -.dropdown-menu-start[data-bs-popper] { - right: auto; - left: 0; -} - -.dropdown-menu-end { - --bs-position: end; -} -.dropdown-menu-end[data-bs-popper] { - right: 0; - left: auto; -} - -@media (min-width: 576px) { - .dropdown-menu-sm-start { - --bs-position: start; - } - .dropdown-menu-sm-start[data-bs-popper] { - right: auto; - left: 0; - } - - .dropdown-menu-sm-end { - --bs-position: end; - } - .dropdown-menu-sm-end[data-bs-popper] { - right: 0; - left: auto; - } -} -@media (min-width: 768px) { - .dropdown-menu-md-start { - --bs-position: start; - } - .dropdown-menu-md-start[data-bs-popper] { - right: auto; - left: 0; - } - - .dropdown-menu-md-end { - --bs-position: end; - } - .dropdown-menu-md-end[data-bs-popper] { - right: 0; - left: auto; - } -} -@media (min-width: 992px) { - .dropdown-menu-lg-start { - --bs-position: start; - } - .dropdown-menu-lg-start[data-bs-popper] { - right: auto; - left: 0; - } - - .dropdown-menu-lg-end { - --bs-position: end; - } - .dropdown-menu-lg-end[data-bs-popper] { - right: 0; - left: auto; - } -} -@media (min-width: 1200px) { - .dropdown-menu-xl-start { - --bs-position: start; - } - .dropdown-menu-xl-start[data-bs-popper] { - right: auto; - left: 0; - } - - .dropdown-menu-xl-end { - --bs-position: end; - } - .dropdown-menu-xl-end[data-bs-popper] { - right: 0; - left: auto; - } -} -@media (min-width: 1400px) { - .dropdown-menu-xxl-start { - --bs-position: start; - } - .dropdown-menu-xxl-start[data-bs-popper] { - right: auto; - left: 0; - } - - .dropdown-menu-xxl-end { - --bs-position: end; - } - .dropdown-menu-xxl-end[data-bs-popper] { - right: 0; - left: auto; - } -} -.dropup .dropdown-menu[data-bs-popper] { - top: auto; - bottom: 100%; - margin-top: 0; - margin-bottom: 0.125rem; -} -.dropup .dropdown-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0; - border-right: 0.3em solid transparent; - border-bottom: 0.3em solid; - border-left: 0.3em solid transparent; -} -.dropup .dropdown-toggle:empty::after { - margin-left: 0; -} - -.dropend .dropdown-menu[data-bs-popper] { - top: 0; - right: auto; - left: 100%; - margin-top: 0; - margin-left: 0.125rem; -} -.dropend .dropdown-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0.3em solid transparent; - border-right: 0; - border-bottom: 0.3em solid transparent; - border-left: 0.3em solid; -} -.dropend .dropdown-toggle:empty::after { - margin-left: 0; -} -.dropend .dropdown-toggle::after { - vertical-align: 0; -} - -.dropstart .dropdown-menu[data-bs-popper] { - top: 0; - right: 100%; - left: auto; - margin-top: 0; - margin-right: 0.125rem; -} -.dropstart .dropdown-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; -} -.dropstart .dropdown-toggle::after { - display: none; -} -.dropstart .dropdown-toggle::before { - display: inline-block; - margin-right: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0.3em solid transparent; - border-right: 0.3em solid; - border-bottom: 0.3em solid transparent; -} -.dropstart .dropdown-toggle:empty::after { - margin-left: 0; -} -.dropstart .dropdown-toggle::before { - vertical-align: 0; -} - -.dropdown-divider { - height: 0; - margin: 0.5rem 0; - overflow: hidden; - border-top: 1px solid rgba(0, 0, 0, 0.15); -} - -.dropdown-item { - display: block; - width: 100%; - padding: 0.25rem 1rem; - clear: both; - font-weight: 400; - color: #212529; - text-align: inherit; - text-decoration: none; - white-space: nowrap; - background-color: transparent; - border: 0; -} -.dropdown-item:hover, .dropdown-item:focus { - color: #1e2125; - background-color: #e9ecef; -} -.dropdown-item.active, .dropdown-item:active { - color: #fff; - text-decoration: none; - background-color: #0d6efd; -} -.dropdown-item.disabled, .dropdown-item:disabled { - color: #adb5bd; - pointer-events: none; - background-color: transparent; -} - -.dropdown-menu.show { - display: block; -} - -.dropdown-header { - display: block; - padding: 0.5rem 1rem; - margin-bottom: 0; - font-size: 0.875rem; - color: #6c757d; - white-space: nowrap; -} - -.dropdown-item-text { - display: block; - padding: 0.25rem 1rem; - color: #212529; -} - -.dropdown-menu-dark { - color: #dee2e6; - background-color: #343a40; - border-color: rgba(0, 0, 0, 0.15); -} -.dropdown-menu-dark .dropdown-item { - color: #dee2e6; -} -.dropdown-menu-dark .dropdown-item:hover, .dropdown-menu-dark .dropdown-item:focus { - color: #fff; - background-color: rgba(255, 255, 255, 0.15); -} -.dropdown-menu-dark .dropdown-item.active, .dropdown-menu-dark .dropdown-item:active { - color: #fff; - background-color: #0d6efd; -} -.dropdown-menu-dark .dropdown-item.disabled, .dropdown-menu-dark .dropdown-item:disabled { - color: #adb5bd; -} -.dropdown-menu-dark .dropdown-divider { - border-color: rgba(0, 0, 0, 0.15); -} -.dropdown-menu-dark .dropdown-item-text { - color: #dee2e6; -} -.dropdown-menu-dark .dropdown-header { - color: #adb5bd; -} - -.btn-group, -.btn-group-vertical { - position: relative; - display: inline-flex; - vertical-align: middle; -} -.btn-group > .btn, -.btn-group-vertical > .btn { - position: relative; - flex: 1 1 auto; -} -.btn-group > .btn-check:checked + .btn, -.btn-group > .btn-check:focus + .btn, -.btn-group > .btn:hover, -.btn-group > .btn:focus, -.btn-group > .btn:active, -.btn-group > .btn.active, -.btn-group-vertical > .btn-check:checked + .btn, -.btn-group-vertical > .btn-check:focus + .btn, -.btn-group-vertical > .btn:hover, -.btn-group-vertical > .btn:focus, -.btn-group-vertical > .btn:active, -.btn-group-vertical > .btn.active { - z-index: 1; -} - -.btn-toolbar { - display: flex; - flex-wrap: wrap; - justify-content: flex-start; -} -.btn-toolbar .input-group { - width: auto; -} - -.btn-group > .btn:not(:first-child), -.btn-group > .btn-group:not(:first-child) { - margin-left: -1px; -} -.btn-group > .btn:not(:last-child):not(.dropdown-toggle), -.btn-group > .btn-group:not(:last-child) > .btn { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} -.btn-group > .btn:nth-child(n+3), -.btn-group > :not(.btn-check) + .btn, -.btn-group > .btn-group:not(:first-child) > .btn { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -.dropdown-toggle-split { - padding-right: 0.5625rem; - padding-left: 0.5625rem; -} -.dropdown-toggle-split::after, .dropup .dropdown-toggle-split::after, .dropend .dropdown-toggle-split::after { - margin-left: 0; -} -.dropstart .dropdown-toggle-split::before { - margin-right: 0; -} - -.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split { - padding-right: 0.375rem; - padding-left: 0.375rem; -} - -.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split { - padding-right: 0.75rem; - padding-left: 0.75rem; -} - -.btn-group-vertical { - flex-direction: column; - align-items: flex-start; - justify-content: center; -} -.btn-group-vertical > .btn, -.btn-group-vertical > .btn-group { - width: 100%; -} -.btn-group-vertical > .btn:not(:first-child), -.btn-group-vertical > .btn-group:not(:first-child) { - margin-top: -1px; -} -.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), -.btn-group-vertical > .btn-group:not(:last-child) > .btn { - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; -} -.btn-group-vertical > .btn ~ .btn, -.btn-group-vertical > .btn-group:not(:first-child) > .btn { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.nav { - display: flex; - flex-wrap: wrap; - padding-left: 0; - margin-bottom: 0; - list-style: none; -} - -.nav-link { - display: block; - padding: 0.5rem 1rem; - color: #0d6efd; - text-decoration: none; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .nav-link { - transition: none; - } -} -.nav-link:hover, .nav-link:focus { - color: #0a58ca; -} -.nav-link.disabled { - color: #6c757d; - pointer-events: none; - cursor: default; -} - -.nav-tabs { - border-bottom: 1px solid #dee2e6; -} -.nav-tabs .nav-link { - margin-bottom: -1px; - background: none; - border: 1px solid transparent; - border-top-left-radius: 0.25rem; - border-top-right-radius: 0.25rem; -} -.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus { - border-color: #e9ecef #e9ecef #dee2e6; - isolation: isolate; -} -.nav-tabs .nav-link.disabled { - color: #6c757d; - background-color: transparent; - border-color: transparent; -} -.nav-tabs .nav-link.active, -.nav-tabs .nav-item.show .nav-link { - color: #495057; - background-color: #fff; - border-color: #dee2e6 #dee2e6 #fff; -} -.nav-tabs .dropdown-menu { - margin-top: -1px; - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.nav-pills .nav-link { - background: none; - border: 0; - border-radius: 0.25rem; -} -.nav-pills .nav-link.active, -.nav-pills .show > .nav-link { - color: #fff; - background-color: #0d6efd; -} - -.nav-fill > .nav-link, -.nav-fill .nav-item { - flex: 1 1 auto; - text-align: center; -} - -.nav-justified > .nav-link, -.nav-justified .nav-item { - flex-basis: 0; - flex-grow: 1; - text-align: center; -} - -.nav-fill .nav-item .nav-link, -.nav-justified .nav-item .nav-link { - width: 100%; -} - -.tab-content > .tab-pane { - display: none; -} -.tab-content > .active { - display: block; -} - -.navbar { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - padding-top: 0.5rem; - padding-bottom: 0.5rem; -} -.navbar > .container, -.navbar > .container-fluid, -.navbar > .container-sm, -.navbar > .container-md, -.navbar > .container-lg, -.navbar > .container-xl, -.navbar > .container-xxl { - display: flex; - flex-wrap: inherit; - align-items: center; - justify-content: space-between; -} -.navbar-brand { - padding-top: 0.3125rem; - padding-bottom: 0.3125rem; - margin-right: 1rem; - font-size: 1.25rem; - text-decoration: none; - white-space: nowrap; -} -.navbar-nav { - display: flex; - flex-direction: column; - padding-left: 0; - margin-bottom: 0; - list-style: none; -} -.navbar-nav .nav-link { - padding-right: 0; - padding-left: 0; -} -.navbar-nav .dropdown-menu { - position: static; -} - -.navbar-text { - padding-top: 0.5rem; - padding-bottom: 0.5rem; -} - -.navbar-collapse { - flex-basis: 100%; - flex-grow: 1; - align-items: center; -} - -.navbar-toggler { - padding: 0.25rem 0.75rem; - font-size: 1.25rem; - line-height: 1; - background-color: transparent; - border: 1px solid transparent; - border-radius: 0.25rem; - transition: box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .navbar-toggler { - transition: none; - } -} -.navbar-toggler:hover { - text-decoration: none; -} -.navbar-toggler:focus { - text-decoration: none; - outline: 0; - box-shadow: 0 0 0 0.25rem; -} - -.navbar-toggler-icon { - display: inline-block; - width: 1.5em; - height: 1.5em; - vertical-align: middle; - background-repeat: no-repeat; - background-position: center; - background-size: 100%; -} - -.navbar-nav-scroll { - max-height: var(--bs-scroll-height, 75vh); - overflow-y: auto; -} - -@media (min-width: 576px) { - .navbar-expand-sm { - flex-wrap: nowrap; - justify-content: flex-start; - } - .navbar-expand-sm .navbar-nav { - flex-direction: row; - } - .navbar-expand-sm .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-sm .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; - } - .navbar-expand-sm .navbar-nav-scroll { - overflow: visible; - } - .navbar-expand-sm .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-sm .navbar-toggler { - display: none; - } - .navbar-expand-sm .offcanvas-header { - display: none; - } - .navbar-expand-sm .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; - } - .navbar-expand-sm .offcanvas-top, -.navbar-expand-sm .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; - } - .navbar-expand-sm .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } -} -@media (min-width: 768px) { - .navbar-expand-md { - flex-wrap: nowrap; - justify-content: flex-start; - } - .navbar-expand-md .navbar-nav { - flex-direction: row; - } - .navbar-expand-md .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-md .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; - } - .navbar-expand-md .navbar-nav-scroll { - overflow: visible; - } - .navbar-expand-md .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-md .navbar-toggler { - display: none; - } - .navbar-expand-md .offcanvas-header { - display: none; - } - .navbar-expand-md .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; - } - .navbar-expand-md .offcanvas-top, -.navbar-expand-md .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; - } - .navbar-expand-md .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } -} -@media (min-width: 992px) { - .navbar-expand-lg { - flex-wrap: nowrap; - justify-content: flex-start; - } - .navbar-expand-lg .navbar-nav { - flex-direction: row; - } - .navbar-expand-lg .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-lg .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; - } - .navbar-expand-lg .navbar-nav-scroll { - overflow: visible; - } - .navbar-expand-lg .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-lg .navbar-toggler { - display: none; - } - .navbar-expand-lg .offcanvas-header { - display: none; - } - .navbar-expand-lg .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; - } - .navbar-expand-lg .offcanvas-top, -.navbar-expand-lg .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; - } - .navbar-expand-lg .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } -} -@media (min-width: 1200px) { - .navbar-expand-xl { - flex-wrap: nowrap; - justify-content: flex-start; - } - .navbar-expand-xl .navbar-nav { - flex-direction: row; - } - .navbar-expand-xl .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-xl .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; - } - .navbar-expand-xl .navbar-nav-scroll { - overflow: visible; - } - .navbar-expand-xl .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-xl .navbar-toggler { - display: none; - } - .navbar-expand-xl .offcanvas-header { - display: none; - } - .navbar-expand-xl .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; - } - .navbar-expand-xl .offcanvas-top, -.navbar-expand-xl .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; - } - .navbar-expand-xl .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } -} -@media (min-width: 1400px) { - .navbar-expand-xxl { - flex-wrap: nowrap; - justify-content: flex-start; - } - .navbar-expand-xxl .navbar-nav { - flex-direction: row; - } - .navbar-expand-xxl .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-xxl .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; - } - .navbar-expand-xxl .navbar-nav-scroll { - overflow: visible; - } - .navbar-expand-xxl .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-xxl .navbar-toggler { - display: none; - } - .navbar-expand-xxl .offcanvas-header { - display: none; - } - .navbar-expand-xxl .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; - } - .navbar-expand-xxl .offcanvas-top, -.navbar-expand-xxl .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; - } - .navbar-expand-xxl .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } -} -.navbar-expand { - flex-wrap: nowrap; - justify-content: flex-start; -} -.navbar-expand .navbar-nav { - flex-direction: row; -} -.navbar-expand .navbar-nav .dropdown-menu { - position: absolute; -} -.navbar-expand .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; -} -.navbar-expand .navbar-nav-scroll { - overflow: visible; -} -.navbar-expand .navbar-collapse { - display: flex !important; - flex-basis: auto; -} -.navbar-expand .navbar-toggler { - display: none; -} -.navbar-expand .offcanvas-header { - display: none; -} -.navbar-expand .offcanvas { - position: inherit; - bottom: 0; - z-index: 1000; - flex-grow: 1; - visibility: visible !important; - background-color: transparent; - border-right: 0; - border-left: 0; - transition: none; - transform: none; -} -.navbar-expand .offcanvas-top, -.navbar-expand .offcanvas-bottom { - height: auto; - border-top: 0; - border-bottom: 0; -} -.navbar-expand .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; -} - -.navbar-light .navbar-brand { - color: rgba(0, 0, 0, 0.9); -} -.navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:focus { - color: rgba(0, 0, 0, 0.9); -} -.navbar-light .navbar-nav .nav-link { - color: rgba(0, 0, 0, 0.55); -} -.navbar-light .navbar-nav .nav-link:hover, .navbar-light .navbar-nav .nav-link:focus { - color: rgba(0, 0, 0, 0.7); -} -.navbar-light .navbar-nav .nav-link.disabled { - color: rgba(0, 0, 0, 0.3); -} -.navbar-light .navbar-nav .show > .nav-link, -.navbar-light .navbar-nav .nav-link.active { - color: rgba(0, 0, 0, 0.9); -} -.navbar-light .navbar-toggler { - color: rgba(0, 0, 0, 0.55); - border-color: rgba(0, 0, 0, 0.1); -} -.navbar-light .navbar-toggler-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); -} -.navbar-light .navbar-text { - color: rgba(0, 0, 0, 0.55); -} -.navbar-light .navbar-text a, -.navbar-light .navbar-text a:hover, -.navbar-light .navbar-text a:focus { - color: rgba(0, 0, 0, 0.9); -} - -.navbar-dark .navbar-brand { - color: #fff; -} -.navbar-dark .navbar-brand:hover, .navbar-dark .navbar-brand:focus { - color: #fff; -} -.navbar-dark .navbar-nav .nav-link { - color: rgba(255, 255, 255, 0.55); -} -.navbar-dark .navbar-nav .nav-link:hover, .navbar-dark .navbar-nav .nav-link:focus { - color: rgba(255, 255, 255, 0.75); -} -.navbar-dark .navbar-nav .nav-link.disabled { - color: rgba(255, 255, 255, 0.25); -} -.navbar-dark .navbar-nav .show > .nav-link, -.navbar-dark .navbar-nav .nav-link.active { - color: #fff; -} -.navbar-dark .navbar-toggler { - color: rgba(255, 255, 255, 0.55); - border-color: rgba(255, 255, 255, 0.1); -} -.navbar-dark .navbar-toggler-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); -} -.navbar-dark .navbar-text { - color: rgba(255, 255, 255, 0.55); -} -.navbar-dark .navbar-text a, -.navbar-dark .navbar-text a:hover, -.navbar-dark .navbar-text a:focus { - color: #fff; -} - -.card { - position: relative; - display: flex; - flex-direction: column; - min-width: 0; - word-wrap: break-word; - background-color: #fff; - background-clip: border-box; - border: 1px solid rgba(0, 0, 0, 0.125); - border-radius: 0.25rem; -} -.card > hr { - margin-right: 0; - margin-left: 0; -} -.card > .list-group { - border-top: inherit; - border-bottom: inherit; -} -.card > .list-group:first-child { - border-top-width: 0; - border-top-left-radius: calc(0.25rem - 1px); - border-top-right-radius: calc(0.25rem - 1px); -} -.card > .list-group:last-child { - border-bottom-width: 0; - border-bottom-right-radius: calc(0.25rem - 1px); - border-bottom-left-radius: calc(0.25rem - 1px); -} -.card > .card-header + .list-group, -.card > .list-group + .card-footer { - border-top: 0; -} - -.card-body { - flex: 1 1 auto; - padding: 1rem 1rem; -} - -.card-title { - margin-bottom: 0.5rem; -} - -.card-subtitle { - margin-top: -0.25rem; - margin-bottom: 0; -} - -.card-text:last-child { - margin-bottom: 0; -} - -.card-link + .card-link { - margin-left: 1rem; -} - -.card-header { - padding: 0.5rem 1rem; - margin-bottom: 0; - background-color: rgba(0, 0, 0, 0.03); - border-bottom: 1px solid rgba(0, 0, 0, 0.125); -} -.card-header:first-child { - border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0; -} - -.card-footer { - padding: 0.5rem 1rem; - background-color: rgba(0, 0, 0, 0.03); - border-top: 1px solid rgba(0, 0, 0, 0.125); -} -.card-footer:last-child { - border-radius: 0 0 calc(0.25rem - 1px) calc(0.25rem - 1px); -} - -.card-header-tabs { - margin-right: -0.5rem; - margin-bottom: -0.5rem; - margin-left: -0.5rem; - border-bottom: 0; -} - -.card-header-pills { - margin-right: -0.5rem; - margin-left: -0.5rem; -} - -.card-img-overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - padding: 1rem; - border-radius: calc(0.25rem - 1px); -} - -.card-img, -.card-img-top, -.card-img-bottom { - width: 100%; -} - -.card-img, -.card-img-top { - border-top-left-radius: calc(0.25rem - 1px); - border-top-right-radius: calc(0.25rem - 1px); -} - -.card-img, -.card-img-bottom { - border-bottom-right-radius: calc(0.25rem - 1px); - border-bottom-left-radius: calc(0.25rem - 1px); -} - -.card-group > .card { - margin-bottom: 0.75rem; -} -@media (min-width: 576px) { - .card-group { - display: flex; - flex-flow: row wrap; - } - .card-group > .card { - flex: 1 0 0%; - margin-bottom: 0; - } - .card-group > .card + .card { - margin-left: 0; - border-left: 0; - } - .card-group > .card:not(:last-child) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - .card-group > .card:not(:last-child) .card-img-top, -.card-group > .card:not(:last-child) .card-header { - border-top-right-radius: 0; - } - .card-group > .card:not(:last-child) .card-img-bottom, -.card-group > .card:not(:last-child) .card-footer { - border-bottom-right-radius: 0; - } - .card-group > .card:not(:first-child) { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - .card-group > .card:not(:first-child) .card-img-top, -.card-group > .card:not(:first-child) .card-header { - border-top-left-radius: 0; - } - .card-group > .card:not(:first-child) .card-img-bottom, -.card-group > .card:not(:first-child) .card-footer { - border-bottom-left-radius: 0; - } -} - -.accordion-button { - position: relative; - display: flex; - align-items: center; - width: 100%; - padding: 1rem 1.25rem; - font-size: 1rem; - color: #212529; - text-align: left; - background-color: #fff; - border: 0; - border-radius: 0; - overflow-anchor: none; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-radius 0.15s ease; -} -@media (prefers-reduced-motion: reduce) { - .accordion-button { - transition: none; - } -} -.accordion-button:not(.collapsed) { - color: #0c63e4; - background-color: #e7f1ff; - box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.125); -} -.accordion-button:not(.collapsed)::after { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); - transform: rotate(-180deg); -} -.accordion-button::after { - flex-shrink: 0; - width: 1.25rem; - height: 1.25rem; - margin-left: auto; - content: ""; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-size: 1.25rem; - transition: transform 0.2s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .accordion-button::after { - transition: none; - } -} -.accordion-button:hover { - z-index: 2; -} -.accordion-button:focus { - z-index: 3; - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} - -.accordion-header { - margin-bottom: 0; -} - -.accordion-item { - background-color: #fff; - border: 1px solid rgba(0, 0, 0, 0.125); -} -.accordion-item:first-of-type { - border-top-left-radius: 0.25rem; - border-top-right-radius: 0.25rem; -} -.accordion-item:first-of-type .accordion-button { - border-top-left-radius: calc(0.25rem - 1px); - border-top-right-radius: calc(0.25rem - 1px); -} -.accordion-item:not(:first-of-type) { - border-top: 0; -} -.accordion-item:last-of-type { - border-bottom-right-radius: 0.25rem; - border-bottom-left-radius: 0.25rem; -} -.accordion-item:last-of-type .accordion-button.collapsed { - border-bottom-right-radius: calc(0.25rem - 1px); - border-bottom-left-radius: calc(0.25rem - 1px); -} -.accordion-item:last-of-type .accordion-collapse { - border-bottom-right-radius: 0.25rem; - border-bottom-left-radius: 0.25rem; -} - -.accordion-body { - padding: 1rem 1.25rem; -} - -.accordion-flush .accordion-collapse { - border-width: 0; -} -.accordion-flush .accordion-item { - border-right: 0; - border-left: 0; - border-radius: 0; -} -.accordion-flush .accordion-item:first-child { - border-top: 0; -} -.accordion-flush .accordion-item:last-child { - border-bottom: 0; -} -.accordion-flush .accordion-item .accordion-button { - border-radius: 0; -} - -.breadcrumb { - display: flex; - flex-wrap: wrap; - padding: 0 0; - margin-bottom: 1rem; - list-style: none; -} - -.breadcrumb-item + .breadcrumb-item { - padding-left: 0.5rem; -} -.breadcrumb-item + .breadcrumb-item::before { - float: left; - padding-right: 0.5rem; - color: #6c757d; - content: var(--bs-breadcrumb-divider, "/") /* rtl: var(--bs-breadcrumb-divider, "/") */; -} -.breadcrumb-item.active { - color: #6c757d; -} - -.pagination, .dataTable-pagination ul { - display: flex; - padding-left: 0; - list-style: none; -} - -.page-link, .dataTable-pagination a { - position: relative; - display: block; - color: #0d6efd; - text-decoration: none; - background-color: #fff; - border: 1px solid #dee2e6; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .page-link, .dataTable-pagination a { - transition: none; - } -} -.page-link:hover, .dataTable-pagination a:hover { - z-index: 2; - color: #0a58ca; - background-color: #e9ecef; - border-color: #dee2e6; -} -.page-link:focus, .dataTable-pagination a:focus { - z-index: 3; - color: #0a58ca; - background-color: #e9ecef; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); -} - -.page-item:not(:first-child) .page-link, .page-item:not(:first-child) .dataTable-pagination a, .dataTable-pagination .page-item:not(:first-child) a, .dataTable-pagination li:not(:first-child) .page-link, .dataTable-pagination li:not(:first-child) a { - margin-left: -1px; -} -.page-item.active .page-link, .page-item.active .dataTable-pagination a, .dataTable-pagination .page-item.active a, .dataTable-pagination li.active .page-link, .dataTable-pagination li.active a { - z-index: 3; - color: #fff; - background-color: #0d6efd; - border-color: #0d6efd; -} -.page-item.disabled .page-link, .page-item.disabled .dataTable-pagination a, .dataTable-pagination .page-item.disabled a, .dataTable-pagination li.disabled .page-link, .dataTable-pagination li.disabled a { - color: #6c757d; - pointer-events: none; - background-color: #fff; - border-color: #dee2e6; -} - -.page-link, .dataTable-pagination a { - padding: 0.375rem 0.75rem; -} - -.page-item:first-child .page-link, .page-item:first-child .dataTable-pagination a, .dataTable-pagination .page-item:first-child a, .dataTable-pagination li:first-child .page-link, .dataTable-pagination li:first-child a { - border-top-left-radius: 0.25rem; - border-bottom-left-radius: 0.25rem; -} -.page-item:last-child .page-link, .page-item:last-child .dataTable-pagination a, .dataTable-pagination .page-item:last-child a, .dataTable-pagination li:last-child .page-link, .dataTable-pagination li:last-child a { - border-top-right-radius: 0.25rem; - border-bottom-right-radius: 0.25rem; -} - -.pagination-lg .page-link, .pagination-lg .dataTable-pagination a, .dataTable-pagination .pagination-lg a { - padding: 0.75rem 1.5rem; - font-size: 1.25rem; -} -.pagination-lg .page-item:first-child .page-link, .pagination-lg .page-item:first-child .dataTable-pagination a, .dataTable-pagination .pagination-lg .page-item:first-child a, .pagination-lg .dataTable-pagination li:first-child .page-link, .pagination-lg .dataTable-pagination li:first-child a, .dataTable-pagination .pagination-lg li:first-child .page-link, .dataTable-pagination .pagination-lg li:first-child a { - border-top-left-radius: 0.3rem; - border-bottom-left-radius: 0.3rem; -} -.pagination-lg .page-item:last-child .page-link, .pagination-lg .page-item:last-child .dataTable-pagination a, .dataTable-pagination .pagination-lg .page-item:last-child a, .pagination-lg .dataTable-pagination li:last-child .page-link, .pagination-lg .dataTable-pagination li:last-child a, .dataTable-pagination .pagination-lg li:last-child .page-link, .dataTable-pagination .pagination-lg li:last-child a { - border-top-right-radius: 0.3rem; - border-bottom-right-radius: 0.3rem; -} - -.pagination-sm .page-link, .pagination-sm .dataTable-pagination a, .dataTable-pagination .pagination-sm a { - padding: 0.25rem 0.5rem; - font-size: 0.875rem; -} -.pagination-sm .page-item:first-child .page-link, .pagination-sm .page-item:first-child .dataTable-pagination a, .dataTable-pagination .pagination-sm .page-item:first-child a, .pagination-sm .dataTable-pagination li:first-child .page-link, .pagination-sm .dataTable-pagination li:first-child a, .dataTable-pagination .pagination-sm li:first-child .page-link, .dataTable-pagination .pagination-sm li:first-child a { - border-top-left-radius: 0.2rem; - border-bottom-left-radius: 0.2rem; -} -.pagination-sm .page-item:last-child .page-link, .pagination-sm .page-item:last-child .dataTable-pagination a, .dataTable-pagination .pagination-sm .page-item:last-child a, .pagination-sm .dataTable-pagination li:last-child .page-link, .pagination-sm .dataTable-pagination li:last-child a, .dataTable-pagination .pagination-sm li:last-child .page-link, .dataTable-pagination .pagination-sm li:last-child a { - border-top-right-radius: 0.2rem; - border-bottom-right-radius: 0.2rem; -} - -.badge { - display: inline-block; - padding: 0.35em 0.65em; - font-size: 0.75em; - font-weight: 700; - line-height: 1; - color: #fff; - text-align: center; - white-space: nowrap; - vertical-align: baseline; - border-radius: 0.25rem; -} -.badge:empty { - display: none; -} - -.btn .badge { - position: relative; - top: -1px; -} - -.alert { - position: relative; - padding: 1rem 1rem; - margin-bottom: 1rem; - border: 1px solid transparent; - border-radius: 0.25rem; -} - -.alert-heading { - color: inherit; -} - -.alert-link { - font-weight: 700; -} - -.alert-dismissible { - padding-right: 3rem; -} -.alert-dismissible .btn-close { - position: absolute; - top: 0; - right: 0; - z-index: 2; - padding: 1.25rem 1rem; -} - -.alert-primary { - color: #084298; - background-color: #cfe2ff; - border-color: #b6d4fe; -} -.alert-primary .alert-link { - color: #06357a; -} - -.alert-secondary { - color: #41464b; - background-color: #e2e3e5; - border-color: #d3d6d8; -} -.alert-secondary .alert-link { - color: #34383c; -} - -.alert-success { - color: #0f5132; - background-color: #d1e7dd; - border-color: #badbcc; -} -.alert-success .alert-link { - color: #0c4128; -} - -.alert-info { - color: #055160; - background-color: #cff4fc; - border-color: #b6effb; -} -.alert-info .alert-link { - color: #04414d; -} - -.alert-warning { - color: #664d03; - background-color: #fff3cd; - border-color: #ffecb5; -} -.alert-warning .alert-link { - color: #523e02; -} - -.alert-danger { - color: #842029; - background-color: #f8d7da; - border-color: #f5c2c7; -} -.alert-danger .alert-link { - color: #6a1a21; -} - -.alert-light { - color: #636464; - background-color: #fefefe; - border-color: #fdfdfe; -} -.alert-light .alert-link { - color: #4f5050; -} - -.alert-dark { - color: #141619; - background-color: #d3d3d4; - border-color: #bcbebf; -} -.alert-dark .alert-link { - color: #101214; -} - -@-webkit-keyframes progress-bar-stripes { - 0% { - background-position-x: 1rem; - } -} - -@keyframes progress-bar-stripes { - 0% { - background-position-x: 1rem; - } -} -.progress { - display: flex; - height: 1rem; - overflow: hidden; - font-size: 0.75rem; - background-color: #e9ecef; - border-radius: 0.25rem; -} - -.progress-bar { - display: flex; - flex-direction: column; - justify-content: center; - overflow: hidden; - color: #fff; - text-align: center; - white-space: nowrap; - background-color: #0d6efd; - transition: width 0.6s ease; -} -@media (prefers-reduced-motion: reduce) { - .progress-bar { - transition: none; - } -} - -.progress-bar-striped { - background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-size: 1rem 1rem; -} - -.progress-bar-animated { - -webkit-animation: 1s linear infinite progress-bar-stripes; - animation: 1s linear infinite progress-bar-stripes; -} -@media (prefers-reduced-motion: reduce) { - .progress-bar-animated { - -webkit-animation: none; - animation: none; - } -} - -.list-group { - display: flex; - flex-direction: column; - padding-left: 0; - margin-bottom: 0; - border-radius: 0.25rem; -} - -.list-group-numbered { - list-style-type: none; - counter-reset: section; -} -.list-group-numbered > li::before { - content: counters(section, ".") ". "; - counter-increment: section; -} - -.list-group-item-action { - width: 100%; - color: #495057; - text-align: inherit; -} -.list-group-item-action:hover, .list-group-item-action:focus { - z-index: 1; - color: #495057; - text-decoration: none; - background-color: #f8f9fa; -} -.list-group-item-action:active { - color: #212529; - background-color: #e9ecef; -} - -.list-group-item { - position: relative; - display: block; - padding: 0.5rem 1rem; - color: #212529; - text-decoration: none; - background-color: #fff; - border: 1px solid rgba(0, 0, 0, 0.125); -} -.list-group-item:first-child { - border-top-left-radius: inherit; - border-top-right-radius: inherit; -} -.list-group-item:last-child { - border-bottom-right-radius: inherit; - border-bottom-left-radius: inherit; -} -.list-group-item.disabled, .list-group-item:disabled { - color: #6c757d; - pointer-events: none; - background-color: #fff; -} -.list-group-item.active { - z-index: 2; - color: #fff; - background-color: #0d6efd; - border-color: #0d6efd; -} -.list-group-item + .list-group-item { - border-top-width: 0; -} -.list-group-item + .list-group-item.active { - margin-top: -1px; - border-top-width: 1px; -} - -.list-group-horizontal { - flex-direction: row; -} -.list-group-horizontal > .list-group-item:first-child { - border-bottom-left-radius: 0.25rem; - border-top-right-radius: 0; -} -.list-group-horizontal > .list-group-item:last-child { - border-top-right-radius: 0.25rem; - border-bottom-left-radius: 0; -} -.list-group-horizontal > .list-group-item.active { - margin-top: 0; -} -.list-group-horizontal > .list-group-item + .list-group-item { - border-top-width: 1px; - border-left-width: 0; -} -.list-group-horizontal > .list-group-item + .list-group-item.active { - margin-left: -1px; - border-left-width: 1px; -} - -@media (min-width: 576px) { - .list-group-horizontal-sm { - flex-direction: row; - } - .list-group-horizontal-sm > .list-group-item:first-child { - border-bottom-left-radius: 0.25rem; - border-top-right-radius: 0; - } - .list-group-horizontal-sm > .list-group-item:last-child { - border-top-right-radius: 0.25rem; - border-bottom-left-radius: 0; - } - .list-group-horizontal-sm > .list-group-item.active { - margin-top: 0; - } - .list-group-horizontal-sm > .list-group-item + .list-group-item { - border-top-width: 1px; - border-left-width: 0; - } - .list-group-horizontal-sm > .list-group-item + .list-group-item.active { - margin-left: -1px; - border-left-width: 1px; - } -} -@media (min-width: 768px) { - .list-group-horizontal-md { - flex-direction: row; - } - .list-group-horizontal-md > .list-group-item:first-child { - border-bottom-left-radius: 0.25rem; - border-top-right-radius: 0; - } - .list-group-horizontal-md > .list-group-item:last-child { - border-top-right-radius: 0.25rem; - border-bottom-left-radius: 0; - } - .list-group-horizontal-md > .list-group-item.active { - margin-top: 0; - } - .list-group-horizontal-md > .list-group-item + .list-group-item { - border-top-width: 1px; - border-left-width: 0; - } - .list-group-horizontal-md > .list-group-item + .list-group-item.active { - margin-left: -1px; - border-left-width: 1px; - } -} -@media (min-width: 992px) { - .list-group-horizontal-lg { - flex-direction: row; - } - .list-group-horizontal-lg > .list-group-item:first-child { - border-bottom-left-radius: 0.25rem; - border-top-right-radius: 0; - } - .list-group-horizontal-lg > .list-group-item:last-child { - border-top-right-radius: 0.25rem; - border-bottom-left-radius: 0; - } - .list-group-horizontal-lg > .list-group-item.active { - margin-top: 0; - } - .list-group-horizontal-lg > .list-group-item + .list-group-item { - border-top-width: 1px; - border-left-width: 0; - } - .list-group-horizontal-lg > .list-group-item + .list-group-item.active { - margin-left: -1px; - border-left-width: 1px; - } -} -@media (min-width: 1200px) { - .list-group-horizontal-xl { - flex-direction: row; - } - .list-group-horizontal-xl > .list-group-item:first-child { - border-bottom-left-radius: 0.25rem; - border-top-right-radius: 0; - } - .list-group-horizontal-xl > .list-group-item:last-child { - border-top-right-radius: 0.25rem; - border-bottom-left-radius: 0; - } - .list-group-horizontal-xl > .list-group-item.active { - margin-top: 0; - } - .list-group-horizontal-xl > .list-group-item + .list-group-item { - border-top-width: 1px; - border-left-width: 0; - } - .list-group-horizontal-xl > .list-group-item + .list-group-item.active { - margin-left: -1px; - border-left-width: 1px; - } -} -@media (min-width: 1400px) { - .list-group-horizontal-xxl { - flex-direction: row; - } - .list-group-horizontal-xxl > .list-group-item:first-child { - border-bottom-left-radius: 0.25rem; - border-top-right-radius: 0; - } - .list-group-horizontal-xxl > .list-group-item:last-child { - border-top-right-radius: 0.25rem; - border-bottom-left-radius: 0; - } - .list-group-horizontal-xxl > .list-group-item.active { - margin-top: 0; - } - .list-group-horizontal-xxl > .list-group-item + .list-group-item { - border-top-width: 1px; - border-left-width: 0; - } - .list-group-horizontal-xxl > .list-group-item + .list-group-item.active { - margin-left: -1px; - border-left-width: 1px; - } -} -.list-group-flush { - border-radius: 0; -} -.list-group-flush > .list-group-item { - border-width: 0 0 1px; -} -.list-group-flush > .list-group-item:last-child { - border-bottom-width: 0; -} - -.list-group-item-primary { - color: #084298; - background-color: #cfe2ff; -} -.list-group-item-primary.list-group-item-action:hover, .list-group-item-primary.list-group-item-action:focus { - color: #084298; - background-color: #bacbe6; -} -.list-group-item-primary.list-group-item-action.active { - color: #fff; - background-color: #084298; - border-color: #084298; -} - -.list-group-item-secondary { - color: #41464b; - background-color: #e2e3e5; -} -.list-group-item-secondary.list-group-item-action:hover, .list-group-item-secondary.list-group-item-action:focus { - color: #41464b; - background-color: #cbccce; -} -.list-group-item-secondary.list-group-item-action.active { - color: #fff; - background-color: #41464b; - border-color: #41464b; -} - -.list-group-item-success { - color: #0f5132; - background-color: #d1e7dd; -} -.list-group-item-success.list-group-item-action:hover, .list-group-item-success.list-group-item-action:focus { - color: #0f5132; - background-color: #bcd0c7; -} -.list-group-item-success.list-group-item-action.active { - color: #fff; - background-color: #0f5132; - border-color: #0f5132; -} - -.list-group-item-info { - color: #055160; - background-color: #cff4fc; -} -.list-group-item-info.list-group-item-action:hover, .list-group-item-info.list-group-item-action:focus { - color: #055160; - background-color: #badce3; -} -.list-group-item-info.list-group-item-action.active { - color: #fff; - background-color: #055160; - border-color: #055160; -} - -.list-group-item-warning { - color: #664d03; - background-color: #fff3cd; -} -.list-group-item-warning.list-group-item-action:hover, .list-group-item-warning.list-group-item-action:focus { - color: #664d03; - background-color: #e6dbb9; -} -.list-group-item-warning.list-group-item-action.active { - color: #fff; - background-color: #664d03; - border-color: #664d03; -} - -.list-group-item-danger { - color: #842029; - background-color: #f8d7da; -} -.list-group-item-danger.list-group-item-action:hover, .list-group-item-danger.list-group-item-action:focus { - color: #842029; - background-color: #dfc2c4; -} -.list-group-item-danger.list-group-item-action.active { - color: #fff; - background-color: #842029; - border-color: #842029; -} - -.list-group-item-light { - color: #636464; - background-color: #fefefe; -} -.list-group-item-light.list-group-item-action:hover, .list-group-item-light.list-group-item-action:focus { - color: #636464; - background-color: #e5e5e5; -} -.list-group-item-light.list-group-item-action.active { - color: #fff; - background-color: #636464; - border-color: #636464; -} - -.list-group-item-dark { - color: #141619; - background-color: #d3d3d4; -} -.list-group-item-dark.list-group-item-action:hover, .list-group-item-dark.list-group-item-action:focus { - color: #141619; - background-color: #bebebf; -} -.list-group-item-dark.list-group-item-action.active { - color: #fff; - background-color: #141619; - border-color: #141619; -} - -.btn-close { - box-sizing: content-box; - width: 1em; - height: 1em; - padding: 0.25em 0.25em; - color: #000; - background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat; - border: 0; - border-radius: 0.25rem; - opacity: 0.5; -} -.btn-close:hover { - color: #000; - text-decoration: none; - opacity: 0.75; -} -.btn-close:focus { - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); - opacity: 1; -} -.btn-close:disabled, .btn-close.disabled { - pointer-events: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - opacity: 0.25; -} - -.btn-close-white { - filter: invert(1) grayscale(100%) brightness(200%); -} - -.toast { - width: 350px; - max-width: 100%; - font-size: 0.875rem; - pointer-events: auto; - background-color: rgba(255, 255, 255, 0.85); - background-clip: padding-box; - border: 1px solid rgba(0, 0, 0, 0.1); - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - border-radius: 0.25rem; -} -.toast.showing { - opacity: 0; -} -.toast:not(.show) { - display: none; -} - -.toast-container { - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 100%; - pointer-events: none; -} -.toast-container > :not(:last-child) { - margin-bottom: 0.75rem; -} - -.toast-header { - display: flex; - align-items: center; - padding: 0.5rem 0.75rem; - color: #6c757d; - background-color: rgba(255, 255, 255, 0.85); - background-clip: padding-box; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - border-top-left-radius: calc(0.25rem - 1px); - border-top-right-radius: calc(0.25rem - 1px); -} -.toast-header .btn-close { - margin-right: -0.375rem; - margin-left: 0.75rem; -} - -.toast-body { - padding: 0.75rem; - word-wrap: break-word; -} - -.modal { - position: fixed; - top: 0; - left: 0; - z-index: 1055; - display: none; - width: 100%; - height: 100%; - overflow-x: hidden; - overflow-y: auto; - outline: 0; -} - -.modal-dialog { - position: relative; - width: auto; - margin: 0.5rem; - pointer-events: none; -} -.modal.fade .modal-dialog { - transition: transform 0.3s ease-out; - transform: translate(0, -50px); -} -@media (prefers-reduced-motion: reduce) { - .modal.fade .modal-dialog { - transition: none; - } -} -.modal.show .modal-dialog { - transform: none; -} -.modal.modal-static .modal-dialog { - transform: scale(1.02); -} - -.modal-dialog-scrollable { - height: calc(100% - 1rem); -} -.modal-dialog-scrollable .modal-content { - max-height: 100%; - overflow: hidden; -} -.modal-dialog-scrollable .modal-body { - overflow-y: auto; -} - -.modal-dialog-centered { - display: flex; - align-items: center; - min-height: calc(100% - 1rem); -} - -.modal-content { - position: relative; - display: flex; - flex-direction: column; - width: 100%; - pointer-events: auto; - background-color: #fff; - background-clip: padding-box; - border: 1px solid rgba(0, 0, 0, 0.2); - border-radius: 0.3rem; - outline: 0; -} - -.modal-backdrop { - position: fixed; - top: 0; - left: 0; - z-index: 1050; - width: 100vw; - height: 100vh; - background-color: #000; -} -.modal-backdrop.fade { - opacity: 0; -} -.modal-backdrop.show { - opacity: 0.5; -} - -.modal-header { - display: flex; - flex-shrink: 0; - align-items: center; - justify-content: space-between; - padding: 1rem 1rem; - border-bottom: 1px solid #dee2e6; - border-top-left-radius: calc(0.3rem - 1px); - border-top-right-radius: calc(0.3rem - 1px); -} -.modal-header .btn-close { - padding: 0.5rem 0.5rem; - margin: -0.5rem -0.5rem -0.5rem auto; -} - -.modal-title { - margin-bottom: 0; - line-height: 1.5; -} - -.modal-body { - position: relative; - flex: 1 1 auto; - padding: 1rem; -} - -.modal-footer { - display: flex; - flex-wrap: wrap; - flex-shrink: 0; - align-items: center; - justify-content: flex-end; - padding: 0.75rem; - border-top: 1px solid #dee2e6; - border-bottom-right-radius: calc(0.3rem - 1px); - border-bottom-left-radius: calc(0.3rem - 1px); -} -.modal-footer > * { - margin: 0.25rem; -} - -@media (min-width: 576px) { - .modal-dialog { - max-width: 500px; - margin: 1.75rem auto; - } - - .modal-dialog-scrollable { - height: calc(100% - 3.5rem); - } - - .modal-dialog-centered { - min-height: calc(100% - 3.5rem); - } - - .modal-sm { - max-width: 300px; - } -} -@media (min-width: 992px) { - .modal-lg, -.modal-xl { - max-width: 800px; - } -} -@media (min-width: 1200px) { - .modal-xl { - max-width: 1140px; - } -} -.modal-fullscreen { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; -} -.modal-fullscreen .modal-content { - height: 100%; - border: 0; - border-radius: 0; -} -.modal-fullscreen .modal-header { - border-radius: 0; -} -.modal-fullscreen .modal-body { - overflow-y: auto; -} -.modal-fullscreen .modal-footer { - border-radius: 0; -} - -@media (max-width: 575.98px) { - .modal-fullscreen-sm-down { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - } - .modal-fullscreen-sm-down .modal-content { - height: 100%; - border: 0; - border-radius: 0; - } - .modal-fullscreen-sm-down .modal-header { - border-radius: 0; - } - .modal-fullscreen-sm-down .modal-body { - overflow-y: auto; - } - .modal-fullscreen-sm-down .modal-footer { - border-radius: 0; - } -} -@media (max-width: 767.98px) { - .modal-fullscreen-md-down { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - } - .modal-fullscreen-md-down .modal-content { - height: 100%; - border: 0; - border-radius: 0; - } - .modal-fullscreen-md-down .modal-header { - border-radius: 0; - } - .modal-fullscreen-md-down .modal-body { - overflow-y: auto; - } - .modal-fullscreen-md-down .modal-footer { - border-radius: 0; - } -} -@media (max-width: 991.98px) { - .modal-fullscreen-lg-down { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - } - .modal-fullscreen-lg-down .modal-content { - height: 100%; - border: 0; - border-radius: 0; - } - .modal-fullscreen-lg-down .modal-header { - border-radius: 0; - } - .modal-fullscreen-lg-down .modal-body { - overflow-y: auto; - } - .modal-fullscreen-lg-down .modal-footer { - border-radius: 0; - } -} -@media (max-width: 1199.98px) { - .modal-fullscreen-xl-down { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - } - .modal-fullscreen-xl-down .modal-content { - height: 100%; - border: 0; - border-radius: 0; - } - .modal-fullscreen-xl-down .modal-header { - border-radius: 0; - } - .modal-fullscreen-xl-down .modal-body { - overflow-y: auto; - } - .modal-fullscreen-xl-down .modal-footer { - border-radius: 0; - } -} -@media (max-width: 1399.98px) { - .modal-fullscreen-xxl-down { - width: 100vw; - max-width: none; - height: 100%; - margin: 0; - } - .modal-fullscreen-xxl-down .modal-content { - height: 100%; - border: 0; - border-radius: 0; - } - .modal-fullscreen-xxl-down .modal-header { - border-radius: 0; - } - .modal-fullscreen-xxl-down .modal-body { - overflow-y: auto; - } - .modal-fullscreen-xxl-down .modal-footer { - border-radius: 0; - } -} -.tooltip { - position: absolute; - z-index: 1080; - display: block; - margin: 0; - font-family: var(--bs-font-sans-serif); - font-style: normal; - font-weight: 400; - line-height: 1.5; - text-align: left; - text-align: start; - text-decoration: none; - text-shadow: none; - text-transform: none; - letter-spacing: normal; - word-break: normal; - word-spacing: normal; - white-space: normal; - line-break: auto; - font-size: 0.875rem; - word-wrap: break-word; - opacity: 0; -} -.tooltip.show { - opacity: 0.9; -} -.tooltip .tooltip-arrow { - position: absolute; - display: block; - width: 0.8rem; - height: 0.4rem; -} -.tooltip .tooltip-arrow::before { - position: absolute; - content: ""; - border-color: transparent; - border-style: solid; -} - -.bs-tooltip-top, .bs-tooltip-auto[data-popper-placement^=top] { - padding: 0.4rem 0; -} -.bs-tooltip-top .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow { - bottom: 0; -} -.bs-tooltip-top .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before { - top: -1px; - border-width: 0.4rem 0.4rem 0; - border-top-color: #000; -} - -.bs-tooltip-end, .bs-tooltip-auto[data-popper-placement^=right] { - padding: 0 0.4rem; -} -.bs-tooltip-end .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow { - left: 0; - width: 0.4rem; - height: 0.8rem; -} -.bs-tooltip-end .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before { - right: -1px; - border-width: 0.4rem 0.4rem 0.4rem 0; - border-right-color: #000; -} - -.bs-tooltip-bottom, .bs-tooltip-auto[data-popper-placement^=bottom] { - padding: 0.4rem 0; -} -.bs-tooltip-bottom .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow { - top: 0; -} -.bs-tooltip-bottom .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before { - bottom: -1px; - border-width: 0 0.4rem 0.4rem; - border-bottom-color: #000; -} - -.bs-tooltip-start, .bs-tooltip-auto[data-popper-placement^=left] { - padding: 0 0.4rem; -} -.bs-tooltip-start .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow { - right: 0; - width: 0.4rem; - height: 0.8rem; -} -.bs-tooltip-start .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before { - left: -1px; - border-width: 0.4rem 0 0.4rem 0.4rem; - border-left-color: #000; -} - -.tooltip-inner { - max-width: 200px; - padding: 0.25rem 0.5rem; - color: #fff; - text-align: center; - background-color: #000; - border-radius: 0.25rem; -} - -.popover { - position: absolute; - top: 0; - left: 0 /* rtl:ignore */; - z-index: 1070; - display: block; - max-width: 276px; - font-family: var(--bs-font-sans-serif); - font-style: normal; - font-weight: 400; - line-height: 1.5; - text-align: left; - text-align: start; - text-decoration: none; - text-shadow: none; - text-transform: none; - letter-spacing: normal; - word-break: normal; - word-spacing: normal; - white-space: normal; - line-break: auto; - font-size: 0.875rem; - word-wrap: break-word; - background-color: #fff; - background-clip: padding-box; - border: 1px solid rgba(0, 0, 0, 0.2); - border-radius: 0.3rem; -} -.popover .popover-arrow { - position: absolute; - display: block; - width: 1rem; - height: 0.5rem; -} -.popover .popover-arrow::before, .popover .popover-arrow::after { - position: absolute; - display: block; - content: ""; - border-color: transparent; - border-style: solid; -} - -.bs-popover-top > .popover-arrow, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow { - bottom: calc(-0.5rem - 1px); -} -.bs-popover-top > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::before { - bottom: 0; - border-width: 0.5rem 0.5rem 0; - border-top-color: rgba(0, 0, 0, 0.25); -} -.bs-popover-top > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::after { - bottom: 1px; - border-width: 0.5rem 0.5rem 0; - border-top-color: #fff; -} - -.bs-popover-end > .popover-arrow, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow { - left: calc(-0.5rem - 1px); - width: 0.5rem; - height: 1rem; -} -.bs-popover-end > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::before { - left: 0; - border-width: 0.5rem 0.5rem 0.5rem 0; - border-right-color: rgba(0, 0, 0, 0.25); -} -.bs-popover-end > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::after { - left: 1px; - border-width: 0.5rem 0.5rem 0.5rem 0; - border-right-color: #fff; -} - -.bs-popover-bottom > .popover-arrow, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow { - top: calc(-0.5rem - 1px); -} -.bs-popover-bottom > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::before { - top: 0; - border-width: 0 0.5rem 0.5rem 0.5rem; - border-bottom-color: rgba(0, 0, 0, 0.25); -} -.bs-popover-bottom > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::after { - top: 1px; - border-width: 0 0.5rem 0.5rem 0.5rem; - border-bottom-color: #fff; -} -.bs-popover-bottom .popover-header::before, .bs-popover-auto[data-popper-placement^=bottom] .popover-header::before { - position: absolute; - top: 0; - left: 50%; - display: block; - width: 1rem; - margin-left: -0.5rem; - content: ""; - border-bottom: 1px solid #f0f0f0; -} - -.bs-popover-start > .popover-arrow, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow { - right: calc(-0.5rem - 1px); - width: 0.5rem; - height: 1rem; -} -.bs-popover-start > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::before { - right: 0; - border-width: 0.5rem 0 0.5rem 0.5rem; - border-left-color: rgba(0, 0, 0, 0.25); -} -.bs-popover-start > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::after { - right: 1px; - border-width: 0.5rem 0 0.5rem 0.5rem; - border-left-color: #fff; -} - -.popover-header { - padding: 0.5rem 1rem; - margin-bottom: 0; - font-size: 1rem; - background-color: #f0f0f0; - border-bottom: 1px solid rgba(0, 0, 0, 0.2); - border-top-left-radius: calc(0.3rem - 1px); - border-top-right-radius: calc(0.3rem - 1px); -} -.popover-header:empty { - display: none; -} - -.popover-body { - padding: 1rem 1rem; - color: #212529; -} - -.carousel { - position: relative; -} - -.carousel.pointer-event { - touch-action: pan-y; -} - -.carousel-inner { - position: relative; - width: 100%; - overflow: hidden; -} -.carousel-inner::after { - display: block; - clear: both; - content: ""; -} - -.carousel-item { - position: relative; - display: none; - float: left; - width: 100%; - margin-right: -100%; - -webkit-backface-visibility: hidden; - backface-visibility: hidden; - transition: transform 0.6s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .carousel-item { - transition: none; - } -} - -.carousel-item.active, -.carousel-item-next, -.carousel-item-prev { - display: block; -} - -/* rtl:begin:ignore */ -.carousel-item-next:not(.carousel-item-start), -.active.carousel-item-end { - transform: translateX(100%); -} - -.carousel-item-prev:not(.carousel-item-end), -.active.carousel-item-start { - transform: translateX(-100%); -} - -/* rtl:end:ignore */ -.carousel-fade .carousel-item { - opacity: 0; - transition-property: opacity; - transform: none; -} -.carousel-fade .carousel-item.active, -.carousel-fade .carousel-item-next.carousel-item-start, -.carousel-fade .carousel-item-prev.carousel-item-end { - z-index: 1; - opacity: 1; -} -.carousel-fade .active.carousel-item-start, -.carousel-fade .active.carousel-item-end { - z-index: 0; - opacity: 0; - transition: opacity 0s 0.6s; -} -@media (prefers-reduced-motion: reduce) { - .carousel-fade .active.carousel-item-start, -.carousel-fade .active.carousel-item-end { - transition: none; - } -} - -.carousel-control-prev, -.carousel-control-next { - position: absolute; - top: 0; - bottom: 0; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - width: 15%; - padding: 0; - color: #fff; - text-align: center; - background: none; - border: 0; - opacity: 0.5; - transition: opacity 0.15s ease; -} -@media (prefers-reduced-motion: reduce) { - .carousel-control-prev, -.carousel-control-next { - transition: none; - } -} -.carousel-control-prev:hover, .carousel-control-prev:focus, -.carousel-control-next:hover, -.carousel-control-next:focus { - color: #fff; - text-decoration: none; - outline: 0; - opacity: 0.9; -} - -.carousel-control-prev { - left: 0; -} - -.carousel-control-next { - right: 0; -} - -.carousel-control-prev-icon, -.carousel-control-next-icon { - display: inline-block; - width: 2rem; - height: 2rem; - background-repeat: no-repeat; - background-position: 50%; - background-size: 100% 100%; -} - -/* rtl:options: { - "autoRename": true, - "stringMap":[ { - "name" : "prev-next", - "search" : "prev", - "replace" : "next" - } ] -} */ -.carousel-control-prev-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e"); -} - -.carousel-control-next-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); -} - -.carousel-indicators { - position: absolute; - right: 0; - bottom: 0; - left: 0; - z-index: 2; - display: flex; - justify-content: center; - padding: 0; - margin-right: 15%; - margin-bottom: 1rem; - margin-left: 15%; - list-style: none; -} -.carousel-indicators [data-bs-target] { - box-sizing: content-box; - flex: 0 1 auto; - width: 30px; - height: 3px; - padding: 0; - margin-right: 3px; - margin-left: 3px; - text-indent: -999px; - cursor: pointer; - background-color: #fff; - background-clip: padding-box; - border: 0; - border-top: 10px solid transparent; - border-bottom: 10px solid transparent; - opacity: 0.5; - transition: opacity 0.6s ease; -} -@media (prefers-reduced-motion: reduce) { - .carousel-indicators [data-bs-target] { - transition: none; - } -} -.carousel-indicators .active { - opacity: 1; -} - -.carousel-caption { - position: absolute; - right: 15%; - bottom: 1.25rem; - left: 15%; - padding-top: 1.25rem; - padding-bottom: 1.25rem; - color: #fff; - text-align: center; -} - -.carousel-dark .carousel-control-prev-icon, -.carousel-dark .carousel-control-next-icon { - filter: invert(1) grayscale(100); -} -.carousel-dark .carousel-indicators [data-bs-target] { - background-color: #000; -} -.carousel-dark .carousel-caption { - color: #000; -} - -@-webkit-keyframes spinner-border { - to { - transform: rotate(360deg) /* rtl:ignore */; - } -} - -@keyframes spinner-border { - to { - transform: rotate(360deg) /* rtl:ignore */; - } -} -.spinner-border { - display: inline-block; - width: 2rem; - height: 2rem; - vertical-align: -0.125em; - border: 0.25em solid currentColor; - border-right-color: transparent; - border-radius: 50%; - -webkit-animation: 0.75s linear infinite spinner-border; - animation: 0.75s linear infinite spinner-border; -} - -.spinner-border-sm { - width: 1rem; - height: 1rem; - border-width: 0.2em; -} - -@-webkit-keyframes spinner-grow { - 0% { - transform: scale(0); - } - 50% { - opacity: 1; - transform: none; - } -} - -@keyframes spinner-grow { - 0% { - transform: scale(0); - } - 50% { - opacity: 1; - transform: none; - } -} -.spinner-grow { - display: inline-block; - width: 2rem; - height: 2rem; - vertical-align: -0.125em; - background-color: currentColor; - border-radius: 50%; - opacity: 0; - -webkit-animation: 0.75s linear infinite spinner-grow; - animation: 0.75s linear infinite spinner-grow; -} - -.spinner-grow-sm { - width: 1rem; - height: 1rem; -} - -@media (prefers-reduced-motion: reduce) { - .spinner-border, -.spinner-grow { - -webkit-animation-duration: 1.5s; - animation-duration: 1.5s; - } -} -.offcanvas { - position: fixed; - bottom: 0; - z-index: 1045; - display: flex; - flex-direction: column; - max-width: 100%; - visibility: hidden; - background-color: #fff; - background-clip: padding-box; - outline: 0; - transition: transform 0.3s ease-in-out; -} -@media (prefers-reduced-motion: reduce) { - .offcanvas { - transition: none; - } -} - -.offcanvas-backdrop { - position: fixed; - top: 0; - left: 0; - z-index: 1040; - width: 100vw; - height: 100vh; - background-color: #000; -} -.offcanvas-backdrop.fade { - opacity: 0; -} -.offcanvas-backdrop.show { - opacity: 0.5; -} - -.offcanvas-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem 1rem; -} -.offcanvas-header .btn-close { - padding: 0.5rem 0.5rem; - margin-top: -0.5rem; - margin-right: -0.5rem; - margin-bottom: -0.5rem; -} - -.offcanvas-title { - margin-bottom: 0; - line-height: 1.5; -} - -.offcanvas-body { - flex-grow: 1; - padding: 1rem 1rem; - overflow-y: auto; -} - -.offcanvas-start { - top: 0; - left: 0; - width: 400px; - border-right: 1px solid rgba(0, 0, 0, 0.2); - transform: translateX(-100%); -} - -.offcanvas-end { - top: 0; - right: 0; - width: 400px; - border-left: 1px solid rgba(0, 0, 0, 0.2); - transform: translateX(100%); -} - -.offcanvas-top { - top: 0; - right: 0; - left: 0; - height: 30vh; - max-height: 100%; - border-bottom: 1px solid rgba(0, 0, 0, 0.2); - transform: translateY(-100%); -} - -.offcanvas-bottom { - right: 0; - left: 0; - height: 30vh; - max-height: 100%; - border-top: 1px solid rgba(0, 0, 0, 0.2); - transform: translateY(100%); -} - -.offcanvas.show { - transform: none; -} - -.placeholder { - display: inline-block; - min-height: 1em; - vertical-align: middle; - cursor: wait; - background-color: currentColor; - opacity: 0.5; -} -.placeholder.btn::before { - display: inline-block; - content: ""; -} - -.placeholder-xs { - min-height: 0.6em; -} - -.placeholder-sm { - min-height: 0.8em; -} - -.placeholder-lg { - min-height: 1.2em; -} - -.placeholder-glow .placeholder { - -webkit-animation: placeholder-glow 2s ease-in-out infinite; - animation: placeholder-glow 2s ease-in-out infinite; -} - -@-webkit-keyframes placeholder-glow { - 50% { - opacity: 0.2; - } -} - -@keyframes placeholder-glow { - 50% { - opacity: 0.2; - } -} -.placeholder-wave { - -webkit-mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%); - mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%); - -webkit-mask-size: 200% 100%; - mask-size: 200% 100%; - -webkit-animation: placeholder-wave 2s linear infinite; - animation: placeholder-wave 2s linear infinite; -} - -@-webkit-keyframes placeholder-wave { - 100% { - -webkit-mask-position: -200% 0%; - mask-position: -200% 0%; - } -} - -@keyframes placeholder-wave { - 100% { - -webkit-mask-position: -200% 0%; - mask-position: -200% 0%; - } -} -.clearfix::after { - display: block; - clear: both; - content: ""; -} - -.link-primary { - color: #0d6efd; -} -.link-primary:hover, .link-primary:focus { - color: #0a58ca; -} - -.link-secondary { - color: #6c757d; -} -.link-secondary:hover, .link-secondary:focus { - color: #565e64; -} - -.link-success { - color: #198754; -} -.link-success:hover, .link-success:focus { - color: #146c43; -} - -.link-info { - color: #0dcaf0; -} -.link-info:hover, .link-info:focus { - color: #3dd5f3; -} - -.link-warning { - color: #ffc107; -} -.link-warning:hover, .link-warning:focus { - color: #ffcd39; -} - -.link-danger { - color: #dc3545; -} -.link-danger:hover, .link-danger:focus { - color: #b02a37; -} - -.link-light { - color: #f8f9fa; -} -.link-light:hover, .link-light:focus { - color: #f9fafb; -} - -.link-dark { - color: #212529; -} -.link-dark:hover, .link-dark:focus { - color: #1a1e21; -} - -.ratio { - position: relative; - width: 100%; -} -.ratio::before { - display: block; - padding-top: var(--bs-aspect-ratio); - content: ""; -} -.ratio > * { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -} - -.ratio-1x1 { - --bs-aspect-ratio: 100%; -} - -.ratio-4x3 { - --bs-aspect-ratio: 75%; -} - -.ratio-16x9 { - --bs-aspect-ratio: 56.25%; -} - -.ratio-21x9 { - --bs-aspect-ratio: 42.8571428571%; -} - -.fixed-top, .sb-nav-fixed #layoutSidenav #layoutSidenav_nav, .sb-nav-fixed .sb-topnav { - position: fixed; - top: 0; - right: 0; - left: 0; - z-index: 1030; -} - -.fixed-bottom { - position: fixed; - right: 0; - bottom: 0; - left: 0; - z-index: 1030; -} - -.sticky-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; -} - -@media (min-width: 576px) { - .sticky-sm-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; - } -} -@media (min-width: 768px) { - .sticky-md-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; - } -} -@media (min-width: 992px) { - .sticky-lg-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; - } -} -@media (min-width: 1200px) { - .sticky-xl-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; - } -} -@media (min-width: 1400px) { - .sticky-xxl-top { - position: -webkit-sticky; - position: sticky; - top: 0; - z-index: 1020; - } -} -.hstack { - display: flex; - flex-direction: row; - align-items: center; - align-self: stretch; -} - -.vstack { - display: flex; - flex: 1 1 auto; - flex-direction: column; - align-self: stretch; -} - -.visually-hidden, -.visually-hidden-focusable:not(:focus):not(:focus-within) { - position: absolute !important; - width: 1px !important; - height: 1px !important; - padding: 0 !important; - margin: -1px !important; - overflow: hidden !important; - clip: rect(0, 0, 0, 0) !important; - white-space: nowrap !important; - border: 0 !important; -} - -.stretched-link::after { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 1; - content: ""; -} - -.text-truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.vr { - display: inline-block; - align-self: stretch; - width: 1px; - min-height: 1em; - background-color: currentColor; - opacity: 0.25; -} - -.align-baseline { - vertical-align: baseline !important; -} - -.align-top { - vertical-align: top !important; -} - -.align-middle { - vertical-align: middle !important; -} - -.align-bottom { - vertical-align: bottom !important; -} - -.align-text-bottom { - vertical-align: text-bottom !important; -} - -.align-text-top { - vertical-align: text-top !important; -} - -.float-start { - float: left !important; -} - -.float-end { - float: right !important; -} - -.float-none { - float: none !important; -} - -.opacity-0 { - opacity: 0 !important; -} - -.opacity-25 { - opacity: 0.25 !important; -} - -.opacity-50 { - opacity: 0.5 !important; -} - -.opacity-75 { - opacity: 0.75 !important; -} - -.opacity-100 { - opacity: 1 !important; -} - -.overflow-auto { - overflow: auto !important; -} - -.overflow-hidden { - overflow: hidden !important; -} - -.overflow-visible { - overflow: visible !important; -} - -.overflow-scroll { - overflow: scroll !important; -} - -.d-inline { - display: inline !important; -} - -.d-inline-block { - display: inline-block !important; -} - -.d-block { - display: block !important; -} - -.d-grid { - display: grid !important; -} - -.d-table { - display: table !important; -} - -.d-table-row { - display: table-row !important; -} - -.d-table-cell { - display: table-cell !important; -} - -.d-flex { - display: flex !important; -} - -.d-inline-flex { - display: inline-flex !important; -} - -.d-none { - display: none !important; -} - -.shadow { - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; -} - -.shadow-sm { - box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important; -} - -.shadow-lg { - box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important; -} - -.shadow-none { - box-shadow: none !important; -} - -.position-static { - position: static !important; -} - -.position-relative { - position: relative !important; -} - -.position-absolute { - position: absolute !important; -} - -.position-fixed { - position: fixed !important; -} - -.position-sticky { - position: -webkit-sticky !important; - position: sticky !important; -} - -.top-0 { - top: 0 !important; -} - -.top-50 { - top: 50% !important; -} - -.top-100 { - top: 100% !important; -} - -.bottom-0 { - bottom: 0 !important; -} - -.bottom-50 { - bottom: 50% !important; -} - -.bottom-100 { - bottom: 100% !important; -} - -.start-0 { - left: 0 !important; -} - -.start-50 { - left: 50% !important; -} - -.start-100 { - left: 100% !important; -} - -.end-0 { - right: 0 !important; -} - -.end-50 { - right: 50% !important; -} - -.end-100 { - right: 100% !important; -} - -.translate-middle { - transform: translate(-50%, -50%) !important; -} - -.translate-middle-x { - transform: translateX(-50%) !important; -} - -.translate-middle-y { - transform: translateY(-50%) !important; -} - -.border { - border: 1px solid #dee2e6 !important; -} - -.border-0 { - border: 0 !important; -} - -.border-top { - border-top: 1px solid #dee2e6 !important; -} - -.border-top-0 { - border-top: 0 !important; -} - -.border-end { - border-right: 1px solid #dee2e6 !important; -} - -.border-end-0 { - border-right: 0 !important; -} - -.border-bottom { - border-bottom: 1px solid #dee2e6 !important; -} - -.border-bottom-0 { - border-bottom: 0 !important; -} - -.border-start { - border-left: 1px solid #dee2e6 !important; -} - -.border-start-0 { - border-left: 0 !important; -} - -.border-primary { - border-color: #0d6efd !important; -} - -.border-secondary { - border-color: #6c757d !important; -} - -.border-success { - border-color: #198754 !important; -} - -.border-info { - border-color: #0dcaf0 !important; -} - -.border-warning { - border-color: #ffc107 !important; -} - -.border-danger { - border-color: #dc3545 !important; -} - -.border-light { - border-color: #f8f9fa !important; -} - -.border-dark { - border-color: #212529 !important; -} - -.border-white { - border-color: #fff !important; -} - -.border-1 { - border-width: 1px !important; -} - -.border-2 { - border-width: 2px !important; -} - -.border-3 { - border-width: 3px !important; -} - -.border-4 { - border-width: 4px !important; -} - -.border-5 { - border-width: 5px !important; -} - -.w-25 { - width: 25% !important; -} - -.w-50 { - width: 50% !important; -} - -.w-75 { - width: 75% !important; -} - -.w-100 { - width: 100% !important; -} - -.w-auto { - width: auto !important; -} - -.mw-100 { - max-width: 100% !important; -} - -.vw-100 { - width: 100vw !important; -} - -.min-vw-100 { - min-width: 100vw !important; -} - -.h-25 { - height: 25% !important; -} - -.h-50 { - height: 50% !important; -} - -.h-75 { - height: 75% !important; -} - -.h-100 { - height: 100% !important; -} - -.h-auto { - height: auto !important; -} - -.mh-100 { - max-height: 100% !important; -} - -.vh-100 { - height: 100vh !important; -} - -.min-vh-100 { - min-height: 100vh !important; -} - -.flex-fill { - flex: 1 1 auto !important; -} - -.flex-row { - flex-direction: row !important; -} - -.flex-column { - flex-direction: column !important; -} - -.flex-row-reverse { - flex-direction: row-reverse !important; -} - -.flex-column-reverse { - flex-direction: column-reverse !important; -} - -.flex-grow-0 { - flex-grow: 0 !important; -} - -.flex-grow-1 { - flex-grow: 1 !important; -} - -.flex-shrink-0 { - flex-shrink: 0 !important; -} - -.flex-shrink-1 { - flex-shrink: 1 !important; -} - -.flex-wrap { - flex-wrap: wrap !important; -} - -.flex-nowrap { - flex-wrap: nowrap !important; -} - -.flex-wrap-reverse { - flex-wrap: wrap-reverse !important; -} - -.gap-0 { - gap: 0 !important; -} - -.gap-1 { - gap: 0.25rem !important; -} - -.gap-2 { - gap: 0.5rem !important; -} - -.gap-3 { - gap: 1rem !important; -} - -.gap-4 { - gap: 1.5rem !important; -} - -.gap-5 { - gap: 3rem !important; -} - -.justify-content-start { - justify-content: flex-start !important; -} - -.justify-content-end { - justify-content: flex-end !important; -} - -.justify-content-center { - justify-content: center !important; -} - -.justify-content-between { - justify-content: space-between !important; -} - -.justify-content-around { - justify-content: space-around !important; -} - -.justify-content-evenly { - justify-content: space-evenly !important; -} - -.align-items-start { - align-items: flex-start !important; -} - -.align-items-end { - align-items: flex-end !important; -} - -.align-items-center { - align-items: center !important; -} - -.align-items-baseline { - align-items: baseline !important; -} - -.align-items-stretch { - align-items: stretch !important; -} - -.align-content-start { - align-content: flex-start !important; -} - -.align-content-end { - align-content: flex-end !important; -} - -.align-content-center { - align-content: center !important; -} - -.align-content-between { - align-content: space-between !important; -} - -.align-content-around { - align-content: space-around !important; -} - -.align-content-stretch { - align-content: stretch !important; -} - -.align-self-auto { - align-self: auto !important; -} - -.align-self-start { - align-self: flex-start !important; -} - -.align-self-end { - align-self: flex-end !important; -} - -.align-self-center { - align-self: center !important; -} - -.align-self-baseline { - align-self: baseline !important; -} - -.align-self-stretch { - align-self: stretch !important; -} - -.order-first { - order: -1 !important; -} - -.order-0 { - order: 0 !important; -} - -.order-1 { - order: 1 !important; -} - -.order-2 { - order: 2 !important; -} - -.order-3 { - order: 3 !important; -} - -.order-4 { - order: 4 !important; -} - -.order-5 { - order: 5 !important; -} - -.order-last { - order: 6 !important; -} - -.m-0 { - margin: 0 !important; -} - -.m-1 { - margin: 0.25rem !important; -} - -.m-2 { - margin: 0.5rem !important; -} - -.m-3 { - margin: 1rem !important; -} - -.m-4 { - margin: 1.5rem !important; -} - -.m-5 { - margin: 3rem !important; -} - -.m-auto { - margin: auto !important; -} - -.mx-0 { - margin-right: 0 !important; - margin-left: 0 !important; -} - -.mx-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; -} - -.mx-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; -} - -.mx-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; -} - -.mx-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; -} - -.mx-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; -} - -.mx-auto { - margin-right: auto !important; - margin-left: auto !important; -} - -.my-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -.my-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; -} - -.my-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; -} - -.my-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; -} - -.my-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; -} - -.my-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; -} - -.my-auto { - margin-top: auto !important; - margin-bottom: auto !important; -} - -.mt-0 { - margin-top: 0 !important; -} - -.mt-1 { - margin-top: 0.25rem !important; -} - -.mt-2 { - margin-top: 0.5rem !important; -} - -.mt-3 { - margin-top: 1rem !important; -} - -.mt-4 { - margin-top: 1.5rem !important; -} - -.mt-5 { - margin-top: 3rem !important; -} - -.mt-auto { - margin-top: auto !important; -} - -.me-0 { - margin-right: 0 !important; -} - -.me-1 { - margin-right: 0.25rem !important; -} - -.me-2 { - margin-right: 0.5rem !important; -} - -.me-3 { - margin-right: 1rem !important; -} - -.me-4 { - margin-right: 1.5rem !important; -} - -.me-5 { - margin-right: 3rem !important; -} - -.me-auto { - margin-right: auto !important; -} - -.mb-0 { - margin-bottom: 0 !important; -} - -.mb-1 { - margin-bottom: 0.25rem !important; -} - -.mb-2 { - margin-bottom: 0.5rem !important; -} - -.mb-3 { - margin-bottom: 1rem !important; -} - -.mb-4 { - margin-bottom: 1.5rem !important; -} - -.mb-5 { - margin-bottom: 3rem !important; -} - -.mb-auto { - margin-bottom: auto !important; -} - -.ms-0 { - margin-left: 0 !important; -} - -.ms-1 { - margin-left: 0.25rem !important; -} - -.ms-2 { - margin-left: 0.5rem !important; -} - -.ms-3 { - margin-left: 1rem !important; -} - -.ms-4 { - margin-left: 1.5rem !important; -} - -.ms-5 { - margin-left: 3rem !important; -} - -.ms-auto { - margin-left: auto !important; -} - -.p-0 { - padding: 0 !important; -} - -.p-1 { - padding: 0.25rem !important; -} - -.p-2 { - padding: 0.5rem !important; -} - -.p-3 { - padding: 1rem !important; -} - -.p-4 { - padding: 1.5rem !important; -} - -.p-5 { - padding: 3rem !important; -} - -.px-0 { - padding-right: 0 !important; - padding-left: 0 !important; -} - -.px-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; -} - -.px-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; -} - -.px-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; -} - -.px-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; -} - -.px-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; -} - -.py-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; -} - -.py-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; -} - -.py-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; -} - -.py-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; -} - -.py-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; -} - -.py-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; -} - -.pt-0 { - padding-top: 0 !important; -} - -.pt-1 { - padding-top: 0.25rem !important; -} - -.pt-2 { - padding-top: 0.5rem !important; -} - -.pt-3 { - padding-top: 1rem !important; -} - -.pt-4 { - padding-top: 1.5rem !important; -} - -.pt-5 { - padding-top: 3rem !important; -} - -.pe-0 { - padding-right: 0 !important; -} - -.pe-1 { - padding-right: 0.25rem !important; -} - -.pe-2 { - padding-right: 0.5rem !important; -} - -.pe-3 { - padding-right: 1rem !important; -} - -.pe-4 { - padding-right: 1.5rem !important; -} - -.pe-5 { - padding-right: 3rem !important; -} - -.pb-0 { - padding-bottom: 0 !important; -} - -.pb-1 { - padding-bottom: 0.25rem !important; -} - -.pb-2 { - padding-bottom: 0.5rem !important; -} - -.pb-3 { - padding-bottom: 1rem !important; -} - -.pb-4 { - padding-bottom: 1.5rem !important; -} - -.pb-5 { - padding-bottom: 3rem !important; -} - -.ps-0 { - padding-left: 0 !important; -} - -.ps-1 { - padding-left: 0.25rem !important; -} - -.ps-2 { - padding-left: 0.5rem !important; -} - -.ps-3 { - padding-left: 1rem !important; -} - -.ps-4 { - padding-left: 1.5rem !important; -} - -.ps-5 { - padding-left: 3rem !important; -} - -.font-monospace { - font-family: var(--bs-font-monospace) !important; -} - -.fs-1 { - font-size: calc(1.375rem + 1.5vw) !important; -} - -.fs-2 { - font-size: calc(1.325rem + 0.9vw) !important; -} - -.fs-3 { - font-size: calc(1.3rem + 0.6vw) !important; -} - -.fs-4 { - font-size: calc(1.275rem + 0.3vw) !important; -} - -.fs-5 { - font-size: 1.25rem !important; -} - -.fs-6 { - font-size: 1rem !important; -} - -.fst-italic { - font-style: italic !important; -} - -.fst-normal { - font-style: normal !important; -} - -.fw-light { - font-weight: 300 !important; -} - -.fw-lighter { - font-weight: lighter !important; -} - -.fw-normal { - font-weight: 400 !important; -} - -.fw-bold { - font-weight: 700 !important; -} - -.fw-bolder { - font-weight: bolder !important; -} - -.lh-1 { - line-height: 1 !important; -} - -.lh-sm { - line-height: 1.25 !important; -} - -.lh-base { - line-height: 1.5 !important; -} - -.lh-lg { - line-height: 2 !important; -} - -.text-start { - text-align: left !important; -} - -.text-end { - text-align: right !important; -} - -.text-center { - text-align: center !important; -} - -.text-decoration-none { - text-decoration: none !important; -} - -.text-decoration-underline { - text-decoration: underline !important; -} - -.text-decoration-line-through { - text-decoration: line-through !important; -} - -.text-lowercase { - text-transform: lowercase !important; -} - -.text-uppercase { - text-transform: uppercase !important; -} - -.text-capitalize { - text-transform: capitalize !important; -} - -.text-wrap { - white-space: normal !important; -} - -.text-nowrap { - white-space: nowrap !important; -} - -/* rtl:begin:remove */ -.text-break { - word-wrap: break-word !important; - word-break: break-word !important; -} - -/* rtl:end:remove */ -.text-primary { - --bs-text-opacity: 1; - color: rgba(var(--bs-primary-rgb), var(--bs-text-opacity)) !important; -} - -.text-secondary { - --bs-text-opacity: 1; - color: rgba(var(--bs-secondary-rgb), var(--bs-text-opacity)) !important; -} - -.text-success { - --bs-text-opacity: 1; - color: rgba(var(--bs-success-rgb), var(--bs-text-opacity)) !important; -} - -.text-info { - --bs-text-opacity: 1; - color: rgba(var(--bs-info-rgb), var(--bs-text-opacity)) !important; -} - -.text-warning { - --bs-text-opacity: 1; - color: rgba(var(--bs-warning-rgb), var(--bs-text-opacity)) !important; -} - -.text-danger { - --bs-text-opacity: 1; - color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important; -} - -.text-light { - --bs-text-opacity: 1; - color: rgba(var(--bs-light-rgb), var(--bs-text-opacity)) !important; -} - -.text-dark { - --bs-text-opacity: 1; - color: rgba(var(--bs-dark-rgb), var(--bs-text-opacity)) !important; -} - -.text-black { - --bs-text-opacity: 1; - color: rgba(var(--bs-black-rgb), var(--bs-text-opacity)) !important; -} - -.text-white { - --bs-text-opacity: 1; - color: rgba(var(--bs-white-rgb), var(--bs-text-opacity)) !important; -} - -.text-body { - --bs-text-opacity: 1; - color: rgba(var(--bs-body-color-rgb), var(--bs-text-opacity)) !important; -} - -.text-muted { - --bs-text-opacity: 1; - color: #6c757d !important; -} - -.text-black-50 { - --bs-text-opacity: 1; - color: rgba(0, 0, 0, 0.5) !important; -} - -.text-white-50 { - --bs-text-opacity: 1; - color: rgba(255, 255, 255, 0.5) !important; -} - -.text-reset { - --bs-text-opacity: 1; - color: inherit !important; -} - -.text-opacity-25 { - --bs-text-opacity: 0.25; -} - -.text-opacity-50 { - --bs-text-opacity: 0.5; -} - -.text-opacity-75 { - --bs-text-opacity: 0.75; -} - -.text-opacity-100 { - --bs-text-opacity: 1; -} - -.bg-primary { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-primary-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-secondary { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-secondary-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-success { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-success-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-info { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-info-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-warning { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-warning-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-danger { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-danger-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-light { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-light-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-dark { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-black { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-black-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-white { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-white-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-body { - --bs-bg-opacity: 1; - background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important; -} - -.bg-transparent { - --bs-bg-opacity: 1; - background-color: transparent !important; -} - -.bg-opacity-10 { - --bs-bg-opacity: 0.1; -} - -.bg-opacity-25 { - --bs-bg-opacity: 0.25; -} - -.bg-opacity-50 { - --bs-bg-opacity: 0.5; -} - -.bg-opacity-75 { - --bs-bg-opacity: 0.75; -} - -.bg-opacity-100 { - --bs-bg-opacity: 1; -} - -.bg-gradient { - background-image: var(--bs-gradient) !important; -} - -.user-select-all { - -webkit-user-select: all !important; - -moz-user-select: all !important; - user-select: all !important; -} - -.user-select-auto { - -webkit-user-select: auto !important; - -moz-user-select: auto !important; - -ms-user-select: auto !important; - user-select: auto !important; -} - -.user-select-none { - -webkit-user-select: none !important; - -moz-user-select: none !important; - -ms-user-select: none !important; - user-select: none !important; -} - -.pe-none { - pointer-events: none !important; -} - -.pe-auto { - pointer-events: auto !important; -} - -.rounded { - border-radius: 0.25rem !important; -} - -.rounded-0 { - border-radius: 0 !important; -} - -.rounded-1 { - border-radius: 0.2rem !important; -} - -.rounded-2 { - border-radius: 0.25rem !important; -} - -.rounded-3 { - border-radius: 0.3rem !important; -} - -.rounded-circle { - border-radius: 50% !important; -} - -.rounded-pill { - border-radius: 50rem !important; -} - -.rounded-top { - border-top-left-radius: 0.25rem !important; - border-top-right-radius: 0.25rem !important; -} - -.rounded-end { - border-top-right-radius: 0.25rem !important; - border-bottom-right-radius: 0.25rem !important; -} - -.rounded-bottom { - border-bottom-right-radius: 0.25rem !important; - border-bottom-left-radius: 0.25rem !important; -} - -.rounded-start { - border-bottom-left-radius: 0.25rem !important; - border-top-left-radius: 0.25rem !important; -} - -.visible { - visibility: visible !important; -} - -.invisible { - visibility: hidden !important; -} - -@media (min-width: 576px) { - .float-sm-start { - float: left !important; - } - - .float-sm-end { - float: right !important; - } - - .float-sm-none { - float: none !important; - } - - .d-sm-inline { - display: inline !important; - } - - .d-sm-inline-block { - display: inline-block !important; - } - - .d-sm-block { - display: block !important; - } - - .d-sm-grid { - display: grid !important; - } - - .d-sm-table { - display: table !important; - } - - .d-sm-table-row { - display: table-row !important; - } - - .d-sm-table-cell { - display: table-cell !important; - } - - .d-sm-flex { - display: flex !important; - } - - .d-sm-inline-flex { - display: inline-flex !important; - } - - .d-sm-none { - display: none !important; - } - - .flex-sm-fill { - flex: 1 1 auto !important; - } - - .flex-sm-row { - flex-direction: row !important; - } - - .flex-sm-column { - flex-direction: column !important; - } - - .flex-sm-row-reverse { - flex-direction: row-reverse !important; - } - - .flex-sm-column-reverse { - flex-direction: column-reverse !important; - } - - .flex-sm-grow-0 { - flex-grow: 0 !important; - } - - .flex-sm-grow-1 { - flex-grow: 1 !important; - } - - .flex-sm-shrink-0 { - flex-shrink: 0 !important; - } - - .flex-sm-shrink-1 { - flex-shrink: 1 !important; - } - - .flex-sm-wrap { - flex-wrap: wrap !important; - } - - .flex-sm-nowrap { - flex-wrap: nowrap !important; - } - - .flex-sm-wrap-reverse { - flex-wrap: wrap-reverse !important; - } - - .gap-sm-0 { - gap: 0 !important; - } - - .gap-sm-1 { - gap: 0.25rem !important; - } - - .gap-sm-2 { - gap: 0.5rem !important; - } - - .gap-sm-3 { - gap: 1rem !important; - } - - .gap-sm-4 { - gap: 1.5rem !important; - } - - .gap-sm-5 { - gap: 3rem !important; - } - - .justify-content-sm-start { - justify-content: flex-start !important; - } - - .justify-content-sm-end { - justify-content: flex-end !important; - } - - .justify-content-sm-center { - justify-content: center !important; - } - - .justify-content-sm-between { - justify-content: space-between !important; - } - - .justify-content-sm-around { - justify-content: space-around !important; - } - - .justify-content-sm-evenly { - justify-content: space-evenly !important; - } - - .align-items-sm-start { - align-items: flex-start !important; - } - - .align-items-sm-end { - align-items: flex-end !important; - } - - .align-items-sm-center { - align-items: center !important; - } - - .align-items-sm-baseline { - align-items: baseline !important; - } - - .align-items-sm-stretch { - align-items: stretch !important; - } - - .align-content-sm-start { - align-content: flex-start !important; - } - - .align-content-sm-end { - align-content: flex-end !important; - } - - .align-content-sm-center { - align-content: center !important; - } - - .align-content-sm-between { - align-content: space-between !important; - } - - .align-content-sm-around { - align-content: space-around !important; - } - - .align-content-sm-stretch { - align-content: stretch !important; - } - - .align-self-sm-auto { - align-self: auto !important; - } - - .align-self-sm-start { - align-self: flex-start !important; - } - - .align-self-sm-end { - align-self: flex-end !important; - } - - .align-self-sm-center { - align-self: center !important; - } - - .align-self-sm-baseline { - align-self: baseline !important; - } - - .align-self-sm-stretch { - align-self: stretch !important; - } - - .order-sm-first { - order: -1 !important; - } - - .order-sm-0 { - order: 0 !important; - } - - .order-sm-1 { - order: 1 !important; - } - - .order-sm-2 { - order: 2 !important; - } - - .order-sm-3 { - order: 3 !important; - } - - .order-sm-4 { - order: 4 !important; - } - - .order-sm-5 { - order: 5 !important; - } - - .order-sm-last { - order: 6 !important; - } - - .m-sm-0 { - margin: 0 !important; - } - - .m-sm-1 { - margin: 0.25rem !important; - } - - .m-sm-2 { - margin: 0.5rem !important; - } - - .m-sm-3 { - margin: 1rem !important; - } - - .m-sm-4 { - margin: 1.5rem !important; - } - - .m-sm-5 { - margin: 3rem !important; - } - - .m-sm-auto { - margin: auto !important; - } - - .mx-sm-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - - .mx-sm-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; - } - - .mx-sm-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; - } - - .mx-sm-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; - } - - .mx-sm-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; - } - - .mx-sm-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; - } - - .mx-sm-auto { - margin-right: auto !important; - margin-left: auto !important; - } - - .my-sm-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - - .my-sm-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; - } - - .my-sm-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - } - - .my-sm-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; - } - - .my-sm-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; - } - - .my-sm-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; - } - - .my-sm-auto { - margin-top: auto !important; - margin-bottom: auto !important; - } - - .mt-sm-0 { - margin-top: 0 !important; - } - - .mt-sm-1 { - margin-top: 0.25rem !important; - } - - .mt-sm-2 { - margin-top: 0.5rem !important; - } - - .mt-sm-3 { - margin-top: 1rem !important; - } - - .mt-sm-4 { - margin-top: 1.5rem !important; - } - - .mt-sm-5 { - margin-top: 3rem !important; - } - - .mt-sm-auto { - margin-top: auto !important; - } - - .me-sm-0 { - margin-right: 0 !important; - } - - .me-sm-1 { - margin-right: 0.25rem !important; - } - - .me-sm-2 { - margin-right: 0.5rem !important; - } - - .me-sm-3 { - margin-right: 1rem !important; - } - - .me-sm-4 { - margin-right: 1.5rem !important; - } - - .me-sm-5 { - margin-right: 3rem !important; - } - - .me-sm-auto { - margin-right: auto !important; - } - - .mb-sm-0 { - margin-bottom: 0 !important; - } - - .mb-sm-1 { - margin-bottom: 0.25rem !important; - } - - .mb-sm-2 { - margin-bottom: 0.5rem !important; - } - - .mb-sm-3 { - margin-bottom: 1rem !important; - } - - .mb-sm-4 { - margin-bottom: 1.5rem !important; - } - - .mb-sm-5 { - margin-bottom: 3rem !important; - } - - .mb-sm-auto { - margin-bottom: auto !important; - } - - .ms-sm-0 { - margin-left: 0 !important; - } - - .ms-sm-1 { - margin-left: 0.25rem !important; - } - - .ms-sm-2 { - margin-left: 0.5rem !important; - } - - .ms-sm-3 { - margin-left: 1rem !important; - } - - .ms-sm-4 { - margin-left: 1.5rem !important; - } - - .ms-sm-5 { - margin-left: 3rem !important; - } - - .ms-sm-auto { - margin-left: auto !important; - } - - .p-sm-0 { - padding: 0 !important; - } - - .p-sm-1 { - padding: 0.25rem !important; - } - - .p-sm-2 { - padding: 0.5rem !important; - } - - .p-sm-3 { - padding: 1rem !important; - } - - .p-sm-4 { - padding: 1.5rem !important; - } - - .p-sm-5 { - padding: 3rem !important; - } - - .px-sm-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - - .px-sm-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; - } - - .px-sm-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; - } - - .px-sm-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; - } - - .px-sm-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; - } - - .px-sm-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; - } - - .py-sm-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - - .py-sm-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; - } - - .py-sm-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - } - - .py-sm-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; - } - - .py-sm-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; - } - - .py-sm-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; - } - - .pt-sm-0 { - padding-top: 0 !important; - } - - .pt-sm-1 { - padding-top: 0.25rem !important; - } - - .pt-sm-2 { - padding-top: 0.5rem !important; - } - - .pt-sm-3 { - padding-top: 1rem !important; - } - - .pt-sm-4 { - padding-top: 1.5rem !important; - } - - .pt-sm-5 { - padding-top: 3rem !important; - } - - .pe-sm-0 { - padding-right: 0 !important; - } - - .pe-sm-1 { - padding-right: 0.25rem !important; - } - - .pe-sm-2 { - padding-right: 0.5rem !important; - } - - .pe-sm-3 { - padding-right: 1rem !important; - } - - .pe-sm-4 { - padding-right: 1.5rem !important; - } - - .pe-sm-5 { - padding-right: 3rem !important; - } - - .pb-sm-0 { - padding-bottom: 0 !important; - } - - .pb-sm-1 { - padding-bottom: 0.25rem !important; - } - - .pb-sm-2 { - padding-bottom: 0.5rem !important; - } - - .pb-sm-3 { - padding-bottom: 1rem !important; - } - - .pb-sm-4 { - padding-bottom: 1.5rem !important; - } - - .pb-sm-5 { - padding-bottom: 3rem !important; - } - - .ps-sm-0 { - padding-left: 0 !important; - } - - .ps-sm-1 { - padding-left: 0.25rem !important; - } - - .ps-sm-2 { - padding-left: 0.5rem !important; - } - - .ps-sm-3 { - padding-left: 1rem !important; - } - - .ps-sm-4 { - padding-left: 1.5rem !important; - } - - .ps-sm-5 { - padding-left: 3rem !important; - } - - .text-sm-start { - text-align: left !important; - } - - .text-sm-end { - text-align: right !important; - } - - .text-sm-center { - text-align: center !important; - } -} -@media (min-width: 768px) { - .float-md-start { - float: left !important; - } - - .float-md-end { - float: right !important; - } - - .float-md-none { - float: none !important; - } - - .d-md-inline { - display: inline !important; - } - - .d-md-inline-block { - display: inline-block !important; - } - - .d-md-block { - display: block !important; - } - - .d-md-grid { - display: grid !important; - } - - .d-md-table { - display: table !important; - } - - .d-md-table-row { - display: table-row !important; - } - - .d-md-table-cell { - display: table-cell !important; - } - - .d-md-flex { - display: flex !important; - } - - .d-md-inline-flex { - display: inline-flex !important; - } - - .d-md-none { - display: none !important; - } - - .flex-md-fill { - flex: 1 1 auto !important; - } - - .flex-md-row { - flex-direction: row !important; - } - - .flex-md-column { - flex-direction: column !important; - } - - .flex-md-row-reverse { - flex-direction: row-reverse !important; - } - - .flex-md-column-reverse { - flex-direction: column-reverse !important; - } - - .flex-md-grow-0 { - flex-grow: 0 !important; - } - - .flex-md-grow-1 { - flex-grow: 1 !important; - } - - .flex-md-shrink-0 { - flex-shrink: 0 !important; - } - - .flex-md-shrink-1 { - flex-shrink: 1 !important; - } - - .flex-md-wrap { - flex-wrap: wrap !important; - } - - .flex-md-nowrap { - flex-wrap: nowrap !important; - } - - .flex-md-wrap-reverse { - flex-wrap: wrap-reverse !important; - } - - .gap-md-0 { - gap: 0 !important; - } - - .gap-md-1 { - gap: 0.25rem !important; - } - - .gap-md-2 { - gap: 0.5rem !important; - } - - .gap-md-3 { - gap: 1rem !important; - } - - .gap-md-4 { - gap: 1.5rem !important; - } - - .gap-md-5 { - gap: 3rem !important; - } - - .justify-content-md-start { - justify-content: flex-start !important; - } - - .justify-content-md-end { - justify-content: flex-end !important; - } - - .justify-content-md-center { - justify-content: center !important; - } - - .justify-content-md-between { - justify-content: space-between !important; - } - - .justify-content-md-around { - justify-content: space-around !important; - } - - .justify-content-md-evenly { - justify-content: space-evenly !important; - } - - .align-items-md-start { - align-items: flex-start !important; - } - - .align-items-md-end { - align-items: flex-end !important; - } - - .align-items-md-center { - align-items: center !important; - } - - .align-items-md-baseline { - align-items: baseline !important; - } - - .align-items-md-stretch { - align-items: stretch !important; - } - - .align-content-md-start { - align-content: flex-start !important; - } - - .align-content-md-end { - align-content: flex-end !important; - } - - .align-content-md-center { - align-content: center !important; - } - - .align-content-md-between { - align-content: space-between !important; - } - - .align-content-md-around { - align-content: space-around !important; - } - - .align-content-md-stretch { - align-content: stretch !important; - } - - .align-self-md-auto { - align-self: auto !important; - } - - .align-self-md-start { - align-self: flex-start !important; - } - - .align-self-md-end { - align-self: flex-end !important; - } - - .align-self-md-center { - align-self: center !important; - } - - .align-self-md-baseline { - align-self: baseline !important; - } - - .align-self-md-stretch { - align-self: stretch !important; - } - - .order-md-first { - order: -1 !important; - } - - .order-md-0 { - order: 0 !important; - } - - .order-md-1 { - order: 1 !important; - } - - .order-md-2 { - order: 2 !important; - } - - .order-md-3 { - order: 3 !important; - } - - .order-md-4 { - order: 4 !important; - } - - .order-md-5 { - order: 5 !important; - } - - .order-md-last { - order: 6 !important; - } - - .m-md-0 { - margin: 0 !important; - } - - .m-md-1 { - margin: 0.25rem !important; - } - - .m-md-2 { - margin: 0.5rem !important; - } - - .m-md-3 { - margin: 1rem !important; - } - - .m-md-4 { - margin: 1.5rem !important; - } - - .m-md-5 { - margin: 3rem !important; - } - - .m-md-auto { - margin: auto !important; - } - - .mx-md-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - - .mx-md-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; - } - - .mx-md-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; - } - - .mx-md-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; - } - - .mx-md-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; - } - - .mx-md-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; - } - - .mx-md-auto { - margin-right: auto !important; - margin-left: auto !important; - } - - .my-md-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - - .my-md-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; - } - - .my-md-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - } - - .my-md-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; - } - - .my-md-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; - } - - .my-md-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; - } - - .my-md-auto { - margin-top: auto !important; - margin-bottom: auto !important; - } - - .mt-md-0 { - margin-top: 0 !important; - } - - .mt-md-1 { - margin-top: 0.25rem !important; - } - - .mt-md-2 { - margin-top: 0.5rem !important; - } - - .mt-md-3 { - margin-top: 1rem !important; - } - - .mt-md-4 { - margin-top: 1.5rem !important; - } - - .mt-md-5 { - margin-top: 3rem !important; - } - - .mt-md-auto { - margin-top: auto !important; - } - - .me-md-0 { - margin-right: 0 !important; - } - - .me-md-1 { - margin-right: 0.25rem !important; - } - - .me-md-2 { - margin-right: 0.5rem !important; - } - - .me-md-3 { - margin-right: 1rem !important; - } - - .me-md-4 { - margin-right: 1.5rem !important; - } - - .me-md-5 { - margin-right: 3rem !important; - } - - .me-md-auto { - margin-right: auto !important; - } - - .mb-md-0 { - margin-bottom: 0 !important; - } - - .mb-md-1 { - margin-bottom: 0.25rem !important; - } - - .mb-md-2 { - margin-bottom: 0.5rem !important; - } - - .mb-md-3 { - margin-bottom: 1rem !important; - } - - .mb-md-4 { - margin-bottom: 1.5rem !important; - } - - .mb-md-5 { - margin-bottom: 3rem !important; - } - - .mb-md-auto { - margin-bottom: auto !important; - } - - .ms-md-0 { - margin-left: 0 !important; - } - - .ms-md-1 { - margin-left: 0.25rem !important; - } - - .ms-md-2 { - margin-left: 0.5rem !important; - } - - .ms-md-3 { - margin-left: 1rem !important; - } - - .ms-md-4 { - margin-left: 1.5rem !important; - } - - .ms-md-5 { - margin-left: 3rem !important; - } - - .ms-md-auto { - margin-left: auto !important; - } - - .p-md-0 { - padding: 0 !important; - } - - .p-md-1 { - padding: 0.25rem !important; - } - - .p-md-2 { - padding: 0.5rem !important; - } - - .p-md-3 { - padding: 1rem !important; - } - - .p-md-4 { - padding: 1.5rem !important; - } - - .p-md-5 { - padding: 3rem !important; - } - - .px-md-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - - .px-md-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; - } - - .px-md-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; - } - - .px-md-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; - } - - .px-md-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; - } - - .px-md-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; - } - - .py-md-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - - .py-md-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; - } - - .py-md-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - } - - .py-md-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; - } - - .py-md-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; - } - - .py-md-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; - } - - .pt-md-0 { - padding-top: 0 !important; - } - - .pt-md-1 { - padding-top: 0.25rem !important; - } - - .pt-md-2 { - padding-top: 0.5rem !important; - } - - .pt-md-3 { - padding-top: 1rem !important; - } - - .pt-md-4 { - padding-top: 1.5rem !important; - } - - .pt-md-5 { - padding-top: 3rem !important; - } - - .pe-md-0 { - padding-right: 0 !important; - } - - .pe-md-1 { - padding-right: 0.25rem !important; - } - - .pe-md-2 { - padding-right: 0.5rem !important; - } - - .pe-md-3 { - padding-right: 1rem !important; - } - - .pe-md-4 { - padding-right: 1.5rem !important; - } - - .pe-md-5 { - padding-right: 3rem !important; - } - - .pb-md-0 { - padding-bottom: 0 !important; - } - - .pb-md-1 { - padding-bottom: 0.25rem !important; - } - - .pb-md-2 { - padding-bottom: 0.5rem !important; - } - - .pb-md-3 { - padding-bottom: 1rem !important; - } - - .pb-md-4 { - padding-bottom: 1.5rem !important; - } - - .pb-md-5 { - padding-bottom: 3rem !important; - } - - .ps-md-0 { - padding-left: 0 !important; - } - - .ps-md-1 { - padding-left: 0.25rem !important; - } - - .ps-md-2 { - padding-left: 0.5rem !important; - } - - .ps-md-3 { - padding-left: 1rem !important; - } - - .ps-md-4 { - padding-left: 1.5rem !important; - } - - .ps-md-5 { - padding-left: 3rem !important; - } - - .text-md-start { - text-align: left !important; - } - - .text-md-end { - text-align: right !important; - } - - .text-md-center { - text-align: center !important; - } -} -@media (min-width: 992px) { - .float-lg-start { - float: left !important; - } - - .float-lg-end { - float: right !important; - } - - .float-lg-none { - float: none !important; - } - - .d-lg-inline { - display: inline !important; - } - - .d-lg-inline-block { - display: inline-block !important; - } - - .d-lg-block { - display: block !important; - } - - .d-lg-grid { - display: grid !important; - } - - .d-lg-table { - display: table !important; - } - - .d-lg-table-row { - display: table-row !important; - } - - .d-lg-table-cell { - display: table-cell !important; - } - - .d-lg-flex { - display: flex !important; - } - - .d-lg-inline-flex { - display: inline-flex !important; - } - - .d-lg-none { - display: none !important; - } - - .flex-lg-fill { - flex: 1 1 auto !important; - } - - .flex-lg-row { - flex-direction: row !important; - } - - .flex-lg-column { - flex-direction: column !important; - } - - .flex-lg-row-reverse { - flex-direction: row-reverse !important; - } - - .flex-lg-column-reverse { - flex-direction: column-reverse !important; - } - - .flex-lg-grow-0 { - flex-grow: 0 !important; - } - - .flex-lg-grow-1 { - flex-grow: 1 !important; - } - - .flex-lg-shrink-0 { - flex-shrink: 0 !important; - } - - .flex-lg-shrink-1 { - flex-shrink: 1 !important; - } - - .flex-lg-wrap { - flex-wrap: wrap !important; - } - - .flex-lg-nowrap { - flex-wrap: nowrap !important; - } - - .flex-lg-wrap-reverse { - flex-wrap: wrap-reverse !important; - } - - .gap-lg-0 { - gap: 0 !important; - } - - .gap-lg-1 { - gap: 0.25rem !important; - } - - .gap-lg-2 { - gap: 0.5rem !important; - } - - .gap-lg-3 { - gap: 1rem !important; - } - - .gap-lg-4 { - gap: 1.5rem !important; - } - - .gap-lg-5 { - gap: 3rem !important; - } - - .justify-content-lg-start { - justify-content: flex-start !important; - } - - .justify-content-lg-end { - justify-content: flex-end !important; - } - - .justify-content-lg-center { - justify-content: center !important; - } - - .justify-content-lg-between { - justify-content: space-between !important; - } - - .justify-content-lg-around { - justify-content: space-around !important; - } - - .justify-content-lg-evenly { - justify-content: space-evenly !important; - } - - .align-items-lg-start { - align-items: flex-start !important; - } - - .align-items-lg-end { - align-items: flex-end !important; - } - - .align-items-lg-center { - align-items: center !important; - } - - .align-items-lg-baseline { - align-items: baseline !important; - } - - .align-items-lg-stretch { - align-items: stretch !important; - } - - .align-content-lg-start { - align-content: flex-start !important; - } - - .align-content-lg-end { - align-content: flex-end !important; - } - - .align-content-lg-center { - align-content: center !important; - } - - .align-content-lg-between { - align-content: space-between !important; - } - - .align-content-lg-around { - align-content: space-around !important; - } - - .align-content-lg-stretch { - align-content: stretch !important; - } - - .align-self-lg-auto { - align-self: auto !important; - } - - .align-self-lg-start { - align-self: flex-start !important; - } - - .align-self-lg-end { - align-self: flex-end !important; - } - - .align-self-lg-center { - align-self: center !important; - } - - .align-self-lg-baseline { - align-self: baseline !important; - } - - .align-self-lg-stretch { - align-self: stretch !important; - } - - .order-lg-first { - order: -1 !important; - } - - .order-lg-0 { - order: 0 !important; - } - - .order-lg-1 { - order: 1 !important; - } - - .order-lg-2 { - order: 2 !important; - } - - .order-lg-3 { - order: 3 !important; - } - - .order-lg-4 { - order: 4 !important; - } - - .order-lg-5 { - order: 5 !important; - } - - .order-lg-last { - order: 6 !important; - } - - .m-lg-0 { - margin: 0 !important; - } - - .m-lg-1 { - margin: 0.25rem !important; - } - - .m-lg-2 { - margin: 0.5rem !important; - } - - .m-lg-3 { - margin: 1rem !important; - } - - .m-lg-4 { - margin: 1.5rem !important; - } - - .m-lg-5 { - margin: 3rem !important; - } - - .m-lg-auto { - margin: auto !important; - } - - .mx-lg-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - - .mx-lg-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; - } - - .mx-lg-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; - } - - .mx-lg-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; - } - - .mx-lg-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; - } - - .mx-lg-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; - } - - .mx-lg-auto { - margin-right: auto !important; - margin-left: auto !important; - } - - .my-lg-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - - .my-lg-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; - } - - .my-lg-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - } - - .my-lg-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; - } - - .my-lg-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; - } - - .my-lg-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; - } - - .my-lg-auto { - margin-top: auto !important; - margin-bottom: auto !important; - } - - .mt-lg-0 { - margin-top: 0 !important; - } - - .mt-lg-1 { - margin-top: 0.25rem !important; - } - - .mt-lg-2 { - margin-top: 0.5rem !important; - } - - .mt-lg-3 { - margin-top: 1rem !important; - } - - .mt-lg-4 { - margin-top: 1.5rem !important; - } - - .mt-lg-5 { - margin-top: 3rem !important; - } - - .mt-lg-auto { - margin-top: auto !important; - } - - .me-lg-0 { - margin-right: 0 !important; - } - - .me-lg-1 { - margin-right: 0.25rem !important; - } - - .me-lg-2 { - margin-right: 0.5rem !important; - } - - .me-lg-3 { - margin-right: 1rem !important; - } - - .me-lg-4 { - margin-right: 1.5rem !important; - } - - .me-lg-5 { - margin-right: 3rem !important; - } - - .me-lg-auto { - margin-right: auto !important; - } - - .mb-lg-0 { - margin-bottom: 0 !important; - } - - .mb-lg-1 { - margin-bottom: 0.25rem !important; - } - - .mb-lg-2 { - margin-bottom: 0.5rem !important; - } - - .mb-lg-3 { - margin-bottom: 1rem !important; - } - - .mb-lg-4 { - margin-bottom: 1.5rem !important; - } - - .mb-lg-5 { - margin-bottom: 3rem !important; - } - - .mb-lg-auto { - margin-bottom: auto !important; - } - - .ms-lg-0 { - margin-left: 0 !important; - } - - .ms-lg-1 { - margin-left: 0.25rem !important; - } - - .ms-lg-2 { - margin-left: 0.5rem !important; - } - - .ms-lg-3 { - margin-left: 1rem !important; - } - - .ms-lg-4 { - margin-left: 1.5rem !important; - } - - .ms-lg-5 { - margin-left: 3rem !important; - } - - .ms-lg-auto { - margin-left: auto !important; - } - - .p-lg-0 { - padding: 0 !important; - } - - .p-lg-1 { - padding: 0.25rem !important; - } - - .p-lg-2 { - padding: 0.5rem !important; - } - - .p-lg-3 { - padding: 1rem !important; - } - - .p-lg-4 { - padding: 1.5rem !important; - } - - .p-lg-5 { - padding: 3rem !important; - } - - .px-lg-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - - .px-lg-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; - } - - .px-lg-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; - } - - .px-lg-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; - } - - .px-lg-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; - } - - .px-lg-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; - } - - .py-lg-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - - .py-lg-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; - } - - .py-lg-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - } - - .py-lg-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; - } - - .py-lg-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; - } - - .py-lg-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; - } - - .pt-lg-0 { - padding-top: 0 !important; - } - - .pt-lg-1 { - padding-top: 0.25rem !important; - } - - .pt-lg-2 { - padding-top: 0.5rem !important; - } - - .pt-lg-3 { - padding-top: 1rem !important; - } - - .pt-lg-4 { - padding-top: 1.5rem !important; - } - - .pt-lg-5 { - padding-top: 3rem !important; - } - - .pe-lg-0 { - padding-right: 0 !important; - } - - .pe-lg-1 { - padding-right: 0.25rem !important; - } - - .pe-lg-2 { - padding-right: 0.5rem !important; - } - - .pe-lg-3 { - padding-right: 1rem !important; - } - - .pe-lg-4 { - padding-right: 1.5rem !important; - } - - .pe-lg-5 { - padding-right: 3rem !important; - } - - .pb-lg-0 { - padding-bottom: 0 !important; - } - - .pb-lg-1 { - padding-bottom: 0.25rem !important; - } - - .pb-lg-2 { - padding-bottom: 0.5rem !important; - } - - .pb-lg-3 { - padding-bottom: 1rem !important; - } - - .pb-lg-4 { - padding-bottom: 1.5rem !important; - } - - .pb-lg-5 { - padding-bottom: 3rem !important; - } - - .ps-lg-0 { - padding-left: 0 !important; - } - - .ps-lg-1 { - padding-left: 0.25rem !important; - } - - .ps-lg-2 { - padding-left: 0.5rem !important; - } - - .ps-lg-3 { - padding-left: 1rem !important; - } - - .ps-lg-4 { - padding-left: 1.5rem !important; - } - - .ps-lg-5 { - padding-left: 3rem !important; - } - - .text-lg-start { - text-align: left !important; - } - - .text-lg-end { - text-align: right !important; - } - - .text-lg-center { - text-align: center !important; - } -} -@media (min-width: 1200px) { - .float-xl-start { - float: left !important; - } - - .float-xl-end { - float: right !important; - } - - .float-xl-none { - float: none !important; - } - - .d-xl-inline { - display: inline !important; - } - - .d-xl-inline-block { - display: inline-block !important; - } - - .d-xl-block { - display: block !important; - } - - .d-xl-grid { - display: grid !important; - } - - .d-xl-table { - display: table !important; - } - - .d-xl-table-row { - display: table-row !important; - } - - .d-xl-table-cell { - display: table-cell !important; - } - - .d-xl-flex { - display: flex !important; - } - - .d-xl-inline-flex { - display: inline-flex !important; - } - - .d-xl-none { - display: none !important; - } - - .flex-xl-fill { - flex: 1 1 auto !important; - } - - .flex-xl-row { - flex-direction: row !important; - } - - .flex-xl-column { - flex-direction: column !important; - } - - .flex-xl-row-reverse { - flex-direction: row-reverse !important; - } - - .flex-xl-column-reverse { - flex-direction: column-reverse !important; - } - - .flex-xl-grow-0 { - flex-grow: 0 !important; - } - - .flex-xl-grow-1 { - flex-grow: 1 !important; - } - - .flex-xl-shrink-0 { - flex-shrink: 0 !important; - } - - .flex-xl-shrink-1 { - flex-shrink: 1 !important; - } - - .flex-xl-wrap { - flex-wrap: wrap !important; - } - - .flex-xl-nowrap { - flex-wrap: nowrap !important; - } - - .flex-xl-wrap-reverse { - flex-wrap: wrap-reverse !important; - } - - .gap-xl-0 { - gap: 0 !important; - } - - .gap-xl-1 { - gap: 0.25rem !important; - } - - .gap-xl-2 { - gap: 0.5rem !important; - } - - .gap-xl-3 { - gap: 1rem !important; - } - - .gap-xl-4 { - gap: 1.5rem !important; - } - - .gap-xl-5 { - gap: 3rem !important; - } - - .justify-content-xl-start { - justify-content: flex-start !important; - } - - .justify-content-xl-end { - justify-content: flex-end !important; - } - - .justify-content-xl-center { - justify-content: center !important; - } - - .justify-content-xl-between { - justify-content: space-between !important; - } - - .justify-content-xl-around { - justify-content: space-around !important; - } - - .justify-content-xl-evenly { - justify-content: space-evenly !important; - } - - .align-items-xl-start { - align-items: flex-start !important; - } - - .align-items-xl-end { - align-items: flex-end !important; - } - - .align-items-xl-center { - align-items: center !important; - } - - .align-items-xl-baseline { - align-items: baseline !important; - } - - .align-items-xl-stretch { - align-items: stretch !important; - } - - .align-content-xl-start { - align-content: flex-start !important; - } - - .align-content-xl-end { - align-content: flex-end !important; - } - - .align-content-xl-center { - align-content: center !important; - } - - .align-content-xl-between { - align-content: space-between !important; - } - - .align-content-xl-around { - align-content: space-around !important; - } - - .align-content-xl-stretch { - align-content: stretch !important; - } - - .align-self-xl-auto { - align-self: auto !important; - } - - .align-self-xl-start { - align-self: flex-start !important; - } - - .align-self-xl-end { - align-self: flex-end !important; - } - - .align-self-xl-center { - align-self: center !important; - } - - .align-self-xl-baseline { - align-self: baseline !important; - } - - .align-self-xl-stretch { - align-self: stretch !important; - } - - .order-xl-first { - order: -1 !important; - } - - .order-xl-0 { - order: 0 !important; - } - - .order-xl-1 { - order: 1 !important; - } - - .order-xl-2 { - order: 2 !important; - } - - .order-xl-3 { - order: 3 !important; - } - - .order-xl-4 { - order: 4 !important; - } - - .order-xl-5 { - order: 5 !important; - } - - .order-xl-last { - order: 6 !important; - } - - .m-xl-0 { - margin: 0 !important; - } - - .m-xl-1 { - margin: 0.25rem !important; - } - - .m-xl-2 { - margin: 0.5rem !important; - } - - .m-xl-3 { - margin: 1rem !important; - } - - .m-xl-4 { - margin: 1.5rem !important; - } - - .m-xl-5 { - margin: 3rem !important; - } - - .m-xl-auto { - margin: auto !important; - } - - .mx-xl-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - - .mx-xl-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; - } - - .mx-xl-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; - } - - .mx-xl-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; - } - - .mx-xl-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; - } - - .mx-xl-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; - } - - .mx-xl-auto { - margin-right: auto !important; - margin-left: auto !important; - } - - .my-xl-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - - .my-xl-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; - } - - .my-xl-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - } - - .my-xl-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; - } - - .my-xl-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; - } - - .my-xl-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; - } - - .my-xl-auto { - margin-top: auto !important; - margin-bottom: auto !important; - } - - .mt-xl-0 { - margin-top: 0 !important; - } - - .mt-xl-1 { - margin-top: 0.25rem !important; - } - - .mt-xl-2 { - margin-top: 0.5rem !important; - } - - .mt-xl-3 { - margin-top: 1rem !important; - } - - .mt-xl-4 { - margin-top: 1.5rem !important; - } - - .mt-xl-5 { - margin-top: 3rem !important; - } - - .mt-xl-auto { - margin-top: auto !important; - } - - .me-xl-0 { - margin-right: 0 !important; - } - - .me-xl-1 { - margin-right: 0.25rem !important; - } - - .me-xl-2 { - margin-right: 0.5rem !important; - } - - .me-xl-3 { - margin-right: 1rem !important; - } - - .me-xl-4 { - margin-right: 1.5rem !important; - } - - .me-xl-5 { - margin-right: 3rem !important; - } - - .me-xl-auto { - margin-right: auto !important; - } - - .mb-xl-0 { - margin-bottom: 0 !important; - } - - .mb-xl-1 { - margin-bottom: 0.25rem !important; - } - - .mb-xl-2 { - margin-bottom: 0.5rem !important; - } - - .mb-xl-3 { - margin-bottom: 1rem !important; - } - - .mb-xl-4 { - margin-bottom: 1.5rem !important; - } - - .mb-xl-5 { - margin-bottom: 3rem !important; - } - - .mb-xl-auto { - margin-bottom: auto !important; - } - - .ms-xl-0 { - margin-left: 0 !important; - } - - .ms-xl-1 { - margin-left: 0.25rem !important; - } - - .ms-xl-2 { - margin-left: 0.5rem !important; - } - - .ms-xl-3 { - margin-left: 1rem !important; - } - - .ms-xl-4 { - margin-left: 1.5rem !important; - } - - .ms-xl-5 { - margin-left: 3rem !important; - } - - .ms-xl-auto { - margin-left: auto !important; - } - - .p-xl-0 { - padding: 0 !important; - } - - .p-xl-1 { - padding: 0.25rem !important; - } - - .p-xl-2 { - padding: 0.5rem !important; - } - - .p-xl-3 { - padding: 1rem !important; - } - - .p-xl-4 { - padding: 1.5rem !important; - } - - .p-xl-5 { - padding: 3rem !important; - } - - .px-xl-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - - .px-xl-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; - } - - .px-xl-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; - } - - .px-xl-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; - } - - .px-xl-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; - } - - .px-xl-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; - } - - .py-xl-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - - .py-xl-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; - } - - .py-xl-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - } - - .py-xl-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; - } - - .py-xl-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; - } - - .py-xl-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; - } - - .pt-xl-0 { - padding-top: 0 !important; - } - - .pt-xl-1 { - padding-top: 0.25rem !important; - } - - .pt-xl-2 { - padding-top: 0.5rem !important; - } - - .pt-xl-3 { - padding-top: 1rem !important; - } - - .pt-xl-4 { - padding-top: 1.5rem !important; - } - - .pt-xl-5 { - padding-top: 3rem !important; - } - - .pe-xl-0 { - padding-right: 0 !important; - } - - .pe-xl-1 { - padding-right: 0.25rem !important; - } - - .pe-xl-2 { - padding-right: 0.5rem !important; - } - - .pe-xl-3 { - padding-right: 1rem !important; - } - - .pe-xl-4 { - padding-right: 1.5rem !important; - } - - .pe-xl-5 { - padding-right: 3rem !important; - } - - .pb-xl-0 { - padding-bottom: 0 !important; - } - - .pb-xl-1 { - padding-bottom: 0.25rem !important; - } - - .pb-xl-2 { - padding-bottom: 0.5rem !important; - } - - .pb-xl-3 { - padding-bottom: 1rem !important; - } - - .pb-xl-4 { - padding-bottom: 1.5rem !important; - } - - .pb-xl-5 { - padding-bottom: 3rem !important; - } - - .ps-xl-0 { - padding-left: 0 !important; - } - - .ps-xl-1 { - padding-left: 0.25rem !important; - } - - .ps-xl-2 { - padding-left: 0.5rem !important; - } - - .ps-xl-3 { - padding-left: 1rem !important; - } - - .ps-xl-4 { - padding-left: 1.5rem !important; - } - - .ps-xl-5 { - padding-left: 3rem !important; - } - - .text-xl-start { - text-align: left !important; - } - - .text-xl-end { - text-align: right !important; - } - - .text-xl-center { - text-align: center !important; - } -} -@media (min-width: 1400px) { - .float-xxl-start { - float: left !important; - } - - .float-xxl-end { - float: right !important; - } - - .float-xxl-none { - float: none !important; - } - - .d-xxl-inline { - display: inline !important; - } - - .d-xxl-inline-block { - display: inline-block !important; - } - - .d-xxl-block { - display: block !important; - } - - .d-xxl-grid { - display: grid !important; - } - - .d-xxl-table { - display: table !important; - } - - .d-xxl-table-row { - display: table-row !important; - } - - .d-xxl-table-cell { - display: table-cell !important; - } - - .d-xxl-flex { - display: flex !important; - } - - .d-xxl-inline-flex { - display: inline-flex !important; - } - - .d-xxl-none { - display: none !important; - } - - .flex-xxl-fill { - flex: 1 1 auto !important; - } - - .flex-xxl-row { - flex-direction: row !important; - } - - .flex-xxl-column { - flex-direction: column !important; - } - - .flex-xxl-row-reverse { - flex-direction: row-reverse !important; - } - - .flex-xxl-column-reverse { - flex-direction: column-reverse !important; - } - - .flex-xxl-grow-0 { - flex-grow: 0 !important; - } - - .flex-xxl-grow-1 { - flex-grow: 1 !important; - } - - .flex-xxl-shrink-0 { - flex-shrink: 0 !important; - } - - .flex-xxl-shrink-1 { - flex-shrink: 1 !important; - } - - .flex-xxl-wrap { - flex-wrap: wrap !important; - } - - .flex-xxl-nowrap { - flex-wrap: nowrap !important; - } - - .flex-xxl-wrap-reverse { - flex-wrap: wrap-reverse !important; - } - - .gap-xxl-0 { - gap: 0 !important; - } - - .gap-xxl-1 { - gap: 0.25rem !important; - } - - .gap-xxl-2 { - gap: 0.5rem !important; - } - - .gap-xxl-3 { - gap: 1rem !important; - } - - .gap-xxl-4 { - gap: 1.5rem !important; - } - - .gap-xxl-5 { - gap: 3rem !important; - } - - .justify-content-xxl-start { - justify-content: flex-start !important; - } - - .justify-content-xxl-end { - justify-content: flex-end !important; - } - - .justify-content-xxl-center { - justify-content: center !important; - } - - .justify-content-xxl-between { - justify-content: space-between !important; - } - - .justify-content-xxl-around { - justify-content: space-around !important; - } - - .justify-content-xxl-evenly { - justify-content: space-evenly !important; - } - - .align-items-xxl-start { - align-items: flex-start !important; - } - - .align-items-xxl-end { - align-items: flex-end !important; - } - - .align-items-xxl-center { - align-items: center !important; - } - - .align-items-xxl-baseline { - align-items: baseline !important; - } - - .align-items-xxl-stretch { - align-items: stretch !important; - } - - .align-content-xxl-start { - align-content: flex-start !important; - } - - .align-content-xxl-end { - align-content: flex-end !important; - } - - .align-content-xxl-center { - align-content: center !important; - } - - .align-content-xxl-between { - align-content: space-between !important; - } - - .align-content-xxl-around { - align-content: space-around !important; - } - - .align-content-xxl-stretch { - align-content: stretch !important; - } - - .align-self-xxl-auto { - align-self: auto !important; - } - - .align-self-xxl-start { - align-self: flex-start !important; - } - - .align-self-xxl-end { - align-self: flex-end !important; - } - - .align-self-xxl-center { - align-self: center !important; - } - - .align-self-xxl-baseline { - align-self: baseline !important; - } - - .align-self-xxl-stretch { - align-self: stretch !important; - } - - .order-xxl-first { - order: -1 !important; - } - - .order-xxl-0 { - order: 0 !important; - } - - .order-xxl-1 { - order: 1 !important; - } - - .order-xxl-2 { - order: 2 !important; - } - - .order-xxl-3 { - order: 3 !important; - } - - .order-xxl-4 { - order: 4 !important; - } - - .order-xxl-5 { - order: 5 !important; - } - - .order-xxl-last { - order: 6 !important; - } - - .m-xxl-0 { - margin: 0 !important; - } - - .m-xxl-1 { - margin: 0.25rem !important; - } - - .m-xxl-2 { - margin: 0.5rem !important; - } - - .m-xxl-3 { - margin: 1rem !important; - } - - .m-xxl-4 { - margin: 1.5rem !important; - } - - .m-xxl-5 { - margin: 3rem !important; - } - - .m-xxl-auto { - margin: auto !important; - } - - .mx-xxl-0 { - margin-right: 0 !important; - margin-left: 0 !important; - } - - .mx-xxl-1 { - margin-right: 0.25rem !important; - margin-left: 0.25rem !important; - } - - .mx-xxl-2 { - margin-right: 0.5rem !important; - margin-left: 0.5rem !important; - } - - .mx-xxl-3 { - margin-right: 1rem !important; - margin-left: 1rem !important; - } - - .mx-xxl-4 { - margin-right: 1.5rem !important; - margin-left: 1.5rem !important; - } - - .mx-xxl-5 { - margin-right: 3rem !important; - margin-left: 3rem !important; - } - - .mx-xxl-auto { - margin-right: auto !important; - margin-left: auto !important; - } - - .my-xxl-0 { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - - .my-xxl-1 { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; - } - - .my-xxl-2 { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - } - - .my-xxl-3 { - margin-top: 1rem !important; - margin-bottom: 1rem !important; - } - - .my-xxl-4 { - margin-top: 1.5rem !important; - margin-bottom: 1.5rem !important; - } - - .my-xxl-5 { - margin-top: 3rem !important; - margin-bottom: 3rem !important; - } - - .my-xxl-auto { - margin-top: auto !important; - margin-bottom: auto !important; - } - - .mt-xxl-0 { - margin-top: 0 !important; - } - - .mt-xxl-1 { - margin-top: 0.25rem !important; - } - - .mt-xxl-2 { - margin-top: 0.5rem !important; - } - - .mt-xxl-3 { - margin-top: 1rem !important; - } - - .mt-xxl-4 { - margin-top: 1.5rem !important; - } - - .mt-xxl-5 { - margin-top: 3rem !important; - } - - .mt-xxl-auto { - margin-top: auto !important; - } - - .me-xxl-0 { - margin-right: 0 !important; - } - - .me-xxl-1 { - margin-right: 0.25rem !important; - } - - .me-xxl-2 { - margin-right: 0.5rem !important; - } - - .me-xxl-3 { - margin-right: 1rem !important; - } - - .me-xxl-4 { - margin-right: 1.5rem !important; - } - - .me-xxl-5 { - margin-right: 3rem !important; - } - - .me-xxl-auto { - margin-right: auto !important; - } - - .mb-xxl-0 { - margin-bottom: 0 !important; - } - - .mb-xxl-1 { - margin-bottom: 0.25rem !important; - } - - .mb-xxl-2 { - margin-bottom: 0.5rem !important; - } - - .mb-xxl-3 { - margin-bottom: 1rem !important; - } - - .mb-xxl-4 { - margin-bottom: 1.5rem !important; - } - - .mb-xxl-5 { - margin-bottom: 3rem !important; - } - - .mb-xxl-auto { - margin-bottom: auto !important; - } - - .ms-xxl-0 { - margin-left: 0 !important; - } - - .ms-xxl-1 { - margin-left: 0.25rem !important; - } - - .ms-xxl-2 { - margin-left: 0.5rem !important; - } - - .ms-xxl-3 { - margin-left: 1rem !important; - } - - .ms-xxl-4 { - margin-left: 1.5rem !important; - } - - .ms-xxl-5 { - margin-left: 3rem !important; - } - - .ms-xxl-auto { - margin-left: auto !important; - } - - .p-xxl-0 { - padding: 0 !important; - } - - .p-xxl-1 { - padding: 0.25rem !important; - } - - .p-xxl-2 { - padding: 0.5rem !important; - } - - .p-xxl-3 { - padding: 1rem !important; - } - - .p-xxl-4 { - padding: 1.5rem !important; - } - - .p-xxl-5 { - padding: 3rem !important; - } - - .px-xxl-0 { - padding-right: 0 !important; - padding-left: 0 !important; - } - - .px-xxl-1 { - padding-right: 0.25rem !important; - padding-left: 0.25rem !important; - } - - .px-xxl-2 { - padding-right: 0.5rem !important; - padding-left: 0.5rem !important; - } - - .px-xxl-3 { - padding-right: 1rem !important; - padding-left: 1rem !important; - } - - .px-xxl-4 { - padding-right: 1.5rem !important; - padding-left: 1.5rem !important; - } - - .px-xxl-5 { - padding-right: 3rem !important; - padding-left: 3rem !important; - } - - .py-xxl-0 { - padding-top: 0 !important; - padding-bottom: 0 !important; - } - - .py-xxl-1 { - padding-top: 0.25rem !important; - padding-bottom: 0.25rem !important; - } - - .py-xxl-2 { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - } - - .py-xxl-3 { - padding-top: 1rem !important; - padding-bottom: 1rem !important; - } - - .py-xxl-4 { - padding-top: 1.5rem !important; - padding-bottom: 1.5rem !important; - } - - .py-xxl-5 { - padding-top: 3rem !important; - padding-bottom: 3rem !important; - } - - .pt-xxl-0 { - padding-top: 0 !important; - } - - .pt-xxl-1 { - padding-top: 0.25rem !important; - } - - .pt-xxl-2 { - padding-top: 0.5rem !important; - } - - .pt-xxl-3 { - padding-top: 1rem !important; - } - - .pt-xxl-4 { - padding-top: 1.5rem !important; - } - - .pt-xxl-5 { - padding-top: 3rem !important; - } - - .pe-xxl-0 { - padding-right: 0 !important; - } - - .pe-xxl-1 { - padding-right: 0.25rem !important; - } - - .pe-xxl-2 { - padding-right: 0.5rem !important; - } - - .pe-xxl-3 { - padding-right: 1rem !important; - } - - .pe-xxl-4 { - padding-right: 1.5rem !important; - } - - .pe-xxl-5 { - padding-right: 3rem !important; - } - - .pb-xxl-0 { - padding-bottom: 0 !important; - } - - .pb-xxl-1 { - padding-bottom: 0.25rem !important; - } - - .pb-xxl-2 { - padding-bottom: 0.5rem !important; - } - - .pb-xxl-3 { - padding-bottom: 1rem !important; - } - - .pb-xxl-4 { - padding-bottom: 1.5rem !important; - } - - .pb-xxl-5 { - padding-bottom: 3rem !important; - } - - .ps-xxl-0 { - padding-left: 0 !important; - } - - .ps-xxl-1 { - padding-left: 0.25rem !important; - } - - .ps-xxl-2 { - padding-left: 0.5rem !important; - } - - .ps-xxl-3 { - padding-left: 1rem !important; - } - - .ps-xxl-4 { - padding-left: 1.5rem !important; - } - - .ps-xxl-5 { - padding-left: 3rem !important; - } - - .text-xxl-start { - text-align: left !important; - } - - .text-xxl-end { - text-align: right !important; - } - - .text-xxl-center { - text-align: center !important; - } -} -@media (min-width: 1200px) { - .fs-1 { - font-size: 2.5rem !important; - } - - .fs-2 { - font-size: 2rem !important; - } - - .fs-3 { - font-size: 1.75rem !important; - } - - .fs-4 { - font-size: 1.5rem !important; - } -} -@media print { - .d-print-inline { - display: inline !important; - } - - .d-print-inline-block { - display: inline-block !important; - } - - .d-print-block { - display: block !important; - } - - .d-print-grid { - display: grid !important; - } - - .d-print-table { - display: table !important; - } - - .d-print-table-row { - display: table-row !important; - } - - .d-print-table-cell { - display: table-cell !important; - } - - .d-print-flex { - display: flex !important; - } - - .d-print-inline-flex { - display: inline-flex !important; - } - - .d-print-none { - display: none !important; - } -} -html, -body { - height: 100%; -} - -#layoutAuthentication { - display: flex; - flex-direction: column; - min-height: 100vh; -} -#layoutAuthentication #layoutAuthentication_content { - min-width: 0; - flex-grow: 1; -} -#layoutAuthentication #layoutAuthentication_footer { - min-width: 0; -} - -#layoutSidenav { - display: flex; -} -#layoutSidenav #layoutSidenav_nav { - flex-basis: 225px; - flex-shrink: 0; - transition: transform 0.15s ease-in-out; - z-index: 1038; - transform: translateX(-225px); -} -#layoutSidenav #layoutSidenav_content { - position: relative; - display: flex; - flex-direction: column; - justify-content: space-between; - min-width: 0; - flex-grow: 1; - min-height: calc(100vh - 56px); - margin-left: -225px; -} - -.sb-sidenav-toggled #layoutSidenav #layoutSidenav_nav { - transform: translateX(0); -} -.sb-sidenav-toggled #layoutSidenav #layoutSidenav_content:before { - content: ""; - display: block; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: #000; - z-index: 1037; - opacity: 0.5; - transition: opacity 0.3s ease-in-out; -} - -@media (min-width: 992px) { - #layoutSidenav #layoutSidenav_nav { - transform: translateX(0); - } - #layoutSidenav #layoutSidenav_content { - margin-left: 0; - transition: margin 0.15s ease-in-out; - } - - .sb-sidenav-toggled #layoutSidenav #layoutSidenav_nav { - transform: translateX(-225px); - } - .sb-sidenav-toggled #layoutSidenav #layoutSidenav_content { - margin-left: -225px; - } - .sb-sidenav-toggled #layoutSidenav #layoutSidenav_content:before { - display: none; - } -} -.sb-nav-fixed .sb-topnav { - z-index: 1039; -} -.sb-nav-fixed #layoutSidenav #layoutSidenav_nav { - width: 225px; - height: 100vh; - z-index: 1038; -} -.sb-nav-fixed #layoutSidenav #layoutSidenav_nav .sb-sidenav { - padding-top: 56px; -} -.sb-nav-fixed #layoutSidenav #layoutSidenav_nav .sb-sidenav .sb-sidenav-menu { - overflow-y: auto; -} -.sb-nav-fixed #layoutSidenav #layoutSidenav_content { - padding-left: 225px; - top: 56px; -} - -#layoutError { - display: flex; - flex-direction: column; - min-height: 100vh; -} -#layoutError #layoutError_content { - min-width: 0; - flex-grow: 1; -} -#layoutError #layoutError_footer { - min-width: 0; -} - -.img-error { - max-width: 20rem; -} - -.nav .nav-link .sb-nav-link-icon, -.sb-sidenav-menu .nav-link .sb-nav-link-icon { - margin-right: 0.5rem; -} - -.sb-topnav { - padding-left: 0; - height: 56px; - z-index: 1039; -} -.sb-topnav .navbar-brand { - width: 225px; - margin: 0; -} -.sb-topnav.navbar-dark #sidebarToggle { - color: rgba(255, 255, 255, 0.5); -} -.sb-topnav.navbar-light #sidebarToggle { - color: #212529; -} - -.sb-sidenav { - display: flex; - flex-direction: column; - height: 100%; - flex-wrap: nowrap; -} -.sb-sidenav .sb-sidenav-menu { - flex-grow: 1; -} -.sb-sidenav .sb-sidenav-menu .nav { - flex-direction: column; - flex-wrap: nowrap; -} -.sb-sidenav .sb-sidenav-menu .nav .sb-sidenav-menu-heading { - padding: 1.75rem 1rem 0.75rem; - font-size: 0.75rem; - font-weight: bold; - text-transform: uppercase; -} -.sb-sidenav .sb-sidenav-menu .nav .nav-link { - display: flex; - align-items: center; - padding-top: 0.75rem; - padding-bottom: 0.75rem; - position: relative; -} -.sb-sidenav .sb-sidenav-menu .nav .nav-link .sb-nav-link-icon { - font-size: 0.9rem; -} -.sb-sidenav .sb-sidenav-menu .nav .nav-link .sb-sidenav-collapse-arrow { - display: inline-block; - margin-left: auto; - transition: transform 0.15s ease; -} -.sb-sidenav .sb-sidenav-menu .nav .nav-link.collapsed .sb-sidenav-collapse-arrow { - transform: rotate(-90deg); -} -.sb-sidenav .sb-sidenav-menu .nav .sb-sidenav-menu-nested { - margin-left: 1.5rem; - flex-direction: column; -} -.sb-sidenav .sb-sidenav-footer { - padding: 0.75rem; - flex-shrink: 0; -} - -.sb-sidenav-dark { - background-color: #212529; - color: rgba(255, 255, 255, 0.5); -} -.sb-sidenav-dark .sb-sidenav-menu .sb-sidenav-menu-heading { - color: rgba(255, 255, 255, 0.25); -} -.sb-sidenav-dark .sb-sidenav-menu .nav-link { - color: rgba(255, 255, 255, 0.5); -} -.sb-sidenav-dark .sb-sidenav-menu .nav-link .sb-nav-link-icon { - color: rgba(255, 255, 255, 0.25); -} -.sb-sidenav-dark .sb-sidenav-menu .nav-link .sb-sidenav-collapse-arrow { - color: rgba(255, 255, 255, 0.25); -} -.sb-sidenav-dark .sb-sidenav-menu .nav-link:hover { - color: #fff; -} -.sb-sidenav-dark .sb-sidenav-menu .nav-link.active { - color: #fff; -} -.sb-sidenav-dark .sb-sidenav-menu .nav-link.active .sb-nav-link-icon { - color: #fff; -} -.sb-sidenav-dark .sb-sidenav-footer { - background-color: #343a40; -} - -.sb-sidenav-light { - background-color: #f8f9fa; - color: #212529; -} -.sb-sidenav-light .sb-sidenav-menu .sb-sidenav-menu-heading { - color: #adb5bd; -} -.sb-sidenav-light .sb-sidenav-menu .nav-link { - color: #212529; -} -.sb-sidenav-light .sb-sidenav-menu .nav-link .sb-nav-link-icon { - color: #adb5bd; -} -.sb-sidenav-light .sb-sidenav-menu .nav-link .sb-sidenav-collapse-arrow { - color: #adb5bd; -} -.sb-sidenav-light .sb-sidenav-menu .nav-link:hover { - color: #0d6efd; -} -.sb-sidenav-light .sb-sidenav-menu .nav-link.active { - color: #0d6efd; -} -.sb-sidenav-light .sb-sidenav-menu .nav-link.active .sb-nav-link-icon { - color: #0d6efd; -} -.sb-sidenav-light .sb-sidenav-footer { - background-color: #e9ecef; -} - -.dataTable-wrapper .dataTable-container { - font-size: 0.875rem; -} - -.dataTable-wrapper.no-header .dataTable-container { - border-top: none; -} - -.dataTable-wrapper.no-footer .dataTable-container { - border-bottom: none; -} - -.dataTable-top { - padding: 0 0 1rem; -} - -.dataTable-bottom { - padding: 0; -} - -.dataTable-top > nav:first-child, -.dataTable-top > div:first-child, -.dataTable-bottom > nav:first-child, -.dataTable-bottom > div:first-child { - float: left; -} - -.dataTable-top > nav:last-child, -.dataTable-top > div:last-child, -.dataTable-bottom > nav:last-child, -.dataTable-bottom > div:last-child { - float: right; -} - -.dataTable-selector { - width: auto; - display: inline-block; - padding-left: 1.125rem; - padding-right: 2.125rem; - margin-right: 0.25rem; -} - -.dataTable-info { - margin: 7px 0; -} - -/* PAGER */ -.dataTable-pagination a:hover { - background-color: #e9ecef; -} - -.dataTable-pagination .active a, -.dataTable-pagination .active a:focus, -.dataTable-pagination .active a:hover { - background-color: #0d6efd; -} - -.dataTable-pagination .ellipsis a, -.dataTable-pagination .disabled a, -.dataTable-pagination .disabled a:focus, -.dataTable-pagination .disabled a:hover { - cursor: not-allowed; -} - -.dataTable-pagination .disabled a, -.dataTable-pagination .disabled a:focus, -.dataTable-pagination .disabled a:hover { - cursor: not-allowed; - opacity: 0.4; -} - -.dataTable-pagination .pager a { - font-weight: bold; -} - -/* TABLE */ -.dataTable-table { - border-collapse: collapse; -} - -.dataTable-table > tbody > tr > td, -.dataTable-table > tbody > tr > th, -.dataTable-table > tfoot > tr > td, -.dataTable-table > tfoot > tr > th, -.dataTable-table > thead > tr > td, -.dataTable-table > thead > tr > th { - vertical-align: top; - padding: 0.5rem 0.5rem; -} - -.dataTable-table > thead > tr > th { - vertical-align: bottom; - text-align: left; - border-bottom: none; -} - -.dataTable-table > tfoot > tr > th { - vertical-align: bottom; - text-align: left; -} - -.dataTable-table th { - vertical-align: bottom; - text-align: left; -} - -.dataTable-table th a { - text-decoration: none; - color: inherit; -} - -.dataTable-sorter { - display: inline-block; - height: 100%; - position: relative; - width: 100%; - padding-right: 1rem; -} - -.dataTable-sorter::before, -.dataTable-sorter::after { - content: ""; - height: 0; - width: 0; - position: absolute; - right: 4px; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - opacity: 0.2; -} - -.dataTable-sorter::before { - bottom: 4px; -} - -.dataTable-sorter::after { - top: 0px; -} - -.asc .dataTable-sorter::after, -.desc .dataTable-sorter::before { - opacity: 0.6; -} - -.dataTables-empty { - text-align: center; -} - -.dataTable-top::after, -.dataTable-bottom::after { - clear: both; - content: " "; - display: table; -} - -.btn-datatable { - height: 20px !important; - width: 20px !important; - font-size: 0.75rem; - border-radius: 0.25rem !important; -} \ No newline at end of file diff --git a/src/main/resources/static/js/alerts.js b/src/main/resources/static/js/alerts.js deleted file mode 100644 index 43e1d7f7..00000000 --- a/src/main/resources/static/js/alerts.js +++ /dev/null @@ -1,65 +0,0 @@ -class Alerts { - constructor() { - - this.alertGroupMap = new Map(); - this.alertGroupMap.set("checkSub", "checkSubAlertGroup"); - this.alertGroupMap.set("subUnsub", "subUnsubAlertGroup"); - this.alertGroupMap.set("fakeUpdate", "fakeUpdateAlertGroup"); - - this.alertHTML = { - "subUnsubSuccessAlert": "

\n" + - " 정상적으로 구독했습니다.\n" + - " \n" + - "
\n", - - "checkSubSuccessAlert": "
\n" + - " 구독 카테고리 목록 로딩이 완료됐습니다.\n" + - " \n" + - "
\n", - - "fakeUpdateSuccessAlert": "
\n" + - " 가짜 공지를 성공적으로 전송했습니다.\n" + - " \n" + - "
\n", - - - "invalidTokenAlert": "
\n" + - " [오류] 유효하지 않은 토큰입니다.\n" + - " \n" + - "
\n", - - "serverErrorAlert": "
\n" + - " [오류] 서버에 오류가 발생했습니다. 잠시 후 시도해주세요.\n" + - "

문제가 지속될 시 관우에게 문의해 주세요.

\n" + - " \n" + - "
", - - "noSubjectAlert": "
\n" + - " [오류] 공지 제목은 최소 1자 이상, 128자 이하 길이여야 합니다.\n" + - " \n" + - "
\n", - - "noArticleIdAlert": "
\n" + - " [오류] 공지 아이디를 입력해주세요.\n" + - " \n" + - "
\n", - } - } - - create(serviceName, alertId) { - - const groupId = this.alertGroupMap.get(serviceName); - const group = document.querySelector(`#${groupId}`); - - if(group === null) { - return false; - } - - if(this.alertHTML[alertId] === undefined) { - return false; - } - - group.insertAdjacentHTML("afterbegin", this.alertHTML[alertId]); - return true; - } -} \ No newline at end of file diff --git a/src/main/resources/static/js/common-utils.js b/src/main/resources/static/js/common-utils.js deleted file mode 100644 index bb6eee1a..00000000 --- a/src/main/resources/static/js/common-utils.js +++ /dev/null @@ -1,37 +0,0 @@ - -const alerts = new Alerts(); -const contentLoader = document.querySelector("#contentLoader"); -const contentWrap = document.querySelector("#contentWrap"); - -function checkStatusCode(statusCode, serviceName, alertIds) { - - if(statusCode >= 200 && statusCode < 300) { - renderAlert(serviceName, alertIds[0]); - } else if(statusCode >= 300 && statusCode < 400) { - renderAlert(serviceName, alertIds[1]); - } else if(statusCode >= 400 && statusCode < 500) { - renderAlert(serviceName, alertIds[2]); - } else { - renderAlert(serviceName, alertIds[3]); - } -} - -function renderAlert(serviceName, alertId) { - const result = alerts.create(serviceName, alertId); - if(result === false) { - console.error(`${alertId} 렌더링 오류`); - } -} - -function initRender() { - contentLoader.setAttribute("style", "display: block"); - contentWrap.setAttribute("style", "display: none"); -} - -/** - * content visible 하게 바꾸고, loader 숨기기 - */ -function finishRender() { - contentLoader.setAttribute("style", "display: none"); - contentWrap.setAttribute("style", "display: block"); -} \ No newline at end of file diff --git a/src/main/resources/static/js/custom-datatable.js b/src/main/resources/static/js/custom-datatable.js deleted file mode 100644 index 26fd485a..00000000 --- a/src/main/resources/static/js/custom-datatable.js +++ /dev/null @@ -1,79 +0,0 @@ -class CustomDatatable extends simpleDatatables.DataTable { - search(query) { - if (!this.hasRows) return false - - if(this.hasFilter) - query = query + " " + this.filterQuery - - query = query.toLowerCase() - - this.currentPage = 1 - this.searching = true - this.searchData = [] - - if (!query.length) { - this.searching = false - this.update() - this.emit("datatable.search", query, this.searchData) - this.wrapper.classList.remove("search-results") - return false - } - - this.clear() - - this.data.forEach((row, idx) => { - const inArray = this.searchData.includes(row) - - // https://github.com/Mobius1/Vanilla-DataTables/issues/12 - const doesQueryMatch = query.split(" ").reduce((bool, word) => { - let includes = false - let cell = null - let content = null - - for (let x = 0; x < row.cells.length; x++) { - - cell = row.cells[x] - content = cell.hasAttribute("data-content") ? cell.getAttribute("data-content") : cell.textContent - - if ( - content.toLowerCase().includes(word) && - this.columns(cell.cellIndex).visible() - ) { - includes = true - break - } - } - - return bool && includes - }, true) - - if (doesQueryMatch && !inArray) { - row.searchIndex = idx - this.searchData.push(idx) - } else { - row.searchIndex = null - } - }) - - this.wrapper.classList.add("search-results") - - if (!this.searchData.length) { - this.wrapper.classList.remove("search-results") - - this.setMessage(this.options.labels.noRows) - } else { - this.update() - } - - this.emit("datatable.search", query, this.searchData) - } - - setFilter(filterFlag, filterName, filterQuery) { - this.hasFilter = filterFlag; - if(this.hasFilter) { - this.filterQuery = filterQuery; - } else { - this.filterQuery = ""; - } - } -} \ No newline at end of file diff --git a/src/main/resources/static/js/datatable-utils.js b/src/main/resources/static/js/datatable-utils.js deleted file mode 100644 index 9ee84fbc..00000000 --- a/src/main/resources/static/js/datatable-utils.js +++ /dev/null @@ -1,103 +0,0 @@ - -let feedbackDatatable, noticeDatatable, userDatatable; -let datatableCnt = 0; - -const paths = document.location.href.split("/"); -const datatableType = paths[paths.length - 1]; - -window.addEventListener('DOMContentLoaded', event => { - // Simple-DataTables - // https://github.com/fiduswriter/Simple-DataTables/wiki - - if(datatableType === "dashboard" || datatableType === "feedback") { - const feedbackDatatableElement = document.getElementById('feedbackDatatable'); - if (feedbackDatatableElement) { - feedbackDatatable = new CustomDatatable(feedbackDatatableElement); - feedbackDatatable.on("datatable.init", () => onDatatableInit("feedbackDatatableWrap")); - datatableCnt += 1; - } - } - - if(datatableType === "dashboard" || datatableType === "notice") { - const noticeDatatableElement = document.getElementById('noticeDatatable'); - if (noticeDatatableElement) { - noticeDatatable = new CustomDatatable(noticeDatatableElement); - noticeDatatable.on("datatable.init", () => onDatatableInit("noticeDatatableWrap")); - datatableCnt += 1; - } - } - - if(datatableType === "dashboard" || datatableType === "user") { - const userDatatableElement = document.getElementById('userDatatable'); - if (userDatatableElement) { - userDatatable = new CustomDatatable(userDatatableElement); - userDatatable.on("datatable.init", () => onDatatableInit("userDatatableWrap")); - datatableCnt += 1; - } - } -}); - -function addSelectorToUserDatatable() { - - const selectorDiv = document.createElement("div"); - selectorDiv.classList.add("dataTable-dropdown"); - selectorDiv.innerText = "카테고리 : " - - const label = document.createElement("label"); - selectorDiv.append(label); - - const selector = document.createElement("select"); - selector.classList.add("dataTable-selector"); - label.append(selector); - - // 전체 카테고리 option 추가 - const allOption = document.createElement("option"); - allOption.value = "all"; allOption.innerText = "all"; - selector.add(allOption); - - // 서버 지원 카테고리 option들 추가 - categories.forEach(category => { - const option = document.createElement("option"); - option.value = category.name; - option.innerText = category.name; - selector.add(option); - }); - - const idx = datatableType === "dashboard" ? 2 : 0; // TODO: dirty code.. - const searchElement = document.querySelectorAll(".dataTable-top .dataTable-search")[idx]; - searchElement.insertBefore(selectorDiv, searchElement.firstChild); - - const br = document.createElement("br"); - br.setAttribute("style", "display: block; content: \"\"; font-size: 4px; height: 4px;"); - searchElement.insertBefore(br, searchElement.lastChild); - - selector.addEventListener("change", onSelectorChange); -} - -function onSelectorChange(event) { - - const selectCategory = event.target.value; - if(selectCategory === "all") { - noticeDatatable.setFilter(false); - } else { - noticeDatatable.setFilter(true, "카테고리", selectCategory); - } - noticeDatatable.search(""); -} - -function onDatatableInit(datatableName) { - const datatable = document.querySelector(`#${datatableName}`); - - if (datatable === null) { - console.error("datatable is null"); - return; - } - - if (datatableName === "noticeDatatableWrap") { - addSelectorToUserDatatable(); - } - - if (--datatableCnt === 0) { - finishRender(); - } -} diff --git a/src/main/resources/static/js/fake-update-utils.js b/src/main/resources/static/js/fake-update-utils.js deleted file mode 100644 index df724f42..00000000 --- a/src/main/resources/static/js/fake-update-utils.js +++ /dev/null @@ -1,90 +0,0 @@ - -const fakeUpdateLoader = document.querySelector("#fakeUpdateLoader"); - -function createCategorySelector() { - const selector = document.querySelector("#noticeCategorySelector"); - - categories.forEach(category => { - const option = document.createElement("option"); - option.value = category.name; - option.innerText = category.name; - selector.append(option); - }); -} - -function addBtnListeners() { - const fakeNoticeBtn = document.querySelector("#fakeNoticeBtn"); - fakeNoticeBtn.addEventListener("click", onFakeNoticeBtnClick); -} - -function onFakeNoticeBtnClick(event) { - event.preventDefault(); - - const noticeSubjectInput = document.querySelector("#noticeSubjectInput"); - const subject = noticeSubjectInput.value; - - if(subject.length === 0 || subject.length > 128) { - alerts.create("fakeUpdate", "noSubjectAlert"); - return; - } - - const noticeArticleIdInput = document.querySelector("#noticeArticleIdInput"); - const articleId = noticeArticleIdInput.value; - - console.log(articleId); - - if(!articleId || articleId === "") { - alerts.create("fakeUpdate", "noArticleIdAlert"); - return; - } - - const noticeCategorySelector = document.querySelector("#noticeCategorySelector"); - const selectedCategory = noticeCategorySelector.value; - - sendNotice(selectedCategory, subject, articleId); -} - -function sendNotice(category, subject, articleId) { - - fakeUpdateLoader.setAttribute("style", "display: inline-block"); - - const url = "/admin/service/fake-update"; - const body = { - category, - subject, - articleId, - }; - const headers = { - "Content-Type": "application/json", - "Accept": "application/json", - } - - axios.post(url, body, { - headers - }).then(response => { - if(response.data) { - const { resultCode } = response.data; - const alertIds = ["fakeUpdateSuccessAlert", "", "noSubjectAlert", "serverErrorAlert"]; - checkStatusCode(resultCode, "fakeUpdate", alertIds); - } else { - renderAlert("fakeUpdate", "serverErrorAlert"); - } - }).catch(error => { - if(error.response) { - const { status } = error.response; - const alertIds = ["", "", "noSubjectAlert", "serverErrorAlert"]; - checkStatusCode(status, "fakeUpdate", alertIds); - } else { - renderAlert("fakeUpdate", "serverErrorAlert"); - } - }).finally(_ => { - fakeUpdateLoader.setAttribute("style", "display: none;"); - }); -} - -function init() { - createCategorySelector(); - addBtnListeners(); -} - -init(); \ No newline at end of file diff --git a/src/main/resources/static/js/login-utils.js b/src/main/resources/static/js/login-utils.js deleted file mode 100644 index 070707e7..00000000 --- a/src/main/resources/static/js/login-utils.js +++ /dev/null @@ -1,67 +0,0 @@ - -function addListeners() { - const loginBtn = document.querySelector("#loginBtn"); - loginBtn.addEventListener("click", onLoginBtnClick); - - const tokenInput = document.querySelector("#tokenInput"); - tokenInput.addEventListener("focus", onLoginInputFocus); -} - -function onLoginBtnClick(event) { - event.preventDefault(); - - const tokenInput = document.querySelector("#tokenInput"); - const token = tokenInput.value; - - if(token === null || token === "") { - tokenInput.classList.add("has-error"); - return; - } - - tryLogin(token); -} - -function onLoginInputFocus(event) { - event.preventDefault(); - if(event.target.classList.contains("has-error")) { - event.target.classList.remove("has-error"); - } - -} - -function tryLogin(token) { - - const url = "/admin/login"; - const body = { - token, - }; - const headers = { - "Content-Type": "application/json", - }; - - axios.post(url, body, { - headers, - }).then(response => { - if(response.data) { - const { resultCode } = response.data; - if(resultCode >= 200 && resultCode < 300) { - console.log(`dashboardUrl = ${response.data.dashboardUrl}`); - document.location.href = response.data.dashboardUrl; - } else if(resultCode >= 400 && resultCode < 500) { - alert("유효하지 않은 토큰입니다."); - } else { - alert("서버 오류입니다. 지속될 시 관리자에게 문의해주세요."); - } - } else { - alert("서버 오류입니다. 지속될 시 관리자에게 문의해주세요."); - } - }).catch(response => { - alert("서버 오류입니다. 지속될 시 관리자에게 문의해주세요."); - }); -} - -function init() { - addListeners(); -} - -init(); \ No newline at end of file diff --git a/src/main/resources/static/js/scripts.js b/src/main/resources/static/js/scripts.js deleted file mode 100644 index bd6c5491..00000000 --- a/src/main/resources/static/js/scripts.js +++ /dev/null @@ -1,26 +0,0 @@ -/*! - * Start Bootstrap - SB Admin v7.0.4 (https://startbootstrap.com/template/sb-admin) - * Copyright 2013-2021 Start Bootstrap - * Licensed under MIT (https://github.com/StartBootstrap/startbootstrap-sb-admin/blob/master/LICENSE) - */ - // -// Scripts -// - -window.addEventListener('DOMContentLoaded', event => { - - // Toggle the side navigation - const sidebarToggle = document.body.querySelector('#sidebarToggle'); - if (sidebarToggle) { - // Uncomment Below to persist sidebar toggle between refreshes - // if (localStorage.getItem('sb|sidebar-toggle') === 'true') { - // document.body.classList.toggle('sb-sidenav-toggled'); - // } - sidebarToggle.addEventListener('click', event => { - event.preventDefault(); - document.body.classList.toggle('sb-sidenav-toggled'); - localStorage.setItem('sb|sidebar-toggle', document.body.classList.contains('sb-sidenav-toggled')); - }); - } - -}); diff --git a/src/main/resources/static/js/sub-utils.js b/src/main/resources/static/js/sub-utils.js deleted file mode 100644 index 9170f614..00000000 --- a/src/main/resources/static/js/sub-utils.js +++ /dev/null @@ -1,186 +0,0 @@ - -function createCategoryCheckBoxes() { - - const checkboxes = document.querySelector("#categoryCheckboxes"); - - if(checkboxes === null) { - console.error("Cannot find Checkbox top div"); - return; - } - - categories.forEach(category => { - - const id = `${category.name}Checkbox`; - - const wrap = document.createElement("div"); - wrap.classList.add("form-check"); - wrap.classList.add("form-check-inline"); - - const input = document.createElement("input"); - input.classList.add("form-check-input"); - input.setAttribute("id", id); - input.setAttribute("type", "checkbox"); - input.setAttribute("value", category.name); - - const label = document.createElement("label"); - label.classList.add("form-check-label"); - label.setAttribute("for", id); - label.innerText = category.name; - - wrap.append(input); - wrap.append(label); - checkboxes.append(wrap); - }); -} - -function addBtnListeners() { - - const subBtn = document.querySelector("#subBtn"); - subBtn.addEventListener("click", onSubBtnClick); - - const subfindBtn = document.querySelector("#subFindBtn"); - subfindBtn.addEventListener("click", event => updateSubCategoryList({ event })); -} - -function onSubBtnClick(event) { - event.preventDefault(); - - const subCategoryListElement = document.querySelector("#subCategoryList"); - if(subCategoryListElement === null) { - console.error("Cannot find subCategoryListElement"); - return; - } - subCategoryListElement.innerHTML = ""; - - const tokenInput = document.querySelector("#fcmTokenInput"); - const categoryCheckboxesParent = document.querySelector("#categoryCheckboxes"); - const categoryCheckboxDivs = Array.from(categoryCheckboxesParent.children); - - const token = tokenInput.value; - const categories = []; - for(let i=0; i { - if(response.data) { - const { resultCode } = response.data; - const alertIds = ["subUnsubSuccessAlert", "", "invalidTokenAlert", "serverErrorAlert"]; - - checkStatusCode(resultCode, serviceName, alertIds); - - if(resultCode >= 200 && resultCode < 300) { - updateSubCategoryList({ token }); - } - } else { - renderAlert("subUnsub", "serverErrorAlert"); - } - }) - .catch(error => { - if(error.response) { - const { status } = error.response; - const alertIds = ["", "", "serverErrorAlert", "serverErrorAlert"]; - checkStatusCode(status, serviceName, alertIds); - } else { - renderAlert(serviceName, "serverErrorAlert"); - } - }); -} - -function updateSubCategoryList({ event, token }) { - - const categoryList = document.querySelector("#subCategoryList"); - if(categoryList === null) { - console.error("Cannot find categoryList element"); - return; - } - - const tokenElement = document.querySelector("#subCategoryListFCMTokenInput"); - if(tokenElement === null) { - console.error("Cannot find fcm token input element"); - return; - } - - // loader on - const checkSubLoader = document.querySelector(".loader-dot"); - checkSubLoader.setAttribute("style", "display: block;"); - - const url = "/api/v1/notice/subscribe"; - if(token === undefined || token === null) { - console.log("hi"); - token = tokenElement.value; - } - const params = { - id: token, - } - - const serviceName = "checkSub"; - axios.get(url, { params }) - .then(response => { - if(response.data) { - const { resultCode } = response.data; - const alertIds = ["checkSubSuccessAlert", "", "invalidTokenAlert", "serverErrorAlert"]; - checkStatusCode(resultCode, serviceName, alertIds); - - if(resultCode >= 200 && resultCode < 300) { - createSubCategoryList(response.data.categories); - } - } else { - renderAlert(serviceName, "serverErrorAlert"); - } - }) - .catch(error => { - if(error.response) { - const { status } = error.response; - const alertIds = ["", "", "serverErrorAlert", "serverErrorAlert"]; - checkStatusCode(status, serviceName, alertIds); - } else { - renderAlert(serviceName, "serverErrorAlert"); - } - }) - .finally(_ => { - checkSubLoader.setAttribute("style", "display: none;"); - }); -} - -function createSubCategoryList(categories) { - - const subCategoryListElement = document.querySelector("#subCategoryList"); - if(subCategoryListElement === null) { - console.error("Cannot find subCategoryListElement"); - return; - } - subCategoryListElement.innerHTML = ""; - - categories.forEach(category => { - const li = document.createElement("li"); - li.classList.add("list-group-item"); - li.innerText = category; - subCategoryListElement.append(li); - }); -} - -function init() { - createCategoryCheckBoxes(); - addBtnListeners(); -} - -init(); - diff --git a/src/main/resources/templates/thymeleaf/401.html b/src/main/resources/templates/thymeleaf/401.html deleted file mode 100644 index 0a9c3988..00000000 --- a/src/main/resources/templates/thymeleaf/401.html +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - 404 Error - SB Admin - - - - -
-
-
-
-
-
-
-

401

-

Unauthorized

-

Access to this resource is denied.

- - - Return to Dashboard - -
-
-
-
-
-
- -
- - - - diff --git a/src/main/resources/templates/thymeleaf/404.html b/src/main/resources/templates/thymeleaf/404.html deleted file mode 100644 index f9f77ebb..00000000 --- a/src/main/resources/templates/thymeleaf/404.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - 404 Error - SB Admin - - - - -
-
-
-
-
-
-
- -

This requested URL was not found on this server.

- - - Return to Dashboard - -
-
-
-
-
-
- -
- - - - diff --git a/src/main/resources/templates/thymeleaf/500.html b/src/main/resources/templates/thymeleaf/500.html deleted file mode 100644 index 79598a71..00000000 --- a/src/main/resources/templates/thymeleaf/500.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - 404 Error - SB Admin - - - - -
-
-
-
-
-
-
-

500

-

Internal Server Error

- - - Return to Dashboard - -
-
-
-
-
-
- -
- - - - diff --git a/src/main/resources/templates/thymeleaf/fake-update.html b/src/main/resources/templates/thymeleaf/fake-update.html deleted file mode 100644 index 0c757543..00000000 --- a/src/main/resources/templates/thymeleaf/fake-update.html +++ /dev/null @@ -1,36 +0,0 @@ - - -
-

가짜 공지 업데이트

-

공지의 postedDate는 서버에서 임의로 설정합니다.

-
-
-
- - -
- -
- - -
- -
- - -
- - - - - - - - - - -
-
- \ No newline at end of file diff --git a/src/main/resources/templates/thymeleaf/feedback-datatable.html b/src/main/resources/templates/thymeleaf/feedback-datatable.html deleted file mode 100644 index 609f4095..00000000 --- a/src/main/resources/templates/thymeleaf/feedback-datatable.html +++ /dev/null @@ -1,36 +0,0 @@ - - -
-
-
- - 피드백 테이블 -
-
- - - - - - - - - - - - - - - - - - - - - - -
번호사용자 FCM 토큰피드백 내용
번호사용자 FCM 토큰피드백 내용
-
-
-
- \ No newline at end of file diff --git a/src/main/resources/templates/thymeleaf/login.html b/src/main/resources/templates/thymeleaf/login.html deleted file mode 100644 index e84f0500..00000000 --- a/src/main/resources/templates/thymeleaf/login.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - Login - KU Ring Admin - - - - -
-
-
-
-
-
-
-

KU Ring 로그인

-
-
-
- - -
- -
- -
-
-
-
-
-
-
-
-
- -
- - - - - - - - - diff --git a/src/main/resources/templates/thymeleaf/main.html b/src/main/resources/templates/thymeleaf/main.html deleted file mode 100644 index 95a7e3c8..00000000 --- a/src/main/resources/templates/thymeleaf/main.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - Dashboard - SB Admin - - - - - - - - -
-
- -
-
-
-
-
-
- -

서버 데이터베이스

- - - - -

FCM 관련 기능

- - -
-
- -
-
- - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/templates/thymeleaf/notice-datatable.html b/src/main/resources/templates/thymeleaf/notice-datatable.html deleted file mode 100644 index e0ed9b06..00000000 --- a/src/main/resources/templates/thymeleaf/notice-datatable.html +++ /dev/null @@ -1,42 +0,0 @@ - - -
-
-
- - 공지 테이블 -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
번호고유번호게시 날짜카테고리제목
번호고유번호게시 날짜카테고리제목
-
-
-
- \ No newline at end of file diff --git a/src/main/resources/templates/thymeleaf/sub-unsub.html b/src/main/resources/templates/thymeleaf/sub-unsub.html deleted file mode 100644 index 6906f050..00000000 --- a/src/main/resources/templates/thymeleaf/sub-unsub.html +++ /dev/null @@ -1,38 +0,0 @@ - - -
-

공지 카테고리 구독 / 구독취소

-
-
-
- -
-
- -
- - -
- -
- -
-
- -

구독한 공지 카테고리 확인

-
-
-
-
- -
- -
- - -
    - -
-
-
- \ No newline at end of file diff --git a/src/main/resources/templates/thymeleaf/user-datatable.html b/src/main/resources/templates/thymeleaf/user-datatable.html deleted file mode 100644 index 37279312..00000000 --- a/src/main/resources/templates/thymeleaf/user-datatable.html +++ /dev/null @@ -1,33 +0,0 @@ - - -
-
-
- - 사용자 테이블 -
-
- - - - - - - - - - - - - - - - - - - -
번호사용자 FCM 토큰
번호사용자 FCM 토큰
-
-
-
- \ No newline at end of file diff --git a/src/test/java/com/kustacks/kuring/acceptance/AdminAcceptanceTest.java b/src/test/java/com/kustacks/kuring/acceptance/AdminAcceptanceTest.java index 18dc0d97..bd4f3e4b 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/AdminAcceptanceTest.java +++ b/src/test/java/com/kustacks/kuring/acceptance/AdminAcceptanceTest.java @@ -1,6 +1,6 @@ package com.kustacks.kuring.acceptance; -import com.kustacks.kuring.admin.common.dto.RealNotificationRequest; +import com.kustacks.kuring.admin.adapter.in.web.dto.RealNotificationRequest; import com.kustacks.kuring.support.IntegrationTestSupport; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; @@ -48,7 +48,7 @@ void role_root_admin_search_feedbacks() { @Test void role_root_admin_create_test_notification() { // given - doNothing().when(firebaseService).sendNotificationByAdmin(any()); + doNothing().when(firebaseNotificationService).sendNotificationByAdmin(any()); String accessToken = 로그인_되어_있음(ADMIN_LOGIN_ID, ADMIN_PASSWORD); // when diff --git a/src/test/java/com/kustacks/kuring/acceptance/AuthAcceptanceTest.java b/src/test/java/com/kustacks/kuring/acceptance/AuthAcceptanceTest.java index 6b01f1d2..a4218dc4 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/AuthAcceptanceTest.java +++ b/src/test/java/com/kustacks/kuring/acceptance/AuthAcceptanceTest.java @@ -1,13 +1,19 @@ package com.kustacks.kuring.acceptance; +import com.kustacks.kuring.message.application.service.exception.FirebaseInvalidTokenException; import com.kustacks.kuring.support.IntegrationTestSupport; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import static com.kustacks.kuring.acceptance.AuthStep.로그인_되어_있음; import static com.kustacks.kuring.acceptance.AuthStep.로그인_요청; +import static com.kustacks.kuring.acceptance.CategoryStep.사용자가_구독한_카테고리_목록_조회_요청; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; @DisplayName("인수 : 인증") class AuthAcceptanceTest extends IntegrationTestSupport { @@ -32,4 +38,19 @@ void bearer_auth_login_fail() { // when, then assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); } + + @DisplayName("[v2] 사용자가 잘못된 FCM token으로 요청을 보내면 예외가 발생한다") + @Test + void invalid_fcm_token_login_fail() { + // given + doThrow(new FirebaseInvalidTokenException()) + .when(firebaseSubscribeService) + .validationToken(anyString()); + + // when + ExtractableResponse response = 사용자가_구독한_카테고리_목록_조회_요청(USER_FCM_TOKEN); + + // when, then + assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + } } diff --git a/src/test/java/com/kustacks/kuring/acceptance/CategoryAcceptanceTest.java b/src/test/java/com/kustacks/kuring/acceptance/CategoryAcceptanceTest.java index 29d6078d..f54fb6c5 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/CategoryAcceptanceTest.java +++ b/src/test/java/com/kustacks/kuring/acceptance/CategoryAcceptanceTest.java @@ -1,9 +1,11 @@ package com.kustacks.kuring.acceptance; import com.google.firebase.messaging.FirebaseMessagingException; -import com.kustacks.kuring.message.firebase.exception.FirebaseInvalidTokenException; +import com.kustacks.kuring.message.application.port.in.dto.UserSubscribeCommand; +import com.kustacks.kuring.message.application.port.in.dto.UserUnsubscribeCommand; +import com.kustacks.kuring.message.application.service.exception.FirebaseInvalidTokenException; import com.kustacks.kuring.support.IntegrationTestSupport; -import com.kustacks.kuring.user.common.dto.SubscribeCategoriesRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserCategoriesSubscribeRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; @@ -12,6 +14,7 @@ import static com.kustacks.kuring.acceptance.CategoryStep.*; import static com.kustacks.kuring.acceptance.CommonStep.실패_응답_확인; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -60,10 +63,10 @@ void look_up_department_list() { @Test void user_subscribe_category() throws FirebaseMessagingException { // given - doNothing().when(firebaseService).subscribe(anyString(), anyString()); + doNothing().when(firebaseSubscribeService).subscribe(any(UserSubscribeCommand.class)); // when - var 카테고리_구독_요청_응답 = 카테고리_구독_요청(USER_FCM_TOKEN, new SubscribeCategoriesRequest(List.of("student", "employment"))); + var 카테고리_구독_요청_응답 = 카테고리_구독_요청(USER_FCM_TOKEN, new UserCategoriesSubscribeRequest(List.of("student", "employment"))); // then 카테고리_구독_요청_응답_확인(카테고리_구독_요청_응답); @@ -78,11 +81,12 @@ void user_subscribe_category() throws FirebaseMessagingException { @Test void user_subscribe_category_with_invalid_token() { // given - doNothing().when(firebaseService).subscribe(anyString(), anyString()); - doThrow(new FirebaseInvalidTokenException()).when(firebaseService).validationToken(anyString()); + doThrow(new FirebaseInvalidTokenException()) + .when(firebaseSubscribeService) + .validationToken(anyString()); // when - var response = 카테고리_구독_요청(USER_FCM_TOKEN, new SubscribeCategoriesRequest(List.of("student", "employment"))); + var response = 카테고리_구독_요청("user_invalid_token", new UserCategoriesSubscribeRequest(List.of("student", "employment"))); // then 실패_응답_확인(response, HttpStatus.UNAUTHORIZED); @@ -97,8 +101,8 @@ void user_subscribe_category_with_invalid_token() { @Test void look_up_user_subscribe_category() { // given - doNothing().when(firebaseService).validationToken(anyString()); - 카테고리_구독_요청(USER_FCM_TOKEN, new SubscribeCategoriesRequest(List.of("student", "employment"))); + doNothing().when(firebaseSubscribeService).validationToken(anyString()); + 카테고리_구독_요청(USER_FCM_TOKEN, new UserCategoriesSubscribeRequest(List.of("student", "employment"))); // when var 조회_응답 = 사용자가_구독한_카테고리_목록_조회_요청(USER_FCM_TOKEN); @@ -118,14 +122,14 @@ void look_up_user_subscribe_category() { @Test void edit_user_subscribe_category() throws FirebaseMessagingException { // given - doNothing().when(firebaseService).validationToken(anyString()); - doNothing().when(firebaseService).subscribe(anyString(), anyString()); - doNothing().when(firebaseService).unsubscribe(anyString(), anyString()); + doNothing().when(firebaseSubscribeService).validationToken(anyString()); + doNothing().when(firebaseSubscribeService).subscribe(any(UserSubscribeCommand.class)); + doNothing().when(firebaseSubscribeService).unsubscribe(any(UserUnsubscribeCommand.class)); - 카테고리_구독_요청(USER_FCM_TOKEN, new SubscribeCategoriesRequest(List.of("student", "employment"))); + 카테고리_구독_요청(USER_FCM_TOKEN, new UserCategoriesSubscribeRequest(List.of("student", "employment"))); // when - var 카테고리_구독_요청_응답 = 카테고리_수정_요청(new SubscribeCategoriesRequest(List.of("student", "library"))); + var 카테고리_구독_요청_응답 = 카테고리_수정_요청(new UserCategoriesSubscribeRequest(List.of("student", "library"))); // then 카테고리_구독_요청_응답_확인(카테고리_구독_요청_응답); @@ -141,10 +145,10 @@ void edit_user_subscribe_category() throws FirebaseMessagingException { @Test void json_body_miss() throws FirebaseMessagingException { // given - doNothing().when(firebaseService).subscribe(anyString(), anyString()); + doNothing().when(firebaseSubscribeService).subscribe(any(UserSubscribeCommand.class)); // when - var 카테고리_구독_요청_응답 = 카테고리_구독_요청(USER_FCM_TOKEN, new SubscribeCategoriesRequest(null)); + var 카테고리_구독_요청_응답 = 카테고리_구독_요청(USER_FCM_TOKEN, new UserCategoriesSubscribeRequest(null)); // then 실패_응답_확인(카테고리_구독_요청_응답, HttpStatus.BAD_REQUEST); @@ -154,10 +158,10 @@ void json_body_miss() throws FirebaseMessagingException { @Test void user_subscribe_invalid_category() throws FirebaseMessagingException { // given - doNothing().when(firebaseService).subscribe(anyString(), anyString()); + doNothing().when(firebaseSubscribeService).subscribe(any(UserSubscribeCommand.class)); // when - var 카테고리_구독_요청_응답 = 카테고리_구독_요청(USER_FCM_TOKEN, new SubscribeCategoriesRequest(List.of("invalid-category"))); + var 카테고리_구독_요청_응답 = 카테고리_구독_요청(USER_FCM_TOKEN, new UserCategoriesSubscribeRequest(List.of("invalid-category"))); // then 실패_응답_확인(카테고리_구독_요청_응답, HttpStatus.BAD_REQUEST); diff --git a/src/test/java/com/kustacks/kuring/acceptance/CategoryStep.java b/src/test/java/com/kustacks/kuring/acceptance/CategoryStep.java index 54d0be8f..45872d9a 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/CategoryStep.java +++ b/src/test/java/com/kustacks/kuring/acceptance/CategoryStep.java @@ -1,6 +1,6 @@ package com.kustacks.kuring.acceptance; -import com.kustacks.kuring.user.common.dto.SubscribeCategoriesRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserCategoriesSubscribeRequest; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -39,7 +39,7 @@ public class CategoryStep { ); } - public static ExtractableResponse 카테고리_구독_요청(String userFcmToken, SubscribeCategoriesRequest reqeust) { + public static ExtractableResponse 카테고리_구독_요청(String userFcmToken, UserCategoriesSubscribeRequest reqeust) { return RestAssured .given().log().all() .header("User-Token", userFcmToken) @@ -76,7 +76,7 @@ public class CategoryStep { ); } - public static ExtractableResponse 카테고리_수정_요청(SubscribeCategoriesRequest request) { + public static ExtractableResponse 카테고리_수정_요청(UserCategoriesSubscribeRequest request) { return 카테고리_구독_요청(USER_FCM_TOKEN, request); } } diff --git a/src/test/java/com/kustacks/kuring/acceptance/FeedbackAcceptanceTest.java b/src/test/java/com/kustacks/kuring/acceptance/FeedbackAcceptanceTest.java index 38035230..05c88e15 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/FeedbackAcceptanceTest.java +++ b/src/test/java/com/kustacks/kuring/acceptance/FeedbackAcceptanceTest.java @@ -9,6 +9,7 @@ import static com.kustacks.kuring.acceptance.CommonStep.실패_응답_확인; import static com.kustacks.kuring.acceptance.FeedbackStep.피드백_요청; import static com.kustacks.kuring.acceptance.FeedbackStep.피드백_요청_응답_확인; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; @@ -24,7 +25,7 @@ class FeedbackAcceptanceTest extends IntegrationTestSupport { @Test public void request_feedback() throws FirebaseMessagingException { // given - doNothing().when(firebaseService).validationToken(anyString()); + doNothing().when(firebaseSubscribeService).validationToken(anyString()); // when var 피드백_요청_응답 = 피드백_요청(USER_FCM_TOKEN, "feedback request"); @@ -37,7 +38,7 @@ public void request_feedback() throws FirebaseMessagingException { @Test public void request_invalid_length_feedback() throws FirebaseMessagingException { // given - doNothing().when(firebaseService).validationToken(anyString()); + doNothing().when(firebaseSubscribeService).validationToken(anyString()); // when var 피드백_요청_응답 = 피드백_요청(USER_FCM_TOKEN, ""); diff --git a/src/test/java/com/kustacks/kuring/acceptance/FeedbackStep.java b/src/test/java/com/kustacks/kuring/acceptance/FeedbackStep.java index 16c68407..a7ff2b57 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/FeedbackStep.java +++ b/src/test/java/com/kustacks/kuring/acceptance/FeedbackStep.java @@ -1,6 +1,6 @@ package com.kustacks.kuring.acceptance; -import com.kustacks.kuring.user.common.dto.SaveFeedbackRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserFeedbackRequest; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -25,7 +25,7 @@ public class FeedbackStep { .given().log().all() .header("User-Token", fcmToken) .contentType(MediaType.APPLICATION_JSON_VALUE) - .body(new SaveFeedbackRequest(feedback)) + .body(new UserFeedbackRequest(feedback)) .when().post("/api/v2/users/feedbacks") .then().log().all() .extract(); diff --git a/src/test/java/com/kustacks/kuring/acceptance/UserAcceptanceTest.java b/src/test/java/com/kustacks/kuring/acceptance/UserAcceptanceTest.java index 17d66718..8fecb7ee 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/UserAcceptanceTest.java +++ b/src/test/java/com/kustacks/kuring/acceptance/UserAcceptanceTest.java @@ -1,8 +1,9 @@ package com.kustacks.kuring.acceptance; -import com.kustacks.kuring.auth.exception.RegisterException; +import com.kustacks.kuring.message.application.port.in.dto.UserSubscribeCommand; +import com.kustacks.kuring.message.application.service.exception.FirebaseSubscribeException; import com.kustacks.kuring.support.IntegrationTestSupport; -import com.kustacks.kuring.user.common.dto.SubscribeCategoriesRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserCategoriesSubscribeRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; @@ -13,6 +14,7 @@ import static com.kustacks.kuring.acceptance.CommonStep.실패_응답_확인; import static com.kustacks.kuring.acceptance.UserStep.*; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -29,7 +31,7 @@ class UserAcceptanceTest extends IntegrationTestSupport { @Test void user_register_success() { // given - doNothing().when(firebaseService).subscribe(anyString(), anyString()); + doNothing().when(firebaseSubscribeService).subscribe(any(UserSubscribeCommand.class)); var 회원_가입_응답 = 회원_가입_요청("test_register_token"); @@ -41,7 +43,9 @@ void user_register_success() { @Test void user_register_fail() { // given - doThrow(new RegisterException()).when(firebaseService).subscribe(anyString(), anyString()); + doThrow(new FirebaseSubscribeException()) + .when(firebaseSubscribeService) + .subscribe(any(UserSubscribeCommand.class)); var 회원_가입_응답 = 회원_가입_요청("test_register_token"); @@ -58,10 +62,10 @@ void user_register_fail() { @Test void user_subscribe_category() { // given - doNothing().when(firebaseService).subscribe(anyString(), anyString()); + doNothing().when(firebaseSubscribeService).subscribe(any(UserSubscribeCommand.class)); // when - var 카테고리_구독_요청_응답 = 카테고리_구독_요청(USER_FCM_TOKEN, new SubscribeCategoriesRequest(List.of("student", "employment"))); + var 카테고리_구독_요청_응답 = 카테고리_구독_요청(USER_FCM_TOKEN, new UserCategoriesSubscribeRequest(List.of("student", "employment"))); // then 카테고리_구독_요청_응답_확인(카테고리_구독_요청_응답); @@ -76,8 +80,8 @@ void user_subscribe_category() { @Test void look_up_user_subscribe_category() { // given - doNothing().when(firebaseService).validationToken(anyString()); - 카테고리_구독_요청(USER_FCM_TOKEN, new SubscribeCategoriesRequest(List.of("student", "employment"))); + doNothing().when(firebaseSubscribeService).validationToken(anyString()); + 카테고리_구독_요청(USER_FCM_TOKEN, new UserCategoriesSubscribeRequest(List.of("student", "employment"))); // when var 조회_응답 = 사용자_카테고리_구독_목록_조회_요청(USER_FCM_TOKEN); @@ -95,7 +99,7 @@ void look_up_user_subscribe_category() { @Test void user_subscribe_department() { // given - doNothing().when(firebaseService).subscribe(anyString(), anyString()); + doNothing().when(firebaseSubscribeService).subscribe(any(UserSubscribeCommand.class)); // when var 학과_구독_응답 = 학과_구독_요청(USER_FCM_TOKEN, List.of("cse", "korea")); @@ -115,7 +119,7 @@ void user_subscribe_department() { @Test void look_up_user_subscribe_department() { // given - doNothing().when(firebaseService).validationToken(anyString()); + doNothing().when(firebaseSubscribeService).validationToken(anyString()); 학과_구독_요청(USER_FCM_TOKEN, List.of("cse", "korea")); // when @@ -140,7 +144,7 @@ void look_up_user_subscribe_department() { @Test void request_feedback() { // given - doNothing().when(firebaseService).validationToken(anyString()); + doNothing().when(firebaseSubscribeService).validationToken(anyString()); // when var 피드백_요청_응답 = 피드백_요청_v2(USER_FCM_TOKEN, "feedback request"); @@ -153,7 +157,7 @@ void request_feedback() { @Test void request_invalid_length_feedback() { // given - doNothing().when(firebaseService).validationToken(anyString()); + doNothing().when(firebaseSubscribeService).validationToken(anyString()); // when var 피드백_요청_응답 = 피드백_요청_v2(USER_FCM_TOKEN, "5자미만"); @@ -166,7 +170,7 @@ void request_invalid_length_feedback() { @Test void request_bookmark() { // given - doNothing().when(firebaseService).validationToken(anyString()); + doNothing().when(firebaseSubscribeService).validationToken(anyString()); // when var 북마크_응답 = 북마크_생성_요청(USER_FCM_TOKEN, "article_1"); @@ -184,7 +188,7 @@ void request_bookmark() { @Test void lookup_bookmark() { // given - doNothing().when(firebaseService).validationToken(anyString()); + doNothing().when(firebaseSubscribeService).validationToken(anyString()); 북마크_생성_요청(USER_FCM_TOKEN, "article_1"); 북마크_생성_요청(USER_FCM_TOKEN, "article_2"); 북마크_생성_요청(USER_FCM_TOKEN, "depart_normal_article_1"); diff --git a/src/test/java/com/kustacks/kuring/acceptance/UserStep.java b/src/test/java/com/kustacks/kuring/acceptance/UserStep.java index 8ec5721c..fdc0b61e 100644 --- a/src/test/java/com/kustacks/kuring/acceptance/UserStep.java +++ b/src/test/java/com/kustacks/kuring/acceptance/UserStep.java @@ -1,10 +1,10 @@ package com.kustacks.kuring.acceptance; import com.kustacks.kuring.auth.dto.UserRegisterRequest; -import com.kustacks.kuring.user.common.dto.SaveBookmarkRequest; -import com.kustacks.kuring.user.common.dto.SubscribeCategoriesRequest; -import com.kustacks.kuring.user.common.dto.SubscribeDepartmentsRequest; -import com.kustacks.kuring.user.common.dto.SaveFeedbackRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserBookmarkRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserCategoriesSubscribeRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserDepartmentsSubscribeRequest; +import com.kustacks.kuring.user.adapter.in.web.dto.UserFeedbackRequest; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -27,7 +27,7 @@ public class UserStep { .extract(); } - public static ExtractableResponse 카테고리_구독_요청(String token, SubscribeCategoriesRequest reqeust) { + public static ExtractableResponse 카테고리_구독_요청(String token, UserCategoriesSubscribeRequest reqeust) { return RestAssured .given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) @@ -72,7 +72,7 @@ public class UserStep { .contentType(MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON_VALUE) .header("User-Token", token) - .body(new SubscribeDepartmentsRequest(departments)) + .body(new UserDepartmentsSubscribeRequest(departments)) .when().post("/api/v2/users/subscriptions/departments") .then().log().all() .extract(); @@ -111,7 +111,7 @@ public class UserStep { .given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .header("User-Token", token) - .body(new SaveFeedbackRequest(feedback)) + .body(new UserFeedbackRequest(feedback)) .when().post("/api/v2/users/feedbacks") .then().log().all() .extract(); @@ -138,7 +138,7 @@ public class UserStep { .given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .header("User-Token", token) - .body(new SaveBookmarkRequest(articleId)) + .body(new UserBookmarkRequest(articleId)) .when().post("/api/v2/users/bookmarks") .then().log().all() .extract(); diff --git a/src/test/java/com/kustacks/kuring/archunit/Adapters.java b/src/test/java/com/kustacks/kuring/archunit/Adapters.java new file mode 100644 index 00000000..b1bfbf9a --- /dev/null +++ b/src/test/java/com/kustacks/kuring/archunit/Adapters.java @@ -0,0 +1,62 @@ +package com.kustacks.kuring.archunit; + +import com.tngtech.archunit.core.domain.JavaClasses; + +import java.util.ArrayList; +import java.util.List; + +public class Adapters extends ArchitectureElement { + + private final HexagonalArchitecture parentContext; + private List incomingAdapterPackages = new ArrayList<>(); + private List outgoingAdapterPackages = new ArrayList<>(); + + Adapters(HexagonalArchitecture parentContext, String basePackage) { + super(basePackage); + this.parentContext = parentContext; + } + + public Adapters outgoing(String packageName) { + this.incomingAdapterPackages.add(fullQualifiedPackage(packageName)); + return this; + } + + public Adapters incoming(String packageName) { + this.outgoingAdapterPackages.add(fullQualifiedPackage(packageName)); + return this; + } + + List allAdapterPackages() { + List allAdapters = new ArrayList<>(); + allAdapters.addAll(incomingAdapterPackages); + allAdapters.addAll(outgoingAdapterPackages); + return allAdapters; + } + + public HexagonalArchitecture and() { + return parentContext; + } + + String getBasePackage() { + return basePackage; + } + + void dontDependOnEachOther(JavaClasses classes) { + List allAdapters = allAdapterPackages(); + for (String adapter1 : allAdapters) { + for (String adapter2 : allAdapters) { + if (!adapter1.equals(adapter2)) { + denyDependency(adapter1, adapter2, classes); + } + } + } + } + + void doesNotDependOn(String packageName, JavaClasses classes) { + denyDependency(this.basePackage, packageName, classes); + } + + void doesNotContainEmptyPackages() { + denyEmptyPackages(allAdapterPackages()); + } +} diff --git a/src/test/java/com/kustacks/kuring/archunit/ApplicationLayer.java b/src/test/java/com/kustacks/kuring/archunit/ApplicationLayer.java new file mode 100644 index 00000000..a7b89162 --- /dev/null +++ b/src/test/java/com/kustacks/kuring/archunit/ApplicationLayer.java @@ -0,0 +1,59 @@ +package com.kustacks.kuring.archunit; + +import com.tngtech.archunit.core.domain.JavaClasses; + +import java.util.ArrayList; +import java.util.List; + +public class ApplicationLayer extends ArchitectureElement { + + private final HexagonalArchitecture parentContext; + private List incomingPortsPackages = new ArrayList<>(); + private List outgoingPortsPackages = new ArrayList<>(); + private List servicePackages = new ArrayList<>(); + + public ApplicationLayer(String basePackage, HexagonalArchitecture parentContext) { + super(basePackage); + this.parentContext = parentContext; + } + + public ApplicationLayer incomingPorts(String packageName) { + this.incomingPortsPackages.add(fullQualifiedPackage(packageName)); + return this; + } + + public ApplicationLayer outgoingPorts(String packageName) { + this.outgoingPortsPackages.add(fullQualifiedPackage(packageName)); + return this; + } + + public ApplicationLayer services(String packageName) { + this.servicePackages.add(fullQualifiedPackage(packageName)); + return this; + } + + public HexagonalArchitecture and() { + return parentContext; + } + + public void doesNotDependOn(String packageName, JavaClasses classes) { + denyDependency(this.basePackage, packageName, classes); + } + + public void incomingAndOutgoingPortsDoNotDependOnEachOther(JavaClasses classes) { + denyAnyDependency(this.incomingPortsPackages, this.outgoingPortsPackages, classes); + denyAnyDependency(this.outgoingPortsPackages, this.incomingPortsPackages, classes); + } + + private List allPackages() { + List allPackages = new ArrayList<>(); + allPackages.addAll(incomingPortsPackages); + allPackages.addAll(outgoingPortsPackages); + allPackages.addAll(servicePackages); + return allPackages; + } + + void doesNotContainEmptyPackages() { + denyEmptyPackages(allPackages()); + } +} diff --git a/src/test/java/com/kustacks/kuring/archunit/ArchitectureElement.java b/src/test/java/com/kustacks/kuring/archunit/ArchitectureElement.java new file mode 100644 index 00000000..cee85c44 --- /dev/null +++ b/src/test/java/com/kustacks/kuring/archunit/ArchitectureElement.java @@ -0,0 +1,71 @@ +package com.kustacks.kuring.archunit; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; + +import java.util.List; + +import static com.tngtech.archunit.base.DescribedPredicate.greaterThanOrEqualTo; +import static com.tngtech.archunit.lang.conditions.ArchConditions.containNumberOfElements; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +abstract class ArchitectureElement { + + final String basePackage; + + public ArchitectureElement(String basePackage) { + this.basePackage = basePackage; + } + + String fullQualifiedPackage(String relativePackage) { + return this.basePackage + "." + relativePackage; + } + + static void denyDependency(String fromPackageName, String toPackageName, JavaClasses classes) { + noClasses() + .that() + .resideInAPackage("com.kustacks.kuring.*.domain..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("com.kustacks.kuring.*.application..") + .check(classes); + } + + static void denyAnyDependency( + List fromPackages, List toPackages, JavaClasses classes) { + for (String fromPackage : fromPackages) { + for (String toPackage : toPackages) { + noClasses() + .that() + .resideInAPackage(matchAllClassesInPackage(fromPackage)) + .should() + .dependOnClassesThat() + .resideInAnyPackage(matchAllClassesInPackage(toPackage)) + .check(classes); + } + } + } + + static String matchAllClassesInPackage(String packageName) { + return packageName + ".."; + } + + void denyEmptyPackage(String packageName) { + classes() + .that() + .resideInAPackage(matchAllClassesInPackage(packageName)) + .should(containNumberOfElements(greaterThanOrEqualTo(1))) + .check(classesInPackage(packageName)); + } + + private JavaClasses classesInPackage(String packageName) { + return new ClassFileImporter().importPackages(packageName); + } + + void denyEmptyPackages(List packages) { + for (String packageName : packages) { + denyEmptyPackage(packageName); + } + } +} diff --git a/src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java b/src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java new file mode 100644 index 00000000..4d221eff --- /dev/null +++ b/src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java @@ -0,0 +1,118 @@ +package com.kustacks.kuring.archunit; + +import com.tngtech.archunit.core.importer.ClassFileImporter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +@DisplayName("헥사고갈 아키텍처 검증") +class DependencyRuleTests { + + @DisplayName("User 아키텍처 검증") + @Test + void validateUserArchitecture() { + HexagonalArchitecture.boundedContext("com.kustacks.kuring.user") + + .withDomainLayer("domain") + + .withAdaptersLayer("adapter") + .incoming("in.web") + .outgoing("out.persistence") + .outgoing("out.event") + .and() + + .withApplicationLayer("application") + .services("service") + .incomingPorts("port.in") + .outgoingPorts("port.out") + .and() + + .withConfiguration("configuration") + .check(new ClassFileImporter() + .importPackages("com.kustacks.kuring.user..")); + } + + @DisplayName("Notice 아키텍처 검증") + @Test + void validateNoticeArchitecture() { + HexagonalArchitecture.boundedContext("com.kustacks.kuring.notice") + + .withDomainLayer("domain") + + .withAdaptersLayer("adapter") + .incoming("in.web") + .outgoing("out.persistence") + .and() + + .withApplicationLayer("application") + .services("service") + .incomingPorts("port.in") + .outgoingPorts("port.out") + .and() + + .withConfiguration("configuration") + .check(new ClassFileImporter() + .importPackages("com.kustacks.kuring.notice..")); + } + + @DisplayName("Admin 아키텍처 검증") + @Test + void validateAdminArchitecture() { + HexagonalArchitecture.boundedContext("com.kustacks.kuring.admin") + + .withDomainLayer("domain") + + .withAdaptersLayer("adapter") + .incoming("in.web") + .outgoing("out.persistence") + .outgoing("out.event") + .and() + + .withApplicationLayer("application") + .services("service") + .incomingPorts("port.in") + .outgoingPorts("port.out") + .and() + + .withConfiguration("configuration") + .check(new ClassFileImporter() + .importPackages("com.kustacks.kuring.admin..")); + } + + @DisplayName("Staff 아키텍처 검증") + @Test + void validateStaffArchitecture() { + HexagonalArchitecture.boundedContext("com.kustacks.kuring.staff") + + .withDomainLayer("domain") + + .withAdaptersLayer("adapter") + .incoming("in.web") + .outgoing("out.persistence") + .and() + + .withApplicationLayer("application") + .services("service") + .incomingPorts("port.in") + .outgoingPorts("port.out") + .and() + + .withConfiguration("configuration") + .check(new ClassFileImporter() + .importPackages("com.kustacks.kuring.staff..")); + } + + @DisplayName("테스트 페키지 의존성 검증") + @Test + void testPackageDependencies() { + noClasses() + .that() + .resideInAPackage("com.kustacks.kuring.user.domain..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("com.kustacks.kuring.user.application..") + .check(new ClassFileImporter() + .importPackages("com.kustacks.kuring.user..")); + } +} diff --git a/src/test/java/com/kustacks/kuring/archunit/HexagonalArchitecture.java b/src/test/java/com/kustacks/kuring/archunit/HexagonalArchitecture.java new file mode 100644 index 00000000..fdb9a78e --- /dev/null +++ b/src/test/java/com/kustacks/kuring/archunit/HexagonalArchitecture.java @@ -0,0 +1,61 @@ +package com.kustacks.kuring.archunit; + +import com.tngtech.archunit.core.domain.JavaClasses; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class HexagonalArchitecture extends ArchitectureElement { + + private Adapters adapters; + private ApplicationLayer applicationLayer; + private String configurationPackage; + private List domainPackages = new ArrayList<>(); + + public static HexagonalArchitecture boundedContext(String basePackage) { + return new HexagonalArchitecture(basePackage); + } + + public HexagonalArchitecture(String basePackage) { + super(basePackage); + } + + public Adapters withAdaptersLayer(String adaptersPackage) { + this.adapters = new Adapters(this, fullQualifiedPackage(adaptersPackage)); + return this.adapters; + } + + public HexagonalArchitecture withDomainLayer(String domainPackage) { + this.domainPackages.add(fullQualifiedPackage(domainPackage)); + return this; + } + + public ApplicationLayer withApplicationLayer(String applicationPackage) { + this.applicationLayer = new ApplicationLayer(fullQualifiedPackage(applicationPackage), this); + return this.applicationLayer; + } + + public HexagonalArchitecture withConfiguration(String packageName) { + this.configurationPackage = fullQualifiedPackage(packageName); + return this; + } + + private void domainDoesNotDependOnOtherPackages(JavaClasses classes) { + denyAnyDependency( + this.domainPackages, Collections.singletonList(adapters.basePackage), classes); + denyAnyDependency( + this.domainPackages, Collections.singletonList(applicationLayer.basePackage), classes); + } + + public void check(JavaClasses classes) { + this.adapters.doesNotContainEmptyPackages(); + this.adapters.dontDependOnEachOther(classes); + this.adapters.doesNotDependOn(this.configurationPackage, classes); + this.applicationLayer.doesNotContainEmptyPackages(); + this.applicationLayer.doesNotDependOn(this.adapters.getBasePackage(), classes); + this.applicationLayer.doesNotDependOn(this.configurationPackage, classes); + this.applicationLayer.incomingAndOutgoingPortsDoNotDependOnEachOther(classes); + this.domainDoesNotDependOnOtherPackages(classes); + } +} diff --git a/src/test/java/com/kustacks/kuring/message/adapter/in/event/MessageAdminEventListenerTest.java b/src/test/java/com/kustacks/kuring/message/adapter/in/event/MessageAdminEventListenerTest.java new file mode 100644 index 00000000..61ef162e --- /dev/null +++ b/src/test/java/com/kustacks/kuring/message/adapter/in/event/MessageAdminEventListenerTest.java @@ -0,0 +1,71 @@ +package com.kustacks.kuring.message.adapter.in.event; + +import com.kustacks.kuring.admin.application.port.in.dto.RealNotificationCommand; +import com.kustacks.kuring.admin.application.port.in.dto.TestNotificationCommand; +import com.kustacks.kuring.admin.application.service.AdminCommandService; +import com.kustacks.kuring.auth.context.Authentication; +import com.kustacks.kuring.message.application.port.in.dto.AdminNotificationCommand; +import com.kustacks.kuring.message.application.port.in.dto.AdminTestNotificationCommand; +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.util.List; + +import static com.kustacks.kuring.admin.domain.AdminRole.ROLE_ROOT; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; + +@DisplayName("[단위] 어드민 이벤트 리스너 테스트") +class MessageAdminEventListenerTest extends IntegrationTestSupport { + + @Autowired + private AdminCommandService adminCommandService; + + @MockBean + private MessageAdminEventListener messageAdminEventListener; + + @DisplayName("어드민이 커스텀으로 생성한 알림을 전송할 수 있다") + @Test + void sendNotificationEvent() { + // given + doNothing().when(firebaseNotificationService).sendNotificationByAdmin(any(AdminNotificationCommand.class)); + + RealNotificationCommand command = new RealNotificationCommand( + "test title", + "test body", + "test url", + ADMIN_PASSWORD, + new Authentication(ADMIN_LOGIN_ID, List.of(ROLE_ROOT.name())) + ); + + // when + adminCommandService.createRealNoticeForAllUser(command); + + // then + Mockito.verify(messageAdminEventListener).sendNotificationEvent(any()); + } + + @DisplayName("어드민이 커스텀으로 생성한 테스트 알림을 전송할 수 있다") + @Test + void sendTestNotificationEvent() { + // given + doNothing().when(firebaseNotificationService).sendTestNotificationByAdmin(any(AdminTestNotificationCommand.class)); + + TestNotificationCommand command = new TestNotificationCommand( + CategoryName.STUDENT.getName(), + "test body", + "test url" + ); + + // when + adminCommandService.createTestNotice(command); + + // then + Mockito.verify(messageAdminEventListener).sendTestNotificationEvent(any()); + } +} diff --git a/src/test/java/com/kustacks/kuring/message/adapter/in/event/MessageUserEventListenerTest.java b/src/test/java/com/kustacks/kuring/message/adapter/in/event/MessageUserEventListenerTest.java new file mode 100644 index 00000000..410ab7be --- /dev/null +++ b/src/test/java/com/kustacks/kuring/message/adapter/in/event/MessageUserEventListenerTest.java @@ -0,0 +1,56 @@ +package com.kustacks.kuring.message.adapter.in.event; + +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.support.IntegrationTestSupport; +import com.kustacks.kuring.user.application.port.in.UserCommandUseCase; +import com.kustacks.kuring.user.application.port.in.dto.UserCategoriesSubscribeCommand; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; + +@DisplayName("[단위] 사용자 이벤트 리스너 테스트") +class MessageUserEventListenerTest extends IntegrationTestSupport { + + @Autowired + private UserCommandUseCase userCommandService; + + @MockBean + private MessageUserEventListener messageUserEventListener; + + @DisplayName("사용자는 알림을 받고싶은 공지의 카테고리를 구독할 수 있다") + @Test + void subscribeEvent() { + // given + doNothing().when(firebaseSubscribeService).subscribe(any()); + UserCategoriesSubscribeCommand command = + new UserCategoriesSubscribeCommand(USER_FCM_TOKEN, List.of(CategoryName.NORMAL.getName())); + + // when + userCommandService.editSubscribeCategories(command); + + // then + Mockito.verify(messageUserEventListener).subscribeEvent(any()); + } + + @DisplayName("사용자는 알림을 받고싶은 공지의 카테고리를 구독 취소 수 있다") + @Test + void unsubscribeEvent() { + // given + doNothing().when(firebaseSubscribeService).unsubscribe(any()); + UserCategoriesSubscribeCommand command = + new UserCategoriesSubscribeCommand(USER_FCM_TOKEN, List.of(CategoryName.BACHELOR.getName())); + + // when + userCommandService.editSubscribeCategories(command); + + // then + Mockito.verify(messageUserEventListener).unSubscribeEvent(any()); + } +} diff --git a/src/test/java/com/kustacks/kuring/notice/repository/NoticeRepositoryTest.java b/src/test/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeRepositoryTest.java similarity index 67% rename from src/test/java/com/kustacks/kuring/notice/repository/NoticeRepositoryTest.java rename to src/test/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeRepositoryTest.java index cd3cc008..f86db1ff 100644 --- a/src/test/java/com/kustacks/kuring/notice/repository/NoticeRepositoryTest.java +++ b/src/test/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticeRepositoryTest.java @@ -1,10 +1,15 @@ -package com.kustacks.kuring.notice.repository; +package com.kustacks.kuring.notice.adapter.out.persistence; -import com.kustacks.kuring.notice.domain.*; +import com.kustacks.kuring.notice.application.port.out.NoticeCommandPort; +import com.kustacks.kuring.notice.application.port.out.NoticeQueryPort; +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.notice.domain.DepartmentName; +import com.kustacks.kuring.notice.domain.DepartmentNotice; +import com.kustacks.kuring.notice.domain.Notice; import com.kustacks.kuring.support.IntegrationTestSupport; -import com.kustacks.kuring.user.common.dto.BookmarkDto; +import com.kustacks.kuring.user.adapter.out.persistence.UserPersistenceAdapter; +import com.kustacks.kuring.user.application.port.out.dto.BookmarkDto; import com.kustacks.kuring.user.domain.User; -import com.kustacks.kuring.user.domain.UserRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -17,13 +22,13 @@ class NoticeRepositoryTest extends IntegrationTestSupport { @Autowired - private NoticeRepository noticeRepository; + private NoticeQueryPort noticeQueryPort; @Autowired - private NoticeJdbcRepository noticeJdbcRepository; + private NoticeCommandPort noticeCommandPort; @Autowired - private UserRepository userRepository; + private UserPersistenceAdapter userPersistenceAdapter; @DisplayName("사용자가 북마크해둔 공지의 ID로 해당 공지들을 찾아올 수 있다") @Test @@ -33,13 +38,13 @@ void lookupAllNoticeByIds() { "notice1", CategoryName.BACHELOR, false, "https://www.example.com"); Notice notice2 = new Notice("2", "2024-01-20", "updatedDate", "notice2", CategoryName.BACHELOR, false, "https://www.example.com"); - noticeJdbcRepository.saveAllCategoryNotices(List.of(notice1, notice2)); + noticeCommandPort.saveAllCategoryNotices(List.of(notice1, notice2)); DepartmentNotice departmentNotice1 = new DepartmentNotice("3", "2024-01-22", "updatedDate", "departmentNotice1", CategoryName.DEPARTMENT, false, "https://www.example.com", DepartmentName.ADMINISTRATION); DepartmentNotice departmentNotice2 = new DepartmentNotice("4", "2024-01-24", "updatedDate", "departmentNotice2", CategoryName.DEPARTMENT, false, "https://www.example.com", DepartmentName.ADMINISTRATION); - noticeJdbcRepository.saveAllDepartmentNotices(List.of(departmentNotice1, departmentNotice2)); + noticeCommandPort.saveAllDepartmentNotices(List.of(departmentNotice1, departmentNotice2)); User user = new User("user_token"); user.addBookmark(notice1.getArticleId()); @@ -47,11 +52,11 @@ void lookupAllNoticeByIds() { user.addBookmark(departmentNotice1.getArticleId()); user.addBookmark(departmentNotice2.getArticleId()); - User savedUser = userRepository.save(user); + User savedUser = userPersistenceAdapter.save(user); List ids = savedUser.lookupAllBookmarkIds(); // when - List bookmarks = noticeRepository.findAllByBookmarkIds(ids); + List bookmarks = noticeQueryPort.findAllByBookmarkIds(ids); // then assertThat(bookmarks).hasSize(4) diff --git a/src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java b/src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java index fff021d5..c0e666f2 100644 --- a/src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java +++ b/src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java @@ -1,13 +1,17 @@ package com.kustacks.kuring.support; import com.kustacks.kuring.admin.domain.Admin; -import com.kustacks.kuring.admin.domain.AdminRepository; +import com.kustacks.kuring.admin.adapter.out.persistence.AdminRepository; import com.kustacks.kuring.admin.domain.AdminRole; -import com.kustacks.kuring.notice.domain.*; +import com.kustacks.kuring.notice.adapter.out.persistence.NoticePersistenceAdapter; +import com.kustacks.kuring.notice.domain.CategoryName; +import com.kustacks.kuring.notice.domain.DepartmentName; +import com.kustacks.kuring.notice.domain.DepartmentNotice; +import com.kustacks.kuring.notice.domain.Notice; import com.kustacks.kuring.staff.domain.Staff; -import com.kustacks.kuring.staff.domain.StaffRepository; +import com.kustacks.kuring.staff.adapter.out.persistence.StaffRepository; +import com.kustacks.kuring.user.adapter.out.persistence.UserPersistenceAdapter; import com.kustacks.kuring.user.domain.User; -import com.kustacks.kuring.user.domain.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; @@ -34,8 +38,8 @@ public class DatabaseConfigurator implements InitializingBean { protected static final String ADMIN_CLIENT_LOGIN_ID = "client@email.com"; protected static final String ADMIN_CLIENT_PASSWORD = "client_password"; - private final NoticeRepository noticeRepository; - private final UserRepository userRepository; + private final NoticePersistenceAdapter noticePersistenceAdapter; + private final UserPersistenceAdapter userPersistenceAdapter; private final StaffRepository staffRepository; private final AdminRepository adminRepository; private final DataSource dataSource; @@ -44,11 +48,11 @@ public class DatabaseConfigurator implements InitializingBean { private List tableNames; - public DatabaseConfigurator(NoticeRepository noticeRepository, UserRepository userRepository, + public DatabaseConfigurator(NoticePersistenceAdapter noticePersistenceAdapter, UserPersistenceAdapter userPersistenceAdapter, StaffRepository staffRepository, AdminRepository adminRepository, DataSource dataSource, JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { - this.noticeRepository = noticeRepository; - this.userRepository = userRepository; + this.noticePersistenceAdapter = noticePersistenceAdapter; + this.userPersistenceAdapter = userPersistenceAdapter; this.staffRepository = staffRepository; this.adminRepository = adminRepository; this.dataSource = dataSource; @@ -86,6 +90,7 @@ public void loadData() { initAdmin(); initUser(); + initUserCategory(); initFeedback(); initStaff(); initNotice(); @@ -133,11 +138,17 @@ private void initAdmin() { private void initUser() { User newUser = new User(USER_FCM_TOKEN); - userRepository.save(newUser); + userPersistenceAdapter.save(newUser); + } + + private void initUserCategory() { + User findUser = userPersistenceAdapter.findByToken(USER_FCM_TOKEN).get(); + findUser.subscribeCategory(CategoryName.STUDENT); + findUser.subscribeCategory(CategoryName.BACHELOR); } private void initFeedback() { - User findUser = userRepository.findByToken(USER_FCM_TOKEN).get(); + User findUser = userPersistenceAdapter.findByToken(USER_FCM_TOKEN).get(); findUser.addFeedback("test feedback 1"); findUser.addFeedback("test feedback 2"); findUser.addFeedback("test feedback 3"); @@ -147,13 +158,13 @@ private void initFeedback() { private void initNotice() { List noticeList = buildNotices(5, CategoryName.STUDENT); - noticeRepository.saveAll(noticeList); + noticePersistenceAdapter.saveAllCategoryNotices(noticeList); List importantDeptNotices = buildImportantDepartmentNotice(7, DepartmentName.COMPUTER, CategoryName.DEPARTMENT, true); - noticeRepository.saveAll(importantDeptNotices); + noticePersistenceAdapter.saveAllDepartmentNotices(importantDeptNotices); List normalDeptNotices = buildNormalDepartmentNotice(5, DepartmentName.COMPUTER, CategoryName.DEPARTMENT, false); - noticeRepository.saveAll(normalDeptNotices); + noticePersistenceAdapter.saveAllDepartmentNotices(normalDeptNotices); } private void initStaff() { diff --git a/src/test/java/com/kustacks/kuring/support/IntegrationTestSupport.java b/src/test/java/com/kustacks/kuring/support/IntegrationTestSupport.java index d81cdbc5..8d700593 100644 --- a/src/test/java/com/kustacks/kuring/support/IntegrationTestSupport.java +++ b/src/test/java/com/kustacks/kuring/support/IntegrationTestSupport.java @@ -1,7 +1,8 @@ package com.kustacks.kuring.support; -import com.kustacks.kuring.message.firebase.FirebaseService; +import com.kustacks.kuring.message.application.service.FirebaseNotificationService; +import com.kustacks.kuring.message.application.service.FirebaseSubscribeService; import io.restassured.RestAssured; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; @@ -20,7 +21,10 @@ public class IntegrationTestSupport { public static final String ADMIN_CLIENT_PASSWORD = "client_password"; @MockBean - protected FirebaseService firebaseService; + protected FirebaseSubscribeService firebaseSubscribeService; + + @MockBean + protected FirebaseNotificationService firebaseNotificationService; @LocalServerPort int port; diff --git a/src/test/java/com/kustacks/kuring/user/repository/UserRepositoryTest.java b/src/test/java/com/kustacks/kuring/user/adapter/out/persistence/UserRepositoryTest.java similarity index 73% rename from src/test/java/com/kustacks/kuring/user/repository/UserRepositoryTest.java rename to src/test/java/com/kustacks/kuring/user/adapter/out/persistence/UserRepositoryTest.java index dbf15885..f7306dac 100644 --- a/src/test/java/com/kustacks/kuring/user/repository/UserRepositoryTest.java +++ b/src/test/java/com/kustacks/kuring/user/adapter/out/persistence/UserRepositoryTest.java @@ -1,9 +1,9 @@ -package com.kustacks.kuring.user.repository; +package com.kustacks.kuring.user.adapter.out.persistence; +import com.kustacks.kuring.user.application.port.out.dto.FeedbackDto; import com.kustacks.kuring.support.IntegrationTestSupport; -import com.kustacks.kuring.admin.common.dto.FeedbackDto; +import com.kustacks.kuring.user.adapter.out.persistence.UserPersistenceAdapter; import com.kustacks.kuring.user.domain.User; -import com.kustacks.kuring.user.domain.UserRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -18,7 +18,7 @@ class UserRepositoryTest extends IntegrationTestSupport { @Autowired - private UserRepository userRepository; + private UserPersistenceAdapter userPersistenceAdapter; @DisplayName("사용자가 작성한 피드백을 페이징 처리하여 가져올 수 있다") @Test @@ -29,11 +29,11 @@ void findAllFeedbackByPageRequest() { user.addFeedback("content2"); user.addFeedback("content3"); - User savedUser = userRepository.save(user); + User savedUser = userPersistenceAdapter.save(user); Long userId = savedUser.getId(); // when - List feedbackDtos = userRepository.findAllFeedbackByPageRequest(PageRequest.of(0, 3)); + List feedbackDtos = userPersistenceAdapter.findAllFeedbackByPageRequest(PageRequest.of(0, 3)); // then assertThat(feedbackDtos).hasSize(3) diff --git a/src/test/java/com/kustacks/kuring/worker/client/staff/StaffScraperTest.java b/src/test/java/com/kustacks/kuring/worker/client/staff/StaffScraperTest.java index c10a763e..79dd7a19 100644 --- a/src/test/java/com/kustacks/kuring/worker/client/staff/StaffScraperTest.java +++ b/src/test/java/com/kustacks/kuring/worker/client/staff/StaffScraperTest.java @@ -1,7 +1,7 @@ package com.kustacks.kuring.worker.client.staff; import com.fasterxml.jackson.databind.ObjectMapper; -import com.kustacks.kuring.common.dto.StaffDto; +import com.kustacks.kuring.worker.update.staff.dto.StaffDto; import com.kustacks.kuring.common.exception.code.ErrorCode; import com.kustacks.kuring.common.exception.InternalLogicException; import com.kustacks.kuring.worker.scrap.client.notice.LatestPageNoticeApiClient; diff --git a/src/test/java/com/kustacks/kuring/worker/update/CategoryNoticeUpdaterTest.java b/src/test/java/com/kustacks/kuring/worker/update/CategoryNoticeUpdaterTest.java index 91381a5d..27c28b14 100644 --- a/src/test/java/com/kustacks/kuring/worker/update/CategoryNoticeUpdaterTest.java +++ b/src/test/java/com/kustacks/kuring/worker/update/CategoryNoticeUpdaterTest.java @@ -1,13 +1,11 @@ package com.kustacks.kuring.worker.update; -import com.kustacks.kuring.message.firebase.FirebaseService; -import com.kustacks.kuring.notice.domain.Notice; -import com.kustacks.kuring.notice.domain.NoticeRepository; +import com.kustacks.kuring.message.application.service.FirebaseNotificationService; +import com.kustacks.kuring.notice.application.port.out.NoticeQueryPort; import com.kustacks.kuring.worker.scrap.KuisNoticeScraperTemplate; import com.kustacks.kuring.worker.scrap.client.notice.LibraryNoticeApiClient; import com.kustacks.kuring.worker.update.notice.CategoryNoticeUpdater; import com.kustacks.kuring.worker.update.notice.dto.response.CommonNoticeFormatDto; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -19,7 +17,7 @@ import java.util.List; import java.util.concurrent.TimeUnit; -import static org.junit.jupiter.api.Assertions.assertAll; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.doNothing; @@ -37,7 +35,7 @@ class CategoryNoticeUpdaterTest { KuisNoticeScraperTemplate scrapperTemplate; @MockBean - FirebaseService firebaseService; + FirebaseNotificationService firebaseService; @MockBean LibraryNoticeApiClient libraryNoticeApiClient; @@ -49,7 +47,7 @@ class CategoryNoticeUpdaterTest { ThreadPoolTaskExecutor noticeUpdaterThreadTaskExecutor; @Autowired - NoticeRepository noticeRepository; + NoticeQueryPort noticeQueryPort; @DisplayName("공지 업데이트 테스트") @Test @@ -64,17 +62,8 @@ void notice_scrap_async_test() throws InterruptedException { noticeUpdaterThreadTaskExecutor.getThreadPoolExecutor().awaitTermination(1, TimeUnit.SECONDS); // then - List notices = noticeRepository.findAll(); - assertAll( - () -> Assertions.assertThat(notices).filteredOn("categoryName", "library").size().isEqualTo(7), - () -> Assertions.assertThat(notices).filteredOn("categoryName", "bachelor").size().isEqualTo(9), - () -> Assertions.assertThat(notices).filteredOn("categoryName", "scholarship").size().isEqualTo(9), - () -> Assertions.assertThat(notices).filteredOn("categoryName", "employment").size().isEqualTo(9), - () -> Assertions.assertThat(notices).filteredOn("categoryName", "national").size().isEqualTo(9), - () -> Assertions.assertThat(notices).filteredOn("categoryName", "student").size().isEqualTo(9), - () -> Assertions.assertThat(notices).filteredOn("categoryName", "industry_university").size().isEqualTo(9), - () -> Assertions.assertThat(notices).filteredOn("categoryName", "normal").size().isEqualTo(9) - ); + Long count = noticeQueryPort.count(); + assertThat(count).isEqualTo(70); } private static List createLibraryFixture() { diff --git a/src/test/java/com/kustacks/kuring/worker/update/DepartmentNoticeUpdaterTest.java b/src/test/java/com/kustacks/kuring/worker/update/DepartmentNoticeUpdaterTest.java index 9545d1c2..20ce2ef0 100644 --- a/src/test/java/com/kustacks/kuring/worker/update/DepartmentNoticeUpdaterTest.java +++ b/src/test/java/com/kustacks/kuring/worker/update/DepartmentNoticeUpdaterTest.java @@ -1,9 +1,7 @@ package com.kustacks.kuring.worker.update; -import com.kustacks.kuring.message.firebase.FirebaseService; -import com.kustacks.kuring.notice.domain.DepartmentNotice; -import com.kustacks.kuring.notice.domain.Notice; -import com.kustacks.kuring.notice.domain.NoticeRepository; +import com.kustacks.kuring.message.application.service.FirebaseNotificationService; +import com.kustacks.kuring.notice.application.port.out.NoticeQueryPort; import com.kustacks.kuring.worker.scrap.DepartmentNoticeScraperTemplate; import com.kustacks.kuring.worker.scrap.dto.ComplexNoticeFormatDto; import com.kustacks.kuring.worker.update.notice.DepartmentNoticeUpdater; @@ -21,7 +19,6 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.doNothing; @@ -39,7 +36,7 @@ class DepartmentNoticeUpdaterTest { DepartmentNoticeScraperTemplate scrapperTemplate; @MockBean - FirebaseService firebaseService; + FirebaseNotificationService firebaseService; @Autowired DepartmentNoticeUpdater departmentNoticeUpdater; @@ -48,7 +45,7 @@ class DepartmentNoticeUpdaterTest { ThreadPoolTaskExecutor noticeUpdaterThreadTaskExecutor; @Autowired - NoticeRepository noticeRepository; + NoticeQueryPort noticeQueryPort; @DisplayName("학과별 공지 업데이트 테스트") @Test @@ -62,11 +59,8 @@ void department_scrap_async_test() throws InterruptedException { noticeUpdaterThreadTaskExecutor.getThreadPoolExecutor().awaitTermination(2, TimeUnit.SECONDS); // then - List notices = noticeRepository.findAll(); - assertAll( - () -> assertThat(notices).hasSize(3720), - () -> assertThat(notices.get(0)).isExactlyInstanceOf(DepartmentNotice.class) - ); + Long count = noticeQueryPort.count(); + assertThat(count).isEqualTo(3720); } private static List createDepartmentNoticesFixture() {