Skip to content

Commit 7b343ba

Browse files
robinjoonclaude
andauthored
feat: 일별 목표(DailyGoal) 도메인 모델 및 API 구현 (#38) (#39)
특정 날짜에 목표를 배치하는 DailyGoal 기능을 goal BC 내부에 구현. 내부 도메인 모델(DailyGoal 엔티티)은 유지하되, GraphQL API는 Goal 중심으로 단순화하여 myGoals 필터(GoalFilter)로 조회하는 방식 채택. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3c26e3e commit 7b343ba

17 files changed

Lines changed: 580 additions & 8 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# 일별 목표(DailyGoal) 검증 체크리스트
2+
3+
## 필수 항목
4+
- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준)
5+
- [x] 레이어 의존성 규칙 위반 없음
6+
- [x] Domain, Application 테스트 코드 작성 완료 (TDD)
7+
- [x] 모든 테스트 통과
8+
- [x] 기존 테스트 깨지지 않음
9+
- [x] DGS Codegen으로 GraphQL 타입 자동 생성 (수동 작성 금지)
10+
11+
## 선택 항목
12+
- [x] Flyway 마이그레이션 작성 (V5__Create_daily_goal_table.sql)
13+
- [x] Goal + Date + Member 유니크 제약 적용

docs/plan/#38-daily-goal/plan.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# 일별 목표(DailyGoal) 구현 계획
2+
3+
> Issue: #38
4+
5+
## 단계
6+
7+
- [x] 1단계: Domain — DailyGoal 엔티티, VO, Command, Query, Repository 인터페이스 (TDD)
8+
- [x] 2단계: Application — GoalService에 DailyGoal 메서드 추가 (TDD)
9+
- [x] 3단계: Infrastructure — DailyGoalTable, ExposedDailyGoalRepository, Flyway 마이그레이션
10+
- [x] 4단계: Presentation — GraphQL 스키마 확장, GoalDataFetcher 업데이트
11+
- [x] 5단계: 검증 — 전체 테스트 통과, 아키텍처 준수 확인
12+
13+
## 리팩토링: DailyGoal API를 Goal 중심으로 단순화
14+
15+
GraphQL의 선택적 필드 조회 특성을 활용하여, 별도 DailyGoal 타입을 API에서 제거하고 Goal에 통합.
16+
내부 도메인 모델(DailyGoal 엔티티)은 유지하되, 스키마 표면은 Goal 중심으로 변경.
17+
18+
- [x] 6단계: GraphQL 스키마 — DailyGoal 타입 제거, myGoals에 날짜 필터 추가, mutation 시그니처 변경
19+
- [x] 7단계: Domain — DailyGoalCommand.Remove를 goalId+memberId+date 기반으로 변경, GoalQuery에 assignedDate 추가
20+
- [x] 8단계: Application/Infrastructure — 변경된 Command/Query 반영
21+
- [x] 9단계: Presentation — GoalDataFetcher 리팩토링 (DailyGoal 리졸버 제거, myGoals 필터 적용)
22+
- [x] 10단계: 검증 — 전체 테스트 통과 확인
23+
24+
## GoalFilter 확장: AND 조건 필터 추가
25+
26+
GoalFilter에 ids 등 다양한 조건을 추가하고, 모든 조건은 AND로 결합.
27+
28+
- [x] 11단계: GoalFilter 확장 — 스키마, GoalQuery, Repository, DataFetcher 일괄 변경
29+
- [x] 12단계: 검증 — 전체 테스트 통과 확인
30+
31+
## GoalFilter 추가 필드: id, title, 우선순위 규칙
32+
33+
id(단건) > ids(복수) > title 순 우선. assignedDate는 항상 AND. 이후 추가 조건도 AND.
34+
35+
- [x] 13단계: GoalFilter에 id, title 추가 및 우선순위 로직 구현
36+
- [x] 14단계: 검증

src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,35 @@
11
package kr.io.team.loop.goal.application.service
22

