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
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)
Comment on lines +31 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor night-push consent for nighttime campaigns

When this endpoint is invoked during the application's night period (21:00–08:00), both target queries select users solely by marketing consent, so users with nightPushConsent = false still receive the campaign. The existing reminder query in NotificationSettingRepository explicitly excludes such users at night; apply the same consent check here or prevent administrators from initiating campaigns during that period.

Useful? React with 👍 / 👎.

}

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()) { "링크는 비어 있을 수 없습니다." }
Comment on lines +14 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject payloads that cannot be stored in history

Limit title, body, and link to the 255-character capacity of the corresponding notification_histories columns (or enlarge those columns). With, for example, a 256-character body that FCM accepts, the push is sent first and history persistence then fails; runCatching suppresses that failure, the endpoint returns 204, and the audit reports the delivered push as failed, which can prompt an administrator to retry and send duplicates.

Useful? React with 👍 / 👎.

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