diff --git "a/docs/003\353\215\260\353\223\234\353\235\274\354\235\270\354\235\204 \353\202\240\354\247\234 \352\270\260\354\244\200\354\234\274\353\241\234 \353\263\200\352\262\275/temporal-constraint-deadline-migration-checklist.md" "b/docs/003\353\215\260\353\223\234\353\235\274\354\235\270\354\235\204 \353\202\240\354\247\234 \352\270\260\354\244\200\354\234\274\353\241\234 \353\263\200\352\262\275/temporal-constraint-deadline-migration-checklist.md" new file mode 100644 index 00000000..8da3ec66 --- /dev/null +++ "b/docs/003\353\215\260\353\223\234\353\235\274\354\235\270\354\235\204 \353\202\240\354\247\234 \352\270\260\354\244\200\354\234\274\353\241\234 \353\263\200\352\262\275/temporal-constraint-deadline-migration-checklist.md" @@ -0,0 +1,50 @@ +# TemporalConstraint `deadline` ZonedDateAttribute 적용 체크리스트 + +## 사전 이해 + +- [x] 기존 `deadline`이 `ZonedDateTimeAttribute`로 `LocalDateTime`+`ZoneId`를 저장하며 컬럼이 `deadline_time`, `deadline_zone_id` 임을 + 확인. +- [x] 새 `ZonedDateAttribute`는 날짜(`LocalDate`)와 `ZoneOffset`만 저장하므로 **타임존(Region) 정보 손실**과 **시간(시:분:초) 00:00 고정** 부작용을 + 문서화. +- [x] API/도메인/리포지터리 로직이 시간 단위 정밀도나 지역 타임존을 기대하는 부분이 있는지 사용처를 전수 조사. + +## 도메인 코드 변경 + +- [x] `TemporalConstraint.deadline` 타입을 `ZonedDateAttribute`로 교체하고 컬럼명을 `deadline_date`, `deadline_offset_id` 등으로 명시. +- [x] 생성자·변경자에서 `ZonedDateAttribute.from`을 사용하도록 수정하고 null 방어/불변성 점검. +- [x] `getDeadline()`이 자정(`00:00`)으로 복원됨을 호출부에 주석 또는 메서드 네이밍으로 명확히 전달. + +## 조회·정렬·커서 로직 + +- [x] `TaskRepository.findNextByCursor` JPQL의 `deadline.dateTime` 참조를 `deadline.date` 기반으로 변경하고 커서 타입을 `LocalDate`로 맞춤. +- [x] `TaskService.encode/decodeCursor` 직렬화 포맷을 날짜 기준으로 업데이트해 클라이언트·링크 공유·페이징 호환성 영향도를 평가. +- [x] 동일 날짜 내 정렬 기준(시간→ID) 변경 시 업무 규칙을 재확인하여 순서 역전 위험이 없는지 검증. + +## DTO·API 계약 + +- [x] API 계약 변경이 필요하면 기존 V1을 보존하고 **새 V2 엔드포인트**를 추가해 전환(문서/라우팅/버전 네고 포함). +- [x] 응답 DTO `DateTimeWithZone`를 유지할지, 날짜 전용 DTO를 도입할지 결정하고 스웨거 스펙/예제를 갱신. +- [ ] 요청·응답에서 시간이 아닌 “마감 날짜” 의미임을 프런트/모바일 팀과 합의하고 배포 노트에 명시. +- [x] `ZoneId` → `ZoneOffset` 변경 시 클라이언트 파싱 호환성(예: `+09:00` 고정 포맷 사용, `Asia/Seoul` 미지원)을 명시하고 샘플 페이로드를 제공. + +## 데이터 마이그레이션 + +- [x] 새 컬럼 DDL 작성 (`deadline_date` DATE, `deadline_offset_id` VARCHAR 등) 및 기존 컬럼 유지/삭제 전략 확정. +- [x] 기존 `deadline_time`, `deadline_zone_id` 값을 날짜와 오프셋으로 변환하는 DML 준비 (`deadline_time::date`, `deadline_zone_id`를 해당 날짜의 + 오프셋으로 계산). +- [x] 지역 타임존이 사라지므로 DST 전환일의 오프셋 결정 방식(표준 vs 써머타임)을 명문화하고 샘플 케이스로 검증. +- [x] 롤백 스크립트, 데이터 백업 시점, 장애 시 복구 절차를 마련. + +## 테스트 + +- [x] 도메인 단위 테스트: 생성/equals/patch/커서 비교가 날짜 단위로 동작하는지 추가. +- [x] 리포지터리 통합 테스트: JPQL 정렬·커서 조회가 기대대로 수행되는지 확인. +- [x] API 컨트롤러/스냅샷 테스트: 직렬화 포맷 변경으로 응답이 깨지지 않는지 검증. +- [ ] 마이그레이션 스크립트(Flyway/Liquibase) 실행 테스트 포함. + +## 배포 체크 + +- [ ] 배포 전에 클라이언트·배치·알림 시스템에 “시간 → 날짜” 계약 변경을 공지. +- [ ] 릴리스 노트에 클라이언트는 `+09:00` 같은 `ZoneOffset` 문자열로 파싱해야 함을 명시(V2 API 포함). +- [ ] 배포 순서: DB 마이그레이션 → 애플리케이션 배포 → 캐시/큐/스케줄러 재기동 필요 여부 확인. +- [ ] 문제 발생 시 스키마/애플리케이션 롤백 플랜과 모니터링 포인트를 준비. diff --git "a/docs/003\353\215\260\353\223\234\353\235\274\354\235\270\354\235\204 \353\202\240\354\247\234 \352\270\260\354\244\200\354\234\274\353\241\234 \353\263\200\352\262\275/temporal-constraint-deadline-migration.sql" "b/docs/003\353\215\260\353\223\234\353\235\274\354\235\270\354\235\204 \353\202\240\354\247\234 \352\270\260\354\244\200\354\234\274\353\241\234 \353\263\200\352\262\275/temporal-constraint-deadline-migration.sql" new file mode 100644 index 00000000..0aea4854 --- /dev/null +++ "b/docs/003\353\215\260\353\223\234\353\235\274\354\235\270\354\235\204 \353\202\240\354\247\234 \352\270\260\354\244\200\354\234\274\353\241\234 \353\263\200\352\262\275/temporal-constraint-deadline-migration.sql" @@ -0,0 +1,34 @@ +-- Task.deadline migration: ZonedDateTimeAttribute -> ZonedDateAttribute (date + offset) +-- Assumptions: +-- 1) Existing columns: deadline_time (DATETIME), deadline_zone_id (Region zone id). +-- 2) Offset is calculated at local midnight of deadline_date in the original region zone. +-- 3) MySQL timezone tables are loaded so CONVERT_TZ supports region zones. + +-- 1. Add new columns (keep nullable during backfill). +ALTER TABLE task + ADD COLUMN deadline_date DATE NULL AFTER deadline_zone_id, + ADD COLUMN deadline_offset_id VARCHAR(10) NULL AFTER deadline_date; + +-- 2. Backfill from legacy columns (local midnight offset). +UPDATE task +SET deadline_date = DATE(deadline_time), + deadline_offset_id = CONCAT( + SUBSTR(DATE_FORMAT(CONVERT_TZ(CONCAT(DATE(deadline_time), ' 00:00:00'), deadline_zone_id, '+00:00'), '%z'), + 1, 3), + ':', + SUBSTR(DATE_FORMAT(CONVERT_TZ(CONCAT(DATE(deadline_time), ' 00:00:00'), deadline_zone_id, '+00:00'), '%z'), + 4, 2) + ); + +-- 3. Enforce NOT NULL after verifying backfill. +ALTER TABLE task + MODIFY deadline_date DATE NOT NULL, + MODIFY deadline_offset_id VARCHAR (10) NOT NULL; + +-- 4. (Optional) Drop legacy columns after application rollout and data validation. +-- ALTER TABLE task DROP COLUMN deadline_time, DROP COLUMN deadline_zone_id; + +-- Rollback plan: +-- - If needed, repopulate deadline_time from deadline_date using stored offset (time fixed at 00:00:00): +-- UPDATE task SET deadline_time = CONVERT_TZ(CONCAT(deadline_date, ' 00:00:00'), '+00:00', deadline_offset_id); +-- - Restore NOT NULL/column definitions accordingly. diff --git a/src/main/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtils.java b/src/main/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtils.java index 4d169e96..a3340741 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtils.java +++ b/src/main/java/me/gg/pinit/pinittask/application/datetime/DateTimeUtils.java @@ -22,4 +22,10 @@ public ZonedDateTime toZonedDateTime(LocalDateTime localDateTime, ZoneId zoneId) Objects.requireNonNull(zoneId, "zoneId must not be null"); return ZonedDateTime.of(localDateTime, zoneId); } + + public ZonedDateTime toStartOfDay(LocalDate date, ZoneOffset offset) { + Objects.requireNonNull(date, "date must not be null"); + Objects.requireNonNull(offset, "offset must not be null"); + return date.atStartOfDay(offset); + } } diff --git a/src/main/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleService.java b/src/main/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleService.java index 6bb1c471..77cc23e4 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleService.java +++ b/src/main/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleService.java @@ -46,7 +46,7 @@ public List getScheduleList(Long memberId, ZonedDateTime dateTime) { Instant startOfDay = dateTime.toLocalDate().atStartOfDay(memberZoneById).toInstant(); Instant endExclusive = dateTime.toLocalDate().plusDays(1).atStartOfDay(memberZoneById).toInstant(); - return scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeInstantBetween( + return scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeBetween( memberId, startOfDay, endExclusive @@ -58,7 +58,7 @@ public List getScheduleListForWeek(Long memberId, ZonedDateTime now) { ZoneOffset zoneOffsetOfMember = memberService.findZoneOffsetOfMember(memberId); Instant start = dateTimeUtils.lastMondayStart(now, zoneOffsetOfMember).toInstant(); Instant end = dateTimeUtils.lastMondayStart(now, zoneOffsetOfMember).plusDays(7).toInstant(); - return scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeInstantBetween( + return scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeBetween( memberId, start, end diff --git a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java index 537d7853..5432b283 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java +++ b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java @@ -11,16 +11,19 @@ import me.gg.pinit.pinittask.domain.task.model.Task; import me.gg.pinit.pinittask.domain.task.patch.TaskPatch; import me.gg.pinit.pinittask.domain.task.repository.TaskRepository; -import me.gg.pinit.pinittask.interfaces.dto.TaskCursorPageResponse; -import me.gg.pinit.pinittask.interfaces.dto.TaskResponse; +import me.gg.pinit.pinittask.interfaces.task.dto.TaskCursorPageResponse; +import me.gg.pinit.pinittask.interfaces.task.dto.TaskResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.Deque; import java.util.List; import java.util.Optional; @@ -29,6 +32,8 @@ @RequiredArgsConstructor public class TaskService { private static final String CURSOR_DELIMITER = "|"; + private static final DateTimeFormatter CURSOR_DEADLINE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + private final TaskRepository taskRepository; private final DependencyService dependencyService; private final ScheduleService scheduleService; @@ -50,23 +55,20 @@ public Page getTasks(Long ownerId, Pageable pageable, boolean readyOnly) { return taskRepository.findAllByOwnerId(ownerId, pageable); } + @Deprecated @Transactional(readOnly = true) public TaskCursorPageResponse getTasksByCursor(Long ownerId, int size, String cursor, boolean readyOnly) { - Cursor decoded = decodeCursor(cursor); - List tasks = taskRepository.findNextByCursor( - ownerId, - readyOnly, - decoded.deadline(), - decoded.id(), - PageRequest.of(0, size) - ); - boolean hasNext = tasks.size() == size; - String nextCursor = hasNext ? encodeCursor(tasks.getLast()) : null; - var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(ownerId, tasks.stream().map(Task::getId).toList()); - List data = tasks.stream() + CursorPage cursorPage = loadTasksByCursor(ownerId, size, cursor, readyOnly); + var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(ownerId, cursorPage.tasks().stream().map(Task::getId).toList()); + List data = cursorPage.tasks().stream() .map(task -> TaskResponse.from(task, dependencyInfoMap.get(task.getId()))) .toList(); - return TaskCursorPageResponse.of(data, nextCursor, hasNext); + return TaskCursorPageResponse.of(data, cursorPage.nextCursor(), cursorPage.hasNext()); + } + + @Transactional(readOnly = true) + public CursorPage getTasksByCursorPage(Long ownerId, int size, String cursor, boolean readyOnly) { + return loadTasksByCursor(ownerId, size, cursor, readyOnly); } @Transactional(readOnly = true) @@ -76,6 +78,7 @@ public List findTasksByIds(Long ownerId, List taskIds) { .toList(); } + @Transactional public Task createTask(Task task) { return taskRepository.save(task); @@ -119,6 +122,20 @@ public void markIncomplete(Long ownerId, Long taskId) { publishEvents(); } + private CursorPage loadTasksByCursor(Long ownerId, int size, String cursor, boolean readyOnly) { + Cursor decoded = decodeCursor(cursor); + List tasks = taskRepository.findNextByCursor( + ownerId, + readyOnly, + decoded.deadline(), + decoded.id(), + PageRequest.of(0, size) + ); + boolean hasNext = tasks.size() == size; + String nextCursor = hasNext ? encodeCursor(tasks.getLast()) : null; + return new CursorPage(tasks, nextCursor, hasNext); + } + private void validateOwner(Long ownerId, Task task) { if (!task.getOwnerId().equals(ownerId)) { throw new IllegalArgumentException("Member does not own the task"); @@ -148,20 +165,20 @@ private void publishEvents() { } private String encodeCursor(Task task) { - LocalDateTime deadline = task.getTemporalConstraint().getDeadline().toLocalDateTime(); - return deadline + CURSOR_DELIMITER + task.getId(); + LocalDate deadline = task.getTemporalConstraint().getDeadlineDate(); + return deadline.atStartOfDay().format(CURSOR_DEADLINE_FORMAT) + CURSOR_DELIMITER + task.getId(); } private Cursor decodeCursor(String cursor) { if (cursor == null || cursor.isBlank()) { - return new Cursor(LocalDateTime.MIN, 0L); + return new Cursor(LocalDate.MIN, 0L); } String[] parts = cursor.split("\\|"); if (parts.length != 2) { throw new IllegalArgumentException("잘못된 커서 형식입니다."); } try { - LocalDateTime deadline = LocalDateTime.parse(parts[0]); + LocalDate deadline = parseDeadline(parts[0]); Long id = Long.parseLong(parts[1]); return new Cursor(deadline, id); } catch (Exception e) { @@ -169,6 +186,29 @@ private Cursor decodeCursor(String cursor) { } } - private record Cursor(LocalDateTime deadline, Long id) { + private LocalDate parseDeadline(String raw) { + try { + return LocalDate.parse(raw); + } catch (DateTimeParseException ignored) { + } + try { + return LocalDateTime.parse(raw).toLocalDate(); + } catch (DateTimeParseException e) { + return LocalDateTime.parse(appendMissingSeconds(raw)).toLocalDate(); + } + } + + private String appendMissingSeconds(String raw) { + if (raw.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$")) { + return raw + ":00"; + } + throw new IllegalArgumentException("잘못된 커서 값입니다."); + } + + + private record Cursor(LocalDate deadline, Long id) { + } + + public record CursorPage(List tasks, String nextCursor, boolean hasNext) { } } diff --git a/src/main/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttribute.java b/src/main/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttribute.java index b065b248..de847576 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttribute.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/datetime/ZonedDateAttribute.java @@ -29,18 +29,29 @@ private ZonedDateAttribute(LocalDate date, String offsetId) { public static ZonedDateAttribute from(ZonedDateTime zonedDateTime) { Objects.requireNonNull(zonedDateTime, "zonedDateTime must not be null"); - return new ZonedDateAttribute(zonedDateTime.toLocalDate(), zonedDateTime.getOffset().getId()); + return of(zonedDateTime.toLocalDate(), zonedDateTime.getOffset()); + } + + public static ZonedDateAttribute of(LocalDate date, ZoneOffset offset) { + Objects.requireNonNull(date, "date must not be null"); + Objects.requireNonNull(offset, "offset must not be null"); + return new ZonedDateAttribute(date, offset.getId()); } public ZonedDateTime toZonedDateTime() { Objects.requireNonNull(date, "dateTime must not be null"); Objects.requireNonNull(offsetId, "offsetId must not be null"); - ZoneOffset to = ZoneOffset.of(offsetId); + ZoneOffset to = getOffset(); return date.atStartOfDay(to); } + public ZoneOffset getOffset() { + Objects.requireNonNull(offsetId, "offsetId must not be null"); + return ZoneOffset.of(offsetId); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/me/gg/pinit/pinittask/domain/schedule/repository/ScheduleRepositoryCustom.java b/src/main/java/me/gg/pinit/pinittask/domain/schedule/repository/ScheduleRepositoryCustom.java index e874eb48..c66983f2 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/schedule/repository/ScheduleRepositoryCustom.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/schedule/repository/ScheduleRepositoryCustom.java @@ -6,5 +6,5 @@ import java.util.List; public interface ScheduleRepositoryCustom { - List findAllByOwnerIdAndDesignatedStartTimeInstantBetween(Long ownerId, Instant start, Instant end); + List findAllByOwnerIdAndDesignatedStartTimeBetween(Long ownerId, Instant start, Instant end); } diff --git a/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java b/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java index 50607511..689849e3 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java @@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.Collection; import java.util.List; @@ -31,14 +31,14 @@ public interface TaskRepository extends JpaRepository { WHERE t.ownerId = :ownerId AND (:readyOnly = false OR (t.inboundDependencyCount = 0 AND t.completed = false)) AND ( - t.temporalConstraint.deadline.dateTime > :cursorTime - OR (t.temporalConstraint.deadline.dateTime = :cursorTime AND t.id > :cursorId) + t.temporalConstraint.deadline.date > :cursorDate + OR (t.temporalConstraint.deadline.date = :cursorDate AND t.id > :cursorId) ) - ORDER BY t.temporalConstraint.deadline.dateTime ASC, t.id ASC + ORDER BY t.temporalConstraint.deadline.date ASC, t.id ASC """) List findNextByCursor(@Param("ownerId") Long ownerId, @Param("readyOnly") boolean readyOnly, - @Param("cursorTime") LocalDateTime cursorTime, + @Param("cursorDate") LocalDate cursorDate, @Param("cursorId") Long cursorId, Pageable pageable); } diff --git a/src/main/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraint.java b/src/main/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraint.java index f4735641..bfa99c69 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraint.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraint.java @@ -2,9 +2,11 @@ import jakarta.persistence.*; import me.gg.pinit.pinittask.domain.converter.service.DurationConverter; -import me.gg.pinit.pinittask.domain.datetime.ZonedDateTimeAttribute; +import me.gg.pinit.pinittask.domain.datetime.ZonedDateAttribute; import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Objects; @@ -12,10 +14,10 @@ public class TemporalConstraint { @Embedded @AttributeOverrides({ - @AttributeOverride(name = "dateTime", column = @Column(name = "deadline_time")), - @AttributeOverride(name = "zoneId", column = @Column(name = "deadline_zone_id")) + @AttributeOverride(name = "date", column = @Column(name = "deadline_date")), + @AttributeOverride(name = "offsetId", column = @Column(name = "deadline_offset_id")) }) - private ZonedDateTimeAttribute deadline; + private ZonedDateAttribute deadline; @Convert(converter = DurationConverter.class) @Column(name = "expected_duration") private Duration duration; @@ -24,18 +26,42 @@ protected TemporalConstraint() { } public TemporalConstraint(ZonedDateTime deadline, Duration duration) { - this.deadline = ZonedDateTimeAttribute.from(deadline); - this.duration = duration; + this(ZonedDateAttribute.from(deadline), duration); + } + + public TemporalConstraint(LocalDate deadlineDate, ZoneOffset offset, Duration duration) { + this(ZonedDateAttribute.of(deadlineDate, offset), duration); + } + + public TemporalConstraint(ZonedDateAttribute deadline, Duration duration) { + this.deadline = Objects.requireNonNull(deadline, "deadline must not be null"); + this.duration = Objects.requireNonNull(duration, "duration must not be null"); } public TemporalConstraint changeDeadline(ZonedDateTime newDeadline) { return new TemporalConstraint(newDeadline, this.duration); } + public TemporalConstraint changeDeadline(LocalDate newDeadlineDate, ZoneOffset offset) { + return new TemporalConstraint(newDeadlineDate, offset, this.duration); + } + + /** + * Returns deadline at start-of-day using stored zone offset. + * Time component is always 00:00 and region ZoneId information is not preserved. + */ public ZonedDateTime getDeadline() { return deadline.toZonedDateTime(); } + public LocalDate getDeadlineDate() { + return deadline.getDate(); + } + + public ZoneOffset getDeadlineOffset() { + return deadline.getOffset(); + } + @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; diff --git a/src/main/java/me/gg/pinit/pinittask/infrastructure/schedule/ScheduleRepositoryImpl.java b/src/main/java/me/gg/pinit/pinittask/infrastructure/schedule/ScheduleRepositoryImpl.java index 918838ce..55fe6645 100644 --- a/src/main/java/me/gg/pinit/pinittask/infrastructure/schedule/ScheduleRepositoryImpl.java +++ b/src/main/java/me/gg/pinit/pinittask/infrastructure/schedule/ScheduleRepositoryImpl.java @@ -22,7 +22,7 @@ public ScheduleRepositoryImpl(JPAQueryFactory jpaQueryFactory) { @Transactional(readOnly = true) @Override - public List findAllByOwnerIdAndDesignatedStartTimeInstantBetween(Long ownerId, Instant start, Instant end) { + public List findAllByOwnerIdAndDesignatedStartTimeBetween(Long ownerId, Instant start, Instant end) { return jpaQueryFactory.select(schedule) .from(schedule) .where( diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java new file mode 100644 index 00000000..e883fd4e --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DateWithOffset.java @@ -0,0 +1,23 @@ +package me.gg.pinit.pinittask.interfaces.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Objects; + +public record DateWithOffset( + @NotNull + @Schema(description = "날짜", example = "2024-03-01") + LocalDate date, + @NotNull + @Schema(description = "UTC 기준 오프셋(+HH:mm)", example = "+09:00") + ZoneOffset offset +) { + public static DateWithOffset from(ZonedDateTime zonedDateTime) { + Objects.requireNonNull(zonedDateTime, "zonedDateTime must not be null"); + return new DateWithOffset(zonedDateTime.toLocalDate(), zonedDateTime.getOffset()); + } +} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/exception/TaskControllerAdvice.java b/src/main/java/me/gg/pinit/pinittask/interfaces/exception/TaskControllerAdvice.java index 880da92b..e7d81dee 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/exception/TaskControllerAdvice.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/exception/TaskControllerAdvice.java @@ -2,7 +2,7 @@ import lombok.extern.slf4j.Slf4j; import me.gg.pinit.pinittask.domain.task.exception.TaskNotFoundException; -import me.gg.pinit.pinittask.interfaces.web.TaskControllerV1; +import me.gg.pinit.pinittask.interfaces.task.TaskControllerV1; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/web/MemberControllerV0.java b/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV0.java similarity index 97% rename from src/main/java/me/gg/pinit/pinittask/interfaces/web/MemberControllerV0.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV0.java index 420cf144..4640de82 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/web/MemberControllerV0.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV0.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.web; +package me.gg.pinit.pinittask.interfaces.member; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/web/MemberControllerV1.java b/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV1.java similarity index 97% rename from src/main/java/me/gg/pinit/pinittask/interfaces/web/MemberControllerV1.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV1.java index abdf204e..41f0ceee 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/web/MemberControllerV1.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV1.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.web; +package me.gg.pinit.pinittask.interfaces.member; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Deprecated @RestController @RequestMapping("/v1/members") @Tag(name = "MemberV1", description = "회원 관련 정보 API (v1)") diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV2.java new file mode 100644 index 00000000..3d6e008d --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/member/MemberControllerV2.java @@ -0,0 +1,51 @@ +package me.gg.pinit.pinittask.interfaces.member; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import me.gg.pinit.pinittask.application.member.service.MemberService; +import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; +import me.gg.pinit.pinittask.interfaces.utils.MemberId; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v2/members") +@Tag(name = "MemberV2", description = "회원 관련 정보 API (v1)") +@ApiResponses({ + @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "대상을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) +}) +@RequiredArgsConstructor +public class MemberControllerV2 { + + private final MemberService memberService; + + @GetMapping("/now") + @Operation(summary = "현재 진행 중인 일정 ID 조회", description = "사용자의 현재 진행 중인 일정 ID를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "현재 진행 중인 일정 ID 조회 성공", content = @Content(schema = @Schema(implementation = Long.class, nullable = true))) + }) + public ResponseEntity getNowInProgressScheduleId(@Parameter(hidden = true) @MemberId Long memberId) { + Long scheduleId = memberService.getNowInProgressScheduleId(memberId); + return ResponseEntity.ok(scheduleId); + } + + @GetMapping("/zone-offset") + @Operation(summary = "사용자 시간대 조회", description = "사용자의 시간대를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사용자 시간대 조회 성공", content = @Content(schema = @Schema(implementation = String.class, example = "+09:00"))), + }) + public ResponseEntity getMemberZoneOffset(@Parameter(hidden = true) @MemberId Long memberId) { + String zoneOffset = memberService.findZoneOffsetOfMember(memberId).toString(); + return ResponseEntity.ok(zoneOffset); + } +} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV0.java similarity index 98% rename from src/main/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV0.java index aedc5537..d02b50ea 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV0.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.web; +package me.gg.pinit.pinittask.interfaces.schedule; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -16,9 +16,9 @@ import me.gg.pinit.pinittask.application.task.service.TaskService; import me.gg.pinit.pinittask.domain.schedule.model.Schedule; import me.gg.pinit.pinittask.domain.task.model.Task; -import me.gg.pinit.pinittask.interfaces.dto.ScheduleRequest; -import me.gg.pinit.pinittask.interfaces.dto.ScheduleResponse; import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleRequest; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleResponse; import me.gg.pinit.pinittask.interfaces.utils.MemberId; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV1.java similarity index 97% rename from src/main/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV1.java index 0027f031..53459ac0 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV1.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.web; +package me.gg.pinit.pinittask.interfaces.schedule; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -16,10 +16,10 @@ import me.gg.pinit.pinittask.application.task.service.TaskService; import me.gg.pinit.pinittask.domain.schedule.model.Schedule; import me.gg.pinit.pinittask.domain.task.model.Task; -import me.gg.pinit.pinittask.interfaces.dto.ScheduleSimplePatchRequest; -import me.gg.pinit.pinittask.interfaces.dto.ScheduleSimpleRequest; -import me.gg.pinit.pinittask.interfaces.dto.ScheduleSimpleResponse; import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimplePatchRequest; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleRequest; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleResponse; import me.gg.pinit.pinittask.interfaces.utils.MemberId; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; @@ -33,6 +33,7 @@ import java.util.function.Function; import java.util.stream.Collectors; +@Deprecated @RestController @RequestMapping("/v1/schedules") @RequiredArgsConstructor diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV2.java new file mode 100644 index 00000000..eeb3c624 --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/ScheduleControllerV2.java @@ -0,0 +1,202 @@ +package me.gg.pinit.pinittask.interfaces.schedule; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; +import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; +import me.gg.pinit.pinittask.application.schedule.service.ScheduleStateChangeService; +import me.gg.pinit.pinittask.application.task.service.TaskService; +import me.gg.pinit.pinittask.domain.schedule.model.Schedule; +import me.gg.pinit.pinittask.domain.task.model.Task; +import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimplePatchRequest; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleRequest; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleResponse; +import me.gg.pinit.pinittask.interfaces.utils.MemberId; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/v2/schedules") +@RequiredArgsConstructor +@Tag(name = "ScheduleV2", description = "일정 관리 API") +@ApiResponses({ + @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "대상을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) +}) +public class ScheduleControllerV2 { + private final DateTimeUtils dateTimeUtils; + private final ScheduleService scheduleService; + private final ScheduleStateChangeService scheduleStateChangeService; + private final TaskService taskService; + + @PostMapping + @Operation(summary = "일정 생성 (작업 없이)", description = "작업과 연결하지 않는 단순 일정을 등록합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "일정이 생성되었습니다.", content = @Content(schema = @Schema(implementation = ScheduleSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity createSchedule(@Parameter(hidden = true) @MemberId Long memberId, + @Valid @RequestBody ScheduleSimpleRequest request) { + Schedule saved = scheduleService.addSchedule(request.toSchedule(memberId, dateTimeUtils)); + return ResponseEntity.status(HttpStatus.CREATED).body(ScheduleSimpleResponse.from(saved)); + } + + @GetMapping + @Operation(summary = "일정 목록 조회 (작업 없이)", description = "지정한 날짜의 일정을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "일정 목록 조회 성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ScheduleSimpleResponse.class)))), + @ApiResponse(responseCode = "400", description = "날짜 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public List getSchedules(@Parameter(hidden = true) @MemberId Long memberId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @RequestParam ZoneId zoneId) { + List schedules = scheduleService.getScheduleList(memberId, dateTimeUtils.toZonedDateTime(time, zoneId)); + Map taskMap = taskService.findTasksByIds(memberId, schedules.stream() + .map(Schedule::getTaskId) + .filter(id -> id != null) + .toList()) + .stream() + .collect(Collectors.toMap(Task::getId, Function.identity())); + return schedules.stream() + .map(schedule -> ScheduleSimpleResponse.from(schedule, taskMap.get(schedule.getTaskId()))) + .toList(); + } + + @GetMapping("/week") + @Operation(summary = "주간 일정 조회 (작업 없이)", description = "주어진 날짜가 포함된 주간의 일정을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "주간 일정 조회 성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = ScheduleSimpleResponse.class)))), + @ApiResponse(responseCode = "400", description = "날짜 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public List getWeeklySchedules(@Parameter(hidden = true) @MemberId Long memberId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @RequestParam ZoneId zoneId) { + List schedules = scheduleService.getScheduleListForWeek(memberId, dateTimeUtils.toZonedDateTime(time, zoneId)); + Map taskMap = taskService.findTasksByIds(memberId, schedules.stream() + .map(Schedule::getTaskId) + .filter(id -> id != null) + .toList()) + .stream() + .collect(Collectors.toMap(Task::getId, Function.identity())); + return schedules.stream() + .map(schedule -> ScheduleSimpleResponse.from(schedule, taskMap.get(schedule.getTaskId()))) + .toList(); + } + + @GetMapping("/{scheduleId}") + @Operation(summary = "일정 단건 조회 (작업 없이)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "일정 단건 조회 성공", content = @Content(schema = @Schema(implementation = ScheduleSimpleResponse.class))), + @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ScheduleSimpleResponse getSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId) { + Schedule schedule = scheduleService.getSchedule(memberId, scheduleId); + Task task = null; + if (schedule.getTaskId() != null) { + task = taskService.getTask(memberId, schedule.getTaskId()); + } + return ScheduleSimpleResponse.from(schedule, task); + } + + @PatchMapping("/{scheduleId}") + @Operation(summary = "일정 수정 (작업 없이)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "일정이 수정되었습니다.", content = @Content(schema = @Schema(implementation = ScheduleSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity updateSchedule(@Parameter(hidden = true) @MemberId Long memberId, + @PathVariable Long scheduleId, + @RequestBody @Valid ScheduleSimplePatchRequest request) { + Schedule updated = scheduleService.updateSchedule(memberId, scheduleId, request.toPatch(dateTimeUtils)); + return ResponseEntity.ok(ScheduleSimpleResponse.from(updated)); + } + + @PostMapping("/{scheduleId}/start") + @Operation(summary = "일정 시작 (작업 없이)") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "일정이 시작되었습니다."), + @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity startSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @RequestParam ZoneId zoneId) { + scheduleStateChangeService.startSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{scheduleId}/complete") + @Operation(summary = "일정 완료 (작업 없이)") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "일정이 완료되었습니다."), + @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity completeSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @RequestParam ZoneId zoneId) { + scheduleStateChangeService.completeSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{scheduleId}/suspend") + @Operation(summary = "일정 일시중지 (작업 없이)") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "일정이 일시중지되었습니다."), + @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity suspendSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @RequestParam ZoneId zoneId) { + scheduleStateChangeService.suspendSchedule(memberId, scheduleId, dateTimeUtils.toZonedDateTime(time, zoneId)); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{scheduleId}/cancel") + @Operation(summary = "일정 취소 (작업 없이)") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "일정이 취소되었습니다."), + @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity cancelSchedule(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long scheduleId) { + scheduleStateChangeService.cancelSchedule(memberId, scheduleId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{scheduleId}") + @Operation(summary = "일정 삭제 (작업 없이)") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "일정이 삭제되었습니다."), + @ApiResponse(responseCode = "404", description = "일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity deleteSchedule(@Parameter(hidden = true) @MemberId Long memberId, + @PathVariable Long scheduleId) { + scheduleService.deleteSchedule(memberId, scheduleId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleRequest.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleRequest.java similarity index 94% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleRequest.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleRequest.java index 0ae97995..92696d55 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleRequest.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleRequest.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.schedule.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -10,6 +10,8 @@ import me.gg.pinit.pinittask.application.schedule.dto.DependencyDto; import me.gg.pinit.pinittask.application.schedule.dto.ScheduleDependencyAdjustCommand; import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; +import me.gg.pinit.pinittask.interfaces.task.dto.DependencyRequest; import me.gg.pinit.pinittask.interfaces.utils.FibonacciDifficulty; import java.util.ArrayList; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleResponse.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleResponse.java similarity index 95% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleResponse.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleResponse.java index d9417216..c4cbda00 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleResponse.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleResponse.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.schedule.dto; import io.swagger.v3.oas.annotations.media.Schema; import me.gg.pinit.pinittask.domain.schedule.model.Schedule; @@ -6,6 +6,7 @@ import me.gg.pinit.pinittask.domain.task.model.Task; import me.gg.pinit.pinittask.domain.task.vo.ImportanceConstraint; import me.gg.pinit.pinittask.domain.task.vo.TemporalConstraint; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; import java.time.Duration; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleSimplePatchRequest.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimplePatchRequest.java similarity index 91% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleSimplePatchRequest.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimplePatchRequest.java index d49eeed7..868a18b0 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleSimplePatchRequest.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimplePatchRequest.java @@ -1,10 +1,11 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.schedule.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; import me.gg.pinit.pinittask.domain.schedule.patch.SchedulePatch; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; public record ScheduleSimplePatchRequest( @Schema(description = "일정 제목", example = "팀 회의") diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleSimpleRequest.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimpleRequest.java similarity index 91% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleSimpleRequest.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimpleRequest.java index b6035cde..35a94df0 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleSimpleRequest.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimpleRequest.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.schedule.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -7,6 +7,7 @@ import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; import me.gg.pinit.pinittask.domain.schedule.model.Schedule; import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; public record ScheduleSimpleRequest( @NotBlank diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleSimpleResponse.java b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimpleResponse.java similarity index 94% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleSimpleResponse.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimpleResponse.java index b025b5ed..68524d23 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/ScheduleSimpleResponse.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/schedule/dto/ScheduleSimpleResponse.java @@ -1,9 +1,10 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.schedule.dto; import io.swagger.v3.oas.annotations.media.Schema; import me.gg.pinit.pinittask.domain.schedule.model.Schedule; import me.gg.pinit.pinittask.domain.schedule.vo.ScheduleHistory; import me.gg.pinit.pinittask.domain.task.model.Task; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; import java.time.Duration; import java.time.Instant; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/web/StatisticsControllerV0.java b/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV0.java similarity index 94% rename from src/main/java/me/gg/pinit/pinittask/interfaces/web/StatisticsControllerV0.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV0.java index 908d7055..a50d0aad 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/web/StatisticsControllerV0.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV0.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.web; +package me.gg.pinit.pinittask.interfaces.statistics; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -8,8 +8,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; import me.gg.pinit.pinittask.application.statistics.service.StatisticsService; -import me.gg.pinit.pinittask.interfaces.dto.StatisticsResponse; import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; +import me.gg.pinit.pinittask.interfaces.statistics.dto.StatisticsResponse; import me.gg.pinit.pinittask.interfaces.utils.MemberId; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/web/StatisticsControllerV1.java b/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV1.java similarity index 94% rename from src/main/java/me/gg/pinit/pinittask/interfaces/web/StatisticsControllerV1.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV1.java index b8680a3c..b93da49b 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/web/StatisticsControllerV1.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV1.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.web; +package me.gg.pinit.pinittask.interfaces.statistics; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -10,8 +10,8 @@ import lombok.RequiredArgsConstructor; import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; import me.gg.pinit.pinittask.application.statistics.service.StatisticsService; -import me.gg.pinit.pinittask.interfaces.dto.StatisticsResponse; import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; +import me.gg.pinit.pinittask.interfaces.statistics.dto.StatisticsResponse; import me.gg.pinit.pinittask.interfaces.utils.MemberId; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; @@ -22,6 +22,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; +@Deprecated @RestController @RequestMapping("/v1/statistics") @Tag(name = "StatisticsV1", description = "통계 조회 API (v1)") diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV2.java new file mode 100644 index 00000000..c73a71d0 --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/StatisticsControllerV2.java @@ -0,0 +1,48 @@ +package me.gg.pinit.pinittask.interfaces.statistics; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; +import me.gg.pinit.pinittask.application.statistics.service.StatisticsService; +import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; +import me.gg.pinit.pinittask.interfaces.statistics.dto.StatisticsResponse; +import me.gg.pinit.pinittask.interfaces.utils.MemberId; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +@RestController +@RequestMapping("/v2/statistics") +@Tag(name = "StatisticsV2", description = "통계 조회 API") +@RequiredArgsConstructor +public class StatisticsControllerV2 { + + private final StatisticsService statisticsService; + private final DateTimeUtils dateTimeUtils; + + @GetMapping + @Operation(summary = "사용자 통계 조회", description = "주어진 시점 기준 주간 통계를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "통계 조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "통계를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public StatisticsResponse getStatistics( + @Parameter(hidden = true) @MemberId Long memberId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime time, + @RequestParam ZoneId zoneId + ) { + return StatisticsResponse.from(statisticsService.getStatistics(memberId, dateTimeUtils.toZonedDateTime(time, zoneId))); + } +} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/StatisticsResponse.java b/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/dto/StatisticsResponse.java similarity index 91% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/StatisticsResponse.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/statistics/dto/StatisticsResponse.java index 6e318f02..4b147d38 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/StatisticsResponse.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/statistics/dto/StatisticsResponse.java @@ -1,8 +1,9 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.statistics.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import me.gg.pinit.pinittask.domain.statistics.model.Statistics; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; import java.time.Duration; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java similarity index 96% rename from src/main/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java index 2dabcc8a..f3b9553a 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.web; +package me.gg.pinit.pinittask.interfaces.task; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -16,8 +16,9 @@ import me.gg.pinit.pinittask.application.task.service.TaskService; import me.gg.pinit.pinittask.domain.schedule.model.Schedule; import me.gg.pinit.pinittask.domain.task.model.Task; -import me.gg.pinit.pinittask.interfaces.dto.*; import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleResponse; +import me.gg.pinit.pinittask.interfaces.task.dto.*; import me.gg.pinit.pinittask.interfaces.utils.MemberId; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -37,6 +38,7 @@ @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) +@Deprecated public class TaskControllerV1 { private final DateTimeUtils dateTimeUtils; private final DependencyService dependencyService; @@ -81,14 +83,14 @@ public Page getTasks(@Parameter(hidden = true) @MemberId Long memb @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "false") boolean readyOnly) { - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.asc("temporalConstraint.deadline.dateTime"))); + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.asc("temporalConstraint.deadline.date"))); Page tasks = taskService.getTasks(memberId, pageable, readyOnly); var dependencyMap = dependencyService.getDependencyInfoForTasks(memberId, tasks.getContent().stream().map(Task::getId).toList()); return tasks.map(task -> TaskResponse.from(task, dependencyMap.get(task.getId()))); } @GetMapping("/cursor") - @Operation(summary = "작업 목록 커서 조회", description = "deadline asc, id asc 커서 기반 페이지네이션. cursor는 'YYYY-MM-DDTHH:MM:SS|taskId' 형식, 더 이상 데이터가 없으면 nextCursor=null.") + @Operation(summary = "작업 목록 커서 조회", description = "마감 날짜(자정 00:00:00) asc, id asc 커서 기반 페이지네이션. cursor는 'YYYY-MM-DDTHH:MM:SS|taskId' 형식이며 시간 부분은 00:00:00으로 고정됩니다. 더 이상 데이터가 없으면 nextCursor=null.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "커서 기반 작업 목록 조회 성공", content = @Content(schema = @Schema(implementation = TaskCursorPageResponse.class))), @ApiResponse(responseCode = "400", description = "커서 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java new file mode 100644 index 00000000..c83994b0 --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java @@ -0,0 +1,174 @@ +package me.gg.pinit.pinittask.interfaces.task; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; +import me.gg.pinit.pinittask.application.dependency.service.DependencyService; +import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; +import me.gg.pinit.pinittask.application.task.service.TaskAdjustmentService; +import me.gg.pinit.pinittask.application.task.service.TaskService; +import me.gg.pinit.pinittask.domain.schedule.model.Schedule; +import me.gg.pinit.pinittask.domain.task.model.Task; +import me.gg.pinit.pinittask.interfaces.exception.ErrorResponse; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleResponse; +import me.gg.pinit.pinittask.interfaces.task.dto.*; +import me.gg.pinit.pinittask.interfaces.utils.MemberId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/v2/tasks") +@RequiredArgsConstructor +@Tag(name = "TaskV2", description = "작업 관리 API (마감 날짜 + 오프셋 기반)") +@ApiResponses({ + @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "대상을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) +}) +public class TaskControllerV2 { + private final DateTimeUtils dateTimeUtils; + private final DependencyService dependencyService; + private final TaskAdjustmentService taskAdjustmentService; + private final TaskService taskService; + private final ScheduleService scheduleService; + + @PostMapping + @Operation(summary = "작업 생성", description = "새 작업과 의존 관계를 등록합니다. 마감은 날짜 + UTC 오프셋(시간 00:00 고정)으로 입력합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "작업이 생성되었습니다.", content = @Content(schema = @Schema(implementation = TaskResponseV2.class))), + @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity createTask(@Parameter(hidden = true) @MemberId Long memberId, + @Valid @RequestBody TaskCreateRequestV2 request) { + Task saved = taskAdjustmentService.createTask(memberId, request.toCommand(null, memberId, dateTimeUtils)); + return ResponseEntity.status(HttpStatus.CREATED).body(TaskResponseV2.from(saved)); + } + + @PatchMapping("/{taskId}") + @Operation(summary = "작업 수정", description = "작업 본문과 의존 관계를 함께 수정합니다. 마감 날짜는 00:00:00 기준으로 저장됩니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "작업이 수정되었습니다.", content = @Content(schema = @Schema(implementation = TaskResponseV2.class))), + @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "현재 상태와 충돌했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity updateTask(@Parameter(hidden = true) @MemberId Long memberId, + @PathVariable Long taskId, + @Valid @RequestBody TaskUpdateRequestV2 request) { + Task updated = taskAdjustmentService.updateTask(memberId, request.toCommand(taskId, memberId, dateTimeUtils)); + return ResponseEntity.ok(TaskResponseV2.from(updated)); + } + + @GetMapping + @Operation(summary = "작업 목록 조회", description = "회원의 작업 목록을 조회합니다. page/size로 마감 날짜 오름차순 페이지네이션, readyOnly로 선행 작업 없는 항목만 필터링합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "작업 목록 조회 성공") + }) + public Page getTasks(@Parameter(hidden = true) @MemberId Long memberId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "false") boolean readyOnly) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.asc("temporalConstraint.deadline.date"))); + Page tasks = taskService.getTasks(memberId, pageable, readyOnly); + var dependencyMap = dependencyService.getDependencyInfoForTasks(memberId, tasks.getContent().stream().map(Task::getId).toList()); + return tasks.map(task -> TaskResponseV2.from(task, dependencyMap.get(task.getId()))); + } + + @GetMapping("/cursor") + @Operation(summary = "작업 목록 커서 조회", description = "마감 날짜(00:00:00) asc, id asc 커서 기반 페이지네이션. cursor는 'YYYY-MM-DDTHH:MM:SS|taskId' 형식(시간은 항상 00:00:00)입니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "커서 기반 작업 목록 조회 성공", content = @Content(schema = @Schema(implementation = TaskCursorPageResponseV2.class))), + @ApiResponse(responseCode = "400", description = "커서 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public TaskCursorPageResponseV2 getTasksByCursor(@Parameter(hidden = true) @MemberId Long memberId, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "false") boolean readyOnly) { + TaskService.CursorPage page = taskService.getTasksByCursorPage(memberId, size, cursor, readyOnly); + var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(memberId, page.tasks().stream().map(Task::getId).toList()); + List data = page.tasks().stream() + .map(task -> TaskResponseV2.from(task, dependencyInfoMap.get(task.getId()))) + .toList(); + return TaskCursorPageResponseV2.of(data, page.nextCursor(), page.hasNext()); + } + + @GetMapping("/{taskId}") + @Operation(summary = "작업 단건 조회", description = "특정 작업의 상세 정보를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "작업 단건 조회 성공", content = @Content(schema = @Schema(implementation = TaskResponseV2.class))), + @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public TaskResponseV2 getTask(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long taskId) { + Task task = taskService.getTask(memberId, taskId); + var dependencyInfo = dependencyService.getDependencyInfo(memberId, taskId); + return TaskResponseV2.from(task, dependencyInfo); + } + + @PostMapping("/{taskId}/complete") + @Operation(summary = "작업 완료", description = "작업을 완료 상태로 변경합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "작업이 완료되었습니다."), + @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity completeTask(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long taskId) { + taskService.markCompleted(memberId, taskId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{taskId}/reopen") + @Operation(summary = "작업 되돌리기", description = "작업을 미완료 상태로 되돌립니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "작업이 되돌려졌습니다."), + @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "잘못된 상태 전환입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity reopenTask(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long taskId) { + taskService.markIncomplete(memberId, taskId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{taskId}") + @Operation(summary = "작업 삭제", description = "작업과 그 작업에 관련된 의존 관계를 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "작업이 삭제되었습니다."), + @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity deleteTask(@Parameter(hidden = true) @MemberId Long memberId, + @PathVariable Long taskId, + @RequestParam(defaultValue = "false") boolean deleteSchedules) { + taskService.deleteTask(memberId, taskId, deleteSchedules); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{taskId}/schedules") + @Operation(summary = "작업을 일정으로 등록", description = "기존 작업을 지정한 시간의 일정으로 복사합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "작업이 일정으로 등록되었습니다.", content = @Content(schema = @Schema(implementation = ScheduleSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값 검증에 실패했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "작업을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity createScheduleFromTask(@Parameter(hidden = true) @MemberId Long memberId, + @PathVariable Long taskId, + @Valid @RequestBody TaskScheduleRequest request) { + Task task = taskService.getTask(memberId, taskId); + Schedule saved = scheduleService.addSchedule(request.toSchedule(task, memberId, dateTimeUtils)); + return ResponseEntity.status(HttpStatus.CREATED).body(ScheduleSimpleResponse.from(saved)); + } +} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DependencyRequest.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/DependencyRequest.java similarity index 90% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/DependencyRequest.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/DependencyRequest.java index 817fde7a..f432e528 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DependencyRequest.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/DependencyRequest.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.task.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequest.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequest.java similarity index 96% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequest.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequest.java index cdadfcc1..3dc97229 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequest.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequest.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.task.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -9,6 +9,7 @@ import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; import me.gg.pinit.pinittask.application.schedule.dto.DependencyDto; import me.gg.pinit.pinittask.application.task.dto.TaskDependencyAdjustCommand; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; import me.gg.pinit.pinittask.interfaces.utils.FibonacciDifficulty; import java.util.ArrayList; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java new file mode 100644 index 00000000..a52f25a1 --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCreateRequestV2.java @@ -0,0 +1,76 @@ +package me.gg.pinit.pinittask.interfaces.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; +import me.gg.pinit.pinittask.application.schedule.dto.DependencyDto; +import me.gg.pinit.pinittask.application.task.dto.TaskDependencyAdjustCommand; +import me.gg.pinit.pinittask.interfaces.dto.DateWithOffset; +import me.gg.pinit.pinittask.interfaces.utils.FibonacciDifficulty; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public record TaskCreateRequestV2( + @NotBlank + @Schema(description = "작업 제목", example = "스터디 준비") + String title, + @NotBlank + @Schema(description = "작업 설명", example = "다음 주 발표 자료 정리") + String description, + @NotNull + @Schema(description = "마감 날짜(+오프셋)", example = "{\"date\":\"2024-03-01\",\"offset\":\"+09:00\"}") + @Valid + DateWithOffset dueDate, + @NotNull + @Min(1) + @Max(9) + @Schema(description = "중요도 (1~9)", example = "5") + Integer importance, + @NotNull + @FibonacciDifficulty + @Schema(description = "난이도 (피보나치 수: 1,2,3,5,8,13,21)", example = "5") + Integer difficulty, + @Schema(description = "추가할 의존 관계 목록 (생성 시 각 항목에 fromId 또는 toId 중 하나는 0)") + List<@Valid DependencyRequest> addDependencies +) { + public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, DateTimeUtils dateTimeUtils) { + validateMustContainSelfPlaceholder(addDependencies); + List remove = List.of(); // 생성 시 remove는 허용하지 않음 + List add = toDependencyDtos(addDependencies); + return new TaskDependencyAdjustCommand( + taskId, + ownerId, + title, + description, + dateTimeUtils.toStartOfDay(dueDate.date(), dueDate.offset()), + importance, + difficulty, + remove, + add + ); + } + + private List toDependencyDtos(List requests) { + return Optional.ofNullable(requests) + .orElseGet(ArrayList::new) + .stream() + .map(request -> new DependencyDto(null, request.fromId(), request.toId())) + .toList(); + } + + private void validateMustContainSelfPlaceholder(List dependencies) { + Optional.ofNullable(dependencies) + .orElseGet(ArrayList::new) + .forEach(dep -> { + if (dep.fromId() != 0L && dep.toId() != 0L) { + throw new IllegalArgumentException("작업 생성 시 의존 관계에는 fromId 또는 toId 중 하나가 0이어야 합니다."); + } + }); + } +} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskCursorPageResponse.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCursorPageResponse.java similarity index 92% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskCursorPageResponse.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCursorPageResponse.java index 2ff1aded..5ce1d390 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskCursorPageResponse.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCursorPageResponse.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.task.dto; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCursorPageResponseV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCursorPageResponseV2.java new file mode 100644 index 00000000..73346a5a --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskCursorPageResponseV2.java @@ -0,0 +1,18 @@ +package me.gg.pinit.pinittask.interfaces.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record TaskCursorPageResponseV2( + @Schema(description = "작업 목록") + List data, + @Schema(description = "다음 페이지 요청 시 사용할 커서. 더 이상 데이터가 없으면 null") + String nextCursor, + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext +) { + public static TaskCursorPageResponseV2 of(List data, String nextCursor, boolean hasNext) { + return new TaskCursorPageResponseV2(data, nextCursor, hasNext); + } +} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskResponse.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskResponse.java similarity index 92% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskResponse.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskResponse.java index f09b5e22..3bdac984 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskResponse.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskResponse.java @@ -1,10 +1,11 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.task.dto; import io.swagger.v3.oas.annotations.media.Schema; import me.gg.pinit.pinittask.application.dependency.service.DependencyService.TaskDependencyInfo; import me.gg.pinit.pinittask.domain.task.model.Task; import me.gg.pinit.pinittask.domain.task.vo.ImportanceConstraint; import me.gg.pinit.pinittask.domain.task.vo.TemporalConstraint; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; import java.time.Instant; import java.util.Collections; @@ -19,7 +20,7 @@ public record TaskResponse( String title, @Schema(description = "작업 설명") String description, - @Schema(description = "마감 기한") + @Schema(description = "마감 기한 (00:00:00 고정, ZoneOffset 기반)") DateTimeWithZone dueDate, @Schema(description = "중요도") int importance, diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskResponseV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskResponseV2.java new file mode 100644 index 00000000..299dbe9d --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskResponseV2.java @@ -0,0 +1,67 @@ +package me.gg.pinit.pinittask.interfaces.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import me.gg.pinit.pinittask.application.dependency.service.DependencyService; +import me.gg.pinit.pinittask.domain.task.model.Task; +import me.gg.pinit.pinittask.domain.task.vo.ImportanceConstraint; +import me.gg.pinit.pinittask.domain.task.vo.TemporalConstraint; +import me.gg.pinit.pinittask.interfaces.dto.DateWithOffset; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +public record TaskResponseV2( + @Schema(description = "작업 ID", example = "10") + Long id, + @Schema(description = "회원 ID", example = "3") + Long ownerId, + @Schema(description = "작업 제목") + String title, + @Schema(description = "작업 설명") + String description, + @Schema(description = "마감 날짜(+오프셋, 00:00 시각)") + DateWithOffset dueDate, + @Schema(description = "중요도") + int importance, + @Schema(description = "난이도") + int difficulty, + @Schema(description = "완료 여부") + boolean completed, + @Schema(description = "들어오는 의존 관계 수") + int inboundDependencyCount, + @Schema(description = "선행 작업 ID 목록") + List previousTaskIds, + @Schema(description = "후행 작업 ID 목록") + List nextTaskIds, + @Schema(description = "생성 시각") + Instant createdAt, + @Schema(description = "수정 시각") + Instant updatedAt +) { + public static TaskResponseV2 from(Task task) { + return from(task, null); + } + + public static TaskResponseV2 from(Task task, DependencyService.TaskDependencyInfo dependencyInfo) { + TemporalConstraint temporal = task.getTemporalConstraint(); + ImportanceConstraint importanceConstraint = task.getImportanceConstraint(); + List previous = dependencyInfo == null ? Collections.emptyList() : dependencyInfo.previousTaskIds(); + List next = dependencyInfo == null ? Collections.emptyList() : dependencyInfo.nextTaskIds(); + return new TaskResponseV2( + task.getId(), + task.getOwnerId(), + task.getTitle(), + task.getDescription(), + DateWithOffset.from(temporal.getDeadline()), + importanceConstraint.getImportance(), + importanceConstraint.getDifficulty(), + task.isCompleted(), + task.getInboundDependencyCount(), + previous, + next, + task.getCreatedAt(), + task.getUpdatedAt() + ); + } +} diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskScheduleRequest.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskScheduleRequest.java similarity index 93% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskScheduleRequest.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskScheduleRequest.java index 1011ab02..02d2071c 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskScheduleRequest.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskScheduleRequest.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.task.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -7,6 +7,7 @@ import me.gg.pinit.pinittask.domain.schedule.model.Schedule; import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; import me.gg.pinit.pinittask.domain.task.model.Task; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; public record TaskScheduleRequest( @Schema(description = "일정 제목(미입력 시 작업 제목 사용)", example = "오늘 할당된 작업") diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskUpdateRequest.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequest.java similarity index 96% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskUpdateRequest.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequest.java index 72559a5d..384f1ef8 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskUpdateRequest.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequest.java @@ -1,4 +1,4 @@ -package me.gg.pinit.pinittask.interfaces.dto; +package me.gg.pinit.pinittask.interfaces.task.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -9,6 +9,7 @@ import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; import me.gg.pinit.pinittask.application.schedule.dto.DependencyDto; import me.gg.pinit.pinittask.application.task.dto.TaskDependencyAdjustCommand; +import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; import me.gg.pinit.pinittask.interfaces.utils.FibonacciDifficulty; import java.util.ArrayList; diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java new file mode 100644 index 00000000..17748381 --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskUpdateRequestV2.java @@ -0,0 +1,79 @@ +package me.gg.pinit.pinittask.interfaces.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; +import me.gg.pinit.pinittask.application.schedule.dto.DependencyDto; +import me.gg.pinit.pinittask.application.task.dto.TaskDependencyAdjustCommand; +import me.gg.pinit.pinittask.interfaces.dto.DateWithOffset; +import me.gg.pinit.pinittask.interfaces.utils.FibonacciDifficulty; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public record TaskUpdateRequestV2( + @NotBlank + @Schema(description = "작업 제목", example = "스터디 준비") + String title, + @NotBlank + @Schema(description = "작업 설명", example = "다음 주 발표 자료 정리") + String description, + @NotNull + @Schema(description = "마감 날짜(+오프셋)", example = "{\"date\":\"2024-03-01\",\"offset\":\"+09:00\"}") + @Valid + DateWithOffset dueDate, + @NotNull + @Min(1) + @Max(9) + @Schema(description = "중요도 (1~9)", example = "5") + Integer importance, + @NotNull + @FibonacciDifficulty + @Schema(description = "난이도 (피보나치 수: 1,2,3,5,8,13,21)", example = "5") + Integer difficulty, + @Schema(description = "제거할 의존 관계 목록 (수정 시 0 사용 금지)") + List<@Valid DependencyRequest> removeDependencies, + @Schema(description = "추가할 의존 관계 목록 (수정 시 0 사용 금지)") + List<@Valid DependencyRequest> addDependencies +) { + public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, DateTimeUtils dateTimeUtils) { + validateNoPlaceholder(removeDependencies); + validateNoPlaceholder(addDependencies); + List remove = toDependencyDtos(removeDependencies); + List add = toDependencyDtos(addDependencies); + return new TaskDependencyAdjustCommand( + taskId, + ownerId, + title, + description, + dateTimeUtils.toStartOfDay(dueDate.date(), dueDate.offset()), + importance, + difficulty, + remove, + add + ); + } + + private void validateNoPlaceholder(List dependencies) { + Optional.ofNullable(dependencies) + .orElseGet(ArrayList::new) + .forEach(dep -> { + if (dep.fromId() == 0L || dep.toId() == 0L) { + throw new IllegalArgumentException("수정 요청에서는 의존 관계 ID에 0을 사용할 수 없습니다."); + } + }); + } + + private List toDependencyDtos(List requests) { + return Optional.ofNullable(requests) + .orElseGet(ArrayList::new) + .stream() + .map(request -> new DependencyDto(null, request.fromId(), request.toId())) + .toList(); + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleServiceTest.java index 3cefaa43..5b877ae8 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleServiceTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/schedule/service/ScheduleServiceTest.java @@ -60,7 +60,7 @@ void getSchedule() { void getScheduleList() { //given when(memberService.findZoneIdOfMember(memberId)).thenReturn(ZoneId.of("Asia/Seoul")); - when(scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeInstantBetween(eq(memberId), any(), any())).thenReturn(List.of(scheduleSample)); + when(scheduleRepository.findAllByOwnerIdAndDesignatedStartTimeBetween(eq(memberId), any(), any())).thenReturn(List.of(scheduleSample)); //when List scheduleList = scheduleService.getScheduleList(memberId, ZonedDateTime.of(2025, 10, 1, 0, 0, 0, 0, ZoneId.of("Asia/Seoul"))); @@ -69,7 +69,7 @@ void getScheduleList() { assertNotNull(scheduleList); assertEquals(1, scheduleList.size()); verify(memberService).findZoneIdOfMember(memberId); - verify(scheduleRepository).findAllByOwnerIdAndDesignatedStartTimeInstantBetween(eq(memberId), any(), any()); + verify(scheduleRepository).findAllByOwnerIdAndDesignatedStartTimeBetween(eq(memberId), any(), any()); } @Test diff --git a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentServiceTest.java index db24c69b..18a02258 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentServiceTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentServiceTest.java @@ -84,7 +84,8 @@ void createTask_checksCycleThenCreatesTaskAndSavesDependencies() { assertThat(createdTask.getOwnerId()).isEqualTo(memberId); assertThat(createdTask.getTitle()).isEqualTo("new task"); assertThat(createdTask.getDescription()).isEqualTo("new description"); - assertThat(createdTask.getDueDate()).isEqualTo(dueDate); + assertThat(createdTask.getDueDate().toLocalDate()).isEqualTo(dueDate.toLocalDate()); + assertThat(createdTask.getDueDate().getOffset()).isEqualTo(dueDate.getOffset()); assertThat(createdTask.getImportanceConstraint().getImportance()).isEqualTo(5); assertThat(createdTask.getImportanceConstraint().getDifficulty()).isEqualTo(5); diff --git a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java index 1e4a09d3..aef7d443 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java @@ -14,7 +14,7 @@ import me.gg.pinit.pinittask.domain.task.repository.TaskRepository; import me.gg.pinit.pinittask.domain.task.vo.ImportanceConstraint; import me.gg.pinit.pinittask.domain.task.vo.TemporalConstraint; -import me.gg.pinit.pinittask.interfaces.dto.TaskCursorPageResponse; +import me.gg.pinit.pinittask.interfaces.task.dto.TaskCursorPageResponse; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -30,6 +30,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.time.Duration; +import java.time.LocalDate; import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; @@ -206,7 +207,7 @@ void getTasksByCursor_returnsNextCursorWhenPageFull() { ReflectionTestUtils.setField(t1, "id", 1L); Task t2 = buildTask(ownerId); ReflectionTestUtils.setField(t2, "id", 2L); - when(taskRepository.findNextByCursor(eq(ownerId), eq(true), any(), any(), any())) + when(taskRepository.findNextByCursor(eq(ownerId), eq(true), any(LocalDate.class), anyLong(), any())) .thenReturn(List.of(t1, t2)); TaskCursorPageResponse resp = taskService.getTasksByCursor(ownerId, 2, null, true); @@ -221,7 +222,7 @@ void getTasksByCursor_noNextWhenSmallerThanSize() { Long ownerId = 51L; Task t1 = buildTask(ownerId); ReflectionTestUtils.setField(t1, "id", 5L); - when(taskRepository.findNextByCursor(eq(ownerId), eq(false), any(), any(), any())) + when(taskRepository.findNextByCursor(eq(ownerId), eq(false), any(LocalDate.class), anyLong(), any())) .thenReturn(List.of(t1)); TaskCursorPageResponse resp = taskService.getTasksByCursor(ownerId, 2, null, false); diff --git a/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java b/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java new file mode 100644 index 00000000..9b034e9c --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java @@ -0,0 +1,51 @@ +package me.gg.pinit.pinittask.domain.task.repository; + +import me.gg.pinit.pinittask.domain.task.model.Task; +import me.gg.pinit.pinittask.domain.task.vo.ImportanceConstraint; +import me.gg.pinit.pinittask.domain.task.vo.TemporalConstraint; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class TaskRepositoryTest { + + @Autowired + TaskRepository taskRepository; + + @Test + void findNextByCursor_ordersByDateThenId() { + Task t1 = new Task(1L, "A", "A-desc", + new TemporalConstraint(ZonedDateTime.of(2024, 1, 2, 18, 0, 0, 0, ZoneOffset.ofHours(9)), Duration.ZERO), + new ImportanceConstraint(3, 3)); + Task t2 = new Task(1L, "B", "B-desc", + new TemporalConstraint(ZonedDateTime.of(2024, 1, 2, 8, 0, 0, 0, ZoneOffset.ofHours(9)), Duration.ZERO), + new ImportanceConstraint(4, 5)); + Task t3 = new Task(1L, "C", "C-desc", + new TemporalConstraint(ZonedDateTime.of(2024, 1, 3, 9, 0, 0, 0, ZoneOffset.UTC), Duration.ZERO), + new ImportanceConstraint(5, 8)); + + taskRepository.saveAll(List.of(t1, t2, t3)); + + List firstPage = taskRepository.findNextByCursor(1L, false, LocalDate.of(2024, 1, 1), 0L, PageRequest.of(0, 10)); + assertThat(firstPage).extracting(Task::getId) + .containsExactly(t1.getId(), t2.getId(), t3.getId()); + + List afterFirst = taskRepository.findNextByCursor(1L, false, t1.getTemporalConstraint().getDeadlineDate(), t1.getId(), PageRequest.of(0, 10)); + assertThat(afterFirst).extracting(Task::getId) + .containsExactly(t2.getId(), t3.getId()); + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraintTest.java b/src/test/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraintTest.java index bd874ff9..ac97629c 100644 --- a/src/test/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraintTest.java +++ b/src/test/java/me/gg/pinit/pinittask/domain/task/vo/TemporalConstraintTest.java @@ -14,7 +14,7 @@ @ExtendWith(MockitoExtension.class) class TemporalConstraintTest { - @Mock + @Mock(strictness = Mock.Strictness.LENIENT) Clock clock; @BeforeEach @@ -26,7 +26,7 @@ void setup() { @Test void Equals_서로_같은_경우() { TemporalConstraint tc = new TemporalConstraint(ZonedDateTime.now(clock), Duration.ofHours(3)); - TemporalConstraint sameTc = new TemporalConstraint(ZonedDateTime.now(clock), Duration.ofHours(3)); + TemporalConstraint sameTc = new TemporalConstraint(ZonedDateTime.now(clock).plusHours(2), Duration.ofHours(3)); assertEquals(tc, sameTc, "TemporalConstraint 객체가 동일한 값으로 생성되었을 때 equals 메서드는 true를 반환해야 합니다."); } @@ -40,10 +40,18 @@ void setup() { } @Test - void Equals_시각_불일치() { + void Equals_날짜_불일치() { TemporalConstraint tc = new TemporalConstraint(ZonedDateTime.now(clock), Duration.ofHours(3)); - TemporalConstraint diffTc = new TemporalConstraint(ZonedDateTime.now(clock).plusHours(3), Duration.ofHours(3)); + TemporalConstraint diffTc = new TemporalConstraint(ZonedDateTime.now(clock).plusDays(1), Duration.ofHours(3)); assertNotEquals(tc, diffTc, "TemporalConstraint 객체가 deadline 값이 다를 때 equals 메서드는 false를 반환해야 합니다."); } + + @Test + void Equals_오프셋_불일치() { + TemporalConstraint tc = new TemporalConstraint(ZonedDateTime.of(LocalDateTime.of(2025, 1, 1, 10, 0), ZoneId.of("+09:00")), Duration.ofHours(3)); + TemporalConstraint diffTc = new TemporalConstraint(ZonedDateTime.of(LocalDateTime.of(2025, 1, 1, 10, 0), ZoneId.of("UTC")), Duration.ofHours(3)); + + assertNotEquals(tc, diffTc, "TemporalConstraint 객체가 오프셋이 다를 때 equals 메서드는 false를 반환해야 합니다."); + } } diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequestTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequestTest.java index 76d436aa..db47a3d3 100644 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequestTest.java +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequestTest.java @@ -1,6 +1,8 @@ package me.gg.pinit.pinittask.interfaces.dto; import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; +import me.gg.pinit.pinittask.interfaces.task.dto.DependencyRequest; +import me.gg.pinit.pinittask.interfaces.task.dto.TaskCreateRequest; import org.junit.jupiter.api.Test; import java.time.LocalDateTime; diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0IntegrationTest.java index 0acc73a6..88568068 100644 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0IntegrationTest.java +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV0IntegrationTest.java @@ -8,7 +8,7 @@ import me.gg.pinit.pinittask.domain.task.repository.TaskRepository; import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.dto.ScheduleRequest; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -82,8 +82,8 @@ void createLegacyScheduleAndListWithTaskDetails() throws Exception { .andExpect(jsonPath("$.id").isNumber()) .andExpect(jsonPath("$.taskId").isNumber()) .andExpect(jsonPath("$.title").value("스터디 준비")) - .andExpect(jsonPath("$.deadline.dateTime").value("2024-03-01T18:00:00")) - .andExpect(jsonPath("$.deadline.zoneId").value(MEMBER_ZONE.getId())) + .andExpect(jsonPath("$.deadline.dateTime").value("2024-03-01T00:00:00")) + .andExpect(jsonPath("$.deadline.zoneId").value("+09:00")) .andExpect(jsonPath("$.importance").value(5)) .andExpect(jsonPath("$.difficulty").value(3)) .andReturn(); diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1IntegrationTest.java index 86b8e4a3..46d681b8 100644 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1IntegrationTest.java +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1IntegrationTest.java @@ -7,7 +7,7 @@ import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.dto.ScheduleSimpleRequest; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1Test.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1Test.java index 4461dbe0..8a6e103a 100644 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1Test.java +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/ScheduleControllerV1Test.java @@ -7,7 +7,8 @@ import me.gg.pinit.pinittask.domain.schedule.model.Schedule; import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.dto.ScheduleSimpleRequest; +import me.gg.pinit.pinittask.interfaces.schedule.ScheduleControllerV1; +import me.gg.pinit.pinittask.interfaces.schedule.dto.ScheduleSimpleRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java index 3d9f9c55..e75e0d2a 100644 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java @@ -7,8 +7,8 @@ import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.dto.TaskCreateRequest; -import me.gg.pinit.pinittask.interfaces.dto.TaskScheduleRequest; +import me.gg.pinit.pinittask.interfaces.task.dto.TaskCreateRequest; +import me.gg.pinit.pinittask.interfaces.task.dto.TaskScheduleRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -76,7 +76,7 @@ void taskLifecycle_create_retrieve_list_cursor_complete_reopen_delete() throws E .andExpect(jsonPath("$.id").isNumber()) .andExpect(jsonPath("$.ownerId").value(MEMBER_ID)) .andExpect(jsonPath("$.title").value("리포트 작성")) - .andExpect(jsonPath("$.dueDate.dateTime").value("2024-04-01T18:00:00")) + .andExpect(jsonPath("$.dueDate.dateTime").value("2024-04-01T00:00:00")) .andExpect(jsonPath("$.completed").value(false)) .andReturn(); diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1Test.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1Test.java index 9978ea4d..73111c44 100644 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1Test.java +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1Test.java @@ -11,8 +11,9 @@ import me.gg.pinit.pinittask.domain.task.vo.ImportanceConstraint; import me.gg.pinit.pinittask.domain.task.vo.TemporalConstraint; import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.dto.TaskCursorPageResponse; -import me.gg.pinit.pinittask.interfaces.dto.TaskScheduleRequest; +import me.gg.pinit.pinittask.interfaces.task.TaskControllerV1; +import me.gg.pinit.pinittask.interfaces.task.dto.TaskCursorPageResponse; +import me.gg.pinit.pinittask.interfaces.task.dto.TaskScheduleRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java new file mode 100644 index 00000000..9a204d93 --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java @@ -0,0 +1,103 @@ +package me.gg.pinit.pinittask.interfaces.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.gg.pinit.pinittask.domain.member.model.Member; +import me.gg.pinit.pinittask.domain.member.repository.MemberRepository; +import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; +import me.gg.pinit.pinittask.interfaces.dto.DateWithOffset; +import me.gg.pinit.pinittask.interfaces.task.dto.TaskCreateRequestV2; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class TaskControllerV2IntegrationTest { + + private static final long MEMBER_ID = 4L; + private static final ZoneOffset OFFSET = ZoneOffset.of("+09:00"); + + @Autowired + MockMvc mockMvc; + @Autowired + ObjectMapper objectMapper; + @Autowired + MemberRepository memberRepository; + + @MockitoBean + RabbitEventPublisher rabbitEventPublisher; + + @BeforeEach + void setUpMember() { + if (!memberRepository.existsById(MEMBER_ID)) { + memberRepository.save(new Member(MEMBER_ID, "task-v2-user", OFFSET)); + } + } + + @Test + void createAndFetchTasksWithDateOffsetCursor() throws Exception { + TaskCreateRequestV2 createRequest = new TaskCreateRequestV2( + "리포트 작성", + "주간 리포트 초안 작성", + new DateWithOffset(java.time.LocalDate.of(2024, 4, 1), OFFSET), + 5, + 3, + List.of() + ); + + var createResult = mockMvc.perform(post("/v2/tasks") + .header("X-Member-Id", MEMBER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.dueDate.date").value("2024-04-01")) + .andExpect(jsonPath("$.dueDate.offset").value("+09:00")) + .andReturn(); + + JsonNode created = objectMapper.readTree(createResult.getResponse().getContentAsString()); + long taskId = created.get("id").asLong(); + + mockMvc.perform(get("/v2/tasks") + .header("X-Member-Id", MEMBER_ID) + .param("page", "0") + .param("size", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath("$.content[0].id").value(taskId)) + .andExpect(jsonPath("$.content[0].dueDate.date").value("2024-04-01")) + .andExpect(jsonPath("$.content[0].dueDate.offset").value("+09:00")); + + var cursorResult = mockMvc.perform(get("/v2/tasks/cursor") + .header("X-Member-Id", MEMBER_ID) + .param("size", "10") + .param("cursor", "2000-01-01T00:00:00|0") + .param("readyOnly", "true")) + .andExpect(status().isOk()) + .andReturn(); + + JsonNode cursorNode = objectMapper.readTree(cursorResult.getResponse().getContentAsString()); + assertThat(cursorNode.get("data").isArray()).isTrue(); + assertThat(cursorNode.get("data").get(0).get("dueDate").get("date").asText()).isEqualTo("2024-04-01"); + assertThat(cursorNode.get("data").get(0).get("dueDate").get("offset").asText()).isEqualTo("+09:00"); + } +}