3+
import kr.io.team.loop.common.domain.GoalId
34
import kr.io.team.loop.common.domain.MemberId
45
import kr.io.team.loop.common.domain.exception.AccessDeniedException
6+
import kr.io.team.loop.common.domain.exception.DuplicateEntityException
57
import kr.io.team.loop.common.domain.exception.EntityNotFoundException
8+
import kr.io.team.loop.goal.domain.model.DailyGoalCommand
69
import kr.io.team.loop.goal.domain.model.Goal
710
import kr.io.team.loop.goal.domain.model.GoalCommand
811
import kr.io.team.loop.goal.domain.model.GoalQuery
12+
import kr.io.team.loop.goal.domain.repository.DailyGoalRepository
913
import kr.io.team.loop.goal.domain.repository.GoalRepository
1014
import org.springframework.stereotype.Service
1115
import org.springframework.transaction.annotation.Transactional
1216

1317
@Service
1418
class GoalService(
1519
private val goalRepository: GoalRepository,
20+
private val dailyGoalRepository: DailyGoalRepository,
1621
) {
1722
@Transactional
1823
fun create(command: GoalCommand.Create): Goal = goalRepository.save(command)
1924

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

28+
@Transactional(readOnly = true)
29+
fun findById(id: GoalId): Goal =
30+
goalRepository.findById(id)
31+
?: throw EntityNotFoundException("Goal not found: ${id.value}")
32+
2333
@Transactional
2434
fun update(
2535
command: GoalCommand.Update,
@@ -47,4 +57,31 @@ class GoalService(
4757
}
4858
goalRepository.delete(command)
4959
}
60+
61+
@Transactional
62+
fun addDailyGoal(command: DailyGoalCommand.Add): Goal {
63+
val goal =
64+
goalRepository.findById(command.goalId)
65+
?: throw EntityNotFoundException("Goal not found: ${command.goalId.value}")
66+
if (!goal.isOwnedBy(command.memberId)) {
67+
throw AccessDeniedException("Goal does not belong to member: ${command.memberId.value}")
68+
}
69+
if (dailyGoalRepository.existsByGoalIdAndMemberIdAndDate(command.goalId, command.memberId, command.date)) {
70+
throw DuplicateEntityException(
71+
"DailyGoal already exists for goal ${command.goalId.value} on ${command.date}",
72+
)
73+
}
74+
dailyGoalRepository.save(command)
75+
return goal
76+
}
77+
78+
@Transactional
79+
fun removeDailyGoal(command: DailyGoalCommand.Remove) {
80+
if (!dailyGoalRepository.existsByGoalIdAndMemberIdAndDate(command.goalId, command.memberId, command.date)) {
81+
throw EntityNotFoundException(
82+
"DailyGoal not found for goal ${command.goalId.value} on ${command.date}",
83+
)
84+
}
85+
dailyGoalRepository.delete(command)
86+
}
5087
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package kr.io.team.loop.goal.domain.model
2+
3+
import kotlinx.datetime.LocalDate
4+
import kr.io.team.loop.common.domain.GoalId
5+
import kr.io.team.loop.common.domain.MemberId
6+
import java.time.Instant
7+
8+
data class DailyGoal(
9+
val id: DailyGoalId,
10+
val goalId: GoalId,
11+
val memberId: MemberId,
12+
val date: LocalDate,
13+
val createdAt: Instant,
14+
) {
15+
fun isOwnedBy(memberId: MemberId): Boolean = this.memberId == memberId
16+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package kr.io.team.loop.goal.domain.model
2+
3+
import kotlinx.datetime.LocalDate
4+
import kr.io.team.loop.common.domain.GoalId
5+
import kr.io.team.loop.common.domain.MemberId
6+
7+
sealed interface DailyGoalCommand {
8+
data class Add(
9+
val goalId: GoalId,
10+
val memberId: MemberId,
11+
val date: LocalDate,
12+
) : DailyGoalCommand
13+
14+
data class Remove(
15+
val goalId: GoalId,
16+
val memberId: MemberId,
17+
val date: LocalDate,
18+
) : DailyGoalCommand
19+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package kr.io.team.loop.goal.domain.model
2+
3+
import kr.io.team.loop.common.domain.exception.InvalidInputException
4+
5+
@JvmInline
6+
value class DailyGoalId(
7+
val value: Long,
8+
) {
9+
init {
10+
if (value <= 0) throw InvalidInputException("DailyGoalId must be positive")
11+
}
12+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
package kr.io.team.loop.goal.domain.model
22

3+
import kotlinx.datetime.LocalDate
4+
import kr.io.team.loop.common.domain.GoalId
35
import kr.io.team.loop.common.domain.MemberId
46

57
data class GoalQuery(
68
val memberId: MemberId? = null,
9+
val id: GoalId? = null,
10+
val ids: List<GoalId>? = null,
11+
val title: String? = null,
12+
val assignedDate: LocalDate? = null,
713
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package kr.io.team.loop.goal.domain.repository
2+
3+
import kotlinx.datetime.LocalDate
4+
import kr.io.team.loop.common.domain.GoalId
5+
import kr.io.team.loop.common.domain.MemberId
6+
import kr.io.team.loop.goal.domain.model.DailyGoal
7+
import kr.io.team.loop.goal.domain.model.DailyGoalCommand
8+
9+
interface DailyGoalRepository {
10+
fun save(command: DailyGoalCommand.Add): DailyGoal
11+
12+
fun delete(command: DailyGoalCommand.Remove)
13+
14+
fun existsByGoalIdAndMemberIdAndDate(
15+
goalId: GoalId,
16+
memberId: MemberId,
17+
date: LocalDate,
18+
): Boolean
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package kr.io.team.loop.goal.infrastructure.persistence
2+
3+
import org.jetbrains.exposed.v1.core.Table
4+
import org.jetbrains.exposed.v1.datetime.date
5+
import org.jetbrains.exposed.v1.datetime.timestampWithTimeZone
6+
7+
object DailyGoalTable : Table("daily_goal") {
8+
val dailyGoalId = long("daily_goal_id").autoIncrement()
9+
val goalId = long("goal_id")
10+
val memberId = long("member_id").index()
11+
val date = date("date")
12+
val createdAt = timestampWithTimeZone("created_at")
13+
14+
override val primaryKey = PrimaryKey(dailyGoalId)
15+
16+
init {
17+
uniqueIndex(goalId, memberId, date)
18+
}
19+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package kr.io.team.loop.goal.infrastructure.persistence
2+
3+
import kotlinx.datetime.LocalDate
4+
import kr.io.team.loop.common.domain.GoalId
5+
import kr.io.team.loop.common.domain.MemberId
6+
import kr.io.team.loop.goal.domain.model.DailyGoal
7+
import kr.io.team.loop.goal.domain.model.DailyGoalCommand
8+
import kr.io.team.loop.goal.domain.model.DailyGoalId
9+
import kr.io.team.loop.goal.domain.repository.DailyGoalRepository
10+
import org.jetbrains.exposed.v1.core.and
11+
import org.jetbrains.exposed.v1.core.eq
12+
import org.jetbrains.exposed.v1.jdbc.deleteWhere
13+
import org.jetbrains.exposed.v1.jdbc.insert
14+
import org.jetbrains.exposed.v1.jdbc.selectAll
15+
import org.springframework.stereotype.Repository
16+
import java.time.OffsetDateTime
17+
18+
@Repository
19+
class ExposedDailyGoalRepository : DailyGoalRepository {
20+
override fun save(command: DailyGoalCommand.Add): DailyGoal {
21+
val now = OffsetDateTime.now()
22+
val row =
23+
DailyGoalTable.insert {
24+
it[goalId] = command.goalId.value
25+
it[memberId] = command.memberId.value
26+
it[date] = command.date
27+
it[createdAt] = now
28+
}
29+
return DailyGoal(
30+
id = DailyGoalId(row[DailyGoalTable.dailyGoalId]),
31+
goalId = command.goalId,
32+
memberId = command.memberId,
33+
date = command.date,
34+
createdAt = now.toInstant(),
35+
)
36+
}
37+
38+
override fun delete(command: DailyGoalCommand.Remove) {
39+
DailyGoalTable.deleteWhere {
40+
(goalId eq command.goalId.value) and
41+
(memberId eq command.memberId.value) and
42+
(date eq command.date)
43+
}
44+
}
45+
46+
override fun existsByGoalIdAndMemberIdAndDate(
47+
goalId: GoalId,
48+
memberId: MemberId,
49+
date: LocalDate,
50+
): Boolean =
51+
DailyGoalTable
52+
.selectAll()
53+
.where {
54+
(DailyGoalTable.goalId eq goalId.value) and
55+
(DailyGoalTable.memberId eq memberId.value) and
56+
(DailyGoalTable.date eq date)
57+
}.count() > 0
58+
}

0 commit comments

Comments
 (0)