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
2 changes: 1 addition & 1 deletion deploy/shared/db-backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ set -o pipefail
DATE=$(date +%Y-%m-%d)
BACKUP_DIR="/home/didit-prod/backups"
BACKUP_FILE="$BACKUP_DIR/didit-$DATE.sql.gz"
BUCKET="didit-prod-db-backup-493168377333-ap-northeast-2-an"
BUCKET="didit-prod-db-backup-499030563674-us-east-1-an"
RETENTION_DAYS=30

if [ -f "/home/didit-prod/didit/deploy/prod/.env" ]; then
Expand Down
11 changes: 11 additions & 0 deletions src/docs/asciidoc/admin/notice.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,14 @@ include::{snippets}/admin-notice-email/send/request-fields.adoc[]
=== Response

include::{snippets}/admin-notice-email/send/http-response.adoc[]

== 푸시 알림 발송

=== Request

include::{snippets}/admin-notice-push/send/http-request.adoc[]
include::{snippets}/admin-notice-push/send/request-fields.adoc[]

=== Response

include::{snippets}/admin-notice-push/send/http-response.adoc[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.didit.adapter.webapi.notification

import com.didit.adapter.webapi.admin.annotation.CurrentAdminId
import com.didit.adapter.webapi.admin.annotation.RequireSuperAdmin
import com.didit.adapter.webapi.notification.dto.AdminNoticePushSendApiRequest
import com.didit.application.notification.provided.AdminNoticePushSender
import com.didit.domain.notification.AdminNoticePushSendRequest
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import java.util.UUID

@RequestMapping("/api/v1/admin/notice-pushes")
@RestController
class AdminNoticePushApi(
private val adminNoticePushSender: AdminNoticePushSender,
) {
@RequireSuperAdmin
@ResponseStatus(HttpStatus.NO_CONTENT)
@PostMapping
fun send(
@CurrentAdminId adminId: UUID,
@RequestBody request: AdminNoticePushSendApiRequest,
) {
adminNoticePushSender.send(
AdminNoticePushSendRequest(
adminId = adminId,
targetType = request.targetType,
userIds = request.userIds,
title = request.title,
body = request.body,
link = request.link,
),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.didit.adapter.webapi.notification.dto

import com.didit.domain.notification.AdminNoticePushTargetType
import java.util.UUID

data class AdminNoticePushSendApiRequest(
val targetType: AdminNoticePushTargetType,
val userIds: List<UUID>,
val title: String,
val body: String,
val link: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,53 @@ interface UserRepository : Repository<User, UUID> {
providerId: String,
): User?

fun findAllByDeletedAtIsNullAndEmailIsNotNull(): List<User>
@Query(
"""
SELECT u FROM User u
JOIN u.consent c
WHERE u.deletedAt IS NULL
AND u.email IS NOT NULL
AND c.marketingAgreed = true
""",
)
fun findAllMarketingAgreedWithEmail(): List<User>

@Query(
"""
SELECT u FROM User u
JOIN u.consent c
WHERE u.id IN :ids
AND u.deletedAt IS NULL
AND u.email IS NOT NULL
AND c.marketingAgreed = true
""",
)
fun findAllMarketingAgreedWithEmailByIdIn(
@Param("ids") ids: List<UUID>,
): List<User>

fun findAllByIdInAndDeletedAtIsNullAndEmailIsNotNull(ids: List<UUID>): List<User>
@Query(
"""
SELECT u FROM User u
JOIN u.consent c
WHERE u.deletedAt IS NULL
AND c.marketingAgreed = true
""",
)
fun findAllMarketingAgreed(): List<User>

@Query(
"""
SELECT u FROM User u
JOIN u.consent c
WHERE u.id IN :ids
AND u.deletedAt IS NULL
AND c.marketingAgreed = true
""",
)
fun findAllMarketingAgreedByIdIn(
@Param("ids") ids: List<UUID>,
): List<User>

@Query("SELECT u FROM User u WHERE u.deletedAt < :cutoff")
fun findAllWithdrawnBefore(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ class AdminNoticeEmailService(
val users =
when (request.targetType) {
AdminNoticeEmailTargetType.ALL ->
userRepository.findAllByDeletedAtIsNullAndEmailIsNotNull()
userRepository.findAllMarketingAgreedWithEmail()

AdminNoticeEmailTargetType.SELECTED_USERS ->
userRepository.findAllByIdInAndDeletedAtIsNullAndEmailIsNotNull(request.userIds)
userRepository.findAllMarketingAgreedWithEmailByIdIn(request.userIds)
}

var sentCount = 0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.didit.application.notification

import com.didit.application.audit.ActorType
import com.didit.application.audit.AuditAction
import com.didit.application.audit.AuditLogger
import com.didit.application.auth.required.UserRepository
import com.didit.application.notification.provided.AdminNoticePushSender
import com.didit.application.notification.provided.NotificationHistoryRegister
import com.didit.application.notification.provided.UserPushSender
import com.didit.domain.notification.AdminNoticePushSendRequest
import com.didit.domain.notification.AdminNoticePushTargetType
import com.didit.domain.notification.NotificationHistoryCreateRequest
import com.didit.domain.notification.NotificationType
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

@Service
class AdminNoticePushService(
private val userRepository: UserRepository,
private val userPushSender: UserPushSender,
private val notificationHistoryRegister: NotificationHistoryRegister,
private val auditLogger: AuditLogger,
) : AdminNoticePushSender {
companion object {
private val logger = LoggerFactory.getLogger(AdminNoticePushService::class.java)
}

override fun send(request: AdminNoticePushSendRequest) {
val users =
when (request.targetType) {
AdminNoticePushTargetType.ALL ->
userRepository.findAllMarketingAgreed()

AdminNoticePushTargetType.SELECTED_USERS ->
userRepository.findAllMarketingAgreedByIdIn(request.userIds)
}

var sentCount = 0
var failedCount = 0

users.forEach { user ->
runCatching {
userPushSender.sendToUser(
userId = user.id,
title = request.title,
body = request.body,
link = request.link,
)
notificationHistoryRegister.save(
NotificationHistoryCreateRequest(
userId = user.id,
type = NotificationType.ADMIN_MARKETING,
title = request.title,
body = request.body,
link = request.link,
),
)
}.onSuccess {
sentCount++
}.onFailure { e ->
failedCount++
logger.warn(
"관리자 마케팅 푸시 발송 실패 - adminId: ${request.adminId}, userId: ${user.id}, reason: ${e.message}",
)
}
}

logger.info(
"관리자 마케팅 푸시 발송 완료 - adminId: ${request.adminId}, targetType: ${request.targetType}, " +
"targetCount: ${users.size}, sentCount: $sentCount, failedCount: $failedCount",
)

auditLogger.log(
actorId = request.adminId,
actorType = ActorType.ADMIN,
action = AuditAction.ADMIN_NOTIFICATION_SENT,
payload =
mapOf(
"targetType" to request.targetType.name,
"targetCount" to users.size,
"sentCount" to sentCount,
"failedCount" to failedCount,
),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.didit.application.notification.provided

import com.didit.domain.notification.AdminNoticePushSendRequest

interface AdminNoticePushSender {
fun send(request: AdminNoticePushSendRequest)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.didit.domain.notification

import java.util.UUID

data class AdminNoticePushSendRequest(
val adminId: UUID,
val targetType: AdminNoticePushTargetType,
val userIds: List<UUID>,
val title: String,
val body: String,
val link: String,
) {
init {
require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다." }
require(body.isNotBlank()) { "본문은 비어 있을 수 없습니다." }
require(link.isNotBlank()) { "링크는 비어 있을 수 없습니다." }
when (targetType) {
AdminNoticePushTargetType.ALL ->
require(userIds.isEmpty()) { "전체 발송에서 사용자 ID 목록을 지정할 수 없습니다." }

AdminNoticePushTargetType.SELECTED_USERS ->
require(userIds.isNotEmpty()) { "선택 발송에서는 사용자 ID 목록이 필요합니다." }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.didit.domain.notification

enum class AdminNoticePushTargetType {
ALL,
SELECTED_USERS,
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ enum class NotificationType {
DAILY_REMINDER,
INQUIRY_ANSWERED,
RETROSPECTIVE_RESULT_CREATED,
ADMIN_MARKETING,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.didit.adapter.webapi.notification

import com.didit.application.notification.provided.AdminNoticePushSender
import com.didit.docs.AdminAuthenticatedRestDocsSupport
import com.didit.docs.ApiDocumentUtils
import com.didit.domain.notification.AdminNoticePushSendRequest
import com.didit.domain.notification.AdminNoticePushTargetType
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.springframework.http.MediaType
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document
import org.springframework.restdocs.payload.JsonFieldType
import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath
import org.springframework.restdocs.payload.PayloadDocumentation.requestFields
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.util.UUID

class AdminNoticePushApiTest : AdminAuthenticatedRestDocsSupport() {
private val adminNoticePushSender: AdminNoticePushSender = mock()

override fun initController() = AdminNoticePushApi(adminNoticePushSender)

@Test
fun `send admin marketing push`() {
val userId = UUID.randomUUID()
val request =
mapOf(
"targetType" to "SELECTED_USERS",
"userIds" to listOf(userId),
"title" to "새로운 소식",
"body" to "디딧의 새로운 기능을 확인해 보세요.",
"link" to "/notices/1",
)

mockMvc
.perform(
post("/api/v1/admin/notice-pushes")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)),
).andExpect(status().isNoContent)
.andDo(
document(
"admin-notice-push/send",
ApiDocumentUtils.getDocumentRequest(),
ApiDocumentUtils.getDocumentResponse(),
requestFields(
fieldWithPath("targetType").type(JsonFieldType.STRING).description("발송 대상 유형"),
fieldWithPath("userIds").type(JsonFieldType.ARRAY).description("선택 사용자 ID 목록"),
fieldWithPath("title").type(JsonFieldType.STRING).description("푸시 알림 제목"),
fieldWithPath("body").type(JsonFieldType.STRING).description("푸시 알림 본문"),
fieldWithPath("link").type(JsonFieldType.STRING).description("알림 클릭 시 이동할 링크"),
),
),
)

val captor = argumentCaptor<AdminNoticePushSendRequest>()
verify(adminNoticePushSender).send(captor.capture())

assertThat(captor.firstValue.adminId).isEqualTo(adminId)
assertThat(captor.firstValue.targetType).isEqualTo(AdminNoticePushTargetType.SELECTED_USERS)
assertThat(captor.firstValue.userIds).containsExactly(userId)
assertThat(captor.firstValue.title).isEqualTo("새로운 소식")
assertThat(captor.firstValue.body).isEqualTo("디딧의 새로운 기능을 확인해 보세요.")
assertThat(captor.firstValue.link).isEqualTo("/notices/1")
}
}
Loading
Loading