Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d2ae895
docs: OpenApiConfig에서 API 설명 업데이트
GoGradually Jan 23, 2026
6f6e952
docs: 지난 Task 모아보기 API 체크리스트 추가
GoGradually Jan 23, 2026
ba071dc
feat: 미완료 상태에서 일정 동기화 로직 추가
GoGradually Jan 24, 2026
38020cb
docs: 완료된 일정 아카이브 기능 구현 및 API 계약 업데이트
GoGradually Jan 27, 2026
509501d
feat: 커서 기반 완료 아카이브 조회 기능 추가
GoGradually Jan 27, 2026
861c34f
test: 완료된 일정 아카이브 조회 기능에 대한 테스트 추가
GoGradually Jan 27, 2026
a05dfc6
feat: Task 엔티티에 완료된 일정 조회를 위한 인덱스 추가
GoGradually Jan 27, 2026
6b95eab
feat: 완료된 일정 아카이브 조회를 위한 서비스 클래스 추가
GoGradually Jan 27, 2026
4381262
test: 완료된 일정 아카이브 조회 기능에 대한 테스트 클래스 추가
GoGradually Jan 27, 2026
640a880
feat: 완료된 일정 아카이브 조회를 위한 커서 페이지 응답 클래스 추가
GoGradually Jan 27, 2026
0bfc6c6
feat: 완료된 작업 아카이브 조회를 위한 API 엔드포인트 추가
GoGradually Jan 27, 2026
5ee4315
feat: 시스템 시계를 위한 ClockConfig 클래스 추가
GoGradually Jan 27, 2026
6ede73b
test: 완료된 일정 아카이브 조회를 위한 통합 테스트 클래스 추가
GoGradually Jan 27, 2026
eb2caac
feat: 완료된 작업 조회를 위한 activeDate 필터 추가
GoGradually Jan 27, 2026
1811c23
feat: 완료된 작업 소유자별 조회를 위한 필터 추가
GoGradually Jan 27, 2026
d280e9b
feat: 완료된 작업 조회를 위한 오늘 날짜 기준 필터 추가
GoGradually Jan 27, 2026
3946f7d
feat: 완료된 작업 조회를 위한 현재 날짜 기준 필터 추가
GoGradually Jan 27, 2026
192924e
docs: 완료되지 않은 작업 목록 및 현재 완료된 사항 추가
GoGradually Jan 27, 2026
20f1876
feat: 작업 목록 조회 API 설명 업데이트
GoGradually Jan 27, 2026
977f07b
docs: 처리해야 하는 완료된 작업 아카이빙 처리 방식 업데이트
GoGradually Jan 27, 2026
649cac8
feat: 소유자 ID와 마감일 기준으로 작업 조회 기능 추가
GoGradually Jan 27, 2026
84d26d8
refactor: 작업 인덱스 순서 변경
GoGradually Jan 27, 2026
ba452d9
test: 소유자 ID와 마감일 기준으로 작업 조회 테스트 추가
GoGradually Jan 27, 2026
950f6c8
refactor: 작업 인덱스 이름 변경 및 불필요한 열 제거
GoGradually Jan 27, 2026
c7305be
feat: 소유자 ID와 마감일 기준으로 작업 조회 기능 추가
GoGradually Jan 27, 2026
41f3482
test: 마감일 기준으로 작업 조회 테스트 추가
GoGradually Jan 27, 2026
1ff9a50
feat: 마감 날짜 기준 작업 조회 기능 추가
GoGradually Jan 27, 2026
bd508e4
test: 마감일 기준 작업 조회 기능 테스트 추가
GoGradually Jan 27, 2026
6d8201c
docs: 마감 날짜 기준 작업 조회 API 추가
GoGradually Jan 27, 2026
32b7c72
feat: 작업 목록의 완료 상태 표시 업데이트
GoGradually Jan 27, 2026
5ffda63
fix: 작업 완료 동기화 호출 제거
GoGradually Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/004 지난 Task 아카이브/past-task-archive-checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 지난 Task 모아보기(완료 + 마감일 1일 경과) API 체크리스트

> 목적: 오늘 00:00 기준 이전(어제까지) 마감일이 지난 완료 Task를 커서 기반으로 모아볼 수 있는 클라이언트용 엔드포인트를 추가한다.

## 요구 정의

