diff --git a/src/main/kotlin/com/beat_it/cal/controller/ScheduleController.kt b/src/main/kotlin/com/beat_it/cal/controller/ScheduleController.kt index e3c9812..3950687 100644 --- a/src/main/kotlin/com/beat_it/cal/controller/ScheduleController.kt +++ b/src/main/kotlin/com/beat_it/cal/controller/ScheduleController.kt @@ -1,15 +1,23 @@ package com.beat_it.cal.controller +import com.beat_it.cal.dto.CalendarSchedulesResponse +import com.beat_it.cal.dto.DateSchedulesResponse import com.beat_it.cal.dto.ScheduleCreateRequest import com.beat_it.cal.dto.ScheduleCreateResponse import com.beat_it.cal.dto.ScheduleDetailResponse import com.beat_it.cal.dto.ScheduleUpdateRequest import com.beat_it.cal.service.ScheduleService +import com.beat_it.global.error.BusinessException +import com.beat_it.global.error.ErrorCode import com.beat_it.global.response.BasicResponse +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.core.userdetails.UserDetails import org.springframework.web.bind.annotation.* +@Tag(name = "4. CALENDAR API", description = "일정 관련 로직") @RestController @RequestMapping("/calendar") class ScheduleController( @@ -18,11 +26,12 @@ class ScheduleController( @PostMapping fun createSchedule( - @RequestHeader("X-USER-ID") userId: Long, + @AuthenticationPrincipal userDetails: UserDetails?, @RequestBody request: ScheduleCreateRequest ): ResponseEntity> { - val responseData = scheduleService.createSchedule(userId, request) + val currentUserId = userDetails?.username?.toLong()?: throw BusinessException(ErrorCode.UNAUTHORIZED) + val responseData = scheduleService.createSchedule(currentUserId, request) return ResponseEntity .status(HttpStatus.CREATED) @@ -31,11 +40,13 @@ class ScheduleController( @PatchMapping("/{scheduleId}") fun updateSchedule( - @RequestHeader("X-USER-ID") userId: Long, + @AuthenticationPrincipal userDetails: UserDetails?, @PathVariable scheduleId: Long, @RequestBody request: ScheduleUpdateRequest ): ResponseEntity> { - val responseData = scheduleService.updateSchedule(scheduleId, userId, request) + val currentUserId = userDetails?.username?.toLong() + ?: throw BusinessException(ErrorCode.UNAUTHORIZED) + val responseData = scheduleService.updateSchedule(scheduleId, currentUserId, request) return ResponseEntity .status(HttpStatus.OK) .body(BasicResponse.success(responseData, HttpStatus.OK, "일정이 수정되었습니다.")) @@ -43,10 +54,12 @@ class ScheduleController( @DeleteMapping("/{scheduleId}") fun deleteSchedule( - @RequestHeader("X-USER-ID") userId: Long, + @AuthenticationPrincipal userDetails: UserDetails?, @PathVariable scheduleId: Long ): ResponseEntity> { - scheduleService.deleteSchedule(scheduleId, userId) + val currentUserId = userDetails?.username?.toLong() + ?: throw BusinessException(ErrorCode.UNAUTHORIZED) + scheduleService.deleteSchedule(scheduleId, currentUserId) return ResponseEntity .status(HttpStatus.OK) .body(BasicResponse.success(HttpStatus.OK, "일정이 성공적으로 삭제되었습니다.") @@ -55,12 +68,45 @@ class ScheduleController( @GetMapping("/{scheduleId}") fun getScheduleDetail( + @AuthenticationPrincipal userDetails: UserDetails?, @PathVariable scheduleId: Long ): ResponseEntity> { - val responseData = scheduleService.getScheduleDetail(scheduleId) + val currentUserId = userDetails?.username?.toLong() + ?: throw BusinessException(ErrorCode.UNAUTHORIZED) + val responseData = scheduleService.getScheduleDetail(scheduleId, currentUserId) return ResponseEntity .status(HttpStatus.OK) .body(BasicResponse.success(responseData, HttpStatus.OK, "일정 상세 조회에 성공했습니다.") ) } + + @GetMapping("/month") + fun getCalendarSchedules( + @AuthenticationPrincipal userDetails: UserDetails?, + @RequestParam year: Int, + @RequestParam month: Int + ): ResponseEntity> { + val currentUserId = userDetails?.username?.toLong() + ?: throw BusinessException(ErrorCode.UNAUTHORIZED) + val responseData = scheduleService.getCalendarSchedules(currentUserId, year, month) + return ResponseEntity + .status(HttpStatus.OK) + .body(BasicResponse.success(responseData, HttpStatus.OK, "공유 캘린더 범위 조회에 성공했습니다.")) + } + + @GetMapping("/date") + fun getDateSchedules( + @AuthenticationPrincipal userDetails: UserDetails?, + @RequestParam year: Int, + @RequestParam month: Int, + @RequestParam date: Int + ): ResponseEntity> { + val currentUserId = userDetails?.username?.toLong() + ?: throw BusinessException(ErrorCode.UNAUTHORIZED) + val responseData = scheduleService.getDateSchedules(currentUserId, year, month, date) + return ResponseEntity + .status(HttpStatus.OK) + .body(BasicResponse.success(responseData, HttpStatus.OK, "선택 날짜 일정 조회에 성공했습니다.")) + } + } \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/cal/dto/CalendarSchedulesResponse.kt b/src/main/kotlin/com/beat_it/cal/dto/CalendarSchedulesResponse.kt new file mode 100644 index 0000000..b1da322 --- /dev/null +++ b/src/main/kotlin/com/beat_it/cal/dto/CalendarSchedulesResponse.kt @@ -0,0 +1,15 @@ +package com.beat_it.cal.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.OffsetDateTime + +data class CalendarSchedulesResponse( + val items: List +) + +data class CalendarSchedule( + val scheduleId: Long, + val title: String, + val startsAt: OffsetDateTime, + val endsAt: OffsetDateTime +) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/cal/dto/DateScheduleResponse.kt b/src/main/kotlin/com/beat_it/cal/dto/DateScheduleResponse.kt new file mode 100644 index 0000000..743a2b4 --- /dev/null +++ b/src/main/kotlin/com/beat_it/cal/dto/DateScheduleResponse.kt @@ -0,0 +1,17 @@ +package com.beat_it.cal.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.OffsetDateTime + +data class DateSchedulesResponse( + val items: List +) + +data class DateSchedule( + val scheduleId: Long, + val title: String, + val content: String, + val startsAt: OffsetDateTime, + val endsAt: OffsetDateTime, + val locationId: Long? +) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/cal/dto/ScheduleCreateRequest.kt b/src/main/kotlin/com/beat_it/cal/dto/ScheduleCreateRequest.kt index 421eff8..3edc1d5 100644 --- a/src/main/kotlin/com/beat_it/cal/dto/ScheduleCreateRequest.kt +++ b/src/main/kotlin/com/beat_it/cal/dto/ScheduleCreateRequest.kt @@ -4,19 +4,10 @@ import com.fasterxml.jackson.annotation.JsonProperty import java.time.OffsetDateTime data class ScheduleCreateRequest( - @JsonProperty("location_id") val locationId: Long?, - val title: String?, - val content: String?, - - @JsonProperty("starts_at") val startsAt: OffsetDateTime?, - - @JsonProperty("ends_at") val endsAt: OffsetDateTime?, - - @JsonProperty("participant_user_ids") val participantUserIds: List = emptyList() ) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/cal/dto/ScheduleCreateResponse.kt b/src/main/kotlin/com/beat_it/cal/dto/ScheduleCreateResponse.kt index fff72e7..6ed23ed 100644 --- a/src/main/kotlin/com/beat_it/cal/dto/ScheduleCreateResponse.kt +++ b/src/main/kotlin/com/beat_it/cal/dto/ScheduleCreateResponse.kt @@ -4,17 +4,9 @@ import com.fasterxml.jackson.annotation.JsonProperty import java.time.OffsetDateTime data class ScheduleCreateResponse( - @JsonProperty("schedule_id") val scheduleId: Long, - val title: String, - - @JsonProperty("starts_at") val startsAt: OffsetDateTime, - - @JsonProperty("ends_at") val endsAt: OffsetDateTime, - - @JsonProperty("created_at") val createdAt: OffsetDateTime ) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/cal/dto/ScheduleDetailResponse.kt b/src/main/kotlin/com/beat_it/cal/dto/ScheduleDetailResponse.kt index f7d8f99..0d80890 100644 --- a/src/main/kotlin/com/beat_it/cal/dto/ScheduleDetailResponse.kt +++ b/src/main/kotlin/com/beat_it/cal/dto/ScheduleDetailResponse.kt @@ -4,20 +4,20 @@ import com.fasterxml.jackson.annotation.JsonProperty import java.time.OffsetDateTime data class ScheduleDetailResponse( - @JsonProperty("schedule_id") val scheduleId: Long, - @JsonProperty("team_id") val teamId: Long, - @JsonProperty("user_id") val userId: Long, - @JsonProperty("location_id") val locationId: Long?, + val scheduleId: Long, + val teamId: Long, + val userId: Long, + val locationId: Long?, val title: String, val content: String?, - @JsonProperty("starts_at") val startsAt: OffsetDateTime, - @JsonProperty("ends_at") val endsAt: OffsetDateTime, - @JsonProperty("created_at") val createdAt: OffsetDateTime, - @JsonProperty("updated_at") val updatedAt: OffsetDateTime, + val startsAt: OffsetDateTime, + val endsAt: OffsetDateTime, + val createdAt: OffsetDateTime, + val updatedAt: OffsetDateTime, val participants: List ) data class ParticipantResponse( - @JsonProperty("schedule_participant_id") val scheduleParticipantId: Long, - @JsonProperty("user_id") val userId: Long + val scheduleParticipantId: Long, + val userId: Long ) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/cal/dto/ScheduledUpdateRequest.kt b/src/main/kotlin/com/beat_it/cal/dto/ScheduledUpdateRequest.kt index 3b95832..ce1bab0 100644 --- a/src/main/kotlin/com/beat_it/cal/dto/ScheduledUpdateRequest.kt +++ b/src/main/kotlin/com/beat_it/cal/dto/ScheduledUpdateRequest.kt @@ -4,18 +4,10 @@ import com.fasterxml.jackson.annotation.JsonProperty import java.time.OffsetDateTime data class ScheduleUpdateRequest( - @JsonProperty("location_id") val locationId: Long?, - val title: String?, val content: String?, - - @JsonProperty("starts_at") val startsAt: OffsetDateTime?, - - @JsonProperty("ends_at") val endsAt: OffsetDateTime?, - - @JsonProperty("participant_user_ids") val participantUserIds: List? = null ) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/cal/repository/ScheduleRepository.kt b/src/main/kotlin/com/beat_it/cal/repository/ScheduleRepository.kt index 4d853c0..c3cf398 100644 --- a/src/main/kotlin/com/beat_it/cal/repository/ScheduleRepository.kt +++ b/src/main/kotlin/com/beat_it/cal/repository/ScheduleRepository.kt @@ -2,8 +2,36 @@ package com.beat_it.cal.repository import com.beat_it.cal.entity.Schedule import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository +import java.time.OffsetDateTime @Repository interface ScheduleRepository : JpaRepository { + @Query(""" + SELECT s FROM Schedule s + WHERE s.userId = :userId + AND s.startsAt <= :endDateTime + AND s.endsAt >= :startDateTime + ORDER BY s.startsAt ASC + """) + fun findByUserIdAndMonthRange( + @Param("userId") userId: Long, + @Param("startDateTime") startDateTime: OffsetDateTime, + @Param("endDateTime") endDateTime: OffsetDateTime + ): List + + @Query(""" + SELECT s FROM Schedule s + WHERE s.userId = :userId + AND s.startsAt <= :endDateTime + AND s.endsAt >= :startDateTime + ORDER BY s.startsAt ASC + """) + fun findByUserIdAndDailyRange( + @Param("userId") userId: Long, + @Param("startDateTime") startDateTime: OffsetDateTime, + @Param("endDateTime") endDateTime: OffsetDateTime + ): List } \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/cal/service/ScheduleService.kt b/src/main/kotlin/com/beat_it/cal/service/ScheduleService.kt index 3370855..35b5dde 100644 --- a/src/main/kotlin/com/beat_it/cal/service/ScheduleService.kt +++ b/src/main/kotlin/com/beat_it/cal/service/ScheduleService.kt @@ -1,5 +1,9 @@ package com.beat_it.cal.service +import com.beat_it.cal.dto.CalendarSchedule +import com.beat_it.cal.dto.CalendarSchedulesResponse +import com.beat_it.cal.dto.DateSchedule +import com.beat_it.cal.dto.DateSchedulesResponse import com.beat_it.cal.dto.ParticipantResponse import com.beat_it.cal.dto.ScheduleCreateRequest import com.beat_it.cal.dto.ScheduleCreateResponse @@ -11,7 +15,10 @@ import com.beat_it.global.error.BusinessException import com.beat_it.global.error.ErrorCode import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalTime import java.time.OffsetDateTime +import java.time.ZoneOffset @Service class ScheduleService( @@ -110,7 +117,7 @@ class ScheduleService( } @Transactional(readOnly = true) - fun getScheduleDetail(scheduleId: Long): ScheduleDetailResponse { + fun getScheduleDetail(scheduleId: Long, userId: Long): ScheduleDetailResponse { val schedule = findScheduleOrThrow(scheduleId) //TODO: 현재 내가 있는 팀 소속이 무엇인지 어떻게 넘겨받을 것인지 @@ -136,6 +143,55 @@ class ScheduleService( ) } + @Transactional(readOnly = true) + fun getCalendarSchedules(userId: Long, year: Int, month: Int): CalendarSchedulesResponse { + validateYearAndMonth(year, month) + val startLocalDate = LocalDate.of(year, month, 1) + val endLocalDate = startLocalDate.withDayOfMonth(startLocalDate.lengthOfMonth()) + + val zoneOffset = ZoneOffset.ofHours(9) + val startDateTime = OffsetDateTime.of(startLocalDate, LocalTime.MIN, zoneOffset) + val endDateTime = OffsetDateTime.of(endLocalDate, LocalTime.MAX, zoneOffset) + + val schedules = scheduleRepository.findByUserIdAndMonthRange(userId, startDateTime, endDateTime) + + val calendarSchedules = schedules.map { schedule -> + CalendarSchedule( + scheduleId = schedule.scheduleId ?: throw BusinessException(ErrorCode.CALENDAR_NOT_FOUND), + title = schedule.title, + startsAt = schedule.startsAt, + endsAt = schedule.endsAt + ) + } + + return CalendarSchedulesResponse(items = calendarSchedules) + } + + @Transactional(readOnly = true) + fun getDateSchedules(userId: Long, year: Int, month: Int, date: Int): DateSchedulesResponse { + validateYearMonthAndDate(year, month, date) + val targetLocalDate = LocalDate.of(year, month, date) + + val zoneOffset = ZoneOffset.ofHours(9) + val startDateTime = OffsetDateTime.of(targetLocalDate, LocalTime.MIN, zoneOffset) + val endDateTime = OffsetDateTime.of(targetLocalDate, LocalTime.MAX, zoneOffset) + + val schedules = scheduleRepository.findByUserIdAndDailyRange(userId, startDateTime, endDateTime) + + val dateSchedules = schedules.map { schedule -> + DateSchedule( + scheduleId = schedule.scheduleId ?: throw BusinessException(ErrorCode.CALENDAR_NOT_FOUND), + title = schedule.title, + content = schedule.content ?: "", + startsAt = schedule.startsAt, + endsAt = schedule.endsAt, + locationId = schedule.locationId + ) + } + + return DateSchedulesResponse(items = dateSchedules) + } + private fun validateScheduleCommon(title: String?, startsAt: OffsetDateTime?, endsAt: OffsetDateTime?) { if (title.isNullOrBlank()) { throw BusinessException(ErrorCode.CALENDAR_TITLE_REQUIRED) @@ -183,4 +239,33 @@ class ScheduleService( throw BusinessException(ErrorCode.CALENDAR_TEAM_MISMATCH) } } + + private fun validateYearAndMonth(year: Int, month: Int) { + if (year !in 1000..9999) { + throw BusinessException(ErrorCode.CALENDAR_INVALID_YEAR) + } + if (month !in 1..12) { + throw BusinessException(ErrorCode.CALENDAR_INVALID_MONTH) + } + } + + private fun validateYearMonthAndDate(year: Int, month: Int, date: Int) { + if (year !in 1000..9999) { + throw BusinessException(ErrorCode.CALENDAR_INVALID_YEAR) + } + + if (month !in 1..12) { + throw BusinessException(ErrorCode.CALENDAR_INVALID_MONTH) + } + + if (date !in 1..31) { + throw BusinessException(ErrorCode.CALENDAR_INVALID_DATE) + } + + try { + java.time.YearMonth.of(year, month).atDay(date) + } catch (e: java.time.DateTimeException) { + throw BusinessException(ErrorCode.CALENDAR_NON_EXISTENT_DATE) + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/global/error/ErrorCode.kt b/src/main/kotlin/com/beat_it/global/error/ErrorCode.kt index 7ed8ae4..35e341d 100644 --- a/src/main/kotlin/com/beat_it/global/error/ErrorCode.kt +++ b/src/main/kotlin/com/beat_it/global/error/ErrorCode.kt @@ -35,6 +35,10 @@ enum class ErrorCode( CALENDAR_TITLE_REQUIRED(HttpStatus.BAD_REQUEST, "CALENDAR-007", "일정명은 필수입니다."), CALENDAR_START_TIME_REQUIRED(HttpStatus.BAD_REQUEST, "CALENDAR-008", "일정 시작 시각은 필수입니다."), CALENDAR_END_TIME_REQUIRED(HttpStatus.BAD_REQUEST, "CALENDAR-009", "일정 종료 시각은 필수입니다."), + CALENDAR_INVALID_YEAR(HttpStatus.BAD_REQUEST, "CALENDAR-010", "연도 값이 올바르지 않습니다."), + CALENDAR_INVALID_MONTH(HttpStatus.BAD_REQUEST, "CALENDAR-011", "월 값은 1~12 사이여야 합니다."), + CALENDAR_INVALID_DATE(HttpStatus.BAD_REQUEST, "CALENDAR-012", "일 값은 1~31 사이여야 합니다."), + CALENDAR_NON_EXISTENT_DATE(HttpStatus.BAD_REQUEST, "CALENDAR-013", "해당 연월에 존재하지 않는 날짜입니다."), // --- 장소 관련 에러 --- LOCATION_NOT_FOUND(HttpStatus.NOT_FOUND, "LOCATION-001", "장소를 찾을 수 없습니다."),