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
13 changes: 13 additions & 0 deletions docs/plan/#38-daily-goal/checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# 일별 목표(DailyGoal) 검증 체크리스트

## 필수 항목
- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준)
- [x] 레이어 의존성 규칙 위반 없음
- [x] Domain, Application 테스트 코드 작성 완료 (TDD)
- [x] 모든 테스트 통과
- [x] 기존 테스트 깨지지 않음
- [x] DGS Codegen으로 GraphQL 타입 자동 생성 (수동 작성 금지)

## 선택 항목
- [x] Flyway 마이그레이션 작성 (V5__Create_daily_goal_table.sql)
- [x] Goal + Date + Member 유니크 제약 적용
36 changes: 36 additions & 0 deletions docs/plan/#38-daily-goal/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 일별 목표(DailyGoal) 구현 계획

> Issue: #38

## 단계

- [x] 1단계: Domain — DailyGoal 엔티티, VO, Command, Query, Repository 인터페이스 (TDD)
- [x] 2단계: Application — GoalService에 DailyGoal 메서드 추가 (TDD)
- [x] 3단계: Infrastructure — DailyGoalTable, ExposedDailyGoalRepository, Flyway 마이그레이션
- [x] 4단계: Presentation — GraphQL 스키마 확장, GoalDataFetcher 업데이트
- [x] 5단계: 검증 — 전체 테스트 통과, 아키텍처 준수 확인

## 리팩토링: DailyGoal API를 Goal 중심으로 단순화

GraphQL의 선택적 필드 조회 특성을 활용하여, 별도 DailyGoal 타입을 API에서 제거하고 Goal에 통합.
내부 도메인 모델(DailyGoal 엔티티)은 유지하되, 스키마 표면은 Goal 중심으로 변경.

- [x] 6단계: GraphQL 스키마 — DailyGoal 타입 제거, myGoals에 날짜 필터 추가, mutation 시그니처 변경
- [x] 7단계: Domain — DailyGoalCommand.Remove를 goalId+memberId+date 기반으로 변경, GoalQuery에 assignedDate 추가
- [x] 8단계: Application/Infrastructure — 변경된 Command/Query 반영
- [x] 9단계: Presentation — GoalDataFetcher 리팩토링 (DailyGoal 리졸버 제거, myGoals 필터 적용)
- [x] 10단계: 검증 — 전체 테스트 통과 확인

## GoalFilter 확장: AND 조건 필터 추가

GoalFilter에 ids 등 다양한 조건을 추가하고, 모든 조건은 AND로 결합.

- [x] 11단계: GoalFilter 확장 — 스키마, GoalQuery, Repository, DataFetcher 일괄 변경
- [x] 12단계: 검증 — 전체 테스트 통과 확인

## GoalFilter 추가 필드: id, title, 우선순위 규칙

id(단건) > ids(복수) > title 순 우선. assignedDate는 항상 AND. 이후 추가 조건도 AND.

- [x] 13단계: GoalFilter에 id, title 추가 및 우선순위 로직 구현
- [x] 14단계: 검증
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
package kr.io.team.loop.goal.application.service

