diff --git a/.coderabbit.yml b/.coderabbit.yml index a20d82c1..93b75ecc 100644 --- a/.coderabbit.yml +++ b/.coderabbit.yml @@ -18,7 +18,7 @@ reviews: # Default: {} auto_review: enabled: true - auto_incremental_review: true + auto_incremental_review: false # PR 내 새로운 커밋 시 자동리뷰 진행 여부 # Ignore reviewing if the title of the pull request contains any of these keywords (case-insensitive). # Default: [] @@ -69,4 +69,4 @@ tools: enabled: true chat: - auto_reply: true \ No newline at end of file + auto_reply: true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..be88876c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# VCS/IDE +.git +.idea +.gradle +*.iml +.DS_Store + +# 빌드 산출물 +build +out +target +.gradle/ + +# 런타임 데이터(이미지 빌드에 불필요) +mysql_data +redis_data +**/*.sock +**/*.pid +**/*.log + +# 환경파일 +.env +.env.* +!.env.sample + +# 기타 +node_modules +__pycache__ diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 00000000..7fd22acd --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,178 @@ +name: Java CI/CD with Docker and GitHub Actions + +on: + push: + branches: [ "develop", "main" ] + pull_request: + branches: [ "develop", "main" ] + workflow_dispatch: + + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.vars.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle Wrapper + run: ./gradlew clean build + + - name: Docker login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Set image tag (commit SHA) + id: vars + run: echo "tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Build Docker image + run: | + docker build \ + -t ${{ secrets.DOCKERHUB_USERNAME }}/onsurvey:${{ steps.vars.outputs.tag }} \ + -t ${{ secrets.DOCKERHUB_USERNAME }}/onsurvey:latest \ + . + + - name: Push Docker images + run: | + docker push ${{ secrets.DOCKERHUB_USERNAME }}/onsurvey:${{ steps.vars.outputs.tag }} + docker push ${{ secrets.DOCKERHUB_USERNAME }}/onsurvey:latest + + deploy-dev: + needs: build-and-push + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/develop' && github.event_name != 'pull_request' + steps: + - name: Checkout code (for docker-compose.dev.yml) + uses: actions/checkout@v4 + + - name: Create dev config from secrets + run: | + mkdir -p config + printf "%s" "${{ secrets.ONSURVEY_APP_DEV_YML }}" > config/application-dev.yml + chmod 600 config/application-dev.yml + + - name: Copy files to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + source: "docker-compose.dev.yml,config/application-dev.yml,blue-green.sh" + target: "/deploy/" + + - name: Deploy on EC2 (blue-green) + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR=/deploy + cd "$DEPLOY_DIR" + + cat > .env < config/application-prod.yml + chmod 600 config/application-prod.yml + + - name: Copy files to NCP server + uses: appleboy/scp-action@master + with: + host: ${{ secrets.NCP_HOST }} + username: ${{ secrets.NCP_USER }} + key: ${{ secrets.NCP_SSH_KEY }} + source: "docker-compose.yml,config/application-prod.yml,blue-green.sh" + target: "/deploy/" + + - name: Deploy on NCP (blue-green) + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.NCP_HOST }} + username: ${{ secrets.NCP_USER }} + key: ${{ secrets.NCP_SSH_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR=/deploy + cd "$DEPLOY_DIR" + + cat > .env < /etc/timezone + +ENV TZ=Asia/Seoul +COPY --from=build /src/build/libs/*.jar /app/app.jar +EXPOSE 8080 + +ENV JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul" +ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app/app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 00000000..ffeac15b --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# ONSURVEY BACKEND REPOSITORY + +--- + +## 1. 프로젝트 개요 +| 항목 | 내용 | +|------|------------------------------------------------------------| +| **프로젝트명** | *ON SURVEY* | +| **주요 기술** | Java 21, Spring Boot 3, Spring JPA, QueryDSL, Redis, MySQL | + +--- + +## 2. 주요 DOMAIN +| DOMAIN | 설명 | +|---------------|------------| +| FORM | 설문 생성 및 수정 | +| MEMBER | 사용자 정보 관리 | +| MANAGEMENT | 설문 관리 및 집계 | +| PARTICIPATION | 설문 참여 | +| PAYMENT | 결제 | +| 멱등성 & 분산 락 | PromotionGrant + Redis 락으로 중복 지급 방지 | + +--- + +## 3. 프로젝트 구조 (수정 중) +``` +Backend/ +├─ src/ +│ └─ main/ +│ ├─ java/OneQ/OnSurvey/ +│ │ ├─ domain/ +│ │ │ ├─ form/ # 설문 생성 및 수정 +│ │ │ │ ├─ api/ +│ │ │ │ │ ├─ FormController +│ │ │ │ │ └─ dto/ +│ │ │ │ │ ├─ request/ +│ │ │ │ │ ├─ response/ +│ │ │ │ │ └─ DefaultSurveyDto +│ │ │ │ ├─ application/ +│ │ │ │ │ ├─ SurveyService +│ │ │ │ │ └─ QuestionService +│ │ │ │ ├─ domain/ +│ │ │ │ │ ├─ model/ # 도메인 엔티티 (POJO) +│ │ │ │ │ │ ├─ Survey +│ │ │ │ │ │ └─ Question +│ │ │ │ │ └─ repository/ +│ │ │ │ │ ├─ SurveyRepository +│ │ │ │ │ └─ QuestionRepository +│ │ │ │ └─ infra/ +│ │ │ │ ├─ entity/ # 영속성 엔티티 (@Entity) +│ │ │ │ │ ├─ SurveyEntity +│ │ │ │ │ ├─ QuestionEntity +│ │ │ │ │ └─ ScreeningEntity +│ │ │ │ ├─ mapper/ # POJO <-> JpaEntity 컨버터 +│ │ │ │ └─ jpa/ +│ │ │ │ └─ SurveyJpaRepository +│ │ │ ├─ management/ # 설문 관리 및 집계 +│ │ │ ├─ member/ # 사용자 관리 +│ │ │ └─ participation/ # 설문 참여 +│ │ └─ global/ # 공유 설정 +│ │ ├─ annotation/ +│ │ ├─ auth/ +│ │ ├─ config/ +│ │ ├─ entity/ +│ │ ├─ exception/ +│ │ ├─ handler/ +│ │ ├─ infra/ +│ │ ├─ response/ +│ │ └─ util/ +│ └─ resources/ +│ └─ application.yml # 환경 설정 +├─ build.gradle +├─ Dockerfile +├─ docker-compose.yml +└─ README.md +``` + +--- + +## 4. 배포·운영 +### Docker 이미지 +```bash +# JAR 파일 빌드 (Gradle) +./gradlew bootJar + +# 이미지 생성 +docker build -t yourrepo/yourproject:latest . +``` + +--- + +## 4. 테스트 +| 테스트 종류 | 실행 명령 | 비고 | +|------------|----------|------| +| 단위 테스트 | `./gradlew test` | JUnit 5 + Mockito | +| 통합 테스트 | `./gradlew integrationTest` (프로젝트에 정의) | Testcontainers 로 실제 DB 구동 | +| 커버리지 보고서 | `./gradlew jacocoTestReport` | `build/reports/jacoco/test/html/index.html` 확인 | + +--- + +## 5. 멱등성 & 분산 락 (Promotion) + +토스 프로모션 포인트 지급은 다음 조합으로 중복 지급을 방지합니다. + +- DB 유니크 제약 + 낙관적 락 기반 멱등 처리 +- Redis 기반 분산 락 +- 토스 API 재시도 및 결과 폴링 +- 포인트 지급 여부 플래그 + +--- + +### 5.1 PromotionGrant 기반 멱등 처리 + +- 엔티티: `promotion_grant` +- 유니크 제약 (1 유저 · 1 설문 · 1 코드당 1건) + - `user_key`, `survey_id`, `promotion_code` + +--- + +## 요약 +| 섹션 | 핵심 내용 | +|----------|--------------------------------------------| +| 레포지토리 개요 | Java 21 + Spring Boot 3 기반 백엔드 서버 | +| 주요 도메인 | 설문 생성, 관리, 참여 / 사용자 및 결제 관리 | +| 구조 | Bounded Context를 기반으로 한 도메인 분리 | +| 배포 | Dockerfile, docker‑compose, GitHub Actions | \ No newline at end of file diff --git a/build.gradle b/build.gradle index 11b3f1ef..a6252a57 100644 --- a/build.gradle +++ b/build.gradle @@ -33,9 +33,41 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.52.0' + + // QueryDSL + implementation 'io.github.openfeign.querydsl:querydsl-core:7.0' + implementation 'io.github.openfeign.querydsl:querydsl-jpa:7.0' + annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:7.0:jakarta' + + // Object Storage + implementation('com.amazonaws:aws-java-sdk-s3:1.12.761') { + exclude group: 'commons-logging', module: 'commons-logging' + } + + // Sentry + implementation("io.sentry:sentry-spring-boot-starter-jakarta:8.22.0") + implementation("io.sentry:sentry-logback:8.22.0") + + // Resilience4j Rate Limiter + implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0' } tasks.named('test') { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..981c936b --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,93 @@ +services: + nginx: + image: nginx:1.25-alpine + container_name: nginx + ports: + - "80:80" + - "443:443" + volumes: + - /deploy/nginx/conf.d:/etc/nginx/conf.d:ro + - /deploy/nginx/upstreams:/etc/nginx/upstreams + - /etc/letsencrypt:/etc/letsencrypt:ro + networks: [web] + restart: unless-stopped + + onsurvey-blue: + image: ${DOCKERHUB_USERNAME}/onsurvey:${SPRING_IMAGE_TAG:-latest} + container_name: onsurvey-blue + environment: + TZ: Asia/Seoul + SPRING_PROFILES_ACTIVE: dev + SPRING_CONFIG_ADDITIONAL_LOCATION: optional:file:/etc/onsurvey/config/ + volumes: + - ./config:/etc/onsurvey/config:ro + - /etc/onsurvey/certs:/etc/onsurvey/certs:ro + ports: + - "8080:8080" + networks: [web] + restart: unless-stopped + healthcheck: + test: ["CMD", "sh", "-c", "curl -sf http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'"] + interval: 10s + timeout: 3s + retries: 12 + + onsurvey-green: + image: ${DOCKERHUB_USERNAME}/onsurvey:${SPRING_IMAGE_TAG:-latest} + container_name: onsurvey-green + environment: + TZ: Asia/Seoul + SPRING_PROFILES_ACTIVE: dev + SPRING_CONFIG_ADDITIONAL_LOCATION: optional:file:/etc/onsurvey/config/ + volumes: + - ./config:/etc/onsurvey/config:ro + - /etc/onsurvey/certs:/etc/onsurvey/certs:ro + ports: + - "8081:8080" + networks: [web] + restart: unless-stopped + healthcheck: + test: ["CMD", "sh", "-c", "curl -sf http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'"] + interval: 10s + timeout: 3s + retries: 12 + + mysql: + image: mysql:8.0 + container_name: onsurvey-mysql + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-onsurvey} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + TZ: Asia/Seoul + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + networks: [web] + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: onsurvey-redis + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"] + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: [web] + restart: unless-stopped + +volumes: + mysql_data: + redis_data: + +networks: + web: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..acd6669b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +services: + nginx: + image: nginx:1.25-alpine + container_name: nginx + depends_on: + - onsurvey-blue + - onsurvey-green + ports: + - "80:80" + - "443:443" + volumes: + - /deploy/nginx/conf.d:/etc/nginx/conf.d:ro + - /deploy/nginx/upstreams:/etc/nginx/upstreams + - /deploy/nginx/www:/usr/share/nginx/html + - /deploy/nginx/cert:/etc/letsencrypt + networks: [web] + restart: unless-stopped + + onsurvey-blue: + image: ${DOCKERHUB_USERNAME}/onsurvey:${SPRING_IMAGE_TAG:-latest} + container_name: onsurvey-blue + environment: + TZ: Asia/Seoul + SPRING_PROFILES_ACTIVE: prod + SPRING_CONFIG_ADDITIONAL_LOCATION: optional:file:/etc/onsurvey/config/ + volumes: + - ./config:/etc/onsurvey/config:ro + - /etc/onsurvey/certs:/etc/onsurvey/certs:ro + ports: + - "8080:8080" + networks: [web] + restart: unless-stopped + healthcheck: + test: ["CMD", "sh", "-c", "curl -sf http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'"] + interval: 10s + timeout: 3s + retries: 12 + + onsurvey-green: + image: ${DOCKERHUB_USERNAME}/onsurvey:${SPRING_IMAGE_TAG:-latest} + container_name: onsurvey-green + environment: + TZ: Asia/Seoul + SPRING_PROFILES_ACTIVE: prod + SPRING_CONFIG_ADDITIONAL_LOCATION: optional:file:/etc/onsurvey/config/ + volumes: + - ./config:/etc/onsurvey/config:ro + - /etc/onsurvey/certs:/etc/onsurvey/certs:ro + ports: + - "8081:8080" + networks: [web] + restart: unless-stopped + healthcheck: + test: ["CMD", "sh", "-c", "curl -sf http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'"] + interval: 10s + timeout: 3s + retries: 12 + + redis: + image: redis:7-alpine + container_name: onsurvey-redis + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD} + ports: + - '6379:6379' + command: [ "redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"] + volumes: + - redis_data:/data + networks: [web] + +volumes: + redis_data: + +networks: + web: + driver: bridge diff --git a/src/main/java/OneQ/OnSurvey/OnSurveyApplication.java b/src/main/java/OneQ/OnSurvey/OnSurveyApplication.java index 82263dae..b869c9cb 100644 --- a/src/main/java/OneQ/OnSurvey/OnSurveyApplication.java +++ b/src/main/java/OneQ/OnSurvey/OnSurveyApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication public class OnSurveyApplication { diff --git a/src/main/java/OneQ/OnSurvey/SentryTestController.java b/src/main/java/OneQ/OnSurvey/SentryTestController.java new file mode 100644 index 00000000..105e3785 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/SentryTestController.java @@ -0,0 +1,26 @@ +package OneQ.OnSurvey; + +import io.sentry.Sentry; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SentryTestController { + + // 자동 캡처: 예외를 던지면 Sentry Starter가 자동 보고 + @GetMapping("/sentry-test/throw") + public String throwError() { + throw new RuntimeException("Sentry auto-capture test"); + } + + // 수동 캡처: try-catch 안에서 직접 보고 + @GetMapping("/sentry-test/capture") + public String captureError() { + try { + throw new RuntimeException("Sentry manual-capture test"); + } catch (Exception e) { + Sentry.captureException(e); + } + return "ok"; + } +} \ No newline at end of file diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java new file mode 100644 index 00000000..2e46e99a --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java @@ -0,0 +1,74 @@ +package OneQ.OnSurvey.domain.admin.api; + +import OneQ.OnSurvey.domain.admin.api.dto.request.AdminSurveySearchQuery; +import OneQ.OnSurvey.domain.admin.api.dto.request.ChangeSurveyOwnerRequest; +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.application.AdminFacade; +import OneQ.OnSurvey.domain.admin.domain.model.member.AdminMemberView; +import OneQ.OnSurvey.global.common.response.PageResponse; +import OneQ.OnSurvey.global.common.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/v1/admin") +@RequiredArgsConstructor +public class AdminController { + + private final AdminFacade adminFacade; + + @GetMapping("/search") + @Operation(summary = "회원 검색", description = "이메일, 전화번호, 회원ID, 이름으로 회원을 검색합니다.") + public SuccessResponse searchMembers( + @RequestParam(required = false) String email, + @RequestParam(required = false) String phoneNumber, + @RequestParam(required = false) Long memberId, + @RequestParam(required = false) String name + ) { + log.info("[ADMIN] 회원 검색 - email: {}, phone: {}, memberId: {}, name: {}", email, phoneNumber, memberId, name); + List members = adminFacade.searchMembers(email, phoneNumber, memberId, name); + return SuccessResponse.ok(MemberSearchResponse.from(members)); + } + + @GetMapping("/surveys") + @Operation(summary = "설문 목록 조회 (어드민)", description = "어드민 권한으로 설문 목록을 조회합니다.") + public PageResponse getAllSurveyList( + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @ModelAttribute AdminSurveySearchQuery query + ) { + log.info("[ADMIN] 설문 목록 조회 - query: {}, pageable: {}", query, pageable); + return PageResponse.ok(adminFacade.getAllSurveyList(pageable, query)); + } + + @GetMapping("/surveys/{surveyId}") + @Operation(summary = "특정 설문 조회 (어드민)", description = "어드민 권한으로 설문을 조회합니다.") + public SuccessResponse getQuestionsCompleted( + @PathVariable Long surveyId + ) { + log.info("[ADMIN] 특정 설문 조회 - surveyId: {}", surveyId); + + return SuccessResponse.ok(adminFacade.getSurveyDetail(surveyId)); + } + + @PatchMapping("/{surveyId}/owner") + @Operation(summary = "설문 소유자 변경 (어드민)", description = "어드민 권한으로 설문의 소유자를 변경합니다.") + public SuccessResponse changeSurveyOwner( + @PathVariable Long surveyId, + @RequestBody ChangeSurveyOwnerRequest request + ) { + log.info("[ADMIN] 설문 소유자 변경 요청 - surveyId: {}, newMemberId: {}", surveyId, request.newMemberId()); + + adminFacade.changeSurveyOwner(surveyId, request.newMemberId()); + + return SuccessResponse.ok("설문 소유자가 변경되었습니다."); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/BackofficeController.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/BackofficeController.java new file mode 100644 index 00000000..99632436 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/BackofficeController.java @@ -0,0 +1,99 @@ +package OneQ.OnSurvey.domain.admin.api; + +import OneQ.OnSurvey.domain.admin.api.dto.response.AuthRegisterResponse; +import OneQ.OnSurvey.global.auth.application.AdminSessionUseCase; +import OneQ.OnSurvey.global.auth.dto.AdminLoginRequest; +import OneQ.OnSurvey.global.auth.dto.AdminRegisterRequest; +import OneQ.OnSurvey.global.common.response.SuccessResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@Slf4j +@Controller +@RequestMapping("/v1/bo") +@RequiredArgsConstructor +public class BackofficeController { + + private final AdminSessionUseCase adminSessionUseCase; + + private static final String ADMIN_SESSION_USERNAME = "ADMIN_USERNAME"; + + @PostMapping("/auth/login") + @ResponseBody + public SuccessResponse backofficeLogin( + @RequestBody AdminLoginRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) { + adminSessionUseCase.login(request, httpRequest, httpResponse); + return SuccessResponse.ok(null); + } + + @PostMapping("/auth/register") + @ResponseBody + public SuccessResponse backofficeRegister( + @RequestBody AdminRegisterRequest request + ) { + adminSessionUseCase.register(request); + return SuccessResponse.ok(new AuthRegisterResponse(request.username(), request.name())); + } + + /** + * 로그아웃 처리 + */ + @PostMapping("/auth/logout") + @ResponseBody + public SuccessResponse logout(HttpServletRequest httpRequest) { + HttpSession session = httpRequest.getSession(false); + if (session != null) { + String username = (String) session.getAttribute(ADMIN_SESSION_USERNAME); + log.info("[백오피스 로그아웃] username: {}", username); + session.invalidate(); + } + SecurityContextHolder.clearContext(); + return SuccessResponse.ok(null); + } + + @GetMapping() + public String backoffice() { + return "bo/login"; + } + + @GetMapping("/index") + public String index(HttpServletRequest request, Model model) { + HttpSession session = request.getSession(false); + if (session != null) { + String adminUsername = (String) session.getAttribute(ADMIN_SESSION_USERNAME); + model.addAttribute("adminName", adminUsername); + } else { + model.addAttribute("adminName", "Admin"); + } + return "bo/index"; + } + + @GetMapping("/survey") + public String survey() { + return "bo/survey"; + } + + @GetMapping("/form-request") + public String formRequest() { + return "bo/form-request"; + } + + @GetMapping("/member-search") + public String memberSearch() { + return "bo/member-search"; + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/AdminSurveySearchQuery.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/AdminSurveySearchQuery.java new file mode 100644 index 00000000..0a68839a --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/AdminSurveySearchQuery.java @@ -0,0 +1,10 @@ +package OneQ.OnSurvey.domain.admin.api.dto.request; + +import OneQ.OnSurvey.domain.admin.domain.model.survey.AdminSurveyStatus; + +public record AdminSurveySearchQuery( + AdminSurveyStatus status, + String keyword, + Long creator +) { +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/AuthRegisterRequest.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/AuthRegisterRequest.java new file mode 100644 index 00000000..7512826e --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/AuthRegisterRequest.java @@ -0,0 +1,16 @@ +package OneQ.OnSurvey.domain.admin.api.dto.request; + +public record AuthRegisterRequest( + Long userKey, + String username, + String password, + String name +) { + + public boolean validate() { + return userKey != null + && username != null && !username.isBlank() + && password != null && !password.isBlank() + && name != null && !name.isBlank(); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/AuthRequest.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/AuthRequest.java new file mode 100644 index 00000000..095b48c2 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/AuthRequest.java @@ -0,0 +1,12 @@ +package OneQ.OnSurvey.domain.admin.api.dto.request; + +public record AuthRequest( + String username, + String password +) { + + public boolean validate() { + return username != null && !username.isBlank() + && password != null; + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/ChangeSurveyOwnerRequest.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/ChangeSurveyOwnerRequest.java new file mode 100644 index 00000000..e207591a --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/request/ChangeSurveyOwnerRequest.java @@ -0,0 +1,7 @@ +package OneQ.OnSurvey.domain.admin.api.dto.request; + +public record ChangeSurveyOwnerRequest( + Long newMemberId, + Long newUserKey +) { +} 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 new file mode 100644 index 00000000..3d6b89de --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyDetailResponse.java @@ -0,0 +1,173 @@ +package OneQ.OnSurvey.domain.admin.api.dto.response; + +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; +import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveySection; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public record AdminSurveyDetailResponse( + SurveyInformationDto information, + List questions, + ScreeningDto screening, + List sections +) { + + public static AdminSurveyDetailResponse from( + SurveySingleViewInfo infoVo, + List questionVos, + SurveyScreening screeningVo, + List sectionVos + ) { + return new AdminSurveyDetailResponse( + SurveyInformationDto.from(infoVo), + + questionVos.stream() + .map(QuestionDto::from) + .toList(), + + ScreeningDto.from(screeningVo), + + sectionVos.stream() + .map(SectionDto::from) + .toList() + ); + } + + public record SurveyInformationDto( + Long surveyId, + String title, + String description, + String deadline, + String imageUrl, + Set ages, + String gender, + String residence, + Set interests, + Integer dueCount + ) { + public static SurveyInformationDto from(SurveySingleViewInfo vo) { + if (vo == null) return null; + return new SurveyInformationDto( + vo.surveyId(), + vo.title(), + vo.description(), + vo.deadline() != null ? vo.deadline().toString() : null, + vo.imageUrl(), + vo.ages(), + vo.gender(), + vo.residence(), + vo.interests(), + vo.dueCount() + ); + } + } + + public record QuestionDto( + Long questionId, + String questionType, + String title, + String description, + Boolean isRequired, + Integer questionOrder, + Integer section, + String imageUrl, + ChoicePropDto choiceProperty, + RatingPropDto ratingProperty, + DatePropDto dateProperty + ) { + public static QuestionDto from(SurveyQuestion vo) { + if (vo == null) return null; + return new QuestionDto( + vo.questionId(), + vo.questionType(), + vo.title(), + vo.description(), + vo.isRequired(), + vo.questionOrder(), + vo.section(), + vo.imageUrl(), + ChoicePropDto.from(vo.choiceProperty()), + RatingPropDto.from(vo.ratingProperty()), + DatePropDto.from(vo.dateProperty()) + ); + } + + public record ChoicePropDto( + Integer maxChoice, + Boolean hasCustomInput, + Boolean hasNoneOption, + Boolean isSectionDecidable, + Set options + ) { + public static ChoicePropDto from(SurveyQuestion.ChoiceProp vo) { + if (vo == null) return null; + Set optionDtos = vo.options() != null + ? vo.options().stream().map(OptionDto::from).collect(Collectors.toSet()) + : Set.of(); + return new ChoicePropDto(vo.maxChoice(), vo.hasCustomInput(), vo.hasNoneOption(), vo.isSectionDecidable(), optionDtos); + } + + public record OptionDto(String content, Integer nextSection, String imageUrl) { + public static OptionDto from(SurveyQuestion.ChoiceProp.Option vo) { + if (vo == null) return null; + return new OptionDto(vo.content(), vo.nextSection(), vo.imageUrl()); + } + } + } + + public record RatingPropDto(String minValue, String maxValue, Integer rate) { + public static RatingPropDto from(SurveyQuestion.RatingProp vo) { + if (vo == null) return null; + return new RatingPropDto(vo.minValue(), vo.maxValue(), vo.rate()); + } + } + + public record DatePropDto(LocalDate defaultDate) { + public static DatePropDto from(SurveyQuestion.DateProp vo) { + if (vo == null) return null; + return new DatePropDto(vo.defaultDate()); + } + } + } + + public record ScreeningDto( + Long screeningId, + String content, + String answer + ) { + public static ScreeningDto from(SurveyScreening vo) { + if (vo == null) return null; + return new ScreeningDto( + vo.screeningId(), + vo.content(), + vo.answer() + ); + } + } + + public record SectionDto( + Long sectionId, + String title, + String description, + Integer order, + Integer nextSection, + String imageUrl + ) { + public static SectionDto from(SurveySection vo) { + if (vo == null) return null; + return new SectionDto( + vo.sectionId(), + vo.title(), + vo.description(), + vo.order(), + vo.nextSection(), + vo.imageUrl() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyIntroItem.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyIntroItem.java new file mode 100644 index 00000000..debe3308 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyIntroItem.java @@ -0,0 +1,22 @@ +package OneQ.OnSurvey.domain.admin.api.dto.response; + +import OneQ.OnSurvey.domain.admin.domain.model.survey.AdminSurveyListView; + +public record AdminSurveyIntroItem( + Long surveyId, + String title, + String status, + Long memberId, + String createdAt +) { + + public static AdminSurveyIntroItem from(AdminSurveyListView surveyListView) { + return new AdminSurveyIntroItem( + surveyListView.surveyId(), + surveyListView.title(), + surveyListView.status().name(), + surveyListView.memberId(), + surveyListView.createdAt().toString() + ); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AuthRegisterResponse.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AuthRegisterResponse.java new file mode 100644 index 00000000..ed37e583 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AuthRegisterResponse.java @@ -0,0 +1,7 @@ +package OneQ.OnSurvey.domain.admin.api.dto.response; + +public record AuthRegisterResponse( + String username, + String name +) { +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/MemberSearchResponse.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/MemberSearchResponse.java new file mode 100644 index 00000000..e43c6c07 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/MemberSearchResponse.java @@ -0,0 +1,43 @@ +package OneQ.OnSurvey.domain.admin.api.dto.response; + +import OneQ.OnSurvey.domain.admin.domain.model.member.AdminMemberView; + +import java.util.List; + +public record MemberSearchResponse( + List members +) { + public record MemberSearchInfo( + Long id, + Long userKey, + String name, + String email, + String phoneNumber, + String birthDay, + String gender, + String status, + Long coin + ) { + public static MemberSearchInfo from(AdminMemberView result) { + return new MemberSearchInfo( + result.id(), + result.userKey(), + result.name(), + result.email(), + result.phoneNumber(), + result.birthDay(), + result.gender(), + result.status(), + result.coin() + ); + } + } + + public static MemberSearchResponse from(List results) { + return new MemberSearchResponse( + results.stream() + .map(MemberSearchInfo::from) + .toList() + ); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminAuthService.java b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminAuthService.java new file mode 100644 index 00000000..53316647 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminAuthService.java @@ -0,0 +1,54 @@ +package OneQ.OnSurvey.domain.admin.application; + +import OneQ.OnSurvey.domain.admin.domain.model.Admin; +import OneQ.OnSurvey.domain.admin.domain.model.AdminRole; +import OneQ.OnSurvey.domain.admin.domain.port.out.MemberPort; +import OneQ.OnSurvey.domain.admin.domain.repository.AdminRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminAuthService { + + private final AdminRepository adminRepository; + private final MemberPort memberPort; + private final PasswordEncoder passwordEncoder; + + public String authenticate(String username, String rawPassword) { + + Admin admin = adminRepository.findByUsername(username); + if (admin == null || !admin.matchPassword(passwordEncoder, rawPassword)) { + return null; + } + + return admin.getAdminId(); + + } + + @Transactional + public boolean register(Long userKey, String username, String password, String name) { + Admin existingAdmin = adminRepository.findByUsername(username); + Long memberId = memberPort.validateAdminRoleAndGetMemberIdByUserKey(userKey); + if (existingAdmin != null || memberId == null) { + return false; + } + + String encodedPassword = passwordEncoder.encode(password); + + Admin newAdmin = Admin.builder() + .memberId(memberId) + .userKey(userKey) + .username(username) + .password(encodedPassword) + .name(name) + .role(AdminRole.ROLE_ADMIN) + .build(); + + adminRepository.save(newAdmin); + return true; + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java new file mode 100644 index 00000000..cfcf5327 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java @@ -0,0 +1,102 @@ +package OneQ.OnSurvey.domain.admin.application; + +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.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.SurveySingleViewInfo; +import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveyQuestion; +import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveyScreening; +import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveySection; +import OneQ.OnSurvey.domain.admin.domain.port.in.AdminUseCase; +import OneQ.OnSurvey.domain.admin.domain.port.in.AuthUseCase; +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 lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminFacade implements AuthUseCase, AdminUseCase { + + private final AdminRepository adminRepository; + private final MemberPort memberPort; + private final SurveyPort surveyPort; + private final PasswordEncoder passwordEncoder; + + @Override + public String authenticate(String username, String rawPassword) { + + Admin admin = adminRepository.findByUsername(username); + if (admin == null || !admin.matchPassword(passwordEncoder, rawPassword)) { + return null; + } + + return admin.getAdminId(); + + } + + @Override + @Transactional + public boolean register(Long userKey, String username, String password, String name) { + Admin existingAdmin = adminRepository.findByUsername(username); + Long memberId = memberPort.validateAdminRoleAndGetMemberIdByUserKey(userKey); + if (existingAdmin != null || memberId == null) { + return false; + } + + String encodedPassword = passwordEncoder.encode(password); + + Admin newAdmin = Admin.builder() + .memberId(memberId) + .userKey(userKey) + .username(username) + .password(encodedPassword) + .name(name) + .role(AdminRole.ROLE_ADMIN) + .build(); + + adminRepository.save(newAdmin); + return true; + } + + @Override + public List searchMembers(String email, String phoneNumber, Long memberId, String name) { + return memberPort.searchMembers(email, phoneNumber, memberId, name); + } + + @Override + public Page getAllSurveyList(Pageable pageable, AdminSurveySearchQuery query) { + Page surveyPage = surveyPort.findPagedSurveyListByQuery(pageable, query); + + return surveyPage.map(AdminSurveyIntroItem::from); + } + + @Override + public AdminSurveyDetailResponse getSurveyDetail(Long surveyId) { + SurveySingleViewInfo surveySingleViewInfo = surveyPort.findSurveyInformationById(surveyId); + List questions = surveyPort.findSurveyQuestionsById(surveyId); + SurveyScreening screening = surveyPort.findSurveyScreeningById(surveyId); + List sections = surveyPort.findSurveySectionsById(surveyId); + + return AdminSurveyDetailResponse.from( + surveySingleViewInfo, questions, screening, sections + ); + } + + @Override + public void changeSurveyOwner(Long surveyId, Long memberId) { + surveyPort.updateSurveyOwner(surveyId, memberId); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/Admin.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/Admin.java new file mode 100644 index 00000000..d97ff60d --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/Admin.java @@ -0,0 +1,52 @@ +package OneQ.OnSurvey.domain.admin.domain.model; + +import OneQ.OnSurvey.global.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Getter @Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity @Table(name = "admin") +public class Admin extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "admin_id") + private String adminId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "user_key", nullable = false) + private Long userKey; + + @Column(unique = true, nullable = false) + private String username; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 31) + private String name; + + @Enumerated(EnumType.STRING) + private AdminRole role; + + // 도메인 로직: 비밀번호 검증 + public boolean matchPassword(PasswordEncoder passwordEncoder, String rawPassword) { + return passwordEncoder.matches(rawPassword, this.password); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/AdminRole.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/AdminRole.java new file mode 100644 index 00000000..7bf02f0f --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/AdminRole.java @@ -0,0 +1,5 @@ +package OneQ.OnSurvey.domain.admin.domain.model; + +public enum AdminRole { + ROLE_ADMIN +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/member/AdminMemberView.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/member/AdminMemberView.java new file mode 100644 index 00000000..6c93c5e2 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/member/AdminMemberView.java @@ -0,0 +1,14 @@ +package OneQ.OnSurvey.domain.admin.domain.model.member; + +public record AdminMemberView ( + Long id, + Long userKey, + String name, + String email, + String phoneNumber, + String birthDay, + String gender, + String status, + Long coin +) { +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/AdminSurveyListView.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/AdminSurveyListView.java new file mode 100644 index 00000000..eec148e5 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/AdminSurveyListView.java @@ -0,0 +1,12 @@ +package OneQ.OnSurvey.domain.admin.domain.model.survey; + +import java.time.LocalDateTime; + +/* 설문 목록 조회 시 사용하는 VO */ +public record AdminSurveyListView( + Long surveyId, + String title, + AdminSurveyStatus status, + Long memberId, // 설문 소유자 + LocalDateTime createdAt +) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/AdminSurveyStatus.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/AdminSurveyStatus.java new file mode 100644 index 00000000..5d2c61d5 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/AdminSurveyStatus.java @@ -0,0 +1,6 @@ +package OneQ.OnSurvey.domain.admin.domain.model.survey; + +public enum AdminSurveyStatus { + WRITING, + NON_WRITING +} diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java new file mode 100644 index 00000000..2d0cbc75 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java @@ -0,0 +1,47 @@ +package OneQ.OnSurvey.domain.admin.domain.model.survey; + +import lombok.Builder; + +import java.time.LocalDate; +import java.util.Set; + +@Builder +public record SurveyQuestion( + Long questionId, + String questionType, + String title, + String description, + Boolean isRequired, + Integer questionOrder, + Integer section, + String imageUrl, + + ChoiceProp choiceProperty, + RatingProp ratingProperty, + DateProp dateProperty +) { + public record ChoiceProp ( + Integer maxChoice, + Boolean hasCustomInput, + Boolean hasNoneOption, + Boolean isSectionDecidable, + + Set