diff --git a/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml b/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml index 336652879..30f337915 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml @@ -2470,4 +2470,24 @@ Indexes for latest move analysis aggregation and analyzed flag lookup + + Add opt-in setting for offline opponent-flagged notifications + + + + + + + + Add opt-in setting for own flagged notifications + + + + + + diff --git a/webapp-dao-migration/src/main/resources/liquibase-changelog.xml b/webapp-dao-migration/src/main/resources/liquibase-changelog.xml index ac7f77cbf..07e06f2da 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog.xml @@ -2697,4 +2697,24 @@ ON bot_game (analysis_end_time, analyzed_from_batch); + + Add opt-in setting for offline opponent-flagged notifications + + + + + + + + Add opt-in setting for own flagged notifications + + + + + + diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/model/NotificationsSettingsRecord.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/model/NotificationsSettingsRecord.kt index d8b587322..ed4eefb66 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/model/NotificationsSettingsRecord.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/model/NotificationsSettingsRecord.kt @@ -3,6 +3,8 @@ package io.elephantchess.db.model data class NotificationsSettingsRecord( val newsletter: Boolean, val opponentJoinedGame: Boolean, + val userFlagged: Boolean, + val opponentFlagged: Boolean, val opponentPlayedMove: Boolean, val opponentResigned: Boolean, val opponentProposedDraw: Boolean, diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsPlayerGameDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsPlayerGameDaoService.kt index 72377d4f2..625b40c10 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsPlayerGameDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/PlayerVsPlayerGameDaoService.kt @@ -685,6 +685,23 @@ class PlayerVsPlayerGameDaoService(private val dslContext: DSLContext) { ) } + /** + * Returns email if we should send a notification, null otherwise + */ + suspend fun shouldSendOpponentFlaggedNotification( + gameId: String, + flaggedUserId: String, + gamePlayersStatus: GamePlayersStatus, + duration: Duration, + ): String? { + return shouldSendNotification( + gameId = gameId, + duration = duration, + allowNotificationField = USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_FLAGGED, + userIdField = findOpponentUserIdField(gamePlayersStatus, flaggedUserId) + ) + } + /** * Returns email if we should send a notification, null otherwise */ diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt index 6c9f455b0..f080c4043 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt @@ -68,6 +68,8 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { .set(USER.USER_TYPE, UserType.GUEST) .set(USER.EMAIL_NOTIFICATION_ENABLED_NEWSLETTER, false) .set(USER.EMAIL_NOTIFICATION_ENABLED_USER_JOINED_GAME, false) + .set(USER.EMAIL_NOTIFICATION_ENABLED_USER_FLAGGED, false) + .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_FLAGGED, false) .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_PLAYED_MOVE, false) .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_RESIGNED, false) .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_PROPOSED_DRAW, false) @@ -110,6 +112,8 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { .select( USER.EMAIL_NOTIFICATION_ENABLED_NEWSLETTER, USER.EMAIL_NOTIFICATION_ENABLED_USER_JOINED_GAME, + USER.EMAIL_NOTIFICATION_ENABLED_USER_FLAGGED, + USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_FLAGGED, USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_PLAYED_MOVE, USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_RESIGNED, USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_PROPOSED_DRAW, @@ -123,6 +127,8 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { NotificationsSettingsRecord( newsletter = userRecord.emailNotificationEnabledNewsletter, opponentJoinedGame = userRecord.emailNotificationEnabledUserJoinedGame, + userFlagged = userRecord.emailNotificationEnabledUserFlagged, + opponentFlagged = userRecord.emailNotificationEnabledOpponentFlagged, opponentPlayedMove = userRecord.emailNotificationEnabledOpponentPlayedMove, opponentResigned = userRecord.emailNotificationEnabledOpponentResigned, opponentProposedDraw = userRecord.emailNotificationEnabledOpponentProposedDraw, @@ -136,6 +142,8 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { userId: String, newsletter: Boolean, opponentJoinedGame: Boolean, + userFlagged: Boolean, + opponentFlagged: Boolean, opponentPlayedMove: Boolean, opponentResigned: Boolean, opponentProposedDraw: Boolean, @@ -148,6 +156,8 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { .update(USER.fixed()) .set(USER.EMAIL_NOTIFICATION_ENABLED_NEWSLETTER.fixed(), newsletter) .set(USER.EMAIL_NOTIFICATION_ENABLED_USER_JOINED_GAME.fixed(), opponentJoinedGame) + .set(USER.EMAIL_NOTIFICATION_ENABLED_USER_FLAGGED.fixed(), userFlagged) + .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_FLAGGED.fixed(), opponentFlagged) .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_PLAYED_MOVE.fixed(), opponentPlayedMove) .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_RESIGNED.fixed(), opponentResigned) .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_PROPOSED_DRAW.fixed(), opponentProposedDraw) @@ -176,6 +186,8 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { .update(USER.fixed()) .set(USER.EMAIL_NOTIFICATION_ENABLED_NEWSLETTER.fixed(), false) .set(USER.EMAIL_NOTIFICATION_ENABLED_USER_JOINED_GAME.fixed(), false) + .set(USER.EMAIL_NOTIFICATION_ENABLED_USER_FLAGGED.fixed(), false) + .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_FLAGGED.fixed(), false) .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_PLAYED_MOVE.fixed(), false) .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_RESIGNED.fixed(), false) .set(USER.EMAIL_NOTIFICATION_ENABLED_OPPONENT_PROPOSED_DRAW.fixed(), false) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/NotificationsSettingsDto.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/NotificationsSettingsDto.kt index cdb2a6cb4..645690dbc 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/NotificationsSettingsDto.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/NotificationsSettingsDto.kt @@ -3,6 +3,8 @@ package io.elephantchess.servicelayer.dto.user data class NotificationsSettingsDto( val newsletter: Boolean, val opponentJoinedGame: Boolean, + val userFlagged: Boolean, + val opponentFlagged: Boolean, val opponentPlayedMove: Boolean, val opponentResigned: Boolean, val opponentProposedDraw: Boolean, diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/MailService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/MailService.kt index 851cd8a04..b2bee99e3 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/MailService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/MailService.kt @@ -257,6 +257,32 @@ class MailService( ) } + suspend fun sendUserFlaggedNotification(recipient: String, gameId: String, username: String) { + resolveAndSendAsync( + recipient = recipient, + subject = "You lost on time", + templateName = "user_flagged_notification", + resolvers = listOf( + SimpleValueTagResolver("game_id", gameId), + SimpleValueTagResolver("username", username), + GameLinkTagResolver(webHost, gameId), + ), + ) + } + + suspend fun sendOpponentFlaggedWhileOffline(recipient: String, opponent: String, gameId: String) { + resolveAndSendAsync( + recipient = recipient, + subject = "$opponent flagged on time", + templateName = "opponent_flagged_while_offline", + resolvers = listOf( + SimpleValueTagResolver("opponent", opponent), + GameLinkTagResolver(webHost, gameId), + ), + copyToAdmin = true + ) + } + suspend fun sendEmailConfirmation(recipient: String, code: String, showWelcomeMessage: Boolean) { val subject = if (showWelcomeMessage) { "Welcome to elephantchess - email address confirmation" diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameService.kt index bef77ccdb..3cbc6cb3b 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameService.kt @@ -805,13 +805,45 @@ class PlayerVsPlayerGameService( if (userId == null) { logger.error { "$color user not found for $gameId" } } else { + val username = userCache.fetchUsernameOrDefault(userId) pvpGameDaoService.flag( userId = userId, gameId = gameId, winnerColor = color.reverse(), updateRatingsCallback = updateRatingsCallback ) - // TODO: send email when offline? + val notificationSettings = userDaoService.fetchNotificationSettings(userId) + if (notificationSettings?.userFlagged != true) { + logger.info { + "not sending 'user flagged' notification for $userId in $gameId because notification is disabled" + } + } else { + val email = userDaoService.findById(userId)?.email + if (email == null) { + logger.info { "not sending 'user flagged' notification for $userId in $gameId because email is null" } + } else { + mailService.sendUserFlaggedNotification( + recipient = email, + gameId = gameId, + username = username, + ) + } + } + val gamePlayersStatus = fetchPlayersAndStatus(gameId) + pvpGameDaoService + .shouldSendOpponentFlaggedNotification( + gameId = gameId, + flaggedUserId = userId, + gamePlayersStatus = gamePlayersStatus, + duration = NOTIFICATIONS_OFFLINE_FOR + ) + ?.let { recipient -> + mailService.sendOpponentFlaggedWhileOffline( + recipient = recipient, + opponent = username, + gameId = gameId, + ) + } } } diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt index d1b45ff87..00fca1b94 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt @@ -387,6 +387,8 @@ class UserService( return NotificationsSettingsDto( newsletter = record.newsletter, opponentJoinedGame = record.opponentJoinedGame, + userFlagged = record.userFlagged, + opponentFlagged = record.opponentFlagged, opponentPlayedMove = record.opponentPlayedMove, opponentResigned = record.opponentResigned, opponentProposedDraw = record.opponentProposedDraw, @@ -400,6 +402,8 @@ class UserService( userId = userId, newsletter = request.newsletter, opponentJoinedGame = request.opponentJoinedGame, + userFlagged = request.userFlagged, + opponentFlagged = request.opponentFlagged, opponentPlayedMove = request.opponentPlayedMove, opponentResigned = request.opponentResigned, opponentProposedDraw = request.opponentProposedDraw, diff --git a/webapp-service-layer/src/main/resources/mail_templates/opponent_flagged_while_offline.html b/webapp-service-layer/src/main/resources/mail_templates/opponent_flagged_while_offline.html new file mode 100644 index 000000000..1f7abee9a --- /dev/null +++ b/webapp-service-layer/src/main/resources/mail_templates/opponent_flagged_while_offline.html @@ -0,0 +1,5 @@ +{{email_css}} +{{opponent}} flagged on time while you were offline.
+Review the game at {{game_link}}
+
+{{update_mail_settings}} diff --git a/webapp-service-layer/src/main/resources/mail_templates/user_flagged_notification.html b/webapp-service-layer/src/main/resources/mail_templates/user_flagged_notification.html new file mode 100644 index 000000000..cd642293e --- /dev/null +++ b/webapp-service-layer/src/main/resources/mail_templates/user_flagged_notification.html @@ -0,0 +1,7 @@ +{{email_css}} +Hi {{username}},
+
+You lost your game on time (flagged): {{game_id}}.
+Review the game at {{game_link}}
+
+{{update_mail_settings}} diff --git a/webapp-service-layer/src/test/kotlin/io/elephantchess/db/services/UserDaoServiceTest.kt b/webapp-service-layer/src/test/kotlin/io/elephantchess/db/services/UserDaoServiceTest.kt index 36297a999..f5b125f69 100644 --- a/webapp-service-layer/src/test/kotlin/io/elephantchess/db/services/UserDaoServiceTest.kt +++ b/webapp-service-layer/src/test/kotlin/io/elephantchess/db/services/UserDaoServiceTest.kt @@ -92,6 +92,8 @@ class UserDaoServiceTest : ServiceTest() { assertFalse(user.emailNotificationEnabledNewsletter) assertFalse(user.emailNotificationEnabledUserJoinedGame) + assertFalse(user.emailNotificationEnabledUserFlagged) + assertFalse(user.emailNotificationEnabledOpponentFlagged) assertFalse(user.emailNotificationEnabledOpponentPlayedMove) assertFalse(user.emailNotificationEnabledOpponentResigned) assertFalse(user.emailNotificationEnabledOpponentProposedDraw) @@ -106,6 +108,8 @@ class UserDaoServiceTest : ServiceTest() { assertTrue(user.emailNotificationEnabledNewsletter) assertTrue(user.emailNotificationEnabledUserJoinedGame) + assertTrue(user.emailNotificationEnabledUserFlagged) + assertTrue(user.emailNotificationEnabledOpponentFlagged) assertTrue(user.emailNotificationEnabledOpponentPlayedMove) assertTrue(user.emailNotificationEnabledOpponentResigned) assertTrue(user.emailNotificationEnabledOpponentProposedDraw) diff --git a/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/MailRendererTest.kt b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/MailRendererTest.kt new file mode 100644 index 000000000..dffc64b0e --- /dev/null +++ b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/MailRendererTest.kt @@ -0,0 +1,45 @@ +package io.elephantchess.servicelayer.services + +import io.elephantchess.htmlrenderer.SimpleValueTagResolver +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertTrue + +class MailRendererTest { + + private val mailRenderer = MailRenderer(MailTemplateRender()) + + @Test + fun `render user flagged notification template`() = runTest { + val html = mailRenderer.renderEmail( + templateName = "user_flagged_notification", + specificTagResolvers = listOf( + SimpleValueTagResolver("email_css", ""), + SimpleValueTagResolver("game_id", "game-123"), + SimpleValueTagResolver("username", "alice"), + SimpleValueTagResolver("game_link", "https://elephantchess.test/g/game-123"), + SimpleValueTagResolver("update_mail_settings", ""), + ) + ) + + assertTrue(html.contains("Hi alice")) + assertTrue(html.contains("You lost your game on time (flagged): game-123.")) + assertTrue(html.contains("Review the game at https://elephantchess.test/g/game-123")) + } + + @Test + fun `render opponent flagged while offline template`() = runTest { + val html = mailRenderer.renderEmail( + templateName = "opponent_flagged_while_offline", + specificTagResolvers = listOf( + SimpleValueTagResolver("email_css", ""), + SimpleValueTagResolver("opponent", "alice"), + SimpleValueTagResolver("game_link", "https://elephantchess.test/g/game-123"), + SimpleValueTagResolver("update_mail_settings", ""), + ) + ) + + assertTrue(html.contains("alice flagged on time while you were offline.")) + assertTrue(html.contains("Review the game at https://elephantchess.test/g/game-123")) + } +} diff --git a/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameServiceTest.kt b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameServiceTest.kt index 9feb74231..65e6e855b 100644 --- a/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameServiceTest.kt +++ b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/PlayerVsPlayerGameServiceTest.kt @@ -205,6 +205,8 @@ class PlayerVsPlayerGameServiceTest : ServiceTest() { request = NotificationsSettingsDto( newsletter = false, opponentJoinedGame = enabled, + userFlagged = false, + opponentFlagged = false, opponentPlayedMove = false, opponentResigned = false, opponentProposedDraw = false, diff --git a/webapp/src/main/resources/public/js/user-settings/notification-settings-widget.js b/webapp/src/main/resources/public/js/user-settings/notification-settings-widget.js index 41501e345..67dbba404 100644 --- a/webapp/src/main/resources/public/js/user-settings/notification-settings-widget.js +++ b/webapp/src/main/resources/public/js/user-settings/notification-settings-widget.js @@ -130,6 +130,8 @@ class NotificationSettingsWidget { #notificationSettings = [ new NotificationSetting('newsletter', 'Newsletter. At most once a month.'), new NotificationSetting('opponent-joined-game', 'Somebody joined a game you created - but you\'re offline.'), + new NotificationSetting('user-flagged', 'You flagged on time.'), + new NotificationSetting('opponent-flagged', 'Your opponent flagged on time - but you\'re offline.'), new NotificationSetting('opponent-played-move', 'Your opponent played a move - but you\'re offline.'), new NotificationSetting('opponent-resigned', 'Your opponent resigned - but you\'re offline.'), new NotificationSetting('opponent-proposed-draw', 'Your opponent proposed a draw - but you\'re offline.'), @@ -141,6 +143,8 @@ class NotificationSettingsWidget { getAndHandle(NOTIFICATIONS_API, json => { this.#findSettingById('newsletter').isChecked = json.newsletter; this.#findSettingById('opponent-joined-game').isChecked = json.opponentJoinedGame; + this.#findSettingById('user-flagged').isChecked = json.userFlagged; + this.#findSettingById('opponent-flagged').isChecked = json.opponentFlagged; this.#findSettingById('opponent-played-move').isChecked = json.opponentPlayedMove; this.#findSettingById('opponent-resigned').isChecked = json.opponentResigned; this.#findSettingById('opponent-proposed-draw').isChecked = json.opponentProposedDraw; @@ -156,6 +160,8 @@ class NotificationSettingsWidget { let body = { 'newsletter': this.#findSettingById('newsletter').isChecked, 'opponentJoinedGame': this.#findSettingById('opponent-joined-game').isChecked, + 'userFlagged': this.#findSettingById('user-flagged').isChecked, + 'opponentFlagged': this.#findSettingById('opponent-flagged').isChecked, 'opponentPlayedMove': this.#findSettingById('opponent-played-move').isChecked, 'opponentResigned': this.#findSettingById('opponent-resigned').isChecked, 'opponentProposedDraw': this.#findSettingById('opponent-proposed-draw').isChecked,