-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 관리자 마케팅 알림 발송 기능 추가 #292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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()) { "링크는 비어 있을 수 없습니다." } | ||
|
Comment on lines
+14
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Limit 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 |
|---|---|---|
|
|
@@ -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") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 = falsestill receive the campaign. The existing reminder query inNotificationSettingRepositoryexplicitly excludes such users at night; apply the same consent check here or prevent administrators from initiating campaigns during that period.Useful? React with 👍 / 👎.