import kr.io.team.loop.common.domain.GoalId
import kr.io.team.loop.common.domain.MemberId
import kr.io.team.loop.common.domain.exception.AccessDeniedException
import kr.io.team.loop.common.domain.exception.DuplicateEntityException
import kr.io.team.loop.common.domain.exception.EntityNotFoundException
import kr.io.team.loop.goal.domain.model.DailyGoalCommand
import kr.io.team.loop.goal.domain.model.Goal
import kr.io.team.loop.goal.domain.model.GoalCommand
import kr.io.team.loop.goal.domain.model.GoalQuery
import kr.io.team.loop.goal.domain.repository.DailyGoalRepository
import kr.io.team.loop.goal.domain.repository.GoalRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class GoalService(
private val goalRepository: GoalRepository,
private val dailyGoalRepository: DailyGoalRepository,
) {
@Transactional
fun create(command: GoalCommand.Create): Goal = goalRepository.save(command)

@Transactional(readOnly = true)
fun findAll(query: GoalQuery): List<Goal> = goalRepository.findAll(query)

@Transactional(readOnly = true)
fun findById(id: GoalId): Goal =
goalRepository.findById(id)
?: throw EntityNotFoundException("Goal not found: ${id.value}")

@Transactional
fun update(
command: GoalCommand.Update,
Expand Down Expand Up @@ -47,4 +57,31 @@ class GoalService(
}
goalRepository.delete(command)
}

@Transactional
fun addDailyGoal(command: DailyGoalCommand.Add): Goal {
val goal =
goalRepository.findById(command.goalId)
?: throw EntityNotFoundException("Goal not found: ${command.goalId.value}")
if (!goal.isOwnedBy(command.memberId)) {
throw AccessDeniedException("Goal does not belong to member: ${command.memberId.value}")
}
if (dailyGoalRepository.existsByGoalIdAndMemberIdAndDate(command.goalId, command.memberId, command.date)) {
throw DuplicateEntityException(
"DailyGoal already exists for goal ${command.goalId.value} on ${command.date}",
)
}
dailyGoalRepository.save(command)
return goal
}

@Transactional
fun removeDailyGoal(command: DailyGoalCommand.Remove) {
if (!dailyGoalRepository.existsByGoalIdAndMemberIdAndDate(command.goalId, command.memberId, command.date)) {
throw EntityNotFoundException(
"DailyGoal not found for goal ${command.goalId.value} on ${command.date}",
)
}
dailyGoalRepository.delete(command)
}
}
16 changes: 16 additions & 0 deletions src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoal.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package kr.io.team.loop.goal.domain.model

import kotlinx.datetime.LocalDate
import kr.io.team.loop.common.domain.GoalId
import kr.io.team.loop.common.domain.MemberId
import java.time.Instant