- [x] "1일" 기준을 확정한다: **사용자/요청 오프셋의 LocalDate 기준 now-1일 00:00** 을 컷오프로 사용한다.
- [x] 포함 범위를 정의한다: 일정 연결 여부와 무관하게 해당 회원의 모든 완료 Task(마감 필수) 포함.
- [x] 정렬 방향과 기본/최대 페이지 크기를 확정한다: `deadline_date DESC, id DESC`, default 20, max 100, 커서 `yyyy-MM-dd[+HH:mm]|taskId`.
- [x] 응답 필드 확정: `TaskResponseV2` 그대로 사용(마감 날짜+오프셋 노출, 완료 소스는 미노출로 합의).
- [x] 기존 Task 목록 API와의 역할 분리를 명문화한다: `/v2/tasks/completed` 는 마감 1일 경과 + 완료만, 기존 목록은 최신/진행중 포함.

## 스키마 & 도메인

- [x] 도메인에서 마감일(`TemporalConstraint.deadline`)을 손쉽게 `LocalDate`로 얻을 수 있는 헬퍼 확인(기존 `getDeadlineDate()` 재사용).
- [x] 1일 컷오프 계산 시 오프셋을 명시하고 서비스 책임으로 분리(`TaskArchiveService`, 테스트용 Clock 주입).
- [x] DB 인덱스 필요 여부를 검토한다: `(owner_id, completed, deadline_date, task_id)` 복합 인덱스 추가용 DDL 초안 작성(
`task-archive-index.sql`).

## 레포지토리 & 쿼리

- [x] `TaskRepository`에 완료 Task 전용 커서 쿼리를 추가한다: `completed=true`, `deadline_date <= cutoffDate`, 정렬
`deadline_date DESC, id DESC`.
- [x] 커서 인코딩/디코딩 포맷을 정의한다(`yyyy-MM-dd|{id}` 기본, 오프셋 허용) 및 파싱 실패 예외 메시지를 규격화한다.
- [x] 커서 단위 테스트/통합 테스트로 동일 마감일 + ID 타이브레이킹이 안정적으로 동작하는지 검증한다.

## 서비스 & 애플리케이션 레이어

- [x] 1일 컷오프 계산 방식을 구현한다(요청/회원 오프셋 기준 LocalDate.now-1) 및 Clock 주입으로 테스트 가능.
- [x] `TaskArchiveService`에 아카이브용 커서 페이지 모델을 추가하고 DESC 정렬에 맞는 `hasNext`/`nextCursor`(size+1) 계산 완료.
- [x] 완료→재오픈 시 아카이브 대상에서 제외되도록 completed 플래그 의존(재오픈 시 false 처리 유지).

## API 계약 & 컨트롤러

- [x] V2에 `GET /v2/tasks/completed` 커서 엔드포인트 추가, `size(1~100)`, `cursor`, `offset` 검증 처리.
- [x] 응답 DTO는 `TaskArchiveCursorPageResponseV2` + `TaskResponseV2` 조합으로 확정.
- [x] Swagger/OAS 어노테이션에 커서 예제, 1일 컷오프 설명, 400 응답 반영.
- [x] Member 인증(`@MemberId`) 적용, 별도 레이트리밋/캐싱은 도입하지 않기로 결정.

## 테스트

- [x] 도메인/서비스 단위: 오프셋 기반 컷오프·커서 파싱·size 검증 테스트 추가.
- [x] 리포지토리 통합: 커서 정렬/필터/경계 테스트 추가.
- [x] 서비스 단위: 커서 직렬화/역직렬화, 잘못된 커서 예외, size 상한, cutoff 계산 검증.
- [x] 컨트롤러: 요청 검증(0 size), 커서 페이지 동작, 오프셋 파라미터 적용 통합 테스트.
- [x] 이벤트/로그: 별도 트래킹 미도입으로 N/A 확인.

## 배포 & 운영

- [x] 스키마 변경 없음(인덱스만 추가)으로 확정, 적용 DDL은 `task-archive-index.sql`에 기록.
- [x] 운영/스테이징은 수동 인덱스 적용 전제(`ddl-auto` 유지 시 위험 없도록 별도 적용 계획 필요).
- [x] 모니터링 포인트 제안: archive API latency/에러율, 응답 건수, deadline null 비율 알림.
- [x] 프런트/모바일 공지 사항 정리: 커서 포맷·1일 기준 오프셋·max size 100·응답 필드 기존과 동일(새 엔드포인트) 공유 필요.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
- 한번에 보유한 모든 작업을 조회하는 API는 비효율적이다.

- 의존관계 추가를 위해선, 데드라인 날짜 기준으로 작업 조회를 해야 한다.

- 따라서, 마감 날짜 기준 작업 조회 API가 필요하다.

