diff --git a/src/main/java/com/cmc/suppin/answer/controller/.gitkeep b/src/main/java/com/cmc/suppin/answer/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/cmc/suppin/answer/converter/.gitkeep b/src/main/java/com/cmc/suppin/answer/converter/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/cmc/suppin/answer/exception/.gitkeep b/src/main/java/com/cmc/suppin/answer/exception/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/cmc/suppin/answer/service/.gitkeep b/src/main/java/com/cmc/suppin/answer/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/cmc/suppin/event/survey/controller/.gitkeep b/src/main/java/com/cmc/suppin/event/survey/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/cmc/suppin/event/survey/controller/SurveyApi.java b/src/main/java/com/cmc/suppin/event/survey/controller/SurveyApi.java new file mode 100644 index 0000000..16062b4 --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/controller/SurveyApi.java @@ -0,0 +1,86 @@ +package com.cmc.suppin.event.survey.controller; + +import com.cmc.suppin.event.survey.controller.dto.SurveyRequestDTO; +import com.cmc.suppin.event.survey.controller.dto.SurveyResponseDTO; +import com.cmc.suppin.event.survey.service.SurveyService; +import com.cmc.suppin.global.response.ApiResponse; +import com.cmc.suppin.global.response.ResponseCode; +import com.cmc.suppin.global.security.reslover.Account; +import com.cmc.suppin.global.security.reslover.CurrentAccount; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@Slf4j +@RequiredArgsConstructor +@Validated +@Tag(name = "Event-Survey", description = "Survey 관련 API") +@RequestMapping("/api/v1/survey") +public class SurveyApi { + + private final SurveyService surveyService; + + @PostMapping("/create") + @Operation(summary = "설문지 생성 API", description = "QuestionType(Enum): SUBJECTIVE(주관식), SINGLE_CHOICE(객관식(단일 선택)), MULTIPLE_CHOICE(객관식(복수 선택))") + public ResponseEntity> createSurvey(@RequestBody @Valid SurveyRequestDTO.SurveyCreateDTO request, @CurrentAccount Account account) { + SurveyResponseDTO.SurveyCreateResponse response = surveyService.createSurvey(request, account.userId()); + return ResponseEntity.ok(ApiResponse.of(response)); + } + + @GetMapping("/{surveyId}") + @Operation(summary = "설문지 조회 API", description = "Request: 설문지 ID, Response: 설문지 정보

" + + "SUBJEVTIVE: 주관식, SINGLE_CHOICE: 객관식(단일 선택), MULTIPLE_CHOICE: 객관식(복수 선택)") + public ResponseEntity> getSurvey(@PathVariable Long surveyId) { + SurveyResponseDTO.SurveyResultDTO response = surveyService.getSurvey(surveyId); + return ResponseEntity.ok(ApiResponse.of(response)); + } + + @PostMapping("/reply") + @Operation(summary = "설문 답변 등록 API") + public ResponseEntity> saveSurveyAnswers(@RequestBody @Valid SurveyRequestDTO.SurveyAnswerDTO request) { + surveyService.saveSurveyAnswers(request); + return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS)); + } + + @GetMapping("/{surveyId}/answers/{questionId}") + @Operation(summary = "질문별 설문 응답 결과 조회 API", description = "Request: 설문지 ID와 질문 ID, Response: 해당 질문에 대한 응답 리스트") + public ResponseEntity> getSurveyAnswers( + @PathVariable Long surveyId, + @PathVariable Long questionId, + @RequestParam int page, + @RequestParam int size, + @CurrentAccount Account account) { + SurveyResponseDTO.SurveyAnswerResultDTO response = surveyService.getSurveyAnswers(surveyId, questionId, page, size, account.userId()); + return ResponseEntity.ok(ApiResponse.of(response)); + } + + @PostMapping("/draft") + @Operation(summary = "당첨자 랜덤 추첨 결과 리스트 조회 API(설문 이벤트)", description = "주관식 답변 중 조건을 설정하여 랜덤으로 당첨자를 추첨합니다.") + public ResponseEntity> selectRandomWinners( + @RequestBody @Valid SurveyRequestDTO.RandomSelectionRequestDTO request, @CurrentAccount Account account) { + SurveyResponseDTO.RandomSelectionResponseDTO response = surveyService.selectRandomWinners(request, account.userId()); + return ResponseEntity.ok(ApiResponse.of(response)); + } + + // 당첨자 세부 정보 조회 API + @GetMapping("/winners/{surveyId}/{participantId}") + @Operation(summary = "당첨자 세부 정보 조회 API", description = "설문 이벤트의 당첨자(익명 참여자) 정보를 조회하며, 해당 참여자가 응답한 모든 설문 내용을 반환합니다.") + public ResponseEntity> getWinnerDetails( + @PathVariable Long surveyId, @PathVariable Long participantId) { + SurveyResponseDTO.WinnerDetailDTO winnerDetails = surveyService.getWinnerDetails(surveyId, participantId); + return ResponseEntity.ok(ApiResponse.of(winnerDetails)); + } + + @DeleteMapping("/winners") + @Operation(summary = "당첨자 리스트 삭제 API", description = "해당 설문조사의 모든 당첨자들의 isWinner 값을 false로 변경합니다.") + public ResponseEntity> deleteWinners(@RequestParam Long surveyId) { + surveyService.deleteWinners(surveyId); + return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS)); + } +} diff --git a/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyRequestDTO.java b/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyRequestDTO.java new file mode 100644 index 0000000..992e07b --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyRequestDTO.java @@ -0,0 +1,118 @@ +package com.cmc.suppin.event.survey.controller.dto; + +import com.cmc.suppin.global.enums.QuestionType; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +public class SurveyRequestDTO { + + // 설문 생성 요청 DTO + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SurveyCreateDTO { + @NotNull + private Long eventId; + private List personalInfoOptionList; + private List questionList; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class QuestionDTO { + @NotBlank(message = "질문 유형: SUBJECTIVE(주관식), SINGLE_CHOICE(객관식(단일 선택)), MULTIPLE_CHOICE(객관식(복수 선택))") + private QuestionType questionType; + @NotBlank(message = "질문 내용을 입력해주세요") + private String questionText; + private List options; // 객관식 질문일 경우 선택지 리스트 + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class PersonalInfoOptionDTO { + @NotBlank(message = "개인정보 수집 항목을 입력해주세요") + private String optionName; + } + } + + // 설문 답변 요청 DTO + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SurveyAnswerDTO { + @NotNull + private Long surveyId; + @Valid + private ParticipantDTO participant; + @Valid + private List answers; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ParticipantDTO { + private String name; + private String address; + private String email; + private String phoneNumber; + private String instagramId; + @NotNull + private Boolean isAgreed; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AnswerDTO { + @NotNull + private Long questionId; + private String answerText; + private List answerOptions; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AnswerOptionDTO { + @NotNull + private Long questionOptionId; + } + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class RandomSelectionRequestDTO { + @NotNull + private Long surveyId; + @NotNull + private Long questionId; + @NotNull + private Integer winnerCount; + @NotNull + private LocalDateTime startDate; + @NotNull + private LocalDateTime endDate; + @NotNull + private Integer minLength; + @NotNull + private List keywords; + } +} diff --git a/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyResponseDTO.java b/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyResponseDTO.java new file mode 100644 index 0000000..d4c2d65 --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyResponseDTO.java @@ -0,0 +1,131 @@ +package com.cmc.suppin.event.survey.controller.dto; + +import com.cmc.suppin.global.enums.QuestionType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +public class SurveyResponseDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SurveyCreateResponse { + private Long surveyId; + private String uuid; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SurveyResultDTO { + private Long eventId; + private String eventTitle; + private String eventDescription; + private String startDate; + private String endDate; + private String announcementDate; + private List personalInfoOptions; + private List questions; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class QuestionDTO { + private QuestionType questionType; + private String questionText; + private List options; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class PersonalInfoOptionDTO { + private String optionName; + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SurveyAnswerResultDTO { + private Long questionId; + private String questionText; + private List answers; + private int totalPages; + private long totalElements; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AnswerDTO { + private String participantName; + private String answerText; + private List selectedOptions; + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class RandomSelectionResponseDTO { + private SelectionCriteriaDTO selectionCriteria; + private List winners; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class WinnerDTO { + private Long participantId; + private String participantName; + private String answerText; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SelectionCriteriaDTO { + private Integer winnerCount; + private LocalDateTime startDate; + private LocalDateTime endDate; + private Integer minLength; + private List keywords; + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class WinnerDetailDTO { + private String name; + private String phoneNumber; + private String address; + private String email; + private String instagramId; + private List answers; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AnswerDetailDTO { + private String questionText; + private String answerText; + private List selectedOptions; // 객관식 질문의 경우 선택된 옵션 리스트 + } + } +} diff --git a/src/main/java/com/cmc/suppin/event/survey/converter/.gitkeep b/src/main/java/com/cmc/suppin/event/survey/converter/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/cmc/suppin/event/survey/converter/SurveyConverter.java b/src/main/java/com/cmc/suppin/event/survey/converter/SurveyConverter.java new file mode 100644 index 0000000..498f47f --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/converter/SurveyConverter.java @@ -0,0 +1,157 @@ +package com.cmc.suppin.event.survey.converter; + +import com.cmc.suppin.event.events.domain.Event; +import com.cmc.suppin.event.survey.controller.dto.SurveyRequestDTO; +import com.cmc.suppin.event.survey.controller.dto.SurveyResponseDTO; +import com.cmc.suppin.event.survey.domain.*; +import org.springframework.data.domain.Page; + +import java.util.List; +import java.util.stream.Collectors; + +public class SurveyConverter { + + public static Survey toSurveyEntity(Event event, String uuid) { + return Survey.builder() + .event(event) + .uuid(uuid) + .build(); + } + + public static Question toQuestionEntity(SurveyRequestDTO.SurveyCreateDTO.QuestionDTO questionDTO, Survey survey) { + return Question.builder() + .survey(survey) + .questionType(questionDTO.getQuestionType()) + .questionText(questionDTO.getQuestionText()) + .build(); + } + + public static List toQuestionOptionEntities(List options, Question question) { + return options.stream() + .map(option -> QuestionOption.builder() + .optionText(option) + .question(question) + .build()) + .collect(Collectors.toList()); + } + + public static List toPersonalInfoCollectOptionEntities(List personalInfoOptions, Survey survey) { + return personalInfoOptions.stream() + .map(option -> PersonalInfoCollectOption.builder() + .optionName(option) + .survey(survey) + .build()) + .collect(Collectors.toList()); + } + + public static SurveyResponseDTO.SurveyResultDTO toSurveyResultDTO(Survey survey, Event event) { + List personalInfoOptions = survey.getPersonalInfoList().stream() + .map(option -> SurveyResponseDTO.SurveyResultDTO.PersonalInfoOptionDTO.builder() + .optionName(option.getOptionName()) + .build()) + .collect(Collectors.toList()); + + List questions = survey.getQuestionList().stream() + .map(question -> SurveyResponseDTO.SurveyResultDTO.QuestionDTO.builder() + .questionType(question.getQuestionType()) + .questionText(question.getQuestionText()) + .options(question.getQuestionOptionList().stream() + .map(QuestionOption::getOptionText) + .collect(Collectors.toList())) + .build()) + .collect(Collectors.toList()); + + return SurveyResponseDTO.SurveyResultDTO.builder() + .eventId(event.getId()) + .eventTitle(event.getTitle()) + .eventDescription(event.getDescription()) + .startDate(event.getStartDate().toString()) + .endDate(event.getEndDate().toString()) + .announcementDate(event.getAnnouncementDate().toString()) + .personalInfoOptions(personalInfoOptions) + .questions(questions) + .build(); + } + + public static SurveyResponseDTO.SurveyAnswerResultDTO toSurveyAnswerResultDTO(Question question, Page answersPage) { + List answers = answersPage.stream() + .map(answer -> SurveyResponseDTO.SurveyAnswerResultDTO.AnswerDTO.builder() + .participantName(answer.getAnonymousParticipant().getName()) + .answerText(answer.getAnswerText()) + .selectedOptions(answer.getAnswerOptionList().stream() + .map(answerOption -> answerOption.getQuestionOption().getOptionText()) + .collect(Collectors.toList())) + .build()) + .collect(Collectors.toList()); + + return SurveyResponseDTO.SurveyAnswerResultDTO.builder() + .questionId(question.getId()) + .questionText(question.getQuestionText()) + .answers(answers) + .totalPages(answersPage.getTotalPages()) + .totalElements(answersPage.getTotalElements()) + .build(); + } + + public static AnonymousParticipant toAnonymousParticipant(SurveyRequestDTO.SurveyAnswerDTO.ParticipantDTO dto, Survey survey) { + return AnonymousParticipant.builder() + .survey(survey) + .name(dto.getName()) + .address(dto.getAddress()) + .email(dto.getEmail()) + .phoneNumber(dto.getPhoneNumber()) + .isAgreed(dto.getIsAgreed()) + .build(); + } + + public static Answer toAnswer(SurveyRequestDTO.SurveyAnswerDTO.AnswerDTO dto, Question question, AnonymousParticipant participant) { + return Answer.builder() + .question(question) + .anonymousParticipant(participant) + .answerText(dto.getAnswerText()) + .build(); + } + + public static AnswerOption toAnswerOption(SurveyRequestDTO.SurveyAnswerDTO.AnswerDTO.AnswerOptionDTO dto, Answer answer, QuestionOption questionOption) { + return AnswerOption.builder() + .answer(answer) + .questionOption(questionOption) + .build(); + } + + public static SurveyResponseDTO.RandomSelectionResponseDTO.SelectionCriteriaDTO toSelectionCriteriaDTO(SurveyRequestDTO.RandomSelectionRequestDTO request) { + return SurveyResponseDTO.RandomSelectionResponseDTO.SelectionCriteriaDTO.builder() + .winnerCount(request.getWinnerCount()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .minLength(request.getMinLength()) + .keywords(request.getKeywords()) + .build(); + } + + public static SurveyResponseDTO.RandomSelectionResponseDTO.WinnerDTO toWinnerDTO(AnonymousParticipant participant, String answerText) { + return SurveyResponseDTO.RandomSelectionResponseDTO.WinnerDTO.builder() + .participantId(participant.getId()) + .participantName(participant.getName()) + .answerText(answerText) + .build(); + } + + public static SurveyResponseDTO.RandomSelectionResponseDTO toRandomSelectionResponseDTO(List winners, SurveyResponseDTO.RandomSelectionResponseDTO.SelectionCriteriaDTO criteria) { + return SurveyResponseDTO.RandomSelectionResponseDTO.builder() + .winners(winners) + .selectionCriteria(criteria) + .build(); + } + + public static SurveyResponseDTO.WinnerDetailDTO toWinnerDetailDTO(AnonymousParticipant participant, List answers) { + return SurveyResponseDTO.WinnerDetailDTO.builder() + .name(participant.getName()) + .phoneNumber(participant.getPhoneNumber()) + .address(participant.getAddress()) + .email(participant.getEmail()) + .instagramId(participant.getInstagramId()) + .answers(answers) + .build(); + } +} diff --git a/src/main/java/com/cmc/suppin/answer/domain/AnonymousParticipant.java b/src/main/java/com/cmc/suppin/event/survey/domain/AnonymousParticipant.java similarity index 68% rename from src/main/java/com/cmc/suppin/answer/domain/AnonymousParticipant.java rename to src/main/java/com/cmc/suppin/event/survey/domain/AnonymousParticipant.java index 111109d..5bdac04 100644 --- a/src/main/java/com/cmc/suppin/answer/domain/AnonymousParticipant.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/AnonymousParticipant.java @@ -1,6 +1,5 @@ -package com.cmc.suppin.answer.domain; +package com.cmc.suppin.event.survey.domain; -import com.cmc.suppin.event.survey.domain.Survey; import com.cmc.suppin.global.domain.BaseDateTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -25,12 +24,28 @@ public class AnonymousParticipant extends BaseDateTimeEntity { @JoinColumn(name = "survey_id") private Survey survey; - @Column(columnDefinition = "VARCHAR(13)", nullable = false) + @OneToMany(mappedBy = "anonymousParticipant") + private List answerList = new ArrayList<>(); + + private String name; + + private String address; + + private String email; + + private String instagramId; + + @Column(columnDefinition = "VARCHAR(20)", unique = true) private String phoneNumber; @Column(nullable = false) private Boolean isAgreed; - @OneToMany(mappedBy = "anonymousParticipant") - private List answerList = new ArrayList<>(); + private Boolean isWinner; + + private Boolean isChecked; + + public void setIsWinner(Boolean isWinner) { + this.isWinner = isWinner; + } } diff --git a/src/main/java/com/cmc/suppin/answer/domain/Answer.java b/src/main/java/com/cmc/suppin/event/survey/domain/Answer.java similarity index 84% rename from src/main/java/com/cmc/suppin/answer/domain/Answer.java rename to src/main/java/com/cmc/suppin/event/survey/domain/Answer.java index c200f17..4cbd141 100644 --- a/src/main/java/com/cmc/suppin/answer/domain/Answer.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/Answer.java @@ -1,6 +1,5 @@ -package com.cmc.suppin.answer.domain; +package com.cmc.suppin.event.survey.domain; -import com.cmc.suppin.event.survey.domain.Question; import com.cmc.suppin.global.domain.BaseDateTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -33,7 +32,7 @@ public class Answer extends BaseDateTimeEntity { @OneToMany(mappedBy = "answer") private List answerOptionList = new ArrayList<>(); - @Column(columnDefinition = "TEXT", nullable = false) + @Column(columnDefinition = "TEXT") private String answerText; } diff --git a/src/main/java/com/cmc/suppin/answer/domain/AnswerOption.java b/src/main/java/com/cmc/suppin/event/survey/domain/AnswerOption.java similarity index 85% rename from src/main/java/com/cmc/suppin/answer/domain/AnswerOption.java rename to src/main/java/com/cmc/suppin/event/survey/domain/AnswerOption.java index 6eac119..dda4506 100644 --- a/src/main/java/com/cmc/suppin/answer/domain/AnswerOption.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/AnswerOption.java @@ -1,6 +1,5 @@ -package com.cmc.suppin.answer.domain; +package com.cmc.suppin.event.survey.domain; -import com.cmc.suppin.event.survey.domain.QuestionOption; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.DynamicInsert; diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/PersonalInfoCollectOption.java b/src/main/java/com/cmc/suppin/event/survey/domain/PersonalInfoCollectOption.java new file mode 100644 index 0000000..d13918d --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/domain/PersonalInfoCollectOption.java @@ -0,0 +1,24 @@ +package com.cmc.suppin.event.survey.domain; + +import com.cmc.suppin.global.domain.BaseDateTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class PersonalInfoCollectOption extends BaseDateTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "personal_info_id") + private Long id; + + @ManyToOne + @JoinColumn(name = "survey_id") + private Survey survey; + + @Column(nullable = false) + private String optionName; +} diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/Question.java b/src/main/java/com/cmc/suppin/event/survey/domain/Question.java index 5df025f..98c86a5 100644 --- a/src/main/java/com/cmc/suppin/event/survey/domain/Question.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/Question.java @@ -1,6 +1,5 @@ package com.cmc.suppin.event.survey.domain; -import com.cmc.suppin.answer.domain.Answer; import com.cmc.suppin.global.enums.QuestionType; import jakarta.persistence.*; import lombok.*; @@ -18,7 +17,8 @@ public class Question { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long questionId; + @Column(name = "question_id") + private Long id; @ManyToOne @JoinColumn(name = "survey_id") diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/QuestionOption.java b/src/main/java/com/cmc/suppin/event/survey/domain/QuestionOption.java index daae3eb..d21d337 100644 --- a/src/main/java/com/cmc/suppin/event/survey/domain/QuestionOption.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/QuestionOption.java @@ -1,6 +1,5 @@ package com.cmc.suppin.event.survey.domain; -import com.cmc.suppin.answer.domain.AnswerOption; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.DynamicInsert; diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/Survey.java b/src/main/java/com/cmc/suppin/event/survey/domain/Survey.java index 39053a5..f359fb1 100644 --- a/src/main/java/com/cmc/suppin/event/survey/domain/Survey.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/Survey.java @@ -1,6 +1,5 @@ package com.cmc.suppin.event.survey.domain; -import com.cmc.suppin.answer.domain.AnonymousParticipant; import com.cmc.suppin.event.events.domain.Event; import com.cmc.suppin.global.domain.BaseDateTimeEntity; import jakarta.persistence.*; @@ -26,11 +25,19 @@ public class Survey extends BaseDateTimeEntity { @JoinColumn(name = "event_id") private Event event; + @OneToMany(mappedBy = "survey", cascade = CascadeType.ALL, orphanRemoval = true) + private List personalInfoList = new ArrayList<>(); + @OneToMany(mappedBy = "survey") private List questionList = new ArrayList<>(); @OneToMany(mappedBy = "survey") private List anonymousParticipantList = new ArrayList<>(); + @Column(columnDefinition = "TEXT") + private String url; + + @Column(nullable = false, updatable = false, unique = true) + private String uuid; } diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnonymousParticipantRepository.java b/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnonymousParticipantRepository.java new file mode 100644 index 0000000..6e94add --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnonymousParticipantRepository.java @@ -0,0 +1,16 @@ +package com.cmc.suppin.event.survey.domain.repository; + +import com.cmc.suppin.event.survey.domain.AnonymousParticipant; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AnonymousParticipantRepository extends JpaRepository { + boolean existsByPhoneNumberAndSurveyId(String phoneNumber, Long surveyId); + + Optional findByIdAndSurveyIdAndIsWinnerTrue(Long id, Long surveyId); + + List findBySurveyIdAndIsWinnerTrue(Long surveyId); + +} diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnswerCustomRepository.java b/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnswerCustomRepository.java new file mode 100644 index 0000000..1e9a02b --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnswerCustomRepository.java @@ -0,0 +1,48 @@ +package com.cmc.suppin.event.survey.domain.repository; + +import com.cmc.suppin.event.survey.domain.Answer; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Repository +public class AnswerCustomRepository { + + @PersistenceContext + private EntityManager entityManager; + + public List findEligibleAnswers(Long questionId, LocalDateTime startDate, LocalDateTime endDate, Integer minLength, List keywords) { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(Answer.class); + Root answer = query.from(Answer.class); + + List predicates = new ArrayList<>(); + + // 기본 조건 추가 + predicates.add(cb.equal(answer.get("question").get("id"), questionId)); + predicates.add(cb.greaterThanOrEqualTo(answer.get("createdAt"), startDate)); + predicates.add(cb.lessThanOrEqualTo(answer.get("createdAt"), endDate)); + predicates.add(cb.ge(cb.length(answer.get("answerText")), minLength)); + + // 키워드 조건 추가 + if (keywords != null && !keywords.isEmpty()) { + List keywordPredicates = new ArrayList<>(); + for (String keyword : keywords) { + keywordPredicates.add(cb.like(cb.lower(answer.get("answerText")), "%" + keyword.toLowerCase() + "%")); + } + predicates.add(cb.or(keywordPredicates.toArray(new Predicate[0]))); + } + + query.where(predicates.toArray(new Predicate[0])); + + return entityManager.createQuery(query).getResultList(); + } +} diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnswerOptionRepository.java b/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnswerOptionRepository.java new file mode 100644 index 0000000..615ddd7 --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnswerOptionRepository.java @@ -0,0 +1,7 @@ +package com.cmc.suppin.event.survey.domain.repository; + +import com.cmc.suppin.event.survey.domain.AnswerOption; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AnswerOptionRepository extends JpaRepository { +} diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnswerRepository.java b/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnswerRepository.java new file mode 100644 index 0000000..75dedda --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnswerRepository.java @@ -0,0 +1,29 @@ +package com.cmc.suppin.event.survey.domain.repository; + +import com.cmc.suppin.event.survey.domain.Answer; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AnswerRepository extends JpaRepository { + Page findByQuestionId(Long questionId, Pageable pageable); + + /* + @Query("SELECT a FROM Answer a WHERE a.question.id = :questionId AND " + + "a.createdAt >= :startDate AND " + + "a.createdAt <= :endDate AND " + + "LENGTH(a.answerText) >= :minLength AND " + + "(COALESCE(:keywords, NULL) IS NULL OR " + + "EXISTS (SELECT 1 FROM Answer a2 WHERE a2.id = a.id AND (" + + "LOWER(a2.answerText) LIKE LOWER(CONCAT('%', :#{#keywords[0]}, '%'))" + + " OR LOWER(a2.answerText) LIKE LOWER(CONCAT('%', :#{#keywords[1]}, '%'))" + + // 추가적인 키워드 OR 조건을 여기에 동적으로 추가해야 합니다. + ")))") + List findEligibleAnswers(@Param("questionId") Long questionId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate, + @Param("minLength") Integer minLength, + @Param("keywords") List keywords); + */ +} + diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/repository/PersonalInfoCollectOptionRepository.java b/src/main/java/com/cmc/suppin/event/survey/domain/repository/PersonalInfoCollectOptionRepository.java new file mode 100644 index 0000000..de3f235 --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/domain/repository/PersonalInfoCollectOptionRepository.java @@ -0,0 +1,7 @@ +package com.cmc.suppin.event.survey.domain.repository; + +import com.cmc.suppin.event.survey.domain.PersonalInfoCollectOption; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PersonalInfoCollectOptionRepository extends JpaRepository { +} diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/repository/QuestionOptionRepository.java b/src/main/java/com/cmc/suppin/event/survey/domain/repository/QuestionOptionRepository.java new file mode 100644 index 0000000..687e618 --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/domain/repository/QuestionOptionRepository.java @@ -0,0 +1,8 @@ +package com.cmc.suppin.event.survey.domain.repository; + +import com.cmc.suppin.event.survey.domain.QuestionOption; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QuestionOptionRepository extends JpaRepository { + +} diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/repository/QuestionRepository.java b/src/main/java/com/cmc/suppin/event/survey/domain/repository/QuestionRepository.java new file mode 100644 index 0000000..09c9c80 --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/domain/repository/QuestionRepository.java @@ -0,0 +1,12 @@ +package com.cmc.suppin.event.survey.domain.repository; + +import com.cmc.suppin.event.survey.domain.Question; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface QuestionRepository extends JpaRepository { + + + Optional findByIdAndSurveyId(Long questionId, Long surveyId); +} diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/repository/SurveyRepository.java b/src/main/java/com/cmc/suppin/event/survey/domain/repository/SurveyRepository.java new file mode 100644 index 0000000..afb0fc2 --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/domain/repository/SurveyRepository.java @@ -0,0 +1,9 @@ +package com.cmc.suppin.event.survey.domain.repository; + +import com.cmc.suppin.event.survey.domain.Survey; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SurveyRepository extends JpaRepository { + + +} diff --git a/src/main/java/com/cmc/suppin/event/survey/exception/.gitkeep b/src/main/java/com/cmc/suppin/event/survey/exception/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/cmc/suppin/event/survey/exception/SurveyErrorCode.java b/src/main/java/com/cmc/suppin/event/survey/exception/SurveyErrorCode.java new file mode 100644 index 0000000..e5f12c2 --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/exception/SurveyErrorCode.java @@ -0,0 +1,28 @@ +package com.cmc.suppin.event.survey.exception; + +import com.cmc.suppin.global.exception.BaseErrorCode; +import com.cmc.suppin.global.response.ErrorResponse; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum SurveyErrorCode implements BaseErrorCode { + + DUPLICATE_PHONENUMBER("survey-404/01", HttpStatus.CONFLICT, "이미 참여한 참가자입니다. 중복해서 참여할 수 없습니다."); + + + private final String code; + private final HttpStatus status; + private final String message; + + SurveyErrorCode(String code, HttpStatus status, String message) { + this.code = code; + this.status = status; + this.message = message; + } + + @Override + public ErrorResponse getErrorResponse() { + return ErrorResponse.of(code, message); + } +} diff --git a/src/main/java/com/cmc/suppin/event/survey/exception/SurveyException.java b/src/main/java/com/cmc/suppin/event/survey/exception/SurveyException.java new file mode 100644 index 0000000..5cc6304 --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/exception/SurveyException.java @@ -0,0 +1,13 @@ +package com.cmc.suppin.event.survey.exception; + +import com.cmc.suppin.global.exception.BaseErrorCode; +import com.cmc.suppin.global.exception.CustomException; +import lombok.Getter; + +@Getter +public class SurveyException extends CustomException { + + public SurveyException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/cmc/suppin/event/survey/service/.gitkeep b/src/main/java/com/cmc/suppin/event/survey/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/cmc/suppin/event/survey/service/SurveyService.java b/src/main/java/com/cmc/suppin/event/survey/service/SurveyService.java new file mode 100644 index 0000000..8125467 --- /dev/null +++ b/src/main/java/com/cmc/suppin/event/survey/service/SurveyService.java @@ -0,0 +1,220 @@ +package com.cmc.suppin.event.survey.service; + +import com.cmc.suppin.event.events.domain.Event; +import com.cmc.suppin.event.events.domain.repository.EventRepository; +import com.cmc.suppin.event.survey.controller.dto.SurveyRequestDTO; +import com.cmc.suppin.event.survey.controller.dto.SurveyResponseDTO; +import com.cmc.suppin.event.survey.converter.SurveyConverter; +import com.cmc.suppin.event.survey.domain.*; +import com.cmc.suppin.event.survey.domain.repository.*; +import com.cmc.suppin.event.survey.exception.SurveyErrorCode; +import com.cmc.suppin.event.survey.exception.SurveyException; +import com.cmc.suppin.global.enums.UserStatus; +import com.cmc.suppin.member.domain.Member; +import com.cmc.suppin.member.domain.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional +public class SurveyService { + + private final MemberRepository memberRepository; + private final SurveyRepository surveyRepository; + private final QuestionRepository questionRepository; + private final QuestionOptionRepository questionOptionRepository; + private final PersonalInfoCollectOptionRepository personalInfoCollectOptionRepository; + private final EventRepository eventRepository; + private final AnonymousParticipantRepository anonymousParticipantRepository; + private final AnswerRepository answerRepository; + private final AnswerOptionRepository answerOptionRepository; + private final AnswerCustomRepository answerCustomRepository; + + @Transactional + public SurveyResponseDTO.SurveyCreateResponse createSurvey(SurveyRequestDTO.SurveyCreateDTO request, String userId) { + // 사용자 식별 + Member member = memberRepository.findByUserIdAndStatusNot(userId, UserStatus.DELETED) + .orElseThrow(() -> new IllegalArgumentException("Member not found")); + + // 이벤트 식별 + Event event = eventRepository.findByIdAndMemberId(request.getEventId(), member.getId()) + .orElseThrow(() -> new IllegalArgumentException("Event not found or does not belong to the user")); + + // Survey 엔티티 생성 및 저장 + String uuid = UUID.randomUUID().toString(); + Survey survey = SurveyConverter.toSurveyEntity(event, uuid); + surveyRepository.save(survey); + + // 각 개인정보 항목 처리 및 저장 + if (request.getPersonalInfoOptionList() != null && !request.getPersonalInfoOptionList().isEmpty()) { + List personalInfoOptions = SurveyConverter.toPersonalInfoCollectOptionEntities( + request.getPersonalInfoOptionList().stream().map(SurveyRequestDTO.SurveyCreateDTO.PersonalInfoOptionDTO::getOptionName).collect(Collectors.toList()), survey); + personalInfoCollectOptionRepository.saveAll(personalInfoOptions); + } + + // 각 질문 처리 및 저장 + for (SurveyRequestDTO.SurveyCreateDTO.QuestionDTO questionDTO : request.getQuestionList()) { + Question question = SurveyConverter.toQuestionEntity(questionDTO, survey); + questionRepository.save(question); + + // 객관식 복수 선택 질문인 경우 처리 및 저장 + if (questionDTO.getOptions() != null && !questionDTO.getOptions().isEmpty()) { + List options = SurveyConverter.toQuestionOptionEntities(questionDTO.getOptions(), question); + questionOptionRepository.saveAll(options); + } + } + + return SurveyResponseDTO.SurveyCreateResponse.builder() + .surveyId(survey.getId()) + .uuid(survey.getUuid()) + .build(); + } + + // 생성된 설문지 조회 + @Transactional(readOnly = true) + public SurveyResponseDTO.SurveyResultDTO getSurvey(Long surveyId) { + Survey survey = surveyRepository.findById(surveyId) + .orElseThrow(() -> new IllegalArgumentException("Survey not found")); + + Event event = survey.getEvent(); + return SurveyConverter.toSurveyResultDTO(survey, event); + } + + // 설문 응답 저장 + @Transactional + public void saveSurveyAnswers(SurveyRequestDTO.SurveyAnswerDTO request) { + Survey survey = surveyRepository.findById(request.getSurveyId()) + .orElseThrow(() -> new IllegalArgumentException("Survey not found")); + + // 중복 핸드폰 번호 체크 + String phoneNumber = request.getParticipant().getPhoneNumber(); + boolean exists = anonymousParticipantRepository.existsByPhoneNumberAndSurveyId(phoneNumber, request.getSurveyId()); + if (exists) { + throw new SurveyException(SurveyErrorCode.DUPLICATE_PHONENUMBER); + } + + AnonymousParticipant participant = SurveyConverter.toAnonymousParticipant(request.getParticipant(), survey); + anonymousParticipantRepository.save(participant); + + for (SurveyRequestDTO.SurveyAnswerDTO.AnswerDTO answerDTO : request.getAnswers()) { + Question question = questionRepository.findById(answerDTO.getQuestionId()) + .orElseThrow(() -> new IllegalArgumentException("Question not found")); + + Answer answer = SurveyConverter.toAnswer(answerDTO, question, participant); + answerRepository.save(answer); + + if (answerDTO.getAnswerOptions() != null) { + for (SurveyRequestDTO.SurveyAnswerDTO.AnswerDTO.AnswerOptionDTO optionDTO : answerDTO.getAnswerOptions()) { + QuestionOption questionOption = questionOptionRepository.findById(optionDTO.getQuestionOptionId()) + .orElseThrow(() -> new IllegalArgumentException("QuestionOption not found")); + + AnswerOption answerOption = SurveyConverter.toAnswerOption(optionDTO, answer, questionOption); + answerOptionRepository.save(answerOption); + } + } + } + } + + // 질문별 설문 응답 결과 조회 + @Transactional(readOnly = true) + public SurveyResponseDTO.SurveyAnswerResultDTO getSurveyAnswers(Long surveyId, Long questionId, int page, int size, String userId) { + // 사용자 식별 + Member member = memberRepository.findByUserIdAndStatusNot(userId, UserStatus.DELETED) + .orElseThrow(() -> new IllegalArgumentException("Member not found")); + + Question question = questionRepository.findByIdAndSurveyId(questionId, surveyId) + .orElseThrow(() -> new IllegalArgumentException("Question not found for the given survey")); + + Pageable pageable = PageRequest.of(page - 1, size); + Page answersPage = answerRepository.findByQuestionId(questionId, pageable); + + return SurveyConverter.toSurveyAnswerResultDTO(question, answersPage); + } + + @Transactional + public SurveyResponseDTO.RandomSelectionResponseDTO selectRandomWinners(SurveyRequestDTO.RandomSelectionRequestDTO request, String userId) { + // 사용자 식별 + Member member = memberRepository.findByUserIdAndStatusNot(userId, UserStatus.DELETED) + .orElseThrow(() -> new IllegalArgumentException("Member not found")); + + // 설문 및 질문 식별 + Survey survey = surveyRepository.findById(request.getSurveyId()) + .orElseThrow(() -> new IllegalArgumentException("Survey not found")); + + Question question = questionRepository.findByIdAndSurveyId(request.getQuestionId(), request.getSurveyId()) + .orElseThrow(() -> new IllegalArgumentException("Question not found for the given survey")); + + // 키워드를 OR 조건으로 연결 + List keywordPatterns = request.getKeywords().stream() + .map(keyword -> "%" + keyword.toLowerCase() + "%") + .collect(Collectors.toList()); + + // 조건에 맞는 주관식 답변 조회 + List eligibleAnswers = answerCustomRepository.findEligibleAnswers( + request.getQuestionId(), request.getStartDate(), request.getEndDate(), + request.getMinLength(), request.getKeywords()); + + // 랜덤 추첨 + Collections.shuffle(eligibleAnswers); + List selectedWinners = eligibleAnswers.stream() + .limit(request.getWinnerCount()) + .collect(Collectors.toList()); + + // 당첨자 업데이트 및 WinnerDTO 생성 + List winners = selectedWinners.stream() + .map(answer -> { + AnonymousParticipant participant = answer.getAnonymousParticipant(); + participant.setIsWinner(true); // isWinner 값을 True로 설정 + anonymousParticipantRepository.save(participant); // 저장 + + return SurveyConverter.toWinnerDTO(participant, answer.getAnswerText()); + }) + .collect(Collectors.toList()); + + // 응답시, 조건도 함께 포함해주기 위한 조건 객체 생성 + SurveyResponseDTO.RandomSelectionResponseDTO.SelectionCriteriaDTO criteria = SurveyConverter.toSelectionCriteriaDTO(request); + + // 응답 객체 생성 + return SurveyConverter.toRandomSelectionResponseDTO(winners, criteria); + } + + @Transactional(readOnly = true) + public SurveyResponseDTO.WinnerDetailDTO getWinnerDetails(Long surveyId, Long participantId) { + AnonymousParticipant participant = anonymousParticipantRepository.findByIdAndSurveyIdAndIsWinnerTrue(participantId, surveyId) + .orElseThrow(() -> new IllegalArgumentException("Winner not found for the given survey")); + + // 모든 답변을 조회하여 응답 DTO로 변환 + List answers = participant.getAnswerList().stream() + .map(answer -> SurveyResponseDTO.WinnerDetailDTO.AnswerDetailDTO.builder() + .questionText(answer.getQuestion().getQuestionText()) + .answerText(answer.getAnswerText()) + .selectedOptions(answer.getAnswerOptionList().stream() + .map(answerOption -> answerOption.getQuestionOption().getOptionText()) + .collect(Collectors.toList())) + .build()) + .collect(Collectors.toList()); + + return SurveyConverter.toWinnerDetailDTO(participant, answers); + } + + public void deleteWinners(Long surveyId) { + List participants = anonymousParticipantRepository.findBySurveyIdAndIsWinnerTrue(surveyId); + + for (AnonymousParticipant participant : participants) { + participant.setIsWinner(false); + anonymousParticipantRepository.save(participant); + } + } +} diff --git a/src/main/java/com/cmc/suppin/global/enums/QuestionType.java b/src/main/java/com/cmc/suppin/global/enums/QuestionType.java index e22b96d..8e768c6 100644 --- a/src/main/java/com/cmc/suppin/global/enums/QuestionType.java +++ b/src/main/java/com/cmc/suppin/global/enums/QuestionType.java @@ -1,7 +1,7 @@ package com.cmc.suppin.global.enums; public enum QuestionType { - SHORT_ANSWER("주관식"), + SUBJECTIVE("주관식"), SINGLE_CHOICE("객관식(단일 선택)"), MULTIPLE_CHOICE("객관식(복수 선택)");