data class DailyGoal(
val id: DailyGoalId,
val goalId: GoalId,
val memberId: MemberId,
val date: LocalDate,
val createdAt: Instant,
) {
fun isOwnedBy(memberId: MemberId): Boolean = this.memberId == memberId
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.io.team.loop.goal.domain.model

import kotlinx.datetime.LocalDate
import kr.io.team.loop.common.domain.GoalId
import kr.io.team.loop.common.domain.MemberId

sealed interface DailyGoalCommand {
data class Add(
val goalId: GoalId,
val memberId: MemberId,
val date: LocalDate,
) : DailyGoalCommand

data class Remove(
val goalId: GoalId,
val memberId: MemberId,
val date: LocalDate,
) : DailyGoalCommand
}
12 changes: 12 additions & 0 deletions src/main/kotlin/kr/io/team/loop/goal/domain/model/DailyGoalId.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.io.team.loop.goal.domain.model

import kr.io.team.loop.common.domain.exception.InvalidInputException

@JvmInline
value class DailyGoalId(
val value: Long,
) {
init {
if (value <= 0) throw InvalidInputException("DailyGoalId must be positive")
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package kr.io.team.loop.goal.domain.model

import kotlinx.datetime.LocalDate
import kr.io.team.loop.common.domain.GoalId
import kr.io.team.loop.common.domain.MemberId

data class GoalQuery(
val memberId: MemberId? = null,
val id: GoalId? = null,
val ids: List<GoalId>? = null,
val title: String? = null,
val assignedDate: LocalDate? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.io.team.loop.goal.domain.repository

import kotlinx.datetime.LocalDate
import kr.io.team.loop.common.domain.GoalId
import kr.io.team.loop.common.domain.MemberId
import kr.io.team.loop.goal.domain.model.DailyGoal
import kr.io.team.loop.goal.domain.model.DailyGoalCommand

interface DailyGoalRepository {
fun save(command: DailyGoalCommand.Add): DailyGoal

fun delete(command: DailyGoalCommand.Remove)

fun existsByGoalIdAndMemberIdAndDate(
goalId: GoalId,
memberId: MemberId,
date: LocalDate,
): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.io.team.loop.goal.infrastructure.persistence

import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.datetime.date
import org.jetbrains.exposed.v1.datetime.timestampWithTimeZone

object DailyGoalTable : Table("daily_goal") {
val dailyGoalId = long("daily_goal_id").autoIncrement()
val goalId = long("goal_id")
val memberId = long("member_id").index()
val date = date("date")
val createdAt = timestampWithTimeZone("created_at")

override val primaryKey = PrimaryKey(dailyGoalId)

init {
uniqueIndex(goalId, memberId, date)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package kr.io.team.loop.goal.infrastructure.persistence

import kotlinx.datetime.LocalDate
import kr.io.team.loop.common.domain.GoalId
import kr.io.team.loop.common.domain.MemberId
import kr.io.team.loop.goal.domain.model.DailyGoal
import kr.io.team.loop.goal.domain.model.DailyGoalCommand
import kr.io.team.loop.goal.domain.model.DailyGoalId
import kr.io.team.loop.goal.domain.repository.DailyGoalRepository
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime

@Repository
class ExposedDailyGoalRepository : DailyGoalRepository {
override fun save(command: DailyGoalCommand.Add): DailyGoal {
val now = OffsetDateTime.now()
val row =
DailyGoalTable.insert {
it[goalId] = command.goalId.value
it[memberId] = command.memberId.value
it[date] = command.date
it[createdAt] = now
}
return DailyGoal(
id = DailyGoalId(row[DailyGoalTable.dailyGoalId]),
goalId = command.goalId,
memberId = command.memberId,
date = command.date,
createdAt = now.toInstant(),
)
}

override fun delete(command: DailyGoalCommand.Remove) {
DailyGoalTable.deleteWhere {
(goalId eq command.goalId.value) and
(memberId eq command.memberId.value) and
(date eq command.date)
}
}

override fun existsByGoalIdAndMemberIdAndDate(
goalId: GoalId,
memberId: MemberId,
date: LocalDate,
): Boolean =
DailyGoalTable
.selectAll()
.where {
(DailyGoalTable.goalId eq goalId.value) and
(DailyGoalTable.memberId eq memberId.value) and
(DailyGoalTable.date eq date)
}.count() > 0
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import kr.io.team.loop.goal.domain.model.GoalCommand
import kr.io.team.loop.goal.domain.model.GoalQuery
import kr.io.team.loop.goal.domain.model.GoalTitle
import kr.io.team.loop.goal.domain.repository.GoalRepository
import org.jetbrains.exposed.v1.core.JoinType
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
Expand Down Expand Up @@ -50,10 +55,29 @@ class ExposedGoalRepository : GoalRepository {
}

override fun findAll(query: GoalQuery): List<Goal> {
var statement = GoalTable.selectAll()
query.memberId?.let { statement = statement.where { GoalTable.memberId eq it.value } }
return statement
.orderBy(GoalTable.createdAt, SortOrder.DESC)
val needsJoin = query.assignedDate != null
val base =
if (needsJoin) {
GoalTable.join(DailyGoalTable, JoinType.INNER, GoalTable.goalId, DailyGoalTable.goalId)
} else {
GoalTable
}
var condition: Op<Boolean> = Op.TRUE
query.memberId?.let { condition = condition and (GoalTable.memberId eq it.value) }
// 목표 선택 조건: id > ids > title 우선순위
when {
query.id != null -> condition = condition and (GoalTable.goalId eq query.id.value)
query.ids != null -> condition = condition and (GoalTable.goalId inList query.ids.map { it.value })
query.title != null -> condition = condition and (GoalTable.title like "%${query.title}%")
}
// AND 조건
query.assignedDate?.let { condition = condition and (DailyGoalTable.date eq it) }
val orderColumn = if (needsJoin) DailyGoalTable.createdAt else GoalTable.createdAt
val orderDirection = if (needsJoin) SortOrder.ASC else SortOrder.DESC
return base
.selectAll()
.where(condition)
.orderBy(orderColumn, orderDirection)
.map { it.toGoal() }
}

Expand Down
Loading
Loading