- 이 API는, 특정 날짜에 마감일이 설정된 작업들을 조회하는 기능을 제공해야 한다.

## 구현 메모

- GET `/v2/tasks/by-deadline?date=YYYY-MM-DD` (v1 동일 경로)로 제공하며, 회원 ID 헤더를 받아 해당 날짜에 마감이 있는 모든 작업(완료/미완료 포함)을 반환한다.
- 응답 스키마는 기존 작업 조회 응답(TaskResponseV2/TaskResponse)과 동일하며, 의존관계 정보도 포함된다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### 현재 완료된 사항

- [x] 지난 일정 모아보기 API 구현 완료

### 남은 작업

- [x] 작업 탭에 기본으로 조회되어야 할 "작업 목록"이 "완료되지 않은 작업"/"마감일이 지나지 않은 완료된 작업"을 마감일 기준 오름차순으로 조회
7 changes: 7 additions & 0 deletions docs/완료되지 않은 작업.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- [ ] 아카이빙 해야 할 작업을 Cassandra DB와 같은 곳으로 이동 (배치 처리)
- 현재 DB에 완료된 작업이 계속 쌓이는 것을 방지
- 현재 조회하는 아카이브 조회 방식을 그대로 Batch 처리를 위한 조회 메소드로 사용하기
- 조회된 작업들을 Cassandra DB로 이동
- 이동된 작업들은 기존 DB에서 삭제
- 실제 사용자가 보게 될 아카이브 조회 API는 RDB가 아닌 Cassandra DB에서 조회하도록 변경
- 해당 작업들은 변경 불가
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package me.gg.pinit.pinittask.application.task.service;

