From 68bd80bde419c3b59c6f2a7a32f6043151eb1ce6 Mon Sep 17 00:00:00 2001 From: seonghaejo Date: Sun, 9 Feb 2025 13:52:59 +0900 Subject: [PATCH 1/4] Add PushOptOut data model and repository --- .../kotlin/notification/data/PushCategory.kt | 33 +++++++++++++++++++ .../kotlin/notification/data/PushOptOut.kt | 21 ++++++++++++ .../repository/PushOptOutRepository.kt | 17 ++++++++++ 3 files changed, 71 insertions(+) create mode 100644 core/src/main/kotlin/notification/data/PushCategory.kt create mode 100644 core/src/main/kotlin/notification/data/PushOptOut.kt create mode 100644 core/src/main/kotlin/notification/repository/PushOptOutRepository.kt diff --git a/core/src/main/kotlin/notification/data/PushCategory.kt b/core/src/main/kotlin/notification/data/PushCategory.kt new file mode 100644 index 00000000..6f1ff1e2 --- /dev/null +++ b/core/src/main/kotlin/notification/data/PushCategory.kt @@ -0,0 +1,33 @@ +package com.wafflestudio.snutt.notification.data + +import com.fasterxml.jackson.annotation.JsonValue +import org.springframework.core.convert.converter.Converter +import org.springframework.data.convert.ReadingConverter +import org.springframework.data.convert.WritingConverter +import org.springframework.stereotype.Component + +enum class PushCategory( + @JsonValue val value: Int, +) { + LECTURE_UPDATE(1), + VACANCY_NOTIFICATION(2), + ; + + companion object { + private val valueMap = PushCategory.entries.associateBy { e -> e.value } + + fun getOfValue(value: Int): PushCategory? = valueMap[value] + } +} + +@ReadingConverter +@Component +class PushCategoryReadConverter : Converter { + override fun convert(source: Int): PushCategory = PushCategory.getOfValue(source)!! +} + +@WritingConverter +@Component +class PushCategoryWriteConverter : Converter { + override fun convert(source: PushCategory): Int = source.value +} diff --git a/core/src/main/kotlin/notification/data/PushOptOut.kt b/core/src/main/kotlin/notification/data/PushOptOut.kt new file mode 100644 index 00000000..30ef5888 --- /dev/null +++ b/core/src/main/kotlin/notification/data/PushOptOut.kt @@ -0,0 +1,21 @@ +package com.wafflestudio.snutt.notification.data + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.index.CompoundIndex +import org.springframework.data.mongodb.core.index.Indexed +import org.springframework.data.mongodb.core.mapping.Document +import org.springframework.data.mongodb.core.mapping.Field +import org.springframework.data.mongodb.core.mapping.FieldType + +@Document(collection = "push_opt_out") +@CompoundIndex(def = "{ 'user_id': 1, 'push_category': 1 }") +data class PushOptOut( + @Id + val id: String? = null, + @Indexed + @Field("user_id", targetType = FieldType.OBJECT_ID) + val userId: String, + @Field("push_category") + @Indexed + val pushCategory: PushCategory, +) diff --git a/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt b/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt new file mode 100644 index 00000000..f8751019 --- /dev/null +++ b/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt @@ -0,0 +1,17 @@ +package com.wafflestudio.snutt.notification.repository + +import com.wafflestudio.snutt.notification.data.PushCategory +import com.wafflestudio.snutt.notification.data.PushOptOut +import org.springframework.data.repository.kotlin.CoroutineCrudRepository + +interface PushOptOutRepository : CoroutineCrudRepository { + suspend fun existsByUserIdAndPushCategory( + userId: String, + pushCategory: PushCategory, + ): Boolean + + suspend fun deleteByUserIdAndPushCategory( + userId: String, + pushCategory: PushCategory, + ): Long +} From d13cd599dcbd91f4a2aaf3c4134bc1fe9da7e2e9 Mon Sep 17 00:00:00 2001 From: seonghaejo Date: Sun, 9 Feb 2025 14:42:44 +0900 Subject: [PATCH 2/4] Add send-categorical-push method to PushService --- .../repository/PushOptOutRepository.kt | 5 ++ .../notification/service/PushService.kt | 65 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt b/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt index f8751019..88972f54 100644 --- a/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt +++ b/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt @@ -14,4 +14,9 @@ interface PushOptOutRepository : CoroutineCrudRepository { userId: String, pushCategory: PushCategory, ): Long + + suspend fun findByUserIdInAndPushCategory( + userIds: List, + pushCategory: PushCategory, + ): List } diff --git a/core/src/main/kotlin/notification/service/PushService.kt b/core/src/main/kotlin/notification/service/PushService.kt index 0627a91e..8dccf9d0 100644 --- a/core/src/main/kotlin/notification/service/PushService.kt +++ b/core/src/main/kotlin/notification/service/PushService.kt @@ -3,6 +3,8 @@ package com.wafflestudio.snutt.notification.service import com.wafflestudio.snutt.common.push.PushClient import com.wafflestudio.snutt.common.push.dto.PushMessage import com.wafflestudio.snutt.common.push.dto.TargetedPushMessageWithToken +import com.wafflestudio.snutt.notification.data.PushCategory +import com.wafflestudio.snutt.notification.repository.PushOptOutRepository import org.springframework.stereotype.Service /** @@ -22,12 +24,30 @@ interface PushService { suspend fun sendGlobalPush(pushMessage: PushMessage) suspend fun sendTargetPushes(userToPushMessage: Map) + + suspend fun sendCategoricalPush( + pushMessage: PushMessage, + userId: String, + pushCategory: PushCategory, + ) + + suspend fun sendCategoricalPushes( + pushMessage: PushMessage, + userIds: List, + pushCategory: PushCategory, + ) + + suspend fun sendCategoricalTargetPushes( + userToPushMessage: Map, + pushCategory: PushCategory, + ) } @Service class PushServiceImpl internal constructor( private val deviceService: DeviceService, private val pushClient: PushClient, + private val pushOptOutRepository: PushOptOutRepository, ) : PushService { override suspend fun sendPush( pushMessage: PushMessage, @@ -60,4 +80,49 @@ class PushServiceImpl internal constructor( deviceService.getUserDevices(userId).map { it.fcmRegistrationId to pushMessage } }.map { (fcmRegistrationId, message) -> TargetedPushMessageWithToken(fcmRegistrationId, message) } .let { pushClient.sendMessages(it) } + + override suspend fun sendCategoricalPush( + pushMessage: PushMessage, + userId: String, + pushCategory: PushCategory, + ) { + if (!pushOptOutRepository.existsByUserIdAndPushCategory(userId, pushCategory)) { + sendPush(pushMessage, userId) + } + } + + override suspend fun sendCategoricalPushes( + pushMessage: PushMessage, + userIds: List, + pushCategory: PushCategory, + ) { + val filteredUserIds = + pushOptOutRepository + .findByUserIdInAndPushCategory(userIds, pushCategory) + .map { it.userId } + .toSet() + .let { optOutUserIds -> userIds.filterNot { it in optOutUserIds } } + + if (filteredUserIds.isNotEmpty()) { + sendPushes(pushMessage, filteredUserIds) + } + } + + override suspend fun sendCategoricalTargetPushes( + userToPushMessage: Map, + pushCategory: PushCategory, + ) { + val userIds = userToPushMessage.keys.toList() + + val filteredUserToPushMessage = + pushOptOutRepository + .findByUserIdInAndPushCategory(userIds, pushCategory) + .map { it.userId } + .toSet() + .let { optOutUserIds -> userToPushMessage.filterKeys { it !in optOutUserIds } } + + if (filteredUserToPushMessage.isNotEmpty()) { + sendTargetPushes(filteredUserToPushMessage) + } + } } From 8c67b4bda4798b8a9532d24df0fca534e830070b Mon Sep 17 00:00:00 2001 From: seonghaejo Date: Sun, 9 Feb 2025 15:08:51 +0900 Subject: [PATCH 3/4] =?UTF-8?q?SugangSnuNotificationService=20/=20PushWith?= =?UTF-8?q?NotificationService=EC=97=90=20=ED=91=B8=EC=8B=9C=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/service/SugangSnuNotificationService.kt | 3 ++- core/src/main/kotlin/notification/data/PushCategory.kt | 8 ++++++++ .../main/kotlin/notification/service/PushService.kt | 10 +++++++++- .../service/PushWithNotificationService.kt | 5 +++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/batch/src/main/kotlin/sugangsnu/common/service/SugangSnuNotificationService.kt b/batch/src/main/kotlin/sugangsnu/common/service/SugangSnuNotificationService.kt index 2b78d01b..69d735b2 100644 --- a/batch/src/main/kotlin/sugangsnu/common/service/SugangSnuNotificationService.kt +++ b/batch/src/main/kotlin/sugangsnu/common/service/SugangSnuNotificationService.kt @@ -5,6 +5,7 @@ import com.wafflestudio.snutt.common.push.dto.PushMessage import com.wafflestudio.snutt.coursebook.data.Coursebook import com.wafflestudio.snutt.notification.data.Notification import com.wafflestudio.snutt.notification.data.NotificationType +import com.wafflestudio.snutt.notification.data.PushCategory import com.wafflestudio.snutt.notification.service.NotificationService import com.wafflestudio.snutt.notification.service.PushService import com.wafflestudio.snutt.notification.service.PushWithNotificationService @@ -74,7 +75,7 @@ class SugangSnuNotificationServiceImpl( urlScheme = DeeplinkType.NOTIFICATIONS, ) } - pushService.sendTargetPushes(userIdToMessage) + pushService.sendCategoricalTargetPushes(userIdToMessage, PushCategory.LECTURE_UPDATE) } override suspend fun notifyCoursebookUpdate(coursebook: Coursebook) { diff --git a/core/src/main/kotlin/notification/data/PushCategory.kt b/core/src/main/kotlin/notification/data/PushCategory.kt index 6f1ff1e2..abb2c537 100644 --- a/core/src/main/kotlin/notification/data/PushCategory.kt +++ b/core/src/main/kotlin/notification/data/PushCategory.kt @@ -9,6 +9,7 @@ import org.springframework.stereotype.Component enum class PushCategory( @JsonValue val value: Int, ) { + NORMAL(0), LECTURE_UPDATE(1), VACANCY_NOTIFICATION(2), ; @@ -31,3 +32,10 @@ class PushCategoryReadConverter : Converter { class PushCategoryWriteConverter : Converter { override fun convert(source: PushCategory): Int = source.value } + +fun PushCategory(notificationType: NotificationType) = + when (notificationType) { + NotificationType.LECTURE_UPDATE -> PushCategory.LECTURE_UPDATE + NotificationType.LECTURE_VACANCY -> PushCategory.VACANCY_NOTIFICATION + else -> PushCategory.NORMAL + } diff --git a/core/src/main/kotlin/notification/service/PushService.kt b/core/src/main/kotlin/notification/service/PushService.kt index 8dccf9d0..f14d8632 100644 --- a/core/src/main/kotlin/notification/service/PushService.kt +++ b/core/src/main/kotlin/notification/service/PushService.kt @@ -86,7 +86,7 @@ class PushServiceImpl internal constructor( userId: String, pushCategory: PushCategory, ) { - if (!pushOptOutRepository.existsByUserIdAndPushCategory(userId, pushCategory)) { + if (pushCategory == PushCategory.NORMAL || !pushOptOutRepository.existsByUserIdAndPushCategory(userId, pushCategory)) { sendPush(pushMessage, userId) } } @@ -96,6 +96,10 @@ class PushServiceImpl internal constructor( userIds: List, pushCategory: PushCategory, ) { + if (pushCategory == PushCategory.NORMAL) { + sendPushes(pushMessage, userIds) + } + val filteredUserIds = pushOptOutRepository .findByUserIdInAndPushCategory(userIds, pushCategory) @@ -112,6 +116,10 @@ class PushServiceImpl internal constructor( userToPushMessage: Map, pushCategory: PushCategory, ) { + if (pushCategory == PushCategory.NORMAL) { + sendTargetPushes(userToPushMessage) + } + val userIds = userToPushMessage.keys.toList() val filteredUserToPushMessage = diff --git a/core/src/main/kotlin/notification/service/PushWithNotificationService.kt b/core/src/main/kotlin/notification/service/PushWithNotificationService.kt index 0da50ba8..1d25c8a4 100644 --- a/core/src/main/kotlin/notification/service/PushWithNotificationService.kt +++ b/core/src/main/kotlin/notification/service/PushWithNotificationService.kt @@ -2,6 +2,7 @@ package com.wafflestudio.snutt.notification.service import com.wafflestudio.snutt.common.push.dto.PushMessage import com.wafflestudio.snutt.notification.data.NotificationType +import com.wafflestudio.snutt.notification.data.PushCategory import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import org.springframework.stereotype.Service @@ -46,7 +47,7 @@ class PushWithNotificationServiceImpl internal constructor( ): Unit = coroutineScope { launch { notificationService.sendNotification(pushMessage.toNotification(notificationType, userId)) } - launch { pushService.sendPush(pushMessage, userId) } + launch { pushService.sendCategoricalPush(pushMessage, userId, PushCategory(notificationType)) } } override suspend fun sendPushesAndNotifications( @@ -65,7 +66,7 @@ class PushWithNotificationServiceImpl internal constructor( }, ) } - launch { pushService.sendPushes(pushMessage, userIds) } + launch { pushService.sendCategoricalPushes(pushMessage, userIds, PushCategory(notificationType)) } } override suspend fun sendGlobalPushAndNotification( From 2cf26d5381918c91ce91713091b4658ec0cc00d8 Mon Sep 17 00:00:00 2001 From: seonghaejo Date: Sun, 9 Feb 2025 23:27:33 +0900 Subject: [PATCH 4/4] Add PushPreference Service/Handler --- .../kotlin/handler/PushPreferenceHandler.kt | 47 +++++++++++++ api/src/main/kotlin/router/MainRouter.kt | 14 ++++ .../kotlin/notification/data/PushOptOut.kt | 2 +- .../kotlin/notification/dto/PushPreference.kt | 8 +++ .../dto/PushPreferenceResponse.kt | 12 ++++ .../repository/PushOptOutRepository.kt | 2 + .../service/PushPreferenceService.kt | 67 +++++++++++++++++++ 7 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 api/src/main/kotlin/handler/PushPreferenceHandler.kt create mode 100644 core/src/main/kotlin/notification/dto/PushPreference.kt create mode 100644 core/src/main/kotlin/notification/dto/PushPreferenceResponse.kt create mode 100644 core/src/main/kotlin/notification/service/PushPreferenceService.kt diff --git a/api/src/main/kotlin/handler/PushPreferenceHandler.kt b/api/src/main/kotlin/handler/PushPreferenceHandler.kt new file mode 100644 index 00000000..c73426e7 --- /dev/null +++ b/api/src/main/kotlin/handler/PushPreferenceHandler.kt @@ -0,0 +1,47 @@ +package com.wafflestudio.snutt.handler + +import com.wafflestudio.snutt.middleware.SnuttRestApiDefaultMiddleware +import com.wafflestudio.snutt.notification.data.PushCategory +import com.wafflestudio.snutt.notification.dto.PushPreferenceResponse +import com.wafflestudio.snutt.notification.service.PushPreferenceService +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.server.ServerRequest + +@Component +class PushPreferenceHandler( + private val pushPreferenceService: PushPreferenceService, + snuttRestApiDefaultMiddleware: SnuttRestApiDefaultMiddleware, +) : ServiceHandler( + handlerMiddleware = snuttRestApiDefaultMiddleware, + ) { + suspend fun getPushPreferences(req: ServerRequest) = + handle(req) { + val user = req.getContext().user!! + val pushPreferences = pushPreferenceService.getPushPreferences(user) + pushPreferences.map { PushPreferenceResponse(it) } + } + + suspend fun enableLectureUpdate(req: ServerRequest) = + handle(req) { + val user = req.getContext().user!! + pushPreferenceService.enablePush(user, PushCategory.LECTURE_UPDATE) + } + + suspend fun disableLectureUpdate(req: ServerRequest) = + handle(req) { + val user = req.getContext().user!! + pushPreferenceService.disablePush(user, PushCategory.LECTURE_UPDATE) + } + + suspend fun enableVacancyNotification(req: ServerRequest) = + handle(req) { + val user = req.getContext().user!! + pushPreferenceService.enablePush(user, PushCategory.VACANCY_NOTIFICATION) + } + + suspend fun disableVacancyNotification(req: ServerRequest) = + handle(req) { + val user = req.getContext().user!! + pushPreferenceService.disablePush(user, PushCategory.VACANCY_NOTIFICATION) + } +} diff --git a/api/src/main/kotlin/router/MainRouter.kt b/api/src/main/kotlin/router/MainRouter.kt index 463de490..00b51023 100644 --- a/api/src/main/kotlin/router/MainRouter.kt +++ b/api/src/main/kotlin/router/MainRouter.kt @@ -15,6 +15,7 @@ import com.wafflestudio.snutt.handler.FriendTableHandler import com.wafflestudio.snutt.handler.LectureSearchHandler import com.wafflestudio.snutt.handler.NotificationHandler import com.wafflestudio.snutt.handler.PopupHandler +import com.wafflestudio.snutt.handler.PushPreferenceHandler import com.wafflestudio.snutt.handler.StaticPageHandler import com.wafflestudio.snutt.handler.TagHandler import com.wafflestudio.snutt.handler.TimetableHandler @@ -70,6 +71,7 @@ class MainRouter( private val feedbackHandler: FeedbackHandler, private val staticPageHandler: StaticPageHandler, private val evServiceHandler: EvServiceHandler, + private val pushPreferenceHandler: PushPreferenceHandler, ) { @Bean fun healthCheck() = @@ -343,4 +345,16 @@ class MainRouter( GET("/privacy_policy").invoke { staticPageHandler.privacyPolicy() } GET("/terms_of_service").invoke { staticPageHandler.termsOfService() } } + + @Bean + fun pushPreferenceRouter() = + v1CoRouter { + "/push/preferences".nest { + GET("", pushPreferenceHandler::getPushPreferences) + POST("lecture-update", pushPreferenceHandler::enableLectureUpdate) + DELETE("lecture-update", pushPreferenceHandler::disableLectureUpdate) + POST("vacancy-notification", pushPreferenceHandler::enableVacancyNotification) + DELETE("vacancy-notification", pushPreferenceHandler::disableVacancyNotification) + } + } } diff --git a/core/src/main/kotlin/notification/data/PushOptOut.kt b/core/src/main/kotlin/notification/data/PushOptOut.kt index 30ef5888..c6c7c1b1 100644 --- a/core/src/main/kotlin/notification/data/PushOptOut.kt +++ b/core/src/main/kotlin/notification/data/PushOptOut.kt @@ -8,7 +8,7 @@ import org.springframework.data.mongodb.core.mapping.Field import org.springframework.data.mongodb.core.mapping.FieldType @Document(collection = "push_opt_out") -@CompoundIndex(def = "{ 'user_id': 1, 'push_category': 1 }") +@CompoundIndex(def = "{ 'user_id': 1, 'push_category': 1 }", unique = true) data class PushOptOut( @Id val id: String? = null, diff --git a/core/src/main/kotlin/notification/dto/PushPreference.kt b/core/src/main/kotlin/notification/dto/PushPreference.kt new file mode 100644 index 00000000..b3d9ff95 --- /dev/null +++ b/core/src/main/kotlin/notification/dto/PushPreference.kt @@ -0,0 +1,8 @@ +package com.wafflestudio.snutt.notification.dto + +import com.wafflestudio.snutt.notification.data.PushCategory + +data class PushPreference( + val pushCategory: PushCategory, + val enabled: Boolean, +) diff --git a/core/src/main/kotlin/notification/dto/PushPreferenceResponse.kt b/core/src/main/kotlin/notification/dto/PushPreferenceResponse.kt new file mode 100644 index 00000000..66b8df99 --- /dev/null +++ b/core/src/main/kotlin/notification/dto/PushPreferenceResponse.kt @@ -0,0 +1,12 @@ +package com.wafflestudio.snutt.notification.dto + +data class PushPreferenceResponse( + val pushCategoryName: String, + val enabled: Boolean, +) + +fun PushPreferenceResponse(pushPreference: PushPreference) = + PushPreferenceResponse( + pushCategoryName = pushPreference.pushCategory.name, + enabled = pushPreference.enabled, + ) diff --git a/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt b/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt index 88972f54..c63be8a4 100644 --- a/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt +++ b/core/src/main/kotlin/notification/repository/PushOptOutRepository.kt @@ -19,4 +19,6 @@ interface PushOptOutRepository : CoroutineCrudRepository { userIds: List, pushCategory: PushCategory, ): List + + suspend fun findByUserId(userId: String): List } diff --git a/core/src/main/kotlin/notification/service/PushPreferenceService.kt b/core/src/main/kotlin/notification/service/PushPreferenceService.kt new file mode 100644 index 00000000..aedefc4a --- /dev/null +++ b/core/src/main/kotlin/notification/service/PushPreferenceService.kt @@ -0,0 +1,67 @@ +package com.wafflestudio.snutt.notification.service + +import com.wafflestudio.snutt.notification.data.PushCategory +import com.wafflestudio.snutt.notification.data.PushOptOut +import com.wafflestudio.snutt.notification.dto.PushPreference +import com.wafflestudio.snutt.notification.repository.PushOptOutRepository +import com.wafflestudio.snutt.users.data.User +import org.springframework.stereotype.Service + +interface PushPreferenceService { + suspend fun enablePush( + user: User, + pushCategory: PushCategory, + ) + + suspend fun disablePush( + user: User, + pushCategory: PushCategory, + ) + + suspend fun getPushPreferences(user: User): List +} + +@Service +class PushPreferenceServiceImpl( + private val pushOptOutRepository: PushOptOutRepository, +) : PushPreferenceService { + override suspend fun enablePush( + user: User, + pushCategory: PushCategory, + ) { + pushOptOutRepository.save( + PushOptOut( + userId = user.id!!, + pushCategory = pushCategory, + ), + ) + } + + override suspend fun disablePush( + user: User, + pushCategory: PushCategory, + ) { + pushOptOutRepository.deleteByUserIdAndPushCategory( + userId = user.id!!, + pushCategory = pushCategory, + ) + } + + override suspend fun getPushPreferences(user: User): List { + val allPushCategories = PushCategory.entries.filterNot { it == PushCategory.NORMAL } + val disabledPushCategories = pushOptOutRepository.findByUserId(user.id!!).map { it.pushCategory }.toSet() + return allPushCategories.map { + if (it in disabledPushCategories) { + return@map PushPreference( + pushCategory = it, + enabled = false, + ) + } else { + return@map PushPreference( + pushCategory = it, + enabled = true, + ) + } + } + } +}