diff --git a/.coderabbit.yml b/.coderabbit.yml index 93b75ecc..ca0ceda5 100644 --- a/.coderabbit.yml +++ b/.coderabbit.yml @@ -34,39 +34,37 @@ reviews: "Docs", "Documentation", "Style", - "Refactor", "Test", "Tests", "Typo", "Merge branch", "Revert", "의존성", - "D2R", - "R2D", - "R2M", - "M2R" + "D2R", "D2M", + "R2D", "R2M", + "M2D", "M2R" ] -tools: - # Backend - pmd: # Java - enabled: true - sqlfluff: # SQL - enabled: true + tools: + # Backend + pmd: # Java + enabled: true + sqlfluff: # SQL + enabled: true - # Security - gitleaks: - enabled: true - semgrep: - enabled: true + # Security + gitleaks: + enabled: true + semgrep: + enabled: true - # Infrastructure - actionlint: # GitHub Actions - enabled: true - hadolint: # Dockerfile - enabled: true - yamllint: # YAML - enabled: true + # Infrastructure + actionlint: # GitHub Actions + enabled: true + hadolint: # Dockerfile + enabled: true + yamllint: # YAML + enabled: true chat: auto_reply: true diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java index 93e7e717..9cb29056 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java @@ -5,6 +5,8 @@ import OneQ.OnSurvey.domain.admin.api.dto.response.AdminSurveyDetailResponse; import OneQ.OnSurvey.domain.admin.api.dto.response.MemberSearchResponse; import OneQ.OnSurvey.domain.admin.api.dto.response.AdminSurveyIntroItem; +import OneQ.OnSurvey.domain.admin.api.dto.response.OngoingSurveyResponse; +import OneQ.OnSurvey.domain.admin.api.dto.response.SurveyGrantStatsResponse; import OneQ.OnSurvey.domain.admin.application.AdminFacade; import OneQ.OnSurvey.domain.admin.domain.model.member.AdminMemberView; import OneQ.OnSurvey.global.common.response.PageResponse; @@ -59,6 +61,22 @@ public SuccessResponse getQuestionsCompleted( return SuccessResponse.ok(adminFacade.getSurveyDetail(surveyId)); } + @GetMapping("/promotion-grants/survey-stats") + @Operation(summary = "설문별 리워드 지급 현황 조회", description = "설문 단위로 성공/실패/대기 건수를 집계하여 최신순으로 반환합니다.") + public SuccessResponse> getSurveyGrantStats() { + return SuccessResponse.ok(adminFacade.getSurveyGrantStats()); + } + + @GetMapping("/dashboard/ongoing-surveys") + @Operation(summary = "수집중인 설문 현황 조회", description = "현재 ONGOING 상태인 설문의 ID, 제목, 현재응답수, 목표응답수를 반환합니다.") + public SuccessResponse> getOngoingSurveys() { + return SuccessResponse.ok( + adminFacade.getOngoingSurveys().stream() + .map(OngoingSurveyResponse::from) + .toList() + ); + } + @PatchMapping("/surveys/{surveyId}/owner") @Operation(summary = "설문 소유자 변경 (어드민)", description = "어드민 권한으로 설문의 소유자를 변경합니다.") public SuccessResponse changeSurveyOwner( diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyDetailResponse.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyDetailResponse.java index 428a54a1..744f8528 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyDetailResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyDetailResponse.java @@ -45,7 +45,7 @@ public record SurveyInformationDto( String deadline, Set ages, String gender, - String residence, + Set residences, Set interests, Integer dueCount ) { @@ -58,7 +58,7 @@ public static SurveyInformationDto from(SurveySingleViewInfo vo) { vo.deadline() != null ? vo.deadline().toString() : null, vo.ages(), vo.gender(), - vo.residence(), + vo.residences(), vo.interests(), vo.dueCount() ); diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/OngoingSurveyResponse.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/OngoingSurveyResponse.java new file mode 100644 index 00000000..c5e7fb70 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/OngoingSurveyResponse.java @@ -0,0 +1,19 @@ +package OneQ.OnSurvey.domain.admin.api.dto.response; + +import OneQ.OnSurvey.domain.admin.domain.model.survey.OngoingSurveyView; + +public record OngoingSurveyResponse( + Long surveyId, + String title, + int completedCount, + int dueCount +) { + public static OngoingSurveyResponse from(OngoingSurveyView view) { + return new OngoingSurveyResponse( + view.surveyId(), + view.title(), + view.completedCount(), + view.dueCount() + ); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/SurveyGrantStatsResponse.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/SurveyGrantStatsResponse.java new file mode 100644 index 00000000..225ae61e --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/SurveyGrantStatsResponse.java @@ -0,0 +1,21 @@ +package OneQ.OnSurvey.domain.admin.api.dto.response; + +import OneQ.OnSurvey.global.promotion.PromotionGrantStatsProjection; + +import java.time.LocalDateTime; + +public record SurveyGrantStatsResponse( + Long surveyId, + long totalCount, + long successCount, + long failedCount, + long pendingCount, + LocalDateTime latestAt +) { + public static SurveyGrantStatsResponse from(PromotionGrantStatsProjection p) { + return new SurveyGrantStatsResponse( + p.surveyId(), p.totalCount(), p.successCount(), + p.failedCount(), p.pendingCount(), p.latestAt() + ); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java index e681694a..67b93251 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java @@ -3,10 +3,12 @@ import OneQ.OnSurvey.domain.admin.api.dto.request.AdminSurveySearchQuery; import OneQ.OnSurvey.domain.admin.api.dto.response.AdminSurveyDetailResponse; import OneQ.OnSurvey.domain.admin.api.dto.response.AdminSurveyIntroItem; +import OneQ.OnSurvey.domain.admin.api.dto.response.SurveyGrantStatsResponse; import OneQ.OnSurvey.domain.admin.domain.model.Admin; import OneQ.OnSurvey.domain.admin.domain.model.AdminRole; import OneQ.OnSurvey.domain.admin.domain.model.member.AdminMemberView; import OneQ.OnSurvey.domain.admin.domain.model.survey.AdminSurveyListView; +import OneQ.OnSurvey.domain.admin.domain.model.survey.OngoingSurveyView; import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveySingleViewInfo; import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveyQuestion; import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveyScreening; @@ -16,6 +18,7 @@ import OneQ.OnSurvey.domain.admin.domain.port.out.MemberPort; import OneQ.OnSurvey.domain.admin.domain.port.out.SurveyPort; import OneQ.OnSurvey.domain.admin.domain.repository.AdminRepository; +import OneQ.OnSurvey.global.promotion.port.out.PromotionGrantRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -34,6 +37,7 @@ public class AdminFacade implements AuthUseCase, AdminUseCase { private final MemberPort memberPort; private final SurveyPort surveyPort; private final PasswordEncoder passwordEncoder; + private final PromotionGrantRepository promotionGrantRepository; @Override public String authenticate(String username, String rawPassword) { @@ -100,4 +104,16 @@ public AdminSurveyDetailResponse getSurveyDetail(Long surveyId) { public void changeSurveyOwner(Long surveyId, Long memberId) { surveyPort.updateSurveyOwner(surveyId, memberId); } + + @Override + public List getSurveyGrantStats() { + return promotionGrantRepository.findSurveyGrantStats().stream() + .map(SurveyGrantStatsResponse::from) + .toList(); + } + + @Override + public List getOngoingSurveys() { + return surveyPort.findOngoingSurveys(); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/OngoingSurveyView.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/OngoingSurveyView.java new file mode 100644 index 00000000..ee788b09 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/OngoingSurveyView.java @@ -0,0 +1,8 @@ +package OneQ.OnSurvey.domain.admin.domain.model.survey; + +public record OngoingSurveyView( + Long surveyId, + String title, + int completedCount, + int dueCount +) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveySingleViewInfo.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveySingleViewInfo.java index 2ddff995..b072ecd5 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveySingleViewInfo.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveySingleViewInfo.java @@ -11,7 +11,7 @@ public record SurveySingleViewInfo( Set ages, String gender, - String residence, + Set residences, Set interests, Integer dueCount ) { diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/in/AdminUseCase.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/in/AdminUseCase.java index 4966d8c1..04b5aa5d 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/in/AdminUseCase.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/in/AdminUseCase.java @@ -3,7 +3,9 @@ import OneQ.OnSurvey.domain.admin.api.dto.request.AdminSurveySearchQuery; import OneQ.OnSurvey.domain.admin.api.dto.response.AdminSurveyDetailResponse; import OneQ.OnSurvey.domain.admin.api.dto.response.AdminSurveyIntroItem; +import OneQ.OnSurvey.domain.admin.api.dto.response.SurveyGrantStatsResponse; import OneQ.OnSurvey.domain.admin.domain.model.member.AdminMemberView; +import OneQ.OnSurvey.domain.admin.domain.model.survey.OngoingSurveyView; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -19,4 +21,7 @@ public interface AdminUseCase { void changeSurveyOwner(Long surveyId, Long newMemberId); + List getSurveyGrantStats(); + + List getOngoingSurveys(); } diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/out/SurveyPort.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/out/SurveyPort.java index bf2214c3..dcbe715b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/out/SurveyPort.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/out/SurveyPort.java @@ -2,6 +2,7 @@ import OneQ.OnSurvey.domain.admin.api.dto.request.AdminSurveySearchQuery; import OneQ.OnSurvey.domain.admin.domain.model.survey.AdminSurveyListView; +import OneQ.OnSurvey.domain.admin.domain.model.survey.OngoingSurveyView; import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveySingleViewInfo; import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveyQuestion; import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveyScreening; @@ -24,4 +25,6 @@ public interface SurveyPort { List findSurveySectionsById(Long surveyId); void updateSurveyOwner(Long surveyId, Long newMemberId); + + List findOngoingSurveys(); } diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/infra/adapter/SurveyAdapter.java b/src/main/java/OneQ/OnSurvey/domain/admin/infra/adapter/SurveyAdapter.java index 9fc09fce..96c521e8 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/infra/adapter/SurveyAdapter.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/infra/adapter/SurveyAdapter.java @@ -2,6 +2,7 @@ import OneQ.OnSurvey.domain.admin.api.dto.request.AdminSurveySearchQuery; import OneQ.OnSurvey.domain.admin.domain.model.survey.AdminSurveyListView; +import OneQ.OnSurvey.domain.admin.domain.model.survey.OngoingSurveyView; import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveySingleViewInfo; import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveyQuestion; import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveyScreening; @@ -12,6 +13,7 @@ import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; import OneQ.OnSurvey.domain.question.service.QuestionQuery; import OneQ.OnSurvey.domain.survey.model.dto.ScreeningViewData; +import OneQ.OnSurvey.domain.survey.model.dto.OngoingSurveyStats; import OneQ.OnSurvey.domain.survey.model.dto.SurveyDetailData; import OneQ.OnSurvey.domain.survey.model.dto.SurveyListView; import OneQ.OnSurvey.domain.survey.model.dto.SurveyOwnerChangeDto; @@ -81,6 +83,19 @@ public List findSurveySectionsById(Long surveyId) { .toList(); } + @Override + public List findOngoingSurveys() { + List statsList = surveyQueryService.getOngoingSurveyStats(); + return statsList.stream() + .map(s -> new OngoingSurveyView( + s.getSurveyId(), + s.getTitle(), + s.getCompletedCount() != null ? s.getCompletedCount() : 0, + s.getDueCount() != null ? s.getDueCount() : 0 + )) + .toList(); + } + @Override public void updateSurveyOwner(Long surveyId, Long memberId) { SurveyOwnerChangeDto changeDto = SurveyOwnerChangeDto.builder() diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java b/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java index c7b0c9a6..32e7ac38 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java @@ -17,6 +17,7 @@ import OneQ.OnSurvey.domain.survey.model.dto.SurveyListView; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; public final class AdminSurveyMapper { @@ -56,7 +57,9 @@ public static SurveySingleViewInfo toSurveySingleViewInfo(SurveyDetailData surve surveyDetailData.getDeadline() != null ? surveyDetailData.getDeadline().toLocalDate() : null, surveyDetailData.getAges().stream().map(Enum::name).collect(Collectors.toSet()), surveyDetailData.getGender() != null ? surveyDetailData.getGender().name() : null, - surveyDetailData.getResidence() != null ? surveyDetailData.getResidence().name() : null, + surveyDetailData.getResidences() != null + ? surveyDetailData.getResidences().stream().map(Enum::name).collect(Collectors.toSet()) + : Set.of(), surveyDetailData.getInterests().stream().map(Enum::name).collect(Collectors.toSet()), surveyDetailData.getDueCount() ); diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java b/src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java new file mode 100644 index 00000000..24fac2de --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/DiscountCodeErrorCode.java @@ -0,0 +1,18 @@ +package OneQ.OnSurvey.domain.discount; + +import OneQ.OnSurvey.global.common.exception.ApiErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum DiscountCodeErrorCode implements ApiErrorCode { + + DISCOUNT_CODE_NOT_FOUND("DISCOUNT_404", "유효하지 않은 할인 코드입니다.", HttpStatus.NOT_FOUND), + DISCOUNT_CODE_EXPIRED("DISCOUNT_410", "만료된 할인 코드입니다.", HttpStatus.GONE); + + private final String errorCode; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/controller/DiscountCodeController.java b/src/main/java/OneQ/OnSurvey/domain/discount/controller/DiscountCodeController.java new file mode 100644 index 00000000..4d99b6ce --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/controller/DiscountCodeController.java @@ -0,0 +1,43 @@ +package OneQ.OnSurvey.domain.discount.controller; + +import OneQ.OnSurvey.global.common.response.SuccessResponse; +import OneQ.OnSurvey.domain.discount.model.request.CreateDiscountCodeRequest; +import OneQ.OnSurvey.domain.discount.model.response.DiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.model.response.ValidateDiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.service.DiscountCodeCommandService; +import OneQ.OnSurvey.domain.discount.service.DiscountCodeQueryService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/v1/discount-codes") +@RequiredArgsConstructor +public class DiscountCodeController { + + private final DiscountCodeQueryService discountCodeQueryService; + private final DiscountCodeCommandService discountCodeCommandService; + + @GetMapping("/{code}") + @Operation(summary = "할인 코드를 검증하고 할인 정보를 반환합니다.") + public SuccessResponse validate(@PathVariable String code) { + return SuccessResponse.ok(discountCodeQueryService.validate(code)); + } + + @PostMapping + @Operation(summary = "할인 코드를 생성합니다.") + public SuccessResponse create( + @RequestBody @Valid CreateDiscountCodeRequest request + ) { + return SuccessResponse.ok(discountCodeCommandService.create(request)); + } + + @GetMapping + @Operation(summary = "전체 할인 코드 목록을 조회합니다.") + public SuccessResponse> findAll() { + return SuccessResponse.ok(discountCodeQueryService.findAll()); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java b/src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java new file mode 100644 index 00000000..fcd200d5 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/entity/DiscountCode.java @@ -0,0 +1,38 @@ +package OneQ.OnSurvey.domain.discount.entity; + +import OneQ.OnSurvey.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +@Table(name = "discount_code") +public class DiscountCode extends BaseEntity { + + @Id + @Column(name = "discount_code_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "organization_name", nullable = false) + private String organizationName; + + @Column(name = "code", nullable = false, unique = true, length = 6) + private String code; + + @Column(name = "expired_at", nullable = false) + private LocalDate expiredAt; + + public static DiscountCode of(String organizationName, String code, LocalDate expiredAt) { + return DiscountCode.builder() + .organizationName(organizationName) + .code(code) + .expiredAt(expiredAt) + .build(); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java b/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java new file mode 100644 index 00000000..d9db48aa --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/model/request/CreateDiscountCodeRequest.java @@ -0,0 +1,20 @@ +package OneQ.OnSurvey.domain.discount.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record CreateDiscountCodeRequest( + @NotBlank + @Schema(description = "학회(기관) 이름", example = "onsurvey") + String organizationName, + + @NotNull + @FutureOrPresent + @Schema(description = "코드 만료 기한", example = "2026-12-31") + LocalDate expiredAt +) { +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java b/src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java new file mode 100644 index 00000000..0d99989d --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/model/response/DiscountCodeResponse.java @@ -0,0 +1,24 @@ +package OneQ.OnSurvey.domain.discount.model.response; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record DiscountCodeResponse( + Long id, + String organizationName, + String code, + LocalDate expiredAt, + LocalDateTime createdAt +) { + public static DiscountCodeResponse from(DiscountCode entity) { + return new DiscountCodeResponse( + entity.getId(), + entity.getOrganizationName(), + entity.getCode(), + entity.getExpiredAt(), + entity.getCreatedAt() + ); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/model/response/ValidateDiscountCodeResponse.java b/src/main/java/OneQ/OnSurvey/domain/discount/model/response/ValidateDiscountCodeResponse.java new file mode 100644 index 00000000..4673a6ea --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/model/response/ValidateDiscountCodeResponse.java @@ -0,0 +1,6 @@ +package OneQ.OnSurvey.domain.discount.model.response; + +public record ValidateDiscountCodeResponse( + boolean eligible +) { +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeJpaRepository.java b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeJpaRepository.java new file mode 100644 index 00000000..812fd03d --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeJpaRepository.java @@ -0,0 +1,11 @@ +package OneQ.OnSurvey.domain.discount.repository; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface DiscountCodeJpaRepository extends JpaRepository { + Optional findByCode(String code); + boolean existsByCode(String code); +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepository.java b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepository.java new file mode 100644 index 00000000..1698afc2 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepository.java @@ -0,0 +1,13 @@ +package OneQ.OnSurvey.domain.discount.repository; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; + +import java.util.List; +import java.util.Optional; + +public interface DiscountCodeRepository { + DiscountCode save(DiscountCode discountCode); + Optional findByCode(String code); + boolean existsByCode(String code); + List findAll(); +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepositoryImpl.java new file mode 100644 index 00000000..95794027 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/repository/DiscountCodeRepositoryImpl.java @@ -0,0 +1,35 @@ +package OneQ.OnSurvey.domain.discount.repository; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class DiscountCodeRepositoryImpl implements DiscountCodeRepository { + + private final DiscountCodeJpaRepository jpaRepository; + + @Override + public DiscountCode save(DiscountCode discountCode) { + return jpaRepository.save(discountCode); + } + + @Override + public Optional findByCode(String code) { + return jpaRepository.findByCode(code); + } + + @Override + public boolean existsByCode(String code) { + return jpaRepository.existsByCode(code); + } + + @Override + public List findAll() { + return jpaRepository.findAll(); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java new file mode 100644 index 00000000..8c2fece2 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandService.java @@ -0,0 +1,47 @@ +package OneQ.OnSurvey.domain.discount.service; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import OneQ.OnSurvey.domain.discount.model.request.CreateDiscountCodeRequest; +import OneQ.OnSurvey.domain.discount.model.response.DiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.repository.DiscountCodeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class DiscountCodeCommandService { + + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final int CODE_LENGTH = 6; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final DiscountCodeRepository discountCodeRepository; + + public DiscountCodeResponse create(CreateDiscountCodeRequest request) { + String code = generateUniqueCode(); + DiscountCode discountCode = DiscountCode.of(request.organizationName(), code, request.expiredAt()); + discountCode = discountCodeRepository.save(discountCode); + + log.info("[DiscountCode:create] 할인 코드 생성 - org={}, code={}, expiredAt={}", request.organizationName(), code, request.expiredAt()); + + return DiscountCodeResponse.from(discountCode); + } + + private String generateUniqueCode() { + String code; + do { + StringBuilder sb = new StringBuilder(CODE_LENGTH); + for (int i = 0; i < CODE_LENGTH; i++) { + sb.append(CHARACTERS.charAt(RANDOM.nextInt(CHARACTERS.length()))); + } + code = sb.toString(); + } while (discountCodeRepository.existsByCode(code)); + return code; + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java new file mode 100644 index 00000000..c0cf8a6c --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeQueryService.java @@ -0,0 +1,55 @@ +package OneQ.OnSurvey.domain.discount.service; + +import OneQ.OnSurvey.global.common.exception.CustomException; +import OneQ.OnSurvey.domain.discount.DiscountCodeErrorCode; +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import OneQ.OnSurvey.domain.discount.model.response.DiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.model.response.ValidateDiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.repository.DiscountCodeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DiscountCodeQueryService { + + private final DiscountCodeRepository discountCodeRepository; + + /** 코드 존재 여부만 확인 */ + public ValidateDiscountCodeResponse validate(String code) { + DiscountCode discountCode = discountCodeRepository.findByCode(code) + .orElseThrow(() -> new CustomException(DiscountCodeErrorCode.DISCOUNT_CODE_NOT_FOUND)); + validateNotExpired(discountCode); + return new ValidateDiscountCodeResponse(true); + } + + /** 설문 등록 시 코드 검증 후 엔티티 반환 */ + public DiscountCode getByCode(String code) { + DiscountCode discountCode = discountCodeRepository.findByCode(code) + .orElseThrow(() -> new CustomException(DiscountCodeErrorCode.DISCOUNT_CODE_NOT_FOUND)); + validateNotExpired(discountCode); + return discountCode; + } + + public List findAll() { + LocalDate today = LocalDate.now(); + return discountCodeRepository.findAll().stream() + .sorted(Comparator + .comparing((DiscountCode c) -> c.getExpiredAt().isBefore(today)) // 활성(false) 먼저 + .thenComparing(DiscountCode::getExpiredAt)) // 만료일 오름차순 + .map(DiscountCodeResponse::from) + .toList(); + } + + private void validateNotExpired(DiscountCode discountCode) { + if (discountCode.getExpiredAt().isBefore(LocalDate.now())) { + throw new CustomException(DiscountCodeErrorCode.DISCOUNT_CODE_EXPIRED); + } + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/member/repository/MemberRepository.java b/src/main/java/OneQ/OnSurvey/domain/member/repository/MemberRepository.java index 8c206f84..bb386efc 100644 --- a/src/main/java/OneQ/OnSurvey/domain/member/repository/MemberRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/member/repository/MemberRepository.java @@ -16,4 +16,5 @@ public interface MemberRepository { Long validateAdminRoleAndGetMemberIdByUserKey(Long userKey); List searchMembers(String email, String phoneNumber, Long memberId, String name); + String getUsernameByUserKey(Long userKey); } diff --git a/src/main/java/OneQ/OnSurvey/domain/member/repository/MemberRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/member/repository/MemberRepositoryImpl.java index 9a54758f..28313b2d 100644 --- a/src/main/java/OneQ/OnSurvey/domain/member/repository/MemberRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/member/repository/MemberRepositoryImpl.java @@ -116,4 +116,12 @@ public List searchMembers(String email, String phoneNumber, Long memberI .limit(100) .fetch(); } + + @Override + public String getUsernameByUserKey(Long userKey) { + return jpaQueryFactory.select(member.name) + .from(member) + .where(member.userKey.eq(userKey)) + .fetchOne(); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/member/service/MemberFinder.java b/src/main/java/OneQ/OnSurvey/domain/member/service/MemberFinder.java index bf88836c..fac0e840 100644 --- a/src/main/java/OneQ/OnSurvey/domain/member/service/MemberFinder.java +++ b/src/main/java/OneQ/OnSurvey/domain/member/service/MemberFinder.java @@ -12,4 +12,5 @@ public interface MemberFinder { Long validateAdminRoleAndGetMemberIdByUserKey(Long userKey); List searchMembers(String email, String phoneNumber, Long memberId, String name); + String getUsernameByUserKey(Long userKey); } diff --git a/src/main/java/OneQ/OnSurvey/domain/member/service/MemberQueryService.java b/src/main/java/OneQ/OnSurvey/domain/member/service/MemberQueryService.java index 17c63e88..ad17c54d 100644 --- a/src/main/java/OneQ/OnSurvey/domain/member/service/MemberQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/member/service/MemberQueryService.java @@ -47,4 +47,9 @@ public List searchMembers(String email, String phoneNumber, List members = memberRepository.searchMembers(email, phoneNumber, memberId, name); return MemberSearchResult.from(members); } + + @Override + public String getUsernameByUserKey(Long userKey) { + return memberRepository.getUsernameByUserKey(userKey); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java index 26abdb05..e5bf6e43 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java @@ -19,6 +19,8 @@ import static OneQ.OnSurvey.domain.member.QMember.member; import static OneQ.OnSurvey.domain.participation.entity.QQuestionAnswer.questionAnswer; +import static OneQ.OnSurvey.domain.participation.entity.QResponse.response; +import static OneQ.OnSurvey.domain.question.entity.QQuestion.question; @Repository public class QuestionAnswerRepositoryImpl extends AbstractAnswerRepository { @@ -50,7 +52,13 @@ public List getAggregatedAnswersByQuestionIds(List questionId questionAnswer.answerId.count() )) .from(questionAnswer) - .where(questionAnswer.questionId.in(questionIdList)) + .join(question).on(question.questionId.eq(questionAnswer.questionId)) + .join(response).on(response.surveyId.eq(question.surveyId) + .and(response.memberId.eq(questionAnswer.memberId))) + .where( + questionAnswer.questionId.in(questionIdList), + response.isResponded.isTrue() + ) .groupBy(questionAnswer.questionId, questionAnswer.content) .orderBy(questionAnswer.questionId.asc()) .fetch(); @@ -63,7 +71,13 @@ public List getAnswersByQuestionIds(List questionIdList) { questionAnswer.content )) .from(questionAnswer) - .where(questionAnswer.questionId.in(questionIdList)) + .join(question).on(question.questionId.eq(questionAnswer.questionId)) + .join(response).on(response.surveyId.eq(question.surveyId) + .and(response.memberId.eq(questionAnswer.memberId))) + .where( + questionAnswer.questionId.in(questionIdList), + response.isResponded.isTrue() + ) .orderBy(questionAnswer.questionId.asc()) .fetch(); } @@ -84,9 +98,13 @@ public List getAggregatedAnswersByQuestionIds( questionAnswer.answerId.count() )) .from(questionAnswer) + .join(question).on(question.questionId.eq(questionAnswer.questionId)) + .join(response).on(response.surveyId.eq(question.surveyId) + .and(response.memberId.eq(questionAnswer.memberId))) .join(member).on(member.id.eq(questionAnswer.memberId)) .where( questionAnswer.questionId.in(questionIds), + response.isResponded.isTrue(), buildAgeCondition(member.birthDay, effective.ages()), buildGenderCondition(member.gender, effective.genders()), buildResidenceCondition(member.residence, effective.residences()) @@ -116,9 +134,13 @@ public List getAnswersByQuestionIds( questionAnswer.content )) .from(questionAnswer) + .join(question).on(question.questionId.eq(questionAnswer.questionId)) + .join(response).on(response.surveyId.eq(question.surveyId) + .and(response.memberId.eq(questionAnswer.memberId))) .join(member).on(questionAnswer.memberId.eq(member.id)) .where( questionAnswer.questionId.in(questionIds), + response.isResponded.isTrue(), buildAgeCondition(member.birthDay, effective.ages()), buildGenderCondition(member.gender, effective.genders()), buildResidenceCondition(member.residence, effective.residences()) @@ -146,9 +168,13 @@ public List getRespondentCountsByQuestionIds( questionAnswer.memberId.countDistinct() )) .from(questionAnswer) + .join(question).on(question.questionId.eq(questionAnswer.questionId)) + .join(response).on(response.surveyId.eq(question.surveyId) + .and(response.memberId.eq(questionAnswer.memberId))) .join(member).on(member.id.eq(questionAnswer.memberId)) .where( questionAnswer.questionId.in(questionIds), + response.isResponded.isTrue(), buildAgeCondition(member.birthDay, effective.ages()), buildGenderCondition(member.gender, effective.genders()), buildResidenceCondition(member.residence, effective.residences()) diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Image.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Image.java index a4b7a1d5..b916384f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Image.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Image.java @@ -20,7 +20,6 @@ public static Image of( Integer order, String title, String description, - Boolean isRequired, Integer section, QuestionType type, String imageUrl @@ -30,7 +29,7 @@ public static Image of( .order(order) .title(title) .description(description) - .isRequired(isRequired) + .isRequired(false) .type(type.name()) .section(section) .imageUrl(imageUrl) @@ -40,11 +39,10 @@ public static Image of( public void updateQuestion( String title, String description, - Boolean isRequired, Integer order, Integer section, String imageUrl ) { - super.updateQuestion(title, description, isRequired, order, section, imageUrl); + super.updateQuestion(title, description, false, order, section, imageUrl); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Title.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Title.java new file mode 100644 index 00000000..3f4bed87 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Title.java @@ -0,0 +1,47 @@ +package OneQ.OnSurvey.domain.question.entity.question; + +import OneQ.OnSurvey.domain.question.entity.Question; +import OneQ.OnSurvey.domain.question.model.QuestionType; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@DiscriminatorValue(value = QuestionType.Values.TITLE) +public class Title extends Question { + + public static Title of( + Long surveyId, + Integer order, + String title, + String description, + Integer section, + QuestionType type + ) { + return Title.builder() + .surveyId(surveyId) + .order(order) + .title(title) + .description(description) + .isRequired(false) + .type(type.name()) + .section(section) + .imageUrl(null) + .build(); + } + + public void updateQuestion( + String title, + String description, + Integer order, + Integer section + ) { + super.updateQuestion(title, description, false, order, section, null); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java b/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java index c144f852..0b5bafab 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java @@ -14,6 +14,7 @@ public enum QuestionType { NUMBER("숫자형", Values.NUMBER), DATE("날짜형", Values.DATE), IMAGE("이미지형", Values.IMAGE), + TITLE("제목형", Values.TITLE), TEXT("주관식", Values.TEXT); private final String description; private final String value; @@ -28,6 +29,7 @@ public static class Values { public static final String DATE = "DATE"; public static final String TEXT = "TEXT"; public static final String IMAGE = "IMAGE"; + public static final String TITLE = "TITLE"; } public boolean isText() { diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionJpaRepository.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionJpaRepository.java index a1f2b475..89a90211 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionJpaRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionJpaRepository.java @@ -12,4 +12,6 @@ public interface QuestionJpaRepository extends JpaRepository { List getQuestionsBySurveyIdAndSectionOrderByOrder(Long surveyId, Integer section); void deleteAllBySurveyIdEqualsAndSectionNotIn(Long surveyId, Collection sections); + + int countBySurveyId(Long surveyId); } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java index b91c2a6c..d929a878 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java @@ -15,4 +15,5 @@ public interface QuestionRepository { Long getSurveyId(Long questionId); void deleteAll(Set idList); void deleteBySurveyIdAndNotInOrder(Long surveyId, Collection order); + int countBySurveyId(Long surveyId); } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java index 99d46da3..ca7acc94 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java @@ -55,4 +55,9 @@ public void deleteAll(Set idList) { public void deleteBySurveyIdAndNotInOrder(Long surveyId, Collection orders) { questionJpaRepository.deleteAllBySurveyIdEqualsAndSectionNotIn(surveyId, orders); } + + @Override + public int countBySurveyId(Long surveyId) { + return questionJpaRepository.countBySurveyId(surveyId); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java index ded5dd8b..3f45d293 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java @@ -36,7 +36,7 @@ public class QuestionCommandService implements QuestionCommand { @Override public QuestionUpsertDto upsertQuestionList(QuestionUpsertDto upsertDto) { - log.info("[QUESTION:COMMAND:upsertQuestionList] 문항 UPSERT - surveyId: {}, upsertInfoList: {}", upsertDto.getSurveyId(), upsertDto.getUpsertInfoList().toString()); + log.info("[QUESTION:COMMAND:upsertQuestionList] 문항 UPSERT - surveyId: {}", upsertDto.getSurveyId()); Long surveyId = upsertDto.getSurveyId(); List upsertInfoList = upsertDto.getUpsertInfoList(); @@ -200,11 +200,17 @@ private void updateQuestion(QuestionUpsertDto.UpsertInfo upsertInfo, Question qu image.updateQuestion( upsertInfo.getTitle(), upsertInfo.getDescription(), - upsertInfo.getIsRequired(), upsertInfo.getQuestionOrder(), upsertInfo.getSection(), upsertInfo.getImageUrl() ); + } else if (question instanceof Title title) { + title.updateQuestion( + upsertInfo.getTitle(), + upsertInfo.getDescription(), + upsertInfo.getQuestionOrder(), + upsertInfo.getSection() + ); } } @@ -302,11 +308,19 @@ private Question createQuestion(Long surveyId, QuestionUpsertDto.UpsertInfo upse upsertInfo.getQuestionOrder(), upsertInfo.getTitle(), upsertInfo.getDescription(), - upsertInfo.getIsRequired(), upsertInfo.getSection(), type, upsertInfo.getImageUrl() ); + } else if (QuestionType.TITLE.equals(type)) { + return Title.of( + surveyId, + upsertInfo.getQuestionOrder(), + upsertInfo.getTitle(), + upsertInfo.getDescription(), + upsertInfo.getSection(), + type + ); } else { throw new CustomException(ErrorCode.INVALID_REQUEST); } @@ -314,7 +328,7 @@ private Question createQuestion(Long surveyId, QuestionUpsertDto.UpsertInfo upse @Override public List upsertChoiceOptionList(List upsertDtoList) { - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 보기 UPSERT - upsertDtoList : {}", upsertDtoList.toString()); + log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 보기 UPSERT"); List finalList = new ArrayList<>(); @@ -345,11 +359,10 @@ public List upsertChoiceOptionList(List upsert .map(ChoiceOption::getChoiceOptionId) .filter(optionId -> !updateIdSet.contains(optionId)) .collect(Collectors.toSet()); - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 삭제되는 문항: {}, 보기 IDs: {}", questionId, deleteIdSet); - - choiceOptionRepository.deleteAll(deleteIdSet); - - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] DELETE 진행"); + if (!deleteIdSet.isEmpty()) { + log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 삭제되는 문항: {}, 보기 IDs: {}", questionId, deleteIdSet); + choiceOptionRepository.deleteAll(deleteIdSet); + } // 5. Update 대상 수정 Map updateInfoMap = updateInfoList.stream().collect(Collectors.toMap( diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java index d97e3cb8..e399c7fe 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java @@ -9,4 +9,5 @@ public interface QuestionQuery { List getOptionsByQuestionIdList(List questionIdList); List getQuestionDtoListBySurveyId(Long surveyId); List getQuestionDtoListBySurveyIdAndSection(Long surveyId, Integer section); + int countQuestionsBySurveyId(Long surveyId); } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java index cc95a7d8..9a5188ec 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java @@ -49,6 +49,11 @@ public List getQuestionDtoListBySurveyIdAndSection(Long surv return fillChoiceOptions(questionList); } + @Override + public int countQuestionsBySurveyId(Long surveyId) { + return questionRepository.countBySurveyId(surveyId); + } + private List fillChoiceOptions(List questionList) { Set choiceIdSet = questionList.stream() diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java index 0d489c19..beabc9ce 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java @@ -31,7 +31,13 @@ public enum SurveyErrorCode implements ApiErrorCode { SURVEY_FREE_PROMOTION_NOT_ALLOWED("SURVEY_PROMOTION_400", "무료 설문은 프로모션 지급 대상이 아닙니다.", HttpStatus.BAD_REQUEST), FORM_REQUEST_NOT_FOUND("FORM_REQUEST_404", "구글 폼 신청을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - FORM_CONVERSION_FAILED("FORM_REQUEST_001", "구글 폼 변환에 실패했습니다.", HttpStatus.BAD_REQUEST); + FORM_CONVERSION_FAILED("FORM_REQUEST_001", "구글 폼 변환에 실패했습니다.", HttpStatus.BAD_REQUEST), + FORM_VALIDATION_FAILED("FORM_REQUEST_002", "구글 폼 링크 유효성 검사에 실패했습니다.", HttpStatus.BAD_REQUEST), + FORM_VALIDATION_PROCEED("FORM_REQUEST_003", "구글 폼 링크 유효성 검사를 진행 중입니다.", HttpStatus.CONFLICT), + FORM_VALIDATION_EMAIL_TOO_MANY_REQUEST("FORM_REQUEST_004", "구글 폼 링크 유효성 검사 이메일 시간 당 한도를 초과했습니다. 잠시 후 시도해주세요.", HttpStatus.TOO_MANY_REQUESTS), + FORM_VALIDATION_BAD_GATEWAY("FORM_REQUEST_005", "구글 폼 링크 유효성 검사가 정상적으로 수행되지 않았습니다.", HttpStatus.BAD_GATEWAY), + FORM_REQUEST_NOT_YET_REGISTERED("FORM_REQUEST_409", "아직 설문 변환이 완료되지 않았습니다.", HttpStatus.CONFLICT), + FORM_REQUEST_MEMBER_NOT_FOUND("FORM_REQUEST_MEMBER_404", "폼 신청자에 해당하는 회원을 찾을 수 없습니다.", HttpStatus.NOT_FOUND); private final String errorCode; private final String message; diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java index 263ce7c5..30aed89a 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java @@ -1,37 +1,49 @@ package OneQ.OnSurvey.domain.survey.controller; +import OneQ.OnSurvey.domain.survey.controller.swagger.FormRequestControllerDoc; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationEmailQuotaResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationResponse; import OneQ.OnSurvey.domain.survey.model.formRequest.FormListResponse; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationRequestDto; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormPublishRequest; import OneQ.OnSurvey.domain.survey.model.formRequest.FormRequestDto; import OneQ.OnSurvey.domain.survey.model.formRequest.FormRequestResponse; +import OneQ.OnSurvey.domain.survey.model.response.SurveyFormResponse; import OneQ.OnSurvey.domain.survey.service.formRequest.FormCreator; import OneQ.OnSurvey.domain.survey.service.formRequest.FormFinder; +import OneQ.OnSurvey.domain.survey.service.formRequest.FormPublisher; import OneQ.OnSurvey.domain.survey.service.formRequest.FormUpdater; +import OneQ.OnSurvey.global.auth.custom.Authenticatable; import OneQ.OnSurvey.global.common.response.PageResponse; import OneQ.OnSurvey.global.common.response.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/v1/form-requests") -public class FormRequestController { +public class FormRequestController implements FormRequestControllerDoc { private final FormCreator formCreator; private final FormFinder formFinder; private final FormUpdater formUpdater; + private final FormPublisher formPublisher; @PostMapping - @Operation(summary = "폼 등록 신청", description = "폼을 등록하기 위한 신청을 생성합니다.") + @Operation(summary = "폼 등록 신청 및 설문 발행", description = "폼을 등록하기 위한 신청을 생성한 뒤 설문변환 및 발행을 진행합니다.") public SuccessResponse createGoogleFormRequest( - @RequestBody FormRequestDto request + @AuthenticationPrincipal Authenticatable principal, + @RequestBody @Valid FormRequestDto request ) { - return SuccessResponse.ok(formCreator.createFormRequest(request)); + return SuccessResponse.ok(formCreator.createFormRequest(principal.getUserKey(), principal.getMemberId(), request)); } @GetMapping @@ -64,7 +76,36 @@ public SuccessResponse markAsRegistered( @PathVariable Long requestId, @RequestParam Long surveyId ) { - formUpdater.markAsRegistered(requestId, surveyId); + formUpdater.markAsRegistered(requestId, surveyId, null); return SuccessResponse.ok("폼이 온서베이에 등록되었습니다."); } + + @PostMapping("/validation") + @Operation(summary = "폼 링크 유효성 검사 및 미리보기 반환", description = "구글 폼 편집 URL 유효성 검사를 진행하여 변환 가능한 문항 수, 변환 불가능 사유, 미리보기 데이터 등을 반환합니다.") + public SuccessResponse getConvertableCounts( + @RequestBody @Valid FormValidationRequestDto request, + @AuthenticationPrincipal Authenticatable principal + ) { + log.info("[FormRequest] 폼 링크 유효성 검사 - URL: {}", request.formLink()); + + FormValidationResponse response = formCreator.validationFormRequestLink(principal.getUserKey(), request); + return SuccessResponse.ok(response); + } + + @GetMapping("/email-quota") + @Operation(summary = "폼 링크 유효성 검사 결과 이메일 수신 일일 한도 조회", description = "사용자 별 링크 유효성 검사 결과에 대한 이메일 수신 일일 한도 잔량 조회을 조회합니다.") + public SuccessResponse getEmailQuota( + @AuthenticationPrincipal Authenticatable principal + ) { + return SuccessResponse.ok(formFinder.getEmailQuota(principal.getUserKey())); + } + + @PatchMapping("/{requestId}/publish") + @Operation(summary = "폼 설문 발행", description = "변환 완료된 설문에 스크리닝 및 세그먼트 정보를 적용하고 발행합니다.") + public SuccessResponse publishFormRequest( + @PathVariable Long requestId, + @RequestBody @Valid FormPublishRequest request + ) { + return SuccessResponse.ok(formPublisher.publishFormRequest(requestId, request)); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/SurveyHelpRequestController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/SurveyHelpRequestController.java new file mode 100644 index 00000000..ca131286 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/SurveyHelpRequestController.java @@ -0,0 +1,57 @@ +package OneQ.OnSurvey.domain.survey.controller; + +import OneQ.OnSurvey.global.auth.custom.CustomUserDetails; +import OneQ.OnSurvey.global.common.response.SuccessResponse; +import OneQ.OnSurvey.global.infra.discord.notifier.AlertNotifier; +import OneQ.OnSurvey.global.infra.discord.notifier.dto.SurveyHelpRequestAlert; +import OneQ.OnSurvey.global.infra.redis.RedisCacheAction; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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 java.time.Duration; +import java.util.List; + +@RestController +@RequestMapping("/v1/surveys/help-requests") +@RequiredArgsConstructor +public class SurveyHelpRequestController { + + private static final Duration HELP_REQUEST_COOLDOWN = Duration.ofSeconds(30); + + private final AlertNotifier alertNotifier; + private final RedisCacheAction redisCache; + + @PostMapping + @Operation(summary = "설문 반려 도움 요청", description = "설문이 반려된 경우 운영팀에 도움을 요청합니다.") + public SuccessResponse requestHelp( + @AuthenticationPrincipal CustomUserDetails principal, + @RequestBody @Valid HelpRequest request + ) { + String dedupKey = "help-request:discord:" + principal.getUserKey(); + Boolean acquired = redisCache.setValueIfAbsent(dedupKey, "1", HELP_REQUEST_COOLDOWN); + if (Boolean.TRUE.equals(acquired)) { + alertNotifier.sendSurveyHelpRequestAsync(new SurveyHelpRequestAlert( + request.email(), + request.name(), + request.rejectionReasons(), + request.content() + )); + } + return SuccessResponse.ok(null); + } + + public record HelpRequest( + @NotBlank String email, + @NotBlank String name, + @NotEmpty List rejectionReasons, + @NotBlank String content + ) {} +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/swagger/FormControllerDoc.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/swagger/FormControllerDoc.java index 26310334..ae741565 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/swagger/FormControllerDoc.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/swagger/FormControllerDoc.java @@ -53,7 +53,7 @@ SuccessResponse createQuestion( "questionType": "LONG", "title": "string", "questionOrder": 2, - "section": 1, + "section": 1 } ] } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/swagger/FormRequestControllerDoc.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/swagger/FormRequestControllerDoc.java new file mode 100644 index 00000000..1ebd68d8 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/swagger/FormRequestControllerDoc.java @@ -0,0 +1,57 @@ +package OneQ.OnSurvey.domain.survey.controller.swagger; + +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationRequestDto; +import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationResponse; +import OneQ.OnSurvey.global.auth.custom.Authenticatable; +import OneQ.OnSurvey.global.common.response.ErrorResponse; +import OneQ.OnSurvey.global.common.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +public interface FormRequestControllerDoc { + + @PostMapping("/validation") + @Operation(summary = "폼 링크 유효성 검사 및 미리보기 반환", description = "구글 폼 편집 URL 유효성 검사를 진행하여 변환 가능한 문항 수, 변환 불가능 사유, 미리보기 데이터 등을 반환합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "409", description = "유효성 검사 진행 중", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "폼 변환 중복요청", value = "{ \"code\": \"FORM_REQUEST_003\", \"message\": \"구글 폼 링크 유효성 검사를 진행 중입니다.\" }") + } + ) + ), + @ApiResponse(responseCode = "429", description = "이메일 시간 당 한도 초과", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "폼 변환 이메일 시간 당 한도 초과", value = "{ \"code\": \"FORM_REQUEST_004\", \"message\": \"구글 폼 링크 유효성 검사 이메일 시간 당 한도를 초과했습니다. 잠시 후 시도해주세요.\" }") + } + ) + ), + @ApiResponse(responseCode = "502", description = "유효성 검사 실패", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "폼 링크 유효성 검사 실패", value = "{ \"code\": \"FORM_REQUEST_005\", \"message\": \"구글 폼 링크 유효성 검사가 정상적으로 수행되지 않았습니다.\" }") + } + ) + ), + }) + SuccessResponse getConvertableCounts( + @RequestBody @Valid FormValidationRequestDto request, + @AuthenticationPrincipal Authenticatable principal + ); +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java b/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java index 8a34318f..fb7f6b79 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/entity/FormRequest.java @@ -5,8 +5,6 @@ import lombok.*; import org.hibernate.annotations.ColumnDefault; -import java.time.LocalDate; - @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -22,19 +20,19 @@ public class FormRequest extends BaseEntity { @Column(columnDefinition = "TEXT", nullable = false) private String formLink; - @Column(nullable = false) - private Integer questionCount; - - @Column(nullable = false) - private Integer targetResponseCount; - - @Column(nullable = false) - private LocalDate deadline; + @Column(name = "user_key", nullable = false) + private Long userKey; @Column(nullable = false, length = 100) private String requesterEmail; - @Column(nullable = false) + @Column + private Integer questionCount; + + @Column + private Integer targetResponseCount; + + @Column private Integer price; @Column(name = "is_registered", nullable = false) @@ -45,27 +43,18 @@ public class FormRequest extends BaseEntity { @Column(name = "registered_survey_id") private Long registeredSurveyId; - public static FormRequest createRequest( - String formLink, - Integer questionCount, - Integer targetResponseCount, - LocalDate deadline, - String requesterEmail, - Integer price - ) { + public static FormRequest createRequest(String formLink, String requesterEmail, Long userKey) { return FormRequest.builder() - .formLink(formLink) - .questionCount(questionCount) - .targetResponseCount(targetResponseCount) - .deadline(deadline) - .requesterEmail(requesterEmail) - .price(price) - .isRegistered(false) - .build(); + .formLink(formLink) + .userKey(userKey) + .requesterEmail(requesterEmail) + .isRegistered(false) + .build(); } - public void markAsRegistered(Long surveyId) { + public void markAsRegistered(Long surveyId, Integer questionCount) { this.isRegistered = true; this.registeredSurveyId = surveyId; + this.questionCount = questionCount; } } \ No newline at end of file diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/entity/Survey.java b/src/main/java/OneQ/OnSurvey/domain/survey/entity/Survey.java index d1f09adb..3df9bd10 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/entity/Survey.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/entity/Survey.java @@ -93,7 +93,8 @@ public void updateSurvey( } public void updateInterests(Set interests) { - this.interests = interests; + this.interests.clear(); + this.interests.addAll(interests); } public void markFree() { diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java b/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java index dc313d30..cd6fbc18 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/entity/SurveyInfo.java @@ -41,14 +41,26 @@ public class SurveyInfo { @Builder.Default private Set ages = new HashSet<>(); + @ElementCollection(targetClass = Residence.class) + @CollectionTable( + name = "survey_residence", + joinColumns = @JoinColumn(name = "info_id") + ) @Enumerated(EnumType.STRING) - private Residence residence; + @Column(name = "residence", length = 30, nullable = false) + @Builder.Default + private Set residences = new HashSet<>(); private Integer genderPrice; private Integer agePrice; private Integer residencePrice; private Integer dueCountPrice; + private Integer promotionAmount; + + @Column(name = "discount_code_id") + private Long discountCodeId; + @Builder.Default @Column(nullable = false) private boolean refundable = true; @@ -58,11 +70,13 @@ public static SurveyInfo createSurveyInfo( Integer dueCount, Gender gender, Set ages, - Residence residence, + Set residences, Integer genderPrice, Integer agePrice, Integer residencePrice, - Integer dueCountPrice + Integer dueCountPrice, + Integer promotionAmount, + Long discountCodeId ) { return SurveyInfo.builder() .surveyId(surveyId) @@ -70,11 +84,13 @@ public static SurveyInfo createSurveyInfo( .completedCount(0) .gender(gender) .ages(ages) - .residence(residence) + .residences(residences) .genderPrice(genderPrice) .agePrice(agePrice) .residencePrice(residencePrice) .dueCountPrice(dueCountPrice) + .promotionAmount(promotionAmount) + .discountCodeId(discountCodeId) .refundable(true) .build(); } @@ -83,20 +99,24 @@ public void updateSurveyInfo( Integer dueCount, Gender gender, Set ages, - Residence residence, + Set residences, Integer genderPrice, Integer agePrice, Integer residencePrice, - Integer dueCountPrice + Integer dueCountPrice, + Integer promotionAmount, + Long discountCodeId ) { this.dueCount = dueCount; this.gender = gender; this.ages = ages; - this.residence = residence; + this.residences = new HashSet<>(residences); this.genderPrice = genderPrice; this.agePrice = agePrice; this.residencePrice = residencePrice; this.dueCountPrice = dueCountPrice; + this.promotionAmount = promotionAmount; + this.discountCodeId = discountCodeId; } public void markNonRefundable() { diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/OngoingSurveyStats.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/OngoingSurveyStats.java new file mode 100644 index 00000000..b3a76f0c --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/OngoingSurveyStats.java @@ -0,0 +1,13 @@ +package OneQ.OnSurvey.domain.survey.model.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class OngoingSurveyStats { + private Long surveyId; + private String title; + private Integer completedCount; + private Integer dueCount; +} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyDetailData.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyDetailData.java index c6ad943a..150c6ef0 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyDetailData.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyDetailData.java @@ -25,6 +25,6 @@ public class SurveyDetailData { private Integer dueCount; private Set ages; private Gender gender; - private Residence residence; + private Set residences; private Set interests; } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveySegmentation.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveySegmentation.java index 5616421f..c939484a 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveySegmentation.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveySegmentation.java @@ -17,7 +17,7 @@ public class SurveySegmentation { private Gender gender; private Set ages; - private Residence residence; + private Set residences; private Set interests; } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyWithEligibility.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyWithEligibility.java index f5c211aa..9b6adfb7 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyWithEligibility.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/dto/SurveyWithEligibility.java @@ -16,6 +16,7 @@ public class SurveyWithEligibility { private String title; private String description; private Boolean isFree; + private Integer promotionAmount; private Set interests; private LocalDateTime deadline; private Boolean isEligible; diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionDto.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionDto.java new file mode 100644 index 00000000..d2631dae --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/ConversionDto.java @@ -0,0 +1,13 @@ +package OneQ.OnSurvey.domain.survey.model.formRequest; + +import OneQ.OnSurvey.domain.question.model.dto.SectionDto; +import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; + +import java.util.List; + +public record ConversionDto( + String title, + String description, + List sections, + List questions +) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java deleted file mode 100644 index 6127c00f..00000000 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionPayload.java +++ /dev/null @@ -1,8 +0,0 @@ -package OneQ.OnSurvey.domain.survey.model.formRequest; - -import java.util.List; - -public record FormConversionPayload ( - List urls -) { -} diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionResponse.java deleted file mode 100644 index b1012944..00000000 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/formRequest/FormConversionResponse.java +++ /dev/null @@ -1,76 +0,0 @@ -package OneQ.OnSurvey.domain.survey.model.formRequest; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.annotation.JsonNaming; -import java.util.List; - -@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -public record FormConversionResponse( - int totalCount, - int successCount, - List results, - String error -) { - - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - public record Result( - String url, - String status, // "SUCCESS", "FAIL" - - // SUCCESS - Survey survey, - List unsupportedQuestions, - - // FAIL - String message - - ) { - public boolean isSuccess() { - return "SUCCESS".equals(status); - } - } - - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - public record Survey( - String title, - String description, - List
sections - ) { } - - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - public record Section( - String id, - int order, - String title, - String description, - Integer nextSectionOrder, - List questions - ) { } - - @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) - public record Question( - String id, - String title, - String description, - String type, - boolean required, - List