import lombok.RequiredArgsConstructor;
import me.gg.pinit.pinittask.application.member.service.MemberService;
import me.gg.pinit.pinittask.domain.task.model.Task;
import me.gg.pinit.pinittask.domain.task.repository.TaskRepository;
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.Clock;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Service
@RequiredArgsConstructor
public class TaskArchiveService {

private static final int DEFAULT_PAGE_SIZE = 20;
private static final int MAX_PAGE_SIZE = 100;
private static final String CURSOR_DELIMITER = "|";
private static final Pattern DATE_WITH_OPTIONAL_OFFSET_PATTERN = Pattern.compile("(?<date>\\d{4}-\\d{2}-\\d{2})(?<offset>[+-]\\d{2}:?\\d{2})?");

private final TaskRepository taskRepository;
private final MemberService memberService;
private final Clock clock;

@Transactional(readOnly = true)
public CursorPage getCompletedArchive(Long ownerId, Integer sizeParam, String cursor, ZoneOffset requestOffset) {
int size = resolveSize(sizeParam);
ZoneOffset effectiveOffset = resolveOffset(ownerId, requestOffset);
LocalDate cutoffDate = LocalDate.now(clock.withZone(effectiveOffset)).minusDays(1);

Cursor decoded = decodeCursor(cursor, cutoffDate);
Pageable pageable = PageRequest.of(0, size + 1);
List<Task> tasks = taskRepository.findCompletedArchiveByCursor(
ownerId,
cutoffDate,
decoded.deadline(),
decoded.id(),
pageable
);

boolean hasNext = tasks.size() > size;
List<Task> content = hasNext ? tasks.subList(0, size) : tasks;
String nextCursor = hasNext ? encodeCursor(content.getLast()) : null;
return new CursorPage(content, nextCursor, hasNext, cutoffDate, effectiveOffset);
}

private int resolveSize(Integer sizeParam) {
int size = sizeParam == null ? DEFAULT_PAGE_SIZE : sizeParam;
if (size <= 0 || size > MAX_PAGE_SIZE) {
throw new IllegalArgumentException("size는 1 이상 100 이하이어야 합니다.");
}
return size;
}

private ZoneOffset resolveOffset(Long ownerId, ZoneOffset requestOffset) {
if (requestOffset != null) {
return requestOffset;
}
return memberService.findZoneOffsetOfMember(ownerId);
}

private String encodeCursor(Task task) {
return task.getTemporalConstraint().getDeadlineDate() + CURSOR_DELIMITER + task.getId();
}

private Cursor decodeCursor(String cursor, LocalDate cutoffDate) {
if (cursor == null || cursor.isBlank()) {
return new Cursor(cutoffDate.plusDays(1), Long.MAX_VALUE);
}
String[] parts = cursor.split("\\|");
if (parts.length != 2) {
throw new IllegalArgumentException("커서는 'yyyy-MM-dd|id' 형식이어야 합니다.");
}
LocalDate deadline = parseDate(parts[0]);
long id = parseId(parts[1]);
return new Cursor(deadline, id);
}

private LocalDate parseDate(String raw) {
Matcher matcher = DATE_WITH_OPTIONAL_OFFSET_PATTERN.matcher(raw);
if (!matcher.matches()) {
throw new IllegalArgumentException("커서는 'yyyy-MM-dd|id' 형식이어야 합니다.");
}
return LocalDate.parse(matcher.group("date"));
}

private long parseId(String raw) {
try {
return Long.parseLong(raw);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("커서의 ID가 올바르지 않습니다.", e);
}
}

public record CursorPage(List<Task> tasks, String nextCursor, boolean hasNext, LocalDate cutoffDate,
ZoneOffset offset) {
}

private record Cursor(LocalDate deadline, long id) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import lombok.RequiredArgsConstructor;
import me.gg.pinit.pinittask.application.dependency.service.DependencyService;
import me.gg.pinit.pinittask.application.events.DomainEventPublisher;
import me.gg.pinit.pinittask.application.member.service.MemberService;
import me.gg.pinit.pinittask.application.schedule.service.ScheduleService;
import me.gg.pinit.pinittask.domain.events.DomainEvent;
import me.gg.pinit.pinittask.domain.events.DomainEvents;
Expand All @@ -19,9 +20,7 @@
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.*;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Deque;
Expand All @@ -38,6 +37,8 @@ public class TaskService {
private final DependencyService dependencyService;
private final ScheduleService scheduleService;
private final DomainEventPublisher domainEventPublisher;
private final MemberService memberService;
private final Clock clock;

@Transactional(readOnly = true)
public Task getTask(Long ownerId, Long taskId) {
Expand All @@ -52,7 +53,8 @@ public Page<Task> getTasks(Long ownerId, Pageable pageable, boolean readyOnly) {
if (readyOnly) {
return taskRepository.findAllByOwnerIdAndInboundDependencyCountAndCompletedFalse(ownerId, 0, pageable);
}
return taskRepository.findAllByOwnerId(ownerId, pageable);
LocalDate today = resolveToday(ownerId);
return taskRepository.findCurrentByOwnerId(ownerId, today, pageable);
}

@Deprecated
Expand Down Expand Up @@ -118,24 +120,37 @@ public void markCompleted(Long ownerId, Long taskId) {
@Transactional
public void markIncomplete(Long ownerId, Long taskId) {
Task task = getTask(ownerId, taskId);
syncScheduleOnTaskCompletion(ownerId, task);
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1) 문제점: markIncomplete()에서 syncScheduleOnTaskCompletion()을 호출하고 있어, 작업을 ‘미완료’로 되돌리는 동작에서 연결된 일정이 finish() 처리(완료 처리)될 수 있습니다.
(2) 영향: 작업은 미완료인데 일정은 완료로 남는 데이터 불일치가 발생하고, 일정이 진행중/중단 상태인 경우에는 ‘작업을 완료할 수 없습니다’ 예외가 발생해 되돌리기(reopen)가 차단될 수 있습니다.
(3) 수정 제안: markIncomplete()에서는 syncScheduleOnTaskCompletion() 호출을 제거하거나, 일정 상태를 미완료로 되돌리는 별도 로직(예: syncScheduleOnTaskReopen/markIncomplete 전용)을 구현해 해당 로직을 호출하도록 수정해 주세요.

Suggested change
syncScheduleOnTaskCompletion(ownerId, task);

Copilot uses AI. Check for mistakes.
task.markIncomplete();
publishEvents();
}

private CursorPage loadTasksByCursor(Long ownerId, int size, String cursor, boolean readyOnly) {
Cursor decoded = decodeCursor(cursor);
LocalDate today = resolveToday(ownerId);
List<Task> tasks = taskRepository.findNextByCursor(
ownerId,
readyOnly,
decoded.deadline(),
decoded.id(),
today,
PageRequest.of(0, size)
);
boolean hasNext = tasks.size() == size;
String nextCursor = hasNext ? encodeCursor(tasks.getLast()) : null;
return new CursorPage(tasks, nextCursor, hasNext);
}

private LocalDate resolveToday(Long ownerId) {
ZoneOffset offset = memberService.findZoneOffsetOfMember(ownerId);
return LocalDate.now(clock.withZone(offset));
}

@Transactional(readOnly = true)
public List<Task> getTasksByDeadline(Long ownerId, LocalDate deadlineDate) {
return taskRepository.findAllByOwnerIdAndDeadlineDate(ownerId, deadlineDate);
}

private void validateOwner(Long ownerId, Task task) {
if (!task.getOwnerId().equals(ownerId)) {
throw new IllegalArgumentException("Member does not own the task");
Expand All @@ -157,6 +172,7 @@ private void syncScheduleOnTaskCompletion(Long ownerId, Task task) {
if (!schedule.isCompleted()) {
schedule.finish(ZonedDateTime.now(schedule.getDesignatedStartTime().getZone()));
}
//TODO 만약 task가 미완료 상태가 되면 일정도 미완료 상태로 변경하는 로직 추가
}

private void publishEvents() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
import java.time.ZonedDateTime;

@Entity
@Table(indexes = {
@Index(name = "idx_task_owner_deadline", columnList = "owner_id, deadline_date, task_id")
})
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public interface TaskRepository extends JpaRepository<Task, Long> {
SELECT t FROM Task t
WHERE t.ownerId = :ownerId
AND (:readyOnly = false OR (t.inboundDependencyCount = 0 AND t.completed = false))
AND (t.completed = false OR t.temporalConstraint.deadline.date >= :activeDate)
AND (
t.temporalConstraint.deadline.date > :cursorDate
OR (t.temporalConstraint.deadline.date = :cursorDate AND t.id > :cursorId)
Expand All @@ -40,5 +41,57 @@ List<Task> findNextByCursor(@Param("ownerId") Long ownerId,
@Param("readyOnly") boolean readyOnly,
@Param("cursorDate") LocalDate cursorDate,
@Param("cursorId") Long cursorId,
@Param("activeDate") LocalDate activeDate,
Pageable pageable);

@Query(value = """
SELECT t FROM Task t
WHERE t.ownerId = :ownerId
AND (t.completed = false OR t.temporalConstraint.deadline.date >= :activeDate)
ORDER BY t.temporalConstraint.deadline.date ASC, t.id ASC
""",
countQuery = """
SELECT count(t) FROM Task t
WHERE t.ownerId = :ownerId
AND (t.completed = false OR t.temporalConstraint.deadline.date >= :activeDate)
""")
Page<Task> findCurrentByOwnerId(@Param("ownerId") Long ownerId,
@Param("activeDate") LocalDate activeDate,
Pageable pageable);

@Query("""
SELECT t FROM Task t
WHERE t.ownerId = :ownerId
AND t.temporalConstraint.deadline.date = :deadlineDate
ORDER BY t.id ASC
""")
List<Task> findAllByOwnerIdAndDeadlineDate(@Param("ownerId") Long ownerId,
@Param("deadlineDate") LocalDate deadlineDate);

@Query("""
SELECT t FROM Task t
WHERE t.ownerId = :ownerId
AND t.completed = true
AND t.temporalConstraint.deadline.date <= :cutoffDate
AND (
t.temporalConstraint.deadline.date < :cursorDate
OR (t.temporalConstraint.deadline.date = :cursorDate AND t.id < :cursorId)
)
ORDER BY t.temporalConstraint.deadline.date DESC, t.id DESC
""")
/**
* 커서 기반 완료 아카이브 조회.
*
* @param ownerId 회원 ID (파티션 키)
* @param cutoffDate 포함되는 마감일 상한 (예: now-1@offset)
* @param cursorDate 커서가 가리키는 마감일. 이 날짜보다 과거 데이터만 조회하며,
* 같은 날짜일 때는 cursorId 미만만 조회한다.
* @param cursorId 커서가 가리키는 작업 ID (마감일 동일 시 tie-breaker)
* @param pageable 페이지 정보(서비스에서 size+1로 전달)
*/
List<Task> findCompletedArchiveByCursor(@Param("ownerId") Long ownerId,
@Param("cutoffDate") LocalDate cutoffDate,
@Param("cursorDate") LocalDate cursorDate,
@Param("cursorId") Long cursorId,
Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package me.gg.pinit.pinittask.infrastructure.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Clock;

@Configuration
public class ClockConfig {

@Bean
public Clock systemClock() {
return Clock.systemUTC();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
info = @Info(
title = "Pinit Task API",
version = "v2",
description = "일정 관리와 의존 관계 기능을 제공하는 API",
description = "일정/작업 관리와 의존 관계 기능을 제공하는 API",
contact = @Contact(name = "Pinit Team", email = "[email protected]")
),
servers = {
Expand Down
Loading
Loading