Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ public record SurveyOptionResult(
Long id,
String code,
String label,
Integer value,
int sortOrder,
boolean exclusive,
boolean active
) {

public static SurveyOptionResult from(SurveyOption o) {
return new SurveyOptionResult(o.getId(), o.getCode(), o.getLabel(), o.getSortOrder(),
return new SurveyOptionResult(o.getId(), o.getCode(), o.getLabel(), o.getValue(), o.getSortOrder(),
o.isExclusive(), o.isActive());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
public record SurveyQuestionResult(
String key,
String title,
boolean active,
List<SurveyOptionResult> options
) {

public static SurveyQuestionResult of(SurveyQuestion question, List<SurveyOptionResult> options) {
return new SurveyQuestionResult(question.getQuestionKey().name(), question.getTitle(), options);
return new SurveyQuestionResult(question.getQuestionKey(), question.getTitle(), question.isActive(),
options);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import com.seoulection.admin.survey.application.dto.SurveyQuestionResult;
import com.seoulection.admin.survey.domain.entity.SurveyOption;
import com.seoulection.admin.survey.domain.entity.SurveyQuestion;
import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey;
import com.seoulection.admin.survey.domain.repository.SurveyOptionRepository;
import com.seoulection.admin.survey.domain.repository.SurveyQuestionRepository;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -37,25 +36,39 @@ public List<SurveyQuestionResult> getQuestions() {
.toList();
}

/**
* 문항 추가. {@code questionKey}는 자연 PK라 유일해야 한다 — 중복을 그냥 저장하면 upsert라서
* 기존 문항을 조용히 덮어써 버린다(제목·순서가 실수로 바뀐다). 그래서 여기서 먼저 걸러 안내한다.
*/
@Transactional
public void createQuestion(String questionKey, String title, int sortOrder) {
String normalizedKey = questionKey == null ? "" : questionKey.trim().toUpperCase();
if (questionRepository.existsByKey(normalizedKey)) {
throw new IllegalArgumentException("이미 있는 문항입니다: " + normalizedKey);
}
questionRepository.save(SurveyQuestion.create(normalizedKey, title, sortOrder));
}

/**
* 선택지 추가. {@code code}는 문항 안에서 유일해야 한다 — 중복을 허용하면 사용자 응답이 어느 쪽을
* 가리키는지 알 수 없어진다(DB 유니크 제약도 있지만 여기서 먼저 걸러 안내 문구를 준다).
*/
@Transactional
public void createOption(SurveyQuestionKey questionKey, String code, String label,
public void createOption(String questionKey, String code, String label, Integer value,
int sortOrder, boolean exclusive) {
String normalizedCode = code == null ? "" : code.trim().toUpperCase();
if (optionRepository.existsByQuestionKeyAndCode(questionKey, normalizedCode)) {
throw new IllegalArgumentException("이미 있는 코드입니다: " + normalizedCode);
}
optionRepository.save(SurveyOption.create(questionKey, normalizedCode, label, sortOrder, exclusive));
optionRepository.save(
SurveyOption.create(questionKey, normalizedCode, label, value, sortOrder, exclusive));
}

/** 문구·순서·단독선택 수정. {@code code}는 대상이 아니다(도메인이 막는다). */
/** 문구·점수·순서·단독선택 수정. {@code code}는 대상이 아니다(도메인이 막는다). */
@Transactional
public void updateOption(Long optionId, String label, int sortOrder, boolean exclusive) {
public void updateOption(Long optionId, String label, Integer value, int sortOrder, boolean exclusive) {
SurveyOption option = getOption(optionId);
optionRepository.save(option.withDetails(label, sortOrder, exclusive));
optionRepository.save(option.withDetails(label, value, sortOrder, exclusive));
}

/**
Expand All @@ -69,13 +82,29 @@ public void changeOptionActive(Long optionId, boolean active) {
}

@Transactional
public void updateQuestionTitle(SurveyQuestionKey questionKey, String title) {
SurveyQuestion question = questionRepository.findByKey(questionKey)
.orElseThrow(() -> new IllegalArgumentException("없는 문항입니다: " + questionKey));
public void updateQuestionTitle(String questionKey, String title) {
SurveyQuestion question = getQuestion(questionKey);
questionRepository.save(question.withTitle(title));
}

private List<SurveyOptionResult> findOptions(SurveyQuestionKey questionKey) {
/**
* 문항 노출/숨김 전환. <b>이것이 문항 삭제다</b> — 이미 이 문항으로 답한 응답이 있어, 행을 지우면
* 그 응답이 가리키는 문항 문구를 되찾을 수 없다({@link #changeOptionActive} 참조). 되살리기도 같은 경로다.
*
* <p>숨겨도 이미 제출된 응답과 그 선택지들은 그대로 남는다 — {@code GET /survey/questions}에서만 빠진다.
*/
@Transactional
public void changeQuestionActive(String questionKey, boolean active) {
SurveyQuestion question = getQuestion(questionKey);
questionRepository.save(question.withActive(active));
}

private SurveyQuestion getQuestion(String questionKey) {
return questionRepository.findByKey(questionKey)
.orElseThrow(() -> new IllegalArgumentException("없는 문항입니다: " + questionKey));
}

private List<SurveyOptionResult> findOptions(String questionKey) {
return optionRepository.findByQuestionKey(questionKey).stream()
.map(SurveyOptionResult::from)
.toList();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
package com.seoulection.admin.survey.domain.entity;

import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey;

import java.util.Objects;
import java.util.regex.Pattern;

/**
* 설문 선택지 — 관리자가 CRUD하는 대상.
*
* <p><b>{@code code}는 생성 시 확정되고 이후 바뀌지 않는다.</b> 사용자 응답이
* {@code survey_response.avoidances} jsonb 배열에 이 문자열로 저장돼 있어서, 코드를 고치면 이미 제출된
* {@code survey_answer.codes} jsonb 배열에 이 문자열로 저장돼 있어서, 코드를 고치면 이미 제출된
* 응답이 가리키는 대상이 사라진다. 그래서 수정 메서드({@link #withDetails})에 code가 없다 —
* 화면에서 막는 게 아니라 <b>타입에서 막는다.</b>
*
Expand All @@ -21,44 +19,46 @@ public class SurveyOption {
private static final Pattern CODE_FORMAT = Pattern.compile("^[A-Z][A-Z0-9_]*$");

private final Long id;
private final SurveyQuestionKey questionKey;
private final String questionKey;
private final String code;
private final String label;
private final Integer value;
private final int sortOrder;
private final boolean exclusive;
private final boolean active;

private SurveyOption(Long id, SurveyQuestionKey questionKey, String code, String label,
private SurveyOption(Long id, String questionKey, String code, String label, Integer value,
int sortOrder, boolean exclusive, boolean active) {
this.id = id;
this.questionKey = Objects.requireNonNull(questionKey, "questionKey");
this.code = requireCode(code);
this.label = requireText(label, "label");
this.value = requireValidValue(value);
this.sortOrder = requireNonNegative(sortOrder);
this.exclusive = exclusive;
this.active = active;
}

/** 새 선택지. 활성 상태로 시작한다. */
public static SurveyOption create(SurveyQuestionKey questionKey, String code, String label,
/** 새 선택지. 활성 상태로 시작한다. {@code value}는 카테고리성 선택지(회피 항목 등)라면 null. */
public static SurveyOption create(String questionKey, String code, String label, Integer value,
int sortOrder, boolean exclusive) {
return new SurveyOption(null, questionKey, code, label, sortOrder, exclusive, true);
return new SurveyOption(null, questionKey, code, label, value, sortOrder, exclusive, true);
}

/** 저장소가 읽어온 기존 행을 복원할 때 쓴다. */
public static SurveyOption of(Long id, SurveyQuestionKey questionKey, String code, String label,
public static SurveyOption of(Long id, String questionKey, String code, String label, Integer value,
int sortOrder, boolean exclusive, boolean active) {
return new SurveyOption(id, questionKey, code, label, sortOrder, exclusive, active);
return new SurveyOption(id, questionKey, code, label, value, sortOrder, exclusive, active);
}

/** 문구·순서·단독선택 여부를 고친다. <b>code와 active는 대상이 아니다.</b> */
public SurveyOption withDetails(String newLabel, int newSortOrder, boolean newExclusive) {
return new SurveyOption(id, questionKey, code, newLabel, newSortOrder, newExclusive, active);
/** 문구·점수·순서·단독선택 여부를 고친다. <b>code와 active는 대상이 아니다.</b> */
public SurveyOption withDetails(String newLabel, Integer newValue, int newSortOrder, boolean newExclusive) {
return new SurveyOption(id, questionKey, code, newLabel, newValue, newSortOrder, newExclusive, active);
}

/** 노출/숨김. {@code false}가 곧 삭제다(행은 남는다). */
public SurveyOption withActive(boolean newActive) {
return new SurveyOption(id, questionKey, code, label, sortOrder, exclusive, newActive);
return new SurveyOption(id, questionKey, code, label, value, sortOrder, exclusive, newActive);
}

private static String requireCode(String code) {
Expand All @@ -83,11 +83,19 @@ private static int requireNonNegative(int sortOrder) {
return sortOrder;
}

/** 카테고리성 선택지는 점수 개념이 없어 null을 허용한다. 있다면 0~100 범위여야 한다. */
private static Integer requireValidValue(Integer value) {
if (value != null && (value < 0 || value > 100)) {
throw new IllegalArgumentException("점수는 0에서 100 사이여야 합니다.");
}
return value;
}

public Long getId() {
return id;
}

public SurveyQuestionKey getQuestionKey() {
public String getQuestionKey() {
return questionKey;
}

Expand All @@ -99,6 +107,10 @@ public String getLabel() {
return label;
}

public Integer getValue() {
return value;
}

public int getSortOrder() {
return sortOrder;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,63 @@
package com.seoulection.admin.survey.domain.entity;

import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey;

import java.util.Objects;
import java.util.regex.Pattern;

/**
* 설문 문항 — 관리자는 <b>문구({@code title})만</b> 고친다.
* 설문 문항 — 관리자가 CRUD하는 대상(추가·문구 수정·숨김).
*
* <p>{@code questionKey}는 문자열 자연 PK다(api-server와 동일). 예전엔 {@code SurveyQuestionKey} enum이었지만,
* api-server의 제출 요청이 문항마다 고정 필드를 갖는 구조를 벗어나 문항 목록 형태로 바뀌면서 문항 집합을
* 코드에 고정할 이유가 사라졌다 — 지금은 {@link SurveyOption}과 마찬가지로 행의 추가·숨김이 데이터만으로 가능하다.
*
* <p>추가·삭제가 없는 이유: 문항 하나가 api-server 제출 요청 본문의 필드 하나와 1:1로 묶여 있다
* ({@code AVOIDANCE → avoidances}). 행을 지우면필드를 받는 쪽이 갈 곳을 잃는다.
* <p>삭제는 {@link SurveyOption}과 같은 이유로 {@link #withActive}(soft delete)만 제공한다 — 이미 이 문항으로
* 답한 {@code survey_answer} 행이 있어, 하드 삭제하면응답이 가리키는 문항 문구를 되찾을 수 없다.
*/
public class SurveyQuestion {

private final SurveyQuestionKey questionKey;
/** 대문자 스네이크. {@link SurveyOption#getCode()}와 같은 규칙 — api-server가 안정 키로 다룬다. */
private static final Pattern QUESTION_KEY_FORMAT = Pattern.compile("^[A-Z][A-Z0-9_]*$");

private final String questionKey;
private final String title;
private final int sortOrder;
private final boolean active;

private SurveyQuestion(SurveyQuestionKey questionKey, String title, int sortOrder) {
this.questionKey = Objects.requireNonNull(questionKey, "questionKey");
private SurveyQuestion(String questionKey, String title, int sortOrder, boolean active) {
this.questionKey = requireQuestionKey(questionKey);
this.title = requireText(title);
this.sortOrder = sortOrder;
this.active = active;
}

public static SurveyQuestion of(SurveyQuestionKey questionKey, String title, int sortOrder) {
return new SurveyQuestion(questionKey, title, sortOrder);
/** 새 문항. 활성 상태로 시작한다. */
public static SurveyQuestion create(String questionKey, String title, int sortOrder) {
return new SurveyQuestion(questionKey, title, sortOrder, true);
}

/** 저장소가 읽어온 기존 행을 복원할 때 쓴다. */
public static SurveyQuestion of(String questionKey, String title, int sortOrder, boolean active) {
return new SurveyQuestion(questionKey, title, sortOrder, active);
}

public SurveyQuestion withTitle(String newTitle) {
return new SurveyQuestion(questionKey, newTitle, sortOrder);
return new SurveyQuestion(questionKey, newTitle, sortOrder, active);
}

/** 노출/숨김. {@code false}가 곧 삭제다(행은 남는다). */
public SurveyQuestion withActive(boolean newActive) {
return new SurveyQuestion(questionKey, title, sortOrder, newActive);
}

private static String requireQuestionKey(String questionKey) {
if (questionKey == null || questionKey.isBlank()) {
throw new IllegalArgumentException("문항 키를 입력해 주세요.");
}
String trimmed = questionKey.trim();
if (!QUESTION_KEY_FORMAT.matcher(trimmed).matches()) {
throw new IllegalArgumentException(
"문항 키는 영문 대문자·숫자·밑줄만 쓸 수 있습니다(예: SKIN_TYPE_CHECK).");
}
return trimmed;
}

private static String requireText(String title) {
Expand All @@ -37,7 +67,7 @@ private static String requireText(String title) {
return title.trim();
}

public SurveyQuestionKey getQuestionKey() {
public String getQuestionKey() {
return questionKey;
}

Expand All @@ -48,4 +78,8 @@ public String getTitle() {
public int getSortOrder() {
return sortOrder;
}

public boolean isActive() {
return active;
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.seoulection.admin.survey.domain.repository;

import com.seoulection.admin.survey.domain.entity.SurveyOption;
import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey;

import java.util.List;
import java.util.Optional;
Expand All @@ -10,11 +9,11 @@
public interface SurveyOptionRepository {

/** 노출 순서로 정렬된 문항별 선택지(비활성 포함 — 관리 화면은 숨긴 것도 봐야 한다). */
List<SurveyOption> findByQuestionKey(SurveyQuestionKey questionKey);
List<SurveyOption> findByQuestionKey(String questionKey);

Optional<SurveyOption> findById(Long id);

boolean existsByQuestionKeyAndCode(SurveyQuestionKey questionKey, String code);
boolean existsByQuestionKeyAndCode(String questionKey, String code);

SurveyOption save(SurveyOption option);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.seoulection.admin.survey.domain.repository;

import com.seoulection.admin.survey.domain.entity.SurveyQuestion;
import com.seoulection.admin.survey.domain.enums.SurveyQuestionKey;

import java.util.List;
import java.util.Optional;
Expand All @@ -11,7 +10,9 @@ public interface SurveyQuestionRepository {

List<SurveyQuestion> findAllOrdered();

Optional<SurveyQuestion> findByKey(SurveyQuestionKey questionKey);
Optional<SurveyQuestion> findByKey(String questionKey);

boolean existsByKey(String questionKey);

SurveyQuestion save(SurveyQuestion question);
}
Loading