diff --git a/build.gradle b/build.gradle index 476d7539..4f9d925a 100644 --- a/build.gradle +++ b/build.gradle @@ -33,47 +33,67 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-test' + // DB implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' + // Query DSL implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Redisson implementation 'org.redisson:redisson-spring-boot-starter:3.46.0' + // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // OAuth 2.0 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.2' implementation 'io.jsonwebtoken:jjwt-impl:0.12.2' implementation 'io.jsonwebtoken:jjwt-jackson:0.12.2' + // REST Docs & Swagger testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.19.2' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:3.0.0' testImplementation 'com.squareup.okhttp3:mockwebserver' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' + // AWS S3 implementation 'io.awspring.cloud:spring-cloud-aws-starter:3.1.1' implementation 'software.amazon.awssdk:s3:2.25.30' + // org.json implementation 'org.json:json:20231013' + // Zxing implementation 'com.google.zxing:core:3.5.1' implementation 'com.google.zxing:javase:3.5.1' + // Health-Check //implementation 'org.springframework.boot:spring-boot-starter-actuator' + // Logback implementation 'net.logstash.logback:logstash-logback-encoder:8.1' + + // Feign + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.4' + + // Jsoup + implementation 'org.jsoup:jsoup:1.21.2' } // QueryDSL 디렉토리 diff --git a/src/main/java/side/onetime/controller/EventController.java b/src/main/java/side/onetime/controller/EventController.java index f8fb8658..3571a4b2 100644 --- a/src/main/java/side/onetime/controller/EventController.java +++ b/src/main/java/side/onetime/controller/EventController.java @@ -1,17 +1,19 @@ package side.onetime.controller; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import side.onetime.dto.event.request.CreateEventRequest; -import side.onetime.dto.event.request.ModifyUserCreatedEventRequest; +import side.onetime.dto.event.request.ModifyEventRequest; import side.onetime.dto.event.response.*; import side.onetime.dto.schedule.request.GetFilteredSchedulesRequest; import side.onetime.global.common.ApiResponse; import side.onetime.global.common.status.SuccessStatus; import side.onetime.service.EventService; +import java.time.LocalDateTime; import java.util.List; @RestController @@ -130,6 +132,27 @@ public ResponseEntity>> getU return ApiResponse.onSuccess(SuccessStatus._GET_USER_PARTICIPATED_EVENTS, getUserParticipatedEventsResponses); } + /** + * 유저 참여 이벤트 목록 조회 API. + * + * 이 API는 인증된 유저가 참여한 이벤트 목록을 페이지 단위로 조회합니다. 유저의 참여 상태, 이벤트 정보 등이 포함됩니다. + * + * 커서 기반의 페이징을 지원하며, createdDate 커서를 기준으로 이전에 생성된 이벤트를 조회합니다. + * createdDate를 전달하지 않으면 가장 최신 이벤트부터 조회합니다. + * + * @param size 한 번에 가져올 이벤트 개수 + * @param createdDate 마지막으로 조회한 이벤트 생성일 + * @return 유저가 참여한 이벤트 목록 및 페이지(커서) 정보가 포함된 응답 DTO + */ + @GetMapping("/user/all/v2") + public ResponseEntity> getParticipatedEventsByCursor( + @RequestParam(value = "size", defaultValue = "2") @Min(1) int size, + @RequestParam(value = "cursor", required = false) LocalDateTime createdDate + ) { + GetParticipatedEventsResponse response = eventService.getParticipatedEventsByCursor(size, createdDate); + return ApiResponse.onSuccess(SuccessStatus._GET_PARTICIPATED_EVENTS, response); + } + /** * 유저가 생성한 이벤트 삭제 API. * @@ -147,9 +170,9 @@ public ResponseEntity> removeUserCreatedEvent( } /** - * 유저가 생성한 이벤트 수정 API. + * 이벤트 수정 API. * - * 이 API는 인증된 유저가 생성한 특정 이벤트의 제목, 시간, 설문 범위를 수정합니다. + * 이 API는 특정 이벤트의 제목, 시간, 설문 범위를 수정합니다. * 수정 가능한 항목은 다음과 같습니다: * - 이벤트 제목 * - 시작 시간 및 종료 시간 @@ -158,16 +181,16 @@ public ResponseEntity> removeUserCreatedEvent( * 요청 데이터에 따라 변경 사항을 반영하며, 필요에 따라 기존 스케줄 데이터를 삭제하거나 새로운 스케줄을 생성합니다. * * @param eventId 수정할 이벤트의 ID - * @param modifyUserCreatedEventRequest 새로운 이벤트 정보가 담긴 요청 데이터 (제목, 시간, 범위 등) + * @param modifyEventRequest 새로운 이벤트 정보가 담긴 요청 데이터 (제목, 시간, 범위 등) * @return 수정 성공 여부 */ @PatchMapping("/{event_id}") - public ResponseEntity> modifyUserCreatedEvent( + public ResponseEntity> modifyEvent( @PathVariable("event_id") String eventId, - @Valid @RequestBody ModifyUserCreatedEventRequest modifyUserCreatedEventRequest) { + @Valid @RequestBody ModifyEventRequest modifyEventRequest) { - eventService.modifyUserCreatedEvent(eventId, modifyUserCreatedEventRequest); - return ApiResponse.onSuccess(SuccessStatus._MODIFY_USER_CREATED_EVENT); + eventService.modifyEvent(eventId, modifyEventRequest); + return ApiResponse.onSuccess(SuccessStatus._MODIFY_EVENT); } /** diff --git a/src/main/java/side/onetime/controller/FixedController.java b/src/main/java/side/onetime/controller/FixedController.java index 3029604f..6fd8cdff 100644 --- a/src/main/java/side/onetime/controller/FixedController.java +++ b/src/main/java/side/onetime/controller/FixedController.java @@ -1,9 +1,17 @@ package side.onetime.controller; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; import side.onetime.dto.fixed.request.UpdateFixedScheduleRequest; import side.onetime.dto.fixed.response.GetFixedScheduleResponse; import side.onetime.global.common.ApiResponse; @@ -13,6 +21,7 @@ @RestController @RequestMapping("/api/v1/fixed-schedules") @RequiredArgsConstructor +@Validated public class FixedController { private final FixedScheduleService fixedScheduleService; @@ -46,4 +55,22 @@ public ResponseEntity> updateUserFixedSchedules( fixedScheduleService.updateUserFixedSchedules(request); return ApiResponse.onSuccess(SuccessStatus._UPDATE_USER_FIXED_SCHEDULE); } + + /** + * 에브리타임 시간표 조회 API. + * + * 유저의 에브리타임 시간표를 조회한 후 파싱하여, 고정 스케줄 형태로 반환합니다. + * + * @param identifier 파싱할 에브리타임 시간표 URL 식별자 + * @return 성공 상태 응답 객체 + */ + @GetMapping("/everytime/{identifier}") + public ResponseEntity> getUserEverytimeTimetable( + @PathVariable + @Pattern(regexp = "^[a-zA-Z0-9]{20}$", message = "식별자는 20자리의 영문 대소문자 및 숫자로만 구성되어야 합니다.") + String identifier + ) { + GetFixedScheduleResponse response = fixedScheduleService.getUserEverytimeTimetable(identifier); + return ApiResponse.onSuccess(SuccessStatus._GET_USER_EVERYTIME_TIMETABLE, response); + } } diff --git a/src/main/java/side/onetime/domain/Event.java b/src/main/java/side/onetime/domain/Event.java index 3734314c..b8ee0044 100644 --- a/src/main/java/side/onetime/domain/Event.java +++ b/src/main/java/side/onetime/domain/Event.java @@ -5,9 +5,13 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; import side.onetime.domain.enums.Category; +import side.onetime.domain.enums.Status; import side.onetime.global.common.dao.BaseEntity; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -15,6 +19,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Table(name = "events") +@SQLDelete(sql = "UPDATE events SET status = 'DELETED', deleted_at = CURRENT_TIMESTAMP WHERE events_id = ?") +@SQLRestriction("status = 'ACTIVE'") public class Event extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -49,6 +55,13 @@ public class Event extends BaseEntity { @OneToMany(mappedBy = "event",cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List eventParticipations; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status; + + @Column(name = "deleted_at", nullable = true) + private LocalDateTime deletedAt; + @Builder public Event(UUID eventId, String title, String startTime, String endTime, Category category) { this.eventId = eventId; @@ -56,6 +69,7 @@ public Event(UUID eventId, String title, String startTime, String endTime, Categ this.startTime = startTime; this.endTime = endTime; this.category = category; + this.status = Status.ACTIVE; } public void updateTitle(String title) { diff --git a/src/main/java/side/onetime/domain/User.java b/src/main/java/side/onetime/domain/User.java index cb19cf37..0525f327 100644 --- a/src/main/java/side/onetime/domain/User.java +++ b/src/main/java/side/onetime/domain/User.java @@ -5,15 +5,21 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; import side.onetime.domain.enums.Language; +import side.onetime.domain.enums.Status; import side.onetime.global.common.dao.BaseEntity; +import java.time.LocalDateTime; import java.util.List; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Table(name = "users") +@SQLDelete(sql = "UPDATE users SET status = 'DELETED', deleted_at = CURRENT_TIMESTAMP WHERE users_id = ?") +@SQLRestriction("status = 'ACTIVE'") public class User extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -32,7 +38,7 @@ public class User extends BaseEntity { @Column(name = "provider", nullable = false, length = 50) private String provider; - @Column(name = "provider_id", nullable = false, length = 50, unique = true) + @Column(name = "provider_id", length = 50, unique = true) private String providerId; @Column(name = "service_policy_agreement") @@ -63,6 +69,13 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List fixedSelections; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status; + + @Column(name = "deleted_at", nullable = true) + private LocalDateTime deletedAt; + @Builder public User(String name, String email, String nickname, String provider, String providerId, Boolean servicePolicyAgreement, Boolean privacyPolicyAgreement, Boolean marketingPolicyAgreement, String sleepStartTime, String sleepEndTime, Language language) { this.name = name; @@ -76,6 +89,7 @@ public User(String name, String email, String nickname, String provider, String this.sleepStartTime = sleepStartTime; this.sleepEndTime = sleepEndTime; this.language = language; + this.status = Status.ACTIVE; } public void updateNickName(String nickname) { diff --git a/src/main/java/side/onetime/domain/enums/Status.java b/src/main/java/side/onetime/domain/enums/Status.java new file mode 100644 index 00000000..093d7b96 --- /dev/null +++ b/src/main/java/side/onetime/domain/enums/Status.java @@ -0,0 +1,6 @@ +package side.onetime.domain.enums; + +public enum Status { + ACTIVE, // 활성 상태의 엔티티 + DELETED, // 삭제된 엔티티 +} diff --git a/src/main/java/side/onetime/dto/event/request/ModifyUserCreatedEventRequest.java b/src/main/java/side/onetime/dto/event/request/ModifyEventRequest.java similarity index 94% rename from src/main/java/side/onetime/dto/event/request/ModifyUserCreatedEventRequest.java rename to src/main/java/side/onetime/dto/event/request/ModifyEventRequest.java index 7507f578..89042070 100644 --- a/src/main/java/side/onetime/dto/event/request/ModifyUserCreatedEventRequest.java +++ b/src/main/java/side/onetime/dto/event/request/ModifyEventRequest.java @@ -10,7 +10,7 @@ @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonInclude(JsonInclude.Include.NON_NULL) -public record ModifyUserCreatedEventRequest( +public record ModifyEventRequest( @NotBlank(message = "제목은 필수 값입니다.") String title, @NotBlank(message = "시작 시간은 필수 값입니다.") String startTime, @NotBlank(message = "종료 시간은 필수 값입니다.") String endTime, diff --git a/src/main/java/side/onetime/dto/event/response/GetParticipatedEventResponse.java b/src/main/java/side/onetime/dto/event/response/GetParticipatedEventResponse.java new file mode 100644 index 00000000..3167412b --- /dev/null +++ b/src/main/java/side/onetime/dto/event/response/GetParticipatedEventResponse.java @@ -0,0 +1,36 @@ +package side.onetime.dto.event.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import side.onetime.domain.Event; +import side.onetime.domain.EventParticipation; +import side.onetime.domain.enums.Category; +import side.onetime.domain.enums.EventStatus; + +import java.util.List; +import java.util.UUID; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record GetParticipatedEventResponse( + UUID eventId, + Category category, + String title, + String createdDate, + int participantCount, + EventStatus eventStatus, + List mostPossibleTimes +) { + public static GetParticipatedEventResponse of(Event event, EventParticipation eventParticipation, int participantCount, List mostPossibleTimes) { + return new GetParticipatedEventResponse( + event.getEventId(), + event.getCategory(), + event.getTitle(), + String.valueOf(event.getCreatedDate()), + participantCount, + eventParticipation.getEventStatus(), + mostPossibleTimes + ); + } +} diff --git a/src/main/java/side/onetime/dto/event/response/GetParticipatedEventsResponse.java b/src/main/java/side/onetime/dto/event/response/GetParticipatedEventsResponse.java new file mode 100644 index 00000000..44c805fc --- /dev/null +++ b/src/main/java/side/onetime/dto/event/response/GetParticipatedEventsResponse.java @@ -0,0 +1,18 @@ +package side.onetime.dto.event.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import java.util.List; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record GetParticipatedEventsResponse( + List events, + PageCursorInfo pageCursorInfo +) { + public static GetParticipatedEventsResponse of(List events, PageCursorInfo pageCursorInfo) { + return new GetParticipatedEventsResponse(events, pageCursorInfo); + } +} diff --git a/src/main/java/side/onetime/dto/event/response/PageCursorInfo.java b/src/main/java/side/onetime/dto/event/response/PageCursorInfo.java new file mode 100644 index 00000000..36f30eb3 --- /dev/null +++ b/src/main/java/side/onetime/dto/event/response/PageCursorInfo.java @@ -0,0 +1,14 @@ +package side.onetime.dto.event.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record PageCursorInfo( + T nextCursor, + boolean hasNext +) { + public static PageCursorInfo of(T nextCursor, boolean hasNext) { + return new PageCursorInfo<>(nextCursor, hasNext); + } +} diff --git a/src/main/java/side/onetime/exception/status/FixedErrorStatus.java b/src/main/java/side/onetime/exception/status/FixedErrorStatus.java index 2b2b290a..564b5f59 100644 --- a/src/main/java/side/onetime/exception/status/FixedErrorStatus.java +++ b/src/main/java/side/onetime/exception/status/FixedErrorStatus.java @@ -1,15 +1,20 @@ package side.onetime.exception.status; +import org.springframework.http.HttpStatus; + import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import side.onetime.global.common.code.BaseErrorCode; import side.onetime.global.common.dto.ErrorReasonDto; @Getter @RequiredArgsConstructor public enum FixedErrorStatus implements BaseErrorCode { - _NOT_FOUND_FIXED_SCHEDULES(HttpStatus.NOT_FOUND, "FIXED-001", "고정 스케줄 목록을 가져오는 데 실패했습니다."), + _NOT_FOUND_FIXED_SCHEDULES(HttpStatus.NOT_FOUND, "FIXED-001", "고정 스케줄 목록을 가져오는 데 실패했습니다."), + _EVERYTIME_TIMETABLE_NOT_PUBLIC(HttpStatus.NOT_FOUND, "FIXED-002", "에브리타임 시간표를 가져오는 데 실패했습니다. 공개 범위를 확인해주세요."), + _EVERYTIME_TIMETABLE_PARSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FIXED-003", "에브리타임 시간표 파싱 중 문제가 발생했습니다."), + _EVERYTIME_API_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "FIXED-004", "에브리타임 API 연동 중 서버 오류가 발생했습니다."), + _NOT_FOUND_EVERYTIME_TIMETABLE(HttpStatus.NOT_FOUND, "FIXED-005", "에브리타임 시간표에 등록된 수업이 없습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/side/onetime/global/common/status/ErrorStatus.java b/src/main/java/side/onetime/global/common/status/ErrorStatus.java index a10c261f..0f8419b9 100644 --- a/src/main/java/side/onetime/global/common/status/ErrorStatus.java +++ b/src/main/java/side/onetime/global/common/status/ErrorStatus.java @@ -9,15 +9,15 @@ @Getter @AllArgsConstructor public enum ErrorStatus implements BaseErrorCode { - // 전역 에러 - _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"500", "서버 내부 오류가 발생했습니다. 자세한 사항은 백엔드 팀에 문의하세요."), - _BAD_REQUEST(HttpStatus.BAD_REQUEST,"400", "입력 값이 잘못된 요청 입니다."), - _UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"401", "인증이 필요 합니다."), - _FORBIDDEN(HttpStatus.FORBIDDEN, "403", "금지된 요청 입니다."), - _METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "405", "허용되지 않은 요청 메소드입니다."), - _UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "415", "지원되지 않는 미디어 타입입니다."), - _NOT_FOUND_HANDLER(HttpStatus.NOT_FOUND, "404", "해당 경로에 대한 핸들러를 찾을 수 없습니다."), - _FAILED_TRANSLATE_SWAGGER(HttpStatus.INTERNAL_SERVER_ERROR, "500", "Rest Docs로 생성된 json파일을 통한 스웨거 변환에 실패하였습니다.") + // 전역 예외 + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E_INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다. 자세한 사항은 백엔드 팀에 문의하세요."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST, "E_BAD_REQUEST", "입력 값이 잘못된 요청 입니다."), + _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "E_UNAUTHORIZED", "인증이 필요 합니다."), + _FORBIDDEN(HttpStatus.FORBIDDEN, "E_FORBIDDEN", "금지된 요청 입니다."), + _METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "E_METHOD_NOT_ALLOWED", "허용되지 않은 요청 메소드입니다."), + _UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "E_UNSUPPORTED_MEDIA_TYPE", "지원되지 않는 미디어 타입입니다."), + _NOT_FOUND_HANDLER(HttpStatus.NOT_FOUND, "E_NOT_FOUND_HANDLER", "해당 경로에 대한 핸들러를 찾을 수 없습니다."), + _FAILED_TRANSLATE_SWAGGER(HttpStatus.INTERNAL_SERVER_ERROR, "E_FAILED_TRANSLATE_SWAGGER", "Rest Docs로 생성된 json파일을 통한 스웨거 변환에 실패하였습니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/side/onetime/global/common/status/SuccessStatus.java b/src/main/java/side/onetime/global/common/status/SuccessStatus.java index 68675325..5096b409 100644 --- a/src/main/java/side/onetime/global/common/status/SuccessStatus.java +++ b/src/main/java/side/onetime/global/common/status/SuccessStatus.java @@ -19,8 +19,9 @@ public enum SuccessStatus implements BaseCode { _GET_MOST_POSSIBLE_TIME(HttpStatus.OK, "200", "가장 많이 되는 시간 조회에 성공했습니다."), _GET_FILTERED_MOST_POSSIBLE_TIME(HttpStatus.OK, "200", "필터링한 참여자의 시간 조회에 성공했습니다."), _GET_USER_PARTICIPATED_EVENTS(HttpStatus.OK, "200", "유저 참여 이벤트 목록 조회에 성공했습니다."), + _GET_PARTICIPATED_EVENTS(HttpStatus.OK, "200", "유저 참여 이벤트 목록 조회에 성공했습니다."), _REMOVE_USER_CREATED_EVENT(HttpStatus.OK, "200", "유저가 생성한 이벤트 삭제에 성공했습니다."), - _MODIFY_USER_CREATED_EVENT(HttpStatus.OK, "200", "유저가 생성한 이벤트 수정에 성공했습니다."), + _MODIFY_EVENT(HttpStatus.OK, "200", "이벤트 수정에 성공했습니다."), _GET_EVENT_QR_CODE(HttpStatus.OK, "200", "이벤트 QR 코드 조회에 성공했습니다."), // Member _REGISTER_MEMBER(HttpStatus.CREATED, "201", "멤버 등록에 성공했습니다."), @@ -55,6 +56,7 @@ public enum SuccessStatus implements BaseCode { // Fixed _GET_USER_FIXED_SCHEDULE(HttpStatus.OK, "200", "유저 고정 스케줄 조회에 성공했습니다."), _UPDATE_USER_FIXED_SCHEDULE(HttpStatus.OK, "200", "유저 고정 스케줄 수정에 성공했습니다."), + _GET_USER_EVERYTIME_TIMETABLE(HttpStatus.OK, "200", "유저 에브리타임 시간표 조회에 성공했습니다."), // Admin User _REGISTER_ADMIN_USER(HttpStatus.CREATED, "201", "관리자 계정 등록에 성공했습니다."), _LOGIN_ADMIN_USER(HttpStatus.OK, "200", "관리자 계정 로그인에 성공했습니다."), diff --git a/src/main/java/side/onetime/global/config/FeignConfig.java b/src/main/java/side/onetime/global/config/FeignConfig.java new file mode 100644 index 00000000..1d621966 --- /dev/null +++ b/src/main/java/side/onetime/global/config/FeignConfig.java @@ -0,0 +1,27 @@ +package side.onetime.global.config; + +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; + +import feign.RequestInterceptor; + +@Configuration +@EnableFeignClients(basePackages = "side.onetime.infra") +public class FeignConfig { + + /** + * Everytime API용 Request Interceptor + */ + @Bean + public RequestInterceptor everytimeRequestInterceptor() { + return requestTemplate -> { + // "everytime"으로 시작하는 Feign Client에만 헤더 적용 + if (requestTemplate.feignTarget().name().startsWith("everytime")) { + requestTemplate.header(HttpHeaders.USER_AGENT, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + requestTemplate.header(HttpHeaders.REFERER, "https://everytime.kr/"); + } + }; + } +} diff --git a/src/main/java/side/onetime/global/config/SecurityConfig.java b/src/main/java/side/onetime/global/config/SecurityConfig.java index e6e6cdad..ec9ebed0 100644 --- a/src/main/java/side/onetime/global/config/SecurityConfig.java +++ b/src/main/java/side/onetime/global/config/SecurityConfig.java @@ -54,13 +54,12 @@ public class SecurityConfig { private static final String[] ALLOWED_ORIGINS = { "http://localhost:5173", "http://localhost:3000", - "https://onetime-test.vercel.app", - "https://www.onetime-test.vercel.app", "https://onetime-with-members.com", "https://www.onetime-with-members.com", "https://1-ti.me", - "https://onetime-admin.vercel.app", - "https://onetime-test-admin.vercel.app", + "https://dev-app.onetime-with-members.workers.dev", + "https://admin.onetime-with-members.workers.dev", + "https://dev-admin.onetime-with-members.workers.dev", }; /** diff --git a/src/main/java/side/onetime/global/filter/JwtFilter.java b/src/main/java/side/onetime/global/filter/JwtFilter.java index e63088f3..336894d5 100644 --- a/src/main/java/side/onetime/global/filter/JwtFilter.java +++ b/src/main/java/side/onetime/global/filter/JwtFilter.java @@ -87,6 +87,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) { // 공통 prefix boolean isGet = method.equals("GET"); boolean isPost = method.equals("POST"); + boolean isPatch = method.equals("PATCH"); return path.equals("/actuator/health") || path.equals("/") || @@ -109,6 +110,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) { (isGet && path.matches("/api/v1/events/[^/]+/participants")) || (isGet && path.matches("/api/v1/events/[^/]+/most")) || (isPost && path.matches("/api/v1/events/[^/]+/most/filtering")) || + (isPatch && path.matches("/api/v1/events/[^/]+")) || (isGet && path.matches("/api/v1/events/qr/[^/]+")) || // 요일 스케줄 등록/조회 (비로그인) (isPost && path.equals("/api/v1/schedules/day")) || diff --git a/src/main/java/side/onetime/infra/everytime/client/EverytimeApiClient.java b/src/main/java/side/onetime/infra/everytime/client/EverytimeApiClient.java new file mode 100644 index 00000000..af852bab --- /dev/null +++ b/src/main/java/side/onetime/infra/everytime/client/EverytimeApiClient.java @@ -0,0 +1,21 @@ +package side.onetime.infra.everytime.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient( + name = "everytimeApi", + url = "https://api.everytime.kr" +) +public interface EverytimeApiClient { + + @PostMapping( + value = "/find/timetable/table/friend", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE + ) + String getUserTimetable( + @RequestParam("identifier") String identifier + ); +} diff --git a/src/main/java/side/onetime/repository/EventParticipationRepository.java b/src/main/java/side/onetime/repository/EventParticipationRepository.java index cdbc61b6..cacf808e 100644 --- a/src/main/java/side/onetime/repository/EventParticipationRepository.java +++ b/src/main/java/side/onetime/repository/EventParticipationRepository.java @@ -15,12 +15,21 @@ public interface EventParticipationRepository extends JpaRepository findAllByEvent(Event event); @Query(""" - SELECT ep FROM EventParticipation ep - JOIN FETCH ep.event - WHERE ep.user = :user + SELECT ep FROM EventParticipation ep + JOIN FETCH ep.event + WHERE ep.user = :user """) List findAllByUserWithEvent(@Param("user") User user); + @Query(""" + SELECT ep FROM EventParticipation ep + JOIN FETCH ep.event e + LEFT JOIN FETCH e.members + LEFT JOIN FETCH ep.user + WHERE ep.event = :event + """) + List findAllByEventWithEventAndMemberAndUser(@Param("event") Event event); + EventParticipation findByUserAndEvent(User user, Event event); List findAllByEventIdIn(List eventIds); diff --git a/src/main/java/side/onetime/repository/custom/EventParticipationRepositoryCustom.java b/src/main/java/side/onetime/repository/custom/EventParticipationRepositoryCustom.java index 655be3db..fb2fb329 100644 --- a/src/main/java/side/onetime/repository/custom/EventParticipationRepositoryCustom.java +++ b/src/main/java/side/onetime/repository/custom/EventParticipationRepositoryCustom.java @@ -1,8 +1,14 @@ package side.onetime.repository.custom; +import side.onetime.domain.EventParticipation; +import side.onetime.domain.User; + +import java.time.LocalDateTime; import java.util.List; import java.util.Map; public interface EventParticipationRepositoryCustom { Map countParticipantsByEventIds(List eventIds); + + List findParticipationsByUserWithCursor(User user, LocalDateTime createdDate, int pageSize); } diff --git a/src/main/java/side/onetime/repository/custom/EventParticipationRepositoryImpl.java b/src/main/java/side/onetime/repository/custom/EventParticipationRepositoryImpl.java index a1256145..5e662fac 100644 --- a/src/main/java/side/onetime/repository/custom/EventParticipationRepositoryImpl.java +++ b/src/main/java/side/onetime/repository/custom/EventParticipationRepositoryImpl.java @@ -1,10 +1,14 @@ package side.onetime.repository.custom; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import side.onetime.domain.EventParticipation; import side.onetime.domain.QEvent; import side.onetime.domain.QEventParticipation; +import side.onetime.domain.User; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -38,4 +42,45 @@ public Map countParticipantsByEventIds(List eventIds) { tuple -> Math.toIntExact(tuple.get(ep.id.count())) )); } + + /** + * 유저가 참여한 이벤트를 생성일(createdDate) 기준으로 페이지 단위로 조회합니다. + * + * @param user 조회할 유저 객체 + * @param createdDate 조회 기준 생성일 + * @param pageSize 한 번에 조회할 이벤트 수 + * @return 이벤트 참여 목록 + */ + @Override + public List findParticipationsByUserWithCursor(User user, LocalDateTime createdDate, int pageSize) { + QEventParticipation ep = QEventParticipation.eventParticipation; + QEvent e = QEvent.event; + + return queryFactory + .select(ep) + .from(ep) + .join(ep.event, e).fetchJoin() + .where( + ep.user.eq(user), + ltCreatedDate(createdDate) + ) + .orderBy(e.createdDate.desc()) + .limit(pageSize) + .fetch(); + } + + /** + * 주어진 createdDate 이전에 생성된 이벤트만 필터링하는 BooleanExpression을 반환합니다. + * + * @param createdDate 기준 생성일 + * @return createdDate 이전의 이벤트를 조회하는 BooleanExpression + */ + private BooleanExpression ltCreatedDate(LocalDateTime createdDate) { + QEventParticipation ep = QEventParticipation.eventParticipation; + + if (createdDate == null) { + return null; + } + return ep.event.createdDate.lt(createdDate); + } } diff --git a/src/main/java/side/onetime/repository/custom/EventRepositoryCustom.java b/src/main/java/side/onetime/repository/custom/EventRepositoryCustom.java index 0e1c44d7..257a41b5 100644 --- a/src/main/java/side/onetime/repository/custom/EventRepositoryCustom.java +++ b/src/main/java/side/onetime/repository/custom/EventRepositoryCustom.java @@ -9,9 +9,9 @@ public interface EventRepositoryCustom { void deleteEvent(Event event); - void deleteSchedulesByRange(Event event, String range); + void deleteSchedulesByRanges(Event event, List ranges); - void deleteSchedulesByTime(Event event, String time); + void deleteSchedulesByTimes(Event event, List times); List findAllWithSort(Pageable pageable, String keyword, String sorting); } diff --git a/src/main/java/side/onetime/repository/custom/EventRepositoryImpl.java b/src/main/java/side/onetime/repository/custom/EventRepositoryImpl.java index d7d51fd6..ebb1961b 100644 --- a/src/main/java/side/onetime/repository/custom/EventRepositoryImpl.java +++ b/src/main/java/side/onetime/repository/custom/EventRepositoryImpl.java @@ -11,6 +11,7 @@ import side.onetime.domain.QEvent; import side.onetime.domain.QEventParticipation; import side.onetime.domain.enums.Category; +import side.onetime.domain.enums.Status; import side.onetime.exception.CustomException; import side.onetime.exception.status.AdminErrorStatus; import side.onetime.util.NamingUtil; @@ -56,7 +57,9 @@ public void deleteEvent(Event e) { .where(member.event.eq(e)) .execute(); - queryFactory.delete(event) + queryFactory.update(event) + .set(event.status, Status.DELETED) + .set(event.deletedAt, LocalDateTime.now()) .where(event.eq(e)) .execute(); } @@ -69,19 +72,24 @@ public void deleteEvent(Event e) { * Selection → Schedule 순으로 진행됩니다. * * @param event 이벤트 객체 - * @param range 삭제할 범위 (DATE 또는 DAY) + * @param ranges 삭제할 범위 리스트 (DATE 또는 DAY) */ @Override - public void deleteSchedulesByRange(Event event, String range) { + public void deleteSchedulesByRanges(Event event, List ranges) { + if (ranges.isEmpty()) { + return; + } + queryFactory.delete(selection) .where(selection.schedule.event.eq(event) - .and(selection.schedule.date.eq(range) - .or(selection.schedule.day.eq(range)))) + .and(selection.schedule.date.in(ranges) + .or(selection.schedule.day.in(ranges)))) .execute(); queryFactory.delete(schedule) .where(schedule.event.eq(event) - .and(schedule.date.eq(range).or(schedule.day.eq(range)))) + .and(schedule.date.in(ranges) + .or(schedule.day.in(ranges)))) .execute(); } @@ -93,18 +101,22 @@ public void deleteSchedulesByRange(Event event, String range) { * Selection → Schedule 순으로 진행됩니다. * * @param event 이벤트 객체 - * @param time 삭제할 시간 (HH:mm 형식) + * @param times 삭제할 시간 리스트 (HH:mm 형식) */ @Override - public void deleteSchedulesByTime(Event event, String time) { + public void deleteSchedulesByTimes(Event event, List times) { + if (times.isEmpty()) { + return; + } + queryFactory.delete(selection) .where(selection.schedule.event.eq(event) - .and(selection.schedule.time.eq(time))) + .and(selection.schedule.time.in(times))) .execute(); queryFactory.delete(schedule) .where(schedule.event.eq(event) - .and(schedule.time.eq(time))) + .and(schedule.time.in(times))) .execute(); } diff --git a/src/main/java/side/onetime/repository/custom/UserRepositoryImpl.java b/src/main/java/side/onetime/repository/custom/UserRepositoryImpl.java index 128b11d2..7d0be5c6 100644 --- a/src/main/java/side/onetime/repository/custom/UserRepositoryImpl.java +++ b/src/main/java/side/onetime/repository/custom/UserRepositoryImpl.java @@ -2,14 +2,16 @@ import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.PathBuilder; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; -import side.onetime.domain.*; +import side.onetime.domain.User; import side.onetime.domain.enums.EventStatus; import side.onetime.domain.enums.Language; +import side.onetime.domain.enums.Status; import side.onetime.exception.CustomException; import side.onetime.exception.status.AdminErrorStatus; import side.onetime.util.NamingUtil; @@ -17,6 +19,14 @@ import java.time.LocalDateTime; import java.util.List; +import static side.onetime.domain.QEvent.event; +import static side.onetime.domain.QEventParticipation.eventParticipation; +import static side.onetime.domain.QFixedSelection.fixedSelection; +import static side.onetime.domain.QMember.member; +import static side.onetime.domain.QSchedule.schedule; +import static side.onetime.domain.QSelection.selection; +import static side.onetime.domain.QUser.user; + @RequiredArgsConstructor public class UserRepositoryImpl implements UserRepositoryCustom { @@ -31,61 +41,66 @@ public class UserRepositoryImpl implements UserRepositoryCustom { * 삭제 순서: * 1. 유저가 생성한 이벤트의 Selection → EventParticipation → Schedule → Member → Event * 2. 유저가 직접 소유한 Selection → FixedSelection - * 3. 최종적으로 User + * 3. 최종적으로 User: status를 DELETED로, providerId를 null로 업데이트 * - * @param user 탈퇴할 유저 + * @param activeUser 탈퇴할 유저 */ @Override - public void withdraw(User user) { + public void withdraw(User activeUser) { // 유저가 생성한 이벤트 ID 리스트 조회 List eventIds = queryFactory - .select(QEventParticipation.eventParticipation.event.id) + .select(eventParticipation.event.id) .distinct() - .from(QEventParticipation.eventParticipation) + .from(eventParticipation) .where( - QEventParticipation.eventParticipation.user.eq(user) - .and(QEventParticipation.eventParticipation.eventStatus.ne(EventStatus.PARTICIPANT)) + eventParticipation.user.eq(activeUser) + .and(eventParticipation.eventStatus.ne(EventStatus.PARTICIPANT)) ) .fetch(); + LocalDateTime deletedTime = LocalDateTime.now(); if (!eventIds.isEmpty()) { - queryFactory.delete(QSelection.selection) - .where(QSelection.selection.schedule.event.id.in(eventIds)) + queryFactory.delete(selection) + .where(selection.schedule.event.id.in(eventIds)) .execute(); - queryFactory.delete(QEventParticipation.eventParticipation) - .where(QEventParticipation.eventParticipation.event.id.in(eventIds)) + queryFactory.delete(eventParticipation) + .where(eventParticipation.event.id.in(eventIds)) .execute(); - queryFactory.delete(QSchedule.schedule) - .where(QSchedule.schedule.event.id.in(eventIds)) + queryFactory.delete(schedule) + .where(schedule.event.id.in(eventIds)) .execute(); - queryFactory.delete(QMember.member) - .where(QMember.member.event.id.in(eventIds)) + queryFactory.delete(member) + .where(member.event.id.in(eventIds)) .execute(); - queryFactory.delete(QEvent.event) - .where(QEvent.event.id.in(eventIds)) + queryFactory.update(event) + .set(event.status, Status.DELETED) + .set(event.deletedAt, deletedTime) + .where(event.id.in(eventIds)) .execute(); } // 유저 소유 Selection, FixedSelection, eventParticipation 삭제 - queryFactory.delete(QSelection.selection) - .where(QSelection.selection.user.eq(user)) + queryFactory.delete(selection) + .where(selection.user.eq(activeUser)) .execute(); - queryFactory.delete(QFixedSelection.fixedSelection) - .where(QFixedSelection.fixedSelection.user.eq(user)) + queryFactory.delete(fixedSelection) + .where(fixedSelection.user.eq(activeUser)) .execute(); - queryFactory.delete(QEventParticipation.eventParticipation) - .where(QEventParticipation.eventParticipation.user.eq(user)) + queryFactory.delete(eventParticipation) + .where(eventParticipation.user.eq(activeUser)) .execute(); - // 최종적으로 유저 삭제 - queryFactory.delete(QUser.user) - .where(QUser.user.eq(user)) + queryFactory.update(user) + .set(user.providerId, Expressions.nullExpression()) + .set(user.status, Status.DELETED) + .set(user.deletedAt, deletedTime) + .where(user.eq(activeUser)) .execute(); } @@ -102,20 +117,17 @@ public List findAllWithSort(Pageable pageable, String keyword, String sort Order order = sorting.equalsIgnoreCase("asc") ? Order.ASC : Order.DESC; String field = NamingUtil.toCamelCase(keyword); - QUser user = QUser.user; - QEventParticipation ep = QEventParticipation.eventParticipation; - JPAQuery query = queryFactory.selectFrom(user); if ("participationCount".equals(field)) { query - .leftJoin(ep).on(ep.user.eq(user)) + .leftJoin(eventParticipation).on(eventParticipation.user.eq(user)) .groupBy(user); if (order == Order.ASC) { - query.orderBy(ep.count().asc()); + query.orderBy(eventParticipation.count().asc()); } else { - query.orderBy(ep.count().desc()); + query.orderBy(eventParticipation.count().desc()); } } else { diff --git a/src/main/java/side/onetime/service/EventService.java b/src/main/java/side/onetime/service/EventService.java index d7f0cc93..a0570253 100644 --- a/src/main/java/side/onetime/service/EventService.java +++ b/src/main/java/side/onetime/service/EventService.java @@ -9,7 +9,7 @@ import side.onetime.domain.enums.Category; import side.onetime.domain.enums.EventStatus; import side.onetime.dto.event.request.CreateEventRequest; -import side.onetime.dto.event.request.ModifyUserCreatedEventRequest; +import side.onetime.dto.event.request.ModifyEventRequest; import side.onetime.dto.event.response.*; import side.onetime.dto.schedule.request.GetFilteredSchedulesRequest; import side.onetime.exception.CustomException; @@ -20,6 +20,7 @@ import side.onetime.repository.*; import side.onetime.util.*; +import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -226,6 +227,23 @@ public GetParticipantsResponse getParticipants(String eventId) { return GetParticipantsResponse.of(event.getMembers(), users); } + /** + * 이벤트 참여자 조회 메서드. + * 특정 이벤트에 참여한 모든 참여자의 이름 목록(멤버 및 유저)을 반환합니다. + * + * @param event 참여자를 조회할 이벤트 + * @param eventParticipations 이벤트에 속한 참여자 목록 + * @return 참여자의 이름 목록 + */ + private GetParticipantsResponse getParticipants(Event event, List eventParticipations) { + List users = eventParticipations.stream() + .filter(eventParticipation -> eventParticipation.getEventStatus() != EventStatus.CREATOR) + .map(EventParticipation::getUser) + .toList(); + + return GetParticipantsResponse.of(event.getMembers(), users); + } + /** * 가장 많이 되는 시간 조회 메서드. * 특정 이벤트에서 참여자 수가 가장 많은 시간대를 계산하여 반환합니다. @@ -268,6 +286,37 @@ public List getMostPossibleTime(String eventId) { return DateUtil.sortMostPossibleTimes(mostPossibleTimes, event.getCategory()); } + /** + * 가장 많이 되는 시간 조회 메서드. + * 특정 이벤트에서 참여자 수가 가장 많은 시간대를 계산하여 반환합니다. + * + * @param event 참여자를 조회할 이벤트 + * @param eventParticipations 이벤트에 속한 참여자 목록 + * @return 가능 인원이 많은 시간대 목록 + */ + private List getMostPossibleTimes(Event event, List eventParticipations) { + List memberNames = event.getMembers().stream() + .map(Member::getName) + .toList(); + + List userNicknames = eventParticipations.stream() + .filter(ep -> ep.getEventStatus() != EventStatus.CREATOR) + .map(ep -> ep.getUser().getNickname()) + .toList(); + + List allParticipants = new ArrayList<>(memberNames); + allParticipants.addAll(userNicknames); + + List selections = selectionRepository.findAllSelectionsByEvent(event); + + Map> scheduleToNamesMap = buildScheduleToNamesMap(selections, event.getCategory()); + + List mostPossibleTimes = buildMostPossibleTimes( + scheduleToNamesMap, allParticipants, event.getCategory()); + + return DateUtil.sortMostPossibleTimes(mostPossibleTimes, event.getCategory()); + } + /** * 필터링한 참여자의 가장 많이 되는 시간을 조회하는 메서드. * 특정 이벤트에서 전달받은 멤버 및 유저 ID를 기준으로 선택 정보를 필터링하고, 가능한 시간대를 정리하여 반환합니다. @@ -508,6 +557,48 @@ public List getUserParticipatedEvents() { .collect(Collectors.toList()); } + /** + * 유저 참여 이벤트 목록 조회 메서드. + * + * 인증된 유저가 참여한 이벤트 목록을 페이지 단위로 조회하며, 각 이벤트에 대한 세부 정보를 반환합니다. + * 각 이벤트에 대해 참여자 및 가장 많이 되는 시간을 조회한 뒤 페이지(커서) 정보를 감싸 반환합니다. + * 이벤트는 항상 최신 순으로 정렬됩니다. + * + * @param size 한 번에 가져올 이벤트 개수 + * @param createdDate 마지막으로 조회한 이벤트 생성일 + * @return 유저가 참여한 이벤트 목록 + */ + @Transactional(readOnly = true) + public GetParticipatedEventsResponse getParticipatedEventsByCursor(int size, LocalDateTime createdDate) { + User user = userRepository.findById(UserAuthorizationUtil.getLoginUserId()) + .orElseThrow(() -> new CustomException(UserErrorStatus._NOT_FOUND_USER)); + + List participations = eventParticipationRepository.findParticipationsByUserWithCursor(user, createdDate, size); + List userParticipatedEvents = participations.stream() + .map(ep -> { + Event event = ep.getEvent(); + List eventParticipations = eventParticipationRepository.findAllByEventWithEventAndMemberAndUser(event); + + GetParticipantsResponse participants = this.getParticipants(event, eventParticipations); + List mostPossibleTimes = this.getMostPossibleTimes(event, eventParticipations); + + return GetParticipatedEventResponse.of( + event, + ep, + participants.users().size() + participants.members().size(), + mostPossibleTimes + ); + }) + .toList(); + + String nextCursor = participations.isEmpty() ? null : participations.get(participations.size() - 1).getEvent().getCreatedDate().toString(); + boolean hasNext = participations.size() == size; + PageCursorInfo pageCursorInfo = PageCursorInfo.of(nextCursor, hasNext); + + return GetParticipatedEventsResponse.of(userParticipatedEvents, pageCursorInfo); + } + + /** * 유저가 생성한 이벤트 삭제 메서드. * 인증된 유저가 생성한 특정 이벤트를 삭제합니다. @@ -524,26 +615,24 @@ public void removeUserCreatedEvent(String eventId) { } /** - * 유저가 생성한 이벤트 수정 메서드. - * 인증된 유저가 생성한 특정 이벤트를 수정합니다. + * 이벤트 수정 메서드. + * 특정 이벤트를 수정합니다. * * @param eventId 수정할 이벤트의 ID - * @param modifyUserCreatedEventRequest 새로운 이벤트 데이터 + * @param modifyEventRequest 새로운 이벤트 데이터 */ @Transactional - public void modifyUserCreatedEvent(String eventId, ModifyUserCreatedEventRequest modifyUserCreatedEventRequest) { - User user = userRepository.findById(UserAuthorizationUtil.getLoginUserId()) - .orElseThrow(() -> new CustomException(UserErrorStatus._NOT_FOUND_USER)); - EventParticipation eventParticipation = verifyUserHasEventAccess(user, eventId); - Event event = eventParticipation.getEvent(); + public void modifyEvent(String eventId, ModifyEventRequest modifyEventRequest) { + Event event = eventRepository.findByEventId(UUID.fromString(eventId)) + .orElseThrow(() -> new CustomException(EventErrorStatus._NOT_FOUND_EVENT)); - event.updateTitle(modifyUserCreatedEventRequest.title()); - updateEventRanges(event, event.getSchedules(), modifyUserCreatedEventRequest.ranges(), modifyUserCreatedEventRequest.startTime(), modifyUserCreatedEventRequest.endTime()); + event.updateTitle(modifyEventRequest.title()); + updateEventRanges(event, event.getSchedules(), modifyEventRequest.ranges(), modifyEventRequest.startTime(), modifyEventRequest.endTime()); // 변경된 범위에 따른 새로운 스케줄 목록 List newSchedules = scheduleRepository.findAllByEvent(event) .orElseThrow(() -> new CustomException(ScheduleErrorStatus._NOT_FOUND_ALL_SCHEDULES)); - updateEventTimes(event, newSchedules, modifyUserCreatedEventRequest.startTime(), modifyUserCreatedEventRequest.endTime()); + updateEventTimes(event, newSchedules, modifyEventRequest.startTime(), modifyEventRequest.endTime()); } /** @@ -562,9 +651,10 @@ private void updateEventRanges(Event event, List schedules, List rangesToDelete = existRanges.stream() .filter(range -> !newRanges.contains(range)) - .forEach(range -> eventRepository.deleteSchedulesByRange(event, range)); + .toList(); + eventRepository.deleteSchedulesByRanges(event, rangesToDelete); // 생성 대상 처리 List rangesToCreate = newRanges.stream() @@ -600,9 +690,10 @@ private void updateEventTimes(Event event, List schedules, String newS .collect(Collectors.toSet()); // 삭제 대상 시간 처리 - existTimes.stream() + List timesToDelete = existTimes.stream() .filter(time -> !newTimeSets.contains(time)) - .forEach(time -> eventRepository.deleteSchedulesByTime(event, time)); + .toList(); + eventRepository.deleteSchedulesByTimes(event, timesToDelete); // 생성 대상 시간 처리 List timesToCreate = newTimeSets.stream() diff --git a/src/main/java/side/onetime/service/FixedScheduleService.java b/src/main/java/side/onetime/service/FixedScheduleService.java index 6021cbaa..76611783 100644 --- a/src/main/java/side/onetime/service/FixedScheduleService.java +++ b/src/main/java/side/onetime/service/FixedScheduleService.java @@ -1,8 +1,24 @@ package side.onetime.service; -import lombok.RequiredArgsConstructor; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.parser.Parser; +import org.jsoup.select.Elements; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; import side.onetime.domain.FixedSchedule; import side.onetime.domain.FixedSelection; import side.onetime.domain.User; @@ -12,22 +28,24 @@ import side.onetime.exception.CustomException; import side.onetime.exception.status.FixedErrorStatus; import side.onetime.exception.status.UserErrorStatus; +import side.onetime.infra.everytime.client.EverytimeApiClient; import side.onetime.repository.FixedScheduleRepository; import side.onetime.repository.FixedSelectionRepository; import side.onetime.repository.UserRepository; import side.onetime.util.UserAuthorizationUtil; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - @Service @RequiredArgsConstructor public class FixedScheduleService { + + private static final int EVERYTIME_PRIVATE_STATUS = -2; + private static final int EVERYTIME_PUBLIC_STATUS = 1; + private static final Pattern STATUS_PATTERN = Pattern.compile("status=\"(-?\\d+)\""); + private final UserRepository userRepository; private final FixedScheduleRepository fixedScheduleRepository; private final FixedSelectionRepository fixedSelectionRepository; + private final EverytimeApiClient everytimeApiClient; /** * 유저의 고정 스케줄 조회 메서드. @@ -90,4 +108,158 @@ public void updateUserFixedSchedules(UpdateFixedScheduleRequest request) { fixedSelectionRepository.saveAll(newFixedSelections); } + + /** + * 에브리타임 시간표 조회 메서드 + * + * 1. Feign으로 XML 호출 + * 2. Jsoup으로 XML 파싱 + */ + public GetFixedScheduleResponse getUserEverytimeTimetable(String identifier) { + // 1. Feign Client로 XML 데이터 요청 + String xmlResponse = fetchTimetableXml(identifier); + + // 2. Jsoup으로 XML 파싱 + List scheduleResponses = parseXmlToSchedules(xmlResponse); + + return new GetFixedScheduleResponse(scheduleResponses); + } + + /** + * Feign Client를 통해 에브리타임 XML을 요청합니다. + */ + private String fetchTimetableXml(String identifier) { + String xmlResponse; + + try { + // Feign Client 호출 + xmlResponse = everytimeApiClient.getUserTimetable(identifier); + } catch (Exception e) { + throw new CustomException(FixedErrorStatus._EVERYTIME_API_FAILED); + } + + if (!xmlResponse.contains("subject")) { + // 200 OK 응답이 왔지만, 테이블이 비어있는 경우 + int status = extractStatusFromXml(xmlResponse); + if (EVERYTIME_PRIVATE_STATUS == status) { + // 1. 공개 범위가 '전체 공개'가 아닌 경우 + throw new CustomException(FixedErrorStatus._EVERYTIME_TIMETABLE_NOT_PUBLIC); + } else if (EVERYTIME_PUBLIC_STATUS == status) { + // 2. '전체 공개'이지만, 등록된 수업이 없는 경우 + throw new CustomException(FixedErrorStatus._NOT_FOUND_EVERYTIME_TIMETABLE); + } else { + // 3. 예상치 못한 status 값 + throw new CustomException(FixedErrorStatus._EVERYTIME_TIMETABLE_PARSE_ERROR); + } + } + + return xmlResponse; + } + + /** + * XML 문자열에서 status 속성값을 추출합니다. + * 예: -> 1 반환 + */ + private int extractStatusFromXml(String xml) { + // status="숫자" 패턴을 찾음 + Matcher matcher = STATUS_PATTERN.matcher(xml); + + if (matcher.find()) { + try { + return Integer.parseInt(matcher.group(1)); + } catch (NumberFormatException e) { + // 숫자가 아닌 경우 파싱 에러 처리 + throw new CustomException(FixedErrorStatus._EVERYTIME_TIMETABLE_PARSE_ERROR); + } + } + + // status 속성을 찾지 못한 경우 파싱 에러 처리 + throw new CustomException(FixedErrorStatus._EVERYTIME_TIMETABLE_PARSE_ERROR); + } + + /** + * Jsoup을 사용하여 XML을 파싱하고 DTO 리스트로 변환합니다. + */ + private List parseXmlToSchedules(String xmlResponse) { + Map> schedulesByDay = new TreeMap<>(); + + try { + Document doc = Jsoup.parse(xmlResponse, "", Parser.xmlParser()); + Elements subjects = doc.select("subject"); + + for (Element subject : subjects) { + for (Element data : subject.select("time > data")) { + String dayName = convertDayCodeToName(data.attr("day")); + if (dayName.equals("알 수 없음")) { + continue; + } + + // 속성 검증 및 파싱 안정성 확보 + String startTimeStr = data.attr("starttime"); + String endTimeStr = data.attr("endtime"); + + // 1. 속성 누락/빈 값 검증 + if (startTimeStr.isEmpty() || endTimeStr.isEmpty()) { + continue; + } + + int startMinutes; + int endMinutes; + + try { + startMinutes = Integer.parseInt(startTimeStr) * 5; // (분 / 5) -> 분 + endMinutes = Integer.parseInt(endTimeStr) * 5; // (분 / 5) -> 분 + } catch (NumberFormatException e) { + // 2. 숫자가 아닌 값이 들어왔을 경우 스킵 + continue; + } + + // 3. 시간 범위 및 논리적 오류 검증 (0분 미만, 24시간 초과, 시작 >= 종료) + final int MINUTES_IN_DAY = 1440; // 24 * 60 + if (startMinutes >= endMinutes || startMinutes < 0 || endMinutes > MINUTES_IN_DAY) { + continue; + } + + // 해당 요일의 Set을 가져오거나 새로 생성 (시간 정렬) + Set timeSlots = schedulesByDay.computeIfAbsent(dayName, k -> new TreeSet<>()); + + // 30분 단위로 쪼개서 Set에 추가 + generateTimeSlots(timeSlots, startMinutes, endMinutes); + } + } + } catch (Exception e) { + throw new CustomException(FixedErrorStatus._EVERYTIME_TIMETABLE_PARSE_ERROR); + } + + return schedulesByDay.entrySet().stream() + .map(entry -> FixedScheduleResponse.of(entry.getKey(), new ArrayList<>(entry.getValue()))) + .collect(Collectors.toList()); + } + + /** + * XML의 day 코드를 요일 이름으로 변환합니다. ("0" -> "월") + */ + private String convertDayCodeToName(String dayCode) { + return switch (dayCode) { + case "0" -> "월"; + case "1" -> "화"; + case "2" -> "수"; + case "3" -> "목"; + case "4" -> "금"; + case "5" -> "토"; + case "6" -> "일"; + default -> "알 수 없음"; + }; + } + + /** + * 시작/종료 분을 기준으로 30분 단위 시간 문자열을 생성하여 Set에 추가합니다. + */ + private void generateTimeSlots(Set timeSlots, int startMinutes, int endMinutes) { + for (int min = startMinutes; min < endMinutes; min += 30) { + int hour = min / 60; + int minute = min % 60; + timeSlots.add(String.format("%02d:%02d", hour, minute)); + } + } } diff --git a/src/test/java/side/onetime/event/EventControllerTest.java b/src/test/java/side/onetime/event/EventControllerTest.java index 43c79754..62bdf49e 100644 --- a/src/test/java/side/onetime/event/EventControllerTest.java +++ b/src/test/java/side/onetime/event/EventControllerTest.java @@ -20,19 +20,19 @@ import side.onetime.domain.enums.Category; import side.onetime.domain.enums.EventStatus; import side.onetime.dto.event.request.CreateEventRequest; -import side.onetime.dto.event.request.ModifyUserCreatedEventRequest; +import side.onetime.dto.event.request.ModifyEventRequest; import side.onetime.dto.event.response.*; import side.onetime.dto.schedule.request.GetFilteredSchedulesRequest; import side.onetime.service.EventService; import side.onetime.util.JwtUtil; +import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.UUID; import static com.epages.restdocs.apispec.ResourceDocumentation.resource; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.*; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; @@ -449,6 +449,88 @@ public void getUserParticipatedEvents() throws Exception { )); } + @Test + @DisplayName("유저 참여 이벤트 목록을 조회한다.") + public void getParticipatedEventsByCursor() throws Exception { + // given + int size = 2; + String createdDate = "2025-01-01T12:00:00.000"; + List userParticipatedEvents = List.of( + new GetParticipatedEventResponse( + UUID.randomUUID(), + Category.DATE, + "Sample Event", + createdDate, + 10, + EventStatus.CREATOR, + List.of( + new GetMostPossibleTime("2024.11.13", "10:00", "10:30", 5, List.of("User1", "User2"), List.of("User3")) + ) + ) + ); + PageCursorInfo pageCursorInfo = PageCursorInfo.of(createdDate, true); + GetParticipatedEventsResponse response = GetParticipatedEventsResponse.of(userParticipatedEvents, pageCursorInfo); + + Mockito.when(eventService.getParticipatedEventsByCursor(anyInt(), any(LocalDateTime.class))).thenReturn(response); + + // when + ResultActions resultActions = this.mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/events/user/all/v2") + .header(HttpHeaders.AUTHORIZATION, "Bearer sampleToken") + .param("size", String.valueOf(size)) + .param("cursor", createdDate) + .accept(MediaType.APPLICATION_JSON)); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.is_success").value(true)) + .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.message").value("유저 참여 이벤트 목록 조회에 성공했습니다.")) + .andExpect(jsonPath("$.payload.events[0].event_id").exists()) + .andExpect(jsonPath("$.payload.events[0].title").value("Sample Event")) + .andExpect(jsonPath("$.payload.page_cursor_info.next_cursor").value(createdDate)) + .andExpect(jsonPath("$.payload.page_cursor_info.has_next").value(true)) + + // docs + .andDo(MockMvcRestDocumentationWrapper.document("event/get-participated-events", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Event API") + .description("유저가 참여한 이벤트 목록을 조회한다.") + .queryParameters( + parameterWithName("size").description("한 번에 가져올 이벤트 개수 (기본값: 2)").optional(), + parameterWithName("cursor").description("마지막으로 조회한 이벤트 생성일").optional() + ) + .responseFields( + fieldWithPath("is_success").type(JsonFieldType.BOOLEAN).description("성공 여부"), + fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지"), + fieldWithPath("payload.events[]").type(JsonFieldType.ARRAY).description("참여 이벤트 목록"), + fieldWithPath("payload.events[].event_id").type(JsonFieldType.STRING).description("이벤트 ID"), + fieldWithPath("payload.events[].category").type(JsonFieldType.STRING).description("이벤트 카테고리"), + fieldWithPath("payload.events[].title").type(JsonFieldType.STRING).description("이벤트 제목"), + fieldWithPath("payload.events[].created_date").type(JsonFieldType.STRING).description("이벤트 생성일"), + fieldWithPath("payload.events[].participant_count").type(JsonFieldType.NUMBER).description("참여자 수"), + fieldWithPath("payload.events[].event_status").type(JsonFieldType.STRING).description("이벤트 참여 상태"), + fieldWithPath("payload.events[].most_possible_times").type(JsonFieldType.ARRAY).description("가장 많이 가능한 시간대"), + fieldWithPath("payload.events[].most_possible_times[].time_point").type(JsonFieldType.STRING).description("날짜 또는 요일"), + fieldWithPath("payload.events[].most_possible_times[].start_time").type(JsonFieldType.STRING).description("시작 시간"), + fieldWithPath("payload.events[].most_possible_times[].end_time").type(JsonFieldType.STRING).description("종료 시간"), + fieldWithPath("payload.events[].most_possible_times[].possible_count").type(JsonFieldType.NUMBER).description("가능한 참여자 수"), + fieldWithPath("payload.events[].most_possible_times[].possible_names").type(JsonFieldType.ARRAY).description("참여 가능한 유저 이름 목록"), + fieldWithPath("payload.events[].most_possible_times[].impossible_names").type(JsonFieldType.ARRAY).description("참여 불가능한 유저 이름 목록"), + fieldWithPath("payload.page_cursor_info").type(JsonFieldType.OBJECT).description("페이지 커서 정보"), + fieldWithPath("payload.page_cursor_info.next_cursor").type(JsonFieldType.STRING).description("다음 페이지 조회용 커서"), + fieldWithPath("payload.page_cursor_info.has_next").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부") + ) + .responseSchema(Schema.schema("GetParticipatedEventsResponseSchema")) + .build() + ) + )); + } + @Test @DisplayName("유저가 생성한 이벤트를 삭제한다.") public void removeUserCreatedEvent() throws Exception { @@ -491,11 +573,11 @@ public void removeUserCreatedEvent() throws Exception { } @Test - @DisplayName("유저가 생성한 이벤트를 수정한다.") - public void modifyUserCreatedEventTitle() throws Exception { + @DisplayName("이벤트를 수정한다.") + public void modifyEvent() throws Exception { // given String eventId = UUID.randomUUID().toString(); - ModifyUserCreatedEventRequest request = new ModifyUserCreatedEventRequest( + ModifyEventRequest request = new ModifyEventRequest( "수정된 이벤트 제목", "09:00", "18:00", @@ -505,11 +587,10 @@ public void modifyUserCreatedEventTitle() throws Exception { String requestContent = new ObjectMapper().writeValueAsString(request); Mockito.doNothing().when(eventService) - .modifyUserCreatedEvent(anyString(), any(ModifyUserCreatedEventRequest.class)); + .modifyEvent(anyString(), any(ModifyEventRequest.class)); // when ResultActions resultActions = this.mockMvc.perform(RestDocumentationRequestBuilders.patch("/api/v1/events/{event_id}", eventId) - .header(HttpHeaders.AUTHORIZATION, "Bearer sampleToken") .contentType(MediaType.APPLICATION_JSON) .content(requestContent) .accept(MediaType.APPLICATION_JSON)); @@ -519,16 +600,16 @@ public void modifyUserCreatedEventTitle() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.is_success").value(true)) .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.message").value("유저가 생성한 이벤트 수정에 성공했습니다.")) + .andExpect(jsonPath("$.message").value("이벤트 수정에 성공했습니다.")) // docs - .andDo(MockMvcRestDocumentationWrapper.document("event/modify-user-created-event-title", + .andDo(MockMvcRestDocumentationWrapper.document("event/modify-event", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), resource( ResourceSnippetParameters.builder() .tag("Event API") - .description("유저가 생성한 이벤트를 수정한다.") + .description("이벤트를 수정한다.") .pathParameters( parameterWithName("event_id").description("수정할 이벤트의 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]") ) @@ -543,7 +624,7 @@ public void modifyUserCreatedEventTitle() throws Exception { fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드"), fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") ) - .responseSchema(Schema.schema("ModifyUserCreatedEventTitleResponseSchema")) + .responseSchema(Schema.schema("ModifyEventResponseSchema")) .build() ) )); diff --git a/src/test/java/side/onetime/fixed/FixedControllerTest.java b/src/test/java/side/onetime/fixed/FixedControllerTest.java index 4d96a2aa..607ac1f8 100644 --- a/src/test/java/side/onetime/fixed/FixedControllerTest.java +++ b/src/test/java/side/onetime/fixed/FixedControllerTest.java @@ -1,7 +1,13 @@ package side.onetime.fixed; -import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; -import com.epages.restdocs.apispec.ResourceSnippetParameters; +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,6 +20,10 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.ResultActions; + +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; +import com.epages.restdocs.apispec.ResourceSnippetParameters; + import side.onetime.auth.dto.CustomUserDetails; import side.onetime.auth.service.CustomUserDetailsService; import side.onetime.configuration.ControllerTestConfig; @@ -22,17 +32,11 @@ import side.onetime.dto.fixed.request.UpdateFixedScheduleRequest; import side.onetime.dto.fixed.response.FixedScheduleResponse; import side.onetime.dto.fixed.response.GetFixedScheduleResponse; +import side.onetime.exception.CustomException; +import side.onetime.exception.status.FixedErrorStatus; import side.onetime.service.FixedScheduleService; import side.onetime.util.JwtUtil; -import java.util.List; - -import static com.epages.restdocs.apispec.ResourceDocumentation.resource; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(FixedController.class) public class FixedControllerTest extends ControllerTestConfig { @@ -153,4 +157,149 @@ public void createFixedSchedules() throws Exception { ) )); } + + @Test + @DisplayName("[SUCCESS] 에브리타임 시간표를 조회한다.") + public void getEverytimeTimetable() throws Exception { + // given + String identifier = "de9YHaTAnl47JtxH0muz"; + List schedules = List.of( + new FixedScheduleResponse("월", List.of("09:00", "09:30", "10:00", "10:30")), + new FixedScheduleResponse("수", List.of("13:00", "13:30", "14:00", "14:30")), + new FixedScheduleResponse("금", List.of("10:00", "10:30")) + ); + GetFixedScheduleResponse response = new GetFixedScheduleResponse(schedules); + + // Service Mocking + Mockito.when(fixedScheduleService.getUserEverytimeTimetable(identifier)).thenReturn(response); + + // when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.get("/api/v1/fixed-schedules/everytime/{identifier}", identifier) + .accept(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.is_success").value(true)) + .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.message").value("유저 에브리타임 시간표 조회에 성공했습니다.")) + .andExpect(jsonPath("$.payload.schedules").isArray()) + .andExpect(jsonPath("$.payload.schedules[0].time_point").value("월")) + .andExpect(jsonPath("$.payload.schedules[0].times[1]").value("09:30")) + .andExpect(jsonPath("$.payload.schedules[2].time_point").value("금")) + .andExpect(jsonPath("$.payload.schedules[2].times[0]").value("10:00")) + .andDo(MockMvcRestDocumentationWrapper.document("fixed/getEverytime", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Fixed API") + .description("에브리타임 시간표를 조회한다.") + .pathParameters( + parameterWithName("identifier").description("에브리타임 시간표 URL 식별자") + ) + .responseFields( + fieldWithPath("is_success").type(JsonFieldType.BOOLEAN).description("성공 여부"), + fieldWithPath("code").type(JsonFieldType.STRING).description("HTTP 상태 코드"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지"), + fieldWithPath("payload.schedules").type(JsonFieldType.ARRAY).description("파싱된 스케줄 목록"), + fieldWithPath("payload.schedules[].time_point").type(JsonFieldType.STRING).description("요일"), + fieldWithPath("payload.schedules[].times[]").type(JsonFieldType.ARRAY).description("시간 목록") + ) + .build() + ) + )); + } + + @Test + @DisplayName("[FAILED] 에브리타임 시간표 조회에 실패한다 (공개 범위가 '전체 공개'가 아님)") + public void getEverytimeTimetable_Fail_NotFound() throws Exception { + // given + String identifier = "de9YHaTAnl47JtxH0muz"; + + Mockito.when(fixedScheduleService.getUserEverytimeTimetable(identifier)) + .thenThrow(new CustomException(FixedErrorStatus._EVERYTIME_TIMETABLE_NOT_PUBLIC)); + + // when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.get("/api/v1/fixed-schedules/everytime/{identifier}", identifier) + .accept(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.is_success").value(false)) + .andExpect(jsonPath("$.code").value("FIXED-002")) + .andExpect(jsonPath("$.message").value("에브리타임 시간표를 가져오는 데 실패했습니다. 공개 범위를 확인해주세요.")) + .andDo(MockMvcRestDocumentationWrapper.document("fixed/getEverytime-fail-not-found", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Fixed API") + .build() + ) + )); + } + + @Test + @DisplayName("[FAILED] 에브리타임 시간표 조회에 실패한다 (등록된 수업 없음)") + public void getEverytimeTimetable_Fail_Empty() throws Exception { + // given + String identifier = "de9YHaTAnl47JtxH0muz"; + + Mockito.when(fixedScheduleService.getUserEverytimeTimetable(identifier)) + .thenThrow(new CustomException(FixedErrorStatus._NOT_FOUND_EVERYTIME_TIMETABLE)); + + // when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.get("/api/v1/fixed-schedules/everytime/{identifier}", identifier) + .accept(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.is_success").value(false)) + .andExpect(jsonPath("$.code").value("FIXED-005")) + .andExpect(jsonPath("$.message").value("에브리타임 시간표에 등록된 수업이 없습니다.")) + .andDo(MockMvcRestDocumentationWrapper.document("fixed/getEverytime-fail-empty", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Fixed API") + .build() + ) + )); + } + + @Test + @DisplayName("[FAILED] 에브리타임 시간표 조회에 실패한다 (식별자 유효성 검증 실패)") + public void getEverytimeTimetable_Fail_Validation() throws Exception { + // given + // 19자: 길이가 20이 아니므로 @Pattern 실패 + String invalidIdentifier = "short-identifier-1234"; + + // when + ResultActions result = mockMvc.perform( + RestDocumentationRequestBuilders.get("/api/v1/fixed-schedules/everytime/{identifier}", invalidIdentifier) + .accept(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.is_success").value(false)) + .andExpect(jsonPath("$.code").value("E_BAD_REQUEST")) + .andExpect(jsonPath("$.message").value("getUserEverytimeTimetable.identifier: 식별자는 20자리의 영문 대소문자 및 숫자로만 구성되어야 합니다.")) + .andDo(MockMvcRestDocumentationWrapper.document("fixed/getEverytime-fail-validation", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Fixed API") + .build() + ) + )); + } }