diff --git a/src/main/java/redot/redot_server/domain/admin/controller/AdminDashboardController.java b/src/main/java/redot/redot_server/domain/admin/controller/AdminDashboardController.java new file mode 100644 index 0000000..56e8fb9 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/controller/AdminDashboardController.java @@ -0,0 +1,24 @@ +package redot.redot_server.domain.admin.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import redot.redot_server.domain.admin.controller.docs.AdminDashboardControllerDocs; +import redot.redot_server.domain.admin.dto.response.AdminDashboardStatsResponse; +import redot.redot_server.domain.admin.service.AdminDashboardService; + +@RestController +@RequestMapping("/api/v1/redot/admin/dashboard") +@RequiredArgsConstructor +public class AdminDashboardController implements AdminDashboardControllerDocs { + + private final AdminDashboardService adminDashboardService; + + @GetMapping("/stats") + @Override + public ResponseEntity getDashboardStats() { + return ResponseEntity.ok(adminDashboardService.getDashboardStats()); + } +} diff --git a/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java b/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java index 42dca48..084ea20 100644 --- a/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java +++ b/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java @@ -2,15 +2,24 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import redot.redot_server.domain.admin.controller.docs.AdminRedotAppControllerDocs; +import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.admin.dto.request.RedotAppStatusUpdateRequest; import redot.redot_server.domain.admin.service.AdminRedotAppService; import redot.redot_server.domain.redot.app.dto.request.RedotAppCreateRequest; import redot.redot_server.domain.redot.app.dto.response.RedotAppInfoResponse; +import redot.redot_server.global.util.dto.response.PageResponse; @RestController @RequestMapping("/api/v1/redot/admin/app") @@ -24,4 +33,27 @@ public class AdminRedotAppController implements AdminRedotAppControllerDocs { public ResponseEntity createRedotApp(@Valid @RequestBody RedotAppCreateRequest request) { return ResponseEntity.ok(redotAppService.createRedotApp(request)); } + + @GetMapping + @Override + public ResponseEntity> getRedotAppInfoList( + @ParameterObject RedotAppInfoSearchCondition searchCondition, + @ParameterObject @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + return ResponseEntity.ok(redotAppService.getRedotAppInfoList(searchCondition, pageable)); + } + + @GetMapping("/{redotAppId}") + @Override + public ResponseEntity getRedotAppInfo( + @PathVariable("redotAppId") Long redotAppId) { + return ResponseEntity.ok(redotAppService.getRedotAppInfo(redotAppId)); + } + + @PostMapping("/{redotAppId}/status") + @Override + public ResponseEntity updateRedotAppStatus( + @PathVariable("redotAppId") Long redotAppId, + @Valid @RequestBody RedotAppStatusUpdateRequest request) { + return ResponseEntity.ok(redotAppService.updateRedotAppStatus(redotAppId, request)); + } } diff --git a/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminDashboardControllerDocs.java b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminDashboardControllerDocs.java new file mode 100644 index 0000000..f061972 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminDashboardControllerDocs.java @@ -0,0 +1,18 @@ +package redot.redot_server.domain.admin.controller.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import redot.redot_server.domain.admin.dto.response.AdminDashboardStatsResponse; + +@Tag(name = "Admin Dashboard", description = "관리자 대시보드 통계 API") +public interface AdminDashboardControllerDocs { + + @Operation(summary = "대시보드 통계 조회", description = "관리자 대시보드에 필요한 요약 통계를 제공합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = AdminDashboardStatsResponse.class))) + ResponseEntity getDashboardStats(); +} diff --git a/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java index 722cc4b..4187efa 100644 --- a/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java @@ -6,9 +6,14 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; +import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.admin.dto.request.RedotAppStatusUpdateRequest; import redot.redot_server.domain.redot.app.dto.request.RedotAppCreateRequest; import redot.redot_server.domain.redot.app.dto.response.RedotAppInfoResponse; +import redot.redot_server.global.util.dto.response.PageResponse; @Tag(name = "Admin Redot App", description = "관리자 앱 관리 API") public interface AdminRedotAppControllerDocs { @@ -17,4 +22,24 @@ public interface AdminRedotAppControllerDocs { @ApiResponse(responseCode = "200", description = "생성 성공", content = @Content(schema = @Schema(implementation = RedotAppInfoResponse.class))) ResponseEntity createRedotApp(@Valid RedotAppCreateRequest request); + + @Operation(summary = "앱 목록 조회", description = "status, name, redot_member_id 파라미터로 필터링하며 createdAt 기준 최신순 정렬을 기본으로 제공합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = PageResponse.class))) + ResponseEntity> getRedotAppInfoList( + @ParameterObject RedotAppInfoSearchCondition searchCondition, + @ParameterObject Pageable pageable); + + @Operation(summary = "앱 단건 조회", description = "redotAppId로 특정 앱 정보를 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = RedotAppInfoResponse.class))) + ResponseEntity getRedotAppInfo( + @io.swagger.v3.oas.annotations.Parameter(description = "Redot 앱 ID", example = "1") Long redotAppId); + + @Operation(summary = "앱 상태 변경", description = "지정한 앱의 상태 및 비고(remark)를 수정합니다.") + @ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content(schema = @Schema(implementation = RedotAppInfoResponse.class))) + ResponseEntity updateRedotAppStatus( + @io.swagger.v3.oas.annotations.Parameter(description = "Redot 앱 ID", example = "1") Long redotAppId, + @Valid RedotAppStatusUpdateRequest request); } diff --git a/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppInfoSearchCondition.java b/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppInfoSearchCondition.java new file mode 100644 index 0000000..4cd60a8 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppInfoSearchCondition.java @@ -0,0 +1,10 @@ +package redot.redot_server.domain.admin.dto.request; + +import redot.redot_server.domain.redot.app.entity.RedotAppStatus; + +public record RedotAppInfoSearchCondition( + String name, + Long redotMemberId, + RedotAppStatus status +) { +} diff --git a/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppStatusUpdateRequest.java b/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppStatusUpdateRequest.java new file mode 100644 index 0000000..a5e5cb6 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppStatusUpdateRequest.java @@ -0,0 +1,10 @@ +package redot.redot_server.domain.admin.dto.request; + +import jakarta.validation.constraints.NotNull; +import redot.redot_server.domain.redot.app.entity.RedotAppStatus; + +public record RedotAppStatusUpdateRequest( + @NotNull(message = "status 는 필수입니다.") RedotAppStatus status, + String remark +) { +} diff --git a/src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java b/src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java new file mode 100644 index 0000000..6073470 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java @@ -0,0 +1,11 @@ +package redot.redot_server.domain.admin.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminDashboardStatsResponse( + @Schema(description = "전체 Redot 회원 수") long totalRedotMembers, + @Schema(description = "전일까지 누적된 Redot 회원 수") long redotMembersUntilYesterday, + @Schema(description = "상태가 PENDING 인 상담 요청 수") long pendingConsultationCount, + @Schema(description = "등록된 관리자 수") long adminCount +) { +} diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java new file mode 100644 index 0000000..97b6b62 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java @@ -0,0 +1,45 @@ +package redot.redot_server.domain.admin.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import redot.redot_server.domain.admin.dto.response.AdminDashboardStatsResponse; +import redot.redot_server.domain.admin.repository.AdminRepository; +import redot.redot_server.domain.redot.consultation.entity.ConsultationStatus; +import redot.redot_server.domain.redot.consultation.repository.ConsultationRepository; +import redot.redot_server.domain.redot.member.repository.RedotMemberRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminDashboardService { + + private final RedotMemberRepository redotMemberRepository; + private final ConsultationRepository consultationRepository; + private final AdminRepository adminRepository; + + public AdminDashboardStatsResponse getDashboardStats() { + ZoneId kstZone = ZoneId.of("Asia/Seoul"); + LocalDateTime startOfTodayKst = LocalDate.now(kstZone).atStartOfDay(); + LocalDateTime startOfTodayUtc = startOfTodayKst.atZone(kstZone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime(); + + long totalRedotMembers = redotMemberRepository.count(); + long redotMembersUntilYesterday = redotMemberRepository.countByCreatedAtBefore(startOfTodayUtc); + + long pendingConsultationCount = consultationRepository.countByStatus(ConsultationStatus.PENDING); + long adminCount = adminRepository.count(); + + return new AdminDashboardStatsResponse( + totalRedotMembers, + redotMembersUntilYesterday, + pendingConsultationCount, + adminCount + ); + } +} diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java index 03f3398..c50ab4d 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java @@ -1,20 +1,128 @@ package redot.redot_server.domain.admin.service; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.admin.dto.request.RedotAppStatusUpdateRequest; +import redot.redot_server.domain.cms.site.setting.dto.response.SiteSettingResponse; +import redot.redot_server.domain.cms.site.style.dto.response.StyleInfoResponse; import redot.redot_server.domain.redot.app.dto.request.RedotAppCreateRequest; import redot.redot_server.domain.redot.app.dto.response.RedotAppInfoResponse; +import redot.redot_server.domain.redot.app.dto.response.RedotAppResponse; +import redot.redot_server.domain.redot.app.entity.RedotApp; +import redot.redot_server.domain.redot.app.exception.RedotAppErrorCode; +import redot.redot_server.domain.redot.app.exception.RedotAppException; +import redot.redot_server.domain.redot.app.repository.RedotAppRepository; import redot.redot_server.domain.redot.app.service.RedotAppCreationService; +import redot.redot_server.domain.redot.member.dto.response.RedotMemberResponse; +import redot.redot_server.domain.site.domain.entity.Domain; +import redot.redot_server.domain.site.domain.repository.DomainRepository; +import redot.redot_server.domain.site.setting.entity.SiteSetting; +import redot.redot_server.domain.site.setting.repository.SiteSettingRepository; +import redot.redot_server.domain.site.style.entity.StyleInfo; +import redot.redot_server.domain.site.style.repository.StyleInfoRepository; +import redot.redot_server.global.s3.util.ImageUrlResolver; +import redot.redot_server.global.util.dto.response.PageResponse; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AdminRedotAppService { private final RedotAppCreationService redotAppCreationService; + private final RedotAppRepository redotAppRepository; + private final DomainRepository domainRepository; + private final SiteSettingRepository siteSettingRepository; + private final StyleInfoRepository styleInfoRepository; + private final ImageUrlResolver imageUrlResolver; @Transactional public RedotAppInfoResponse createRedotApp(RedotAppCreateRequest request) { return redotAppCreationService.createWithoutOwner(request); } + + public PageResponse getRedotAppInfoList(RedotAppInfoSearchCondition searchCondition, + Pageable pageable) { + Page redotApps = redotAppRepository.findAllBySearchCondition(searchCondition, pageable); + + List redotAppIds = redotApps.stream() + .map(RedotApp::getId) + .toList(); + + Map domainMap = redotAppIds.isEmpty() ? Map.of() + : domainRepository.findByRedotAppIdIn(redotAppIds) + .stream() + .collect(Collectors.toMap(domain -> domain.getRedotApp().getId(), Function.identity())); + + Map styleInfoMap = redotAppIds.isEmpty() ? Map.of() + : styleInfoRepository.findByRedotApp_IdIn(redotAppIds) + .stream() + .collect(Collectors.toMap(style -> style.getRedotApp().getId(), Function.identity())); + + Map siteSettingMap = redotAppIds.isEmpty() ? Map.of() + : siteSettingRepository.findByRedotAppIdIn(redotAppIds) + .stream() + .collect(Collectors.toMap(setting -> setting.getRedotApp().getId(), Function.identity())); + + Page responsePage = redotApps.map(redotApp -> + toRedotAppInfoResponse( + redotApp, + domainMap.get(redotApp.getId()), + styleInfoMap.get(redotApp.getId()), + siteSettingMap.get(redotApp.getId()) + )); + + return PageResponse.from(responsePage); + } + + private RedotAppInfoResponse toRedotAppInfoResponse(RedotApp redotApp) { + Domain domain = domainRepository.findByRedotAppId(redotApp.getId()).orElse(null); + StyleInfo styleInfo = styleInfoRepository.findByRedotApp_Id(redotApp.getId()).orElse(null); + SiteSetting siteSetting = siteSettingRepository.findByRedotAppId(redotApp.getId()).orElse(null); + + return toRedotAppInfoResponse(redotApp, domain, styleInfo, siteSetting); + } + + private RedotAppInfoResponse toRedotAppInfoResponse(RedotApp redotApp, + Domain domain, + StyleInfo styleInfo, + SiteSetting siteSetting) { + SiteSettingResponse siteSettingResponse = (siteSetting != null && domain != null) + ? SiteSettingResponse.fromEntity(siteSetting, domain, imageUrlResolver) + : null; + + StyleInfoResponse styleInfoResponse = styleInfo != null + ? StyleInfoResponse.fromEntity(styleInfo) + : null; + + return new RedotAppInfoResponse( + RedotAppResponse.fromEntity(redotApp), + siteSettingResponse, + styleInfoResponse, + RedotMemberResponse.fromNullable(redotApp.getOwner(), imageUrlResolver) + ); + } + + public RedotAppInfoResponse getRedotAppInfo(Long redotAppId) { + RedotApp redotApp = redotAppRepository.findById(redotAppId) + .orElseThrow(() -> new RedotAppException(RedotAppErrorCode.REDOT_APP_NOT_FOUND)); + + return toRedotAppInfoResponse(redotApp); + } + + @Transactional + public RedotAppInfoResponse updateRedotAppStatus(Long redotAppId, RedotAppStatusUpdateRequest request) { + RedotApp redotApp = redotAppRepository.findById(redotAppId) + .orElseThrow(() -> new RedotAppException(RedotAppErrorCode.REDOT_APP_NOT_FOUND)); + + redotApp.updateStatus(request.status(), request.remark()); + + return toRedotAppInfoResponse(redotApp); + } } diff --git a/src/main/java/redot/redot_server/domain/auth/controller/AdminImpersonationController.java b/src/main/java/redot/redot_server/domain/auth/controller/AdminImpersonationController.java index addb18b..7cca1f5 100644 --- a/src/main/java/redot/redot_server/domain/auth/controller/AdminImpersonationController.java +++ b/src/main/java/redot/redot_server/domain/auth/controller/AdminImpersonationController.java @@ -25,10 +25,8 @@ public class AdminImpersonationController implements AdminImpersonationControlle @PostMapping("/cms-admin") @Override - public ResponseEntity impersonateAsCMSAdmin(HttpServletRequest request, @Valid @RequestBody CMSAdminImpersonationRequest cmsAdminImpersonationRequest, @AuthenticationPrincipal JwtPrincipal jwtPrincipal) { - Long adminId = jwtPrincipal.id(); - - AuthResult authResult = adminImpersonationService.impersonateAsCMSAdmin(request, cmsAdminImpersonationRequest, adminId); + public ResponseEntity impersonateAsCMSAdmin(HttpServletRequest request, @Valid @RequestBody CMSAdminImpersonationRequest cmsAdminImpersonationRequest) { + AuthResult authResult = adminImpersonationService.impersonateAsCMSAdmin(request, cmsAdminImpersonationRequest); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, authResult.accessCookie().toString()) .header(HttpHeaders.SET_COOKIE, authResult.refreshCookie().toString()) diff --git a/src/main/java/redot/redot_server/domain/auth/controller/docs/AdminImpersonationControllerDocs.java b/src/main/java/redot/redot_server/domain/auth/controller/docs/AdminImpersonationControllerDocs.java index b568040..e49d2a5 100644 --- a/src/main/java/redot/redot_server/domain/auth/controller/docs/AdminImpersonationControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/auth/controller/docs/AdminImpersonationControllerDocs.java @@ -11,7 +11,6 @@ import org.springframework.http.ResponseEntity; import redot.redot_server.domain.auth.dto.request.CMSAdminImpersonationRequest; import redot.redot_server.domain.auth.dto.response.TokenResponse; -import redot.redot_server.global.security.principal.JwtPrincipal; @Tag(name = "Admin Impersonation", description = "관리자 권한 위임 API") public interface AdminImpersonationControllerDocs { @@ -20,6 +19,5 @@ public interface AdminImpersonationControllerDocs { @ApiResponse(responseCode = "200", description = "발급 성공", content = @Content(schema = @Schema(implementation = TokenResponse.class))) ResponseEntity impersonateAsCMSAdmin(@Parameter(hidden = true) HttpServletRequest request, - @Valid CMSAdminImpersonationRequest cmsAdminImpersonationRequest, - @Parameter(hidden = true) JwtPrincipal jwtPrincipal); + @Valid CMSAdminImpersonationRequest cmsAdminImpersonationRequest); } diff --git a/src/main/java/redot/redot_server/domain/auth/service/AdminImpersonationService.java b/src/main/java/redot/redot_server/domain/auth/service/AdminImpersonationService.java index ba8e9d3..09c56c9 100644 --- a/src/main/java/redot/redot_server/domain/auth/service/AdminImpersonationService.java +++ b/src/main/java/redot/redot_server/domain/auth/service/AdminImpersonationService.java @@ -7,7 +7,11 @@ import org.springframework.transaction.annotation.Transactional; import redot.redot_server.domain.auth.dto.request.CMSAdminImpersonationRequest; import redot.redot_server.domain.auth.dto.response.AuthResult; +import redot.redot_server.domain.auth.exception.AuthErrorCode; +import redot.redot_server.domain.auth.exception.AuthException; +import redot.redot_server.domain.cms.member.entity.CMSMember; import redot.redot_server.domain.cms.member.entity.CMSMemberRole; +import redot.redot_server.domain.cms.member.repository.CMSMemberRepository; import redot.redot_server.global.jwt.token.TokenContext; import redot.redot_server.global.jwt.token.TokenType; @@ -17,10 +21,18 @@ public class AdminImpersonationService { private final AuthTokenService authTokenService; + private final CMSMemberRepository cmsMemberRepository; + + public AuthResult impersonateAsCMSAdmin(HttpServletRequest request, + CMSAdminImpersonationRequest cmsAdminImpersonationRequest) { + CMSMember owner = cmsMemberRepository + .findFirstByRedotApp_IdAndRoleOrderByIdAsc(cmsAdminImpersonationRequest.redotAppId(), + CMSMemberRole.OWNER) + .orElseThrow(() -> new AuthException(AuthErrorCode.CMS_MEMBER_NOT_FOUND)); - public AuthResult impersonateAsCMSAdmin(HttpServletRequest request, CMSAdminImpersonationRequest cmsAdminImpersonationRequest, Long adminId) { return authTokenService.issueTokens(request, - new TokenContext(adminId, TokenType.CMS, List.of(CMSMemberRole.ADMIN.name()), cmsAdminImpersonationRequest.redotAppId()) + new TokenContext(owner.getId(), TokenType.CMS, List.of(owner.getRole().name()), + cmsAdminImpersonationRequest.redotAppId()) ); } } diff --git a/src/main/java/redot/redot_server/domain/cms/member/repository/CMSMemberRepository.java b/src/main/java/redot/redot_server/domain/cms/member/repository/CMSMemberRepository.java index 9ec7af2..bb7d53d 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/repository/CMSMemberRepository.java +++ b/src/main/java/redot/redot_server/domain/cms/member/repository/CMSMemberRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import redot.redot_server.domain.cms.member.entity.CMSMember; +import redot.redot_server.domain.cms.member.entity.CMSMemberRole; public interface CMSMemberRepository extends JpaRepository, CMSMemberRepositoryCustom { Optional findByEmailAndRedotApp_Id(String email, Long redotAppId); @@ -21,4 +22,5 @@ Optional findByIdAndRedotApp_IdIncludingDeleted( @Param("id") Long id, @Param("redotAppId") Long redotAppId); + Optional findFirstByRedotApp_IdAndRoleOrderByIdAsc(Long redotAppId, CMSMemberRole role); } diff --git a/src/main/java/redot/redot_server/domain/redot/app/dto/response/RedotAppResponse.java b/src/main/java/redot/redot_server/domain/redot/app/dto/response/RedotAppResponse.java index 4476b0d..502965a 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/dto/response/RedotAppResponse.java +++ b/src/main/java/redot/redot_server/domain/redot/app/dto/response/RedotAppResponse.java @@ -10,7 +10,8 @@ public record RedotAppResponse( RedotAppStatus status, boolean isCreatedManager, LocalDateTime createdAt, - Long planId + Long planId, + String remark ) { public static RedotAppResponse fromEntity(RedotApp redotApp) { return new RedotAppResponse( @@ -19,7 +20,8 @@ public static RedotAppResponse fromEntity(RedotApp redotApp) { redotApp.getStatus(), redotApp.isCreatedManager(), redotApp.getCreatedAt(), - redotApp.getPlan().getId() + redotApp.getPlan().getId(), + redotApp.getRemark() ); } } diff --git a/src/main/java/redot/redot_server/domain/redot/app/entity/RedotApp.java b/src/main/java/redot/redot_server/domain/redot/app/entity/RedotApp.java index 4203986..e87cf77 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/entity/RedotApp.java +++ b/src/main/java/redot/redot_server/domain/redot/app/entity/RedotApp.java @@ -41,6 +41,9 @@ public class RedotApp extends BaseTimeEntity { @Column(nullable = false) private String name; + @Column(columnDefinition = "text") + private String remark; + // 초기 관리자 계정 생성 여부(default=false) @Column(nullable = false) @Builder.Default @@ -78,4 +81,9 @@ public boolean isOwner(Long redotMemberId) { public void updatePlan(Plan plan) { this.plan = plan; } + + public void updateStatus(RedotAppStatus status, String remark) { + this.status = status; + this.remark = remark; + } } diff --git a/src/main/java/redot/redot_server/domain/redot/app/entity/RedotAppStatus.java b/src/main/java/redot/redot_server/domain/redot/app/entity/RedotAppStatus.java index 29bf6cb..3f6ee0f 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/entity/RedotAppStatus.java +++ b/src/main/java/redot/redot_server/domain/redot/app/entity/RedotAppStatus.java @@ -5,5 +5,6 @@ public enum RedotAppStatus { INACTIVE, DELETED, RESIGNED, - PAYMENT_DELAYED + PAYMENT_DELAYED, + BANNED } diff --git a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepository.java b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepository.java index b2bd245..5248ac1 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepository.java +++ b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepository.java @@ -5,7 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import redot.redot_server.domain.redot.app.entity.RedotApp; -public interface RedotAppRepository extends JpaRepository { +public interface RedotAppRepository extends JpaRepository, RedotAppRepositoryCustom { Page findByOwnerId(Long ownerId, Pageable pageable); long countByOwnerId(Long ownerId); diff --git a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java new file mode 100644 index 0000000..711ad31 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java @@ -0,0 +1,18 @@ +package redot.redot_server.domain.redot.app.repository; + +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.redot.app.entity.RedotApp; + +public interface RedotAppRepositoryCustom { + + Page findAllBySearchCondition(RedotAppInfoSearchCondition searchCondition, + Pageable pageable); + + List findAllWithSiteInfo(RedotAppInfoSearchCondition searchCondition, + Pageable pageable); + + long countBySearchCondition(RedotAppInfoSearchCondition searchCondition); +} diff --git a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java new file mode 100644 index 0000000..3cb4d9f --- /dev/null +++ b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java @@ -0,0 +1,139 @@ +package redot.redot_server.domain.redot.app.repository; + +import static redot.redot_server.domain.redot.app.entity.QRedotApp.redotApp; +import static redot.redot_server.domain.site.domain.entity.QDomain.domain; +import static redot.redot_server.domain.site.setting.entity.QSiteSetting.siteSetting; +import static redot.redot_server.domain.site.style.entity.QStyleInfo.styleInfo; + +import com.querydsl.core.Tuple; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.redot.app.entity.RedotApp; +import redot.redot_server.domain.redot.app.entity.RedotAppStatus; + +@RequiredArgsConstructor +public class RedotAppRepositoryImpl implements RedotAppRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findAllBySearchCondition(RedotAppInfoSearchCondition searchCondition, + Pageable pageable) { + String name = searchCondition == null ? null : searchCondition.name(); + Long ownerId = searchCondition == null ? null : searchCondition.redotMemberId(); + RedotAppStatus status = searchCondition == null ? null : searchCondition.status(); + + BooleanExpression[] predicates = new BooleanExpression[]{ + nameContains(name), + ownerIdEq(ownerId), + statusEq(status) + }; + + List content = queryFactory + .selectFrom(redotApp) + .where(predicates) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(resolveOrder(pageable)) + .fetch(); + + long total = countByPredicates(predicates); + + return new PageImpl<>(content, pageable, total); + } + + @Override + public List findAllWithSiteInfo(RedotAppInfoSearchCondition searchCondition, + Pageable pageable) { + String name = searchCondition == null ? null : searchCondition.name(); + Long ownerId = searchCondition == null ? null : searchCondition.redotMemberId(); + RedotAppStatus status = searchCondition == null ? null : searchCondition.status(); + + BooleanExpression[] predicates = new BooleanExpression[]{ + nameContains(name), + ownerIdEq(ownerId), + statusEq(status) + }; + + List tuples = queryFactory + .select(redotApp, domain, siteSetting, styleInfo) + .from(redotApp) + .leftJoin(domain).on(domain.redotApp.eq(redotApp)) + .leftJoin(siteSetting).on(siteSetting.redotApp.eq(redotApp)) + .leftJoin(styleInfo).on(styleInfo.redotApp.eq(redotApp)) + .where(predicates) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(resolveOrder(pageable)) + .fetch(); + + return tuples.stream() + .map(tuple -> new RedotAppWithSiteInfo( + tuple.get(redotApp), + tuple.get(domain), + tuple.get(siteSetting), + tuple.get(styleInfo) + )) + .filter(appWithInfo -> appWithInfo.redotApp() != null) + .toList(); + } + + @Override + public long countBySearchCondition(RedotAppInfoSearchCondition searchCondition) { + String name = searchCondition == null ? null : searchCondition.name(); + Long ownerId = searchCondition == null ? null : searchCondition.redotMemberId(); + RedotAppStatus status = searchCondition == null ? null : searchCondition.status(); + + BooleanExpression[] predicates = new BooleanExpression[]{ + nameContains(name), + ownerIdEq(ownerId), + statusEq(status) + }; + + return countByPredicates(predicates); + } + + private long countByPredicates(BooleanExpression[] predicates) { + Long totalCount = queryFactory + .select(redotApp.count()) + .from(redotApp) + .where(predicates) + .fetchOne(); + + return totalCount == null ? 0L : totalCount; + } + + private OrderSpecifier resolveOrder(Pageable pageable) { + Sort.Order order = pageable.getSort().stream() + .filter(it -> Objects.equals(it.getProperty(), "createdAt")) + .findFirst() + .orElseGet(() -> new Sort.Order(Sort.Direction.DESC, "createdAt")); + + return order.isAscending() ? redotApp.createdAt.asc() : redotApp.createdAt.desc(); + } + + private BooleanExpression nameContains(String name) { + return hasText(name) ? redotApp.name.containsIgnoreCase(name) : null; + } + + private BooleanExpression ownerIdEq(Long ownerId) { + return ownerId == null ? null : redotApp.owner.id.eq(ownerId); + } + + private BooleanExpression statusEq(RedotAppStatus status) { + return status == null ? null : redotApp.status.eq(status); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppWithSiteInfo.java b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppWithSiteInfo.java new file mode 100644 index 0000000..c354665 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppWithSiteInfo.java @@ -0,0 +1,14 @@ +package redot.redot_server.domain.redot.app.repository; + +import redot.redot_server.domain.redot.app.entity.RedotApp; +import redot.redot_server.domain.site.domain.entity.Domain; +import redot.redot_server.domain.site.setting.entity.SiteSetting; +import redot.redot_server.domain.site.style.entity.StyleInfo; + +public record RedotAppWithSiteInfo( + RedotApp redotApp, + Domain domain, + SiteSetting siteSetting, + StyleInfo styleInfo +) { +} diff --git a/src/main/java/redot/redot_server/domain/redot/consultation/repository/ConsultationRepository.java b/src/main/java/redot/redot_server/domain/redot/consultation/repository/ConsultationRepository.java index d8f6500..ff7d556 100644 --- a/src/main/java/redot/redot_server/domain/redot/consultation/repository/ConsultationRepository.java +++ b/src/main/java/redot/redot_server/domain/redot/consultation/repository/ConsultationRepository.java @@ -2,6 +2,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import redot.redot_server.domain.redot.consultation.entity.Consultation; +import redot.redot_server.domain.redot.consultation.entity.ConsultationStatus; public interface ConsultationRepository extends JpaRepository, ConsultationRepositoryCustom { + + long countByStatus(ConsultationStatus status); } diff --git a/src/main/java/redot/redot_server/domain/redot/member/repository/RedotMemberRepository.java b/src/main/java/redot/redot_server/domain/redot/member/repository/RedotMemberRepository.java index 3e0d197..6b96043 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/repository/RedotMemberRepository.java +++ b/src/main/java/redot/redot_server/domain/redot/member/repository/RedotMemberRepository.java @@ -1,5 +1,6 @@ package redot.redot_server.domain.redot.member.repository; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import redot.redot_server.domain.redot.member.entity.RedotMember; @@ -11,4 +12,6 @@ public interface RedotMemberRepository extends JpaRepository, Optional findByEmail(String email); Optional findBySocialProviderAndSocialProviderId(SocialProvider provider, String socialProviderId); + + long countByCreatedAtBefore(LocalDateTime before); } diff --git a/src/main/java/redot/redot_server/domain/site/domain/repository/DomainRepository.java b/src/main/java/redot/redot_server/domain/site/domain/repository/DomainRepository.java index 80f4967..044ca9f 100644 --- a/src/main/java/redot/redot_server/domain/site/domain/repository/DomainRepository.java +++ b/src/main/java/redot/redot_server/domain/site/domain/repository/DomainRepository.java @@ -1,5 +1,7 @@ package redot.redot_server.domain.site.domain.repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; @@ -15,5 +17,7 @@ public interface DomainRepository extends JpaRepository { Optional findByRedotAppId(Long redotAppId); + List findByRedotAppIdIn(Collection redotAppIds); + boolean existsByCustomDomain(String customDomain); } diff --git a/src/main/java/redot/redot_server/domain/site/setting/repository/SiteSettingRepository.java b/src/main/java/redot/redot_server/domain/site/setting/repository/SiteSettingRepository.java index 309cc4d..38653fe 100644 --- a/src/main/java/redot/redot_server/domain/site/setting/repository/SiteSettingRepository.java +++ b/src/main/java/redot/redot_server/domain/site/setting/repository/SiteSettingRepository.java @@ -1,9 +1,13 @@ package redot.redot_server.domain.site.setting.repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import redot.redot_server.domain.site.setting.entity.SiteSetting; public interface SiteSettingRepository extends JpaRepository { Optional findByRedotAppId(Long redotAppId); + + List findByRedotAppIdIn(Collection redotAppIds); } diff --git a/src/main/java/redot/redot_server/domain/site/style/repository/StyleInfoRepository.java b/src/main/java/redot/redot_server/domain/site/style/repository/StyleInfoRepository.java index 56d4998..a3afe6b 100644 --- a/src/main/java/redot/redot_server/domain/site/style/repository/StyleInfoRepository.java +++ b/src/main/java/redot/redot_server/domain/site/style/repository/StyleInfoRepository.java @@ -1,9 +1,13 @@ package redot.redot_server.domain.site.style.repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import redot.redot_server.domain.site.style.entity.StyleInfo; public interface StyleInfoRepository extends JpaRepository { Optional findByRedotApp_Id(Long redotAppId); + + List findByRedotApp_IdIn(Collection redotAppIds); } diff --git a/src/main/resources/db/migration/V6__add_redot_app_remark.sql b/src/main/resources/db/migration/V6__add_redot_app_remark.sql new file mode 100644 index 0000000..cdf98a8 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_redot_app_remark.sql @@ -0,0 +1,2 @@ +ALTER TABLE redot_apps + ADD COLUMN IF NOT EXISTS remark TEXT; diff --git a/src/main/resources/db/migration/V7__add_index_to_redot_members_created_at.sql b/src/main/resources/db/migration/V7__add_index_to_redot_members_created_at.sql new file mode 100644 index 0000000..e8a5e28 --- /dev/null +++ b/src/main/resources/db/migration/V7__add_index_to_redot_members_created_at.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_redot_members_created_at ON redot_members(created_at);