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
12 changes: 12 additions & 0 deletions docs/plan/#16-review/checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Review(회고) BC 검증 체크리스트

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

## 선택 항목 (해당 시)
- [x] Flyway 마이그레이션 작성
- [ ] API 엔드포인트 동작 확인
14 changes: 14 additions & 0 deletions docs/plan/#16-review/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Review(회고) BC 구현 계획

> Issue: #16

## 단계

- [x] Step 0: 준비 — 마이그레이션, GraphQL 스키마, Codegen 실행
- [x] Step 1: Domain Layer (TDD) — ReviewId, PeriodKey, Review, ReviewType, StepType, ReviewStep, ReviewCommand, ReviewQuery, ReviewRepository
- [x] Step 2: Application Layer (TDD) — ReviewStatsDto, ReviewService (create, findAll, getStats)
- [x] Step 3: Infrastructure Layer — ReviewTable (JSONB), ExposedReviewRepository
- [x] Step 4: Presentation Layer — ReviewDataFetcher
- [x] Step 5: 검증 — 전체 테스트 통과, 빌드 성공
- [x] Step 6: Flyway 마이그레이션 JSONB 복원 (TIMESTAMPTZ는 H2 미지원, 표준 SQL 유지)
- [x] Step 7: ReviewService.findAll 리팩토링 — 함수형 스타일로 정리
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.io.team.loop.review.application.dto

