Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ class EventController(
@AuthenticationPrincipal principal: AuthPrincipal,
@PathVariable id: UUID,
) = eventService.delete(principal.userId, id)

/** 종료(완료) 처리 — 홈 목록에서 숨긴다. 자동 삭제 대신 유저가 명시적으로 종료(FRD 5.1). */
@PostMapping("/{id}/complete")
fun complete(
@AuthenticationPrincipal principal: AuthPrincipal,
@PathVariable id: UUID,
): ApiResponse<EventResponse> {
val event = eventService.complete(principal.userId, id)
return ApiResponse.ok(EventResponse.from(event, eventService.dDay(event)))
}

/** 종료 취소(재개) — 다시 D-Day/D+ 로 노출한다. */
@PostMapping("/{id}/reopen")
fun reopen(
@AuthenticationPrincipal principal: AuthPrincipal,
@PathVariable id: UUID,
): ApiResponse<EventResponse> {
val event = eventService.reopen(principal.userId, id)
return ApiResponse.ok(EventResponse.from(event, eventService.dDay(event)))
}
}

internal fun <T : Any> Page<T>.toMeta(): Meta = Meta(
Expand Down
11 changes: 11 additions & 0 deletions src/main/kotlin/app/ieoseo/server/event/domain/Event.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Index
import jakarta.persistence.Table
import java.time.Instant
import java.time.LocalDate
import java.util.UUID

Expand Down Expand Up @@ -71,8 +72,18 @@ class Event(
/** 표시 색상(헥스 등). 표현용 메타데이터. */
@Column(name = "color", length = 16)
var color: String? = null,

/**
* 종료(완료) 처리 시각. null = 미종료(D-Day/기간이 지나도 D+로 계속 노출),
* 값 있음 = 유저가 명시적으로 종료(완료) 처리(홈 목록에서 숨김). FRD 5.1.
*/
@Column(name = "completed_at")
var completedAt: Instant? = null,
) : BaseEntity() {

/** 종료(완료) 처리 여부 — [completedAt] 존재로 파생. */
val completed: Boolean get() = completedAt != null

init {
// 타입별 날짜 필수 불변식은 EventValidation(EventService 경유)에서 강제한다.
require(title.isNotBlank()) { "title 은 비어 있을 수 없다" }
Expand Down
6 changes: 6 additions & 0 deletions src/main/kotlin/app/ieoseo/server/event/dto/EventDtos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ data class EventResponse(
val pinned: Boolean,
val memo: String?,
val color: String?,
/** 종료(완료) 여부 — true 면 클라이언트가 홈 "다가오는 일정"에서 숨긴다(FRD 5.1). */
val completed: Boolean,
/** 종료(완료) 처리 시각. 미종료면 null. */
val completedAt: Instant?,
/** server 권위 D-Day 파생 계산(FRD 5.1). 계산 컨텍스트가 없으면 null. */
val dday: DDaySummary?,
val createdAt: Instant,
Expand All @@ -84,6 +88,8 @@ data class EventResponse(
pinned = event.pinned,
memo = event.memo,
color = event.color,
completed = event.completed,
completedAt = event.completedAt,
dday = dDay?.let(DDaySummary::from),
createdAt = event.createdAt,
updatedAt = event.updatedAt,
Expand Down
17 changes: 17 additions & 0 deletions src/main/kotlin/app/ieoseo/server/event/service/EventService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
import java.time.LocalDate
import java.util.UUID

Expand Down Expand Up @@ -64,4 +65,20 @@ class EventService(
val event = findById(userId, id)
eventRepository.delete(event)
}

/** 종료(완료) 처리. 이미 종료면 멱등(시각 유지). 홈 목록에서 숨겨진다(FRD 5.1). */
@Transactional
fun complete(userId: UUID, id: UUID, now: Instant = Instant.now()): Event {
val event = findById(userId, id)
if (event.completedAt == null) event.completedAt = now
return event
}

/** 종료 취소(재개). 다시 D-Day/D+ 로 노출된다. 이미 미종료면 멱등. */
@Transactional
fun reopen(userId: UUID, id: UUID): Event {
val event = findById(userId, id)
event.completedAt = null
return event
}
}
4 changes: 4 additions & 0 deletions src/main/resources/db/migration/V12__event_completed_at.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- V12: 이벤트 종료(완료) 처리 지원(FRD 5.1).
-- D-Day/기간이 지나도(D+) 자동 삭제하지 않고, 유저가 명시적으로 "종료 처리"할 때 시각을 기록한다.
-- null = 미종료(진행/경과, 홈에 계속 노출), 값 있음 = 종료(완료, 홈 목록에서 숨김). 백필 없음.
ALTER TABLE events ADD COLUMN completed_at TIMESTAMPTZ;
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.time.Instant
import java.time.LocalDate
import java.util.UUID

Expand Down Expand Up @@ -116,6 +117,29 @@ class EventControllerWebMvcTest {
.andExpect(jsonPath("$.error.details[0].field").value("title"))
}

@Test
fun `종료 처리는 completed=true 로 응답한다`() {
val id = UUID.randomUUID()
val event = Event(
id = id,
userId = userId,
type = EventType.T1_DDAY,
title = "정처기 실기",
date = LocalDate.of(2026, 8, 2),
completedAt = Instant.parse("2026-07-01T00:00:00Z"),
)
`when`(eventService.complete(eqv(userId), eqv(id), anyInstant())).thenReturn(event)
`when`(eventService.dDay(anyEvent(), anyDate())).thenReturn(
DDayResult(daysRemaining = 32, label = "D-32", phase = EventPhase.UPCOMING, urgency = Urgency.LOW),
)

mockMvc.perform(post("/api/v1/events/{id}/complete", id).with(authentication(asUser())))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.completed").value(true))
.andExpect(jsonPath("$.data.completedAt").exists())
}

/** Kotlin non-null 파라미터용 Mockito any() 헬퍼(매처가 null 을 반환해 NPE 나는 문제 회피). */
private fun anyEvent(): Event {
org.mockito.ArgumentMatchers.any(Event::class.java)
Expand All @@ -126,4 +150,9 @@ class EventControllerWebMvcTest {
org.mockito.ArgumentMatchers.any(LocalDate::class.java)
return LocalDate.now()
}

private fun anyInstant(): Instant {
org.mockito.ArgumentMatchers.any(Instant::class.java)
return Instant.now()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.mockito.ArgumentMatchers
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import java.time.Instant
import java.time.LocalDate
import java.util.Optional
import java.util.UUID
Expand Down Expand Up @@ -72,4 +73,55 @@ class EventServiceTest {

assertThrows<NotFoundException> { service.delete(owner, id) }
}

@Test
fun `종료 처리는 completedAt 을 세팅한다`() {
val id = UUID.randomUUID()
val event = Event(id = id, userId = owner, type = EventType.T1_DDAY, title = "x", date = LocalDate.of(2026, 8, 2))
`when`(eventRepository.findByIdAndUserId(id, owner)).thenReturn(Optional.of(event))
val now = Instant.parse("2026-07-01T00:00:00Z")

val result = service.complete(owner, id, now)

assertEquals(now, result.completedAt)
assertEquals(true, result.completed)
}

@Test
fun `종료 처리는 멱등이다 - 이미 종료면 최초 시각을 유지한다`() {
val id = UUID.randomUUID()
val first = Instant.parse("2026-06-01T00:00:00Z")
val event = Event(
id = id, userId = owner, type = EventType.T1_DDAY, title = "x",
date = LocalDate.of(2026, 8, 2), completedAt = first,
)
`when`(eventRepository.findByIdAndUserId(id, owner)).thenReturn(Optional.of(event))

val result = service.complete(owner, id, Instant.parse("2026-07-01T00:00:00Z"))

assertEquals(first, result.completedAt)
}

@Test
fun `종료 취소는 completedAt 을 null 로 되돌린다`() {
val id = UUID.randomUUID()
val event = Event(
id = id, userId = owner, type = EventType.T1_DDAY, title = "x",
date = LocalDate.of(2026, 8, 2), completedAt = Instant.parse("2026-06-01T00:00:00Z"),
)
`when`(eventRepository.findByIdAndUserId(id, owner)).thenReturn(Optional.of(event))

val result = service.reopen(owner, id)

assertEquals(null, result.completedAt)
assertEquals(false, result.completed)
}

@Test
fun `없거나 타인 이벤트 종료 처리는 404 로 차단된다`() {
val id = UUID.randomUUID()
`when`(eventRepository.findByIdAndUserId(id, owner)).thenReturn(Optional.empty())

assertThrows<NotFoundException> { service.complete(owner, id) }
}
}
Loading