diff --git a/adapters/in-web/src/main/kotlin/com/pokit/alert/AlertController.kt b/adapters/in-web/src/main/kotlin/com/pokit/alert/AlertController.kt new file mode 100644 index 00000000..b0e68697 --- /dev/null +++ b/adapters/in-web/src/main/kotlin/com/pokit/alert/AlertController.kt @@ -0,0 +1,45 @@ +package com.pokit.alert + +import com.pokit.alert.dto.response.AlertsResponse +import com.pokit.alert.port.`in`.AlertUseCase +import com.pokit.auth.model.PrincipalUser +import com.pokit.common.dto.SliceResponseDto +import com.pokit.common.wrapper.ResponseWrapper.wrapOk +import com.pokit.common.wrapper.ResponseWrapper.wrapSlice +import com.pokit.common.wrapper.ResponseWrapper.wrapUnit +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.data.web.PageableDefault +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/alert") +class AlertController( + private val alertUseCase: AlertUseCase +) { + @GetMapping + fun getAlerts( + @AuthenticationPrincipal user: PrincipalUser, + @PageableDefault( + page = 0, + size = 10, + sort = ["createdAt"], + direction = Sort.Direction.DESC + ) pageable: Pageable + ): ResponseEntity> { + return alertUseCase.getAlerts(user.id, pageable) + .wrapSlice() + .wrapOk() + } + + @PutMapping("/{alertId}") + fun deleteAlert( + @AuthenticationPrincipal user: PrincipalUser, + @PathVariable("alertId") alertId: Long + ): ResponseEntity { + return alertUseCase.delete(user.id, alertId) + .wrapUnit() + } +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertAdapter.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertAdapter.kt new file mode 100644 index 00000000..10108620 --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertAdapter.kt @@ -0,0 +1,31 @@ +package com.pokit.out.persistence.alert.impl + +import com.pokit.alert.model.Alert +import com.pokit.alert.port.out.AlertPort +import com.pokit.out.persistence.alert.persist.AlertRepository +import com.pokit.out.persistence.alert.persist.toDomain +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Repository + +@Repository +class AlertAdapter( + private val alertRepository: AlertRepository +) : AlertPort { + override fun loadAllByUserId(userId: Long, pageable: Pageable): Slice { + return alertRepository.findAllByUserIdAndDeleted(userId, false, pageable) + .map { it.toDomain() } + } + + override fun loadByIdAndUserId(id: Long, userId: Long): Alert? { + return alertRepository.findByIdAndUserId(id, userId) + ?.toDomain() + } + + override fun delete(alert: Alert) { + alertRepository.findByIdOrNull(alert.id) + ?.delete() + } + +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertEntity.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertEntity.kt index 62a9f63f..59a40f3c 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertEntity.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertEntity.kt @@ -1,7 +1,6 @@ package com.pokit.out.persistence.alert.persist import com.pokit.alert.model.Alert -import com.pokit.content.model.ContentInfo import com.pokit.out.persistence.BaseEntity import jakarta.persistence.* @@ -25,20 +24,20 @@ class AlertEntity( @Column(name = "title") val title: String, - @Column(name = "body") - val body: String, - @Column(name = "is_deleted") - val deleted: Boolean = false - + var deleted: Boolean = false ) : BaseEntity() { + + fun delete() { + this.deleted = true + } + companion object { fun of(alert: Alert) = AlertEntity( userId = alert.userId, - contentId = alert.content.contentId, - contentThumbNail = alert.content.contentThumbNail, + contentId = alert.contentId, + contentThumbNail = alert.contentThumbNail, title = alert.title, - body = alert.body, ) } } @@ -46,7 +45,8 @@ class AlertEntity( fun AlertEntity.toDomain() = Alert( id = this.id, userId = this.userId, - content = ContentInfo(this.contentId, this.contentThumbNail), + contentId = this.contentId, + contentThumbNail = this.contentThumbNail, title = this.title, - body = this.body, + createdAt = this.createdAt ) diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertRepository.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertRepository.kt new file mode 100644 index 00000000..236533fc --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertRepository.kt @@ -0,0 +1,11 @@ +package com.pokit.out.persistence.alert.persist + +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import org.springframework.data.jpa.repository.JpaRepository + +interface AlertRepository : JpaRepository { + fun findAllByUserIdAndDeleted(userId: Long, deleted: Boolean, pageable: Pageable): Slice + + fun findByIdAndUserId(id: Long, userId: Long): AlertEntity? +} diff --git a/adapters/out-persistence/src/testFixtures/kotlin/com/pokit/alert/AlertFixure.kt b/adapters/out-persistence/src/testFixtures/kotlin/com/pokit/alert/AlertFixure.kt new file mode 100644 index 00000000..170fed75 --- /dev/null +++ b/adapters/out-persistence/src/testFixtures/kotlin/com/pokit/alert/AlertFixure.kt @@ -0,0 +1,16 @@ +package com.pokit.alert + +import com.pokit.alert.model.Alert +import java.time.LocalDateTime + +class AlertFixure { + companion object { + fun getAlert(userId: Long, title: String, createdAt: LocalDateTime) = Alert( + userId = userId, + contentId = 1L, + contentThumbNail = "www.imageThumbnail.com", + title = title, + createdAt = createdAt + ) + } +} diff --git a/adapters/out-web/src/main/kotlin/com/pokit/alert/common/config/FirebaseConfig.kt b/adapters/out-web/src/main/kotlin/com/pokit/alert/common/config/FirebaseConfig.kt deleted file mode 100644 index dcda7933..00000000 --- a/adapters/out-web/src/main/kotlin/com/pokit/alert/common/config/FirebaseConfig.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.pokit.alert.common.config - -import com.google.auth.oauth2.GoogleCredentials -import com.google.firebase.FirebaseApp -import com.google.firebase.FirebaseOptions -import com.google.firebase.auth.FirebaseAuth -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.core.io.ClassPathResource - -@Configuration -class FirebaseConfig { - @Bean - fun firebaseApp(): FirebaseApp { - val resource = ClassPathResource("google-services.json") - val serviceAccount = resource.inputStream - - val options = FirebaseOptions.builder() - .setCredentials(GoogleCredentials.fromStream(serviceAccount)) - .build() - - return FirebaseApp.initializeApp(options) - } - - @Bean - fun firebaseAuth() = FirebaseAuth.getInstance(firebaseApp()) -} diff --git a/adapters/out-web/src/main/kotlin/com/pokit/alert/impl/FcmSender.kt b/adapters/out-web/src/main/kotlin/com/pokit/alert/impl/FcmSender.kt deleted file mode 100644 index fe522a05..00000000 --- a/adapters/out-web/src/main/kotlin/com/pokit/alert/impl/FcmSender.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.pokit.alert.impl - -import com.google.firebase.messaging.FirebaseMessaging -import com.google.firebase.messaging.FirebaseMessagingException -import com.google.firebase.messaging.Message -import com.google.firebase.messaging.Notification -import com.pokit.alert.model.Alert -import com.pokit.alert.port.out.AlertSender -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.stereotype.Component - -@Component -class FcmSender : AlertSender { - private val logger = KotlinLogging.logger { } - - companion object { - const val IMAGE_PATH = "https://pokit-storage.s3.ap-northeast-2.amazonaws.com/logo/pokit.png" // 앱 로고 - } - - override fun sendMessage(tokens: List, alert: Alert) { - val notification = Notification.builder() - .setTitle(alert.title) - .setBody(alert.body) - .setImage(IMAGE_PATH) - .build() - - val messages = tokens.map { - Message.builder() - .setNotification(notification) - .setToken(it) - .build() - } - - try { - messages.forEach { - FirebaseMessaging.getInstance().sendAsync(it) - } - } catch (e: FirebaseMessagingException) { - logger.warn { "Failed To Send Message : ${e.message}" } - } - } -} diff --git a/application/src/main/kotlin/com/pokit/alert/port/in/AlertUseCase.kt b/application/src/main/kotlin/com/pokit/alert/port/in/AlertUseCase.kt new file mode 100644 index 00000000..f6aecf68 --- /dev/null +++ b/application/src/main/kotlin/com/pokit/alert/port/in/AlertUseCase.kt @@ -0,0 +1,11 @@ +package com.pokit.alert.port.`in` + +import com.pokit.alert.dto.response.AlertsResponse +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice + +interface AlertUseCase { + fun getAlerts(userId: Long, pageable: Pageable): Slice + + fun delete(userId: Long, alertId: Long) +} diff --git a/application/src/main/kotlin/com/pokit/alert/port/out/AlertPort.kt b/application/src/main/kotlin/com/pokit/alert/port/out/AlertPort.kt new file mode 100644 index 00000000..6120444f --- /dev/null +++ b/application/src/main/kotlin/com/pokit/alert/port/out/AlertPort.kt @@ -0,0 +1,13 @@ +package com.pokit.alert.port.out + +import com.pokit.alert.model.Alert +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice + +interface AlertPort { + fun loadAllByUserId(userId: Long, pageable: Pageable): Slice + + fun loadByIdAndUserId(id: Long, userId: Long): Alert? + + fun delete(alert: Alert) +} diff --git a/application/src/main/kotlin/com/pokit/alert/port/out/AlertSender.kt b/application/src/main/kotlin/com/pokit/alert/port/out/AlertSender.kt deleted file mode 100644 index 689593c4..00000000 --- a/application/src/main/kotlin/com/pokit/alert/port/out/AlertSender.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.pokit.alert.port.out - -import com.pokit.alert.model.Alert - -interface AlertSender { - fun sendMessage(tokens: List, alert: Alert) -} diff --git a/application/src/main/kotlin/com/pokit/alert/port/service/AlertService.kt b/application/src/main/kotlin/com/pokit/alert/port/service/AlertService.kt new file mode 100644 index 00000000..e96732cc --- /dev/null +++ b/application/src/main/kotlin/com/pokit/alert/port/service/AlertService.kt @@ -0,0 +1,40 @@ +package com.pokit.alert.port.service + +import com.pokit.alert.dto.response.AlertsResponse +import com.pokit.alert.dto.response.toAlertsResponse +import com.pokit.alert.exception.AlertErrorCode +import com.pokit.alert.port.`in`.AlertUseCase +import com.pokit.alert.port.out.AlertPort +import com.pokit.common.exception.NotFoundCustomException +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import java.util.function.Supplier +import kotlin.math.abs + +@Service +@Transactional(readOnly = true) +class AlertService( + private val now: Supplier, + private val alertPort: AlertPort +) : AlertUseCase { + override fun getAlerts(userId: Long, pageable: Pageable): Slice { + val nowDay = now.get().toLocalDate() + + return alertPort.loadAllByUserId(userId, pageable) + .map { + val dayDifference = ChronoUnit.DAYS.between(nowDay, it.createdAt.toLocalDate()) + it.toAlertsResponse(abs(dayDifference.toInt())) + } + } + + @Transactional + override fun delete(userId: Long, alertId: Long) { + val alert = alertPort.loadByIdAndUserId(alertId, userId) + ?: throw NotFoundCustomException(AlertErrorCode.NOT_FOUND_ALERT) + alertPort.delete(alert) + } +} diff --git a/application/src/main/kotlin/com/pokit/config/TimeConfig.kt b/application/src/main/kotlin/com/pokit/config/TimeConfig.kt new file mode 100644 index 00000000..9cb74ad6 --- /dev/null +++ b/application/src/main/kotlin/com/pokit/config/TimeConfig.kt @@ -0,0 +1,15 @@ +package com.pokit.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.LocalDateTime +import java.util.function.Supplier + +@Configuration +class TimeConfig { + + @Bean("nowTimeSupplier") + fun nowTimeSupplier(): Supplier { + return Supplier { LocalDateTime.now() } + } +} diff --git a/application/src/test/kotlin/com/pokit/alert/port/service/AlertServiceTest.kt b/application/src/test/kotlin/com/pokit/alert/port/service/AlertServiceTest.kt new file mode 100644 index 00000000..38290e92 --- /dev/null +++ b/application/src/test/kotlin/com/pokit/alert/port/service/AlertServiceTest.kt @@ -0,0 +1,42 @@ +package com.pokit.alert.port.service + +import com.pokit.alert.AlertFixure +import com.pokit.alert.port.out.AlertPort +import com.pokit.user.UserFixture +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.SliceImpl +import java.time.LocalDateTime +import java.util.function.Supplier + +class AlertServiceTest : BehaviorSpec({ + val alertPort = mockk() + val now = mockk>() + + val alertService = AlertService(now, alertPort) + + Given("알림 관련 요청이 들어올 때") { + val nowTime = LocalDateTime.of(2024, 8, 15, 15, 30) + val user = UserFixture.getUser() + val pageable = PageRequest.of(0, 10) + val alert1 = AlertFixure.getAlert(user.id, "title1", nowTime.minusDays(1)) + val alert2 = AlertFixure.getAlert(user.id, "title2", nowTime) + val alerts = SliceImpl(mutableListOf(alert1, alert2), pageable, false) + every { now.get() } returns nowTime + every { alertPort.loadAllByUserId(user.id, pageable) } returns alerts + + When("조회 시") { + val result = alertService.getAlerts(user.id, pageable) + val alertList = result.content + Then("해당 유저의 알림 목록이 조회된다.") { + alertList[0].title shouldBe alert1.title + alertList[1].title shouldBe alert2.title + alertList[0].createdAt shouldBe "1일 전" + alertList[1].createdAt shouldBe "오늘" + } + } + } +}) diff --git a/domain/src/main/kotlin/com/pokit/alert/dto/response/AlertsResponse.kt b/domain/src/main/kotlin/com/pokit/alert/dto/response/AlertsResponse.kt new file mode 100644 index 00000000..27f5c65b --- /dev/null +++ b/domain/src/main/kotlin/com/pokit/alert/dto/response/AlertsResponse.kt @@ -0,0 +1,23 @@ +package com.pokit.alert.dto.response + +import com.pokit.alert.model.Alert +import com.pokit.alert.model.AlertDefault + +data class AlertsResponse( + val id: Long, + val userId: Long, + val contentId: Long, + val thumbNail: String, + val title: String, + val body: String = AlertDefault.body, + val createdAt: String +) + +fun Alert.toAlertsResponse(dayDifference: Int) = AlertsResponse( + id = this.id, + userId = this.userId, + contentId = this.id, + thumbNail = this.contentThumbNail, + title = this.title, + createdAt = if (dayDifference == 0) "오늘" else "${dayDifference}일 전" +) diff --git a/domain/src/main/kotlin/com/pokit/alert/exception/AlertErrorCode.kt b/domain/src/main/kotlin/com/pokit/alert/exception/AlertErrorCode.kt new file mode 100644 index 00000000..56b65d01 --- /dev/null +++ b/domain/src/main/kotlin/com/pokit/alert/exception/AlertErrorCode.kt @@ -0,0 +1,10 @@ +package com.pokit.alert.exception + +import com.pokit.common.exception.ErrorCode + +enum class AlertErrorCode( + override val message: String, + override val code: String +) : ErrorCode { + NOT_FOUND_ALERT("존재하지 않는 알림입니다.", "AL_001") +} diff --git a/domain/src/main/kotlin/com/pokit/alert/model/Alert.kt b/domain/src/main/kotlin/com/pokit/alert/model/Alert.kt index 95c984da..4e31737b 100644 --- a/domain/src/main/kotlin/com/pokit/alert/model/Alert.kt +++ b/domain/src/main/kotlin/com/pokit/alert/model/Alert.kt @@ -1,11 +1,16 @@ package com.pokit.alert.model -import com.pokit.content.model.ContentInfo +import java.time.LocalDateTime data class Alert( - val id: Long, + val id: Long = 0L, val userId: Long, - val content: ContentInfo, + val contentId: Long, + val contentThumbNail: String, val title: String, - val body: String + val createdAt: LocalDateTime = LocalDateTime.now() ) + +object AlertDefault { + const val body = "저장하신 링크가 기다리고 있어요. 잊지 말고 링크를 확인하세요!" +}