data class ReviewStatsDto(
val totalCount: Long,
val consecutiveDays: Int,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package kr.io.team.loop.review.application.service

import kotlinx.datetime.LocalDate
import kotlinx.datetime.minus
import kr.io.team.loop.common.domain.MemberId
import kr.io.team.loop.common.domain.exception.DuplicateEntityException
import kr.io.team.loop.review.application.dto.ReviewStatsDto
import kr.io.team.loop.review.domain.model.Review
import kr.io.team.loop.review.domain.model.ReviewCommand
import kr.io.team.loop.review.domain.model.ReviewQuery
import kr.io.team.loop.review.domain.repository.ReviewRepository
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class ReviewService(
private val reviewRepository: ReviewRepository,
) {
@Transactional
fun create(command: ReviewCommand.Create): Review =
try {
reviewRepository.save(command)
} catch (e: DataIntegrityViolationException) {
throw DuplicateEntityException("Review already exists for this period")
}

@Transactional(readOnly = true)
fun findAll(query: ReviewQuery): List<Review> {
val reviews = reviewRepository.findAll(query.copy(stepType = null))
val stepType = query.stepType ?: return reviews
return reviews.filter { it.containsStepType(stepType) }
}

@Transactional(readOnly = true)
fun getStats(
memberId: MemberId,
today: LocalDate,
): ReviewStatsDto {
val totalCount = reviewRepository.countByMemberId(memberId)
val reviews = reviewRepository.findAll(ReviewQuery(memberId = memberId))
val consecutiveDays = calculateConsecutiveDays(reviews, today)
return ReviewStatsDto(
totalCount = totalCount,
consecutiveDays = consecutiveDays,
)
}

private fun calculateConsecutiveDays(
reviews: List<Review>,
today: LocalDate,
): Int {
val reviewDates = reviews.map { it.startDate }.toSet()
var count = 0
var current = today
while (current in reviewDates) {
count++
current = current.minus(1, kotlinx.datetime.DateTimeUnit.DAY)
}
return count
}
}
17 changes: 17 additions & 0 deletions src/main/kotlin/kr/io/team/loop/review/domain/model/PeriodKey.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package kr.io.team.loop.review.domain.model

import kotlinx.datetime.LocalDate
import kr.io.team.loop.common.domain.exception.InvalidInputException

@JvmInline
value class PeriodKey(
val value: String,
) {
init {
if (value.isBlank()) throw InvalidInputException("PeriodKey must not be blank")
}

companion object {
fun daily(date: LocalDate): PeriodKey = PeriodKey("DAILY:$date")
}
}
21 changes: 21 additions & 0 deletions src/main/kotlin/kr/io/team/loop/review/domain/model/Review.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package kr.io.team.loop.review.domain.model

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

data class Review(
val id: ReviewId,
val reviewType: ReviewType,
val memberId: MemberId,
val steps: List<ReviewStep>,
val startDate: LocalDate,
val endDate: LocalDate?,
val periodKey: PeriodKey,
val createdAt: Instant,
val updatedAt: Instant?,
) {
fun isOwnedBy(memberId: MemberId): Boolean = this.memberId == memberId

fun containsStepType(stepType: StepType): Boolean = steps.any { it.type == stepType }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.io.team.loop.review.domain.model

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

sealed interface ReviewCommand {
data class Create(
val memberId: MemberId,
val steps: List<ReviewStep>,
val date: LocalDate,
) : ReviewCommand
}
12 changes: 12 additions & 0 deletions src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewId.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.io.team.loop.review.domain.model

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

@JvmInline
value class ReviewId(
val value: Long,
) {
init {
if (value <= 0) throw InvalidInputException("ReviewId must be positive")
}
}
13 changes: 13 additions & 0 deletions src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewQuery.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package kr.io.team.loop.review.domain.model

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

data class ReviewQuery(
val memberId: MemberId? = null,
val reviewType: ReviewType? = null,
val stepType: StepType? = null,
val date: LocalDate? = null,
val startDate: LocalDate? = null,
val endDate: LocalDate? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.io.team.loop.review.domain.model

data class ReviewStep(
val type: StepType,
val content: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package kr.io.team.loop.review.domain.model

enum class ReviewType {
DAILY,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package kr.io.team.loop.review.domain.model

enum class StepType {
KEEP,
PROBLEM,
TRY,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package kr.io.team.loop.review.domain.repository

import kr.io.team.loop.common.domain.MemberId
import kr.io.team.loop.review.domain.model.Review
import kr.io.team.loop.review.domain.model.ReviewCommand
import kr.io.team.loop.review.domain.model.ReviewQuery

interface ReviewRepository {
fun save(command: ReviewCommand.Create): Review

fun findAll(query: ReviewQuery): List<Review>

fun findById(id: kr.io.team.loop.review.domain.model.ReviewId): Review?

fun countByMemberId(memberId: MemberId): Long
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package kr.io.team.loop.review.infrastructure.persistence

import kr.io.team.loop.common.domain.MemberId
import kr.io.team.loop.review.domain.model.PeriodKey
import kr.io.team.loop.review.domain.model.Review
import kr.io.team.loop.review.domain.model.ReviewCommand
import kr.io.team.loop.review.domain.model.ReviewId
import kr.io.team.loop.review.domain.model.ReviewQuery
import kr.io.team.loop.review.domain.model.ReviewStep
import kr.io.team.loop.review.domain.model.ReviewType
import kr.io.team.loop.review.domain.model.StepType
import kr.io.team.loop.review.domain.repository.ReviewRepository
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.count
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greaterEq
import org.jetbrains.exposed.v1.core.lessEq
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 ExposedReviewRepository : ReviewRepository {
override fun save(command: ReviewCommand.Create): Review {
val now = OffsetDateTime.now()
val periodKey = PeriodKey.daily(command.date)
val stepsJson = command.steps.map { StepJson(type = it.type.name, content = it.content) }

val row =
ReviewTable.insert {
it[reviewType] = ReviewType.DAILY.name
it[memberId] = command.memberId.value
it[steps] = stepsJson
it[startDate] = command.date
it[this.periodKey] = periodKey.value
it[createdAt] = now
}

return Review(
id = ReviewId(row[ReviewTable.reviewId]),
reviewType = ReviewType.DAILY,
memberId = command.memberId,
steps = command.steps,
startDate = command.date,
endDate = null,
periodKey = periodKey,
createdAt = now.toInstant(),
updatedAt = null,
)
}

override fun findAll(query: ReviewQuery): List<Review> {
var condition: Op<Boolean> = Op.TRUE
query.memberId?.let { condition = condition and (ReviewTable.memberId eq it.value) }
query.reviewType?.let { condition = condition and (ReviewTable.reviewType eq it.name) }
query.date?.let {
condition = condition and (ReviewTable.startDate eq it)
}
query.startDate?.let { condition = condition and (ReviewTable.startDate greaterEq it) }
query.endDate?.let { condition = condition and (ReviewTable.startDate lessEq it) }

return ReviewTable
.selectAll()
.where(condition)
.orderBy(ReviewTable.startDate, SortOrder.DESC)
.map { it.toReview() }
}

override fun findById(id: ReviewId): Review? =
ReviewTable
.selectAll()
.where { ReviewTable.reviewId eq id.value }
.singleOrNull()
?.toReview()

override fun countByMemberId(memberId: MemberId): Long =
ReviewTable
.selectAll()
.where { ReviewTable.memberId eq memberId.value }
.count()

private fun ResultRow.toReview(): Review =
Review(
id = ReviewId(this[ReviewTable.reviewId]),
reviewType = ReviewType.valueOf(this[ReviewTable.reviewType]),
memberId = MemberId(this[ReviewTable.memberId]),
steps =
this[ReviewTable.steps].map { stepJson ->
ReviewStep(
type = StepType.valueOf(stepJson.type),
content = stepJson.content,
)
},
startDate = this[ReviewTable.startDate],
endDate = this[ReviewTable.endDate],
periodKey = PeriodKey(this[ReviewTable.periodKey]),
createdAt = this[ReviewTable.createdAt].toInstant(),
updatedAt = this[ReviewTable.updatedAt]?.toInstant(),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package kr.io.team.loop.review.infrastructure.persistence

import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.datetime.date
import org.jetbrains.exposed.v1.datetime.timestampWithTimeZone
import org.jetbrains.exposed.v1.json.jsonb
import tools.jackson.module.kotlin.jacksonObjectMapper
import tools.jackson.module.kotlin.readValue

private val objectMapper = jacksonObjectMapper()

object ReviewTable : Table("review") {
val reviewId = long("review_id").autoIncrement()
val reviewType = text("review_type")
val memberId = long("member_id").index()
val steps =
jsonb<List<StepJson>>(
"steps",
serialize = { objectMapper.writeValueAsString(it) },
deserialize = { objectMapper.readValue(it) },
)
val startDate = date("start_date")
val endDate = date("end_date").nullable()
val periodKey = text("period_key")
val createdAt = timestampWithTimeZone("created_at")
val updatedAt = timestampWithTimeZone("updated_at").nullable()

override val primaryKey = PrimaryKey(reviewId)

init {
uniqueIndex(memberId, periodKey)
}
}

data class StepJson(
val type: String,
val content: String,
)
Loading